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
23 changes: 21 additions & 2 deletions src/permission/reactor-authorize.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@ import {
type WorkerGrantStore,
} from "./worker-grant.js";
import { canonicalToolName } from "../agent/canonical-tool-name.js";
import { prepareDispatchedToolCall } from "../agent/tool-aliases.js";
import { getSubAgentIdentity } from "../subagent/identity-context.js";
import {
FLEET_VERBS,
Expand Down Expand Up @@ -100,6 +101,23 @@ function resolveWorkerCwd(cwd: string | undefined): string {
return cwd ?? getSubAgentIdentity()?.cwd ?? process.cwd();
}

/**
* The call as DynamicToolRunner dispatches it: canonical engine name plus
* hidden-alias argument coercion (gpt `shell` -> run_shell via
* coerceShellArgs). Authorize sees the raw wire call; the middleware that
* consumes an authorized retry sees the dispatched call. Keying both on the
* dispatched shape keeps one fingerprint per executed call. Invalid hidden
* arguments never dispatch, so they keep their raw shape.
*/
function dispatchedCall(call: ToolCallType): ToolCallType {
const engine = canonicalToolName(call.name);
try {
return prepareDispatchedToolCall(call, engine);
} catch {
return { ...call, name: engine };
}
}

function workerCallIdentity(
sessionId: string,
call: ToolCallType,
Expand All @@ -110,10 +128,11 @@ function workerCallIdentity(
args: Record<string, unknown>;
cwd: string;
} {
const dispatched = dispatchedCall(call);
return {
sessionId,
canonicalTool: canonicalToolName(call.name),
args: (call.arguments ?? {}) as Record<string, unknown>,
canonicalTool: dispatched.name,
args: (dispatched.arguments ?? {}) as Record<string, unknown>,
cwd,
};
}
Expand Down
147 changes: 147 additions & 0 deletions src/permission/worker-grant-dispatch-identity.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,147 @@
// gpt-family workers call run_shell as `shell`. Reactor authz sees that raw
// wire call; the permission middleware sees DynamicToolRunner's
// prepareDispatchedToolCall output, whose coerceShellArgs rebuilds the
// arguments. The authorized retry must match the same envelope at both
// stages.
import { describe, expect, test } from "bun:test";
import type { ToolCall } from "@intx/types/runtime";

import { prepareDispatchedToolCall } from "../agent/tool-aliases.js";
import { gateToolCall } from "../plugins/permission-plugin.js";
import { createPermissionGate } from "./gate.js";
import { workerPermissionGate } from "./reactor-authorize.js";
import { WorkerGrantStore } from "./worker-grant.js";

const CWD = process.cwd();

// The call shape a gpt-family worker sends for run_shell.
const wireCall = (id: string): ToolCall => ({
id,
name: "shell",
arguments: {
command: "git status --short",
timeout: 120000,
cwd: CWD,
background: false,
stop: "",
},
});

function extractRequestId(reason: string): string {
const match = /grant request ([0-9a-f-]{36})/.exec(reason);
if (match?.[1] === undefined) throw new Error(`no grant id: ${reason}`);
return match[1];
}

describe("gpt-family worker authorized retry identity", () => {
test("approved retry of a `shell` call executes once instead of minting a new grant request", async () => {
// Parent: reactor-gated interactive gate, nothing pre-approved, no auto.
const parentGate = createPermissionGate({
approvals: [],
cwd: CWD,
skipPermissions: false,
interactive: true,
reactorGated: true,
auto: false,
requestApproval: async () => ({ allow: false }),
});
const store = new WorkerGrantStore();
const workerGate = workerPermissionGate(parentGate, {
sessionId: "worker-gpt",
store,
});

// 1. First attempt: reactor authz denies and records envelope A.
const denied = await workerGate.authorizeCall(wireCall("call-1"));
if (denied.effect !== "deny") throw new Error("expected worker deny");
const requestId = extractRequestId(denied.reason);
const envelope = store.peek(requestId);
expect(envelope?.canonicalTool).toBe("run_shell");

// 2. approve_worker_call: parent approved the frozen call (allow-once,
// no session grant) and flipped the envelope to authorized.
expect(store.authorizeRetry(requestId)).toBe(true);

// 3. Retry, stage 1: reactor authz on the raw wire call allows via
// hasAuthorizedRetry (does not consume).
const retryAuthz = await workerGate.authorizeCall(wireCall("call-2"));
expect(retryAuthz).toEqual({ effect: "allow" });
expect(store.peek(requestId)?.status).toBe("authorized");

// 4. Retry, stage 2: DynamicToolRunner coerces the `shell` call onto
// run_shell, then the posix middleware runs gateToolCall.
const dispatched = prepareDispatchedToolCall(
wireCall("call-2"),
"run_shell",
);
expect(dispatched.arguments).not.toEqual(wireCall("x").arguments);

let executed = 0;
const result = await gateToolCall(
workerGate,
dispatched,
new AbortController().signal,
async (call) => {
executed++;
return { callId: call.id, content: "ran" };
},
);

expect(result.isError).not.toBe(true);
expect(executed).toBe(1);
expect(store.peek(requestId)?.status).toBe("consumed");

// One grant, one execution: a replay of the same call fails closed at
// both stages, and no second envelope was minted.
const replay = await gateToolCall(
workerGate,
prepareDispatchedToolCall(wireCall("call-3"), "run_shell"),
new AbortController().signal,
async (call) => {
executed++;
return { callId: call.id, content: "ran" };
},
);
expect(replay.isError).toBe(true);
expect(String(replay.content)).toContain("already consumed");
expect((await workerGate.authorizeCall(wireCall("call-4"))).effect).toBe(
"deny",
);
expect(executed).toBe(1);
});

test("an altered retry (different command) still fails closed", async () => {
const parentGate = createPermissionGate({
approvals: [],
cwd: CWD,
skipPermissions: false,
interactive: true,
reactorGated: true,
auto: false,
requestApproval: async () => ({ allow: false }),
});
const store = new WorkerGrantStore();
const workerGate = workerPermissionGate(parentGate, {
sessionId: "worker-gpt-alt",
store,
});
const denied = await workerGate.authorizeCall(wireCall("call-1"));
if (denied.effect !== "deny") throw new Error("expected worker deny");
const requestId = extractRequestId(denied.reason);
expect(store.authorizeRetry(requestId)).toBe(true);

const altered: ToolCall = {
...wireCall("call-2"),
arguments: { ...wireCall("x").arguments, command: "git push --force" },
};
expect((await workerGate.authorizeCall(altered)).effect).toBe("deny");
const result = await gateToolCall(
workerGate,
prepareDispatchedToolCall(altered, "run_shell"),
new AbortController().signal,
async (call) => ({ callId: call.id, content: "ran" }),
);
expect(result.isError).toBe(true);
expect(store.peek(requestId)?.status).toBe("authorized");
});
});
Loading