Skip to content

Commit 84130e2

Browse files
committed
fix(permission): ask once when approving a worker's frozen call
The approve_worker_call prompt shows the worker's frozen call and prompts on every use: it offers no standing grant, and a saved rule for it is ignored. On the top-level parent, approving it answers the frozen call's ordinary ask, so the operator is asked once per worker action. A forced ask (a sensitive or restricted target, or a plugin-policy ask) still prompts on its own, and a deny still blocks. A nested orchestrator's approve_worker_call runs without an operator prompt, so there the frozen call keeps its full evaluation.
1 parent 83c6eef commit 84130e2

10 files changed

Lines changed: 420 additions & 8 deletions

‎src/agent/tools.test.ts‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -173,6 +173,7 @@ const {
173173

174174
const fakePermissionGate: PermissionGate = {
175175
evaluate: mock(async () => ({ allowed: true as const })),
176+
evaluateApprovedWorkerCall: mock(async () => ({ allowed: true as const })),
176177
authorizeCall: mock(async () => ({ effect: "allow" as const })),
177178
executionVerdict: mock(async () => ({ effect: "allow" as const })),
178179
resolveSuspended: mock(async () => undefined),

‎src/agent/tools.ts‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -628,6 +628,7 @@ export async function createAgentToolset(
628628
sessions: fleetSessions,
629629
fleetRecords,
630630
permissionGate: args.permissionGate,
631+
approvalPrompted: true,
631632
}),
632633
);
633634
// Exec-primary opt-in only; TUI primary and nested orchestrators
Lines changed: 247 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,247 @@
1+
import { afterAll, afterEach, describe, expect, test } from "bun:test";
2+
import { mkdtempSync, rmSync } from "node:fs";
3+
import { tmpdir } from "node:os";
4+
import { join } from "node:path";
5+
import type { ToolCall } from "@intx/types/runtime";
6+
7+
import { createPermissionGate, isRequestCoveredByApproval } from "./gate.js";
8+
import { createPathRestriction } from "./path-restriction.js";
9+
import type { Approval, PermissionRequest } from "./types.js";
10+
import {
11+
createDeniedCallEnvelope,
12+
getProcessWorkerGrantStore,
13+
} from "./worker-grant.js";
14+
import { createWorktreeRootsProvider } from "./worktree-roots.js";
15+
16+
const cwd = mkdtempSync(join(tmpdir(), "approve-worker-call-"));
17+
afterAll(() => rmSync(cwd, { recursive: true, force: true }));
18+
afterEach(() => getProcessWorkerGrantStore().clear());
19+
20+
const SAVED_RULE: Approval = { tool: "approve_worker_call", pattern: "*" };
21+
22+
function countingGate(
23+
opts: {
24+
approvals?: Approval[];
25+
allow?: boolean;
26+
pluginEffect?: "allow" | "ask";
27+
} = {},
28+
) {
29+
const prompts: PermissionRequest[] = [];
30+
const { pluginEffect } = opts;
31+
const gate = createPermissionGate({
32+
approvals: opts.approvals ?? [],
33+
cwd,
34+
interactive: true,
35+
skipPermissions: false,
36+
reactorGated: false,
37+
requestApproval: async (request) => {
38+
prompts.push(request);
39+
return { allow: opts.allow ?? true };
40+
},
41+
...(pluginEffect !== undefined
42+
? {
43+
pluginAuthorize: async () => ({
44+
effect: pluginEffect,
45+
matchingGrants: [],
46+
resolvedBy: null,
47+
}),
48+
}
49+
: {}),
50+
});
51+
return { gate, prompts };
52+
}
53+
54+
const shellCall = (command: string): ToolCall => ({
55+
id: "frozen-shell",
56+
name: "run_shell",
57+
arguments: { command },
58+
});
59+
60+
function deniedShellCall(command: string, workerLabel?: string) {
61+
return getProcessWorkerGrantStore().register(
62+
createDeniedCallEnvelope({
63+
callId: "worker-call-1",
64+
tool: "run_shell",
65+
action: "Run shell command",
66+
subject: command,
67+
args: { command },
68+
cwd,
69+
workerSessionId: "worker-1",
70+
...(workerLabel !== undefined ? { workerLabel } : {}),
71+
}),
72+
);
73+
}
74+
75+
const approveCall = (requestId: string): ToolCall => ({
76+
id: "approve-1",
77+
name: "approve_worker_call",
78+
arguments: {
79+
target: "worker-1",
80+
question_id: "question-1",
81+
request_id: requestId,
82+
},
83+
});
84+
85+
describe("the approve_worker_call prompt", () => {
86+
test("shows the worker's frozen call and offers no standing grant", async () => {
87+
const envelope = deniedShellCall(
88+
"printf changed > generated.txt",
89+
"lint fixer",
90+
);
91+
const { gate, prompts } = countingGate();
92+
93+
expect(await gate.evaluate(approveCall(envelope.requestId))).toEqual({
94+
allowed: true,
95+
});
96+
97+
expect(prompts).toHaveLength(1);
98+
const [prompt] = prompts;
99+
expect(prompt?.tool).toBe("approve_worker_call");
100+
expect(prompt?.subject).toBe("printf changed > generated.txt");
101+
expect(prompt?.action).toContain("Run shell command");
102+
expect(prompt?.agentLabel).toBe("lint fixer");
103+
expect(prompt?.scopes).toEqual([]);
104+
});
105+
106+
test("names the worker by id when its label was not recorded", async () => {
107+
const envelope = deniedShellCall("printf changed > generated.txt");
108+
const { gate, prompts } = countingGate();
109+
110+
await gate.evaluate(approveCall(envelope.requestId));
111+
112+
expect(prompts[0]?.agentLabel).toBe("worker-1");
113+
});
114+
115+
test("still prompts when no live denied call matches, without a standing grant", async () => {
116+
const { gate, prompts } = countingGate();
117+
118+
await gate.evaluate(approveCall("no-such-request"));
119+
120+
expect(prompts).toHaveLength(1);
121+
expect(prompts[0]?.tool).toBe("approve_worker_call");
122+
expect(prompts[0]?.scopes).toEqual([]);
123+
});
124+
125+
test("prompts even when a saved rule would cover it", async () => {
126+
const envelope = deniedShellCall("printf changed > generated.txt");
127+
const { gate, prompts } = countingGate({ approvals: [SAVED_RULE] });
128+
129+
await gate.evaluate(approveCall(envelope.requestId));
130+
131+
expect(prompts).toHaveLength(1);
132+
});
133+
134+
test("is never drained from the queue by a saved rule", () => {
135+
const envelope = deniedShellCall("printf changed > generated.txt");
136+
const request: PermissionRequest = {
137+
tool: "approve_worker_call",
138+
action: "Approve worker call",
139+
subject: envelope.permissionSubject,
140+
scopes: [],
141+
cwd,
142+
};
143+
const isRestricted = createPathRestriction(
144+
cwd,
145+
createWorktreeRootsProvider(cwd),
146+
).isRestricted;
147+
148+
expect(
149+
isRequestCoveredByApproval(request, SAVED_RULE, undefined, isRestricted, {
150+
resolvedCwd: cwd,
151+
roots: [],
152+
}),
153+
).toBe(false);
154+
});
155+
});
156+
157+
describe("evaluateApprovedWorkerCall", () => {
158+
const writeCall: ToolCall = {
159+
id: "frozen-1",
160+
name: "write_file",
161+
arguments: { path: join(cwd, "notes.txt"), content: "approved" },
162+
};
163+
164+
test("an ordinary ask is answered by the approve_worker_call prompt", async () => {
165+
const control = countingGate();
166+
await control.gate.evaluate(writeCall);
167+
expect(control.prompts).toHaveLength(1);
168+
169+
const { gate, prompts } = countingGate();
170+
expect(await gate.evaluateApprovedWorkerCall(writeCall)).toEqual({
171+
allowed: true,
172+
});
173+
expect(prompts).toHaveLength(0);
174+
});
175+
176+
test.each([
177+
["a sensitive path", "cat .env"],
178+
["a restricted target", "cat /etc/passwd"],
179+
])("a call touching %s still prompts on its own", async (_name, command) => {
180+
const approved = countingGate();
181+
expect(
182+
await approved.gate.evaluateApprovedWorkerCall({
183+
id: "frozen-1",
184+
name: "run_shell",
185+
arguments: { command },
186+
}),
187+
).toEqual({ allowed: true });
188+
expect(approved.prompts).toHaveLength(1);
189+
190+
const declined = countingGate({ allow: false });
191+
const verdict = await declined.gate.evaluateApprovedWorkerCall({
192+
id: "frozen-2",
193+
name: "run_shell",
194+
arguments: { command },
195+
});
196+
expect(verdict.allowed).toBe(false);
197+
});
198+
199+
test("a restricted non-shell target still prompts on its own", async () => {
200+
const { gate, prompts } = countingGate();
201+
202+
// The session state root is write-ask even inside the workspace.
203+
const verdict = await gate.evaluateApprovedWorkerCall({
204+
id: "frozen-1",
205+
name: "write_file",
206+
arguments: { path: join(cwd, ".agent-state", "x.json"), content: "{}" },
207+
});
208+
209+
expect(verdict).toEqual({ allowed: true });
210+
expect(prompts).toHaveLength(1);
211+
});
212+
213+
test.each([
214+
["the gate allows", "ls"],
215+
["the gate already asks for", "printf x > out.txt"],
216+
])(
217+
"a plugin-policy ask on a call %s still prompts",
218+
async (_name, command) => {
219+
const { gate, prompts } = countingGate({ pluginEffect: "ask" });
220+
221+
await gate.evaluateApprovedWorkerCall(shellCall(command));
222+
223+
expect(prompts).toHaveLength(1);
224+
},
225+
);
226+
227+
test("a plugin allow adds no prompt to an ordinary ask", async () => {
228+
const { gate, prompts } = countingGate({ pluginEffect: "allow" });
229+
230+
await gate.evaluateApprovedWorkerCall(shellCall("printf x > out.txt"));
231+
232+
expect(prompts).toHaveLength(0);
233+
});
234+
235+
test("a hard deny still blocks without a prompt", async () => {
236+
const { gate, prompts } = countingGate();
237+
238+
const verdict = await gate.evaluateApprovedWorkerCall({
239+
id: "frozen-1",
240+
name: "run_shell",
241+
arguments: { command: "rm -rf /" },
242+
});
243+
244+
expect(verdict.allowed).toBe(false);
245+
expect(prompts).toHaveLength(0);
246+
});
247+
});
Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
import type { ToolCall } from "@intx/types/runtime";
2+
import { type } from "arktype";
3+
4+
import type { PermissionRequest } from "./types.js";
5+
import { getProcessWorkerGrantStore } from "./worker-grant.js";
6+
7+
const ApproveWorkerCallRequestId = type({ request_id: "string" });
8+
9+
// The operator approves the worker's frozen call through this prompt, and the
10+
// frozen call does not prompt again for an ordinary ask, so the prompt shows
11+
// that call rather than approve_worker_call's correlation ids. It offers no
12+
// scopes: a standing grant would let the parent model approve worker calls
13+
// with no operator involved.
14+
export function approveWorkerCallRequest(call: ToolCall): PermissionRequest {
15+
const parsed = ApproveWorkerCallRequestId(call.arguments);
16+
const requestId =
17+
parsed instanceof type.errors ? "" : parsed.request_id.trim();
18+
const envelope =
19+
requestId.length > 0
20+
? getProcessWorkerGrantStore().peek(requestId)
21+
: undefined;
22+
if (envelope === undefined) {
23+
return {
24+
tool: call.name,
25+
action: "Approve worker call",
26+
subject: requestId,
27+
arguments: call.arguments,
28+
scopes: [],
29+
notice:
30+
"No live denied worker call matches this request, so approving runs nothing.",
31+
};
32+
}
33+
return {
34+
tool: call.name,
35+
action: `Approve worker call: ${envelope.toolAction ?? envelope.canonicalTool}`,
36+
subject: envelope.permissionSubject,
37+
arguments: call.arguments,
38+
scopes: [],
39+
agentLabel: envelope.workerLabel ?? envelope.workerSessionId,
40+
notice: "Approving lets the worker retry this exact call once.",
41+
};
42+
}

‎src/permission/classify.ts‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,7 @@ import {
3535
READ_ONLY_CONTROL_PLANE_TOOLS,
3636
} from "../agent/tool-classification.js";
3737
import { canonicalToolName } from "../agent/canonical-tool-name.js";
38+
import { approveWorkerCallRequest } from "./approve-worker-request.js";
3839

3940
// Read-only tools never need approval while they stay off restricted paths —
4041
// they cannot change the workspace. `lsp` is included despite activating
@@ -570,6 +571,9 @@ function shellApprovalScopes(command: string): ApprovalScope[] {
570571
// Shell is one request for the full command (security still splits under the
571572
// gate); file tools key on the target path.
572573
export function buildRequests(call: ToolCall): PermissionRequest[] {
574+
if (call.name === "approve_worker_call") {
575+
return [approveWorkerCallRequest(call)];
576+
}
573577
if (call.name === "run_shell") {
574578
const command = stringArg(call, "command");
575579
// Pure comments / empty: nothing to approve.

0 commit comments

Comments
 (0)