From 3835a313ce682ef04cc87419bfee8d90dc6ac4db Mon Sep 17 00:00:00 2001 From: Roo Code Date: Sat, 20 Dec 2025 13:47:57 +0000 Subject: [PATCH] fix: handle multiline quoted strings in command auto-approval Commands with multiline quoted strings (like beads descriptions) were not being auto-approved because parseCommand split by newlines before processing quoted strings, breaking the command into invalid fragments. This fix: - Adds replaceMultilineQuotes() to detect and replace multiline quoted strings with placeholders before splitting by newlines - Adds restoreMultilineQuotes() to restore the original quoted strings after parsing - Includes comprehensive tests for multiline quoted string handling Fixes #10226 --- src/shared/__tests__/parse-command.spec.ts | 151 +++++++++++++++++++++ src/shared/parse-command.ts | 74 +++++++++- 2 files changed, 222 insertions(+), 3 deletions(-) create mode 100644 src/shared/__tests__/parse-command.spec.ts diff --git a/src/shared/__tests__/parse-command.spec.ts b/src/shared/__tests__/parse-command.spec.ts new file mode 100644 index 0000000000..d766b10416 --- /dev/null +++ b/src/shared/__tests__/parse-command.spec.ts @@ -0,0 +1,151 @@ +import { parseCommand } from "../parse-command" + +describe("parseCommand", () => { + describe("basic command parsing", () => { + it("should return empty array for empty command", () => { + expect(parseCommand("")).toEqual([]) + expect(parseCommand(" ")).toEqual([]) + expect(parseCommand(null as any)).toEqual([]) + expect(parseCommand(undefined as any)).toEqual([]) + }) + + it("should parse simple commands", () => { + expect(parseCommand("echo hello")).toEqual(["echo hello"]) + expect(parseCommand("git status")).toEqual(["git status"]) + }) + + it("should split commands by chain operators", () => { + expect(parseCommand("echo hello && echo world")).toEqual(["echo hello", "echo world"]) + expect(parseCommand("echo hello || echo world")).toEqual(["echo hello", "echo world"]) + expect(parseCommand("echo hello; echo world")).toEqual(["echo hello", "echo world"]) + expect(parseCommand("echo hello | grep h")).toEqual(["echo hello", "grep h"]) + }) + + it("should split commands by newlines", () => { + expect(parseCommand("echo hello\necho world")).toEqual(["echo hello", "echo world"]) + expect(parseCommand("echo hello\r\necho world")).toEqual(["echo hello", "echo world"]) + expect(parseCommand("echo hello\recho world")).toEqual(["echo hello", "echo world"]) + }) + + it("should skip empty lines", () => { + expect(parseCommand("echo hello\n\necho world")).toEqual(["echo hello", "echo world"]) + expect(parseCommand("echo hello\n \necho world")).toEqual(["echo hello", "echo world"]) + }) + }) + + describe("quoted string handling", () => { + it("should preserve double-quoted strings", () => { + expect(parseCommand('echo "hello world"')).toEqual(['echo "hello world"']) + }) + + it("should preserve double-quoted strings with special characters", () => { + expect(parseCommand('echo "hello && world"')).toEqual(['echo "hello && world"']) + }) + + // Note: shell-quote strips single quotes but preserves their content + it("should handle single-quoted strings (quotes stripped by shell-quote)", () => { + expect(parseCommand("echo 'hello world'")).toEqual(["echo hello world"]) + }) + }) + + describe("multiline quoted string handling", () => { + it("should preserve multiline double-quoted strings", () => { + const command = 'bd create "This is a\nmultiline description"' + const result = parseCommand(command) + expect(result).toEqual(['bd create "This is a\nmultiline description"']) + }) + + it("should preserve multiline single-quoted strings", () => { + const command = "bd create 'This is a\nmultiline description'" + const result = parseCommand(command) + expect(result).toEqual(["bd create 'This is a\nmultiline description'"]) + }) + + it("should handle Windows-style line endings in multiline quotes", () => { + const command = 'bd create "This is a\r\nmultiline description"' + const result = parseCommand(command) + expect(result).toEqual(['bd create "This is a\r\nmultiline description"']) + }) + + it("should handle old Mac-style line endings in multiline quotes", () => { + const command = 'bd create "This is a\rmultiline description"' + const result = parseCommand(command) + expect(result).toEqual(['bd create "This is a\rmultiline description"']) + }) + + it("should preserve multiline strings with multiple newlines", () => { + const command = 'echo "line1\nline2\nline3"' + const result = parseCommand(command) + expect(result).toEqual(['echo "line1\nline2\nline3"']) + }) + + it("should handle multiple multiline quoted strings in one command", () => { + const command = 'echo "first\nmultiline" && echo "second\nmultiline"' + const result = parseCommand(command) + expect(result).toEqual(['echo "first\nmultiline"', 'echo "second\nmultiline"']) + }) + + it("should handle mixed single and double multiline quotes", () => { + const command = "echo \"double\nquote\" && echo 'single\nquote'" + const result = parseCommand(command) + expect(result).toEqual(['echo "double\nquote"', "echo 'single\nquote'"]) + }) + + it("should handle regular newlines between commands with multiline strings", () => { + const command = 'bd create "This is a\nmultiline description"\necho done' + const result = parseCommand(command) + expect(result).toEqual(['bd create "This is a\nmultiline description"', "echo done"]) + }) + + it("should handle escaped quotes within multiline double-quoted strings", () => { + const command = 'echo "line1\nline2 with \\"escaped\\" quotes"' + const result = parseCommand(command) + expect(result).toEqual(['echo "line1\nline2 with \\"escaped\\" quotes"']) + }) + + it("should handle beads create command with multiline description", () => { + // This is the exact use case from the bug report + const command = `bd create "A tool that helps users manage their tasks. +It supports multiple features: +- Adding tasks +- Removing tasks +- Listing tasks"` + const result = parseCommand(command) + expect(result).toHaveLength(1) + expect(result[0]).toContain("bd create") + expect(result[0]).toContain("A tool that helps users manage their tasks.") + expect(result[0]).toContain("- Adding tasks") + }) + }) + + describe("subshell handling", () => { + // Note: The parser extracts subshell commands as separate entries + it("should extract subshell commands", () => { + expect(parseCommand("echo $(date)")).toEqual(["echo", "date"]) + }) + + it("should extract backtick commands", () => { + expect(parseCommand("echo `date`")).toEqual(["echo", "date"]) + }) + }) + + describe("variable handling", () => { + it("should preserve variable references", () => { + expect(parseCommand("echo $HOME")).toEqual(["echo $HOME"]) + }) + + it("should preserve parameter expansions", () => { + expect(parseCommand("echo ${HOME}")).toEqual(["echo ${HOME}"]) + }) + }) + + describe("redirection handling", () => { + it("should preserve redirections", () => { + expect(parseCommand("echo hello > output.txt")).toEqual(["echo hello > output.txt"]) + }) + + it("should preserve PowerShell-style redirections", () => { + expect(parseCommand("command 2>&1")).toEqual(["command 2>&1"]) + }) + }) +}) diff --git a/src/shared/parse-command.ts b/src/shared/parse-command.ts index a8d87b76ac..441d8eed10 100644 --- a/src/shared/parse-command.ts +++ b/src/shared/parse-command.ts @@ -2,6 +2,63 @@ import { parse } from "shell-quote" export type ShellToken = string | { op: string } | { command: string } +/** + * Replace multiline quoted strings with placeholders before splitting by newlines. + * This prevents quoted strings that span multiple lines from being incorrectly split. + * + * @param command - The command string to process + * @returns An object with the processed command and arrays of replaced quotes + */ +function replaceMultilineQuotes(command: string): { + processedCommand: string + multilineDoubleQuotes: string[] + multilineSingleQuotes: string[] +} { + const multilineDoubleQuotes: string[] = [] + const multilineSingleQuotes: string[] = [] + + // Replace multiline double-quoted strings with placeholders + // This regex matches double-quoted strings that contain newlines + let processedCommand = command.replace(/"([^"\\]|\\.)*"/gs, (match) => { + if (/\r\n|\r|\n/.test(match)) { + multilineDoubleQuotes.push(match) + return `__MLQUOTE_D_${multilineDoubleQuotes.length - 1}__` + } + return match + }) + + // Replace multiline single-quoted strings with placeholders + // Single quotes don't support escape sequences in shell, but we still need to handle them + processedCommand = processedCommand.replace(/'[^']*'/gs, (match) => { + if (/\r\n|\r|\n/.test(match)) { + multilineSingleQuotes.push(match) + return `__MLQUOTE_S_${multilineSingleQuotes.length - 1}__` + } + return match + }) + + return { processedCommand, multilineDoubleQuotes, multilineSingleQuotes } +} + +/** + * Restore multiline quoted string placeholders back to their original values. + * + * @param command - The command with placeholders + * @param multilineDoubleQuotes - Array of replaced double-quoted strings + * @param multilineSingleQuotes - Array of replaced single-quoted strings + * @returns The command with placeholders restored + */ +function restoreMultilineQuotes( + command: string, + multilineDoubleQuotes: string[], + multilineSingleQuotes: string[], +): string { + let result = command + result = result.replace(/__MLQUOTE_D_(\d+)__/g, (_, i) => multilineDoubleQuotes[parseInt(i)]) + result = result.replace(/__MLQUOTE_S_(\d+)__/g, (_, i) => multilineSingleQuotes[parseInt(i)]) + return result +} + /** * Split a command string into individual sub-commands by * chaining operators (&&, ||, ;, |, or &) and newlines. @@ -12,15 +69,20 @@ export type ShellToken = string | { op: string } | { command: string } * - PowerShell redirections (2>&1) * - Chain operators (&&, ||, ;, |, &) * - Newlines as command separators + * - Multiline quoted strings (preserves them as single commands) */ export function parseCommand(command: string): string[] { if (!command?.trim()) { return [] } - // Split by newlines first (handle different line ending formats) + // First, replace multiline quoted strings with placeholders + // This prevents them from being incorrectly split when we split by newlines + const { processedCommand, multilineDoubleQuotes, multilineSingleQuotes } = replaceMultilineQuotes(command) + + // Split by newlines (handle different line ending formats) // This regex splits on \r\n (Windows), \n (Unix), or \r (old Mac) - const lines = command.split(/\r\n|\r|\n/) + const lines = processedCommand.split(/\r\n|\r|\n/) const allCommands: string[] = [] for (const line of lines) { @@ -31,7 +93,13 @@ export function parseCommand(command: string): string[] { // Process each line through the existing parsing logic const lineCommands = parseCommandLine(line) - allCommands.push(...lineCommands) + + // Restore multiline quotes in each parsed command + const restoredCommands = lineCommands.map((cmd) => + restoreMultilineQuotes(cmd, multilineDoubleQuotes, multilineSingleQuotes), + ) + + allCommands.push(...restoredCommands) } return allCommands