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
This commit is contained in:
Roo Code 2025-12-20 13:47:57 +00:00
parent 78dc34498b
commit 3835a313ce
2 changed files with 222 additions and 3 deletions

View file

@ -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"])
})
})
})

View file

@ -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