(command: string)
| 469 | * should surface the error rather than proceeding with auto-approval. |
| 470 | */ |
| 471 | export function parseCommand(command: string): ParseResult { |
| 472 | if (!command?.trim()) { |
| 473 | return { commands: [], parseError: null } |
| 474 | } |
| 475 | |
| 476 | // Run the shared state-machine scan once. It gives us both the list of |
| 477 | // top-level quoted spans (used by maskTopLevelQuotes) and the parse-error |
| 478 | // descriptor (used here to detect malformed input) in a single pass. |
| 479 | const { unterminatedQuote } = scanTopLevelQuotes(command) |
| 480 | |
| 481 | // Reject syntactically malformed input -- an unterminated quote is a shell |
| 482 | // syntax error. Return the raw input as a single opaque token so callers |
| 483 | // can surface the error to the agent rather than splitting unsafe fragments |
| 484 | // that might be auto-approved in isolation. |
| 485 | if (unterminatedQuote !== null) { |
| 486 | return { commands: [command], parseError: unterminatedQuote } |
| 487 | } |
| 488 | |
| 489 | // Pre-escape any literal __ sequences present in the raw command so they |
| 490 | // cannot collide with the internal placeholder tokens (e.g. __QUOTE_0__) |
| 491 | // used during masking and splitting. \x00 (the null byte, U+0000) is the |
| 492 | // sentinel: it encodes end-of-string at the C/OS level, so the OS terminates |
| 493 | // any command string at the first \x00 -- meaning a real shell command can |
| 494 | // never contain one. It therefore cannot appear in any command text the |
| 495 | // parser receives and will never match a placeholder regex. The post-unescape |
| 496 | // step at the return converts \x00 back to __ in every output command. |
| 497 | const escapedCommand = command.replace(/__/g, "\x00") |
| 498 | |
| 499 | // Mask quoted strings before splitting on newlines so that newlines embedded |
| 500 | // inside a quoted argument are not mistaken for command separators. The |
| 501 | // masker delegates to scanTopLevelQuotes internally, ensuring identical |
| 502 | // quoting rules with the check above. |
| 503 | const { masked, quotes: topLevelQuotes } = maskTopLevelQuotes(escapedCommand) |
| 504 | |
| 505 | // Split on unquoted newlines (all line-ending formats). |
| 506 | const lines = masked.split(/\r\n|\r|\n/) |
| 507 | const allCommands: string[] = [] |
| 508 | |
| 509 | for (const line of lines) { |
| 510 | if (!line.trim()) { |
| 511 | continue |
| 512 | } |
| 513 | |
| 514 | // Restore top-level quote placeholders before per-line parsing so that |
| 515 | // parseCommandLine sees the original quoted content and can apply its own |
| 516 | // masking for operator splitting. |
| 517 | const restoredLine = line.replace(/__TOPLEVEL_QUOTE_(\d+)__/g, (_, i) => topLevelQuotes[parseInt(i)]) |
| 518 | |
| 519 | // If the restored line contains embedded newlines it means a top-level |
| 520 | // quote (e.g. a heredoc) spanned multiple lines. The entire restored |
| 521 | // string is a single atomic command -- passing it through parseCommandLine |
| 522 | // would let shell-quote split on the embedded newlines and << operators. |
| 523 | if (restoredLine.includes("\n")) { |
| 524 | allCommands.push(restoredLine) |
| 525 | continue |
| 526 | } |
| 527 | |
| 528 | const lineCommands = parseCommandLine(restoredLine) |
no test coverage detected