Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
26 changes: 26 additions & 0 deletions src/permission/classify.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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") {
Expand Down
61 changes: 61 additions & 0 deletions src/permission/permission.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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
Expand Down
Loading