Skip to content

Commit e6a82d9

Browse files
Merge pull request #1567 from corbitsdev/cl-10768-let-the-approve_worker_call-prompt-approve-the-workers
fix(permission): ask once when approving a worker's frozen call
2 parents 56013ae + 5cf5e9e commit e6a82d9

10 files changed

Lines changed: 502 additions & 15 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: 307 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,307 @@
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+
// Per-prompt answers in order; once exhausted, `allow` answers.
27+
answers?: boolean[];
28+
pluginEffect?: "allow" | "ask";
29+
} = {},
30+
) {
31+
const prompts: PermissionRequest[] = [];
32+
const answers = [...(opts.answers ?? [])];
33+
const { pluginEffect } = opts;
34+
const gate = createPermissionGate({
35+
approvals: opts.approvals ?? [],
36+
cwd,
37+
interactive: true,
38+
skipPermissions: false,
39+
reactorGated: false,
40+
requestApproval: async (request) => {
41+
prompts.push(request);
42+
return { allow: answers.shift() ?? opts.allow ?? true };
43+
},
44+
...(pluginEffect !== undefined
45+
? {
46+
pluginAuthorize: async () => ({
47+
effect: pluginEffect,
48+
matchingGrants: [],
49+
resolvedBy: null,
50+
}),
51+
}
52+
: {}),
53+
});
54+
return { gate, prompts };
55+
}
56+
57+
const shellCall = (command: string): ToolCall => ({
58+
id: "frozen-shell",
59+
name: "run_shell",
60+
arguments: { command },
61+
});
62+
63+
function deniedShellCall(command: string, workerLabel?: string) {
64+
return getProcessWorkerGrantStore().register(
65+
createDeniedCallEnvelope({
66+
callId: "worker-call-1",
67+
tool: "run_shell",
68+
action: "Run shell command",
69+
subject: command,
70+
args: { command },
71+
cwd,
72+
workerSessionId: "worker-1",
73+
...(workerLabel !== undefined ? { workerLabel } : {}),
74+
}),
75+
);
76+
}
77+
78+
const approveCall = (requestId: string): ToolCall => ({
79+
id: "approve-1",
80+
name: "approve_worker_call",
81+
arguments: {
82+
target: "worker-1",
83+
question_id: "question-1",
84+
request_id: requestId,
85+
},
86+
});
87+
88+
describe("the approve_worker_call prompt", () => {
89+
test("shows the worker's frozen call and offers no standing grant", async () => {
90+
const envelope = deniedShellCall(
91+
"printf changed > generated.txt",
92+
"lint fixer",
93+
);
94+
const { gate, prompts } = countingGate();
95+
96+
expect(await gate.evaluate(approveCall(envelope.requestId))).toEqual({
97+
allowed: true,
98+
});
99+
100+
expect(prompts).toHaveLength(1);
101+
const [prompt] = prompts;
102+
expect(prompt?.tool).toBe("approve_worker_call");
103+
expect(prompt?.subject).toBe("printf changed > generated.txt");
104+
expect(prompt?.action).toContain("Run shell command");
105+
expect(prompt?.agentLabel).toBe("lint fixer");
106+
expect(prompt?.scopes).toEqual([]);
107+
});
108+
109+
test("names the worker by id when its label was not recorded", async () => {
110+
const envelope = deniedShellCall("printf changed > generated.txt");
111+
const { gate, prompts } = countingGate();
112+
113+
await gate.evaluate(approveCall(envelope.requestId));
114+
115+
expect(prompts[0]?.agentLabel).toBe("worker-1");
116+
});
117+
118+
test("still prompts when no live denied call matches, without a standing grant", async () => {
119+
const { gate, prompts } = countingGate();
120+
121+
await gate.evaluate(approveCall("no-such-request"));
122+
123+
expect(prompts).toHaveLength(1);
124+
expect(prompts[0]?.tool).toBe("approve_worker_call");
125+
expect(prompts[0]?.scopes).toEqual([]);
126+
});
127+
128+
test("prompts even when a saved rule would cover it", async () => {
129+
const envelope = deniedShellCall("printf changed > generated.txt");
130+
const { gate, prompts } = countingGate({ approvals: [SAVED_RULE] });
131+
132+
await gate.evaluate(approveCall(envelope.requestId));
133+
134+
expect(prompts).toHaveLength(1);
135+
});
136+
137+
test("is never drained from the queue by a saved rule", () => {
138+
const envelope = deniedShellCall("printf changed > generated.txt");
139+
const request: PermissionRequest = {
140+
tool: "approve_worker_call",
141+
action: "Approve worker call",
142+
subject: envelope.permissionSubject,
143+
scopes: [],
144+
cwd,
145+
};
146+
const isRestricted = createPathRestriction(
147+
cwd,
148+
createWorktreeRootsProvider(cwd),
149+
).isRestricted;
150+
151+
expect(
152+
isRequestCoveredByApproval(request, SAVED_RULE, undefined, isRestricted, {
153+
resolvedCwd: cwd,
154+
roots: [],
155+
}),
156+
).toBe(false);
157+
});
158+
});
159+
160+
describe("evaluateApprovedWorkerCall", () => {
161+
const writeCall: ToolCall = {
162+
id: "frozen-1",
163+
name: "write_file",
164+
arguments: { path: join(cwd, "notes.txt"), content: "approved" },
165+
};
166+
167+
test("an ordinary ask is answered by the approve_worker_call prompt", async () => {
168+
const control = countingGate();
169+
await control.gate.evaluate(writeCall);
170+
expect(control.prompts).toHaveLength(1);
171+
172+
const { gate, prompts } = countingGate();
173+
expect(await gate.evaluateApprovedWorkerCall(writeCall)).toEqual({
174+
allowed: true,
175+
});
176+
expect(prompts).toHaveLength(0);
177+
});
178+
179+
test.each([
180+
["a sensitive path", "cat .env"],
181+
["a restricted target", "cat /etc/passwd"],
182+
])("a call touching %s still prompts on its own", async (_name, command) => {
183+
const approved = countingGate();
184+
expect(
185+
await approved.gate.evaluateApprovedWorkerCall({
186+
id: "frozen-1",
187+
name: "run_shell",
188+
arguments: { command },
189+
}),
190+
).toEqual({ allowed: true });
191+
expect(approved.prompts).toHaveLength(1);
192+
193+
const declined = countingGate({ allow: false });
194+
const verdict = await declined.gate.evaluateApprovedWorkerCall({
195+
id: "frozen-2",
196+
name: "run_shell",
197+
arguments: { command },
198+
});
199+
expect(verdict.allowed).toBe(false);
200+
});
201+
202+
test("a restricted non-shell target still prompts on its own", async () => {
203+
const { gate, prompts } = countingGate();
204+
205+
// The session state root is write-ask even inside the workspace.
206+
const verdict = await gate.evaluateApprovedWorkerCall({
207+
id: "frozen-1",
208+
name: "write_file",
209+
arguments: { path: join(cwd, ".agent-state", "x.json"), content: "{}" },
210+
});
211+
212+
expect(verdict).toEqual({ allowed: true });
213+
expect(prompts).toHaveLength(1);
214+
});
215+
216+
test.each([
217+
["the gate allows", "ls"],
218+
["the gate already asks for", "printf x > out.txt"],
219+
])(
220+
"a plugin-policy ask on a call %s still prompts",
221+
async (_name, command) => {
222+
const { gate, prompts } = countingGate({ pluginEffect: "ask" });
223+
224+
await gate.evaluateApprovedWorkerCall(shellCall(command));
225+
226+
expect(prompts).toHaveLength(1);
227+
},
228+
);
229+
230+
test("a plugin allow adds no prompt to an ordinary ask", async () => {
231+
const { gate, prompts } = countingGate({ pluginEffect: "allow" });
232+
233+
await gate.evaluateApprovedWorkerCall(shellCall("printf x > out.txt"));
234+
235+
expect(prompts).toHaveLength(0);
236+
});
237+
238+
test("supersedes a decline the operator gave the same call earlier in the turn", async () => {
239+
const { gate, prompts } = countingGate({ answers: [false] });
240+
expect((await gate.evaluate(writeCall)).allowed).toBe(false);
241+
expect(prompts).toHaveLength(1);
242+
243+
expect(await gate.evaluateApprovedWorkerCall(writeCall)).toEqual({
244+
allowed: true,
245+
});
246+
expect(prompts).toHaveLength(1);
247+
});
248+
249+
test("leaves the decline cached for an unapproved same-turn retry", async () => {
250+
const { gate, prompts } = countingGate({ answers: [false] });
251+
const declined = await gate.evaluate(writeCall);
252+
253+
await gate.evaluateApprovedWorkerCall(writeCall);
254+
255+
expect(await gate.evaluate({ ...writeCall, id: "retry-1" })).toEqual(
256+
declined,
257+
);
258+
expect(prompts).toHaveLength(1);
259+
});
260+
261+
test("leaves a different call's cached decline in force", async () => {
262+
const { gate, prompts } = countingGate({ answers: [false] });
263+
const otherCall: ToolCall = {
264+
id: "frozen-2",
265+
name: "write_file",
266+
arguments: { path: join(cwd, "other.txt"), content: "approved" },
267+
};
268+
const declined = await gate.evaluate(otherCall);
269+
expect(declined.allowed).toBe(false);
270+
271+
expect(await gate.evaluateApprovedWorkerCall(writeCall)).toEqual({
272+
allowed: true,
273+
});
274+
275+
expect(await gate.evaluate({ ...otherCall, id: "retry-1" })).toEqual(
276+
declined,
277+
);
278+
expect(prompts).toHaveLength(1);
279+
});
280+
281+
test("a declined forced ask prompts again when approved, and a second decline denies", async () => {
282+
const { gate, prompts } = countingGate({ answers: [false, false] });
283+
const sensitiveCall = shellCall("cat .env");
284+
expect((await gate.evaluate(sensitiveCall)).allowed).toBe(false);
285+
expect(prompts).toHaveLength(1);
286+
287+
const verdict = await gate.evaluateApprovedWorkerCall(sensitiveCall);
288+
289+
expect(verdict.allowed).toBe(false);
290+
expect(prompts).toHaveLength(2);
291+
expect((await gate.evaluate(sensitiveCall)).allowed).toBe(false);
292+
expect(prompts).toHaveLength(2);
293+
});
294+
295+
test("a hard deny still blocks without a prompt", async () => {
296+
const { gate, prompts } = countingGate();
297+
298+
const verdict = await gate.evaluateApprovedWorkerCall({
299+
id: "frozen-1",
300+
name: "run_shell",
301+
arguments: { command: "rm -rf /" },
302+
});
303+
304+
expect(verdict.allowed).toBe(false);
305+
expect(prompts).toHaveLength(0);
306+
});
307+
});
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)