diff --git a/CHANGELOG.md b/CHANGELOG.md index db4373ac..a58a3c8e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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.** diff --git a/scripts/approval-forensics.ts b/scripts/approval-forensics.ts index d9b3c7e6..bd9575c7 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/classify.ts b/src/permission/classify.ts index c2976bc7..7c429624 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." @@ -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 []; @@ -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 } - : {}), }, ]; } diff --git a/src/permission/gate.ts b/src/permission/gate.ts index b14f0596..53bc6974 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 @@ -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 @@ -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) { @@ -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"); @@ -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; diff --git a/src/permission/grant-scope.test.ts b/src/permission/grant-scope.test.ts index 6d295abf..72ff4f24 100644 --- a/src/permission/grant-scope.test.ts +++ b/src/permission/grant-scope.test.ts @@ -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", @@ -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 }; diff --git a/src/permission/permission.test.ts b/src/permission/permission.test.ts index 52ba28dc..0b23d2ce 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", () => { @@ -2090,11 +2086,12 @@ describe("createPermissionGate", () => { 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 +2116,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/tests/unit/permission/cross-commit-composition.test.ts b/tests/unit/permission/cross-commit-composition.test.ts index 7e7ce67c..74a8d264 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); }); });