Skip to content

Commit 15968a1

Browse files
committed
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.
1 parent 32e6d6e commit 15968a1

4 files changed

Lines changed: 198 additions & 6 deletions

File tree

src/permission/command.test.ts

Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -193,3 +193,69 @@ describe("splitChainedCommand heredoc boundaries", () => {
193193
).toEqual(["cat <<OUTER\nfoo <<INNER\nOUTER", "echo done"]);
194194
});
195195
});
196+
197+
describe("splitChainedCommand lexical context (arithmetic and comments)", () => {
198+
// Inside `((` / `$((` the `<<` token is the left-shift operator, never a
199+
// heredoc opener — the chain after it must still split.
200+
test("never opens a heredoc inside arithmetic expansion", () => {
201+
expect(splitChainedCommand("echo $((a<<1))")).toEqual(["echo $((a<<1))"]);
202+
expect(splitChainedCommand("echo $((a << 1)) && echo done")).toEqual([
203+
"echo $((a << 1))",
204+
"echo done",
205+
]);
206+
});
207+
208+
test("never opens a heredoc inside a (( )) arithmetic command", () => {
209+
expect(splitChainedCommand("((x = a << 1)) && echo done")).toEqual([
210+
"x = a << 1",
211+
"echo done",
212+
]);
213+
});
214+
215+
// A bare `( ... )` subshell is not arithmetic: a heredoc inside it is real.
216+
test("still opens a heredoc inside a bare-paren subshell", () => {
217+
const command = "(cat <<EOF\nbody\nEOF) && echo done";
218+
expect(splitChainedCommand(command)).toEqual([command]);
219+
});
220+
221+
// A top-level `#` starts a comment through end of line: a `<<` down there
222+
// documents rather than opens, so the next line still splits.
223+
test("never opens a heredoc from a #-to-EOL comment", () => {
224+
expect(splitChainedCommand("# example: cat <<EOF\necho hi")).toEqual([
225+
"# example: cat <<EOF",
226+
"echo hi",
227+
]);
228+
expect(splitChainedCommand("echo hi # tail <<EOF\n&& echo done")).toEqual([
229+
"echo hi # tail <<EOF",
230+
"echo done",
231+
]);
232+
});
233+
234+
// Comment text never touches arithmetic depth: an unbalanced `((` inside
235+
// a `#` comment must not poison later lines, so a genuine heredoc after
236+
// the comment still opens and the following chain still splits.
237+
test("never counts comment parens toward arithmetic depth", () => {
238+
const command = "# (( \ncat <<EOF\nbody\nEOF\n&& echo done";
239+
expect(splitChainedCommand(command)).toEqual([
240+
"# ((",
241+
"cat <<EOF\nbody\nEOF",
242+
"echo done",
243+
]);
244+
});
245+
246+
// Chain operators after `#` still split, so a dangerous command hiding
247+
// behind a comment still surfaces as its own approval subject.
248+
test("still splits chain operators after a # comment", () => {
249+
expect(splitChainedCommand("# note && rm -rf /")).toEqual([
250+
"# note",
251+
"rm -rf /",
252+
]);
253+
});
254+
255+
// A `#` line inside a genuine heredoc body stays payload: the marker still
256+
// closes and the following chain still splits.
257+
test("keeps a # line inside a heredoc body as payload", () => {
258+
const command = "cat <<EOF\n# payload\nEOF\necho done";
259+
expect(splitChainedCommand(command)).toEqual([command]);
260+
});
261+
});

src/shell/command-segments.ts

Lines changed: 61 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -7,14 +7,21 @@
77
// Parentheses group: operators inside a subshell or command substitution never
88
// split, and a segment that is exactly one `( ... )` group is unwrapped and its
99
// inner chain split recursively — so `(cd a && b)` yields `cd a` and `b`, not
10-
// the fragment `(cd a`.
10+
// the fragment `(cd a`. `<<` inside `(( ... ))` / `$(( ... ))` arithmetic is the
11+
// left-shift operator and a top-level `#` starts a comment — neither opens a
12+
// heredoc (see isArithmeticOpener / isCommentStart).
1113
export function splitChainedCommand(command: string): string[] {
1214
const segments: string[] = [];
1315
let current = "";
1416
let quote: '"' | "'" | "`" | null = null;
1517
let heredocMarker: string | null = null;
1618
let heredocStripTabs = false;
1719
let parenDepth = 0;
20+
let arithDepth = 0;
21+
let commentToEOL = false;
22+
// Inside a top-level `#`-to-EOL comment: suppresses only the `<<` heredoc
23+
// opener below. Chain operators after `#` still split, so
24+
// `# note && rm -rf /` surfaces `rm -rf /` as its own segment.
1825

1926
const push = (): void => {
2027
const trimmed = current.trim();
@@ -72,7 +79,20 @@ export function splitChainedCommand(command: string): string[] {
7279
}
7380

7481
// Detect heredoc redirect: << or <<-
75-
if (ch === "<" && command[i + 1] === "<") {
82+
// A top-level `#` starts a comment through end of line: a `<<` down there
83+
// (e.g. `# example: cat <<EOF`) documents rather than opens. Only the
84+
// opener is suppressed — the comment text flows through the normal scan
85+
// below, so chain operators after `#` still split.
86+
if (ch === "\n") commentToEOL = false;
87+
if (!commentToEOL && arithDepth === 0 && isCommentStart(command, i)) {
88+
commentToEOL = true;
89+
}
90+
if (
91+
!commentToEOL &&
92+
arithDepth === 0 &&
93+
ch === "<" &&
94+
command[i + 1] === "<"
95+
) {
7696
const opener = parseHeredocOpener(command, i);
7797
if (opener !== null) {
7898
current += command.slice(i, opener.lineEnd);
@@ -83,12 +103,17 @@ export function splitChainedCommand(command: string): string[] {
83103
}
84104
}
85105

86-
if (ch === "(") {
106+
if (ch === "(" && !commentToEOL) {
107+
// `((` / `$((` opens arithmetic, where `<<` shifts instead of opening a
108+
// heredoc (see the `<<` guard above). Bare `(` subshells still detect
109+
// heredocs — e.g. `(cat <<EOF ...)` is genuine.
110+
if (isArithmeticOpener(command, i)) arithDepth++;
87111
parenDepth++;
88112
current += ch;
89113
continue;
90114
}
91-
if (ch === ")") {
115+
if (ch === ")" && !commentToEOL) {
116+
if (isArithmeticCloser(command, i) && arithDepth > 0) arithDepth--;
92117
if (parenDepth > 0) parenDepth--;
93118
current += ch;
94119
continue;
@@ -145,6 +170,38 @@ export function splitChainedCommand(command: string): string[] {
145170
return segments;
146171
}
147172

173+
// Whether text[i] opens an arithmetic context (`((` or `$((`): inside it `<<`
174+
// is the left-shift operator, never a heredoc opener. Keyed on the doubled
175+
// paren — a bare `( ... )` subshell can still contain a genuine heredoc.
176+
// Deliberately not a full arithmetic evaluator: callers only track depth.
177+
export function isArithmeticOpener(text: string, i: number): boolean {
178+
return text[i] === "(" && text[i + 1] === "(";
179+
}
180+
181+
// Whether text[i] closes one arithmetic-context level (`))`).
182+
export function isArithmeticCloser(text: string, i: number): boolean {
183+
return text[i] === ")" && text[i + 1] === ")";
184+
}
185+
186+
// Whether text[i] starts a `#`-to-EOL comment: at the very start of the input
187+
// or right after whitespace, a newline, or a command separator (`;`, `&`,
188+
// `|`, `(`). A `#` glued to a word (`foo#bar`, `$#`, `${a#b}`) is data.
189+
export function isCommentStart(text: string, i: number): boolean {
190+
if (text[i] !== "#") return false;
191+
if (i === 0) return true;
192+
const prev = text[i - 1] as string;
193+
return (
194+
prev === " " ||
195+
prev === "\t" ||
196+
prev === "\r" ||
197+
prev === "\n" ||
198+
prev === ";" ||
199+
prev === "&" ||
200+
prev === "|" ||
201+
prev === "("
202+
);
203+
}
204+
148205
// Parses a heredoc opener (`<<` or `<<-`) starting at `command[i]` (which must
149206
// be the first "<"). Returns the terminating marker text, the exclusive end
150207
// index of the line that opened the heredoc, and whether the opener was `<<-`

src/tui/command-display.test.ts

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,11 @@ test("display segments exactly match authorization segments", () => {
2323
"cmd1 && \\\ncmd2",
2424
"(cd packages/shared && bunx tsc --noEmit 2>&1 | tail -3)",
2525
"echo start && (cd apps/web && bun test) && echo done",
26+
"echo $((a << 1)) && echo done",
27+
"((x = a << 1)) && echo done",
28+
"# example: cat <<EOF\necho hi",
29+
"# (( \ncat <<EOF\nbody\nEOF\n&& echo done",
30+
"cat <<EOF\n# payload\nEOF\necho done",
2631
];
2732

2833
for (const command of commands) {
@@ -76,6 +81,40 @@ test("a here-string never opens a pending heredoc", () => {
7681
]);
7782
});
7883

84+
test("a << inside arithmetic never opens a pending heredoc", () => {
85+
expect(groupChainSegmentsForDisplay("echo $((a << 1)) && echo done")).toEqual(
86+
["echo $((a << 1))", "echo done"],
87+
);
88+
// With no pending heredoc, a later line is ordinary text, never body.
89+
expect(verbatimCommandLines("echo $((a<<1))\nEOF\necho done")).toEqual([
90+
{ text: "echo $((a<<1))", isComment: false },
91+
{ text: "EOF", isComment: false },
92+
{ text: "echo done", isComment: false },
93+
]);
94+
});
95+
96+
test("a << inside a comment documents rather than opens", () => {
97+
expect(verbatimCommandLines("# example: cat <<EOF\necho hi")).toEqual([
98+
{ text: "# example: cat <<EOF", isComment: true },
99+
{ text: "echo hi", isComment: false },
100+
]);
101+
expect(groupChainSegmentsForDisplay("# c <<EOF")).toEqual(["# c <<EOF"]);
102+
});
103+
104+
test("comment parens never suppress a later heredoc on the display", () => {
105+
// Mirrors the splitter pin: `# ((` is comment text, so the heredoc opens
106+
// here exactly as it does for authorization and `&& echo done` separates.
107+
expect(
108+
verbatimCommandLines("# (( \ncat <<EOF\nbody\nEOF\n&& echo done"),
109+
).toEqual([
110+
{ text: "# (( ", isComment: true },
111+
{ text: "cat <<EOF", isComment: false },
112+
{ text: "body", isComment: false },
113+
{ text: "EOF", isComment: false },
114+
{ text: "&& echo done", isComment: false },
115+
]);
116+
});
117+
79118
test("top-level newlines become verbatim lines; quoted newlines stay marked inline", () => {
80119
expect(verbatimCommandLines('echo "a\nb"\necho two')).toEqual([
81120
{ text: 'echo "a↵b"', isComment: false },

src/tui/command-display.ts

Lines changed: 32 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,7 @@
11
import {
2+
isArithmeticCloser,
3+
isArithmeticOpener,
4+
isCommentStart,
25
isHeredocTerminator,
36
splitChainedCommand,
47
} from "../shell/command-segments.js";
@@ -66,6 +69,9 @@ export function verbatimCommandLines(text: string): VerbatimLine[] {
6669
let heredocStripTabs = false;
6770
let heredocPending: { marker: string; stripTabs: boolean } | null = null;
6871
let continued = false;
72+
// Arithmetic depth (`((` / `$((`): `<<` inside is the shift operator and
73+
// `#`-to-EOL comments never open a heredoc — mirrors the splitter.
74+
let arithDepth = 0;
6975

7076
const push = (): void => {
7177
const isComment =
@@ -135,7 +141,25 @@ export function verbatimCommandLines(text: string): VerbatimLine[] {
135141
continue;
136142
}
137143

138-
if (ch === "<" && normalized[i + 1] === "<" && heredocPending === null) {
144+
if (isArithmeticOpener(normalized, i)) arithDepth++;
145+
else if (isArithmeticCloser(normalized, i) && arithDepth > 0) arithDepth--;
146+
147+
// A top-level `#` comment runs to end of line: `<<` inside it documents
148+
// rather than opens. The line still renders whole (see push's isComment).
149+
if (arithDepth === 0 && isCommentStart(normalized, i)) {
150+
let j = i;
151+
while (j < normalized.length && normalized[j] !== "\n") j++;
152+
current += normalized.slice(i, j);
153+
i = j - 1;
154+
continue;
155+
}
156+
157+
if (
158+
arithDepth === 0 &&
159+
ch === "<" &&
160+
normalized[i + 1] === "<" &&
161+
heredocPending === null
162+
) {
139163
const opener = parseHeredocMarker(normalized, i);
140164
if (opener !== null) heredocPending = opener;
141165
}
@@ -232,6 +256,9 @@ function segmentWords(segment: string): string[] {
232256
let heredocMarker: string | null = null;
233257
let heredocStripTabs = false;
234258
let heredocPending: { marker: string; stripTabs: boolean } | null = null;
259+
// Arithmetic depth (`((` / `$((`): `<<` inside shifts, never opens —
260+
// mirrors the splitter (keyed on arithmetic, NOT on bare parens).
261+
let arithDepth = 0;
235262

236263
const push = (): void => {
237264
if (current.length > 0) words.push(current);
@@ -275,7 +302,10 @@ function segmentWords(segment: string): string[] {
275302
continue;
276303
}
277304

278-
if (ch === "<" && segment[i + 1] === "<") {
305+
if (isArithmeticOpener(segment, i)) arithDepth++;
306+
else if (isArithmeticCloser(segment, i) && arithDepth > 0) arithDepth--;
307+
308+
if (arithDepth === 0 && ch === "<" && segment[i + 1] === "<") {
279309
const opener = parseHeredocMarker(segment, i);
280310
if (opener !== null) {
281311
push();

0 commit comments

Comments
 (0)