Skip to content

Commit d36740c

Browse files
Merge pull request #1521 from corbitsdev/cl-10855-key-a-workers-authorized-retry-on-the-dispatched-call-so-the
fix(permission): key worker grants on the dispatched call
2 parents c03784d + 787b80c commit d36740c

2 files changed

Lines changed: 168 additions & 2 deletions

File tree

‎src/permission/reactor-authorize.ts‎

Lines changed: 21 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,7 @@ import {
3131
type WorkerGrantStore,
3232
} from "./worker-grant.js";
3333
import { canonicalToolName } from "../agent/canonical-tool-name.js";
34+
import { prepareDispatchedToolCall } from "../agent/tool-aliases.js";
3435
import { getSubAgentIdentity } from "../subagent/identity-context.js";
3536
import {
3637
FLEET_VERBS,
@@ -100,6 +101,23 @@ function resolveWorkerCwd(cwd: string | undefined): string {
100101
return cwd ?? getSubAgentIdentity()?.cwd ?? process.cwd();
101102
}
102103

104+
/**
105+
* The call as DynamicToolRunner dispatches it: canonical engine name plus
106+
* hidden-alias argument coercion (gpt `shell` -> run_shell via
107+
* coerceShellArgs). Authorize sees the raw wire call; the middleware that
108+
* consumes an authorized retry sees the dispatched call. Keying both on the
109+
* dispatched shape keeps one fingerprint per executed call. Invalid hidden
110+
* arguments never dispatch, so they keep their raw shape.
111+
*/
112+
function dispatchedCall(call: ToolCallType): ToolCallType {
113+
const engine = canonicalToolName(call.name);
114+
try {
115+
return prepareDispatchedToolCall(call, engine);
116+
} catch {
117+
return { ...call, name: engine };
118+
}
119+
}
120+
103121
function workerCallIdentity(
104122
sessionId: string,
105123
call: ToolCallType,
@@ -110,10 +128,11 @@ function workerCallIdentity(
110128
args: Record<string, unknown>;
111129
cwd: string;
112130
} {
131+
const dispatched = dispatchedCall(call);
113132
return {
114133
sessionId,
115-
canonicalTool: canonicalToolName(call.name),
116-
args: (call.arguments ?? {}) as Record<string, unknown>,
134+
canonicalTool: dispatched.name,
135+
args: (dispatched.arguments ?? {}) as Record<string, unknown>,
117136
cwd,
118137
};
119138
}
Lines changed: 147 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,147 @@
1+
// gpt-family workers call run_shell as `shell`. Reactor authz sees that raw
2+
// wire call; the permission middleware sees DynamicToolRunner's
3+
// prepareDispatchedToolCall output, whose coerceShellArgs rebuilds the
4+
// arguments. The authorized retry must match the same envelope at both
5+
// stages.
6+
import { describe, expect, test } from "bun:test";
7+
import type { ToolCall } from "@intx/types/runtime";
8+
9+
import { prepareDispatchedToolCall } from "../agent/tool-aliases.js";
10+
import { gateToolCall } from "../plugins/permission-plugin.js";
11+
import { createPermissionGate } from "./gate.js";
12+
import { workerPermissionGate } from "./reactor-authorize.js";
13+
import { WorkerGrantStore } from "./worker-grant.js";
14+
15+
const CWD = process.cwd();
16+
17+
// The call shape a gpt-family worker sends for run_shell.
18+
const wireCall = (id: string): ToolCall => ({
19+
id,
20+
name: "shell",
21+
arguments: {
22+
command: "git status --short",
23+
timeout: 120000,
24+
cwd: CWD,
25+
background: false,
26+
stop: "",
27+
},
28+
});
29+
30+
function extractRequestId(reason: string): string {
31+
const match = /grant request ([0-9a-f-]{36})/.exec(reason);
32+
if (match?.[1] === undefined) throw new Error(`no grant id: ${reason}`);
33+
return match[1];
34+
}
35+
36+
describe("gpt-family worker authorized retry identity", () => {
37+
test("approved retry of a `shell` call executes once instead of minting a new grant request", async () => {
38+
// Parent: reactor-gated interactive gate, nothing pre-approved, no auto.
39+
const parentGate = createPermissionGate({
40+
approvals: [],
41+
cwd: CWD,
42+
skipPermissions: false,
43+
interactive: true,
44+
reactorGated: true,
45+
auto: false,
46+
requestApproval: async () => ({ allow: false }),
47+
});
48+
const store = new WorkerGrantStore();
49+
const workerGate = workerPermissionGate(parentGate, {
50+
sessionId: "worker-gpt",
51+
store,
52+
});
53+
54+
// 1. First attempt: reactor authz denies and records envelope A.
55+
const denied = await workerGate.authorizeCall(wireCall("call-1"));
56+
if (denied.effect !== "deny") throw new Error("expected worker deny");
57+
const requestId = extractRequestId(denied.reason);
58+
const envelope = store.peek(requestId);
59+
expect(envelope?.canonicalTool).toBe("run_shell");
60+
61+
// 2. approve_worker_call: parent approved the frozen call (allow-once,
62+
// no session grant) and flipped the envelope to authorized.
63+
expect(store.authorizeRetry(requestId)).toBe(true);
64+
65+
// 3. Retry, stage 1: reactor authz on the raw wire call allows via
66+
// hasAuthorizedRetry (does not consume).
67+
const retryAuthz = await workerGate.authorizeCall(wireCall("call-2"));
68+
expect(retryAuthz).toEqual({ effect: "allow" });
69+
expect(store.peek(requestId)?.status).toBe("authorized");
70+
71+
// 4. Retry, stage 2: DynamicToolRunner coerces the `shell` call onto
72+
// run_shell, then the posix middleware runs gateToolCall.
73+
const dispatched = prepareDispatchedToolCall(
74+
wireCall("call-2"),
75+
"run_shell",
76+
);
77+
expect(dispatched.arguments).not.toEqual(wireCall("x").arguments);
78+
79+
let executed = 0;
80+
const result = await gateToolCall(
81+
workerGate,
82+
dispatched,
83+
new AbortController().signal,
84+
async (call) => {
85+
executed++;
86+
return { callId: call.id, content: "ran" };
87+
},
88+
);
89+
90+
expect(result.isError).not.toBe(true);
91+
expect(executed).toBe(1);
92+
expect(store.peek(requestId)?.status).toBe("consumed");
93+
94+
// One grant, one execution: a replay of the same call fails closed at
95+
// both stages, and no second envelope was minted.
96+
const replay = await gateToolCall(
97+
workerGate,
98+
prepareDispatchedToolCall(wireCall("call-3"), "run_shell"),
99+
new AbortController().signal,
100+
async (call) => {
101+
executed++;
102+
return { callId: call.id, content: "ran" };
103+
},
104+
);
105+
expect(replay.isError).toBe(true);
106+
expect(String(replay.content)).toContain("already consumed");
107+
expect((await workerGate.authorizeCall(wireCall("call-4"))).effect).toBe(
108+
"deny",
109+
);
110+
expect(executed).toBe(1);
111+
});
112+
113+
test("an altered retry (different command) still fails closed", async () => {
114+
const parentGate = createPermissionGate({
115+
approvals: [],
116+
cwd: CWD,
117+
skipPermissions: false,
118+
interactive: true,
119+
reactorGated: true,
120+
auto: false,
121+
requestApproval: async () => ({ allow: false }),
122+
});
123+
const store = new WorkerGrantStore();
124+
const workerGate = workerPermissionGate(parentGate, {
125+
sessionId: "worker-gpt-alt",
126+
store,
127+
});
128+
const denied = await workerGate.authorizeCall(wireCall("call-1"));
129+
if (denied.effect !== "deny") throw new Error("expected worker deny");
130+
const requestId = extractRequestId(denied.reason);
131+
expect(store.authorizeRetry(requestId)).toBe(true);
132+
133+
const altered: ToolCall = {
134+
...wireCall("call-2"),
135+
arguments: { ...wireCall("x").arguments, command: "git push --force" },
136+
};
137+
expect((await workerGate.authorizeCall(altered)).effect).toBe("deny");
138+
const result = await gateToolCall(
139+
workerGate,
140+
prepareDispatchedToolCall(altered, "run_shell"),
141+
new AbortController().signal,
142+
async (call) => ({ callId: call.id, content: "ran" }),
143+
);
144+
expect(result.isError).toBe(true);
145+
expect(store.peek(requestId)?.status).toBe("authorized");
146+
});
147+
});

0 commit comments

Comments
 (0)