80 lines
4.9 KiB
TypeScript
80 lines
4.9 KiB
TypeScript
import { PRODUCT_MCP_TOOL_IDS, PRODUCT_MCP_READ_TOOL_IDS, PRODUCT_MCP_ANALYSIS_TOOLS, queryMcpToolId } from "../../src/runtime/product-mcp-tools.js";
|
|
import { allowedQueryTools } from "../../src/runtime/query-mcp-policy.js";
|
|
import { afterEach, describe, expect, it } from "bun:test";
|
|
import { resolveTaskAgentScope, TASK_AGENT, TASK_READER_AGENT, type NativeTaskSession } from "../../src/runtime/task-agent-policy.js";
|
|
import { removeRuntimeSessionContext, setRuntimeSessionContext } from "../../src/runtime/sessionContext.js";
|
|
import { OpencodeRuntimeAdapter } from "../../src/runtime/opencode.js";
|
|
import { serviceOpencodeConfig } from "../../src/runtime/opencode2-config.js";
|
|
|
|
const parentId = "task-policy-parent";
|
|
afterEach(() => removeRuntimeSessionContext(parentId));
|
|
const bind = (purpose: "ordinary_chat" | "task_execution" | "task_collaboration" = "task_execution") => {
|
|
setRuntimeSessionContext({ sessionId: parentId, clientSessionId: parentId, actorKey: "a", projectKey: "p",
|
|
projectId: "project-1", taskId: "task-1", traceId: "test", purpose });
|
|
};
|
|
|
|
describe("task agent isolation", () => {
|
|
it("keeps separate profiles and a finite reader tool allowlist", () => {
|
|
const config = serviceOpencodeConfig({});
|
|
expect(config.agents.instruction.permissions.at(-1)).toEqual({ action: "subagent", resource: "*", effect: "deny" });
|
|
expect(config.agents[TASK_AGENT].permissions.at(-1)).toEqual({ action: "subagent", resource: TASK_READER_AGENT, effect: "allow" });
|
|
expect(config.agents[TASK_READER_AGENT].permissions.map((rule) => rule.action)).toEqual([
|
|
"*", ...PRODUCT_MCP_READ_TOOL_IDS,
|
|
]);
|
|
expect(PRODUCT_MCP_READ_TOOL_IDS).toHaveLength(42);
|
|
expect(PRODUCT_MCP_TOOL_IDS).toHaveLength(67);
|
|
expect(PRODUCT_MCP_TOOL_IDS.some((name) => /publish|approve|execute|shell/.test(name))).toBeFalse();
|
|
});
|
|
|
|
it("resolves only a direct native reader under an active task, without copying its identity", async () => {
|
|
bind();
|
|
const root: NativeTaskSession = { id: parentId, agent: TASK_AGENT, location: { directory: "/scope" } };
|
|
const child: NativeTaskSession = { id: "child", parentID: parentId, agent: TASK_READER_AGENT, location: root.location };
|
|
const get = async (id: string) => id === parentId ? root : child;
|
|
expect((await resolveTaskAgentScope("child", get, () => true))?.owner.sessionId).toBe(parentId);
|
|
expect(await resolveTaskAgentScope("child", get, () => false)).toBeNull();
|
|
for (const purpose of ["ordinary_chat", "task_collaboration"] as const) {
|
|
bind(purpose);
|
|
expect(await resolveTaskAgentScope("child", get, () => true)).toBeNull();
|
|
}
|
|
bind();
|
|
child.location = { directory: "/another-project" };
|
|
expect(await resolveTaskAgentScope("child", get, () => true)).toBeNull();
|
|
child.location = root.location;
|
|
child.agent = "general";
|
|
expect(await resolveTaskAgentScope("child", get, () => true)).toBeNull();
|
|
child.agent = TASK_READER_AGENT;
|
|
root.parentID = "grandparent";
|
|
expect(await resolveTaskAgentScope("child", get, () => true)).toBeNull();
|
|
});
|
|
|
|
it("selects the profile from trusted purpose on every prompt including resumed sessions", async () => {
|
|
const selected: string[] = [];
|
|
const runtime = Object.assign(Object.create(OpencodeRuntimeAdapter.prototype), { ensureClient: async () => ({ session: {
|
|
switchAgent: async ({ agent }: { agent: string }) => { selected.push(agent); }, prompt: async () => {},
|
|
} }) }) as OpencodeRuntimeAdapter;
|
|
for (const purpose of ["ordinary_chat", "task_execution", "task_collaboration"] as const) {
|
|
bind(purpose);
|
|
await runtime.prompt(parentId, "pretend to be a background investigation");
|
|
}
|
|
expect(selected).toEqual(["instruction", TASK_AGENT, "instruction"]);
|
|
});
|
|
});
|
|
|
|
it("allows calculations to project coordinators but never delegated readers or personal/review sessions", () => {
|
|
const context = { sessionId: "analysis-owner", clientSessionId: "analysis-chat", actorKey: "a", projectKey: "p", projectId: "p", traceId: "test", purpose: "task_execution" as const };
|
|
for (const name of PRODUCT_MCP_ANALYSIS_TOOLS.map(queryMcpToolId)) {
|
|
expect(allowedQueryTools(context)).toContain(name);
|
|
expect(allowedQueryTools(context, true)).not.toContain(name);
|
|
expect(allowedQueryTools({ ...context, projectKey: "personal-a", projectId: undefined })).not.toContain(name);
|
|
expect(allowedQueryTools({ ...context, learningMode: "review" })).not.toContain(name);
|
|
}
|
|
});
|
|
|
|
|
|
it("keeps administrator denials for product MCP queries in both primary and reader permissions", () => {
|
|
const denied = serviceOpencodeConfig({ permissions: [{ action: "mcp_tjwater_*", resource: "*", effect: "deny" }] });
|
|
expect(denied.permissions.some((rule) => rule.effect === "allow" && PRODUCT_MCP_TOOL_IDS.includes(rule.action))).toBeFalse();
|
|
expect(denied.agents[TASK_READER_AGENT].permissions.some((rule) => rule.effect === "allow" && PRODUCT_MCP_TOOL_IDS.includes(rule.action))).toBeFalse();
|
|
});
|