fix: handle multiline quoted strings in command auto-approval with quote-aware parsing

This commit is contained in:
Roo Code 2025-12-20 13:58:52 +00:00
parent 78dc34498b
commit 35c8d13a2b
2 changed files with 306 additions and 4 deletions

View file

@ -0,0 +1,217 @@
import { parseCommand, joinQuotedLines } from "../parse-command"
describe("joinQuotedLines", () => {
it("should return empty array for empty input", () => {
expect(joinQuotedLines("")).toEqual([])
expect(joinQuotedLines(null as any)).toEqual([])
expect(joinQuotedLines(undefined as any)).toEqual([])
})
it("should handle single line commands without quotes", () => {
expect(joinQuotedLines("echo hello")).toEqual(["echo hello"])
expect(joinQuotedLines("git status")).toEqual(["git status"])
})
it("should split multiple lines without quotes", () => {
expect(joinQuotedLines("echo hello\necho world")).toEqual(["echo hello", "echo world"])
expect(joinQuotedLines("git status\ngit log")).toEqual(["git status", "git log"])
})
it("should keep multiline double-quoted strings together", () => {
const input = 'bd create "This is a\nmultiline description"'
expect(joinQuotedLines(input)).toEqual([input])
})
it("should keep multiline single-quoted strings together", () => {
const input = "bd create 'This is a\nmultiline description'"
expect(joinQuotedLines(input)).toEqual([input])
})
it("should handle Windows line endings (CRLF) within quotes", () => {
const input = 'bd create "This is a\r\nmultiline description"'
expect(joinQuotedLines(input)).toEqual([input])
})
it("should handle Windows line endings (CRLF) between commands", () => {
const input = "echo hello\r\necho world"
expect(joinQuotedLines(input)).toEqual(["echo hello", "echo world"])
})
it("should handle old Mac line endings (CR) within quotes", () => {
const input = 'bd create "This is a\rmultiline description"'
expect(joinQuotedLines(input)).toEqual([input])
})
it("should handle multiple newlines within quotes", () => {
const input = 'bd create "Line 1\nLine 2\nLine 3"'
expect(joinQuotedLines(input)).toEqual([input])
})
it("should handle quoted strings followed by newline and another command", () => {
const input = 'bd create "multiline\ndesc"\necho done'
expect(joinQuotedLines(input)).toEqual(['bd create "multiline\ndesc"', "echo done"])
})
it("should handle escaped quotes within double-quoted strings", () => {
const input = 'echo "hello \\"world\\"\nline 2"'
expect(joinQuotedLines(input)).toEqual([input])
})
it("should handle single quotes within double quotes", () => {
const input = 'echo "it\'s\na test"'
expect(joinQuotedLines(input)).toEqual([input])
})
it("should handle double quotes within single quotes", () => {
// Single quotes preserve everything literally
const input = "echo 'He said \"hello\"\nworld'"
expect(joinQuotedLines(input)).toEqual([input])
})
it("should skip empty lines between commands", () => {
expect(joinQuotedLines("echo hello\n\necho world")).toEqual(["echo hello", "echo world"])
expect(joinQuotedLines("echo hello\n \necho world")).toEqual(["echo hello", "echo world"])
})
it("should handle complex multiline commands with command chaining", () => {
const input = 'bd create "desc\nmore" && npm install\necho done'
expect(joinQuotedLines(input)).toEqual(['bd create "desc\nmore" && npm install', "echo done"])
})
})
describe("parseCommand", () => {
it("should return empty array for empty input", () => {
expect(parseCommand("")).toEqual([])
expect(parseCommand(" ")).toEqual([])
expect(parseCommand(null as any)).toEqual([])
expect(parseCommand(undefined as any)).toEqual([])
})
it("should parse simple single command", () => {
expect(parseCommand("echo hello")).toEqual(["echo hello"])
expect(parseCommand("git status")).toEqual(["git status"])
})
it("should parse command chain with &&", () => {
expect(parseCommand("npm install && npm test")).toEqual(["npm install", "npm test"])
})
it("should parse command chain with ||", () => {
expect(parseCommand("npm test || echo failed")).toEqual(["npm test", "echo failed"])
})
it("should parse command chain with ;", () => {
expect(parseCommand("echo hello; echo world")).toEqual(["echo hello", "echo world"])
})
it("should parse commands on separate lines", () => {
expect(parseCommand("echo hello\necho world")).toEqual(["echo hello", "echo world"])
})
it("should handle multiline double-quoted strings as single command", () => {
const input = 'bd create "This is a\nmultiline description"'
const result = parseCommand(input)
expect(result).toEqual(['bd create "This is a\nmultiline description"'])
})
it("should handle multiline single-quoted strings as single command", () => {
const input = "bd create 'This is a\nmultiline description'"
const result = parseCommand(input)
// Note: shell-quote strips single quotes, but the multiline content stays together
expect(result.length).toBe(1)
expect(result[0]).toContain("bd create")
expect(result[0]).toContain("This is a")
expect(result[0]).toContain("multiline description")
})
it("should handle multiline quoted string with command chain on same line", () => {
const input = 'bd create "desc\nmore" && echo done'
const result = parseCommand(input)
expect(result).toEqual(['bd create "desc\nmore"', "echo done"])
})
it("should handle multiline quoted string followed by newline command", () => {
const input = 'bd create "desc\nmore"\necho done'
const result = parseCommand(input)
expect(result).toEqual(['bd create "desc\nmore"', "echo done"])
})
it("should handle real-world beads create command", () => {
const input = `bd create "This is the first line.
This is the second line.
This is the third line."`
const result = parseCommand(input)
expect(result.length).toBe(1)
expect(result[0]).toContain("bd create")
expect(result[0]).toContain("first line")
expect(result[0]).toContain("third line")
})
it("should handle beads command followed by another command", () => {
const input = `bd create "Multiline
description" && npm install`
const result = parseCommand(input)
expect(result).toEqual(['bd create "Multiline\ndescription"', "npm install"])
})
it("should handle multiple multiline quoted strings in sequence", () => {
const input = 'echo "Line 1\nLine 2" && echo "Line 3\nLine 4"'
const result = parseCommand(input)
expect(result).toEqual(['echo "Line 1\nLine 2"', 'echo "Line 3\nLine 4"'])
})
it("should handle Windows line endings in multiline quotes", () => {
const input = 'bd create "Line 1\r\nLine 2"'
const result = parseCommand(input)
expect(result).toEqual(['bd create "Line 1\r\nLine 2"'])
})
it("should preserve variable references", () => {
const input = 'echo $HOME && echo "path: $PATH"'
const result = parseCommand(input)
expect(result.length).toBe(2)
expect(result[0]).toContain("$HOME")
expect(result[1]).toContain("$PATH")
})
it("should handle empty lines in the input", () => {
const input = "echo hello\n\necho world"
const result = parseCommand(input)
expect(result).toEqual(["echo hello", "echo world"])
})
})
describe("parseCommand - auto-approval validation scenarios", () => {
// These tests verify the fix for issue #10226:
// Multiline commands with quoted strings should be correctly parsed
// for auto-approval validation
it("should correctly identify bd command prefix with multiline description", () => {
const input = 'bd create "This is a\nmultiline\ndescription"'
const result = parseCommand(input)
// Should be a single command that starts with "bd"
expect(result.length).toBe(1)
expect(result[0].startsWith("bd ")).toBe(true)
})
it("should correctly split bd command from other commands", () => {
const input = 'bd create "desc\nmore" && npm install'
const result = parseCommand(input)
expect(result.length).toBe(2)
expect(result[0].startsWith("bd ")).toBe(true)
expect(result[1].startsWith("npm ")).toBe(true)
})
it("should handle multiline command on separate lines from other commands", () => {
const input = `bd create "multiline
description"
echo done`
const result = parseCommand(input)
expect(result.length).toBe(2)
expect(result[0].startsWith("bd ")).toBe(true)
expect(result[1]).toBe("echo done")
})
})

View file

@ -2,6 +2,91 @@ import { parse } from "shell-quote"
export type ShellToken = string | { op: string } | { command: string }
/**
* Join lines that are within quoted strings.
*
* This function scans through the input character by character, tracking
* whether we're inside a quoted string. When we encounter a newline while
* inside quotes, we preserve it rather than splitting the command.
*
* This approach:
* - Preserves the original content exactly (no placeholder replacement/restoration)
* - Properly handles both single and double quotes
* - Handles escaped quotes within strings
* - Handles different line ending formats (CRLF, LF, CR)
*
* @param command - The command string that may contain multiline quoted strings
* @returns Array of logical command lines with multiline quotes joined
*/
export function joinQuotedLines(command: string): string[] {
if (!command) {
return []
}
const result: string[] = []
let currentLine = ""
let inDoubleQuote = false
let inSingleQuote = false
let i = 0
while (i < command.length) {
const char = command[i]
const prevChar = i > 0 ? command[i - 1] : ""
// Handle escape sequences (only in double quotes, single quotes are literal)
const isEscaped = prevChar === "\\" && inDoubleQuote
// Handle quote state changes
if (char === '"' && !inSingleQuote && !isEscaped) {
inDoubleQuote = !inDoubleQuote
currentLine += char
} else if (char === "'" && !inDoubleQuote) {
// Single quotes can't be escaped, so we just toggle
inSingleQuote = !inSingleQuote
currentLine += char
} else if (char === "\r" || char === "\n") {
// Handle different line endings: \r\n, \n, or \r
if (char === "\r" && command[i + 1] === "\n") {
// CRLF - consume both characters
if (inDoubleQuote || inSingleQuote) {
// Inside quotes - preserve the newline in the command
currentLine += "\r\n"
} else {
// Outside quotes - this is a line separator
if (currentLine.trim()) {
result.push(currentLine)
}
currentLine = ""
}
i++ // Skip the \n since we consumed it
} else {
// LF or CR alone
if (inDoubleQuote || inSingleQuote) {
// Inside quotes - preserve the newline in the command
currentLine += char
} else {
// Outside quotes - this is a line separator
if (currentLine.trim()) {
result.push(currentLine)
}
currentLine = ""
}
}
} else {
currentLine += char
}
i++
}
// Don't forget the last line
if (currentLine.trim()) {
result.push(currentLine)
}
return result
}
/**
* Split a command string into individual sub-commands by
* chaining operators (&&, ||, ;, |, or &) and newlines.
@ -11,16 +96,16 @@ export type ShellToken = string | { op: string } | { command: string }
* - Subshell commands ($(cmd), `cmd`, <(cmd), >(cmd))
* - PowerShell redirections (2>&1)
* - Chain operators (&&, ||, ;, |, &)
* - Newlines as command separators
* - Newlines as command separators (respecting quoted strings)
*/
export function parseCommand(command: string): string[] {
if (!command?.trim()) {
return []
}
// Split by newlines first (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/)
// Join lines that are within quoted strings first
// This preserves multiline quoted content as single logical lines
const lines = joinQuotedLines(command)
const allCommands: string[] = []
for (const line of lines) {