@@ -17,11 +17,12 @@ import {
1717} from "./classify.js" ;
1818import { autoShellRuleForCall , safeWorktreeCommand } from "./auto-shell-policy.js" ;
1919import { commandReferencesSensitivePath } from "../plugins/secret-guard-plugin.js" ;
20+ import { looksLikePath } from "../plugins/path-escape-plugin.js" ;
2021import { runShellAuthzBlockReason } from "../shell/run-shell-authz.js" ;
2122import { matchesPattern , escapeGlobLiteral } from "./matcher.js" ;
2223import { evaluateApprovals , grantScopeMatches , type GrantWorkspace } from "./authz-grants.js" ;
2324import { splitChainedCommand , isShellCommentOnly , stripCommentLines } from "./command.js" ;
24- import { createPathRestriction } from "./path-restriction.js" ;
25+ import { createPathRestriction , resolveWorkspacePath } from "./path-restriction.js" ;
2526import { createWorktreeRootsProvider , type RootsProvider } from "./worktree-roots.js" ;
2627import { OPERATOR_DECLINED_PREFIX } from "./decline-markers.js" ;
2728import { 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+
381401export 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 ;
0 commit comments