Skip to content

Commit 5f5b96e

Browse files
Merge pull request #1440 from corbitsdev/cl-10696-add-typed-parent-approval-for-pending-worker-denied-calls
fix(permissions): add typed parent approval
2 parents 8c3908a + a92761f commit 5f5b96e

16 files changed

Lines changed: 1057 additions & 90 deletions

‎src/agent/tools.ts‎

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -87,6 +87,7 @@ import {
8787
createResumeAgentTool,
8888
createInterruptAgentTool,
8989
createSendInputTool,
90+
createApproveWorkerCallTool,
9091
} from "../subagent/lifecycle-tools.js";
9192
import { createManageTasksRunner } from "./tasks.js";
9293
import { createSpillingBackgroundShellExitNotifier } from "./background-shell-tool.js";
@@ -618,6 +619,11 @@ export async function createAgentToolset(
618619
createResumeAgentTool({ sessions: fleetSessions, fleetRecords }),
619620
createInterruptAgentTool({ sessions: fleetSessions, fleetRecords }),
620621
createSendInputTool({ sessions: fleetSessions, fleetRecords }),
622+
createApproveWorkerCallTool({
623+
sessions: fleetSessions,
624+
fleetRecords,
625+
permissionGate: args.permissionGate,
626+
}),
621627
);
622628
// Exec-primary opt-in only; TUI primary and nested orchestrators
623629
// collect via mailbox mail, so wait_agents stays unmounted there.

‎src/permission/reactor-authorize.test.ts‎

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -234,6 +234,33 @@ test("nested orchestrator wait_agents allows with only a spawn_agent grant", asy
234234
).toBe("allow");
235235
});
236236

237+
test("nested parent approval tool passes the worker authorization seam", async () => {
238+
const policy = gate({ interactive: true, auto: false });
239+
const authorize = createWorkerAuthorize(policy);
240+
expect(
241+
(
242+
await authorize(
243+
"tool:approve_worker_call",
244+
"invoke",
245+
namedCall("approve_worker_call", {
246+
target: "child-session",
247+
question_id: "child-question",
248+
request_id: "child-request",
249+
}),
250+
)
251+
).effect,
252+
).toBe("allow");
253+
expect(
254+
(
255+
await authorize(
256+
"tool:write_file",
257+
"invoke",
258+
namedCall("write_file", { path: "ordinary.txt", content: "data" }),
259+
)
260+
).effect,
261+
).toBe("deny");
262+
});
263+
237264
test("worker spawn_agent still needs a parent grant", async () => {
238265
const policy = gate({ interactive: true, auto: false });
239266
expect(

‎src/permission/reactor-authorize.ts‎

Lines changed: 21 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,6 @@ import type { AuthorizeVerdict, GateVerdict, PermissionGate } from "./gate.js";
2323
import type { PermissionRequest } from "./types.js";
2424
import {
2525
createDeniedCallEnvelope,
26-
fingerprintDeniedCall,
2726
formatWorkerDenyWithGrantId,
2827
getProcessWorkerGrantStore,
2928
type WorkerGrantStore,
@@ -164,17 +163,6 @@ function denyWorkerCallWithEnvelope(
164163
};
165164
}
166165

167-
/** Mutex key covering the envelope match: concurrent identical worker calls
168-
* serialize through precheck-to-consume so one envelope allows exactly once. */
169-
function workerGrantKey(
170-
sessionId: string,
171-
call: ToolCallType,
172-
cwd: string,
173-
): string {
174-
const identity = workerCallIdentity(sessionId, call, cwd);
175-
return `${sessionId}\n${fingerprintDeniedCall(identity.canonicalTool, identity.args, identity.cwd)}`;
176-
}
177-
178166
async function authorizeWorkerCall(
179167
gate: PermissionGate,
180168
call: ToolCallType,
@@ -187,11 +175,13 @@ async function authorizeWorkerCall(
187175
if (sessionId === undefined)
188176
return authorizeWorkerCallInner(gate, call, grantOptions, workerCwd);
189177
const store = grantOptions?.store ?? getProcessWorkerGrantStore();
190-
return store.runExclusive(workerGrantKey(sessionId, call, workerCwd), () =>
191-
authorizeWorkerCallInner(gate, call, grantOptions, workerCwd, {
192-
sessionId,
193-
store,
194-
}),
178+
return store.runExclusiveIdentity(
179+
workerCallIdentity(sessionId, call, workerCwd),
180+
() =>
181+
authorizeWorkerCallInner(gate, call, grantOptions, workerCwd, {
182+
sessionId,
183+
store,
184+
}),
195185
);
196186
}
197187

@@ -203,10 +193,10 @@ async function authorizeWorkerCallInner(
203193
grant?: { sessionId: string; store: WorkerGrantStore },
204194
): Promise<{ effect: "allow" } | { effect: "deny"; reason: string }> {
205195
if (grant !== undefined) {
206-
const precheck = grant.store.precheck(
207-
workerCallIdentity(grant.sessionId, call, workerCwd),
208-
);
196+
const identity = workerCallIdentity(grant.sessionId, call, workerCwd);
197+
const precheck = grant.store.precheck(identity);
209198
if (!precheck.ok) return { effect: "deny", reason: precheck.blocker };
199+
if (grant.store.hasAuthorizedRetry(identity)) return { effect: "allow" };
210200
}
211201
const verdict = await gate.authorizeCall(call);
212202
// Authorize never consumes: it only permits the call to proceed to the
@@ -248,11 +238,13 @@ async function executionVerdictWorkerCall(
248238
if (sessionId === undefined)
249239
return executionVerdictWorkerCallInner(gate, call, grantOptions, workerCwd);
250240
const store = grantOptions?.store ?? getProcessWorkerGrantStore();
251-
return store.runExclusive(workerGrantKey(sessionId, call, workerCwd), () =>
252-
executionVerdictWorkerCallInner(gate, call, grantOptions, workerCwd, {
253-
sessionId,
254-
store,
255-
}),
241+
return store.runExclusiveIdentity(
242+
workerCallIdentity(sessionId, call, workerCwd),
243+
() =>
244+
executionVerdictWorkerCallInner(gate, call, grantOptions, workerCwd, {
245+
sessionId,
246+
store,
247+
}),
256248
);
257249
}
258250

@@ -264,10 +256,11 @@ async function executionVerdictWorkerCallInner(
264256
grant?: { sessionId: string; store: WorkerGrantStore },
265257
): Promise<AuthorizeVerdict> {
266258
if (grant !== undefined) {
267-
const precheck = grant.store.precheck(
268-
workerCallIdentity(grant.sessionId, call, workerCwd),
269-
);
259+
const identity = workerCallIdentity(grant.sessionId, call, workerCwd);
260+
const precheck = grant.store.precheck(identity);
270261
if (!precheck.ok) return { effect: "deny", reason: precheck.blocker };
262+
if (grant.store.consumeAuthorizedRetry(identity) !== undefined)
263+
return { effect: "allow" };
271264
}
272265
const verdict = await gate.executionVerdict(call);
273266
// Sole consumption point: the execution backstop spends the envelope when

‎src/permission/worker-grant-flow.test.ts‎

Lines changed: 3 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -186,7 +186,7 @@ describe("worker grant-request flow: deny → parent replay grant → one retry"
186186
expect(store.peek(requestId)?.status).toBe("pending");
187187
});
188188

189-
test("send_input text answers the ask but grants nothing", () => {
189+
test("send_input text answers an unbound ask but grants nothing", () => {
190190
const sessions = createSubAgentSessionStore();
191191
const session = sessions.start({
192192
id: "fleet-1",
@@ -221,9 +221,8 @@ describe("worker grant-request flow: deny → parent replay grant → one retry"
221221
},
222222
}),
223223
).toBe(true);
224-
expect(sessions.peekAsk(session.id)?.deniedCall?.requestId).toBe(
225-
envelope.requestId,
226-
);
224+
// A request ID quoted in prose is not an authority-bearing binding.
225+
expect(sessions.peekAsk(session.id)?.deniedCall).toBeUndefined();
227226

228227
const echo = JSON.stringify({
229228
requestId: envelope.requestId,

‎src/permission/worker-grant.test.ts‎

Lines changed: 48 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -45,12 +45,15 @@ describe("fingerprintDeniedCall", () => {
4545
expect(a).toBe(b);
4646
});
4747

48-
test("differs across args and tool (cwd binds at precheck, not in the hash)", () => {
48+
test("differs across args, tool, and cwd", () => {
4949
const base = fingerprintDeniedCall("run_shell", ARGS, CWD);
5050
expect(
5151
fingerprintDeniedCall("run_shell", { command: "npm run evil" }, CWD),
5252
).not.toBe(base);
5353
expect(fingerprintDeniedCall("read_file", ARGS, CWD)).not.toBe(base);
54+
expect(fingerprintDeniedCall("run_shell", ARGS, "/elsewhere")).not.toBe(
55+
base,
56+
);
5457
expect(
5558
fingerprintDeniedCall("read_file", { path: "a.md" }, "/repo") ===
5659
fingerprintDeniedCall("read_file", { path: "b.md" }, "/repo"),
@@ -283,19 +286,32 @@ describe("WorkerGrantStore lifecycle", () => {
283286
expect(store.invalidateSession("worker-1", "again")).toBe(0);
284287
});
285288

286-
test("attachToAsk stamps the questionId and keeps pending", () => {
289+
test("attachToAsk binds only an explicit requestId and keeps it pending", () => {
287290
const store = new WorkerGrantStore();
288291
const envelope = store.register(createDeniedCallEnvelope(descriptor()));
289292
// Reads pin the clock: fixture now predates wall time, so an unpinned
290293
// read would sweep the envelope as expired.
291-
expect(store.attachToAsk("worker-1", "ask-7", undefined, 1_000_001)).toBe(
292-
envelope,
293-
);
294-
expect(envelope.questionId).toBe("ask-7");
294+
expect(
295+
store.attachToAsk("worker-1", "ask-generic", undefined, 1_000_001),
296+
).toBeUndefined();
297+
expect(envelope.questionId).toBeUndefined();
295298
expect(envelope.status).toBe("pending");
296299
expect(store.peek(envelope.requestId)).toBe(envelope);
297300
expect(
298-
store.attachToAsk("worker-9", "ask-8", undefined, 1_000_001),
301+
store.attachToAsk("worker-1", "ask-7", envelope.requestId, 1_000_001),
302+
).toBe(envelope);
303+
expect(envelope.questionId).toBe("ask-7");
304+
expect(
305+
store.attachToAsk(
306+
"worker-1",
307+
"ask-rebound",
308+
envelope.requestId,
309+
1_000_001,
310+
),
311+
).toBeUndefined();
312+
expect(envelope.questionId).toBe("ask-7");
313+
expect(
314+
store.attachToAsk("worker-9", "ask-8", envelope.requestId, 1_000_001),
299315
).toBeUndefined();
300316
expect(envelope.audit.map((event) => event.event)).toEqual([
301317
"denied",
@@ -329,12 +345,12 @@ describe("WorkerGrantStore lifecycle", () => {
329345
expect(store.peek(envelopeB.requestId)?.questionId).toBe("ask-B");
330346
});
331347

332-
test("unnamed ask keeps the legacy first-pending bind", () => {
348+
test("unnamed ask does not bind a pending denial", () => {
333349
const { store, envelopeA, envelopeB } = twoDenies();
334-
expect(store.attachToAsk("worker-1", "ask-x", undefined, 1_000_001)).toBe(
335-
envelopeA,
336-
);
337-
expect(envelopeA.questionId).toBe("ask-x");
350+
expect(
351+
store.attachToAsk("worker-1", "ask-x", undefined, 1_000_001),
352+
).toBeUndefined();
353+
expect(envelopeA.questionId).toBeUndefined();
338354
expect(envelopeB.questionId).toBeUndefined();
339355
});
340356

@@ -370,7 +386,7 @@ describe("WorkerGrantStore lifecycle", () => {
370386
test("audit trail orders deny → ask → consume", () => {
371387
const store = new WorkerGrantStore();
372388
const envelope = store.register(createDeniedCallEnvelope(descriptor()));
373-
store.attachToAsk("worker-1", "ask-1", undefined, 1_000_001);
389+
store.attachToAsk("worker-1", "ask-1", envelope.requestId, 1_000_001);
374390
store.consumeOnAllow({
375391
sessionId: "worker-1",
376392
canonicalTool: "run_shell",
@@ -385,6 +401,25 @@ describe("WorkerGrantStore lifecycle", () => {
385401
]);
386402
});
387403

404+
test("revokeAuthorizedRetry returns authorized to pending and audits revoked", () => {
405+
const store = new WorkerGrantStore();
406+
const envelope = store.register(createDeniedCallEnvelope(descriptor()));
407+
expect(store.authorizeRetry(envelope.requestId, 1_000_001)).toBe(true);
408+
store.revokeAuthorizedRetry(envelope.requestId);
409+
expect(envelope.status).toBe("pending");
410+
expect(envelope.audit.map((event) => event.event)).toEqual([
411+
"denied",
412+
"authorized",
413+
"revoked",
414+
]);
415+
store.revokeAuthorizedRetry(envelope.requestId);
416+
expect(envelope.audit.map((event) => event.event)).toEqual([
417+
"denied",
418+
"authorized",
419+
"revoked",
420+
]);
421+
});
422+
388423
test("no text API: state moves only on typed identities, never prose", () => {
389424
const store = new WorkerGrantStore();
390425
const envelope = store.register(createDeniedCallEnvelope(descriptor()));

0 commit comments

Comments
 (0)