Skip to content

Commit 384a601

Browse files
committed
Match reactor verdict cache to path-escaped arguments
posix path escape rewrites workspace paths to their realpath before gateToolCall. Comparing cache identity after the same resolve keeps a worker allow from being re-decided as a deny on Darwin tmpdir symlinks, without letting a reused call id inherit allow onto a different tool or command.
1 parent 2bc44f1 commit 384a601

2 files changed

Lines changed: 72 additions & 7 deletions

File tree

src/permission/gate.ts

Lines changed: 30 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -17,11 +17,12 @@ import {
1717
} from "./classify.js";
1818
import { autoShellRuleForCall, safeWorktreeCommand } from "./auto-shell-policy.js";
1919
import { commandReferencesSensitivePath } from "../plugins/secret-guard-plugin.js";
20+
import { looksLikePath } from "../plugins/path-escape-plugin.js";
2021
import { runShellAuthzBlockReason } from "../shell/run-shell-authz.js";
2122
import { matchesPattern, escapeGlobLiteral } from "./matcher.js";
2223
import { evaluateApprovals, grantScopeMatches, type GrantWorkspace } from "./authz-grants.js";
2324
import { splitChainedCommand, isShellCommentOnly, stripCommentLines } from "./command.js";
24-
import { createPathRestriction } from "./path-restriction.js";
25+
import { createPathRestriction, resolveWorkspacePath } from "./path-restriction.js";
2526
import { createWorktreeRootsProvider, type RootsProvider } from "./worktree-roots.js";
2627
import { OPERATOR_DECLINED_PREFIX } from "./decline-markers.js";
2728
import { getSubAgentIdentity } from "../subagent/identity-context.js";
@@ -378,6 +379,25 @@ function canSafelyMintPerSegment(pattern: string): boolean {
378379
return true;
379380
}
380381

382+
// posix pathEscapePlugin rewrites path-like args to resolveWorkspacePath before
383+
// gateToolCall. Cache identity must use that same resolution so an authorizeCall
384+
// allow is not treated as a different call (and re-decided) at execution.
385+
function identityArguments(
386+
args: ToolCall["arguments"],
387+
cwd: string,
388+
rootsProvider: RootsProvider,
389+
): string {
390+
const normalized: Record<string, unknown> = {};
391+
for (const [key, value] of Object.entries(args)) {
392+
if (typeof value === "string" && looksLikePath(key)) {
393+
normalized[key] = resolveWorkspacePath(cwd, value, rootsProvider) ?? value;
394+
} else {
395+
normalized[key] = value;
396+
}
397+
}
398+
return JSON.stringify(normalized);
399+
}
400+
381401
export function createPermissionGate(options: PermissionGateOptions): PermissionGate {
382402
const { requestApproval, persist, interactive, providerName, model, cwd } = options;
383403
const reactorGated = options.reactorGated;
@@ -496,11 +516,13 @@ export function createPermissionGate(options: PermissionGateOptions): Permission
496516
// inner posix op, so a lasting set would mute later JSONL records. A hit
497517
// still requires matching name and arguments so a reused id cannot apply an
498518
// outer allow to a different inner tool. Nested posix with the same id still
499-
// consume-once when identity matches. reset() clears leftovers (outer tools
500-
// that never hit posix middleware).
519+
// consume-once when identity matches. Path-like arguments are compared after
520+
// the same workspace resolve pathEscapePlugin applies, so a Darwin
521+
// /var/folders vs /private/var/folders rewrite is still the same call.
522+
// reset() clears leftovers (outer tools that never hit posix middleware).
501523
const authorizedByCallId = new Map<
502524
string,
503-
{ name: string; arguments: ToolCall["arguments"]; verdict: AuthorizeVerdict }
525+
{ name: string; arguments: string; verdict: AuthorizeVerdict }
504526
>();
505527

506528
// Non-blocking policy decision for one tool call: everything the gate owns —
@@ -775,20 +797,22 @@ export function createPermissionGate(options: PermissionGateOptions): Permission
775797

776798
const authorizeCall = async (call: ToolCall): Promise<AuthorizeVerdict> => {
777799
const verdict = mapAuthorizeVerdict(await decide(call));
800+
const identityCwd = getSubAgentIdentity()?.cwd ?? resolvedCwd;
778801
authorizedByCallId.set(call.id, {
779802
name: call.name,
780-
arguments: call.arguments,
803+
arguments: identityArguments(call.arguments, identityCwd, rootsProvider),
781804
verdict,
782805
});
783806
return verdict;
784807
};
785808

786809
const executionVerdict = async (call: ToolCall): Promise<AuthorizeVerdict> => {
787810
const cached = authorizedByCallId.get(call.id);
811+
const identityCwd = getSubAgentIdentity()?.cwd ?? resolvedCwd;
788812
if (
789813
cached !== undefined &&
790814
cached.name === call.name &&
791-
JSON.stringify(cached.arguments) === JSON.stringify(call.arguments)
815+
cached.arguments === identityArguments(call.arguments, identityCwd, rootsProvider)
792816
) {
793817
authorizedByCallId.delete(call.id);
794818
return cached.verdict;

src/permission/reactor-authorize.test.ts

Lines changed: 42 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import { expect, test } from "bun:test";
2-
import { mkdtempSync } from "node:fs";
2+
import { mkdtempSync, realpathSync } from "node:fs";
33
import { tmpdir } from "node:os";
44
import { join } from "node:path";
55
import { createPermissionGate } from "./gate.js";
@@ -73,6 +73,47 @@ test("worker grant after a denied write allows a later call id through gateToolC
7373
expect(called).toBe(true);
7474
});
7575

76+
test("worker grant survives pathEscape realpath rewrite through gateToolCall", async () => {
77+
const cwd = mkdtempSync(join(tmpdir(), "worker-grant-realpath-"));
78+
const lexical = join(cwd, "probe.txt");
79+
const escaped = join(realpathSync(cwd), "probe.txt");
80+
const policy = createPermissionGate({
81+
cwd,
82+
approvals: [{ tool: "write_file", pattern: lexical }],
83+
interactive: false,
84+
auto: false,
85+
skipPermissions: false,
86+
reactorGated: true,
87+
requestApproval: async () => {
88+
throw new Error("worker must never ask");
89+
},
90+
});
91+
const workerGate = workerPermissionGate(policy);
92+
const authorized: ToolCall = {
93+
id: "call_auto_0",
94+
name: "write_file",
95+
arguments: { path: lexical, content: "unauthorized" },
96+
};
97+
const executed: ToolCall = {
98+
id: "call_auto_0",
99+
name: "write_file",
100+
arguments: { path: escaped, content: "unauthorized" },
101+
};
102+
expect((await workerGate.authorizeCall(authorized)).effect).toBe("allow");
103+
let called = false;
104+
const result = await gateToolCall(
105+
workerGate,
106+
executed,
107+
new AbortController().signal,
108+
async () => {
109+
called = true;
110+
return { callId: executed.id, content: "executed", isError: false };
111+
},
112+
);
113+
expect(result.isError).toBe(false);
114+
expect(called).toBe(true);
115+
});
116+
76117
test("worker maps unresolved ask to deny while main reactor suspends", async () => {
77118
const policy = gate();
78119
expect((await createReactorAuthorize(policy)("tool:write_file", "invoke", call)).effect).toBe(

0 commit comments

Comments
 (0)