diff --git a/src/permission/classify.ts b/src/permission/classify.ts index b956966b8..e0c2ccba2 100644 --- a/src/permission/classify.ts +++ b/src/permission/classify.ts @@ -256,11 +256,37 @@ function pathLikeTokens(command: string): string[] { return out; } +// AgentId-addressed fleet continuation verbs (see the CL-9362 note on +// callTargetsRestricted below). +const AGENT_ID_TARGETED_FLEET_TOOLS = new Set([ + "close_agent", + "interrupt_agent", + "send_input", + "resume_agent", + "read_agent_trace", +]); + export function callTargetsRestricted( call: ToolCall, isRestricted: (path: string, isWrite: boolean) => boolean, ): boolean { const name = canonicalToolName(call.name); + // Fleet verbs that address workers by opaque agent id (`target`), never by + // path (CL-9362). There is nothing path-shaped here for isRestricted to + // judge, so agentId-to-worktree resolution deliberately does not live in + // this function and the gate's auto-allow `!restricted` guard stays + // vacuous for these calls — intentionally, not by oversight. Path + // restriction is enforced where paths are actually touched: inside the + // target worker, whose own gate binds restriction judgments to its process + // cwd (bindRestrictedToProcessCwd in gate.ts). Resolving ids to worktrees + // here would duplicate that enforcement at a layer with no session access, + // so these calls always report "not restricted", exactly like + // spawn_agent/wait_agents. (The full fleet verb list lives in + // subagent/authority.ts as FLEET_VERBS; the five single-`target` + // agentId-addressed verbs are named above — spawn_agent, wait_agents, + // list_agents, and search_agents take no single-agent `target` argument + // and already fall through to false below.) + if (AGENT_ID_TARGETED_FLEET_TOOLS.has(name)) return false; if (name === "run_shell") return commandTargetsRestricted(stringArg(call, "command"), isRestricted); if (name === "apply_patch") { diff --git a/src/permission/permission.test.ts b/src/permission/permission.test.ts index f6749b4bc..7bb19ca06 100644 --- a/src/permission/permission.test.ts +++ b/src/permission/permission.test.ts @@ -25,6 +25,7 @@ import { classifyTool, buildRequests, isAutoAllowedShellCall, + callTargetsRestricted, } from "./classify.js"; import { createPermissionGate } from "./gate.js"; import { APPROVAL_TIMEOUT_RESULT_TEXT } from "./decline-markers.js"; @@ -1596,6 +1597,66 @@ describe("createPermissionGate", () => { expect(asked).toBe(tools.length); }); + // CL-9362: agentId-targeted fleet calls address workers by opaque session + // id (`target`), never by path — there is nothing path-shaped for + // callTargetsRestricted to judge, so the gate's auto-allow `!restricted` + // guard is intentionally vacuous for them. Path restriction is enforced + // where paths are actually touched: inside the target worker, whose own + // gate binds restriction judgments to its process cwd. + test("auto mode auto-allows agentId-targeted fleet calls even when every path is treated as restricted", async () => { + let asked = 0; + const gate = createPermissionGate({ + approvals: [], + requestApproval: async () => { + asked++; + return { allow: false }; + }, + interactive: true, + skipPermissions: false, + reactorGated: false, + auto: true, + }); + const calls: ToolCall[] = [ + { id: "c", name: "close_agent", arguments: { target: "worker-1" } }, + { id: "c", name: "interrupt_agent", arguments: { target: "worker-1" } }, + { + id: "c", + name: "send_input", + arguments: { target: "worker-1", message: "continue" }, + }, + { + id: "c", + name: "resume_agent", + arguments: { target: "worker-1", message: "continue" }, + }, + { + id: "c", + name: "read_agent_trace", + arguments: { target: "worker-1" }, + }, + ]; + for (const call of calls) { + const verdict = await gate.evaluate(call); + expect(verdict.allowed).toBe(true); + } + expect(asked).toBe(0); + // Same-gate in-bounds write: this fixture is not a restricted worktree. + const inBounds = await gate.evaluate({ + id: "c", + name: "write_file", + arguments: { path: "notes.md" }, + }); + expect(inBounds.allowed).toBe(true); + expect(asked).toBe(0); + // The carve-out is intentional, not an oversight: even an isRestricted + // that reports everything restricted does not flag these calls — they + // carry agent ids, not paths. + const alwaysRestricted = () => true; + for (const call of calls) { + expect(callTargetsRestricted(call, alwaysRestricted)).toBe(false); + } + }); + // manage_tasks's handler has no side effect — the task list is mutated // earlier by the director, before this tool ever executes — so denying it // cannot undo anything. It auto-allows unconditionally, not just in auto