Skip to content
Closed
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 @@ -13,6 +13,22 @@ parallel copies under `docs/` or `scripts/notes/`. At cut time: rename

## [Unreleased]

### 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.

### Agent

- **Fleet authority tiers are now runtime-enforced, not documented in a prompt.**
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
20 changes: 3 additions & 17 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 @@ -470,12 +459,12 @@ 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.
// match `npm i && rm -rf /` on a later call (fail-closed). Minting decomposes
// that exact-chain scope into one grant per real segment (see mintGrant in
// gate.ts), so persisting it still yields reusable, segment-level approvals.
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 [];
Expand All @@ -502,9 +491,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
122 changes: 39 additions & 83 deletions src/permission/gate.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -370,33 +345,45 @@ export function createPermissionGate(options: PermissionGateOptions): Permission
// 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 =
// normalized space grant matching works against.
//
// The exact-chain scope covers the whole command as one string, but a
// grant that coarse would replay for any future chain containing the
// same segments, blind to how many there were. Decompose it 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, and strictly no broader than
// the whole-string approval it replaces.
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) =>
isRequestCoveredByGrant(
request,
approval,
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
Expand Down Expand Up @@ -510,30 +497,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) {
Expand Down Expand Up @@ -570,14 +533,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");
Expand Down Expand Up @@ -621,7 +577,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;
Expand Down
28 changes: 15 additions & 13 deletions src/permission/grant-scope.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -81,13 +81,14 @@ 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 now decomposes a multi-segment exact-chain scope into one
// grant per real segment (see mintGrant in gate.ts) rather than persisting a
// single whole-string pattern, so a grant scoped to the full chain string is
// legacy shape and no longer the replay path — per-segment grants are (see
// permission.test.ts). This confirms a grant scoped to a mismatched cwd or
// provider model still never replays, consistent with grantScopeMatches
// everywhere else.
describe("a scope-mismatched grant never replays a multi-segment chain", () => {
const full = "npm i && curl x";
const shellCall = (command: string): ToolCall => ({
id: "c",
Expand All @@ -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 };
Expand All @@ -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 () => {
Expand All @@ -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 };
Expand Down
Loading
Loading