Skip to content

Commit aac1b61

Browse files
CL-9362: resolve restricted-worktree carve-out for agentId-targeted fleet calls (#1184)
* test(permissions): pin auto-allow for agentId-targeted fleet calls * refactor(permissions): name the intentional fleet agentId carve-out * 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. * 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.
1 parent b167db3 commit aac1b61

2 files changed

Lines changed: 87 additions & 0 deletions

File tree

‎src/permission/classify.ts‎

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -256,11 +256,37 @@ function pathLikeTokens(command: string): string[] {
256256
return out;
257257
}
258258

259+
// AgentId-addressed fleet continuation verbs (see the CL-9362 note on
260+
// callTargetsRestricted below).
261+
const AGENT_ID_TARGETED_FLEET_TOOLS = new Set([
262+
"close_agent",
263+
"interrupt_agent",
264+
"send_input",
265+
"resume_agent",
266+
"read_agent_trace",
267+
]);
268+
259269
export function callTargetsRestricted(
260270
call: ToolCall,
261271
isRestricted: (path: string, isWrite: boolean) => boolean,
262272
): boolean {
263273
const name = canonicalToolName(call.name);
274+
// Fleet verbs that address workers by opaque agent id (`target`), never by
275+
// path (CL-9362). There is nothing path-shaped here for isRestricted to
276+
// judge, so agentId-to-worktree resolution deliberately does not live in
277+
// this function and the gate's auto-allow `!restricted` guard stays
278+
// vacuous for these calls — intentionally, not by oversight. Path
279+
// restriction is enforced where paths are actually touched: inside the
280+
// target worker, whose own gate binds restriction judgments to its process
281+
// cwd (bindRestrictedToProcessCwd in gate.ts). Resolving ids to worktrees
282+
// here would duplicate that enforcement at a layer with no session access,
283+
// so these calls always report "not restricted", exactly like
284+
// spawn_agent/wait_agents. (The full fleet verb list lives in
285+
// subagent/authority.ts as FLEET_VERBS; the five single-`target`
286+
// agentId-addressed verbs are named above — spawn_agent, wait_agents,
287+
// list_agents, and search_agents take no single-agent `target` argument
288+
// and already fall through to false below.)
289+
if (AGENT_ID_TARGETED_FLEET_TOOLS.has(name)) return false;
264290
if (name === "run_shell")
265291
return commandTargetsRestricted(stringArg(call, "command"), isRestricted);
266292
if (name === "apply_patch") {

‎src/permission/permission.test.ts‎

Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@ import {
2525
classifyTool,
2626
buildRequests,
2727
isAutoAllowedShellCall,
28+
callTargetsRestricted,
2829
} from "./classify.js";
2930
import { createPermissionGate } from "./gate.js";
3031
import { APPROVAL_TIMEOUT_RESULT_TEXT } from "./decline-markers.js";
@@ -1596,6 +1597,66 @@ describe("createPermissionGate", () => {
15961597
expect(asked).toBe(tools.length);
15971598
});
15981599

1600+
// CL-9362: agentId-targeted fleet calls address workers by opaque session
1601+
// id (`target`), never by path — there is nothing path-shaped for
1602+
// callTargetsRestricted to judge, so the gate's auto-allow `!restricted`
1603+
// guard is intentionally vacuous for them. Path restriction is enforced
1604+
// where paths are actually touched: inside the target worker, whose own
1605+
// gate binds restriction judgments to its process cwd.
1606+
test("auto mode auto-allows agentId-targeted fleet calls even when every path is treated as restricted", async () => {
1607+
let asked = 0;
1608+
const gate = createPermissionGate({
1609+
approvals: [],
1610+
requestApproval: async () => {
1611+
asked++;
1612+
return { allow: false };
1613+
},
1614+
interactive: true,
1615+
skipPermissions: false,
1616+
reactorGated: false,
1617+
auto: true,
1618+
});
1619+
const calls: ToolCall[] = [
1620+
{ id: "c", name: "close_agent", arguments: { target: "worker-1" } },
1621+
{ id: "c", name: "interrupt_agent", arguments: { target: "worker-1" } },
1622+
{
1623+
id: "c",
1624+
name: "send_input",
1625+
arguments: { target: "worker-1", message: "continue" },
1626+
},
1627+
{
1628+
id: "c",
1629+
name: "resume_agent",
1630+
arguments: { target: "worker-1", message: "continue" },
1631+
},
1632+
{
1633+
id: "c",
1634+
name: "read_agent_trace",
1635+
arguments: { target: "worker-1" },
1636+
},
1637+
];
1638+
for (const call of calls) {
1639+
const verdict = await gate.evaluate(call);
1640+
expect(verdict.allowed).toBe(true);
1641+
}
1642+
expect(asked).toBe(0);
1643+
// Same-gate in-bounds write: this fixture is not a restricted worktree.
1644+
const inBounds = await gate.evaluate({
1645+
id: "c",
1646+
name: "write_file",
1647+
arguments: { path: "notes.md" },
1648+
});
1649+
expect(inBounds.allowed).toBe(true);
1650+
expect(asked).toBe(0);
1651+
// The carve-out is intentional, not an oversight: even an isRestricted
1652+
// that reports everything restricted does not flag these calls — they
1653+
// carry agent ids, not paths.
1654+
const alwaysRestricted = () => true;
1655+
for (const call of calls) {
1656+
expect(callTargetsRestricted(call, alwaysRestricted)).toBe(false);
1657+
}
1658+
});
1659+
15991660
// manage_tasks's handler has no side effect — the task list is mutated
16001661
// earlier by the director, before this tool ever executes — so denying it
16011662
// cannot undo anything. It auto-allows unconditionally, not just in auto

0 commit comments

Comments
 (0)