Skip to content

Commit 2535cbf

Browse files
committed
fix(commands): add heredoc and locale-quote support to command parser; fix pattern extractor
- parseCommand: mask heredocs (<<, <<-, all delimiter quoting styles) as single atomic tokens before newline splitting; unterminated heredocs returned as opaque token - parseCommand: add locale-quote ($"...") support alongside existing ANSI-C ($'...') - findUnterminatedQuote: extend QuoteType with "locale" and "heredoc" variants - extractPatternsFromCommand (webview): pre-split via parseCommand before shell-quote tokenization, preventing spurious EOF/body-line/operator tokens in allow/deny selector - Update changeset to cover all three fix areas
1 parent 6f4a302 commit 2535cbf

6 files changed

Lines changed: 513 additions & 116 deletions

File tree

.changeset/fix-multiline-quoted-command-parsing.md

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,16 @@
22
"zoo-code": patch
33
---
44

5-
Fix command auto-approval for a single command that wraps a multi-line script in a quoted argument (e.g. `sh -c '...'`). The parser previously split on every newline before handling quotes, so newlines inside a quoted argument were treated as separate commands, which defeated allowlist auto-approval and produced a noisy command-pattern breakdown. Single-quoted and ANSI-C (`$'...'`) strings are now masked correctly so embedded newlines and operators stay within their command. Genuine unquoted newlines still split into separate sub-commands, each of which must be allowlisted for auto-approval. Quote masking is comment-aware: a quote character inside a `#` comment is not paired with a quote on a later line, so a comment cannot hide a real newline separator and merge two distinct commands. Commands with an unterminated quote (a shell syntax error, common in LLM-generated commands with nested quotes) are detected with a quote-aware scanner and returned as a single opaque token, so a line intended to live inside the unclosed quote cannot surface as an independently auto-approvable command.
5+
Fix command auto-approval for multi-line shell constructs that must be treated as a single command.
6+
7+
**Quoted multi-line arguments** (`sh -c '...'`, `sh -c $'...'`, `sh -c "..."`): the parser previously split on every newline before handling quotes, so newlines inside a quoted argument were treated as separate commands, defeating allowlist auto-approval. Single-quoted, ANSI-C (`$'...'`), and double-quoted strings are now masked before the newline split so embedded newlines and operators stay within their command.
8+
9+
**Heredocs** (`<< EOF`, `<< 'EOF'`, `<< "EOF"`, `<<- EOF`): the entire heredoc -- opener line, body, and terminator -- is now treated as a single quoted region. Body lines are not split into independent sub-commands. All heredoc delimiter quoting styles (unquoted, single-quoted, double-quoted, backslash-escaped) are supported. An unterminated heredoc (missing terminator) is treated as malformed and returned as a single opaque token.
10+
11+
**Locale quoting** (`$"..."`): treated as a distinct token analogous to ANSI-C quoting, preserving the `$` prefix and preventing the double-quote handler from stripping it.
12+
13+
Quote masking is comment-aware: a quote character inside a `#` comment is not paired with a quote on a later line, so a comment cannot hide a real newline separator and merge two distinct commands. Commands with an unterminated quote are detected with a quote-aware scanner and returned as a single opaque token, preventing a line inside the unclosed quote from surfacing as an independently auto-approvable command. Genuine unquoted newlines still split into separate sub-commands, each of which must be allowlisted for auto-approval.
14+
15+
**Pattern selector (UI)**: the command pattern breakdown shown after execution now uses the same heredoc- and quote-aware parser (`parseCommand`) before extracting patterns, so an unterminated or terminated heredoc no longer produces spurious tokens like `EOF`, body-line words, or `<<` fragments in the allow/deny selector.
616

717
Note: this change only prevents *auto-approval* of fragments from a malformed command; it does not reject malformed commands before execution, which will be addressed in a separate PR to keep the scope focused here.

src/shared/__tests__/parse-command.spec.ts

Lines changed: 117 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -235,6 +235,123 @@ describe("parseCommand", () => {
235235
})
236236
})
237237

238+
describe("heredoc quoting", () => {
239+
// The entire heredoc -- opener line, body, and terminator -- must be
240+
// treated as a single opaque token so body lines are never split into
241+
// independent sub-commands for auto-approval evaluation.
242+
243+
it("treats an unquoted-delimiter heredoc as one command", () => {
244+
const input = "sh -c bash << EOF\necho hello\nEOF"
245+
expect(parseCommand(input)).toEqual([input])
246+
})
247+
248+
it("treats a single-quoted-delimiter heredoc as one command", () => {
249+
const input = "sh -c bash << 'EOF'\necho hello\nEOF"
250+
expect(parseCommand(input)).toEqual([input])
251+
})
252+
253+
it("treats a double-quoted-delimiter heredoc as one command", () => {
254+
const input = 'sh -c bash << "EOF"\necho hello\nEOF'
255+
expect(parseCommand(input)).toEqual([input])
256+
})
257+
258+
it("treats a backslash-escaped-delimiter heredoc as one command", () => {
259+
const input = "sh -c bash << \\EOF\necho hello\nEOF"
260+
expect(parseCommand(input)).toEqual([input])
261+
})
262+
263+
it("treats a <<- heredoc (strip leading tabs) as one command", () => {
264+
// <<- allows the terminator to be indented with tabs.
265+
const input = "sh -c bash <<-EOF\n\techo hello\n\tEOF"
266+
expect(parseCommand(input)).toEqual([input])
267+
})
268+
269+
it("does not split body lines of a multi-line heredoc", () => {
270+
const input = [
271+
"sh -c bash << 'EOF'",
272+
"echo line1",
273+
"echo line2",
274+
"echo line3",
275+
"EOF",
276+
].join("\n")
277+
expect(parseCommand(input)).toEqual([input])
278+
})
279+
280+
it("splits a command that follows the heredoc terminator", () => {
281+
const input = "sh -c bash << EOF\necho hello\nEOF\necho done"
282+
const result = parseCommand(input)
283+
expect(result).toHaveLength(2)
284+
expect(result[0]).toBe("sh -c bash << EOF\necho hello\nEOF")
285+
expect(result[1]).toBe("echo done")
286+
})
287+
288+
it("treats a heredoc with a missing terminator as one opaque token", () => {
289+
// An unterminated heredoc is a syntax error; the whole input must be
290+
// returned as a single token so no body line can be auto-approved alone.
291+
const input = "sh -c bash << EOF\necho hello"
292+
expect(parseCommand(input)).toEqual([input])
293+
})
294+
295+
it("treats the real-world sh heredoc pattern as one command", () => {
296+
const input = [
297+
"sh -c bash << 'EOF'",
298+
"echo line1 > /tmp/test.txt",
299+
"echo line2 \\",
300+
" --flag value \\",
301+
" --other value",
302+
"EOF",
303+
].join("\n")
304+
expect(parseCommand(input)).toEqual([input])
305+
})
306+
it("does not treat a # comment inside a heredoc body as a command separator", () => {
307+
// A '#' inside a heredoc body is literal text, not a shell comment.
308+
// The body must not be split and the comment line must be preserved.
309+
const input = "sh -c bash << 'EOF'\n# this is a comment\necho hello\nEOF"
310+
expect(parseCommand(input)).toEqual([input])
311+
})
312+
313+
it("does not mistake << inside a # comment as a heredoc opener", () => {
314+
// A heredoc opener that appears inside a # comment must be ignored;
315+
// the following lines must still be treated as separate commands.
316+
const input = "echo hi # << EOF\necho world"
317+
const result = parseCommand(input)
318+
expect(result).toHaveLength(2)
319+
expect(result[result.length - 1]).toBe("echo world")
320+
})
321+
})
322+
323+
describe("locale quoting ($\"...\")", () => {
324+
// Locale quoting $"..." behaves like double quotes for delimiter purposes
325+
// but the $ prefix is part of the token and must be preserved verbatim.
326+
327+
it("treats a locale-quoted argument as one command", () => {
328+
const input = `echo $"hello world"`
329+
expect(parseCommand(input)).toEqual([input])
330+
})
331+
332+
it("does not split on operators inside a locale-quoted string", () => {
333+
const input = `echo $"hello && world"`
334+
expect(parseCommand(input)).toEqual([input])
335+
})
336+
337+
it("does not split on a newline inside a locale-quoted string", () => {
338+
const input = `echo $"hello\nworld"`
339+
expect(parseCommand(input)).toEqual([input])
340+
})
341+
342+
it("preserves the $ prefix in the output without leaking placeholders", () => {
343+
const input = `echo $"greeting" && echo done`
344+
const result = parseCommand(input)
345+
expect(result[0]).toContain('$"greeting"')
346+
expect(result.join(" ")).not.toContain("__")
347+
})
348+
349+
it("returns an unterminated locale-quoted string as one opaque token", () => {
350+
const input = `echo $"hello`
351+
expect(parseCommand(input)).toEqual([input])
352+
})
353+
})
354+
238355
describe("findUnterminatedQuote", () => {
239356
it("returns null for balanced and quote-free input", () => {
240357
expect(findUnterminatedQuote("git status")).toBeNull()

0 commit comments

Comments
 (0)