Skip to content

Commit d7bc9f6

Browse files
authored
fix(commands): correct multi-line quoted command parsing, auto-approval, 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.
1 parent 8d9c078 commit d7bc9f6

29 files changed

Lines changed: 1318 additions & 51 deletions

File tree

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
---
2+
"zoo-code": patch
3+
---
4+
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.
16+
17+
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.

packages/types/src/terminal.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,11 @@ export const commandExecutionStatusSchema = z.discriminatedUnion("status", [
2929
executionId: z.string(),
3030
status: z.literal("timeout"),
3131
}),
32+
z.object({
33+
executionId: z.string(),
34+
status: z.literal("error"),
35+
message: z.string().optional(),
36+
}),
3237
])
3338

3439
export type CommandExecutionStatus = z.infer<typeof commandExecutionStatusSchema>

src/core/auto-approval/__tests__/commands.spec.ts

Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -99,3 +99,63 @@ describe("getCommandDecision — integration with dangerous substitution checks"
9999
expect(getCommandDecision('echo "${var@P}"', allowedCommands)).toBe("ask_user")
100100
})
101101
})
102+
103+
describe("getCommandDecision — multi-line script wrapped in a quoted argument", () => {
104+
// A wrapper command (e.g. sh -c '...') carrying a multi-line script as a
105+
// single quoted argument must be treated as one command. The embedded
106+
// newlines belong to the quoted string and must not be mistaken for a
107+
// multi-statement sequence that would defeat allowlist auto-approval.
108+
const wrappedSingleQuoted = [
109+
`sh -c 'kubectl exec pod -- python3 -c "`,
110+
`import urllib.request`,
111+
`url = \\"http://127.0.0.1:49527/\\"`,
112+
`try:`,
113+
` with urllib.request.urlopen(url, timeout=10) as r:`,
114+
` print(r.status)`,
115+
`except Exception as e:`,
116+
` print(\\"fetch failed:\\", e)`,
117+
`"'`,
118+
].join("\n")
119+
120+
it("auto-approves a multi-line single-quoted script when the wrapper prefix is allowed", () => {
121+
expect(getCommandDecision(wrappedSingleQuoted, ["sh"])).toBe("auto_approve")
122+
})
123+
124+
it("auto-approves a multi-line double-quoted script when the wrapper prefix is allowed", () => {
125+
const wrappedDoubleQuoted = 'sh -c "echo line1\necho line2"'
126+
expect(getCommandDecision(wrappedDoubleQuoted, ["sh"])).toBe("auto_approve")
127+
})
128+
129+
it("asks user when the wrapper prefix is not in the allowlist", () => {
130+
expect(getCommandDecision(wrappedSingleQuoted, ["git"])).toBe("ask_user")
131+
})
132+
133+
it("still splits genuine multi-statement scripts and asks when a statement is not allowed", () => {
134+
// Real unquoted newlines separate independent statements; each must be on
135+
// the allowlist for auto-approval to engage.
136+
const multiStatement = "echo hello\nrm -rf /tmp/x"
137+
expect(getCommandDecision(multiStatement, ["echo"])).toBe("ask_user")
138+
})
139+
140+
it("auto-approves a genuine multi-statement script when every statement is allowed", () => {
141+
const multiStatement = "echo hello\nls -la"
142+
expect(getCommandDecision(multiStatement, ["echo", "ls"])).toBe("auto_approve")
143+
})
144+
145+
it("auto-approves an ANSI-C quoted ($'...') multi-line argument when the wrapper prefix is allowed", () => {
146+
const ansiC = "sh -c $'echo 1\necho 2'"
147+
expect(getCommandDecision(ansiC, ["sh"])).toBe("auto_approve")
148+
})
149+
150+
it("returns malformed_command for a command with an unterminated quote regardless of allowlist", () => {
151+
// An unterminated quote is a shell syntax error. Even if the leading word
152+
// is on the allowlist (or the allowlist is the wildcard), or the exact
153+
// full command string is listed, the command must not be auto-approved --
154+
// the shell would report a syntax error and any prefix that ran before
155+
// the error could have unintended side effects.
156+
const malformed = "sh -c 'echo a\necho b"
157+
expect(getCommandDecision(malformed, ["sh"])).toBe("malformed_command")
158+
expect(getCommandDecision(malformed, ["*"])).toBe("malformed_command")
159+
expect(getCommandDecision(malformed, [malformed])).toBe("malformed_command")
160+
})
161+
})

src/core/auto-approval/commands.ts

Lines changed: 16 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import { parseCommand } from "../../shared/parse-command"
22

3+
34
/**
45
* Detect dangerous parameter substitutions that could lead to command execution.
56
* These patterns are never auto-approved and always require explicit user approval.
@@ -204,7 +205,7 @@ export function isAutoDeniedSingleCommand(
204205
/**
205206
* Command approval decision types
206207
*/
207-
export type CommandDecision = "auto_approve" | "auto_deny" | "ask_user"
208+
export type CommandDecision = "auto_approve" | "auto_deny" | "ask_user" | "malformed_command"
208209

209210
/**
210211
* Unified command validation that implements the longest prefix match rule.
@@ -224,6 +225,7 @@ export type CommandDecision = "auto_approve" | "auto_deny" | "ask_user"
224225
* - `"auto_approve"`: All sub-commands are explicitly allowed and no dangerous patterns detected
225226
* - `"auto_deny"`: At least one sub-command is explicitly denied
226227
* - `"ask_user"`: Mixed or no matches found, requires user decision, or contains dangerous patterns
228+
* - `"malformed_command"`: Command contains an unterminated quote -- a shell syntax error that must not be auto-approved
227229
*
228230
* **Examples:**
229231
* ```typescript
@@ -262,8 +264,19 @@ export function getCommandDecision(
262264
return "auto_approve"
263265
}
264266

265-
// Parse into sub-commands (split by &&, ||, ;, |)
266-
const subCommands = parseCommand(command)
267+
// Parse into sub-commands (split by &&, ||, ;, |). parseCommand also
268+
// detects shell syntax errors (unterminated quotes, unclosed heredocs) and
269+
// returns a non-null parseError in that case.
270+
const { commands: subCommands, parseError } = parseCommand(command)
271+
272+
// Reject commands with a shell syntax error. An unterminated quote means
273+
// the shell would report a parse error; in a compound command it may
274+
// partially execute the well-formed prefix before aborting. Returning a
275+
// distinct decision lets callers surface a useful message to the agent
276+
// rather than silently presenting the command for user approval.
277+
if (parseError !== null) {
278+
return "malformed_command"
279+
}
267280

268281
// Check each sub-command and collect decisions
269282
const decisions: CommandDecision[] = subCommands.map((cmd) => {

src/core/tools/ExecuteCommandTool.ts

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ import { Task } from "../task/Task"
1212
import { ToolUse, ToolResponse } from "../../shared/tools"
1313
import { formatResponse } from "../prompts/responses"
1414
import { unescapeHtmlEntities } from "../../utils/text-normalization"
15+
import { parseCommand } from "../../shared/parse-command"
1516
import {
1617
ExitCodeDetails,
1718
RooTerminalCallbacks,
@@ -86,6 +87,21 @@ export class ExecuteCommandTool extends BaseTool<"execute_command"> {
8687

8788
task.consecutiveMistakeCount = 0
8889

90+
// Detect shell syntax errors (unterminated quotes, unclosed heredocs) before
91+
// presenting the command for approval. Surfacing this as a tool error gives
92+
// the agent a precise, actionable message so it can retry with a corrected
93+
// command, rather than receiving a generic denial from the approval dialog.
94+
const { parseError } = parseCommand(canonicalCommand)
95+
if (parseError !== null) {
96+
const executionId = task.lastMessageTs?.toString() ?? Date.now().toString()
97+
const provider = await task.providerRef.deref()
98+
const errorStatus: CommandExecutionStatus = { executionId, status: "error", message: parseError.message }
99+
provider?.postMessageToWebview({ type: "commandExecutionStatus", text: JSON.stringify(errorStatus) })
100+
task.didToolFailInCurrentTurn = true
101+
pushToolResult(formatResponse.toolError(parseError.message))
102+
return
103+
}
104+
89105
const didApprove = await askApproval("command", canonicalCommand)
90106

91107
if (!didApprove) {

0 commit comments

Comments
 (0)