From a4e581205c7a5d514fa8591c8e62ee77264d66d8 Mon Sep 17 00:00:00 2001 From: Sawyer Cutler Date: Fri, 11 Sep 2026 21:25:18 -0700 Subject: [PATCH 1/5] Treat here-strings as inline arguments, never heredoc openers --- src/permission/permission.test.ts | 22 ++++++++++++++++++++++ src/shell/command-segments.ts | 9 +++++++++ src/tui/command-display.test.ts | 12 ++++++++++++ src/tui/command-display.ts | 3 +++ 4 files changed, 46 insertions(+) diff --git a/src/permission/permission.test.ts b/src/permission/permission.test.ts index f5ab455db..f7d213c65 100644 --- a/src/permission/permission.test.ts +++ b/src/permission/permission.test.ts @@ -176,6 +176,23 @@ describe("splitChainedCommand", () => { expect(splitChainedCommand(cmd)).toHaveLength(2); }); + test("does not treat a here-string (<<<) as a heredoc opener", () => { + expect(splitChainedCommand('cat <<< "word" && echo hi')).toEqual([ + 'cat <<< "word"', + "echo hi", + ]); + expect(splitChainedCommand("cmd << { + const cmd = "cat <<-EOF\nbody\nEOF"; + expect(splitChainedCommand(cmd)).toHaveLength(1); + }); + test("treats shell line continuation (backslash + newline) as glue, not a chain split", () => { // Common pattern from agents emitting readable multi-line shell calls. expect(splitChainedCommand("cd foo && \\\nbun test")).toEqual([ @@ -3962,6 +3979,11 @@ describe("stripCommentLines", () => { expect(stripCommentLines(command)).toBe(command); }); + test("a here-string never swallows a later line into a heredoc body", () => { + const command = 'cat <<< "word"\n# a real comment'; + expect(stripCommentLines(command)).toBe('cat <<< "word"\n'); + }); + test("leaves a real command with a trailing inline comment untouched", () => { expect(stripCommentLines("ls -la # list files")).toBe( "ls -la # list files", diff --git a/src/shell/command-segments.ts b/src/shell/command-segments.ts index 2939d2fb1..b1303f27c 100644 --- a/src/shell/command-segments.ts +++ b/src/shell/command-segments.ts @@ -152,6 +152,15 @@ export function parseHeredocOpener( i: number, ): { marker: string; lineEnd: number } | null { if (command[i] !== "<" || command[i + 1] !== "<") return null; + // `<<<` is a here-string, not a heredoc: its word is an inline argument, + // so there is no marker line to wait for. + if (command[i + 2] === "<") return null; + // A `<<` opener cannot start in the middle of a `<` run: when the scan + // reaches the second `<` of a `<<<` here-string, the character ahead is no + // longer `<`, so only this backward guard stops it from parsing the + // here-string word as a heredoc marker and swallowing the rest of the + // command as body. + if (command[i - 1] === "<") return null; let j = i + 2; if (command[j] === "-") j++; // <<- strips leading tabs // Skip whitespace between << and the marker word. diff --git a/src/tui/command-display.test.ts b/src/tui/command-display.test.ts index e92d3225f..eafd55775 100644 --- a/src/tui/command-display.test.ts +++ b/src/tui/command-display.test.ts @@ -61,6 +61,18 @@ test("heredoc bodies are not enumerated as segments", () => { ]); }); +test("a here-string never opens a pending heredoc", () => { + expect(groupChainSegmentsForDisplay('cat <<< "word" && echo hi')).toEqual([ + 'cat <<< "word"', + "echo hi", + ]); + expect(groupChainSegmentsForDisplay("cmd << { expect(verbatimCommandLines('echo "a\nb"\necho two')).toEqual([ { text: 'echo "a↵b"', isComment: false }, diff --git a/src/tui/command-display.ts b/src/tui/command-display.ts index 003a75885..608321790 100644 --- a/src/tui/command-display.ts +++ b/src/tui/command-display.ts @@ -5,6 +5,9 @@ import { sliceTailToWidth, sliceToWidth, stringWidth } from "./view/height.js"; function parseHeredocMarker(command: string, i: number): string | null { if (command[i] !== "<" || command[i + 1] !== "<" || command[i + 2] === "<") return null; + // Same `<`-run rule as parseHeredocOpener: the second `<` of a `<<<` + // here-string must not parse the here-string word as a heredoc marker. + if (command[i - 1] === "<") return null; let j = i + 2; if (command[j] === "-") j++; while (command[j] === " " || command[j] === "\t") j++; From 32e6d6ed1c725bdb4b7f155b294387f1cd38ddf4 Mon Sep 17 00:00:00 2001 From: Sawyer Cutler Date: Fri, 11 Sep 2026 21:32:44 -0700 Subject: [PATCH 2/5] Match heredoc terminators exactly, with tab-stripping only for <<- Trim-based closing accepted a space-indented marker for plain << and kept a stray carriage return in CRLF markers, so the splitter and the approval display disagreed with the shell about where a heredoc ends. Compare exact lines instead. --- src/permission/command.test.ts | 42 ++++++++++++++++++++ src/permission/command.ts | 12 +++++- src/shell/command-segments.ts | 38 ++++++++++++++---- src/tui/command-display.test.ts | 38 ++++++++++++++++++ src/tui/command-display.ts | 70 ++++++++++++++++++++++++--------- 5 files changed, 172 insertions(+), 28 deletions(-) diff --git a/src/permission/command.test.ts b/src/permission/command.test.ts index 68e94b465..1cf2ea282 100644 --- a/src/permission/command.test.ts +++ b/src/permission/command.test.ts @@ -151,3 +151,45 @@ describe("splitChainedCommand redirect and background fragments", () => { expect(splitChainedCommand(prose)).toEqual([prose]); }); }); + +describe("splitChainedCommand heredoc boundaries", () => { + // A marker glued to `<<` is still an opener, and separators trailing the + // opener line do not split while the heredoc body is pending. + test("keeps separators on the opener line inside a glued-marker heredoc", () => { + const command = "cat < { + const command = "cat < { + expect( + splitChainedCommand("cat <<-EOF\nbody\n\tEOF\n&& echo evil"), + ).toEqual(["cat <<-EOF\nbody\n\tEOF", "echo evil"]); + const spaces = "cat < { + const command = "cat < { + expect( + splitChainedCommand("cat < { if (commentState !== "yes") out += line; @@ -44,7 +48,10 @@ export function stripCommentLines(command: string): string { if (ch === "\n") { const lines = line.split("\n"); const lastLine = lines[lines.length - 2] ?? ""; - if (lastLine.trim() === heredocMarker) heredocMarker = null; + if (isHeredocTerminator(lastLine, heredocMarker, heredocStripTabs)) { + heredocMarker = null; + heredocStripTabs = false; + } out += line; line = ""; } @@ -91,6 +98,7 @@ export function stripCommentLines(command: string): string { line += command.slice(i, opener.lineEnd); i = opener.lineEnd - 1; heredocMarker = opener.marker; + heredocStripTabs = opener.stripTabs; continue; } } diff --git a/src/shell/command-segments.ts b/src/shell/command-segments.ts index b1303f27c..f0f25ae37 100644 --- a/src/shell/command-segments.ts +++ b/src/shell/command-segments.ts @@ -13,6 +13,7 @@ export function splitChainedCommand(command: string): string[] { let current = ""; let quote: '"' | "'" | "`" | null = null; let heredocMarker: string | null = null; + let heredocStripTabs = false; let parenDepth = 0; const push = (): void => { @@ -31,14 +32,16 @@ export function splitChainedCommand(command: string): string[] { const ch = command[i] as string; // Inside a heredoc body: scan for the terminating marker on its own line. + // A second `<<` down here is payload, never a nested opener. 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) { + if (isHeredocTerminator(lastLine, heredocMarker, heredocStripTabs)) { heredocMarker = null; + heredocStripTabs = false; } } continue; @@ -75,6 +78,7 @@ export function splitChainedCommand(command: string): string[] { current += command.slice(i, opener.lineEnd); i = opener.lineEnd - 1; heredocMarker = opener.marker; + heredocStripTabs = opener.stripTabs; continue; } } @@ -142,15 +146,16 @@ export function splitChainedCommand(command: string): string[] { } // 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. +// be the first "<"). Returns the terminating marker text, the exclusive end +// index of the line that opened the heredoc, and whether the opener was `<<-` +// (which strips leading tabs from the closing line) — 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. export function parseHeredocOpener( command: string, i: number, -): { marker: string; lineEnd: number } | null { +): { marker: string; lineEnd: number; stripTabs: boolean } | null { if (command[i] !== "<" || command[i + 1] !== "<") return null; // `<<<` is a here-string, not a heredoc: its word is an inline argument, // so there is no marker line to wait for. @@ -162,7 +167,8 @@ export function parseHeredocOpener( // command as body. if (command[i - 1] === "<") return null; let j = i + 2; - if (command[j] === "-") j++; // <<- strips leading tabs + const stripTabs = command[j] === "-"; + if (stripTabs) 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). @@ -184,9 +190,27 @@ export function parseHeredocOpener( marker += command[j++]; } if (markerQuote !== null && command[j] === markerQuote) j++; + // A CRLF opener line leaves a trailing \r on a bare marker word; the + // terminator line carries the same \r, so drop it here and compare + // CR-stripped lines at close time. + if (marker.endsWith("\r")) marker = marker.slice(0, -1); // Advance j to the end of the line that opened the heredoc. while (j < command.length && command[j] !== "\n") j++; - return { marker, lineEnd: j }; + return { marker, lineEnd: j, stripTabs }; +} + +// Whether a completed body line closes a heredoc: an exact match against the +// marker, ignoring one trailing CR from CRLF input and leading tabs only when +// the opener was `<<-`. A space-indented close never terminates a plain `<<` +// heredoc — it stays body, exactly like a real shell. +export function isHeredocTerminator( + line: string, + marker: string, + stripTabs: boolean, +): boolean { + const noCR = line.endsWith("\r") ? line.slice(0, -1) : line; + const candidate = stripTabs ? noCR.replace(/^\t+/, "") : noCR; + return candidate === marker; } // Whether `text` ends (ignoring trailing whitespace) in a redirect operator diff --git a/src/tui/command-display.test.ts b/src/tui/command-display.test.ts index eafd55775..0bcd761ed 100644 --- a/src/tui/command-display.test.ts +++ b/src/tui/command-display.test.ts @@ -17,6 +17,9 @@ test("display segments exactly match authorization segments", () => { `echo "a && b" | cat`, "cat > /tmp/out.md << 'EOF'\nline one; still body && more\nEOF", "cat << 'EOF'\nline one; still body && more\nEOF\necho after", + "cat <<-EOF\nbody\n\tEOF\n&& echo evil", + "cat <&1 | tail -3)", "echo start && (cd apps/web && bun test) && echo done", @@ -102,6 +105,32 @@ test("heredoc body lines are never flagged as comments", () => { ]); }); +test("a tab-indented line closes a <<- heredoc; spaces never close <<", () => { + expect(verbatimCommandLines("cat <<-EOF\n\tbody\n\tEOF")).toEqual([ + { text: "cat <<-EOF", isComment: false }, + { text: "\tbody", isComment: false }, + { text: "\tEOF", isComment: false }, + ]); + // The space-indented marker stays body, so a later # line is still payload. + expect(verbatimCommandLines("cat < { + expect( + verbatimCommandLines("cat < { expect(verbatimCommandLines("echo safe\rrm -rf /")).toEqual([ { text: "echo safe↵rm -rf /", isComment: false }, @@ -128,6 +157,15 @@ test("collapseSegmentPayloads collapses a heredoc body to a placeholder with a l ]); }); +test("collapseSegmentPayloads closes a <<- body on its tab-indented marker", () => { + const segment = "cat <<-EOF\n\tbody\n\tEOF\n&& echo done"; + const { display, payloads } = collapseSegmentPayloads(segment); + expect(display).toBe("cat <<-EOF && echo done"); + expect(payloads).toEqual([ + { placeholder: "", lines: ["\tbody"] }, + ]); +}); + test("collapseSegmentPayloads collapses a multi-line -m message to ", () => { const segment = 'git commit -m "line one\nline two\nline three"'; const { display, payloads } = collapseSegmentPayloads(segment); diff --git a/src/tui/command-display.ts b/src/tui/command-display.ts index 608321790..52bd503a2 100644 --- a/src/tui/command-display.ts +++ b/src/tui/command-display.ts @@ -1,15 +1,24 @@ -import { splitChainedCommand } from "../shell/command-segments.js"; +import { + isHeredocTerminator, + splitChainedCommand, +} from "../shell/command-segments.js"; import { sliceTailToWidth, sliceToWidth, stringWidth } from "./view/height.js"; // 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 { +// stripTabs is true only for `<<-`, which strips leading tabs from the +// closing line. +function parseHeredocMarker( + command: string, + i: number, +): { marker: string; stripTabs: boolean } | null { if (command[i] !== "<" || command[i + 1] !== "<" || command[i + 2] === "<") return null; // Same `<`-run rule as parseHeredocOpener: the second `<` of a `<<<` // here-string must not parse the here-string word as a heredoc marker. if (command[i - 1] === "<") return null; let j = i + 2; - if (command[j] === "-") j++; + const stripTabs = command[j] === "-"; + if (stripTabs) j++; while (command[j] === " " || command[j] === "\t") j++; let markerQuote: string | null = null; if (command[j] === "'" || command[j] === '"') { @@ -25,7 +34,8 @@ function parseHeredocMarker(command: string, i: number): string | null { ) { marker += command[j++]; } - return marker.length > 0 ? marker : null; + if (marker.endsWith("\r")) marker = marker.slice(0, -1); + return marker.length > 0 ? { marker, stripTabs } : null; } export function groupChainSegmentsForDisplay(command: string): string[] { @@ -53,7 +63,8 @@ export function verbatimCommandLines(text: string): VerbatimLine[] { let current = ""; let quote: '"' | "'" | "`" | null = null; let heredocMarker: string | null = null; - let heredocPending: string | null = null; + let heredocStripTabs = false; + let heredocPending: { marker: string; stripTabs: boolean } | null = null; let continued = false; const push = (): void => { @@ -76,9 +87,16 @@ export function verbatimCommandLines(text: string): VerbatimLine[] { if (heredocMarker !== null) { if (ch === "\n") { - const done = current.trim() === heredocMarker; + const done = isHeredocTerminator( + current, + heredocMarker, + heredocStripTabs, + ); push(); - if (done) heredocMarker = null; + if (done) { + heredocMarker = null; + heredocStripTabs = false; + } continue; } current += ch; @@ -105,7 +123,8 @@ export function verbatimCommandLines(text: string): VerbatimLine[] { if (ch === "\n") { push(); - heredocMarker = heredocPending; + heredocMarker = heredocPending?.marker ?? null; + heredocStripTabs = heredocPending?.stripTabs ?? false; heredocPending = null; continue; } @@ -117,8 +136,8 @@ export function verbatimCommandLines(text: string): VerbatimLine[] { } if (ch === "<" && normalized[i + 1] === "<" && heredocPending === null) { - const marker = parseHeredocMarker(normalized, i); - if (marker !== null) heredocPending = marker; + const opener = parseHeredocMarker(normalized, i); + if (opener !== null) heredocPending = opener; } current += ch; } @@ -211,7 +230,8 @@ function segmentWords(segment: string): string[] { let current = ""; let quote: '"' | "'" | "`" | null = null; let heredocMarker: string | null = null; - let heredocPending: string | null = null; + let heredocStripTabs = false; + let heredocPending: { marker: string; stripTabs: boolean } | null = null; const push = (): void => { if (current.length > 0) words.push(current); @@ -226,8 +246,15 @@ function segmentWords(segment: string): string[] { if (ch === "\n") { let lineEnd = segment.indexOf("\n", i + 1); if (lineEnd === -1) lineEnd = segment.length; - if (segment.slice(i + 1, lineEnd).trim() === heredocMarker) { + if ( + isHeredocTerminator( + segment.slice(i + 1, lineEnd), + heredocMarker, + heredocStripTabs, + ) + ) { heredocMarker = null; + heredocStripTabs = false; i = lineEnd; } } @@ -249,10 +276,11 @@ function segmentWords(segment: string): string[] { } if (ch === "<" && segment[i + 1] === "<") { - const marker = parseHeredocMarker(segment, i); - if (marker !== null) { + const opener = parseHeredocMarker(segment, i); + if (opener !== null) { push(); - heredocPending = marker; + heredocPending = opener; + const marker = opener.marker; i += 2; if (segment[i] === "-") i++; while (segment[i] === " " || segment[i] === "\t") i++; @@ -260,6 +288,9 @@ function segmentWords(segment: string): string[] { segment[i] === "'" || segment[i] === '"' ? segment[i++] : null; i += marker.length; if (markerQuote !== null && segment[i] === markerQuote) i++; + // The parser strips a CRLF trailing \r from the marker, so skip it + // here too instead of leaking it into the word stream. + if (segment[i] === "\r") i++; continue; } } @@ -267,7 +298,8 @@ function segmentWords(segment: string): string[] { if (ch === " " || ch === "\t" || ch === "\n") { push(); if (ch === "\n" && heredocPending !== null) { - heredocMarker = heredocPending; + heredocMarker = heredocPending.marker; + heredocStripTabs = heredocPending.stripTabs; heredocPending = null; } i++; @@ -325,8 +357,8 @@ export function collapseSegmentPayloads(segment: string): CollapsedSegment { const ch = segment[i] as string; if (ch === "<" && segment[i + 1] === "<") { - const marker = parseHeredocMarker(segment, i); - if (marker !== null) { + const opener = parseHeredocMarker(segment, i); + if (opener !== null) { let j = i; while (j < segment.length && segment[j] !== "\n") j++; display += segment.slice(i, j); @@ -336,7 +368,7 @@ export function collapseSegmentPayloads(segment: string): CollapsedSegment { let lineEnd = segment.indexOf("\n", i); if (lineEnd === -1) lineEnd = segment.length; const line = segment.slice(i, lineEnd); - if (line.trim() === marker) { + if (isHeredocTerminator(line, opener.marker, opener.stripTabs)) { i = lineEnd + 1; break; } From 15968a1629f08bdca21fe0038b2cc7872d57a803 Mon Sep 17 00:00:00 2001 From: Sawyer Cutler Date: Fri, 11 Sep 2026 22:53:32 -0700 Subject: [PATCH 3/5] Ignore heredoc openers inside arithmetic and comments A << inside ((/$(( is the left-shift operator and a << after a top-level # is documentation, so neither the splitter nor the approval display may open a heredoc there and swallow the following chain. Track arithmetic depth and skip #-to-EOL comments in both. --- src/permission/command.test.ts | 66 +++++++++++++++++++++++++++++++++ src/shell/command-segments.ts | 65 ++++++++++++++++++++++++++++++-- src/tui/command-display.test.ts | 39 +++++++++++++++++++ src/tui/command-display.ts | 34 ++++++++++++++++- 4 files changed, 198 insertions(+), 6 deletions(-) diff --git a/src/permission/command.test.ts b/src/permission/command.test.ts index 1cf2ea282..8b839f8aa 100644 --- a/src/permission/command.test.ts +++ b/src/permission/command.test.ts @@ -193,3 +193,69 @@ describe("splitChainedCommand heredoc boundaries", () => { ).toEqual(["cat < { + // Inside `((` / `$((` the `<<` token is the left-shift operator, never a + // heredoc opener — the chain after it must still split. + test("never opens a heredoc inside arithmetic expansion", () => { + expect(splitChainedCommand("echo $((a<<1))")).toEqual(["echo $((a<<1))"]); + expect(splitChainedCommand("echo $((a << 1)) && echo done")).toEqual([ + "echo $((a << 1))", + "echo done", + ]); + }); + + test("never opens a heredoc inside a (( )) arithmetic command", () => { + expect(splitChainedCommand("((x = a << 1)) && echo done")).toEqual([ + "x = a << 1", + "echo done", + ]); + }); + + // A bare `( ... )` subshell is not arithmetic: a heredoc inside it is real. + test("still opens a heredoc inside a bare-paren subshell", () => { + const command = "(cat < { + expect(splitChainedCommand("# example: cat < { + const command = "# (( \ncat < { + expect(splitChainedCommand("# note && rm -rf /")).toEqual([ + "# note", + "rm -rf /", + ]); + }); + + // A `#` line inside a genuine heredoc body stays payload: the marker still + // closes and the following chain still splits. + test("keeps a # line inside a heredoc body as payload", () => { + const command = "cat < { const trimmed = current.trim(); @@ -72,7 +79,20 @@ export function splitChainedCommand(command: string): string[] { } // Detect heredoc redirect: << or <<- - if (ch === "<" && command[i + 1] === "<") { + // A top-level `#` starts a comment through end of line: a `<<` down there + // (e.g. `# example: cat < 0) arithDepth--; if (parenDepth > 0) parenDepth--; current += ch; continue; @@ -145,6 +170,38 @@ export function splitChainedCommand(command: string): string[] { return segments; } +// Whether text[i] opens an arithmetic context (`((` or `$((`): inside it `<<` +// is the left-shift operator, never a heredoc opener. Keyed on the doubled +// paren — a bare `( ... )` subshell can still contain a genuine heredoc. +// Deliberately not a full arithmetic evaluator: callers only track depth. +export function isArithmeticOpener(text: string, i: number): boolean { + return text[i] === "(" && text[i + 1] === "("; +} + +// Whether text[i] closes one arithmetic-context level (`))`). +export function isArithmeticCloser(text: string, i: number): boolean { + return text[i] === ")" && text[i + 1] === ")"; +} + +// Whether text[i] starts a `#`-to-EOL comment: at the very start of the input +// or right after whitespace, a newline, or a command separator (`;`, `&`, +// `|`, `(`). A `#` glued to a word (`foo#bar`, `$#`, `${a#b}`) is data. +export function isCommentStart(text: string, i: number): boolean { + if (text[i] !== "#") return false; + if (i === 0) return true; + const prev = text[i - 1] as string; + return ( + prev === " " || + prev === "\t" || + prev === "\r" || + prev === "\n" || + prev === ";" || + prev === "&" || + prev === "|" || + prev === "(" + ); +} + // Parses a heredoc opener (`<<` or `<<-`) starting at `command[i]` (which must // be the first "<"). Returns the terminating marker text, the exclusive end // index of the line that opened the heredoc, and whether the opener was `<<-` diff --git a/src/tui/command-display.test.ts b/src/tui/command-display.test.ts index 0bcd761ed..f322e77c8 100644 --- a/src/tui/command-display.test.ts +++ b/src/tui/command-display.test.ts @@ -23,6 +23,11 @@ test("display segments exactly match authorization segments", () => { "cmd1 && \\\ncmd2", "(cd packages/shared && bunx tsc --noEmit 2>&1 | tail -3)", "echo start && (cd apps/web && bun test) && echo done", + "echo $((a << 1)) && echo done", + "((x = a << 1)) && echo done", + "# example: cat < { ]); }); +test("a << inside arithmetic never opens a pending heredoc", () => { + expect(groupChainSegmentsForDisplay("echo $((a << 1)) && echo done")).toEqual( + ["echo $((a << 1))", "echo done"], + ); + // With no pending heredoc, a later line is ordinary text, never body. + expect(verbatimCommandLines("echo $((a<<1))\nEOF\necho done")).toEqual([ + { text: "echo $((a<<1))", isComment: false }, + { text: "EOF", isComment: false }, + { text: "echo done", isComment: false }, + ]); +}); + +test("a << inside a comment documents rather than opens", () => { + expect(verbatimCommandLines("# example: cat < { + // Mirrors the splitter pin: `# ((` is comment text, so the heredoc opens + // here exactly as it does for authorization and `&& echo done` separates. + expect( + verbatimCommandLines("# (( \ncat < { expect(verbatimCommandLines('echo "a\nb"\necho two')).toEqual([ { text: 'echo "a↵b"', isComment: false }, diff --git a/src/tui/command-display.ts b/src/tui/command-display.ts index 52bd503a2..1246e51a5 100644 --- a/src/tui/command-display.ts +++ b/src/tui/command-display.ts @@ -1,4 +1,7 @@ import { + isArithmeticCloser, + isArithmeticOpener, + isCommentStart, isHeredocTerminator, splitChainedCommand, } from "../shell/command-segments.js"; @@ -66,6 +69,9 @@ export function verbatimCommandLines(text: string): VerbatimLine[] { let heredocStripTabs = false; let heredocPending: { marker: string; stripTabs: boolean } | null = null; let continued = false; + // Arithmetic depth (`((` / `$((`): `<<` inside is the shift operator and + // `#`-to-EOL comments never open a heredoc — mirrors the splitter. + let arithDepth = 0; const push = (): void => { const isComment = @@ -135,7 +141,25 @@ export function verbatimCommandLines(text: string): VerbatimLine[] { continue; } - if (ch === "<" && normalized[i + 1] === "<" && heredocPending === null) { + if (isArithmeticOpener(normalized, i)) arithDepth++; + else if (isArithmeticCloser(normalized, i) && arithDepth > 0) arithDepth--; + + // A top-level `#` comment runs to end of line: `<<` inside it documents + // rather than opens. The line still renders whole (see push's isComment). + if (arithDepth === 0 && isCommentStart(normalized, i)) { + let j = i; + while (j < normalized.length && normalized[j] !== "\n") j++; + current += normalized.slice(i, j); + i = j - 1; + continue; + } + + if ( + arithDepth === 0 && + ch === "<" && + normalized[i + 1] === "<" && + heredocPending === null + ) { const opener = parseHeredocMarker(normalized, i); if (opener !== null) heredocPending = opener; } @@ -232,6 +256,9 @@ function segmentWords(segment: string): string[] { let heredocMarker: string | null = null; let heredocStripTabs = false; let heredocPending: { marker: string; stripTabs: boolean } | null = null; + // Arithmetic depth (`((` / `$((`): `<<` inside shifts, never opens — + // mirrors the splitter (keyed on arithmetic, NOT on bare parens). + let arithDepth = 0; const push = (): void => { if (current.length > 0) words.push(current); @@ -275,7 +302,10 @@ function segmentWords(segment: string): string[] { continue; } - if (ch === "<" && segment[i + 1] === "<") { + if (isArithmeticOpener(segment, i)) arithDepth++; + else if (isArithmeticCloser(segment, i) && arithDepth > 0) arithDepth--; + + if (arithDepth === 0 && ch === "<" && segment[i + 1] === "<") { const opener = parseHeredocMarker(segment, i); if (opener !== null) { push(); From c3aeb9aab30a52643f8c647aa4de6243a7dc8e96 Mon Sep 17 00:00:00 2001 From: Sawyer Cutler Date: Fri, 11 Sep 2026 22:48:20 -0700 Subject: [PATCH 4/5] Lock secret-before-listing ordering for shell secret paths MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Pure ls of a secret name still asks, and chains flag the content-reading half; bounded listings stay exempt and unbounded listings still ask. The secret-first ordering already holds at all three sites (classify auto-allow, gate segment guard, auto-shell policy) — these tests pin it. --- src/permission/classify-security.test.ts | 49 ++++++++++++++++++++++++ 1 file changed, 49 insertions(+) diff --git a/src/permission/classify-security.test.ts b/src/permission/classify-security.test.ts index 5d137142c..8dcc75e2f 100644 --- a/src/permission/classify-security.test.ts +++ b/src/permission/classify-security.test.ts @@ -1080,3 +1080,52 @@ describe("CL-6988 — nested / escaped interpreter peels do not auto-allow", () expect(rule?.effect === "ask" || rule?.effect === "deny").toBe(true); }); }); + +describe("CL-5420 — secret checks run before pure-listing exemptions", () => { + test("a pure listing of a secret name still asks", () => { + const rule = autoShellRuleForCall(shellCall("ls .env")); + expect(rule?.name).toBe("sensitive-path"); + expect(rule?.effect).toBe("ask"); + }); + + test("a chain with a safe listing half flags the content-reading half", () => { + const rule = autoShellRuleForCall(shellCall("ls /tmp && cat .env")); + expect(rule?.name).toBe("sensitive-path"); + expect(rule?.effect).toBe("ask"); + }); + + test("a bounded listing with no secret reference stays exempt", () => { + expect(autoShellRuleForCall(shellCall("ls /tmp"))).toBeUndefined(); + }); + + test("a flag-glued secret path asks", () => { + const rule = autoShellRuleForCall( + shellCall("bun --env-file=.env run publish.ts"), + ); + expect(rule?.name).toBe("sensitive-path"); + expect(rule?.effect).toBe("ask"); + }); + + test("unbounded listing still asks", () => { + expect(autoShellRuleForCall(shellCall("ls -R"))?.name).toBe( + "unbounded-listing", + ); + }); + + test("the gate asks on a pure listing of a secret name", async () => { + let asked = 0; + const gate = createPermissionGate({ + approvals: [], + requestApproval: async () => { + asked++; + return { allow: false }; + }, + interactive: true, + skipPermissions: false, + reactorGated: false, + }); + const verdict = await gate.evaluate(shellCall("ls .env")); + expect(verdict.allowed).toBe(false); + expect(asked).toBe(1); + }); +}); From 98ae0ea7005ec027a761c26b90519967656b2e2e Mon Sep 17 00:00:00 2001 From: Sawyer Cutler Date: Sat, 12 Sep 2026 10:17:51 -0700 Subject: [PATCH 5/5] Document list-free listings and dump-locked secret reads in auto mode --- docs/IMPLEMENTATION.md | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/docs/IMPLEMENTATION.md b/docs/IMPLEMENTATION.md index 9852857a1..257704bed 100644 --- a/docs/IMPLEMENTATION.md +++ b/docs/IMPLEMENTATION.md @@ -185,6 +185,17 @@ When auto is on, the gate auto-allows workspace file tools in `AUTO_ALLOWED_TOOL Unmatched shell auto-allows, including contained non-force `git worktree add`/`remove`/`prune` and read-only `list`. Path-arg tools that escape the workspace are denied at authorize time (the same sandbox path-escape enforces at execution). Writes under the in-workspace session state root (legacy `.agent-state`), mutating MCP, and unknown built-ins still prompt. Authorization hard-denies (catastrophic commands, open-ended shell search) remain independent of auto mode. +Listings are list-free, dumps are dump-locked: a bounded `ls`/`tree` prints names only, so it auto-allows even in a directory containing a secret file — but the secret check runs before the listing exemption, so naming the secret itself still asks. Anything that dumps file contents never auto-allows in auto mode; shell references stay ask (not deny) so legitimate uses proceed after an explicit yes, while path-keyed tools hard-deny. + +| Command | Verdict | +| ----------------------------------------- | ------------------------------------------------------- | +| `ls` in a directory containing `.env` | auto-allow (names only, no secret named) | +| `ls .env` | ask (secret check beats the listing exemption) | +| `cat .env`, `head .corbits/settings.json` | ask, never auto-allow | +| `bun --env-file=.env run …` | ask; runs after an explicit yes | +| `read_file` on `.env` | hard deny via secret-guard, even under skip-permissions | +| `cat README.md` in the workspace | auto-allow under the existing contained-read rules | + ### Reasoning Effort **Shift+Tab** in the TUI cycles reasoning effort for the live model (`cycleReasoningEffort` in `src/provider/reasoning-effort.ts`); the runner rebuilds inference sources and the prompt-border `profile · model · effort` label so the next turn picks it up. Plain Tab still toggles focus.