11import type { ToolCall } from "@intx/types/runtime" ;
2- import type { Approval , ApprovalOutcome , GrantScope , RequestApproval } from "./types.js" ;
2+ import type { Approval , ApprovalOutcome , GrantScope , PermissionRequest , RequestApproval } from "./types.js" ;
33import {
44 classifyTool ,
55 buildRequests ,
@@ -12,7 +12,7 @@ import {
1212import { autoShellRuleForCall } from "./auto-shell-policy.js" ;
1313import { commandReferencesSensitivePath } from "../plugins/secret-guard-plugin.js" ;
1414import { runShellAuthzBlockReason } from "../shell/run-shell-authz.js" ;
15- import { isApproved , escapeGlobLiteral } from "./matcher.js" ;
15+ import { isApproved , matchesPattern , escapeGlobLiteral } from "./matcher.js" ;
1616import { splitChainedCommand , tokenize , isShellCommentOnly , stripCommentLines } from "./command.js" ;
1717import { createPathRestriction } from "./path-restriction.js" ;
1818import { createWorktreeRootsProvider , type RootsProvider } from "./worktrees.js" ;
@@ -45,6 +45,7 @@ function hasExactFullCommandGrant(
4545 fullCommand : string ,
4646 approvals : readonly Approval [ ] ,
4747 activeProviderModel : string | undefined ,
48+ requestCwd : string | undefined ,
4849) : boolean {
4950 // Comment-insensitive: a model-authored "# why" line prepended to an
5051 // otherwise-identical command must still replay against a grant minted
@@ -55,10 +56,40 @@ function hasExactFullCommandGrant(
5556 ( a ) =>
5657 a . tool === tool &&
5758 a . pattern === normalized &&
58- ( a . providerModel === undefined || a . providerModel === activeProviderModel ) ,
59+ ( a . providerModel === undefined || a . providerModel === activeProviderModel ) &&
60+ ( a . cwd === undefined || a . cwd === requestCwd ) ,
5961 ) ;
6062}
6163
64+ // Pure reconciliation check used to re-evaluate the TUI's pending approval
65+ // queue against a single newly-minted grant (see PermissionGateOptions.onGrant).
66+ // A queued request is covered only when this one grant, by itself, would have
67+ // let it skip the prompt — mirrors the matching evaluate() itself applies, so
68+ // reconciliation never auto-approves something evaluate() would still ask for.
69+ export function isRequestCoveredByGrant (
70+ request : PermissionRequest ,
71+ approval : Approval ,
72+ activeProviderModel : string | undefined ,
73+ ) : boolean {
74+ if ( request . tool !== approval . tool ) return false ;
75+ if ( approval . cwd !== undefined && approval . cwd !== request . cwd ) return false ;
76+ if (
77+ approval . providerModel !== undefined &&
78+ approval . providerModel !== activeProviderModel
79+ ) {
80+ return false ;
81+ }
82+ if ( request . tool !== "run_shell" ) {
83+ return matchesPattern ( request . subject , approval . pattern ) ;
84+ }
85+ const segments = splitChainedCommand ( request . subject ) . filter ( ( s ) => ! isShellCommentOnly ( s ) ) ;
86+ if ( segments . length === 0 ) return false ;
87+ if ( segments . length > 1 ) {
88+ return approval . pattern === stripCommentLines ( request . subject ) . trim ( ) ;
89+ }
90+ return matchesPattern ( segments [ 0 ] ! , approval . pattern ) ;
91+ }
92+
6293// In auto mode these non-shell built-in tools auto-allow without an operator
6394// prompt: file mutations plus the benign built-ins that a hands-off run should
6495// not stop for. Reads auto-allow via their own path and run_shell via the shell
@@ -117,6 +148,12 @@ export type PermissionGateOptions = {
117148 // Tiers learned from connected MCP servers (tools/list annotations). Tests may
118149 // inject a shared registry; production gates create one when omitted.
119150 mcpTiers ?: McpToolPermissionRegistry ;
151+ // Fires synchronously right after a grant is minted (in-memory list already
152+ // updated), before evaluate() moves on to the next request. Callers use this
153+ // to re-evaluate any requests already queued behind the one just answered —
154+ // see isRequestCoveredByGrant — so a scope-widening grant drains the rest of
155+ // the queue instead of re-prompting for coverage it already grants.
156+ onGrant ?: ( approval : Approval ) => void ;
120157} ;
121158
122159export type PermissionGate = {
@@ -185,13 +222,16 @@ export function createPermissionGate(options: PermissionGateOptions): Permission
185222 const approval : Approval =
186223 grant === "provider-model" && activeProviderModel !== undefined
187224 ? { tool, pattern, providerModel : activeProviderModel }
188- : { tool, pattern } ;
225+ : grant === "project"
226+ ? { tool, pattern, cwd : resolvedCwd }
227+ : { tool, pattern } ;
189228 approvals . push ( approval ) ;
190229 if ( grant === "session" ) {
191230 sessionGrants . push ( approval ) ;
192231 } else {
193232 persist ?.( approval , grant ) ;
194233 }
234+ options . onGrant ?.( approval ) ;
195235 } ;
196236
197237 const evaluate = async ( call : ToolCall ) : Promise < GateVerdict > => {
@@ -233,7 +273,8 @@ export function createPermissionGate(options: PermissionGateOptions): Permission
233273 // blanket-allowed; fall through to the operator prompt below.
234274 }
235275
236- for ( const request of buildRequests ( call ) ) {
276+ for ( const rawRequest of buildRequests ( call ) ) {
277+ const request : typeof rawRequest = { ...rawRequest , cwd : resolvedCwd } ;
237278 // Shell: security still splits the chain, but the operator sees (and
238279 // accepts/rejects) the full command once. Any unapproved segment fails the
239280 // whole block. Execution always runs the full string the model asked for.
@@ -256,7 +297,7 @@ export function createPermissionGate(options: PermissionGateOptions): Permission
256297 ! fullReferencesSecret &&
257298 ! commandTargetsRestricted ( fullCommand , isRestricted ) &&
258299 segments . length > 1 &&
259- hasExactFullCommandGrant ( request . tool , fullCommand , approvals , activeProviderModel )
300+ hasExactFullCommandGrant ( request . tool , fullCommand , approvals , activeProviderModel , resolvedCwd )
260301 ) {
261302 continue ;
262303 }
@@ -290,7 +331,7 @@ export function createPermissionGate(options: PermissionGateOptions): Permission
290331 needsOperator = true ;
291332 continue ;
292333 }
293- if ( isApproved ( request . tool , segment , approvals , activeProviderModel ) ) {
334+ if ( isApproved ( request . tool , segment , approvals , activeProviderModel , resolvedCwd ) ) {
294335 continue ;
295336 }
296337 // Safe pipeline tails (`| sort`) and pure no-ops (`|| true`) skip.
@@ -338,7 +379,7 @@ export function createPermissionGate(options: PermissionGateOptions): Permission
338379 const alreadyApproved =
339380 // Path-arg tools already drop to ask via callTargetsRestricted; grants
340381 // match on the path subject the same as before.
341- isApproved ( request . tool , request . subject , approvals , activeProviderModel ) ;
382+ isApproved ( request . tool , request . subject , approvals , activeProviderModel , resolvedCwd ) ;
342383 if ( alreadyApproved ) {
343384 continue ;
344385 }
0 commit comments