From f2bf4b7af94dee7ae4994570d53aa746801d1b9f Mon Sep 17 00:00:00 2001 From: Sawyer Cutler Date: Mon, 24 Aug 2026 15:29:43 -0700 Subject: [PATCH 1/3] Mint per-segment grants for multi-step shell chains Chains of 5+ segments used to be accept-once only with no persisted grant, so the same long chain re-prompted every time. Approving a multi-segment chain now mints one grant per real segment, so approving a && b also covers b alone later, and long chains behave like short ones. --- CHANGELOG.md | 16 ++ scripts/approval-forensics.ts | 13 +- src/permission/authz-grants.ts | 6 +- src/permission/classify.ts | 31 ++- src/permission/gate.ts | 184 ++++++++--------- src/permission/grant-scope.test.ts | 189 ++++++++++++++++-- src/permission/permission.test.ts | 140 +++++++++---- src/permission/types.ts | 3 +- .../cross-commit-composition.test.ts | 14 +- 9 files changed, 405 insertions(+), 191 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 5c0d515ed..905d94d3f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -24,6 +24,22 @@ parallel copies under `docs/` or `scripts/notes/`. At cut time: rename - One-shot confirmation flashes (copy, mouse toggle, attach results, reasoning effort, stall recovery) now clear themselves after a short TTL. Rate-limit waits no longer park on the bottom notice row; the durable error stays in the transcript. Live stall notice and landing hold still omit a TTL so they stay until replaced. - A TTL flash no longer paints chrome after the TUI renderer is destroyed, which crashed parallel TUI tests with `TextBuffer is destroyed`. +### Security + +- **Shell chain approvals no longer skip minting once a chain gets long.** + Chains of 5+ segments used to be accept-once only — no grant was ever + persisted, so the same long chain re-prompted every single time no matter + what had already been approved. Approving a multi-segment chain now mints + one grant per real segment instead of one grant for the whole string, so + approving `a && b` also covers `b` on its own later, and long chains behave + the same as short ones. This is a real change in what a single approval + buys: granting per segment is strictly more permissive on later commands + than granting one exact whole-string match was, since a segment now reuses + outside the chain it was first approved in. Nothing that previously + auto-approved now prompts, and nothing that previously required a fresh + decision now silently skips one — chains still ask for any segment that + isn't already granted. + ## [0.3.1] - 2026-08-24 ### Fixed diff --git a/scripts/approval-forensics.ts b/scripts/approval-forensics.ts index d9b3c7e65..bd9575c75 100644 --- a/scripts/approval-forensics.ts +++ b/scripts/approval-forensics.ts @@ -3,10 +3,9 @@ // before approval volume could be measured at all. // // Reports: total asks, split by mode (auto vs interactive) and outcome, a -// per-rule breakdown, settle-duration and display-delay percentiles (the +// per-rule breakdown, and settle-duration and display-delay percentiles (the // display delay is the CL-5664 signal — a queued gate arming its timeout -// before the operator could see it), and a mega-chain count (segments >= -// MEGA_CHAIN_SEGMENT_THRESHOLD). +// before the operator could see it). // // Prints only aggregate counts and timings, never a tool subject or command // text — the log itself never records either, so there is nothing to leak @@ -19,7 +18,6 @@ import { join } from "node:path"; import { homedir } from "node:os"; import { APPROVAL_LOG_FILE, type ApprovalRecord } from "../src/permission/approval-log.js"; -import { MEGA_CHAIN_SEGMENT_THRESHOLD } from "../src/permission/classify.js"; // lstat, and skip symlinks: session dirs carry a `latest` symlink to a real // session, and following it double-counts every record in that session. @@ -56,7 +54,6 @@ interface Bucket { byMode: Map; durations: number[]; displayDelays: number[]; - megaChains: number; } function emptyBucket(): Bucket { @@ -66,7 +63,6 @@ function emptyBucket(): Bucket { byMode: new Map(), durations: [], displayDelays: [], - megaChains: 0, }; } @@ -111,7 +107,6 @@ for (const file of files) { bucket.byMode.set(record.mode, (bucket.byMode.get(record.mode) ?? 0) + 1); if (typeof record.durationMs === "number") bucket.durations.push(record.durationMs); if (typeof record.displayDelayMs === "number") bucket.displayDelays.push(record.displayDelayMs); - if ((record.segments ?? 0) >= MEGA_CHAIN_SEGMENT_THRESHOLD) bucket.megaChains++; // Duplicate-rate proxy: how often the same rule fires more than once per // session file (a session repeatedly asking for something it was already @@ -133,7 +128,7 @@ if (records === 0) { const rows = [...buckets.entries()].sort((a, b) => b[1].count - a[1].count); console.log( - "\ntool n auto/interactive duration p50/p90/max displayDelay p50/p90/max megaChains", + "\ntool n auto/interactive duration p50/p90/max displayDelay p50/p90/max", ); for (const [key, bucket] of rows) { const durations = [...bucket.durations].sort((a, b) => a - b); @@ -149,7 +144,7 @@ for (const [key, bucket] of rows) { const autoCount = bucket.byMode.get("auto") ?? 0; const interactiveCount = bucket.byMode.get("interactive") ?? 0; console.log( - `${key.padEnd(26)} ${String(bucket.count).padStart(3)} ${String(autoCount).padStart(4)}/${String(interactiveCount).padEnd(11)} ${durDist.padEnd(24)} ${delayDist.padEnd(24)} ${bucket.megaChains}`, + `${key.padEnd(26)} ${String(bucket.count).padStart(3)} ${String(autoCount).padStart(4)}/${String(interactiveCount).padEnd(11)} ${durDist.padEnd(24)} ${delayDist}`, ); } diff --git a/src/permission/authz-grants.ts b/src/permission/authz-grants.ts index a1e2f2e99..b7b0b07c4 100644 --- a/src/permission/authz-grants.ts +++ b/src/permission/authz-grants.ts @@ -67,9 +67,9 @@ export function cwdMatchesGrant( // The single place that decides whether a grant's tool/providerModel/cwd // scope covers a request, independent of whether the grant's pattern matches // the request's subject. Every live call site that needs to know "does this -// grant cover this request's scope" — evaluateApprovals, isRequestCoveredByGrant, -// hasExactFullCommandGrant — delegates here so a scoping-dimension change -// never has to be made in more than one place. +// grant cover this request's scope" — evaluateApprovals, isRequestCoveredByGrant — +// delegates here so a scoping-dimension change never has to be made in more +// than one place. export function grantScopeMatches( approval: Approval, tool: string, diff --git a/src/permission/classify.ts b/src/permission/classify.ts index c2976bc76..ac0b01c9c 100644 --- a/src/permission/classify.ts +++ b/src/permission/classify.ts @@ -440,17 +440,6 @@ function stringArg(call: ToolCall, key: string): string { return typeof value === "string" ? value : ""; } -// A shell chain at or above this many top-level segments gets accept-once-only -// approval: no scope is offered or minted, however broad or exact. A grant -// this coarse would let one operator decision silently cover an unbounded, -// ever-changing family of commands as the model keeps appending segments; -// forcing a fresh decision every time keeps mega-chains reviewable instead of -// rubber-stamped once and replayed forever. Below the threshold, the existing -// exact-only multi-segment rule (and single-segment ladder) is unchanged. -export const MEGA_CHAIN_SEGMENT_THRESHOLD = 5; - -export const MEGA_CHAIN_NOTICE = `Chains of ${MEGA_CHAIN_SEGMENT_THRESHOLD}+ steps are approved once only — split into shorter commands for reusable approvals.`; - // The real (non-comment-only) chain segments of a shell command — the basis // both shellApprovalScopes and isSingleShellCommand use to answer "is this // one command or a chain." @@ -469,19 +458,26 @@ export function isSingleShellCommand(command: string): boolean { } // Approval scopes for a shell command the operator may persist. Multi-segment -// chains only offer the exact full string — a prefix like `npm *` would also -// match `npm i && rm -rf /` on a later call (fail-closed). At or above -// MEGA_CHAIN_SEGMENT_THRESHOLD, no scope is offered at all — see the constant. +// chains only offer the full chain string as the persist payload — a prefix +// like `npm *` would also match `npm i && rm -rf /` on a later call +// (fail-closed). Minting decomposes that payload into one grant per real +// segment (see mintGrant in gate.ts), so the label names the actual effect: +// each step becomes its own reusable approval. function shellApprovalScopes(command: string): ApprovalScope[] { const segments = realShellSegments(command); if (segments.length === 0) return []; - if (segments.length >= MEGA_CHAIN_SEGMENT_THRESHOLD) return []; if (segments.length === 1) { const only = segments[0]; if (only === undefined) return []; return deriveCommandScopes(only); } - return [{ id: "exact", label: "Always allow this exact command", pattern: command.trim() }]; + return [ + { + id: "exact", + label: "Always allow each command in this chain", + pattern: command.trim(), + }, + ]; } // Decompose an "ask"-tier tool call into the approval request(s) the operator @@ -502,9 +498,6 @@ export function buildRequests(call: ToolCall): PermissionRequest[] { subject: command, arguments: { command }, scopes: shellApprovalScopes(command), - ...(realSegments.length >= MEGA_CHAIN_SEGMENT_THRESHOLD - ? { notice: MEGA_CHAIN_NOTICE } - : {}), }, ]; } diff --git a/src/permission/gate.ts b/src/permission/gate.ts index b14f05967..eeba192d0 100644 --- a/src/permission/gate.ts +++ b/src/permission/gate.ts @@ -15,7 +15,6 @@ import { isSingleShellCommand, callTargetsRestricted, commandTargetsRestricted, - MEGA_CHAIN_SEGMENT_THRESHOLD, } from "./classify.js"; import { autoShellRuleForCall, safeWorktreeCommand } from "./auto-shell-policy.js"; import { commandReferencesSensitivePath } from "../plugins/secret-guard-plugin.js"; @@ -75,30 +74,6 @@ function finishApprovalWait( export type GateVerdict = { allowed: true } | { allowed: false; reason: string }; -// Multi-segment shell may only short-circuit on an exact full-command grant. -// Prefix globs like `npm *` must not match `npm i && curl x` — the unapproved -// tail still needs a full-block operator decision. String equality (not glob) -// keeps exact multi-segment reuse without reopening that hole. -function hasExactFullCommandGrant( - tool: string, - fullCommand: string, - approvals: readonly Approval[], - activeProviderModel: string | undefined, - requestCwd: string | undefined, - workspace: GrantWorkspace, -): boolean { - // Comment-insensitive: a model-authored "# why" line prepended to an - // otherwise-identical command must still replay against a grant minted - // for that command (see mintGrant, which normalizes the same way before - // storing a run_shell pattern). - const normalized = stripCommentLines(fullCommand).trim(); - return approvals.some( - (a) => - a.pattern === normalized && - grantScopeMatches(a, tool, activeProviderModel, requestCwd, workspace), - ); -} - // One shell segment's forced-ask guard: a secret-path reference or a // restricted target, either of which forces an operator decision no matter // what a grant would otherwise cover. Shared by evaluate() (which also needs @@ -189,12 +164,17 @@ export function preGrantGuardReason( } // Pure reconciliation check used to re-evaluate the TUI's pending approval -// queue against a single newly-minted grant (see PermissionGateOptions.onGrant). -// A queued request is covered only when this one grant, by itself, would have -// let it skip the prompt AND the request clears preGrantGuardReason — the same -// guard sequence evaluate() enforces ahead of grant matching — so -// reconciliation never auto-approves something evaluate() would still ask for -// or hard-deny. +// queue against newly-minted grant(s) (see PermissionGateOptions.onGrant). +// A queued request is covered only when the supplied approval(s) would let +// evaluate() skip the prompt — same per-segment matching and the same +// preGrantGuardReason sequence — so reconciliation never auto-approves +// something evaluate() would still ask for or hard-deny. +// +// For run_shell, coverage is per-segment: every real segment must match some +// supplied approval (or be an auto-allowed no-op/safe tail). A legacy +// whole-string chain pattern (e.g. `"a && b"`) is not special-cased and does +// not cover — minting decomposes chains into per-segment grants, and evaluate() +// likewise matches per segment only. export function isRequestCoveredByGrant( request: PermissionRequest, approval: Approval, @@ -203,18 +183,43 @@ export function isRequestCoveredByGrant( workspace: GrantWorkspace, rootsProvider?: RootsProvider, ): boolean { - if (!grantScopeMatches(approval, request.tool, activeProviderModel, request.cwd, workspace)) - return false; + return isRequestCoveredByApprovals( + request, + [approval], + activeProviderModel, + isRestricted, + workspace, + rootsProvider, + ); +} + +// Same coverage predicate as isRequestCoveredByGrant, but against a live +// approvals list. mintGrant hands this to onGrant so that after per-segment +// minting of `a && b`, the second onGrant sees both `a` and `b` already in the +// list and can drain a queued identical chain. +function isRequestCoveredByApprovals( + request: PermissionRequest, + approvals: readonly Approval[], + activeProviderModel: string | undefined, + isRestricted: (path: string, isWrite: boolean) => boolean, + workspace: GrantWorkspace, + rootsProvider?: RootsProvider, +): boolean { + const scoped = approvals.filter((a) => + grantScopeMatches(a, request.tool, activeProviderModel, request.cwd, workspace), + ); + if (scoped.length === 0) return false; if (request.tool !== "run_shell") { - return matchesPattern(request.subject, approval.pattern); + return scoped.some((a) => matchesPattern(request.subject, a.pattern)); } if (preGrantGuardReason(request, isRestricted, rootsProvider) !== undefined) return false; const segments = splitChainedCommand(request.subject).filter((s) => !isShellCommentOnly(s)); if (segments.length === 0) return false; - if (segments.length > 1) { - return approval.pattern === stripCommentLines(request.subject).trim(); - } - return matchesPattern(segments[0]!, approval.pattern); + const cwd = request.cwd ?? workspace.resolvedCwd; + return segments.every((segment) => { + if (scoped.some((a) => matchesPattern(segment, a.pattern))) return true; + return isAutoAllowedShellSegment(segment, cwd, rootsProvider); + }); } // In auto mode these non-shell built-in tools auto-allow without an operator @@ -367,36 +372,48 @@ export function createPermissionGate(options: PermissionGateOptions): Permission if (!outcome.persist || outcome.persist.pattern === null) return; const grant: GrantScope = outcome.persist.grant ?? "session"; // A run_shell pattern may still carry a model-authored comment line (the - // multi-segment "exact full command" scope persists the command - // verbatim). Strip it here, at the single place a grant comes into - // existence, so every stored run_shell pattern is already in the same - // normalized space hasExactFullCommandGrant matches against. - const pattern = + // multi-segment chain scope persists the command verbatim as its payload). + // Strip it here, at the single place a grant comes into existence, so every + // stored run_shell pattern is already in the same normalized space grant + // matching works against. + // + // Decompose the chain payload into one Approval per real segment (reusing + // the same quote-aware splitter the gate's own evaluation loop uses) so + // approving `a && b` grants `a` and `b` individually — reusable on their + // own. That is broader than a whole-string grant was (a segment can replay + // outside the original chain); the CHANGELOG Security note documents the + // tradeoff. onGrant's covers predicate consults the live approvals list so + // a queued identical chain drains only once every segment has been minted. + const patterns = tool === "run_shell" - ? stripCommentLines(outcome.persist.pattern).trim() - : outcome.persist.pattern; - const approval: Approval = - grant === "provider-model" && activeProviderModel !== undefined - ? { tool, pattern, providerModel: activeProviderModel } - : grant === "project" - ? { tool, pattern, cwd: resolvedCwd } - : { tool, pattern }; - approvals.push(approval); - if (grant === "session") { - sessionGrants.push(approval); - } else { - persist?.(approval, grant); + ? splitChainedCommand(stripCommentLines(outcome.persist.pattern).trim()) + .filter((segment) => !isShellCommentOnly(segment)) + .map((segment) => segment.trim()) + : [outcome.persist.pattern]; + for (const pattern of patterns) { + const approval: Approval = + grant === "provider-model" && activeProviderModel !== undefined + ? { tool, pattern, providerModel: activeProviderModel } + : grant === "project" + ? { tool, pattern, cwd: resolvedCwd } + : { tool, pattern }; + approvals.push(approval); + if (grant === "session") { + sessionGrants.push(approval); + } else { + persist?.(approval, grant); + } + options.onGrant?.(approval, (request) => + isRequestCoveredByApprovals( + request, + approvals, + activeProviderModel, + isRestricted, + grantWorkspace(), + rootsProvider, + ), + ); } - options.onGrant?.(approval, (request) => - isRequestCoveredByGrant( - request, - approval, - activeProviderModel, - isRestricted, - grantWorkspace(), - rootsProvider, - ), - ); }; // An auto-mode (or non-interactive-unavailable) decision settles the @@ -510,30 +527,6 @@ export function createPermissionGate(options: PermissionGateOptions): Permission return { allowed: false, reason: blockReason }; } - const fullReferencesSecret = commandReferencesSensitivePath(fullCommand) !== undefined; - // Multi-segment: only an exact stored pattern for the full command may - // short-circuit. Never glob-match the unsplit string — a grant like - // `npm *` would otherwise swallow `npm i && curl evil`. Single-segment - // grants are applied per segment in the loop below. A restricted target - // always requires a fresh operator decision, so no grant — however it - // matched — ever replays for a restricted command; see the per-segment - // restriction check below for the same rule applied within a chain. - if ( - !fullReferencesSecret && - !commandTargetsRestricted(fullCommand, isRestrictedHere) && - segments.length > 1 && - hasExactFullCommandGrant( - request.tool, - fullCommand, - approvals, - activeProviderModel, - effectiveCwd, - grantWorkspace(), - ) - ) { - continue; - } - let needsOperator = false; let anySecret = false; for (const segment of segments) { @@ -570,14 +563,7 @@ export function createPermissionGate(options: PermissionGateOptions): Permission } if (!needsOperator) continue; - // A mega-chain (see MEGA_CHAIN_SEGMENT_THRESHOLD) is accept-once only: - // no scope is offered for it (buildRequests already returns none), and - // this check is the belt to that suspenders — the gate itself refuses - // to mint a grant for one even if a persist scope somehow arrived. - // Computed ahead of the non-interactive branch too, so both settle - // paths tag the same ask with the same rule. - const isMegaChain = segments.length >= MEGA_CHAIN_SEGMENT_THRESHOLD; - const askRule = anySecret ? "sensitive-path" : isMegaChain ? "mega-chain" : undefined; + const askRule = anySecret ? "sensitive-path" : undefined; if (!interactive || requestApproval === undefined) { recordAutoDecision(request.tool, askRule ?? "non-interactive", "deny"); @@ -621,7 +607,7 @@ export function createPermissionGate(options: PermissionGateOptions): Permission reason: `Operator declined: ${request.action} (${request.subject})${suffix}`, }; } - if (!anySecret && !isMegaChain) { + if (!anySecret) { mintGrant(request.tool, outcome); } continue; diff --git a/src/permission/grant-scope.test.ts b/src/permission/grant-scope.test.ts index 6d295abf3..e8ed46bfb 100644 --- a/src/permission/grant-scope.test.ts +++ b/src/permission/grant-scope.test.ts @@ -3,6 +3,8 @@ import type { ToolCall } from "@intx/types/runtime"; import type { Approval, PermissionRequest } from "./types.js"; import { evaluateApprovals, grantScopeMatches, type GrantWorkspace } from "./authz-grants.js"; import { createPermissionGate, isRequestCoveredByGrant } from "./gate.js"; +import { createPermissionRequestQueue } from "./queue.js"; +import { buildRequests } from "./classify.js"; // evaluateApprovals and isRequestCoveredByGrant each decide, independently, // whether a grant's tool/providerModel/cwd scope covers a request. Both are @@ -81,13 +83,12 @@ describe("grant tool/providerModel/cwd scoping agrees across call sites", () => } }); -// hasExactFullCommandGrant (gate.ts) is the third live call site grantScopeMatches -// unifies, but it is not exported — it only surfaces through the exact-full-command -// replay path inside evaluate(). This drives that path directly with grants that -// grantScopeMatches would refuse (wrong cwd, wrong providerModel) to confirm the -// replay never fires when the shared predicate says no, matching the coverage the -// other two call sites get above. -describe("hasExactFullCommandGrant agrees with grantScopeMatches", () => { +// Grant minting decomposes a multi-segment chain scope into one grant per +// real segment (see mintGrant in gate.ts). A grant whose pattern is the full +// chain string is legacy shape: evaluate() and isRequestCoveredByGrant both +// match per segment only, so that shape never replays. Per-segment grants are +// the live path (see permission.test.ts). +describe("a scope-mismatched grant never replays a multi-segment chain", () => { const full = "npm i && curl x"; const shellCall = (command: string): ToolCall => ({ id: "c", @@ -98,7 +99,7 @@ describe("hasExactFullCommandGrant agrees with grantScopeMatches", () => { test("does not replay a grant scoped to a different cwd", async () => { let asked = 0; const gate = createPermissionGate({ - approvals: [{ tool: "run_shell", pattern: full, cwd: "/other-project" }], + approvals: [{ tool: "run_shell", pattern: "npm i", cwd: "/other-project" }], requestApproval: async () => { asked++; return { allow: true }; @@ -107,15 +108,13 @@ describe("hasExactFullCommandGrant agrees with grantScopeMatches", () => { skipPermissions: false, }); expect((await gate.evaluate(shellCall(full))).allowed).toBe(true); - // grantScopeMatches would refuse this grant (cwd mismatch), so the - // exact-full-command shortcut must not fire — the operator is still asked. expect(asked).toBeGreaterThan(0); }); test("does not replay a grant scoped to a different provider model", async () => { let asked = 0; const gate = createPermissionGate({ - approvals: [{ tool: "run_shell", pattern: full, providerModel: "openai:gpt-5" }], + approvals: [{ tool: "run_shell", pattern: "npm i", providerModel: "openai:gpt-5" }], providerName: "anthropic", model: "opus", requestApproval: async () => { @@ -129,10 +128,13 @@ describe("hasExactFullCommandGrant agrees with grantScopeMatches", () => { expect(asked).toBeGreaterThan(0); }); - test("replays a grant whose scope grantScopeMatches accepts", async () => { + test("replays per-segment grants whose scope grantScopeMatches accepts", async () => { let asked = 0; const gate = createPermissionGate({ - approvals: [{ tool: "run_shell", pattern: full }], + approvals: [ + { tool: "run_shell", pattern: "npm i" }, + { tool: "run_shell", pattern: "curl x" }, + ], requestApproval: async () => { asked++; return { allow: true }; @@ -144,3 +146,164 @@ describe("hasExactFullCommandGrant agrees with grantScopeMatches", () => { expect(asked).toBe(0); }); }); + +// Explicit rejection of the pre-CL-5752 whole-string chain grant shape: both +// evaluate() and isRequestCoveredByGrant match per segment only, so a stored +// pattern equal to the full chain never short-circuits. Dual paths stay +// aligned — neither honors legacy while the other rejects it. +describe("legacy whole-string chain grants are explicitly rejected", () => { + const full = "npm i && curl x"; + const shellCall = (command: string): ToolCall => ({ + id: "c", + name: "run_shell", + arguments: { command }, + }); + const workspace: GrantWorkspace = { resolvedCwd: "/proj", roots: ["/proj"] }; + const noopRestricted = () => false; + + test("evaluate() re-prompts for a legacy full-chain pattern", async () => { + let asked = 0; + const gate = createPermissionGate({ + approvals: [{ tool: "run_shell", pattern: full }], + requestApproval: async () => { + asked++; + return { allow: true }; + }, + interactive: true, + skipPermissions: false, + }); + expect((await gate.evaluate(shellCall(full))).allowed).toBe(true); + expect(asked).toBe(1); + }); + + test("isRequestCoveredByGrant refuses a legacy full-chain pattern", () => { + const request: PermissionRequest = { + tool: "run_shell", + action: "Run", + subject: full, + scopes: [], + cwd: "/proj", + }; + expect( + isRequestCoveredByGrant( + request, + { tool: "run_shell", pattern: full }, + undefined, + noopRestricted, + workspace, + ), + ).toBe(false); + }); + + test("a single per-segment grant alone does not cover the full chain", () => { + const request: PermissionRequest = { + tool: "run_shell", + action: "Run", + subject: full, + scopes: [], + cwd: "/proj", + }; + expect( + isRequestCoveredByGrant( + request, + { tool: "run_shell", pattern: "npm i" }, + undefined, + noopRestricted, + workspace, + ), + ).toBe(false); + }); +}); + +// After the operator approves `a && b`, mintGrant emits one grant per segment +// and onGrant's covers predicate sees the live approvals list — so a queued +// identical chain drains once both segments are present, without a second +// prompt. +describe("queue reconcile drains an identical chain after per-segment mint", () => { + const full = "npm i && curl x"; + const shellCall = (command: string): ToolCall => ({ + id: "c", + name: "run_shell", + arguments: { command }, + }); + + test("queued identical chain settles after approving the same chain", async () => { + const queue = createPermissionRequestQueue(); + const outcomes: { allow: boolean }[] = []; + queue.enqueue( + { + tool: "run_shell", + action: "Run", + subject: full, + scopes: [], + cwd: process.cwd(), + }, + (o) => outcomes.push({ allow: o.allow }), + ); + + const built = buildRequests(shellCall(full))[0]?.scopes[0]; + expect(built?.pattern).toBe(full); + if (built === undefined) throw new Error("expected chain scope"); + + let grantEvents = 0; + const gate = createPermissionGate({ + approvals: [], + requestApproval: async () => ({ + allow: true, + persist: { ...built, grant: "session" as const }, + }), + onGrant: (_approval, covers) => { + grantEvents++; + queue.reconcile(covers); + }, + interactive: true, + skipPermissions: false, + }); + + expect((await gate.evaluate(shellCall(full))).allowed).toBe(true); + // Two per-segment grants minted → two onGrant fires; the second drains. + expect(grantEvents).toBe(2); + expect(outcomes).toEqual([{ allow: true }]); + expect(queue.size()).toBe(0); + }); + + test("first per-segment mint alone does not drain a multi-segment queue entry", async () => { + const queue = createPermissionRequestQueue(); + const outcomes: { allow: boolean }[] = []; + queue.enqueue( + { + tool: "run_shell", + action: "Run", + subject: full, + scopes: [], + cwd: process.cwd(), + }, + (o) => outcomes.push({ allow: o.allow }), + ); + + // Seed only the first segment via a one-segment persist, then assert the + // queued full chain stays put — draining on a partial grant would let an + // unapproved tail through. + const gate = createPermissionGate({ + approvals: [], + requestApproval: async () => ({ + allow: true, + persist: { + id: "exact", + label: "Always allow this exact command", + pattern: "npm i", + grant: "session" as const, + }, + }), + onGrant: (_approval, covers) => { + queue.reconcile(covers); + }, + interactive: true, + skipPermissions: false, + }); + + expect((await gate.evaluate(shellCall("npm i"))).allowed).toBe(true); + expect(outcomes).toEqual([]); + expect(queue.size()).toBe(1); + }); +}); diff --git a/src/permission/permission.test.ts b/src/permission/permission.test.ts index 52ba28dcc..5c50dd4dd 100644 --- a/src/permission/permission.test.ts +++ b/src/permission/permission.test.ts @@ -461,22 +461,18 @@ describe("buildRequests", () => { expect(reqs[0]?.notice).toBeUndefined(); }); - test("a 5-segment chain (threshold) offers no scopes and shows the mega-chain notice", () => { + test("a 5-segment chain keeps the exact-command scope and no notice", () => { const cmd = ["a", "b", "c", "d", "e"].join(" && "); const reqs = buildRequests(shellCall(cmd)); - expect(reqs[0]?.scopes).toEqual([]); - expect(reqs[0]?.notice).toBe( - "Chains of 5+ steps are approved once only — split into shorter commands for reusable approvals.", - ); + expect(reqs[0]?.scopes.map((s) => s.pattern)).toEqual([cmd]); + expect(reqs[0]?.notice).toBeUndefined(); }); - test("a 6-segment chain (threshold+1) also offers no scopes", () => { - const cmd = ["a", "b", "c", "d", "e", "f"].join(" && "); + test("an 8-segment chain also keeps the exact-command scope and no notice", () => { + const cmd = ["a", "b", "c", "d", "e", "f", "g", "h"].join(" && "); const reqs = buildRequests(shellCall(cmd)); - expect(reqs[0]?.scopes).toEqual([]); - expect(reqs[0]?.notice).toBe( - "Chains of 5+ steps are approved once only — split into shorter commands for reusable approvals.", - ); + expect(reqs[0]?.scopes.map((s) => s.pattern)).toEqual([cmd]); + expect(reqs[0]?.notice).toBeUndefined(); }); test("full-line shell comments never become approval subjects", () => { @@ -2077,24 +2073,29 @@ describe("createPermissionGate", () => { expect(seen).toEqual([full]); }); - // buildRequests surfaces the full chain once; multi-segment scopes are exact-only - // so a prefix grant cannot later cover a different dangerous chain. + // buildRequests surfaces the full chain once; multi-segment scopes are the + // full-chain persist payload (minted per-segment) so a prefix grant cannot + // later cover a different dangerous chain. test("buildRequests surfaces a chained command as one full-block request with exact scopes", () => { const full = "echo ok && cat > /etc/x"; const reqs = buildRequests(shellCall(full)); expect(reqs).toHaveLength(1); expect(reqs[0]?.subject).toBe(full); expect(reqs[0]?.scopes.map((s) => s.pattern)).toEqual([full]); + expect(reqs[0]?.scopes.map((s) => s.label)).toEqual([ + "Always allow each command in this chain", + ]); // No per-segment prefix scopes that would cross-contaminate. expect(reqs[0]?.scopes.some((s) => s.pattern === "echo *")).toBe(false); expect(reqs[0]?.scopes.some((s) => s.pattern === "cat *")).toBe(false); }); - // Persisting the exact multi-segment scope must cover the same full block on - // a later call without re-prompting, and must not cover a different chain. - test("persisting an exact multi-segment scope reuses on the same chain only", async () => { + // Persisting the exact multi-segment scope decomposes into one grant per + // real segment, so approving `a && b` later covers `b` on its own — a chain + // containing a previously-granted segment only re-prompts for the new part. + test("persisting an exact multi-segment scope mints one grant per segment", async () => { const full = "npm i && curl x"; - const other = "npm i && curl y"; + const later = "curl x && npm run build"; let asked = 0; const persisted: Approval[] = []; const built = buildRequests(shellCall(full))[0]?.scopes[0]; @@ -2119,46 +2120,109 @@ describe("createPermissionGate", () => { }); expect((await gate.evaluate(shellCall(full))).allowed).toBe(true); expect(asked).toBe(1); - expect(persisted).toEqual([{ tool: "run_shell", pattern: full, cwd: process.cwd() }]); - // Same full block is covered by the exact grant. + expect(persisted).toEqual([ + { tool: "run_shell", pattern: "npm i", cwd: process.cwd() }, + { tool: "run_shell", pattern: "curl x", cwd: process.cwd() }, + ]); + // Same full block is covered — both segments already granted. expect((await gate.evaluate(shellCall(full))).allowed).toBe(true); expect(asked).toBe(1); - // A different chain still needs its own decision. - expect((await gate.evaluate(shellCall(other))).allowed).toBe(true); + // A chain reusing `curl x` in a different order/company only needs a + // fresh decision for the ungranted segment (`npm run build`), not the + // whole new chain — the point of granting per segment. + expect((await gate.evaluate(shellCall(later))).allowed).toBe(true); expect(asked).toBe(2); }); - // A mega-chain (>= MEGA_CHAIN_SEGMENT_THRESHOLD segments) never mints a - // grant, even if a persist scope somehow arrives back from requestApproval - // (defense in depth alongside buildRequests offering no scopes at all). - test("a mega-chain never mints a grant and re-prompts every time", async () => { - const full = "a && b && c && d && e"; + // All-granted chains behave identically regardless of length: once every + // segment has its own grant, a long chain auto-resolves exactly like a + // short one — there is no length-based special case left in minting. + test("all-granted chains of length 1, 2, and 8 behave identically", async () => { + const letters = ["a", "b", "c", "d", "e", "f", "g", "h"]; + const approvals: Approval[] = letters.map((l) => ({ tool: "run_shell", pattern: l })); let asked = 0; - const persisted: Approval[] = []; + const gate = createPermissionGate({ + approvals, + requestApproval: async () => { + asked++; + return { allow: true }; + }, + interactive: true, + skipPermissions: false, + }); + expect((await gate.evaluate(shellCall("a"))).allowed).toBe(true); + expect((await gate.evaluate(shellCall("a && b"))).allowed).toBe(true); + expect((await gate.evaluate(shellCall(letters.join(" && ")))).allowed).toBe(true); + expect(asked).toBe(0); + }); + + // Approving `a && b` grants both segments individually; a later chain that + // reuses only `b` prompts for just the new segment, never the whole chain. + test("approving a && b then running b && c prompts only for c", async () => { + let asked = 0; + const seenSubjects: string[] = []; const gate = createPermissionGate({ approvals: [], requestApproval: async (req) => { asked++; + seenSubjects.push(req.subject); + const exact = req.scopes.find((s) => s.id === "exact"); return { allow: true, - persist: { - id: "exact", - label: "Always allow this exact command", - pattern: req.subject, - grant: "project", - }, + ...(exact !== undefined ? { persist: { ...exact, grant: "session" as const } } : {}), }; }, - persist: (a) => persisted.push(a), interactive: true, skipPermissions: false, }); - expect((await gate.evaluate(shellCall(full))).allowed).toBe(true); + expect((await gate.evaluate(shellCall("a && b"))).allowed).toBe(true); expect(asked).toBe(1); - expect(persisted).toEqual([]); - // No grant was minted, so the same chain prompts again. - expect((await gate.evaluate(shellCall(full))).allowed).toBe(true); + expect((await gate.evaluate(shellCall("b && c"))).allowed).toBe(true); expect(asked).toBe(2); + expect(seenSubjects[1]).toBe("b && c"); + }); + + // Verdicts are order-independent: the same segment set granted from one + // ordering auto-resolves the same set in a different order. + test("the same segment set in a different order gives the same verdict", async () => { + const approvals: Approval[] = [ + { tool: "run_shell", pattern: "a" }, + { tool: "run_shell", pattern: "b" }, + { tool: "run_shell", pattern: "c" }, + ]; + let asked = 0; + const gate = createPermissionGate({ + approvals, + requestApproval: async () => { + asked++; + return { allow: true }; + }, + interactive: true, + skipPermissions: false, + }); + expect((await gate.evaluate(shellCall("a && b && c"))).allowed).toBe(true); + expect((await gate.evaluate(shellCall("c && a && b"))).allowed).toBe(true); + expect(asked).toBe(0); + }); + + // A wrapper that hides an ungranted segment inside `bash -c "..."` still + // prompts — expandShellSubjects peels the wrapper so the grant can't be + // laundered through it. + test("a wrapper hiding an ungranted segment still prompts", async () => { + const approvals: Approval[] = [{ tool: "run_shell", pattern: "granted" }]; + let asked = 0; + const gate = createPermissionGate({ + approvals, + requestApproval: async () => { + asked++; + return { allow: true }; + }, + interactive: true, + skipPermissions: false, + }); + const verdict = await gate.evaluate(shellCall('bash -c "granted && ungranted"')); + expect(verdict.allowed).toBe(true); + expect(asked).toBe(1); }); // The gate must own its approval state, not mutate the caller's array. diff --git a/src/permission/types.ts b/src/permission/types.ts index 430fba689..ab5db7ac9 100644 --- a/src/permission/types.ts +++ b/src/permission/types.ts @@ -54,8 +54,7 @@ export interface PermissionRequest { agentLabel?: string; // A single muted-line explanation shown to the operator when scopes were // withheld for a reason beyond the ordinary "no persistent option exists - // yet" case (e.g. a mega-chain that only offers accept-once). Plain literal - // text, never model-authored. + // yet" case. Plain literal text, never model-authored. notice?: string; // Set by the gate right before handing this request to requestApproval, so // whichever surface actually renders it (see gate-wire.ts's overlay host) diff --git a/tests/unit/permission/cross-commit-composition.test.ts b/tests/unit/permission/cross-commit-composition.test.ts index 7e7ce67c8..74a8d2644 100644 --- a/tests/unit/permission/cross-commit-composition.test.ts +++ b/tests/unit/permission/cross-commit-composition.test.ts @@ -6,7 +6,7 @@ import { splitChainedCommand, tokenize, } from "../../../src/permission/command.js"; -import { buildRequests, MEGA_CHAIN_SEGMENT_THRESHOLD } from "../../../src/permission/classify.js"; +import { buildRequests } from "../../../src/permission/classify.js"; import type { RequestApproval } from "../../../src/permission/types.js"; const call = (command: string) => ({ id: "t", name: "run_shell", arguments: { command } }); @@ -40,19 +40,17 @@ describe("comment normalization x exact full-command grants", () => { expect(prompts).toBe(1); // no re-prompt: comment-insensitive replay }); - test("comment lines do not count toward the mega-chain threshold", () => { + test("comment lines do not count toward the real segment count", () => { const comments = Array.from({ length: 10 }, (_, i) => `# c${i}`).join("\n"); const cmd = `${comments}\ngit fetch origin && git rebase origin/main`; const [req] = buildRequests(call(cmd)); - expect(req?.scopes.length).toBeGreaterThan(0); // not treated as mega-chain + expect(req?.scopes.length).toBeGreaterThan(0); }); - test("a real chain hidden after comments still reaches the threshold", () => { - const cmd = Array.from({ length: MEGA_CHAIN_SEGMENT_THRESHOLD }, (_, i) => `cmd${i} run`).join( - " && ", - ); + test("a long real chain hidden after comments still gets an exact-command scope", () => { + const cmd = Array.from({ length: 8 }, (_, i) => `cmd${i} run`).join(" && "); const [req] = buildRequests(call(cmd)); - expect(req?.scopes.length).toBe(0); // mega-chain: accept-once only + expect(req?.scopes.length).toBe(1); }); }); From 0c8be8f60f87650464d261ede6dc7d12c467dbf8 Mon Sep 17 00:00:00 2001 From: Sawyer Cutler Date: Mon, 24 Aug 2026 22:04:06 -0700 Subject: [PATCH 2/3] Harden shell chain grant minting --- src/permission/command.ts | 18 ++++++ src/permission/gate.ts | 16 ++++-- src/permission/permission.test.ts | 94 ++++++++++++++++++++++++++++--- 3 files changed, 114 insertions(+), 14 deletions(-) diff --git a/src/permission/command.ts b/src/permission/command.ts index 286faa3b2..765ca7658 100644 --- a/src/permission/command.ts +++ b/src/permission/command.ts @@ -49,6 +49,10 @@ export function splitChainedCommand(command: string): string[] { if (quote !== null) { current += ch; + if ((quote === '"' || quote === "`") && ch === "\\" && i + 1 < command.length) { + current += command[++i] as string; + continue; + } if (ch === quote) quote = null; continue; } @@ -97,6 +101,11 @@ export function splitChainedCommand(command: string): string[] { continue; } + if (ch === "#" && startsShellComment(current)) { + while (i + 1 < command.length && command[i + 1] !== "\n") i++; + continue; + } + const next = command[i + 1]; // A chain operator immediately following a dangling redirect operator // (`>`, `<`, `>&`, `<&` with no target yet) does not start a new command — @@ -299,6 +308,11 @@ function endsWithDanglingRedirect(text: string): boolean { return DANGLING_REDIRECT.test(text.trimEnd()); } +function startsShellComment(currentSegment: string): boolean { + const previous = currentSegment.at(-1); + return previous === undefined || previous === " " || previous === "\t"; +} + // The inner chain of a segment that is exactly one parenthesised group, or null // when the segment is not a bare group (trailing redirects like `(a && b) 2>&1` // keep the segment atomic). Quote-aware so a `)` inside quotes does not close @@ -432,6 +446,10 @@ export function tokenize(command: string): string[] { } if (quote === '"') { + if (ch === "\\" && i + 1 < chars.length) { + current += chars[++i] as string; + continue; + } if (ch === '"') quote = null; else current += ch; continue; diff --git a/src/permission/gate.ts b/src/permission/gate.ts index eeba192d0..539f6fa27 100644 --- a/src/permission/gate.ts +++ b/src/permission/gate.ts @@ -384,12 +384,18 @@ export function createPermissionGate(options: PermissionGateOptions): Permission // outside the original chain); the CHANGELOG Security note documents the // tradeoff. onGrant's covers predicate consults the live approvals list so // a queued identical chain drains only once every segment has been minted. - const patterns = + const normalizedPattern = + tool === "run_shell" + ? stripCommentLines(outcome.persist.pattern).trim() + : outcome.persist.pattern; + const shellSegments = tool === "run_shell" - ? splitChainedCommand(stripCommentLines(outcome.persist.pattern).trim()) - .filter((segment) => !isShellCommentOnly(segment)) - .map((segment) => segment.trim()) - : [outcome.persist.pattern]; + ? splitChainedCommand(normalizedPattern).filter((segment) => !isShellCommentOnly(segment)) + : []; + const patterns = + tool === "run_shell" && shellSegments.length > 1 + ? shellSegments.map((segment) => escapeGlobLiteral(segment.trim())) + : [normalizedPattern]; for (const pattern of patterns) { const approval: Approval = grant === "provider-model" && activeProviderModel !== undefined diff --git a/src/permission/permission.test.ts b/src/permission/permission.test.ts index 5c50dd4dd..78f2e2935 100644 --- a/src/permission/permission.test.ts +++ b/src/permission/permission.test.ts @@ -112,6 +112,16 @@ describe("splitChainedCommand", () => { expect(splitChainedCommand(`grep 'x;y' file`)).toEqual([`grep 'x;y' file`]); }); + test("does not split at separators after escaped double quotes", () => { + expect(splitChainedCommand(`printf "safe \\" && touch PWNED && \\""`)).toEqual([ + `printf "safe \\" && touch PWNED && \\""`, + ]); + }); + + test("ignores inline comments before looking for chain separators", () => { + expect(splitChainedCommand("echo ok # && touch PWNED")).toEqual(["echo ok"]); + }); + test("drops empty segments", () => { expect(splitChainedCommand(" ; ; ls ")).toEqual(["ls"]); }); @@ -2093,6 +2103,77 @@ describe("createPermissionGate", () => { // Persisting the exact multi-segment scope decomposes into one grant per // real segment, so approving `a && b` later covers `b` on its own — a chain // containing a previously-granted segment only re-prompts for the new part. + test("persisting a segment containing a glob stores an exact escaped grant", async () => { + const full = "echo prep && bash -c 'echo *'"; + const persisted: Approval[] = []; + const built = buildRequests(shellCall(full))[0]?.scopes[0]; + if (built === undefined) throw new Error("expected exact multi-segment scope"); + const gate = createPermissionGate({ + approvals: [], + requestApproval: async () => ({ allow: true, persist: { ...built, grant: "project" } }), + persist: (a) => persisted.push(a), + interactive: true, + skipPermissions: false, + }); + + expect((await gate.evaluate(shellCall(full))).allowed).toBe(true); + expect(persisted).toEqual([ + { tool: "run_shell", pattern: "echo prep", cwd: process.cwd() }, + { tool: "run_shell", pattern: "bash -c 'echo \\*'", cwd: process.cwd() }, + ]); + const bashGrant = persisted[1]; + if (bashGrant === undefined) throw new Error("expected bash segment grant"); + expect(matchesPattern("bash -c 'echo *'", bashGrant.pattern)).toBe(true); + expect(matchesPattern("bash -c 'touch PWNED'", bashGrant.pattern)).toBe(false); + + let asked = 0; + const replay = createPermissionGate({ + approvals: persisted, + requestApproval: async () => { + asked++; + return { allow: true }; + }, + interactive: true, + skipPermissions: false, + }); + expect((await replay.evaluate(shellCall("bash -c 'touch PWNED'"))).allowed).toBe(true); + expect(asked).toBe(1); + }); + + test("escaped quotes do not mint grants for unexecuted text", async () => { + const full = `printf "safe \\" && touch PWNED && \\""`; + const persisted: Approval[] = []; + const built = buildRequests(shellCall(full))[0]?.scopes.find((scope) => scope.id === "exact"); + if (built === undefined) throw new Error("expected exact command scope"); + const gate = createPermissionGate({ + approvals: [], + requestApproval: async () => ({ allow: true, persist: { ...built, grant: "project" } }), + persist: (a) => persisted.push(a), + interactive: true, + skipPermissions: false, + }); + + expect((await gate.evaluate(shellCall(full))).allowed).toBe(true); + expect(persisted.map((a) => a.pattern)).not.toContain("touch PWNED"); + }); + + test("inline comments do not mint grants for commented shell text", async () => { + const full = "echo ok # && touch PWNED"; + const persisted: Approval[] = []; + const built = buildRequests(shellCall(full))[0]?.scopes.find((scope) => scope.id === "exact"); + if (built === undefined) throw new Error("expected exact command scope"); + const gate = createPermissionGate({ + approvals: [], + requestApproval: async () => ({ allow: true, persist: { ...built, grant: "project" } }), + persist: (a) => persisted.push(a), + interactive: true, + skipPermissions: false, + }); + + expect((await gate.evaluate(shellCall(full))).allowed).toBe(true); + expect(persisted.map((a) => a.pattern)).not.toContain("touch PWNED"); + }); + test("persisting an exact multi-segment scope mints one grant per segment", async () => { const full = "npm i && curl x"; const later = "curl x && npm run build"; @@ -2496,15 +2577,10 @@ describe("preApprove", () => { expect(isSingleShellCommand("# just a comment")).toBe(false); }); - test("isSingleShellCommand treats a leading-comment-then-chain as its trailing real segment", () => { - // splitChainedCommand splits on "&&" before recognizing that "#" extends - // a comment to end of line, so "# a && b" splits into ["# a", "b"] even - // though a real shell treats the whole line as one comment (nothing - // after "#" ever runs). Filtering the comment-only "# a" segment leaves - // exactly one real segment, "b", so this is scored as a single command — - // matching shellApprovalScopes' existing behavior, not a regression - // introduced here. - expect(isSingleShellCommand("# a && b")).toBe(true); + test("isSingleShellCommand treats a leading-comment-then-chain as comment-only", () => { + // Shell comments extend to end-of-line; text after `#` must not become a + // phantom segment that can drive approvals or grant reuse. + expect(isSingleShellCommand("# a && b")).toBe(false); }); }); From 81b29c2d853c7b4e7d8d21a6ac995aea14e6a56d Mon Sep 17 00:00:00 2001 From: Sawyer Cutler Date: Tue, 25 Aug 2026 10:53:29 -0700 Subject: [PATCH 3/3] Keep shell grant minting from inventing phantom segments Per-segment minting reused a splitter that intentionally has no backslash-escape support so nested-interpreter peels stay opaque. Fall back to one exact grant when escapes or inline comments would otherwise mint text that never runs. --- src/permission/command.ts | 18 ----------------- src/permission/gate.ts | 27 ++++++++++++++++++++++---- src/permission/permission.test.ts | 32 +++++++++++++++++-------------- 3 files changed, 41 insertions(+), 36 deletions(-) diff --git a/src/permission/command.ts b/src/permission/command.ts index 765ca7658..286faa3b2 100644 --- a/src/permission/command.ts +++ b/src/permission/command.ts @@ -49,10 +49,6 @@ export function splitChainedCommand(command: string): string[] { if (quote !== null) { current += ch; - if ((quote === '"' || quote === "`") && ch === "\\" && i + 1 < command.length) { - current += command[++i] as string; - continue; - } if (ch === quote) quote = null; continue; } @@ -101,11 +97,6 @@ export function splitChainedCommand(command: string): string[] { continue; } - if (ch === "#" && startsShellComment(current)) { - while (i + 1 < command.length && command[i + 1] !== "\n") i++; - continue; - } - const next = command[i + 1]; // A chain operator immediately following a dangling redirect operator // (`>`, `<`, `>&`, `<&` with no target yet) does not start a new command — @@ -308,11 +299,6 @@ function endsWithDanglingRedirect(text: string): boolean { return DANGLING_REDIRECT.test(text.trimEnd()); } -function startsShellComment(currentSegment: string): boolean { - const previous = currentSegment.at(-1); - return previous === undefined || previous === " " || previous === "\t"; -} - // The inner chain of a segment that is exactly one parenthesised group, or null // when the segment is not a bare group (trailing redirects like `(a && b) 2>&1` // keep the segment atomic). Quote-aware so a `)` inside quotes does not close @@ -446,10 +432,6 @@ export function tokenize(command: string): string[] { } if (quote === '"') { - if (ch === "\\" && i + 1 < chars.length) { - current += chars[++i] as string; - continue; - } if (ch === '"') quote = null; else current += ch; continue; diff --git a/src/permission/gate.ts b/src/permission/gate.ts index 539f6fa27..b698715f8 100644 --- a/src/permission/gate.ts +++ b/src/permission/gate.ts @@ -339,6 +339,15 @@ export interface PermissionGate { unregisterMcpServer: (serverName: string) => void; } +// True when splitChainedCommand can be trusted to yield only real segments for +// grant minting. False for patterns that confuse the no-backslash-escape +// splitter into phantom segments — those mint as one exact whole-pattern grant. +function canSafelyMintPerSegment(pattern: string): boolean { + if (/\\["`]/.test(pattern)) return false; + if (pattern.includes("#")) return false; + return true; +} + export function createPermissionGate(options: PermissionGateOptions): PermissionGate { const { requestApproval, persist, interactive, providerName, model, cwd } = options; const telemetry = options.telemetry ?? NOOP_TELEMETRY; @@ -384,6 +393,13 @@ export function createPermissionGate(options: PermissionGateOptions): Permission // outside the original chain); the CHANGELOG Security note documents the // tradeoff. onGrant's covers predicate consults the live approvals list so // a queued identical chain drains only once every segment has been minted. + // + // splitChainedCommand / tokenize intentionally have no backslash-escape + // support (CL-6988 / #673 rely on that so misparsed wrappers stay opaque). + // When the pattern contains escapes or inline `#` comments, the naive + // splitter can invent phantom segments (`printf "…\" && evil"` → `evil`). + // Fall back to one exact grant for the whole normalized pattern instead of + // minting those phantoms. const normalizedPattern = tool === "run_shell" ? stripCommentLines(outcome.persist.pattern).trim() @@ -392,10 +408,13 @@ export function createPermissionGate(options: PermissionGateOptions): Permission tool === "run_shell" ? splitChainedCommand(normalizedPattern).filter((segment) => !isShellCommentOnly(segment)) : []; - const patterns = - tool === "run_shell" && shellSegments.length > 1 - ? shellSegments.map((segment) => escapeGlobLiteral(segment.trim())) - : [normalizedPattern]; + const mintPerSegment = + tool === "run_shell" && + shellSegments.length > 1 && + canSafelyMintPerSegment(normalizedPattern); + const patterns = mintPerSegment + ? shellSegments.map((segment) => escapeGlobLiteral(segment.trim())) + : [normalizedPattern]; for (const pattern of patterns) { const approval: Approval = grant === "provider-model" && activeProviderModel !== undefined diff --git a/src/permission/permission.test.ts b/src/permission/permission.test.ts index 78f2e2935..b9aff9d81 100644 --- a/src/permission/permission.test.ts +++ b/src/permission/permission.test.ts @@ -112,16 +112,6 @@ describe("splitChainedCommand", () => { expect(splitChainedCommand(`grep 'x;y' file`)).toEqual([`grep 'x;y' file`]); }); - test("does not split at separators after escaped double quotes", () => { - expect(splitChainedCommand(`printf "safe \\" && touch PWNED && \\""`)).toEqual([ - `printf "safe \\" && touch PWNED && \\""`, - ]); - }); - - test("ignores inline comments before looking for chain separators", () => { - expect(splitChainedCommand("echo ok # && touch PWNED")).toEqual(["echo ok"]); - }); - test("drops empty segments", () => { expect(splitChainedCommand(" ; ; ls ")).toEqual(["ls"]); }); @@ -2141,6 +2131,9 @@ describe("createPermissionGate", () => { }); test("escaped quotes do not mint grants for unexecuted text", async () => { + // splitChainedCommand has no backslash-escape support (CL-6988). Minting + // per segment would invent a phantom `touch PWNED` grant, so the gate + // falls back to one exact whole-pattern grant instead. const full = `printf "safe \\" && touch PWNED && \\""`; const persisted: Approval[] = []; const built = buildRequests(shellCall(full))[0]?.scopes.find((scope) => scope.id === "exact"); @@ -2154,10 +2147,14 @@ describe("createPermissionGate", () => { }); expect((await gate.evaluate(shellCall(full))).allowed).toBe(true); + expect(persisted.map((a) => a.pattern)).toEqual([full]); expect(persisted.map((a) => a.pattern)).not.toContain("touch PWNED"); }); test("inline comments do not mint grants for commented shell text", async () => { + // Inline `# …` is not stripped by stripCommentLines (full-line only), and + // the splitter does not treat it as a comment, so per-segment minting + // would invent `touch PWNED`. Fall back to one exact grant. const full = "echo ok # && touch PWNED"; const persisted: Approval[] = []; const built = buildRequests(shellCall(full))[0]?.scopes.find((scope) => scope.id === "exact"); @@ -2171,6 +2168,7 @@ describe("createPermissionGate", () => { }); expect((await gate.evaluate(shellCall(full))).allowed).toBe(true); + expect(persisted.map((a) => a.pattern)).toEqual([full]); expect(persisted.map((a) => a.pattern)).not.toContain("touch PWNED"); }); @@ -2577,10 +2575,16 @@ describe("preApprove", () => { expect(isSingleShellCommand("# just a comment")).toBe(false); }); - test("isSingleShellCommand treats a leading-comment-then-chain as comment-only", () => { - // Shell comments extend to end-of-line; text after `#` must not become a - // phantom segment that can drive approvals or grant reuse. - expect(isSingleShellCommand("# a && b")).toBe(false); + test("isSingleShellCommand treats a leading-comment-then-chain as its trailing real segment", () => { + // splitChainedCommand splits on "&&" before recognizing that "#" extends + // a comment to end of line, so "# a && b" splits into ["# a", "b"] even + // though a real shell treats the whole line as one comment (nothing + // after "#" ever runs). Filtering the comment-only "# a" segment leaves + // exactly one real segment, "b", so this is scored as a single command — + // matching shellApprovalScopes' existing behavior, not a regression + // introduced here. Teaching the splitter about inline comments would + // break CL-6988's no-backslash-escape opaque contract (see #673). + expect(isSingleShellCommand("# a && b")).toBe(true); }); });