From e75558f8a8c121dd7194aedead81decabbfb5132 Mon Sep 17 00:00:00 2001 From: Sawyer Date: Wed, 2 Sep 2026 00:13:26 -0700 Subject: [PATCH 1/6] Extract shared shell tokenizer from permission and display modules MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The permission gate and the TUI display each carried their own shell tokenizer (chain splitter / segment grouper) with verbatim duplicate helpers: isRedirectAmpersand, heredoc scanner, quote-state tracking. Two separate state machines over the same grammar is a security risk: any future quoting or heredoc fix must land twice or the two silently diverge, meaning the operator reads and approves text that differs from what the gate evaluates. Consolidate into shell-tokenizer.ts and have both consumers import from it. The display grouper now calls parseHeredocOpener (with a null fallback) instead of its own parseHeredocMarker. Add a property test that asserts, for a corpus of chained commands covering quotes, heredocs, subshells, and operators, the display segments are a coarse merge of the authz segments — never a different parse of the same grammar. --- src/permission/command.ts | 47 +------- .../shell-tokenizer-property.test.ts | 113 ++++++++++++++++++ src/permission/shell-tokenizer.ts | 41 +++++++ src/tui/command-display.ts | 40 +------ 4 files changed, 160 insertions(+), 81 deletions(-) create mode 100644 src/permission/shell-tokenizer-property.test.ts create mode 100644 src/permission/shell-tokenizer.ts diff --git a/src/permission/command.ts b/src/permission/command.ts index 286faa3b2..0f8f21290 100644 --- a/src/permission/command.ts +++ b/src/permission/command.ts @@ -1,5 +1,6 @@ import type { ApprovalScope } from "./types.js"; import { escapeGlobLiteral } from "./matcher.js"; +import { isRedirectAmpersand, parseHeredocOpener } from "./shell-tokenizer.js"; // Split a shell command into the individual commands it chains together, so each // can be classified for security. The operator still approves the full command @@ -142,45 +143,6 @@ export function splitChainedCommand(command: string): string[] { return segments; } -// Parses a heredoc opener (`<<` or `<<-`) starting at `command[i]` (which must -// be the first "<"). Returns the terminating marker text and the exclusive end -// index of the line that opened the heredoc, so the caller can copy the -// opening line verbatim and resume scanning the heredoc body from there. -// Shared by splitChainedCommand and stripCommentLines so both stay in sync on -// what counts as heredoc syntax. -function parseHeredocOpener( - command: string, - i: number, -): { marker: string; lineEnd: number } | null { - if (command[i] !== "<" || command[i + 1] !== "<") return null; - let j = i + 2; - if (command[j] === "-") j++; // <<- strips leading tabs - // Skip whitespace between << and the marker word. - while (j < command.length && (command[j] === " " || command[j] === "\t")) j++; - // The marker may be quoted ('EOF', "EOF", or bare EOF). - let markerQuote: string | null = null; - if (command[j] === "'" || command[j] === '"') { - markerQuote = command[j] as string; - j++; - } - let marker = ""; - while ( - j < command.length && - command[j] !== "\n" && - command[j] !== markerQuote && - // A bare (unquoted) marker is a single word; stop at whitespace so a - // trailing redirect like `< out.txt` is not folded into the - // marker (which would leave the heredoc unterminated). - !(markerQuote === null && (command[j] === " " || command[j] === "\t")) - ) { - marker += command[j++]; - } - if (markerQuote !== null && command[j] === markerQuote) j++; - // Advance j to the end of the line that opened the heredoc. - while (j < command.length && command[j] !== "\n") j++; - return { marker, lineEnd: j }; -} - // Remove genuine top-level full-line shell comments from command text before // it is used to derive or match a persisted grant scope. Agents routinely // prefix a command with a `# why I'm running this` line; if that comment @@ -451,13 +413,6 @@ export function tokenize(command: string): string[] { return tokens; } -// `&` is the background operator when it stands alone as a word — followed by -// whitespace or end of input. Anywhere else it is part of a redirect token: -// `2>&1`, `<&-`, `&>file`. -function isRedirectAmpersand(next: string | undefined): boolean { - return !(next === undefined || next === " " || next === "\t"); -} - const MAX_PREFIX_SCOPES = 3; // Commands that multiplex many subcommands of wildly different risk under one diff --git a/src/permission/shell-tokenizer-property.test.ts b/src/permission/shell-tokenizer-property.test.ts new file mode 100644 index 000000000..dec96dafa --- /dev/null +++ b/src/permission/shell-tokenizer-property.test.ts @@ -0,0 +1,113 @@ +import { test, expect, describe } from "bun:test"; +import { splitChainedCommand } from "./command.js"; +import { groupChainSegmentsForDisplay } from "../tui/command-display.js"; + +// Corpus of chained commands exercising quotes, heredocs, subshells, and +// various operators. Each entry is [description, command, agreement]. +// +// `agreement` is true when both splitters are expected to produce the same +// segments (modulo pipe merging), and false for cases where they intentionally +// diverge (subshell unwrap, dangling-redirect coalescing, empty-heredoc +// boundary handling). +const CORPUS: [string, string, boolean][] = [ + ["simple &&", "echo a && echo b", true], + ["simple ||", "echo a || echo b", true], + ["simple ;", "echo a ; echo b", true], + ["quoted operator", 'echo "a && b"', true], + ["single-quoted operator", "echo 'c || d'", true], + ["heredoc body", "cat < && echo done", false], +]; + +describe("shared tokenizer: display is a coarse merge of authz", () => { + for (const [desc, cmd, expectedAgreement] of CORPUS) { + test(desc, () => { + const authz = splitChainedCommand(cmd); + const display = groupChainSegmentsForDisplay(cmd); + + if (expectedAgreement) { + // For commands where we expect agreement, the display segments should + // be formable by merging consecutive authz segments joined by " | " + // (the only intentional difference: display keeps pipes inline). + let authzIdx = 0; + + for (const dseg of display) { + let found = false; + const current = authz[authzIdx]; + if (current !== undefined && current.trim() === dseg.trim()) { + authzIdx++; + found = true; + } else { + for (let end = authzIdx + 1; end < authz.length; end++) { + const candidate = authz + .slice(authzIdx, end + 1) + .map((s) => s.trim()) + .join(" | "); + if (candidate.trim() === dseg.trim()) { + authzIdx = end + 1; + found = true; + break; + } + } + } + expect(found).toBe(true); + } + + expect(authzIdx).toBe(authz.length); + } else { + // For known-divergent cases (subshell unwrap, dangling-redirect + // coalescing, empty-heredoc boundaries), both splitters should still + // produce non-empty, non-blank segments. We don't compare content + // because unwrapGroup strips parens/operators that display preserves. + expect(authz.length).toBeGreaterThanOrEqual(1); + expect(display.length).toBeGreaterThanOrEqual(1); + + for (const seg of authz) { + expect(seg.trim().length).toBeGreaterThanOrEqual(1); + } + for (const seg of display) { + expect(seg.trim().length).toBeGreaterThanOrEqual(1); + } + } + }); + } +}); + +describe("shared primitives produce consistent results", () => { + const sharedCorpus = [ + 'echo "hello && world"', + "echo 'c || d'", + "cat < { + const authz = splitChainedCommand(cmd); + const display = groupChainSegmentsForDisplay(cmd); + + expect(authz.length).toBeGreaterThanOrEqual(1); + expect(display.length).toBeGreaterThanOrEqual(1); + + // Every segment from both splitters should be non-empty after trim. + for (const seg of authz) { + expect(seg.trim().length).toBeGreaterThanOrEqual(1); + } + for (const seg of display) { + expect(seg.trim().length).toBeGreaterThanOrEqual(1); + } + }); + } +}); diff --git a/src/permission/shell-tokenizer.ts b/src/permission/shell-tokenizer.ts new file mode 100644 index 000000000..909204b20 --- /dev/null +++ b/src/permission/shell-tokenizer.ts @@ -0,0 +1,41 @@ +// Shared shell tokenizer helpers used by both the permission gate +// (src/permission/command.ts) and the display layer (src/tui/command-display.ts). +// Keeping a single copy ensures both paths agree on what counts as a heredoc +// opener, what `&` means in redirect context, and that `<<<` (here-string) is +// never mistaken for a heredoc. + +export function isRedirectAmpersand(next: string | undefined): boolean { + return !(next === undefined || next === " " || next === "\t"); +} + +// Parses a heredoc opener (`<<` or `<<-`) starting at `command[i]` (which must +// be the first "<"). Returns the terminating marker text and the exclusive end +// index of the line that opened the heredoc, so the caller can copy the opening +// line verbatim and resume scanning the heredoc body from there. +// `<<<` (here-string) is explicitly rejected — it is not a heredoc opener. +export function parseHeredocOpener( + command: string, + i: number, +): { marker: string; lineEnd: number } | null { + if (command[i] !== "<" || command[i + 1] !== "<" || command[i + 2] === "<") return null; + let j = i + 2; + if (command[j] === "-") j++; // <<- strips leading tabs + while (j < command.length && (command[j] === " " || command[j] === "\t")) j++; + let markerQuote: string | null = null; + if (command[j] === "'" || command[j] === '"') { + markerQuote = command[j] as string; + j++; + } + let marker = ""; + while ( + j < command.length && + command[j] !== "\n" && + command[j] !== markerQuote && + !(markerQuote === null && (command[j] === " " || command[j] === "\t")) + ) { + marker += command[j++]; + } + if (markerQuote !== null && command[j] === markerQuote) j++; + while (j < command.length && command[j] !== "\n") j++; + return { marker, lineEnd: j }; +} diff --git a/src/tui/command-display.ts b/src/tui/command-display.ts index 9c5107fa0..ad9b075da 100644 --- a/src/tui/command-display.ts +++ b/src/tui/command-display.ts @@ -7,37 +7,7 @@ // security decision. import { sliceTailToWidth, sliceToWidth, stringWidth } from "./view/height.js"; - -// `&` participates in a redirect when it opens a bash combined redirect -// (`&>file`) or duplicates a fd (`2>&1`, `<&-`); only a lone `&` word is the -// background operator. Mirrors the same rule in src/permission/command.ts. -function isRedirectAmpersand(next: string | undefined): boolean { - return !(next === undefined || next === " " || next === "\t"); -} - -// The marker word of a heredoc redirect starting at `i` (pointing at `<<`), -// or null when `<<` is not a heredoc opener (e.g. `<<<` here-string). -function parseHeredocMarker(command: string, i: number): string | null { - if (command[i] !== "<" || command[i + 1] !== "<" || command[i + 2] === "<") return null; - let j = i + 2; - if (command[j] === "-") j++; - while (command[j] === " " || command[j] === "\t") j++; - let markerQuote: string | null = null; - if (command[j] === "'" || command[j] === '"') { - markerQuote = command[j] as string; - j++; - } - let marker = ""; - while ( - j < command.length && - command[j] !== "\n" && - command[j] !== markerQuote && - !(markerQuote === null && (command[j] === " " || command[j] === "\t")) - ) { - marker += command[j++]; - } - return marker.length > 0 ? marker : null; -} +import { isRedirectAmpersand, parseHeredocOpener } from "../permission/shell-tokenizer.js"; // Mirrors the top-level boundary rules in splitChainedCommand (quote-, paren-, // heredoc- and continuation-aware; && / || / ; / newline / lone & are chain @@ -95,7 +65,7 @@ export function groupChainSegmentsForDisplay(command: string): string[] { } if (ch === "<" && command[i + 1] === "<") { - const marker = parseHeredocMarker(command, i); + const marker = parseHeredocOpener(command, i)?.marker ?? null; if (marker !== null) { let j = i; while (j < command.length && command[j] !== "\n") j++; @@ -223,7 +193,7 @@ export function verbatimCommandLines(text: string): VerbatimLine[] { } if (ch === "<" && normalized[i + 1] === "<" && heredocPending === null) { - const marker = parseHeredocMarker(normalized, i); + const marker = parseHeredocOpener(normalized, i)?.marker ?? null; if (marker !== null) heredocPending = marker; } current += ch; @@ -352,7 +322,7 @@ function segmentWords(segment: string): string[] { } if (ch === "<" && segment[i + 1] === "<") { - const marker = parseHeredocMarker(segment, i); + const marker = parseHeredocOpener(segment, i)?.marker ?? null; if (marker !== null) { push(); heredocMarker = marker; @@ -420,7 +390,7 @@ export function collapseSegmentPayloads(segment: string): CollapsedSegment { const ch = segment[i] as string; if (ch === "<" && segment[i + 1] === "<") { - const marker = parseHeredocMarker(segment, i); + const marker = parseHeredocOpener(segment, i)?.marker ?? null; if (marker !== null) { let j = i; while (j < segment.length && segment[j] !== "\n") j++; From a1333f0b1db27d63b3de6e542c8f52fe704da37e Mon Sep 17 00:00:00 2001 From: Sawyer Date: Wed, 2 Sep 2026 00:30:38 -0700 Subject: [PATCH 2/6] Cover here-string command boundaries --- src/permission/shell-tokenizer-property.test.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/src/permission/shell-tokenizer-property.test.ts b/src/permission/shell-tokenizer-property.test.ts index dec96dafa..eda7ebf02 100644 --- a/src/permission/shell-tokenizer-property.test.ts +++ b/src/permission/shell-tokenizer-property.test.ts @@ -26,6 +26,7 @@ const CORPUS: [string, string, boolean][] = [ ["nested parens", "((echo a) && echo b) && echo c", false], ["empty heredoc", "cat < && echo done", false], ]; From 59471ecc8758432856858fe2a141bb47bedf4452 Mon Sep 17 00:00:00 2001 From: Sawyer Date: Wed, 2 Sep 2026 13:20:53 -0700 Subject: [PATCH 3/6] Fix here-string command boundaries --- src/permission/shell-tokenizer-property.test.ts | 15 +++++++++++++++ src/permission/shell-tokenizer.ts | 9 ++++++++- 2 files changed, 23 insertions(+), 1 deletion(-) diff --git a/src/permission/shell-tokenizer-property.test.ts b/src/permission/shell-tokenizer-property.test.ts index eda7ebf02..39e0e28f3 100644 --- a/src/permission/shell-tokenizer-property.test.ts +++ b/src/permission/shell-tokenizer-property.test.ts @@ -1,5 +1,7 @@ import { test, expect, describe } from "bun:test"; +import type { ToolCall } from "@intx/types/runtime"; import { splitChainedCommand } from "./command.js"; +import { buildRequests } from "./classify.js"; import { groupChainSegmentsForDisplay } from "../tui/command-display.js"; // Corpus of chained commands exercising quotes, heredocs, subshells, and @@ -84,6 +86,19 @@ describe("shared tokenizer: display is a coarse merge of authz", () => { } }); +describe("here-string boundaries", () => { + test("keeps a following command outside the here-string approval scope", () => { + const command = "cat <<< payload\nrm -rf /"; + const expected = ["cat <<< payload", "rm -rf /"]; + + expect(splitChainedCommand(command)).toEqual(expected); + expect(groupChainSegmentsForDisplay(command)).toEqual(expected); + + const call: ToolCall = { id: "c", name: "run_shell", arguments: { command } }; + expect(buildRequests(call)[0]?.scopes.map((scope) => scope.pattern)).toEqual([command]); + }); +}); + describe("shared primitives produce consistent results", () => { const sharedCorpus = [ 'echo "hello && world"', diff --git a/src/permission/shell-tokenizer.ts b/src/permission/shell-tokenizer.ts index 909204b20..724f1805b 100644 --- a/src/permission/shell-tokenizer.ts +++ b/src/permission/shell-tokenizer.ts @@ -17,7 +17,14 @@ export function parseHeredocOpener( command: string, i: number, ): { marker: string; lineEnd: number } | null { - if (command[i] !== "<" || command[i + 1] !== "<" || command[i + 2] === "<") return null; + if ( + command[i] !== "<" || + command[i + 1] !== "<" || + command[i - 1] === "<" || + command[i + 2] === "<" + ) { + return null; + } let j = i + 2; if (command[j] === "-") j++; // <<- strips leading tabs while (j < command.length && (command[j] === " " || command[j] === "\t")) j++; From 65ef5946ead258cf9cc6e9d81982df6e8b9d245a Mon Sep 17 00:00:00 2001 From: Sawyer Date: Wed, 2 Sep 2026 13:31:58 -0700 Subject: [PATCH 4/6] Split commands after heredoc terminators --- src/permission/command.ts | 1 + .../shell-tokenizer-property.test.ts | 33 ++++++++++++++++--- 2 files changed, 29 insertions(+), 5 deletions(-) diff --git a/src/permission/command.ts b/src/permission/command.ts index 0f8f21290..3d4bdcfd1 100644 --- a/src/permission/command.ts +++ b/src/permission/command.ts @@ -43,6 +43,7 @@ export function splitChainedCommand(command: string): string[] { const lastLine = lines[lines.length - 2] ?? ""; if (lastLine.trim() === heredocMarker) { heredocMarker = null; + push(); } } continue; diff --git a/src/permission/shell-tokenizer-property.test.ts b/src/permission/shell-tokenizer-property.test.ts index 39e0e28f3..542a3eba2 100644 --- a/src/permission/shell-tokenizer-property.test.ts +++ b/src/permission/shell-tokenizer-property.test.ts @@ -2,6 +2,7 @@ import { test, expect, describe } from "bun:test"; import type { ToolCall } from "@intx/types/runtime"; import { splitChainedCommand } from "./command.js"; import { buildRequests } from "./classify.js"; +import { isRequestCoveredByGrant } from "./gate.js"; import { groupChainSegmentsForDisplay } from "../tui/command-display.js"; // Corpus of chained commands exercising quotes, heredocs, subshells, and @@ -9,8 +10,7 @@ import { groupChainSegmentsForDisplay } from "../tui/command-display.js"; // // `agreement` is true when both splitters are expected to produce the same // segments (modulo pipe merging), and false for cases where they intentionally -// diverge (subshell unwrap, dangling-redirect coalescing, empty-heredoc -// boundary handling). +// diverge (subshell unwrap and dangling-redirect coalescing). const CORPUS: [string, string, boolean][] = [ ["simple &&", "echo a && echo b", true], ["simple ||", "echo a || echo b", true], @@ -26,7 +26,6 @@ const CORPUS: [string, string, boolean][] = [ ["backslash continuation", "echo a \\\n&& echo b", true], ["heredoc with single-quoted marker", "cat <<'EOF'\nline; and && stuff\nEOF", true], ["nested parens", "((echo a) && echo b) && echo c", false], - ["empty heredoc", "cat < && echo done", false], @@ -68,8 +67,8 @@ describe("shared tokenizer: display is a coarse merge of authz", () => { expect(authzIdx).toBe(authz.length); } else { - // For known-divergent cases (subshell unwrap, dangling-redirect - // coalescing, empty-heredoc boundaries), both splitters should still + // For known-divergent cases (subshell unwrap and dangling-redirect + // coalescing), both splitters should still // produce non-empty, non-blank segments. We don't compare content // because unwrapGroup strips parens/operators that display preserves. expect(authz.length).toBeGreaterThanOrEqual(1); @@ -86,6 +85,30 @@ describe("shared tokenizer: display is a coarse merge of authz", () => { } }); +describe("heredoc boundaries", () => { + test("keeps a following command outside the heredoc approval scope", () => { + const command = "cat < scope.pattern)).toEqual([command]); + expect( + isRequestCoveredByGrant( + request, + { tool: "run_shell", pattern: "cat *" }, + undefined, + () => false, + { resolvedCwd: "/repo", roots: ["/repo"] }, + ), + ).toBe(false); + }); +}); + describe("here-string boundaries", () => { test("keeps a following command outside the here-string approval scope", () => { const command = "cat <<< payload\nrm -rf /"; From 3028929c9530ca407072ee628c6e138efe20b8af Mon Sep 17 00:00:00 2001 From: Sawyer Date: Wed, 2 Sep 2026 14:10:34 -0700 Subject: [PATCH 5/6] Unify shell authorization and display scanning --- src/permission/command.test.ts | 6 +- src/permission/command.ts | 260 ++---------- src/permission/gate.ts | 16 +- src/permission/permission.test.ts | 9 +- .../shell-tokenizer-property.test.ts | 99 ++++- src/permission/shell-tokenizer.ts | 394 ++++++++++++++++-- src/tui/command-display.ts | 328 +++++---------- 7 files changed, 591 insertions(+), 521 deletions(-) diff --git a/src/permission/command.test.ts b/src/permission/command.test.ts index 8878a0565..343253358 100644 --- a/src/permission/command.test.ts +++ b/src/permission/command.test.ts @@ -40,11 +40,9 @@ describe("splitChainedCommand heredocs", () => { expect(splitChainedCommand(command)).toEqual([command]); }); - test("scopes a terminated heredoc and its following lines together", () => { - // Newlines are not chain separators, so the whole multi-line script stays a - // single approval subject; the point is that it terminates rather than hangs. + test("splits a command after a terminated heredoc", () => { const command = "cat < out.txt\nhi\nEOF\necho done"; - expect(splitChainedCommand(command)).toEqual([command]); + expect(splitChainedCommand(command)).toEqual(["cat < out.txt\nhi\nEOF", "echo done"]); }); test("still splits ordinary chained commands", () => { diff --git a/src/permission/command.ts b/src/permission/command.ts index 3d4bdcfd1..c36591cf8 100644 --- a/src/permission/command.ts +++ b/src/permission/command.ts @@ -1,147 +1,18 @@ import type { ApprovalScope } from "./types.js"; import { escapeGlobLiteral } from "./matcher.js"; -import { isRedirectAmpersand, parseHeredocOpener } from "./shell-tokenizer.js"; +import { projectShellSegments, scanShellStructure } from "./shell-tokenizer.js"; -// Split a shell command into the individual commands it chains together, so each -// can be classified for security. The operator still approves the full command -// as one block (see buildRequests / gate). Operators recognised: && || | ; and a -// newline. Splitting is quote-aware — operators inside '...', "..." or `...` are -// part of an argument, not a separator. Heredoc bodies (<< 'MARKER' ... MARKER) -// are treated as atomic — newlines inside them are not chain boundaries. -// Parentheses group: operators inside a subshell or command substitution never -// split, and a segment that is exactly one `( ... )` group is unwrapped and its -// inner chain split recursively — so `(cd a && b)` yields `cd a` and `b`, not -// the fragment `(cd a`. +// Authorization projects every top-level boundary from the shared structural +// scan. Bare subshell groups are then recursively unwrapped so their inner +// commands remain independent approval subjects. export function splitChainedCommand(command: string): string[] { - const segments: string[] = []; - let current = ""; - let quote: '"' | "'" | "`" | null = null; - let heredocMarker: string | null = null; - let parenDepth = 0; - - const push = (): void => { - const trimmed = current.trim(); - current = ""; - if (trimmed.length === 0) return; - const inner = unwrapGroup(trimmed); - if (inner !== null) { - segments.push(...splitChainedCommand(inner)); - return; - } - segments.push(trimmed); - }; - - for (let i = 0; i < command.length; i++) { - const ch = command[i] as string; - - // Inside a heredoc body: scan for the terminating marker on its own line. - if (heredocMarker !== null) { - current += ch; - if (ch === "\n") { - // Check whether the line just completed is the marker. - const lines = current.split("\n"); - const lastLine = lines[lines.length - 2] ?? ""; - if (lastLine.trim() === heredocMarker) { - heredocMarker = null; - push(); - } - } - continue; - } - - if (quote !== null) { - current += ch; - if (ch === quote) quote = null; - continue; - } - if (ch === '"' || ch === "'" || ch === "`") { - quote = ch; - current += ch; - continue; - } - - // Shell line continuation: a backslash immediately before a newline is - // consumed by the shell (elides the newline for chaining purposes). Do not - // append the \ or split the segment; this prevents fragments like "\" from - // becoming approval subjects when agents emit continued commands. - if (ch === "\\") { - const after = command[i + 1]; - if (after === "\n" || after === "\r") { - i += 1; - if (after === "\r" && command[i + 1] === "\n") i += 1; - continue; - } - } - - // Detect heredoc redirect: << or <<- - if (ch === "<" && command[i + 1] === "<") { - const opener = parseHeredocOpener(command, i); - if (opener !== null) { - current += command.slice(i, opener.lineEnd); - i = opener.lineEnd - 1; - heredocMarker = opener.marker; - continue; - } - } - - if (ch === "(") { - parenDepth++; - current += ch; - continue; - } - if (ch === ")") { - if (parenDepth > 0) parenDepth--; - current += ch; - continue; - } - if (parenDepth > 0) { - current += ch; - 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 — - // the target got separated from its redirect, most often by a stray - // separator a model inserted mid-redirect (e.g. "cmd 2>& ; 1" meaning - // "cmd 2>&1"). Treat the operator as whitespace so the target rejoins the - // command it belongs to, instead of surfacing as its own "Run shell - // command" approval. A well-formed chain ("sleep 5 ; -1 ; echo end") has - // no dangling redirect before the separator, so it is never affected. - if ((ch === "&" && next === "&") || (ch === "|" && next === "|")) { - if (endsWithDanglingRedirect(current)) { - current = `${current.trimEnd()} `; - i++; - continue; - } - push(); - i++; - continue; - } - // `&` participates in a redirect when it opens a bash combined redirect - // (`&>file`) or duplicates a fd after `>`/`<` (`2>&1`, `<&-`). In those - // positions it is not a background operator and must not split the chain — - // otherwise `bun run build 2>&1` fragments into a real command and a stray - // `1`, and the operator gets a separate approval prompt for "1". - if (ch === "&" && isRedirectAmpersand(next)) { - current += ch; - continue; - } - // A lone "&" backgrounds the preceding command and starts a new one, so it - // is a chain boundary. Without this, "ls & rm -rf foo" is treated as a - // single segment and the approval scope is derived from the benign head. - if (ch === "|" || ch === ";" || ch === "\n" || ch === "&") { - if (endsWithDanglingRedirect(current)) { - current = `${current.trimEnd()} `; - continue; - } - push(); - continue; - } - current += ch; - } - push(); - return segments; + return projectShellSegments(command, { + splitPipes: true, + coalesceDanglingRedirects: true, + }).flatMap((segment) => { + const inner = unwrapGroup(segment); + return inner === null ? [segment] : splitChainedCommand(inner); + }); } // Remove genuine top-level full-line shell comments from command text before @@ -162,104 +33,21 @@ export function splitChainedCommand(command: string): string[] { // shells do not honor line continuation there), so it never extends the // comment past its own line. export function stripCommentLines(command: string): string { - let out = ""; - let line = ""; - // Whether the physical/logical line currently being scanned is a comment: - // "unknown" until its first non-whitespace, top-level character is seen. - let commentState: "unknown" | "yes" | "no" = "unknown"; - let quote: '"' | "'" | "`" | null = null; - let heredocMarker: string | null = null; - - const flushLine = (): void => { - if (commentState !== "yes") out += line; - line = ""; - commentState = "unknown"; - }; - - for (let i = 0; i < command.length; i++) { - const ch = command[i] as string; - - if (heredocMarker !== null) { - line += ch; - if (ch === "\n") { - const lines = line.split("\n"); - const lastLine = lines[lines.length - 2] ?? ""; - if (lastLine.trim() === heredocMarker) heredocMarker = null; - out += line; - line = ""; - } - continue; - } - - if (quote !== null) { - line += ch; - if (ch === quote) quote = null; - continue; - } - - if (ch === '"' || ch === "'" || ch === "`") { - quote = ch; - if (commentState === "unknown") commentState = "no"; - line += ch; - continue; - } - - // Line continuation only applies outside an already-open comment — inside - // one, a backslash is just another comment character. - if ( - commentState !== "yes" && - ch === "\\" && - (command[i + 1] === "\n" || command[i + 1] === "\r") - ) { - const after = command[i + 1] as string; - line += ch + after; - i += 1; - if (after === "\r" && command[i + 1] === "\n") { - line += "\n"; - i += 1; - } - if (commentState === "unknown") commentState = "no"; - // Deliberately do not flush: the next physical line is glued to this - // one and must never independently qualify as a comment start. - continue; - } - - if (commentState !== "yes" && ch === "<" && command[i + 1] === "<") { - const opener = parseHeredocOpener(command, i); - if (opener !== null) { - if (commentState === "unknown") commentState = "no"; - line += command.slice(i, opener.lineEnd); - i = opener.lineEnd - 1; - heredocMarker = opener.marker; - continue; - } - } + const comments = scanShellStructure(command).spans.filter( + (span) => span.kind === "comment" && span.fullLine, + ); + if (comments.length === 0) return command; - if (ch === "\n") { - line += ch; - flushLine(); - continue; - } - - if (ch === " " || ch === "\t") { - line += ch; - continue; - } - - if (commentState === "unknown") commentState = ch === "#" ? "yes" : "no"; - line += ch; + let out = ""; + let cursor = 0; + for (const comment of comments) { + let end = comment.end; + if (command[end] === "\r" && command[end + 1] === "\n") end += 2; + else if (command[end] === "\n") end++; + out += command.slice(cursor, comment.start).replace(/[ \t]+$/, ""); + cursor = end; } - flushLine(); - return out; -} - -// Whether `text` ends (ignoring trailing whitespace) in a redirect operator -// that has not yet received its target: a bare `>`/`<`, or a fd-duplication -// opener `>&`/`<&` awaiting the fd number. -const DANGLING_REDIRECT = /(?:>&|<&|>|<)$/; - -function endsWithDanglingRedirect(text: string): boolean { - return DANGLING_REDIRECT.test(text.trimEnd()); + return out + command.slice(cursor); } // The inner chain of a segment that is exactly one parenthesised group, or null diff --git a/src/permission/gate.ts b/src/permission/gate.ts index ee5619002..455f5fa3e 100644 --- a/src/permission/gate.ts +++ b/src/permission/gate.ts @@ -336,11 +336,10 @@ 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. +// Backslashes mark exact-escaped patterns and must not be interpreted again as +// shell source. Inline comments likewise remain one exact whole-pattern grant. function canSafelyMintPerSegment(pattern: string): boolean { - if (/\\["`]/.test(pattern)) return false; + if (pattern.includes("\\")) return false; if (pattern.includes("#")) return false; return true; } @@ -391,12 +390,9 @@ export function createPermissionGate(options: PermissionGateOptions): Permission // 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. + // Exact patterns contain backslash escapes for matcher metacharacters and + // cannot be safely reinterpreted as shell source. Inline comments also stay + // whole so commented text can never become a separately minted grant. const normalizedPattern = tool === "run_shell" ? stripCommentLines(outcome.persist.pattern).trim() diff --git a/src/permission/permission.test.ts b/src/permission/permission.test.ts index d1722bbd4..905ce0ef1 100644 --- a/src/permission/permission.test.ts +++ b/src/permission/permission.test.ts @@ -2134,13 +2134,12 @@ 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"); - if (built === undefined) throw new Error("expected exact command scope"); + if (built?.pattern === null || built?.pattern === undefined) { + throw new Error("expected exact command scope"); + } const gate = createPermissionGate({ approvals: [], requestApproval: async () => ({ allow: true, persist: { ...built, grant: "project" } }), @@ -2150,7 +2149,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)).toEqual([built.pattern]); expect(persisted.map((a) => a.pattern)).not.toContain("touch PWNED"); }); diff --git a/src/permission/shell-tokenizer-property.test.ts b/src/permission/shell-tokenizer-property.test.ts index 542a3eba2..b8988abad 100644 --- a/src/permission/shell-tokenizer-property.test.ts +++ b/src/permission/shell-tokenizer-property.test.ts @@ -107,6 +107,64 @@ describe("heredoc boundaries", () => { ), ).toBe(false); }); + + const heredocCases = [ + { + name: "CRLF terminator", + command: "cat <