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. 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); + }); +}); diff --git a/src/permission/command.test.ts b/src/permission/command.test.ts index 68e94b465..8b839f8aa 100644 --- a/src/permission/command.test.ts +++ b/src/permission/command.test.ts @@ -151,3 +151,111 @@ 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 < { + // 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 < { 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/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..845d2634b 100644 --- a/src/shell/command-segments.ts +++ b/src/shell/command-segments.ts @@ -7,13 +7,21 @@ // 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`. +// the fragment `(cd a`. `<<` inside `(( ... ))` / `$(( ... ))` arithmetic is the +// left-shift operator and a top-level `#` starts a comment — neither opens a +// heredoc (see isArithmeticOpener / isCommentStart). export function splitChainedCommand(command: string): string[] { const segments: string[] = []; let current = ""; let quote: '"' | "'" | "`" | null = null; let heredocMarker: string | null = null; + let heredocStripTabs = false; let parenDepth = 0; + let arithDepth = 0; + let commentToEOL = false; + // Inside a top-level `#`-to-EOL comment: suppresses only the `<<` heredoc + // opener below. Chain operators after `#` still split, so + // `# note && rm -rf /` surfaces `rm -rf /` as its own segment. const push = (): void => { const trimmed = current.trim(); @@ -31,14 +39,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; @@ -69,22 +79,41 @@ 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; @@ -141,19 +170,62 @@ 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 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. + 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 + 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). @@ -175,9 +247,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 e92d3225f..f322e77c8 100644 --- a/src/tui/command-display.test.ts +++ b/src/tui/command-display.test.ts @@ -17,9 +17,17 @@ 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", + "echo $((a << 1)) && echo done", + "((x = a << 1)) && echo done", + "# example: cat < { ]); }); +test("a here-string never opens a pending heredoc", () => { + expect(groupChainSegmentsForDisplay('cat <<< "word" && echo hi')).toEqual([ + 'cat <<< "word"', + "echo hi", + ]); + expect(groupChainSegmentsForDisplay("cmd << { + 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 }, @@ -90,6 +144,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 }, @@ -116,6 +196,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 003a75885..1246e51a5 100644 --- a/src/tui/command-display.ts +++ b/src/tui/command-display.ts @@ -1,12 +1,27 @@ -import { splitChainedCommand } from "../shell/command-segments.js"; +import { + isArithmeticCloser, + isArithmeticOpener, + isCommentStart, + 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] === '"') { @@ -22,7 +37,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[] { @@ -50,8 +66,12 @@ 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; + // 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 = @@ -73,9 +93,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; @@ -102,7 +129,8 @@ export function verbatimCommandLines(text: string): VerbatimLine[] { if (ch === "\n") { push(); - heredocMarker = heredocPending; + heredocMarker = heredocPending?.marker ?? null; + heredocStripTabs = heredocPending?.stripTabs ?? false; heredocPending = null; continue; } @@ -113,9 +141,27 @@ export function verbatimCommandLines(text: string): VerbatimLine[] { continue; } - if (ch === "<" && normalized[i + 1] === "<" && heredocPending === null) { - const marker = parseHeredocMarker(normalized, i); - if (marker !== null) heredocPending = marker; + 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; } current += ch; } @@ -208,7 +254,11 @@ 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; + // 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); @@ -223,8 +273,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; } } @@ -245,11 +302,15 @@ function segmentWords(segment: string): string[] { continue; } - if (ch === "<" && segment[i + 1] === "<") { - const marker = parseHeredocMarker(segment, i); - if (marker !== null) { + 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(); - heredocPending = marker; + heredocPending = opener; + const marker = opener.marker; i += 2; if (segment[i] === "-") i++; while (segment[i] === " " || segment[i] === "\t") i++; @@ -257,6 +318,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; } } @@ -264,7 +328,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++; @@ -322,8 +387,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); @@ -333,7 +398,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; }