diff --git a/packages/paperclip-runner/src/drivers/acpx/permission-policy.test.ts b/packages/paperclip-runner/src/drivers/acpx/permission-policy.test.ts new file mode 100644 index 0000000000..2e9182a693 --- /dev/null +++ b/packages/paperclip-runner/src/drivers/acpx/permission-policy.test.ts @@ -0,0 +1,119 @@ +import { describe, expect, it } from "vitest"; + +import { + acpxRuntimePermissionPolicy, + decideAcpxPermission, +} from "./permission-policy.js"; + +describe("ACPX permission policy", () => { + it("maps each configured mode to a closed ACP runtime policy", () => { + expect(acpxRuntimePermissionPolicy("approve-all")).toEqual({ + defaultAction: "approve", + }); + expect(acpxRuntimePermissionPolicy("deny-all")).toEqual({ + defaultAction: "deny", + }); + expect(acpxRuntimePermissionPolicy("approve-reads")).toEqual({ + defaultAction: "escalate", + }); + }); + + it.each([ + ["approve-all", "execute", "allow_once"], + ["approve-reads", "read", "delegate"], + ["approve-reads", "search", "delegate"], + ["approve-reads", "execute", "delegate"], + ["deny-all", "read", "reject_once"], + ] as const)("%s maps %s to %s", (mode, inferredKind, expected) => { + expect( + decideAcpxPermission("claude", mode, { inferredKind, raw: {} }), + ).toBe(expected); + }); + + it("keeps deny-all closed against provider-supplied semantic metadata", () => { + for (const [agent, raw] of [ + ["claude", { toolCall: { name: "mcp__paperclip__paperclip_finish" } }], + ["claude", { toolCall: { rawInput: { serverName: "paperclip" } } }], + [ + "codex", + { + _meta: { is_mcp_tool_approval: true }, + toolCall: { title: "MCP approval" }, + }, + ], + ] as const) { + expect( + decideAcpxPermission( + agent, + "deny-all", + { inferredKind: "execute", raw }, + { allConfiguredMcpServersAreRunnerOwned: true }, + ), + ).toBe("reject_once"); + } + }); + + it("does not let provider metadata widen approve-reads", () => { + for (const [agent, inferredKind, raw, options] of [ + [ + "codex", + "execute", + { + _meta: { is_mcp_tool_approval: true }, + toolCall: { title: "MCP approval" }, + }, + { allConfiguredMcpServersAreRunnerOwned: true }, + ], + [ + "claude", + "write", + { toolCall: { rawInput: { serverName: "paperclip" } } }, + {}, + ], + [ + "claude", + "execute", + { toolCall: { name: "mcp__paperclip__paperclip_finish" } }, + {}, + ], + [ + "claude", + "write", + { + toolCall: { + _meta: { + claudeCode: { toolName: "mcp.paperclip.get_task_context" }, + }, + }, + }, + {}, + ], + ] as const) { + expect( + decideAcpxPermission( + agent, + "approve-reads", + { inferredKind, raw }, + options, + ), + ).toBe("delegate"); + } + }); + + it("does not trust provider-originated read classifications", () => { + for (const inferredKind of ["read", "search", "list", "READ"]) { + expect( + decideAcpxPermission("codex", "approve-reads", { + inferredKind, + raw: { + _meta: { is_mcp_tool_approval: true }, + toolCall: { + name: "mcp__paperclip__get_task_context", + rawInput: { serverName: "paperclip" }, + }, + }, + }), + ).toBe("delegate"); + } + }); +}); diff --git a/packages/paperclip-runner/src/drivers/acpx/permission-policy.ts b/packages/paperclip-runner/src/drivers/acpx/permission-policy.ts new file mode 100644 index 0000000000..b4bb7d0eba --- /dev/null +++ b/packages/paperclip-runner/src/drivers/acpx/permission-policy.ts @@ -0,0 +1,54 @@ +import type { NativeAcpxPermissionMode } from "../../contracts/native-execution.js"; +import type { QualifiedAcpxAgent } from "./qualified-profiles.js"; + +export interface AcpxPermissionRequestLike { + inferredKind?: unknown; + raw?: unknown; +} + +export type AcpxPermissionDisposition = + "allow_once" | "reject_once" | "delegate"; + +export interface AcpxPermissionPolicyOptions { + /** Descriptive configuration only; never proof that a request is authorized. */ + runnerOwnedMcpServerNames?: ReadonlySet; + /** Descriptive configuration only; never proof that a request is authorized. */ + allConfiguredMcpServersAreRunnerOwned?: boolean; +} + +export interface AcpxRuntimePermissionPolicy { + autoApprove?: readonly string[]; + escalate?: readonly string[]; + defaultAction: "approve" | "deny" | "escalate"; +} + +export function acpxRuntimePermissionPolicy( + mode: NativeAcpxPermissionMode, +): AcpxRuntimePermissionPolicy { + if (mode === "approve-all") return { defaultAction: "approve" }; + if (mode === "deny-all") return { defaultAction: "deny" }; + // ACPX derives permission kinds from provider-originated requests. Until the + // host can bind a request to independent authority, no kind is safe to + // auto-approve. Escalation delegates to the coordinator and otherwise fails + // closed through the non-interactive permission policy. + return { defaultAction: "escalate" }; +} + +/** + * Decide the local part of an ACP permission request. `delegate` means the + * caller must ask the coordinator and fail closed when no delegate exists. + */ +export function decideAcpxPermission( + _agent: QualifiedAcpxAgent, + mode: NativeAcpxPermissionMode, + _request: AcpxPermissionRequestLike, + _options: AcpxPermissionPolicyOptions = {}, +): AcpxPermissionDisposition { + if (mode === "deny-all") return "reject_once"; + if (mode === "approve-all") return "allow_once"; + // inferredKind and raw semantic/MCP metadata both originate outside the + // runner trust boundary. Neither can grant local read or semantic authority. + // A future verified decision must carry runner-issued call identity bound to + // the run-scoped catalog; absent that proof, the coordinator decides. + return "delegate"; +}