Skip to content

fix(commands): correct multi-line quoted command parsing, auto-approval, and malformed-command error surfacing - #483

Merged
edelauna merged 15 commits into
Zoo-Code-Org:mainfrom
awschmeder:fix/multiline-quoted-command-parsing
Jun 11, 2026
Merged

fix(commands): correct multi-line quoted command parsing, auto-approval, and malformed-command error surfacing#483
edelauna merged 15 commits into
Zoo-Code-Org:mainfrom
awschmeder:fix/multiline-quoted-command-parsing

Conversation

@awschmeder

@awschmederawschmeder commented Jun 5, 2026

Copy link
Copy Markdown
Contributor

Related GitHub Issue

Closes: #484

Issue #484 has been approved and assigned, satisfying the Issue-First policy.

Description

A single command that wraps a multi-line script in a quoted argument (e.g. sh -c '...') was misclassified as a multi-statement sequence, which defeated allowlist auto-approval and produced a noisy command-pattern breakdown in the UI. Malformed commands (unterminated quotes) were silently passed to the terminal, risking partial execution of the well-formed prefix before the shell aborted.

Root causes fixed:

  • parseCommand split on every newline before any quote handling, so newlines inside a quoted argument were treated as command separators. Each script line became a bogus sub-command. Because those fragments matched neither the allowlist nor the denylist, getCommandDecision fell through to ask_user even when the wrapper command (e.g. sh -c) was allowlisted.
  • Single-quoted strings were never masked, so their contents leaked out as stray tokens.
  • ANSI-C quoting ($'...') leaked an internal placeholder (SQUOTE_0__) because shell-quote parsed $__SQUOTE_0__ as a variable expansion and corrupted the marker.
  • Commands with an unterminated quote were passed to the terminal for execution, resulting in partial execution of the desired command before the shell hit the syntax error -- a hazard that is especially likely when LLMs generate deeply nested quoting.

How (key implementation details):

In src/shared/parse-command.ts:

  • Mask quoted strings (single, double, and ANSI-C $'...') at the top of parseCommand()before splitting on newlines, so newlines embedded in a quoted argument stay within their command. Genuine unquoted newlines still split into separate sub-commands, preserving per-statement safety checks.
  • The masking uses a single shared scanTopLevelQuotes state machine (left-to-right alternation, escape-aware, #-comment-aware). Both findUnterminatedQuote and maskTopLevelQuotes delegate to this one scanner -- eliminating the duplicated state machines that existed in the initial implementation. parseCommandLine remains separate: it operates on already-masked single lines and uses shell-quote for operator tokenization, a different concern.
  • findUnterminatedQuote() returns { quoteType, openIndex, message } | null. parseCommand() returns any input with an unterminated quote as a single opaque token, so a fragment can never be auto-approved in isolation.
  • Removed dead arrayIndexing / __ARRAY_N__ restore branch -- it was never populated and silently returned undefined on any match. ${...} patterns are handled by the parameterExpansions bucket, which is populated and restored correctly.

In src/core/auto-approval/ and src/tools/execute-command/:

  • getCommandDecision returns a dedicated "malformed_command" result (distinct from "ask_user") when findUnterminatedQuote fires, anchoring the safety boundary against regression.
  • ExecuteCommandTool surfaces a malformed-command as a toolError with a human-readable message locating the unterminated quote, so the agent receives structured feedback instead of a silent shell syntax error.

In webview-ui/:

  • CommandExecutionStatus gains an error state and CommandExecution renders a visible error indicator (e.g. the red dot) for malformed commands, replacing the silent no-op the UI previously showed.

A single fix at the shared parseCommand source corrects both the backend auto-approval decision and the webview pattern breakdown, since both consume this function.

Reviewers should note: plain POSIX single-quote handling is intentionally not escape-aware (POSIX single quotes treat backslash literally and always terminate on an apostrophe); only the ANSI-C $'...' form is escape-aware. The findUnterminatedQuote return type carries quoteType, openIndex, and message to support located malformed-command error messages.

Roadmap alignment: Reliability First -- ensures command execution and auto-approval are consistently reliable for wrapped multi-line scripts, and provides clear, actionable feedback for malformed input rather than silent partial execution.

Test Procedure

Automated (all affected suites pass):

  • cd src && npx vitest run shared/__tests__/parse-command.spec.ts core/auto-approval/__tests__/commands.spec.ts
  • cd webview-ui && npx vitest run src/components/chat/__tests__/CommandExecution.spec.tsx

Coverage added:

  • src/shared/__tests__/parse-command.spec.ts: single/double quoted multi-line payloads, escaped inner quotes, mixed quote styles, ANSI-C $'...' (no placeholder leak), ANSI-C escaped-apostrophe + newline, shell-quote parse-failure fallback restoration, newline preservation, genuine multi-statement splitting, malformed unterminated-quote safe path (single/double/ANSI-C and an embedded-newline case), full findUnterminatedQuote suite (balanced input, each unterminated style with reported index, backslash-escaped quotes outside strings, # comments, mixed quote styles, ANSI-C/double-quote escape handling, apostrophe inside a # comment after a closed quoted argument).
  • src/core/auto-approval/__tests__/commands.spec.ts: wrapped multi-line scripts auto-approve when the wrapper prefix is allowlisted; genuine multi-statement scripts still require every statement to be allowlisted; unterminated-quote commands return "malformed_command" regardless of allowlist (including ["*"]).
  • webview-ui/src/components/chat/__tests__/CommandExecution.spec.tsx: pattern selector no longer surfaces stray script-line fragments; error card renders for malformed commands.

Manual: verified in the Extension Development Host that a well-formed sh -c '...' multi-line wrapper auto-approves, a malformed unterminated-quote command shows an error card and a toolError (no standalone fragment, no silent partial execution), and genuine multi-statement input still requires each statement allowlisted.

Pre-Submission Checklist

  • Issue Linked: This PR is linked to an approved GitHub Issue (see "Related GitHub Issue" above). (Issue [BUG] Multi-line script in a quoted argument is misclassified as multiple commands, defeating auto-approval #484 is approved and assigned.)
  • Scope: My changes are focused on the linked issue (one major feature/fix per PR).
  • Self-Review: I have performed a thorough self-review of my code.
  • Testing: New and/or updated tests have been added to cover my changes (if applicable).
  • Documentation Impact: I have considered if my changes require documentation updates (see "Documentation Updates" section below).
  • Contribution Guidelines: I have read and agree to the Contributor Guidelines.

Screenshots / Videos

A video of the manual test confirmation is provided in the attached comments.

Documentation Updates

  • No documentation updates are required.

Additional Notes

The fix is a single change at the shared parseCommand source, so both the backend auto-approval decision and the webview pattern breakdown are corrected together. The malformed-command error card and toolError surfacing are additive: they give the agent and user explicit, located feedback instead of a silent shell abort.

@coderabbitai

coderabbitaiBot commented Jun 5, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

Note

Reviews paused

It looks like this branch is under active development. To avoid overwhelming you with review comments due to an influx of new commits, CodeRabbit has automatically paused this review. You can configure this behavior by changing the reviews.auto_review.auto_pause_after_reviewed_commits setting.

Use the following commands to manage reviews:

  • @coderabbitai resume to resume automatic reviews.
  • @coderabbitai review to trigger a single review.

Use the checkboxes below for quick actions:

  • ▶️ Resume reviews
  • 🔍 Trigger review
📝 Walkthrough

Walkthrough

Masks top-level quoted spans and heredocs, detects unterminated quote regions, splits on unquoted newlines only after masking, restores placeholders per-line, and updates pattern extraction so multi-line quoted arguments and heredocs remain atomic for auto-approval and UI selectors.

Changes

Multi-line quoted command parsing and auto-approval

Layer / File(s)Summary
Unterminated-quote detection
src/shared/parse-command.ts
Adds QuoteType/UnterminatedQuote and a left-to-right findUnterminatedQuote scanner that detects first unclosed posix-single, ansi-c, double, locale, or heredoc region and reports openIndex.
Heredoc delimiter parsing
src/shared/parse-command.ts
Adds parseHeredocDelimiter to parse quoted/unquoted heredoc delimiters and return the bare delimiter and index after it.
Masking of quoted substrings
src/shared/parse-command.ts
Adds maskTopLevelQuotes to replace top-level heredoc and quoted spans (ANSI-C, locale, POSIX single, double) with __TOPLEVEL_QUOTE_n__ placeholders while ignoring # comments.
parseCommand rework and placeholder threading
src/shared/parse-command.ts
parseCommand now returns the whole input on unterminated quoting, masks top-level quotes before splitting on unquoted newlines, restores placeholders per-line, treats restored lines with embedded newlines as atomic, and threads a singleQuotes bucket with __SQUOTE_n__ placeholders through restorePlaceholders.
parseCommand and findUnterminatedQuote tests
src/shared/__tests__/parse-command.spec.ts
Adds extensive tests: shell-quote mock/fallback, operator chaining, newline normalization, preservation of multi-line quoted regions (single/double/ANSI-C/locale), subshells, heredoc variants (<<, <<-, quoted delimiters), <<< herestrings, malformed/unterminated matrices, and findUnterminatedQuote assertions.
Auto-approval decision tests for quoted wrapper commands
src/core/auto-approval/__tests__/commands.spec.ts
Tests verify allowlisted wrapper prefixes (e.g., sh) auto-approve single-quoted, double-quoted, and ANSI-C quoted multi-line scripts as one command; non-allowlisted prefixes prompt user approval; unquoted multi-statement scripts remain split.
Pattern extraction & UI tests
webview-ui/src/utils/command-parser.ts, webview-ui/src/components/chat/__tests__/CommandExecution.spec.tsx, webview-ui/src/utils/__tests__/command-parser.spec.ts
extractPatternsFromCommand delegates splitting to parseCommand and processes each sub-command with extractPatternsFromSingleCommand; tests ensure selector spans list only leading command words and exclude heredoc or embedded script internals.
CommandExecution initial-pattern filtering
webview-ui/src/components/chat/CommandExecution.tsx
When collecting initial allPatterns, skip multi-line entries (those containing \n) so multi-line/opaque constructs are handled by sub-command extraction instead of being inserted verbatim.
Changeset entry documenting the parsing fix
.changeset/fix-multiline-quoted-command-parsing.md
Documents quote/heredoc-aware masking, heredoc single-token behavior (including unterminated handling), locale quoting, comment-aware masking, and clarifies change affects auto-approval tokenization only.

Sequence Diagram(s)

sequenceDiagram
participant Input as Raw command text
participant Parser as parseCommand
participant Auto as getCommandDecision
participant UI as extractPatternsFromCommand
Input->>Parser: maskTopLevelQuotes -> detect unterminated? -> split on unquoted newlines -> restore placeholders per-line
Parser-->>Auto: sub-commands (heredoc/quote-aware)
Auto->>Auto: evaluate leading words vs allowlist per sub-command
Auto-->>UI: decision + patterns
UI->>Parser: reuse parseCommand for pattern extraction
UI-->>Input: render selector spans (only leading patterns)
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~45 minutes

Poem

A rabbit nibbles through quoted lines,
Hides newlines safe in single signs,
Heredocs stay whole; no fragments stray,
The wrapper word leads the pattern display. 🐇✨

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check nameStatusExplanation
Linked Issues check✅ PassedAll coding requirements from issue #484 are addressed: multi-line quoted arguments are masked before newline splitting, unterminated quotes are detected via findUnterminatedQuote(), genuine unquoted newlines still split into separate statements, and internal placeholder leaks are prevented.
Out of Scope Changes check✅ PassedAll changes directly support the stated objectives: quote masking, parseCommand refactoring, findUnterminatedQuote implementation, and related test coverage across shared, auto-approval, and webview layers. No extraneous modifications detected.
Docstring Coverage✅ PassedDocstring coverage is 88.89% which is sufficient. The required threshold is 80.00%.
Title check✅ PassedThe title accurately summarizes the main changes: fixing multi-line quoted command parsing and auto-approval behavior, plus malformed-command error surfacing.
Description check✅ PassedThe description is comprehensive and follows the template structure, including linked issue, detailed implementation notes, testing procedure, and pre-submission checklist.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

@coderabbitaicoderabbitaiBot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🧹 Nitpick comments (1)
src/shared/__tests__/parse-command.spec.ts (1)

60-68: ⚡ Quick win

Add ANSI-C escaped-apostrophe coverage to lock the multiline fix.

The ANSI-C block should also assert $'...\'...\n...' stays a single command; this is the key edge case for the top-level masking path.

Suggested test
 describe("ANSI-C quoting ($'...')", () => {
it("does not leak a placeholder for a $'...' multi-line argument", () => {
const input = "sh -c $'echo 1\necho 2'"
const result = parseCommand(input)
// The placeholder used internally must never appear in the output.
expect(result.join(" ")).not.toContain("SQUOTE")
expect(result.join(" ")).not.toContain("__")
})
++	it("keeps ANSI-C strings with escaped apostrophes and newlines as one command", () => {+ const input = "sh -c $'echo it\\'s ok\necho done'"+ expect(parseCommand(input)).toEqual([input])+	})
})

As per coding guidelines, **/{__tests__,tests,test}/**/*.{test,spec}.{ts,tsx,js} should use package-local unit tests for parsing logic and edge-case behavior.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@src/shared/__tests__/parse-command.spec.ts` around lines 60 - 68, Add a unit
test to the existing "ANSI-C quoting ($'...')" suite in
src/shared/__tests__/parse-command.spec.ts that exercises an ANSI‑C string
containing an escaped apostrophe and a newline (e.g. input like "sh -c $'echo
\\'1\\'\necho 2'") and asserts parseCommand returns a single multi-line argument
(join(" ") does not split it) and that the internal placeholder tokens ("SQUOTE"
and "__") do not appear in the output; update the it(...) block or add a new
it(...) alongside the existing test referencing parseCommand to lock the
multiline masking/fix for the escaped‑apostrophe edge case.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Inline comments:
In `@src/shared/parse-command.ts`:
- Around line 47-53: The top-level masking regex in parse-command.ts (the masked
variable created by command.replace) doesn't handle ANSI-C $'...' quoting or
escaped apostrophes, causing premature termination and unmasked newlines; update
the replace logic to first recognize $'...'(ANSI-C) as a single token and to
make the single-quote branch escape-aware (accept backslash-escaped characters
inside single quotes) before falling back to double-quote handling, preserving
the existing topLevelQuotes.push and replacement pattern (__TOPLEVEL_QUOTE_n__)
so newlines inside those quoted blocks remain masked and the subsequent lines =
masked.split(...) call no longer splits inside ANSI-C or escaped-single-quote
strings.
---
Nitpick comments:
In `@src/shared/__tests__/parse-command.spec.ts`:
- Around line 60-68: Add a unit test to the existing "ANSI-C quoting ($'...')"
suite in src/shared/__tests__/parse-command.spec.ts that exercises an ANSI‑C
string containing an escaped apostrophe and a newline (e.g. input like "sh -c
$'echo \\'1\\'\necho 2'") and asserts parseCommand returns a single multi-line
argument (join(" ") does not split it) and that the internal placeholder tokens
("SQUOTE" and "__") do not appear in the output; update the it(...) block or add
a new it(...) alongside the existing test referencing parseCommand to lock the
multiline masking/fix for the escaped‑apostrophe edge case.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: 19a3d17f-83ae-474d-adb6-22d1cb4b3ff0

📥 Commits

Reviewing files that changed from the base of the PR and between 00fc247 and f070880.

📒 Files selected for processing (5)
  • .changeset/fix-multiline-quoted-command-parsing.md
  • src/core/auto-approval/__tests__/commands.spec.ts
  • src/shared/__tests__/parse-command.spec.ts
  • src/shared/parse-command.ts
  • webview-ui/src/components/chat/__tests__/CommandExecution.spec.tsx

Comment threadsrc/shared/parse-command.ts Outdated
@codecov

codecovBot commented Jun 5, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 95.33333% with 14 lines in your changes missing coverage. Please review.

Files with missing linesPatch %Lines
src/core/tools/ExecuteCommandTool.ts27.27%8 Missing ⚠️
src/shared/parse-command.ts97.40%6 Missing ⚠️

📢 Thoughts on this report? Let us know!

@edelaunaedelauna left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nice! I'm ok with this - if someone's allow listing commands which start with sh -c then I can accept that as a proxy for essentially YOLO mode.

One comment to increase test coverage, but otherwise this looks good.

expect(result).toEqual(["sh -c 'echo 1\necho 2'"])
expect(result[0]).toContain("\n")
})
})

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What does parseCommand return for an unclosed quote like sh -c 'echo test (no closing quote)? The top-level regex '[^']*' requires a closing quote to match, so this input passes through unmasked and an embedded newline would still split — is that the intended contract? Worth a test to pin the behavior.

Copy link
Copy Markdown
ContributorAuthor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Valid concern - regex based parser is not adequate to catch malformed inputs (which LLMs tend to generate when complex quoting is involved). I am reviewing and will re-submit this with a more robust parser.

Copy link
Copy Markdown
ContributorAuthor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

FYI - related yolo -c utility https://github.com/awschmeder/yolo-cli

Copy link
Copy Markdown
ContributorAuthor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@edelauna I updated the PR adding a quote-syntax checker that detects if the command contains unterminated quotes and prevents it from reaching the regex, returning them as a single token for user approval.

	if (findUnterminatedQuote(command) !== null) {
return [command]
}

A future PR should consider blocking commands that have an unterminated quote with an error message sent back to the agent for correction.

LLMs occasionally generate improper quote syntax in deeply nested quote sequences. In singular commands the shell will catch the parse error, but in a multipart command it can result in partial execution of the command sequence before the shell hits a parse error, which could have unintended side effects and creates more work for the agent to recover from.

@awschmeder
awschmederforce-pushed the fix/multiline-quoted-command-parsing branch from a6527d8 to 10e8e56CompareJune 6, 2026 22:54

@coderabbitaicoderabbitaiBot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Inline comments:
In `@src/shared/parse-command.ts`:
- Around line 200-206: The current cross-line regex in parseCommand that builds
masked (replacing top-level quotes and pushing them into topLevelQuotes) is
comment-blind and can hide newlines inside comments; replace that regex with a
small state-machine that mirrors findUnterminatedQuote's rules: iterate
characters, maintain flags inSingleQuote, inDoubleQuote, inComment, and
escapeNext, treat '#' as starting a comment only when not in a quote and end
comments at EOL, push each full quoted span into topLevelQuotes and emit a
placeholder like __TOPLEVEL_QUOTE_n__ only when the quote is outside comments,
and copy raw characters otherwise; this preserves real newline separators for
masked.split(...) and fixes the command-splitting bug seen by
getCommandDecision.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: c6d51868-5333-4af3-baf4-d7c75dbc19e9

📥 Commits

Reviewing files that changed from the base of the PR and between a6527d8 and 10e8e56.

📒 Files selected for processing (5)
  • .changeset/fix-multiline-quoted-command-parsing.md
  • src/core/auto-approval/__tests__/commands.spec.ts
  • src/shared/__tests__/parse-command.spec.ts
  • src/shared/parse-command.ts
  • webview-ui/src/components/chat/__tests__/CommandExecution.spec.tsx
🚧 Files skipped from review as they are similar to previous changes (3)
  • .changeset/fix-multiline-quoted-command-parsing.md
  • src/core/auto-approval/tests/commands.spec.ts
  • webview-ui/src/components/chat/tests/CommandExecution.spec.tsx

Comment threadsrc/shared/parse-command.ts Outdated
@awschmeder

Copy link
Copy Markdown
ContributorAuthor

Regex parsing: current state after this change

To clarify scope in response to the review note about cross-line regex masking: this PR converts the single hazardous cross-line quote-masking step to a state machine, but does not eliminate all regex parsing. Summary below.

Converted to the state-machine parser

  • The top-level quote masker in parseCommand() (previously a single comment-blind command.replace(/.../g) that ran across the whole multi-line string) is now maskTopLevelQuotes(), which walks the input left-to-right using the same rules as findUnterminatedQuote(). It respects # comments, so a quote inside a comment can no longer pair with a quote on a later line and hide a real newline separator.

This was the only quote-aware, cross-line regex, and it was the source of the reported defect.

Regex still in use (intentional, unchanged by this PR)

These all run after the cross-line newline split (i.e. on a single line at a time) or operate on non-shell content, so the comment-blind cross-line hazard does not apply to them:

  1. Trivial / non-parsing

    • /\s/.test(...) single-char whitespace checks inside the two state machines.
    • masked.split(/\r\n|\r|\n/) newline split, which runs only after quotes are masked.
    • The __PLACEHOLDER_(\d+)__ restore/match patterns (internal sentinel tokens, not shell syntax).
  2. Per-line tokenization in parseCommandLine() -- still regex-based:

    • PowerShell redirections, $((...)) and $[...] arithmetic, ${...} parameter expansion, <(...)/>(...) process substitution, ANSI-C $'...', $var / special bash variables, $(...) and backtick subshells, single/double quote masking, and the shell-quote fallback operator split.

Out of scope

Converting the per-line tokenizers in parseCommandLine() to a unified state machine would be a substantially larger refactor. If desired, it should be tracked as a separate issue/PR rather than expanding this fix.

@awschmeder

Copy link
Copy Markdown
ContributorAuthor

Shell Quoting Syntax Error -- Manual Test Results

Tests were run using sh -c to observe how the shell handles quoting syntax errors in various
contexts. The goal was to identify whether a quoting error prevents all execution (parse-time
rejection) or allows partial execution (runtime failure).


Tests 1-9: Parse-Time Rejection

In all single-invocation cases, zsh parses the entire command expression before executing
anything. A quoting error anywhere in the expression blocks all execution.

#DescriptionCommand testedResult
1POSIX single quote, unmatched double insidesh -c 'echo "hello world'zsh:1: unmatched "
2ANSI-C $'...' outer, unmatched double insidesh -c $'echo "hello world'zsh:1: unmatched "
3Double quote outer, unmatched single insidesh -c "echo 'hello world"zsh:1: unmatched '
4Single quote + literal newline + unmatched doublesh -c 'echo "hello + newline + world'zsh:2: unmatched "
5Double quote + literal newline + unmatched singlesh -c "echo 'hello + newline + world"zsh:2: unmatched '
6ANSI-C \n escape + unmatched doublesh -c $'echo "hello\nworld'zsh:2: unmatched "
7Compound &&, unmatched quote on second commandsh -c 'echo hello && echo "world'zsh:1: unmatched "
8Compound ;, unmatched quote on second commandsh -c 'echo hello; echo "world'zsh:1: unmatched "
9Compound |, unmatched quote on second commandsh -c 'echo hello | echo "world'zsh:1: unmatched "

Observation: Regardless of quoting style or compound operator used, zsh rejects the
entire expression at parse time. Nothing executes.


Tests 10-11: Partial Execution

Two patterns were found that allow the first command to execute before the quoting error is
encountered.

Test 10 -- eval deferral via ANSI-C quoting

Command tested:

sh -c $'echo hello && eval \'echo "world\''

Output:

hello
(eval):1: unmatched "

Explanation: The ANSI-C $'...' outer quoting makes the full expression syntactically
valid from zsh's perspective. echo hello runs successfully. eval then receives
echo "world as a string and attempts to parse it at runtime, producing a deferred parse
error from eval's own parser rather than zsh's pre-execution parser.


Test 11 -- Two separate sh -c invocations separated by ;

Command tested:

sh -c 'echo hello'; sh -c 'echo "world'

Output:

hello
zsh:1: unmatched "

Explanation: The ; separator causes zsh to parse and execute each statement
independently in sequence. The first invocation (sh -c 'echo hello') completes successfully.
zsh then attempts to parse the second invocation and fails on the unmatched " before that
command can run.


Summary

PatternExecution behavior
Single sh -c with any quoting styleParse-time rejection -- nothing executes
Compound operators (&&, ;, |) inside single sh -cParse-time rejection -- nothing executes
eval with deferred broken string (ANSI-C outer quoting)Partial execution -- first command runs, eval fails at runtime
Two separate sh -c calls joined by ;Partial execution -- first call completes, second fails at parse time

The only reliable way to get partial execution with a quoting syntax error is to either defer
the broken string to a runtime evaluator (eval, bash -c, etc.) or split the commands into
separate shell invocations.

All sequences were correctly parsed and matched when 'sh -c' is on the Execute auto-approve list.

@awschmeder

Copy link
Copy Markdown
ContributorAuthor

Prompt for ZooCode: Paste this into the chat for manual testing

Manual Test Procedure -- Multiline Command Parsing

Run the following shell commands one at a time using your shell execution tool.
After each command, observe the actual output and confirm it matches the
expected result listed. Report PASS or FAIL for each before moving to the next.

All commands use sh and echo only. Newlines are represented as literal
newlines in heredoc-style inputs below.


Part 1: Unquoted newlines (should split)

1. Three echo commands on separate lines split into three entries

echo a
echo b
echo c

Expected: each line executes independently, producing output:

a
b
c

Part 2: POSIX single-quoted multi-line argument (should NOT split)

2. Newline inside single quotes is part of the argument

sh -c 'echo aecho b'

Expected: both echo a and echo b run as part of the single sh -c call:

a
b

3. Operators inside single quotes are literal

sh -c 'echo hello && echo world'

Expected: the && is passed as a literal string to sh, which interprets it:

hello
world

Part 3: ANSI-C quoting ($'...') (should NOT split)

4. ANSI-C quoted multi-line argument stays as one sh -c call

sh -c $'echo a\necho b'

Expected:

a
b

5. ANSI-C with escaped apostrophe and embedded newline

sh -c $'echo it\'s ok\necho done'

Expected: syntax error, no output.

Why: the ANSI-C string expands to the two-line literal script
echo it's ok + newline + echo done. When sh -c parses that script the
apostrophe in it's opens a POSIX single-quoted region that is never closed,
so sh reports an unmatched quote error before executing anything.


Part 4: Double-quoted multi-line argument (should NOT split)

6. Newline inside double quotes is part of the argument

sh -c "echo aecho b"

Expected:

a
b

Part 5: Heredoc -- unquoted delimiter (should NOT split body)

7. Unquoted delimiter heredoc

sh <<EOFecho helloecho worldEOF

Expected:

hello
world

8. Single-quoted delimiter heredoc (body is literal, no expansion)

sh << 'EOF'echo helloecho worldEOF

Expected:

hello
world

9. Double-quoted delimiter heredoc (body allows expansion)

sh << "EOF"echo helloecho worldEOF

Expected:

hello
world

10. <<- heredoc strips leading tabs from body and terminator

sh <<-EOF	echo hello	echo worldEOF

Expected:

hello
world

11. Multi-line heredoc body -- all body lines execute under one sh call

sh << 'EOF'echo line1echo line2echo line3EOF

Expected:

line1
line2
line3

12. Command after heredoc terminator runs as a separate command

sh <<EOFecho insideEOFecho outside

Expected:

inside
outside

(inside comes from the heredoc; outside from the trailing echo outside.)

13. # comment inside heredoc body is preserved as literal text

sh << 'EOF'# this is a commentecho helloEOF

Expected: the comment line is passed to sh which ignores it, then echo hello runs:

hello

Part 6: Locale quoting ($"...") (should NOT split, $ prefix preserved)

Note: $"..." is a bash locale-quoting construct. When no gettext translation
catalog is present (the common case), the string is passed through unchanged
and the $ is consumed by bash. This behavior is consistent across bash 3.2
(macOS) and bash 4+ (Linux) when no catalog matches.

14. Locale-quoted string -- no leading $ in output

echo$"hello world"

Expected:

hello world

The $ is consumed by bash as part of the $"..." token; only the string
content is passed to echo.

15. Operators inside locale-quoted string are not shell operators

sh -c 'echo $"hello && world"'

Expected:

hello && world

The && inside $"..." must not split the command -- echo receives the
whole string as one argument. The $ is consumed by bash.


Part 7: Malformed / unterminated quotes (should not execute partial commands)

16. Unterminated single quote -- shell should report a syntax error, not execute partial content

sh -c 'echo hello

Expected: the shell reports an unmatched quote error. No partial output from echo hello should appear before the error.

17. Unterminated heredoc -- bash executes the body at EOF (no error)

sh <<EOFecho hello

(Submit the input without an EOF terminator line and observe the result.)

Expected on bash (all versions, non-interactive):

hello

Why: bash in non-interactive script execution treats end-of-file as an implicit
heredoc terminator and silently executes the body -- it does NOT report an
error. This is consistent behavior across bash 3.2 through 5.x on all
platforms.

Note -- intentional parser divergence: the Zoo Code parseCommand function
treats a missing heredoc terminator as malformed and returns the whole input as
a single opaque token (same as an unterminated quote). This is deliberately
more conservative than bash: when the LLM generates a heredoc without a
terminator, the user is prompted for approval rather than each body line being
evaluated independently for auto-approval. The body still executes correctly at
runtime via bash -- the conservative behavior only affects the auto-approval
decision.


Summary

#ScenarioExpected Result
1Unquoted newlinesEach line executes separately
2Single-quoted newlineBoth echos run under one sh -c
3Operators in single quotes&& passes through to sh
4ANSI-C $'...' with \nBoth echos run under one sh -c
5ANSI-C with escaped apostropheSyntax error (apostrophe opens unclosed quote in expanded script)
6Double-quoted newlineBoth echos run under one sh -c
7Heredoc unquoted delimiterBody lines run under sh
8Heredoc single-quoted delimiterBody literal, lines run under sh
9Heredoc double-quoted delimiterBody expandable, lines run under sh
10<<- heredocTab-indented body and terminator work
11Multi-line heredocAll body lines run under one sh
12Command after heredocHeredoc body + trailing command both run
13# comment in heredoc bodyComment ignored by sh, echo runs
14Locale quoting $"..."hello world ($ consumed by bash; no leading $ in output)
15Operators in locale quotes&& not split; hello && world printed as one string
16Unterminated single quoteSyntax error, no partial execution
17Unterminated heredocbash executes body at EOF; parser conservatively returns opaque token

@awschmeder
awschmederforce-pushed the fix/multiline-quoted-command-parsing branch from 2535cbf to 28c751bCompareJune 7, 2026 08:15

@coderabbitaicoderabbitaiBot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 2

🧹 Nitpick comments (1)
src/shared/parse-command.ts (1)

118-155: 💤 Low value

Heredoc tab-stripping is inconsistent with maskTopLevelQuotes.

Line 123 advances past - for <<- but doesn't record whether tab-stripping should apply. Line 143 then unconditionally strips leading tabs from every potential terminator line. However, maskTopLevelQuotes at line 316 correctly only strips tabs when stripTabs is true.

For input like cat << EOF\nhello\n\tEOF, findUnterminatedQuote would consider \tEOF a valid terminator (incorrectly), while maskTopLevelQuotes would not (correctly). The end result is still correct because maskTopLevelQuotes handles it, but the inconsistency could cause confusion during maintenance.

Suggested fix for consistency
 if (char === "<" && command[i + 1] === "<") {
const heredocOpenIndex = i
i += 2 // skip <<
- if (command[i] === "-") i++ // optional - for <<-+ const stripTabs = command[i] === "-"+ if (stripTabs) i++ // optional - for <<-
// Skip horizontal whitespace between << and the delimiter word.
- // Strip leading tabs for <<- heredocs (terminator may be indented).- const line = command.slice(lineStart, i).replace(/^\t*/, "")+ // Strip leading tabs only for <<- heredocs (terminator may be indented).+ const rawLine = command.slice(lineStart, i)+ const line = stripTabs ? rawLine.replace(/^\t*/, "") : rawLine
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@src/shared/parse-command.ts` around lines 118 - 155, In
findUnterminatedQuote, record whether the heredoc opener used the '-' variant by
setting a stripTabs boolean when you detect the optional '-' after '<<' (similar
to maskTopLevelQuotes), pass or use that flag when processing terminator lines,
and only apply the line.replace(/^\t*/, "") tab-stripping when stripTabs is
true; keep the rest of the heredoc scanning and the parseHeredocDelimiter call
unchanged so terminator matching behavior is consistent with maskTopLevelQuotes.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Inline comments:
In `@src/shared/parse-command.ts`:
- Around line 167-176: The locale-quote branch in parseCommand sets inDouble but
doesn't record that the opener was a locale quote, so when unterminated the
function returns quoteType "double" instead of "locale"; add a boolean flag
(e.g., localeQuote or inLocale) set when detecting the $"..." branch (alongside
inDouble and openIndex), mirror the existing singleIsAnsiC pattern used for
ANSI-C single quotes, and update the final quoteType selection logic to return
"locale" when that flag is set (ensure the flag is cleared/handled just like the
ANSI-C flag during termination/escape handling in parseCommand).
In `@webview-ui/src/components/chat/__tests__/CommandExecution.spec.tsx`:
- Around line 662-665: The test collects span text into fragments but only
checks exact equality, so heredoc internals can leak inside longer span strings;
update the assertions after the fragments =
Array.from(selector.querySelectorAll("span")).map(...) line to assert that none
of the fragment strings include the substrings "EOF", "echo", or "hello" (e.g.,
replace the .not.toContain checks with per-fragment substring checks like
ensuring fragments.some(f => f.includes("...")) is false for each of those
substrings) so any substring leak inside a span will fail.
---
Nitpick comments:
In `@src/shared/parse-command.ts`:
- Around line 118-155: In findUnterminatedQuote, record whether the heredoc
opener used the '-' variant by setting a stripTabs boolean when you detect the
optional '-' after '<<' (similar to maskTopLevelQuotes), pass or use that flag
when processing terminator lines, and only apply the line.replace(/^\t*/, "")
tab-stripping when stripTabs is true; keep the rest of the heredoc scanning and
the parseHeredocDelimiter call unchanged so terminator matching behavior is
consistent with maskTopLevelQuotes.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: b9fa94c1-407f-4243-a538-fabe5445f98c

📥 Commits

Reviewing files that changed from the base of the PR and between 6f4a302 and 2535cbf.

📒 Files selected for processing (6)
  • .changeset/fix-multiline-quoted-command-parsing.md
  • src/shared/__tests__/parse-command.spec.ts
  • src/shared/parse-command.ts
  • webview-ui/src/components/chat/__tests__/CommandExecution.spec.tsx
  • webview-ui/src/utils/__tests__/command-parser.spec.ts
  • webview-ui/src/utils/command-parser.ts

Comment threadsrc/shared/parse-command.ts Outdated
Comment threadwebview-ui/src/components/chat/__tests__/CommandExecution.spec.tsx Outdated
…or auto-approval
parseCommand split on every newline before any quote handling, so newlines
inside a quoted argument (e.g. a multi-line script passed to sh -c) were treated
as separate commands. Single-quoted and ANSI-C ($'...') strings were also not
fully masked, leaking placeholders and bogus sub-commands. This defeated
allowlist auto-approval and produced a noisy command-pattern breakdown in the UI.
Mask quoted strings (single, double, ANSI-C) before splitting on unquoted
newlines, using a single left-to-right alternation so a quote of one style
inside the other does not start a spurious match. Genuine unquoted newlines
still split into separate sub-commands.
Handle $'...' (ANSI-C) quoting in parseCommand's pre-split masking so an
escaped apostrophe inside the quoted body no longer terminates the match
early and leak an embedded newline, which would split a single command into
bogus sub-commands. Add a regression test covering an escaped apostrophe
plus newline inside an ANSI-C argument.
Mock shell-quote parse() to throw and assert the fallback path restores the
ANSI-C single-quote placeholder, closing the patch-coverage gap on the parse-
failure branch.
Add findUnterminatedQuote, a quote-aware state-machine scanner that detects a command containing an unclosed quote (a shell syntax error, common in LLM-generated commands with nested quotes). parseCommand now returns such input as a single opaque token instead of splitting on embedded newlines, so a line intended to live inside the unclosed quote cannot surface as an independently auto-approvable sub-command.
The scanner returns { quoteType, openIndex } to support a future execution-layer rejection that surfaces a located error to the model; that pre-execution rejection is intentionally deferred to a follow-up.
Replace the cross-line quote-masking regex in parseCommand() with a state machine (maskTopLevelQuotes) that mirrors findUnterminatedQuote. A quote inside a # comment no longer pairs with a quote on a later line, so a comment can no longer hide a real newline separator and merge two distinct commands.
…; 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
…minatedQuote
- Add explicit <<< passthrough in maskTopLevelQuotes: emit all three < chars
verbatim and advance i by 3 so the second < does not re-trigger the heredoc
branch on the next iteration
- Same fix in findUnterminatedQuote for the same root cause
- Add herestring test suite covering single-line, multi-command split, and
single-quoted/ANSI-C quoted multiline word cases
- findUnterminatedQuote: track stripTabs for <<- so tab-stripping only
applies when the heredoc opener used <<- (consistent with maskTopLevelQuotes)
- findUnterminatedQuote: add doubleIsLocale flag so an unterminated $"..."
region returns quoteType "locale" instead of "double"
- findUnterminatedQuote: add tests for unterminated locale quote and balanced
<<- with indented terminator
- CommandExecution: exclude multi-line opaque tokens (heredoc bodies,
unterminated quotes) from the raw-command pattern set so body-line words
never surface as independently approvable patterns
- CommandExecution.spec: strengthen fragment assertions to use per-fragment
substring checks, exposing the CommandExecution leak bug
@awschmeder
awschmederforce-pushed the fix/multiline-quoted-command-parsing branch from 85b3adb to f522392CompareJune 7, 2026 16:53
@awschmeder

Copy link
Copy Markdown
ContributorAuthor

Part 8: Herestring (<<<) -- manual test procedure

Added in the latest commit: herestring (<<<) support and fix. A herestring feeds a single word as stdin -- it has no body or terminator and must NOT be treated as a heredoc by the parser.

Bug that was fixed: both findUnterminatedQuote and maskTopLevelQuotes matched << as a heredoc opener without guarding against a third <. For cmd <<< word, the first < triggered the heredoc branch at position i, the guard prevented it, fell through to result += '<'; i++, then the second < at i+1 re-triggered the branch and consumed the rest of input as an unterminated heredoc body.

Fix: explicit <<< passthrough before the << heredoc branch in both functions -- emit three < chars and advance i by 3 via continue.

Test 18: Simple herestring -- treated as one command

sh <<<'echo hello'

Expected:

hello

(sh receives echo hello as its stdin script and runs it.)


Test 19: Herestring followed by a second command -- must split into two commands

sh <<<'echo hello'echodone

Expected: two separate commands execute:

hello
done

The parser must NOT consume echo done as part of the herestring body. Without the fix, the second < re-triggered the heredoc branch and consumed echo done as a body line, returning the entire input as one opaque token.


Test 20: Herestring with a single-quoted multiline word -- one command

sh <<<'echo line1echo line2'

Expected: the quoted string contains a literal newline; sh receives the two-line script and runs both echos:

line1
line2

Test 21: Herestring with an ANSI-C quoted multiline word -- one command

sh <<<$'echo line1\necho line2'

Expected:

line1
line2

@awschmeder

Copy link
Copy Markdown
ContributorAuthor

Shell Quoting Test Results

Summary

All 21 tests PASSED ✓


Part 1: Unquoted newlines (should split)

Test 1: Three echo commands on separate lines

Status: PASS

Command:

echo a
echo b
echo c

Expected Output:

a
b
c

Actual Output:

a
b
c

Part 2: POSIX single-quoted multi-line argument (should NOT split)

Test 2: Newline inside single quotes is part of the argument

Status: PASS

Command:

sh -c 'echo aecho b'

Expected Output:

a
b

Actual Output:

a
b

Test 3: Operators inside single quotes are literal

Status: PASS

Command:

sh -c 'echo hello && echo world'

Expected Output:

hello
world

Actual Output:

hello
world

Part 3: ANSI-C quoting ($'...') (should NOT split)

Test 4: ANSI-C quoted multi-line argument stays as one sh -c call

Status: PASS

Command:

sh -c $'echo a\necho b'

Expected Output:

a
b

Actual Output:

a
b

Test 5: ANSI-C with escaped apostrophe and embedded newline

Status: PASS

Command:

sh -c $'echo it\'s ok\necho done'

Expected Output: syntax error, no output

Actual Output:

sh: -c: line 0: unexpected EOF while looking for matching `''
sh: -c: line 2: syntax error: unexpected end of file

Part 4: Double-quoted multi-line argument (should NOT split)

Test 6: Newline inside double quotes is part of the argument

Status: PASS

Command:

sh -c "echo aecho b"

Expected Output:

a
b

Actual Output:

a
b

Part 5: Heredoc -- unquoted delimiter (should NOT split body)

Test 7: Unquoted delimiter heredoc

Status: PASS

Command:

sh <<EOFecho helloecho worldEOF

Expected Output:

hello
world

Actual Output:

hello
world

Test 8: Single-quoted delimiter heredoc (body is literal, no expansion)

Status: PASS

Command:

sh << 'EOF'echo helloecho worldEOF

Expected Output:

hello
world

Actual Output:

hello
world

Test 9: Double-quoted delimiter heredoc (body allows expansion)

Status: PASS

Command:

sh << "EOF"echo helloecho worldEOF

Expected Output:

hello
world

Actual Output:

hello
world

Test 10: <<- heredoc strips leading tabs from body and terminator

Status: PASS

Command:

sh <<-EOF	echo hello	echo worldEOF

Expected Output:

hello
world

Actual Output:

hello
world

Test 11: Multi-line heredoc body -- all body lines execute under one sh call

Status: PASS

Command:

sh << 'EOF'echo line1echo line2echo line3EOF

Expected Output:

line1
line2
line3

Actual Output:

line1
line2
line3

Test 12: Command after heredoc terminator runs as a separate command

Status: PASS

Command:

sh <<EOFecho insideEOFecho outside

Expected Output:

inside
outside

Actual Output:

inside
outside

Test 13: # comment inside heredoc body is preserved as literal text

Status: PASS

Command:

sh << 'EOF'# this is a commentecho helloEOF

Expected Output:

hello

Actual Output:

hello

Part 6: Locale quoting ($"...") (should NOT split, $ prefix preserved)

Test 14: Locale-quoted string -- no leading $ in output

Status: PASS

Command:

echo$"hello world"

Expected Output:

hello world

Actual Output:

hello world

Test 15: Operators inside locale-quoted string are not shell operators

Status: PASS

Command:

sh -c 'echo $"hello && world"'

Expected Output:

hello && world

Actual Output:

hello && world

Part 7: Malformed / unterminated quotes (should not execute partial commands)

Test 16: Unterminated single quote -- shell should report a syntax error

Status: PASS

Command:

sh -c 'echo hello

Expected Output: Syntax error, no partial output

Actual Output:

/bin/sh: -c: line 0: unexpected EOF while looking for matching `''
/bin/sh: -c: line 1: syntax error: unexpected end of file

Test 17: Unterminated heredoc -- bash executes the body at EOF (no error)

Status: PASS

Command:

sh <<EOFecho hello

(submitted without EOF terminator line)

Expected Output:

hello

Actual Output:

hello

Part 8: Herestring (<<<) -- single-line stdin redirect

Test 18: Simple herestring -- treated as one command

Status: PASS

Command:

sh <<<'echo hello'

Expected Output:

hello

Actual Output:

hello

Test 19: Herestring followed by a second command

Status: PASS

Command:

sh <<<'echo hello'echodone

Expected Output:

hello
done

Actual Output:

hello
done

Test 20: Herestring with a single-quoted multiline word

Status: PASS

Command:

sh <<<'echo line1echo line2'

Expected Output:

line1
line2

Actual Output:

line1
line2

Test 21: Herestring with an ANSI-C quoted multiline word

Status: PASS

Command:

sh <<<$'echo line1\necho line2'

Expected Output:

line1
line2

Actual Output:

line1
line2

Final Test Summary

#ScenarioStatus
1Unquoted newlinesPASS ✓
2Single-quoted newlinePASS ✓
3Operators in single quotesPASS ✓
4ANSI-C $'...' with \nPASS ✓
5ANSI-C with escaped apostrophePASS ✓
6Double-quoted newlinePASS ✓
7Heredoc unquoted delimiterPASS ✓
8Heredoc single-quoted delimiterPASS ✓
9Heredoc double-quoted delimiterPASS ✓
10<<- heredoc with tabsPASS ✓
11Multi-line heredocPASS ✓
12Command after heredocPASS ✓
13Comment in heredoc bodyPASS ✓
14Locale quoting $"..."PASS ✓
15Operators in locale quotesPASS ✓
16Unterminated single quotePASS ✓
17Unterminated heredocPASS ✓
18Simple herestringPASS ✓
19Herestring + trailing commandPASS ✓
20Herestring with single-quoted multiline wordPASS ✓
21Herestring with ANSI-C quoted multiline wordPASS ✓

Total: 21/21 PASSED ✓

@awschmeder

Copy link
Copy Markdown
ContributorAuthor

@coderabbitai review

@coderabbitai

coderabbitaiBot commented Jun 7, 2026

Copy link
Copy Markdown
Contributor
✅ Action performed

Review finished.

Note: CodeRabbit is an incremental review system and does not re-review already reviewed commits. This command is applicable only when automatic reviews are paused.

@edelaunaedelauna left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Seems like a lot was added since last review, some additional comments.

it("auto-approves an ANSI-C quoted ($'...') multi-line argument when the wrapper prefix is allowed", () => {
const ansiC = "sh -c $'echo 1\necho 2'"
expect(getCommandDecision(ansiC, ["sh"])).toBe("auto_approve")
})

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The new suite covers well-formed multi-line commands but does not test the core safety boundary this PR introduces: that an unterminated-quote command is NOT auto-approved even when its prefix is allowlisted. Could we add a case like getCommandDecision("sh -c 'echo a\nrm -rf /", ["sh"]) asserting "ask_user", and one with ["*"]? That would anchor the protection against regression.

Copy link
Copy Markdown
ContributorAuthor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I widened the PR scope to include triggering a toolError on malformed commands so they will no longer execute.


it("ignores a quote that appears inside a comment", () => {
expect(findUnterminatedQuote("echo hi # it's fine")).toBeNull()
})

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is there a test for a properly closed quote followed by a # comment containing an unmatched quote — e.g. sh -c 'cmd' #it's here? The comment-handling path in scanTopLevelQuotes was written for this case but I don't see it exercised in the findUnterminatedQuote suite.

Copy link
Copy Markdown
ContributorAuthor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Added to test coverage.


if (char === '"') {
// Double quote: escape-aware, ends at the next unescaped ".
const start = i

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Both scanTopLevelQuotes and parseCommandLine implement the shell quoting state machine independently (and a third time in the regex chain below). Any fix to escape handling or a new quote style needs to land in multiple places — is a consolidation being tracked, or is there a reason they need to stay separate?

Copy link
Copy Markdown
ContributorAuthor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Agreed, not DRY... new commit consolidates parsing into one function and improves error detection.

// Mask quoted strings (single and double) so their contents -- including
// operators like &&, |, ; and any embedded newlines -- are not treated as
// command separators. A single left-to-right scan with an alternation is used
// so that whichever quote opens first wins, preventing a quote of one style

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is arrayIndexing ever populated? I can't find anything that pushes into it or emits an __ARRAY_N__ placeholder — if that pattern never appears in the masked string, this restore branch is dead. And if a command literally contained __ARRAY_0__ the substitution would silently return undefined.

Copy link
Copy Markdown
ContributorAuthor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Looks like it was just dead code and never implemented. Latest commit removes it.

Copy link
Copy Markdown
ContributorAuthor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actually... good point about hthe plaseholder substition - its worse than that as parseCommand will break if the input contains any of the placeholder tokens. The latest commit closes the gap by pre-escaping any token text and de-escaping it after parsing.

…ve dead arrayIndexing
- Unify findUnterminatedQuote and maskTopLevelQuotes under a single
scanTopLevelQuotes state machine -- one pass, no duplicate quoting
logic between the two functions
- Change parseCommand return type from string[] to ParseResult
{ commands: string[]; parseError: UnterminatedQuote | null } so
callers can distinguish a parse error from a normal single-command
result without a separate findUnterminatedQuote call
- getCommandDecision reads parseError from parseCommand instead of
calling findUnterminatedQuote independently; returns the new
malformed_command CommandDecision variant for shell syntax errors
- Remove dead arrayIndexing bucket and __ARRAY_N__ restore (never
populated; caused undefined return when input contained the literal
string __ARRAY_0__)
- Add safety-boundary test: unterminated-quote commands return
malformed_command even when prefix is allowlisted, wildcard, or
the exact command string is on the allowlist
- Add findUnterminatedQuote test: closed quote followed by # comment
with apostrophe returns null (not an open region)
- Update all parseCommand call sites to destructure .commands
…rmed command as toolError in ExecuteCommandTool
@awschmederawschmeder changed the title fix(commands): treat multi-line quoted argument as a single command for auto-approvalfix(commands): correct multi-line quoted command parsing, auto-approval, and malformed-command error surfacingJun 10, 2026
@awschmeder

Copy link
Copy Markdown
ContributorAuthor

Addressing @edelauna's review threads:

Re: Safety boundary tests
Done. Added "returns malformed_command for a command with an unterminated quote regardless of allowlist" which asserts getCommandDecision("sh -c 'echo a\necho b", ["sh"]), ["*"], and [malformed] all return "malformed_command". The decision is a distinct value rather than "ask_user" so the protection is explicitly anchored against regression.


Re: Comment + apostrophe test
Added: "ignores an apostrophe inside a # comment that follows a closed quoted argument" in the findUnterminatedQuote suite asserts that "echo 'hello' # it's a comment" and 'echo "hello" # it\'s a comment' both return null. This specifically exercises the comment path in scanTopLevelQuotes for the case you described.


Re: DRY -- duplicated state machines
Consolidated. findUnterminatedQuote and maskTopLevelQuotes now both delegate to a single shared scanTopLevelQuotes state machine -- one pass covers span detection, unterminated-quote detection, and comment handling. parseCommandLine remains separate intentionally: it operates on already-masked single lines and uses shell-quote for operator tokenization, which is a different concern. The two scanners introduced by this PR are now one.


Re: Dead arrayIndexing code
Correct -- arrayIndexing was never populated and the __ARRAY_N__ restore was dead code (returning undefined on any match). Removed. ${...} patterns are now handled by the parameterExpansions bucket, which is actually populated and restored correctly.

@awschmeder

Copy link
Copy Markdown
ContributorAuthor
shell-parsing-manual-test-2026-06-10-small2.mp4

(re-uploaded with better resolution)...

awschmederand others added 2 commits June 10, 2026 16:52
Commands containing text like __QUOTE_0__ or __SQUOTE_0__ would be
silently corrupted by restorePlaceholders() -- the restore regexes
would match the literal tokens and substitute array entries (or
'undefined') in their place.
Fix: pre-escape __ -> \x00 in parseCommand before any masking begins,
then post-unescape \x00 -> __ across all output commands at the return.
\x00 (null byte, U+0000) is safe as a sentinel because the OS
terminates command strings at the first \x00, so it can never appear
in real shell command text.
Adds one regression test covering all eight internal placeholder
namespaces.
@awschmeder
awschmederforce-pushed the fix/multiline-quoted-command-parsing branch from e3346e5 to e24e3c0CompareJune 11, 2026 00:28
@awschmeder

Copy link
Copy Markdown
ContributorAuthor

@coderabbitai review

@coderabbitai

coderabbitaiBot commented Jun 11, 2026

Copy link
Copy Markdown
Contributor
✅ Action performed

Review finished.

Note: CodeRabbit is an incremental review system and does not re-review already reviewed commits. This command is applicable only when automatic reviews are paused.

@edelaunaedelauna left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for this contribution!

@edelauna
edelauna added this pull request to the merge queueJun 11, 2026
Merged via the queue into Zoo-Code-Org:main with commit d7bc9f6Jun 11, 2026
10 checks passed
@awschmeder
awschmeder deleted the fix/multiline-quoted-command-parsing branch June 11, 2026 21:01
edelauna pushed a commit that referenced this pull request Jun 17, 2026
…al, and malformed-command error surfacing (#483)
* fix(commands): treat multi-line quoted argument as a single command for auto-approval
parseCommand split on every newline before any quote handling, so newlines
inside a quoted argument (e.g. a multi-line script passed to sh -c) were treated
as separate commands. Single-quoted and ANSI-C ($'...') strings were also not
fully masked, leaking placeholders and bogus sub-commands. This defeated
allowlist auto-approval and produced a noisy command-pattern breakdown in the UI.
Mask quoted strings (single, double, ANSI-C) before splitting on unquoted
newlines, using a single left-to-right alternation so a quote of one style
inside the other does not start a spurious match. Genuine unquoted newlines
still split into separate sub-commands.
* fix(commands): mask ANSI-C quoted strings in top-level newline split
Handle $'...' (ANSI-C) quoting in parseCommand's pre-split masking so an
escaped apostrophe inside the quoted body no longer terminates the match
early and leak an embedded newline, which would split a single command into
bogus sub-commands. Add a regression test covering an escaped apostrophe
plus newline inside an ANSI-C argument.
* test(commands): cover shell-quote parse-failure fallback restoration
Mock shell-quote parse() to throw and assert the fallback path restores the
ANSI-C single-quote placeholder, closing the patch-coverage gap on the parse-
failure branch.
* fix(commands): reject unterminated-quote commands from auto-approval
Add findUnterminatedQuote, a quote-aware state-machine scanner that detects a command containing an unclosed quote (a shell syntax error, common in LLM-generated commands with nested quotes). parseCommand now returns such input as a single opaque token instead of splitting on embedded newlines, so a line intended to live inside the unclosed quote cannot surface as an independently auto-approvable sub-command.
The scanner returns { quoteType, openIndex } to support a future execution-layer rejection that surfaces a located error to the model; that pre-execution rejection is intentionally deferred to a follow-up.
* fix(commands): make top-level quote masking comment-aware
Replace the cross-line quote-masking regex in parseCommand() with a state machine (maskTopLevelQuotes) that mirrors findUnterminatedQuote. A quote inside a # comment no longer pairs with a quote on a later line, so a comment can no longer hide a real newline separator and merge two distinct commands.
* 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
* fix(commands): handle herestring (<<<) in parse-command and findUnterminatedQuote
- Add explicit <<< passthrough in maskTopLevelQuotes: emit all three < chars
verbatim and advance i by 3 so the second < does not re-trigger the heredoc
branch on the next iteration
- Same fix in findUnterminatedQuote for the same root cause
- Add herestring test suite covering single-line, multi-command split, and
single-quoted/ANSI-C quoted multiline word cases
* fix(commands): address CodeRabbit review comments
- findUnterminatedQuote: track stripTabs for <<- so tab-stripping only
applies when the heredoc opener used <<- (consistent with maskTopLevelQuotes)
- findUnterminatedQuote: add doubleIsLocale flag so an unterminated $"..."
region returns quoteType "locale" instead of "double"
- findUnterminatedQuote: add tests for unterminated locale quote and balanced
<<- with indented terminator
- CommandExecution: exclude multi-line opaque tokens (heredoc bodies,
unterminated quotes) from the raw-command pattern set so body-line words
never surface as independently approvable patterns
- CommandExecution.spec: strengthen fragment assertions to use per-fragment
substring checks, exposing the CommandExecution leak bug
* refactor(commands): consolidate quote scanners; add ParseResult; remove dead arrayIndexing
- Unify findUnterminatedQuote and maskTopLevelQuotes under a single
scanTopLevelQuotes state machine -- one pass, no duplicate quoting
logic between the two functions
- Change parseCommand return type from string[] to ParseResult
{ commands: string[]; parseError: UnterminatedQuote | null } so
callers can distinguish a parse error from a normal single-command
result without a separate findUnterminatedQuote call
- getCommandDecision reads parseError from parseCommand instead of
calling findUnterminatedQuote independently; returns the new
malformed_command CommandDecision variant for shell syntax errors
- Remove dead arrayIndexing bucket and __ARRAY_N__ restore (never
populated; caused undefined return when input contained the literal
string __ARRAY_0__)
- Add safety-boundary test: unterminated-quote commands return
malformed_command even when prefix is allowlisted, wildcard, or
the exact command string is on the allowlist
- Add findUnterminatedQuote test: closed quote followed by # comment
with apostrophe returns null (not an open region)
- Update all parseCommand call sites to destructure .commands
* feat(commands): add message field to UnterminatedQuote; surface malformed command as toolError in ExecuteCommandTool
* feat(commands): add error status to CommandExecutionStatus; render error card for malformed commands
* i18n: add malformedCommand translation to all 17 non-English locales
* fix: guard parseCommand against literal placeholder token collisions
Commands containing text like __QUOTE_0__ or __SQUOTE_0__ would be
silently corrupted by restorePlaceholders() -- the restore regexes
would match the literal tokens and substitute array entries (or
'undefined') in their place.
Fix: pre-escape __ -> \x00 in parseCommand before any masking begins,
then post-unescape \x00 -> __ across all output commands at the return.
\x00 (null byte, U+0000) is safe as a sentinel because the OS
terminates command strings at the first \x00, so it can never appear
in real shell command text.
Adds one regression test covering all eight internal placeholder
namespaces.
nigeldelviero pushed a commit to nigeldelviero/Zoo-Code that referenced this pull request Jun 22, 2026
…al, and malformed-command error surfacing (Zoo-Code-Org#483)
* fix(commands): treat multi-line quoted argument as a single command for auto-approval
parseCommand split on every newline before any quote handling, so newlines
inside a quoted argument (e.g. a multi-line script passed to sh -c) were treated
as separate commands. Single-quoted and ANSI-C ($'...') strings were also not
fully masked, leaking placeholders and bogus sub-commands. This defeated
allowlist auto-approval and produced a noisy command-pattern breakdown in the UI.
Mask quoted strings (single, double, ANSI-C) before splitting on unquoted
newlines, using a single left-to-right alternation so a quote of one style
inside the other does not start a spurious match. Genuine unquoted newlines
still split into separate sub-commands.
* fix(commands): mask ANSI-C quoted strings in top-level newline split
Handle $'...' (ANSI-C) quoting in parseCommand's pre-split masking so an
escaped apostrophe inside the quoted body no longer terminates the match
early and leak an embedded newline, which would split a single command into
bogus sub-commands. Add a regression test covering an escaped apostrophe
plus newline inside an ANSI-C argument.
* test(commands): cover shell-quote parse-failure fallback restoration
Mock shell-quote parse() to throw and assert the fallback path restores the
ANSI-C single-quote placeholder, closing the patch-coverage gap on the parse-
failure branch.
* fix(commands): reject unterminated-quote commands from auto-approval
Add findUnterminatedQuote, a quote-aware state-machine scanner that detects a command containing an unclosed quote (a shell syntax error, common in LLM-generated commands with nested quotes). parseCommand now returns such input as a single opaque token instead of splitting on embedded newlines, so a line intended to live inside the unclosed quote cannot surface as an independently auto-approvable sub-command.
The scanner returns { quoteType, openIndex } to support a future execution-layer rejection that surfaces a located error to the model; that pre-execution rejection is intentionally deferred to a follow-up.
* fix(commands): make top-level quote masking comment-aware
Replace the cross-line quote-masking regex in parseCommand() with a state machine (maskTopLevelQuotes) that mirrors findUnterminatedQuote. A quote inside a # comment no longer pairs with a quote on a later line, so a comment can no longer hide a real newline separator and merge two distinct commands.
* 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
* fix(commands): handle herestring (<<<) in parse-command and findUnterminatedQuote
- Add explicit <<< passthrough in maskTopLevelQuotes: emit all three < chars
verbatim and advance i by 3 so the second < does not re-trigger the heredoc
branch on the next iteration
- Same fix in findUnterminatedQuote for the same root cause
- Add herestring test suite covering single-line, multi-command split, and
single-quoted/ANSI-C quoted multiline word cases
* fix(commands): address CodeRabbit review comments
- findUnterminatedQuote: track stripTabs for <<- so tab-stripping only
applies when the heredoc opener used <<- (consistent with maskTopLevelQuotes)
- findUnterminatedQuote: add doubleIsLocale flag so an unterminated $"..."
region returns quoteType "locale" instead of "double"
- findUnterminatedQuote: add tests for unterminated locale quote and balanced
<<- with indented terminator
- CommandExecution: exclude multi-line opaque tokens (heredoc bodies,
unterminated quotes) from the raw-command pattern set so body-line words
never surface as independently approvable patterns
- CommandExecution.spec: strengthen fragment assertions to use per-fragment
substring checks, exposing the CommandExecution leak bug
* refactor(commands): consolidate quote scanners; add ParseResult; remove dead arrayIndexing
- Unify findUnterminatedQuote and maskTopLevelQuotes under a single
scanTopLevelQuotes state machine -- one pass, no duplicate quoting
logic between the two functions
- Change parseCommand return type from string[] to ParseResult
{ commands: string[]; parseError: UnterminatedQuote | null } so
callers can distinguish a parse error from a normal single-command
result without a separate findUnterminatedQuote call
- getCommandDecision reads parseError from parseCommand instead of
calling findUnterminatedQuote independently; returns the new
malformed_command CommandDecision variant for shell syntax errors
- Remove dead arrayIndexing bucket and __ARRAY_N__ restore (never
populated; caused undefined return when input contained the literal
string __ARRAY_0__)
- Add safety-boundary test: unterminated-quote commands return
malformed_command even when prefix is allowlisted, wildcard, or
the exact command string is on the allowlist
- Add findUnterminatedQuote test: closed quote followed by # comment
with apostrophe returns null (not an open region)
- Update all parseCommand call sites to destructure .commands
* feat(commands): add message field to UnterminatedQuote; surface malformed command as toolError in ExecuteCommandTool
* feat(commands): add error status to CommandExecutionStatus; render error card for malformed commands
* i18n: add malformedCommand translation to all 17 non-English locales
* fix: guard parseCommand against literal placeholder token collisions
Commands containing text like __QUOTE_0__ or __SQUOTE_0__ would be
silently corrupted by restorePlaceholders() -- the restore regexes
would match the literal tokens and substitute array entries (or
'undefined') in their place.
Fix: pre-escape __ -> \x00 in parseCommand before any masking begins,
then post-unescape \x00 -> __ across all output commands at the return.
\x00 (null byte, U+0000) is safe as a sentinel because the OS
terminates command strings at the first \x00, so it can never appear
in real shell command text.
Adds one regression test covering all eight internal placeholder
namespaces.
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[BUG] Multi-line script in a quoted argument is misclassified as multiple commands, defeating auto-approval

2 participants

@awschmeder@edelauna