Skip to content

Commit c662023

Browse files
Mint per-segment grants for multi-step shell chains (CL-5752) (#674)
* 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. * Harden shell chain grant minting * 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.
1 parent 1a8b214 commit c662023

9 files changed

Lines changed: 509 additions & 190 deletions

File tree

CHANGELOG.md

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,22 @@ parallel copies under `docs/` or `scripts/notes/`. At cut time: rename
2424
- 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.
2525
- A TTL flash no longer paints chrome after the TUI renderer is destroyed, which crashed parallel TUI tests with `TextBuffer is destroyed`.
2626

27+
### Security
28+
29+
- **Shell chain approvals no longer skip minting once a chain gets long.**
30+
Chains of 5+ segments used to be accept-once only — no grant was ever
31+
persisted, so the same long chain re-prompted every single time no matter
32+
what had already been approved. Approving a multi-segment chain now mints
33+
one grant per real segment instead of one grant for the whole string, so
34+
approving `a && b` also covers `b` on its own later, and long chains behave
35+
the same as short ones. This is a real change in what a single approval
36+
buys: granting per segment is strictly more permissive on later commands
37+
than granting one exact whole-string match was, since a segment now reuses
38+
outside the chain it was first approved in. Nothing that previously
39+
auto-approved now prompts, and nothing that previously required a fresh
40+
decision now silently skips one — chains still ask for any segment that
41+
isn't already granted.
42+
2743
## [0.3.1] - 2026-08-24
2844

2945
### Fixed

scripts/approval-forensics.ts

Lines changed: 4 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -3,10 +3,9 @@
33
// before approval volume could be measured at all.
44
//
55
// Reports: total asks, split by mode (auto vs interactive) and outcome, a
6-
// per-rule breakdown, settle-duration and display-delay percentiles (the
6+
// per-rule breakdown, and settle-duration and display-delay percentiles (the
77
// display delay is the CL-5664 signal — a queued gate arming its timeout
8-
// before the operator could see it), and a mega-chain count (segments >=
9-
// MEGA_CHAIN_SEGMENT_THRESHOLD).
8+
// before the operator could see it).
109
//
1110
// Prints only aggregate counts and timings, never a tool subject or command
1211
// text — the log itself never records either, so there is nothing to leak
@@ -19,7 +18,6 @@ import { join } from "node:path";
1918
import { homedir } from "node:os";
2019

2120
import { APPROVAL_LOG_FILE, type ApprovalRecord } from "../src/permission/approval-log.js";
22-
import { MEGA_CHAIN_SEGMENT_THRESHOLD } from "../src/permission/classify.js";
2321

2422
// lstat, and skip symlinks: session dirs carry a `latest` symlink to a real
2523
// session, and following it double-counts every record in that session.
@@ -56,7 +54,6 @@ interface Bucket {
5654
byMode: Map<string, number>;
5755
durations: number[];
5856
displayDelays: number[];
59-
megaChains: number;
6057
}
6158

6259
function emptyBucket(): Bucket {
@@ -66,7 +63,6 @@ function emptyBucket(): Bucket {
6663
byMode: new Map(),
6764
durations: [],
6865
displayDelays: [],
69-
megaChains: 0,
7066
};
7167
}
7268

@@ -111,7 +107,6 @@ for (const file of files) {
111107
bucket.byMode.set(record.mode, (bucket.byMode.get(record.mode) ?? 0) + 1);
112108
if (typeof record.durationMs === "number") bucket.durations.push(record.durationMs);
113109
if (typeof record.displayDelayMs === "number") bucket.displayDelays.push(record.displayDelayMs);
114-
if ((record.segments ?? 0) >= MEGA_CHAIN_SEGMENT_THRESHOLD) bucket.megaChains++;
115110

116111
// Duplicate-rate proxy: how often the same rule fires more than once per
117112
// session file (a session repeatedly asking for something it was already
@@ -133,7 +128,7 @@ if (records === 0) {
133128

134129
const rows = [...buckets.entries()].sort((a, b) => b[1].count - a[1].count);
135130
console.log(
136-
"\ntool n auto/interactive duration p50/p90/max displayDelay p50/p90/max megaChains",
131+
"\ntool n auto/interactive duration p50/p90/max displayDelay p50/p90/max",
137132
);
138133
for (const [key, bucket] of rows) {
139134
const durations = [...bucket.durations].sort((a, b) => a - b);
@@ -149,7 +144,7 @@ for (const [key, bucket] of rows) {
149144
const autoCount = bucket.byMode.get("auto") ?? 0;
150145
const interactiveCount = bucket.byMode.get("interactive") ?? 0;
151146
console.log(
152-
`${key.padEnd(26)} ${String(bucket.count).padStart(3)} ${String(autoCount).padStart(4)}/${String(interactiveCount).padEnd(11)} ${durDist.padEnd(24)} ${delayDist.padEnd(24)} ${bucket.megaChains}`,
147+
`${key.padEnd(26)} ${String(bucket.count).padStart(3)} ${String(autoCount).padStart(4)}/${String(interactiveCount).padEnd(11)} ${durDist.padEnd(24)} ${delayDist}`,
153148
);
154149
}
155150

src/permission/authz-grants.ts

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -67,9 +67,9 @@ export function cwdMatchesGrant(
6767
// The single place that decides whether a grant's tool/providerModel/cwd
6868
// scope covers a request, independent of whether the grant's pattern matches
6969
// the request's subject. Every live call site that needs to know "does this
70-
// grant cover this request's scope" — evaluateApprovals, isRequestCoveredByGrant,
71-
// hasExactFullCommandGrant — delegates here so a scoping-dimension change
72-
// never has to be made in more than one place.
70+
// grant cover this request's scope" — evaluateApprovals, isRequestCoveredByGrant
71+
// delegates here so a scoping-dimension change never has to be made in more
72+
// than one place.
7373
export function grantScopeMatches(
7474
approval: Approval,
7575
tool: string,

src/permission/classify.ts

Lines changed: 12 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -440,17 +440,6 @@ function stringArg(call: ToolCall, key: string): string {
440440
return typeof value === "string" ? value : "";
441441
}
442442

443-
// A shell chain at or above this many top-level segments gets accept-once-only
444-
// approval: no scope is offered or minted, however broad or exact. A grant
445-
// this coarse would let one operator decision silently cover an unbounded,
446-
// ever-changing family of commands as the model keeps appending segments;
447-
// forcing a fresh decision every time keeps mega-chains reviewable instead of
448-
// rubber-stamped once and replayed forever. Below the threshold, the existing
449-
// exact-only multi-segment rule (and single-segment ladder) is unchanged.
450-
export const MEGA_CHAIN_SEGMENT_THRESHOLD = 5;
451-
452-
export const MEGA_CHAIN_NOTICE = `Chains of ${MEGA_CHAIN_SEGMENT_THRESHOLD}+ steps are approved once only — split into shorter commands for reusable approvals.`;
453-
454443
// The real (non-comment-only) chain segments of a shell command — the basis
455444
// both shellApprovalScopes and isSingleShellCommand use to answer "is this
456445
// one command or a chain."
@@ -469,19 +458,26 @@ export function isSingleShellCommand(command: string): boolean {
469458
}
470459

471460
// Approval scopes for a shell command the operator may persist. Multi-segment
472-
// chains only offer the exact full string — a prefix like `npm *` would also
473-
// match `npm i && rm -rf /` on a later call (fail-closed). At or above
474-
// MEGA_CHAIN_SEGMENT_THRESHOLD, no scope is offered at all — see the constant.
461+
// chains only offer the full chain string as the persist payload — a prefix
462+
// like `npm *` would also match `npm i && rm -rf /` on a later call
463+
// (fail-closed). Minting decomposes that payload into one grant per real
464+
// segment (see mintGrant in gate.ts), so the label names the actual effect:
465+
// each step becomes its own reusable approval.
475466
function shellApprovalScopes(command: string): ApprovalScope[] {
476467
const segments = realShellSegments(command);
477468
if (segments.length === 0) return [];
478-
if (segments.length >= MEGA_CHAIN_SEGMENT_THRESHOLD) return [];
479469
if (segments.length === 1) {
480470
const only = segments[0];
481471
if (only === undefined) return [];
482472
return deriveCommandScopes(only);
483473
}
484-
return [{ id: "exact", label: "Always allow this exact command", pattern: command.trim() }];
474+
return [
475+
{
476+
id: "exact",
477+
label: "Always allow each command in this chain",
478+
pattern: command.trim(),
479+
},
480+
];
485481
}
486482

487483
// Decompose an "ask"-tier tool call into the approval request(s) the operator
@@ -502,9 +498,6 @@ export function buildRequests(call: ToolCall): PermissionRequest[] {
502498
subject: command,
503499
arguments: { command },
504500
scopes: shellApprovalScopes(command),
505-
...(realSegments.length >= MEGA_CHAIN_SEGMENT_THRESHOLD
506-
? { notice: MEGA_CHAIN_NOTICE }
507-
: {}),
508501
},
509502
];
510503
}

0 commit comments

Comments
 (0)