Skip to content

Commit eb805d7

Browse files
committed
Re-evaluate the pending approval queue when a grant widens
A scope-widening grant (session, project, or global) now drains any already-queued permission requests it covers instead of leaving them to re-prompt one at a time. Project-scoped grants carry the minting repo's cwd so they only drain requests from that repo; cross-repo requests still prompt. Provider-model grants only drain requests matching the active model, matching evaluate()'s own matching rules.
1 parent b8bfe1d commit eb805d7

8 files changed

Lines changed: 295 additions & 17 deletions

File tree

src/permission/gate.ts

Lines changed: 49 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import 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";
33
import {
44
classifyTool,
55
buildRequests,
@@ -12,7 +12,7 @@ import {
1212
import { autoShellRuleForCall } from "./auto-shell-policy.js";
1313
import { commandReferencesSensitivePath } from "../plugins/secret-guard-plugin.js";
1414
import { runShellAuthzBlockReason } from "../shell/run-shell-authz.js";
15-
import { isApproved, escapeGlobLiteral } from "./matcher.js";
15+
import { isApproved, matchesPattern, escapeGlobLiteral } from "./matcher.js";
1616
import { splitChainedCommand, tokenize, isShellCommentOnly, stripCommentLines } from "./command.js";
1717
import { createPathRestriction } from "./path-restriction.js";
1818
import { 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

122159
export 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
}

src/permission/matcher.ts

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -38,17 +38,21 @@ export function matchesPattern(subject: string, pattern: string): boolean {
3838
// True when any stored approval for this tool matches the subject. The subject
3939
// is the shell command segment (run_shell) or the file path (write/edit). An
4040
// approval bound to a `providerModel` only matches when `activeProviderModel`
41-
// equals it, so a grant scoped to one model never leaks to another.
41+
// equals it, so a grant scoped to one model never leaks to another. An
42+
// approval bound to a `cwd` (project-scoped) only matches when `requestCwd`
43+
// equals it, so a project grant from one repo never leaks into another.
4244
export function isApproved(
4345
tool: string,
4446
subject: string,
4547
approvals: readonly Approval[],
4648
activeProviderModel?: string,
49+
requestCwd?: string,
4750
): boolean {
4851
return approvals.some(
4952
(a) =>
5053
a.tool === tool &&
5154
matchesPattern(subject, a.pattern) &&
52-
(a.providerModel === undefined || a.providerModel === activeProviderModel),
55+
(a.providerModel === undefined || a.providerModel === activeProviderModel) &&
56+
(a.cwd === undefined || a.cwd === requestCwd),
5357
);
5458
}

src/permission/permission.test.ts

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -779,7 +779,7 @@ describe("createPermissionGate", () => {
779779
expect((await gate.evaluate(shellCall("npm test"))).allowed).toBe(true);
780780
expect((await gate.evaluate(shellCall("npm run build"))).allowed).toBe(true);
781781
expect(asked).toBe(1);
782-
expect(persisted).toEqual([{ tool: "run_shell", pattern: "npm *" }]);
782+
expect(persisted).toEqual([{ tool: "run_shell", pattern: "npm *", cwd: process.cwd() }]);
783783
});
784784

785785
test("a declined request blocks the call", async () => {
@@ -1484,7 +1484,7 @@ describe("createPermissionGate", () => {
14841484
// Evaluate same command again — now pre-approved, persist should not fire again.
14851485
await gate.evaluate(shellCall("curl x"));
14861486
expect(persisted).toHaveLength(1);
1487-
expect(persisted[0]).toEqual({ tool: "run_shell", pattern: "curl x" });
1487+
expect(persisted[0]).toEqual({ tool: "run_shell", pattern: "curl x", cwd: process.cwd() });
14881488
});
14891489

14901490
test("persist never fires when pattern is null (one-time approval)", async () => {
@@ -1626,7 +1626,7 @@ describe("createPermissionGate", () => {
16261626
});
16271627
expect((await gate.evaluate(shellCall(full))).allowed).toBe(true);
16281628
expect(asked).toBe(1);
1629-
expect(persisted).toEqual([{ tool: "run_shell", pattern: full }]);
1629+
expect(persisted).toEqual([{ tool: "run_shell", pattern: full, cwd: process.cwd() }]);
16301630
// Same full block is covered by the exact grant.
16311631
expect((await gate.evaluate(shellCall(full))).allowed).toBe(true);
16321632
expect(asked).toBe(1);
@@ -1769,7 +1769,7 @@ describe("scoped grants", () => {
17691769
model: "gpt-5",
17701770
});
17711771
await gate.evaluate(shellCall("npm test"));
1772-
expect(routed[0]).toEqual({ tool: "run_shell", pattern: "npm *" });
1772+
expect(routed[0]).toEqual({ tool: "run_shell", pattern: "npm *", cwd: process.cwd() });
17731773
});
17741774
});
17751775

src/permission/types.ts

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -10,8 +10,11 @@ export type GrantScope = "session" | "project" | "global" | "provider-model";
1010
// A single persistable approval: a tool name plus a glob pattern that, when it
1111
// matches a future call's subject (a shell command or a file path), auto-allows
1212
// it without asking again. `providerModel`, when set, restricts the approval to
13-
// the matching active providerName+model.
14-
export type Approval = { tool: string; pattern: string; providerModel?: string };
13+
// the matching active providerName+model. `cwd`, when set (project-scoped
14+
// grants only), restricts the approval to requests originating from that
15+
// workspace root — a project grant minted in one repo must never auto-allow a
16+
// queued request from a different repo.
17+
export type Approval = { tool: string; pattern: string; providerModel?: string; cwd?: string };
1518

1619
// One option offered to the operator at approval time. `pattern` is the glob
1720
// that gets persisted if the operator picks this scope; `null` means "just this
@@ -37,6 +40,9 @@ export type PermissionRequest = {
3740
subject: string;
3841
arguments?: Record<string, unknown>;
3942
scopes: ApprovalScope[];
43+
// The workspace root this request was raised from. Used to confine
44+
// project-scoped grant reconciliation to the repo the grant was minted in.
45+
cwd?: string;
4046
// A single muted-line explanation shown to the operator when scopes were
4147
// withheld for a reason beyond the ordinary "no persistent option exists
4248
// yet" case (e.g. a mega-chain that only offers accept-once). Plain literal

src/tui/app.tsx

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -459,6 +459,10 @@ export function App({
459459
eventEmitter,
460460
setGatePending: state.setGatePending,
461461
activationBlocked: approvalActivationBlocked,
462+
// Mirrors the permission gate's own activeProviderModel (fixed at gate
463+
// creation from initialProvider/initialModel), so queue reconciliation
464+
// honors provider-model-scoped grants the same way evaluate() does.
465+
activeProviderModel: `${initialProvider}:${initialModel}`,
462466
});
463467

464468
useEffect(() => {

0 commit comments

Comments
 (0)