Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 16 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
13 changes: 4 additions & 9 deletions scripts/approval-forensics.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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.
Expand Down Expand Up @@ -56,7 +54,6 @@ interface Bucket {
byMode: Map<string, number>;
durations: number[];
displayDelays: number[];
megaChains: number;
}

function emptyBucket(): Bucket {
Expand All @@ -66,7 +63,6 @@ function emptyBucket(): Bucket {
byMode: new Map(),
durations: [],
displayDelays: [],
megaChains: 0,
};
}

Expand Down Expand Up @@ -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
Expand All @@ -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);
Expand All @@ -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}`,
);
}

Expand Down
6 changes: 3 additions & 3 deletions src/permission/authz-grants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
31 changes: 12 additions & 19 deletions src/permission/classify.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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."
Expand All @@ -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
Expand All @@ -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 }
: {}),
},
];
}
Expand Down
Loading
Loading