Reuse OpenCode 2 HTTP/stdio clients with per-session read-only grants, native approval and sanitized owner-scoped MCP status. Narrow the environment inherited by trusted local MCP processes. Validated in an isolated checkout with bun run check and 20 focused tests.
94 lines
6.6 KiB
TypeScript
94 lines
6.6 KiB
TypeScript
import { describe, expect, it } from "bun:test";
|
|
import express from "express";
|
|
import type { Plugin } from "../../.opencode/node_modules/@opencode/plugin/dist/promise/index.js";
|
|
import { registerMcpPolicy } from "../../.opencode/mcp-policy.js";
|
|
import { registerMcpPolicyBridge } from "../../src/routes/mcpRoutes.js";
|
|
import { setRuntimeSessionContext, removeRuntimeSessionContext } from "../../src/runtime/sessionContext.js";
|
|
|
|
type Context = Parameters<Plugin.Plugin["setup"]>[0];
|
|
type Evaluation = Parameters<Parameters<Context["permission"]["hook"]>[1]>[0];
|
|
type Tool = Awaited<ReturnType<Context["tool"]["list"]>>[number];
|
|
|
|
describe("MCP session enforcement", () => {
|
|
it("applies grants and role restrictions to direct and Code Mode executors and saved approvals", async () => {
|
|
const app = express(); app.use(express.json());
|
|
registerMcpPolicyBridge(app, { internalToken: "MCP_TEST_ONLY", runtime: { mcpReadonlyTools: () => ["mcp_fixture_read"] } });
|
|
const server = app.listen(0, "127.0.0.1");
|
|
await new Promise<void>((resolve) => server.once("listening", resolve));
|
|
const previous = [process.env.AGENT_INTERNAL_TOKEN, process.env.TJWATER_AGENT_INTERNAL_BASE_URL];
|
|
process.env.AGENT_INTERNAL_TOKEN = "MCP_TEST_ONLY";
|
|
process.env.TJWATER_AGENT_INTERNAL_BASE_URL = `http://127.0.0.1:${(server.address() as { port: number }).port}`;
|
|
const sessionID = "mcp-policy-test";
|
|
const baseContext = { sessionId: sessionID, clientSessionId: sessionID, actorKey: "owner", projectKey: "project", traceId: "test" };
|
|
const calls: string[] = [];
|
|
const tools = new Map<string, Tool>([true, false].flatMap((codemode) => {
|
|
const id = codemode ? "mcp_fixture_read" : "mcp_fixture_write";
|
|
return [[id, { id, name: id, description: "test", input: {}, options: { codemode }, execute: async (_input: unknown, context: { signal: AbortSignal }) => {
|
|
context.signal.throwIfAborted(); calls.push(id); return { content: "ok" };
|
|
} } as Tool]];
|
|
}));
|
|
let evaluate: ((event: Evaluation) => Promise<void>) | undefined;
|
|
let filterContext: ((event: { sessionID: string; tools: Record<string, unknown>; system: unknown[] }) => Promise<void>) | undefined;
|
|
const report = await registerMcpPolicy({
|
|
mcp: { transform: async (fn: (editor: unknown) => void) => fn({ list: () => [] }), list: async () => ({ data: [] }) },
|
|
tool: { transform: async (fn: (editor: unknown) => void) => fn({ list: () => [...tools.values()], update: (id: string, fn: (tool: Tool) => void) => fn(tools.get(id)!) }), list: async () => [...tools.values()] },
|
|
permission: { hook: async (_name: string, fn: typeof evaluate) => { evaluate = fn; } },
|
|
session: { hook: async (_name: string, fn: typeof filterContext) => { filterContext = fn; } },
|
|
} as never);
|
|
const callContext = { sessionID, signal: new AbortController().signal } as Parameters<Tool["execute"]>[1];
|
|
const decision = async (action: string, effect: "allow" | "ask" | "deny" = "allow") => {
|
|
const event: Evaluation = { sessionID: sessionID as Evaluation["sessionID"], action, effect, resources: ["*"] };
|
|
await evaluate!(event); return event.effect;
|
|
};
|
|
try {
|
|
setRuntimeSessionContext({ ...baseContext, purpose: "ordinary_chat" });
|
|
expect(await decision("mcp_fixture_read")).toBe("allow");
|
|
expect(await decision("mcp_fixture_write")).toBe("ask");
|
|
expect(await decision("opencode_read_mcp_resource")).toBe("ask");
|
|
expect(await decision("mcp_fixture_read", "deny")).toBe("deny");
|
|
setRuntimeSessionContext({ ...baseContext, purpose: "task_execution" });
|
|
expect(await tools.get("mcp_fixture_read")!.execute({}, callContext)).toEqual({ content: "ok" });
|
|
await expect(tools.get("mcp_fixture_write")!.execute({}, callContext)).rejects.toThrow("not available");
|
|
expect(await decision("mcp_fixture_write")).toBe("deny");
|
|
expect(await decision("opencode_read_mcp_resource")).toBe("deny");
|
|
const event = { sessionID, tools: { mcp_fixture_read: {}, mcp_fixture_write: {}, shell: {} }, system: [] };
|
|
await filterContext!(event);
|
|
expect(Object.keys(event.tools)).toEqual(["mcp_fixture_read", "shell"]);
|
|
setRuntimeSessionContext({ ...baseContext, learningMode: "review" });
|
|
await expect(tools.get("mcp_fixture_read")!.execute({}, callContext)).rejects.toThrow("not available");
|
|
expect(await decision("mcp_fixture_read")).toBe("deny");
|
|
setRuntimeSessionContext({ ...baseContext, purpose: "task_collaboration" });
|
|
const controller = new AbortController(); controller.abort();
|
|
await expect(tools.get("mcp_fixture_write")!.execute({}, { ...callContext, signal: controller.signal })).rejects.toThrow();
|
|
removeRuntimeSessionContext(sessionID);
|
|
expect(await decision("mcp_fixture_read")).toBe("deny");
|
|
await expect(tools.get("mcp_fixture_read")!.execute({}, callContext)).rejects.toThrow("unavailable");
|
|
expect(calls).toEqual(["mcp_fixture_read"]);
|
|
expect(await report()).toEqual({ configuration_errors: [], servers: [] });
|
|
} finally {
|
|
removeRuntimeSessionContext(sessionID);
|
|
for (const [i, name] of ["AGENT_INTERNAL_TOKEN", "TJWATER_AGENT_INTERNAL_BASE_URL"].entries()) {
|
|
if (previous[i] === undefined) delete process.env[name]; else process.env[name] = previous[i];
|
|
}
|
|
await new Promise<void>((resolve) => server.close(() => resolve()));
|
|
}
|
|
});
|
|
|
|
it("disables invalid global servers and reports only safe connection fields", async () => {
|
|
const servers = new Map([
|
|
["mcp_fixture", { type: "remote", url: "https://example.com/private", headers: { Authorization: "TEST_ONLY" } }],
|
|
["invalid https://secret", { type: "remote", url: "file:///secret" }],
|
|
]);
|
|
const report = await registerMcpPolicy({
|
|
mcp: {
|
|
transform: async (fn: (editor: unknown) => void) => fn({ list: () => [...servers], set: (name: string, config: never) => servers.set(name, config), update: (name: string, fn: (config: unknown) => void) => fn(servers.get(name)) }),
|
|
list: async () => ({ data: [{ name: "mcp_fixture", status: { status: "failed", error: "PRIVATE_CREDENTIAL" } }] }),
|
|
},
|
|
tool: { transform: async (fn: (editor: unknown) => void) => fn({ list: () => [] }), list: async () => [{ id: "mcp_fixture_read", options: { namespace: "mcp_fixture" } }] },
|
|
permission: { hook: async () => {} }, session: { hook: async () => {} },
|
|
} as never);
|
|
expect(servers.get("invalid https://secret")).toMatchObject({ disabled: true });
|
|
expect(await report()).toEqual({ configuration_errors: ["invalid_server_name"], servers: [{ name: "mcp_fixture", type: "remote", status: "failed", configured_protocol: "auto", tools: ["mcp_fixture_read"] }] });
|
|
});
|
|
});
|