From 44ab28089300f5adfd557cbf245f9f56c2d1f1f6 Mon Sep 17 00:00:00 2001 From: Sawyer Cutler Date: Fri, 25 Sep 2026 18:56:12 -0700 Subject: [PATCH 1/4] test(permissions): pin auto-allow for agentId-targeted fleet calls --- src/permission/permission.test.ts | 45 +++++++++++++++++++++++++++++++ 1 file changed, 45 insertions(+) diff --git a/src/permission/permission.test.ts b/src/permission/permission.test.ts index f6749b4bc..3ec421350 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,50 @@ 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. This pins that + // decision: a fleet call aimed at a restricted-worktree worker still + // auto-allows in auto mode, exactly like spawn_agent/wait_agents. + test("auto mode auto-allows agentId-targeted fleet calls regardless of target worktree", 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" }, + }, + ]; + for (const call of calls) { + const verdict = await gate.evaluate(call); + expect(verdict.allowed).toBe(true); + } + expect(asked).toBe(0); + // The carve-out is intentional, not an oversight: even an isRestricted + // that reports everything restricted (the target worktree is 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 From 4312666a51cb8ead5531da7c62706cb0561a57df Mon Sep 17 00:00:00 2001 From: Sawyer Cutler Date: Fri, 25 Sep 2026 19:00:57 -0700 Subject: [PATCH 2/4] refactor(permissions): name the intentional fleet agentId carve-out --- src/permission/classify.ts | 22 ++++++++++++++++++++++ 1 file changed, 22 insertions(+) diff --git a/src/permission/classify.ts b/src/permission/classify.ts index b956966b8..98af38224 100644 --- a/src/permission/classify.ts +++ b/src/permission/classify.ts @@ -256,11 +256,33 @@ 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", +]); + 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; only the agentId-addressed + // continuation verbs need naming here.) + if (AGENT_ID_TARGETED_FLEET_TOOLS.has(name)) return false; if (name === "run_shell") return commandTargetsRestricted(stringArg(call, "command"), isRestricted); if (name === "apply_patch") { From 80c900ff29d7f28ee4c3ea67eea1b5edd2ca5ba2 Mon Sep 17 00:00:00 2001 From: Sawyer Cutler Date: Fri, 25 Sep 2026 19:54:38 -0700 Subject: [PATCH 3/4] test(permissions): prove fleet carve-out under a restricted worktree Name resume_agent and read_agent_trace in the agentId carve-out so all five single-target verbs are explicit, and build the CL-9362 gate on a registered worktree with a session-state control write that still asks. --- src/permission/classify.ts | 8 ++++++-- src/permission/permission.test.ts | 26 ++++++++++++++++++++++++++ 2 files changed, 32 insertions(+), 2 deletions(-) diff --git a/src/permission/classify.ts b/src/permission/classify.ts index 98af38224..e0c2ccba2 100644 --- a/src/permission/classify.ts +++ b/src/permission/classify.ts @@ -262,6 +262,8 @@ const AGENT_ID_TARGETED_FLEET_TOOLS = new Set([ "close_agent", "interrupt_agent", "send_input", + "resume_agent", + "read_agent_trace", ]); export function callTargetsRestricted( @@ -280,8 +282,10 @@ export function callTargetsRestricted( // 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; only the agentId-addressed - // continuation verbs need naming here.) + // 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); diff --git a/src/permission/permission.test.ts b/src/permission/permission.test.ts index 3ec421350..c6a521773 100644 --- a/src/permission/permission.test.ts +++ b/src/permission/permission.test.ts @@ -1607,6 +1607,11 @@ describe("createPermissionGate", () => { // auto-allows in auto mode, exactly like spawn_agent/wait_agents. test("auto mode auto-allows agentId-targeted fleet calls regardless of target worktree", async () => { let asked = 0; + // A registered worktree standing in for the restricted target worktree. + // Restriction is live in this gate — the session-state control write + // below still asks — so the fleet auto-allows prove worktree-independence + // instead of assuming it. + const worktree = mkdtempSync(join(tmpdir(), "corbits-restricted-wt-")); const gate = createPermissionGate({ approvals: [], requestApproval: async () => { @@ -1617,6 +1622,8 @@ describe("createPermissionGate", () => { skipPermissions: false, reactorGated: false, auto: true, + cwd: worktree, + rootsProvider: () => [realpathSync(worktree)], }); const calls: ToolCall[] = [ { id: "c", name: "close_agent", arguments: { target: "worker-1" } }, @@ -1626,12 +1633,31 @@ describe("createPermissionGate", () => { 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); + // Control: restriction is live in this gate — a session-state write + // through the same gate still asks (and is denied here). + const control = await gate.evaluate({ + id: "c", + name: "write_file", + arguments: { path: ".agent-state/run.json" }, + }); + expect(control.allowed).toBe(false); + expect(asked).toBe(1); // The carve-out is intentional, not an oversight: even an isRestricted // that reports everything restricted (the target worktree is restricted) // does not flag these calls — they carry agent ids, not paths. From 84e90488ae5891936d30c8209dc7b7d28052ddaa Mon Sep 17 00:00:00 2001 From: Sawyer Cutler Date: Sat, 26 Sep 2026 08:27:03 -0700 Subject: [PATCH 4/4] test(permissions): drop restricted-worktree theater from the fleet carve-out pin The previous fixture registered cwd as a root, which is the in-bounds pattern, and the session-state write control already lives in its own test. The alwaysRestricted classify loop is the real pin. --- src/permission/permission.test.ts | 28 +++++++++------------------- 1 file changed, 9 insertions(+), 19 deletions(-) diff --git a/src/permission/permission.test.ts b/src/permission/permission.test.ts index c6a521773..7bb19ca06 100644 --- a/src/permission/permission.test.ts +++ b/src/permission/permission.test.ts @@ -1602,16 +1602,9 @@ describe("createPermissionGate", () => { // 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. This pins that - // decision: a fleet call aimed at a restricted-worktree worker still - // auto-allows in auto mode, exactly like spawn_agent/wait_agents. - test("auto mode auto-allows agentId-targeted fleet calls regardless of target worktree", async () => { + // 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; - // A registered worktree standing in for the restricted target worktree. - // Restriction is live in this gate — the session-state control write - // below still asks — so the fleet auto-allows prove worktree-independence - // instead of assuming it. - const worktree = mkdtempSync(join(tmpdir(), "corbits-restricted-wt-")); const gate = createPermissionGate({ approvals: [], requestApproval: async () => { @@ -1622,8 +1615,6 @@ describe("createPermissionGate", () => { skipPermissions: false, reactorGated: false, auto: true, - cwd: worktree, - rootsProvider: () => [realpathSync(worktree)], }); const calls: ToolCall[] = [ { id: "c", name: "close_agent", arguments: { target: "worker-1" } }, @@ -1649,18 +1640,17 @@ describe("createPermissionGate", () => { expect(verdict.allowed).toBe(true); } expect(asked).toBe(0); - // Control: restriction is live in this gate — a session-state write - // through the same gate still asks (and is denied here). - const control = await gate.evaluate({ + // Same-gate in-bounds write: this fixture is not a restricted worktree. + const inBounds = await gate.evaluate({ id: "c", name: "write_file", - arguments: { path: ".agent-state/run.json" }, + arguments: { path: "notes.md" }, }); - expect(control.allowed).toBe(false); - expect(asked).toBe(1); + expect(inBounds.allowed).toBe(true); + expect(asked).toBe(0); // The carve-out is intentional, not an oversight: even an isRestricted - // that reports everything restricted (the target worktree is restricted) - // does not flag these calls — they carry agent ids, not paths. + // 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);