From d32e69f7c17c93f14bbd20c4a7db473316dd79ad Mon Sep 17 00:00:00 2001 From: Franz Daubner Date: Fri, 16 Oct 2026 01:19:56 +0200 Subject: [PATCH] fix(auto-approval): extract nested shell substitutions for allowlist The command splitter pre-replaced shell constructs with placeholder tokens by regex before handing the string to shell-quote. Parameter expansions were masked before substitution extraction, so the payload of `echo ${x:-$(rm -rf ~/work)}` never became its own segment, and the lazy substitution regex cut nested forms at the first close paren, so `ls $(foo $(rm -rf /) bar)` produced three bogus fragments. In both shapes the inner command rode the outer command's allowlist approval (#1947). Replace the sequential regex pipeline in parse-command.ts with one quote-aware, left-to-right scan that tracks quote state, delimiter depth, and heredoc state in a single pass. shell-quote still tokenizes the masked host text. Every extracted substitution payload is re-scanned through the same path, so nested, expansion-embedded, process-substitution, and heredoc-embedded commands become independent segments that each match the allow/deny lists under the existing rules. A heredoc body under an unquoted delimiter is scanned, because the shell expands substitutions there; a quoted-delimiter body stays literal. The scan also fails closed. An expansion or arithmetic delimiter that was opened but never closed, a stray close delimiter with no opener, a raw NUL byte, or substitution nesting beyond 64 levels now yields a parse error, and the decision becomes malformed_command (ask/deny) instead of an approval resting on a partial decomposition. Unbalanced delimiters inside quoted regions stay parseable: a shell never parses them there. Allow/deny matching semantics are untouched; commands.ts gains comment updates only. Substitutions inside double-quoted regions remain opaque to the splitter, as before. --- .../auto-approval/__tests__/commands.spec.ts | 79 + src/core/auto-approval/commands.ts | 32 +- src/eslint-suppressions.json | 5 - src/shared/__tests__/parse-command.spec.ts | 346 +++- src/shared/parse-command.ts | 1777 +++++++++++------ .../utils/__tests__/command-parser.spec.ts | 22 + 6 files changed, 1673 insertions(+), 588 deletions(-) diff --git a/src/core/auto-approval/__tests__/commands.spec.ts b/src/core/auto-approval/__tests__/commands.spec.ts index ade53c2b20..e0c689840e 100644 --- a/src/core/auto-approval/__tests__/commands.spec.ts +++ b/src/core/auto-approval/__tests__/commands.spec.ts @@ -98,6 +98,74 @@ describe("getCommandDecision — integration with dangerous substitution checks" it("should ask user for dangerous parameter expansion even when command is allowed", () => { expect(getCommandDecision('echo "${var@P}"', allowedCommands)).toBe("ask_user") }) + + it("does not auto-approve a substitution embedded in a parameter expansion", () => { + // The shell runs $(rm -rf ~/work) when x is unset, so the inner command must + // match the lists on its own; allowing `echo` must not approve the payload. + expect(getCommandDecision("echo ${x:-$(rm -rf ~/work)}", ["echo"])).toBe("ask_user") + }) + + it("does not auto-approve a nested substitution riding outer allowlist entries", () => { + // `$(foo $(rm -rf /) bar)` must decompose so the innermost `rm -rf /` is + // evaluated independently; cutting at the first ")" would let the payload ride + // the `foo` and `bar` allowlist entries. + expect(getCommandDecision("ls $(foo $(rm -rf /) bar)", ["ls", "foo", "bar"])).toBe("ask_user") + }) + + it("asks user when a heredoc opened inside a parameter expansion carries a substitution", () => { + // With x unset, bash expands the alternate word and runs the heredoc body's + // $(evil) even though the expansion itself stays one segment. + expect(getCommandDecision("cat ${x:-y < { + // bash drops the NUL and runs the glued substitution, so the placeholder + // degradation must not leave `git status$(whoami)` looking like one allowed + // word: the input is rejected as malformed instead. + expect(getCommandDecision("git status\x00$(whoami)", ["git"])).toBe("malformed_command") + }) + + it("asks user for a substitution on the heredoc opener line after the delimiter", () => { + // The opener line's remainder is expanded even when the delimiter is quoted, + // so $(whoami) there matches the lists on its own. + expect(getCommandDecision("cat < { + // The shell expands $(whoami) inside an unquoted-delimiter heredoc body, so the + // inner command must match the lists even though the heredoc is one host segment. + const heredoc = "cat < { + expect(getCommandDecision("diff <(sort a) <(sort b)", ["diff"])).toBe("ask_user") + }) + + it("auto-approves an unbalanced delimiter inside single quotes", () => { + // A shell never parses delimiter characters inside single quotes, so an + // unbalanced "$(" there is ordinary argument text, not a syntax error. + expect(getCommandDecision("echo 'a $(b'", ["echo"])).toBe("auto_approve") + }) + + it("fails closed on a stray closer even when the first word is allowed", () => { + // A stray ")" is a shell syntax error; treating the command as malformed + // costs a prompt instead of letting an allowlisted first word carry a + // structure the parser cannot account for straight to execution. + expect(getCommandDecision("echo )", ["echo"])).toBe("malformed_command") + }) + + it("fails closed on an unclosed arithmetic opener even when the first word is allowed", () => { + // bash refuses an unclosed "$(("; the masked remainder must not let the + // allowlisted "echo" carry a never-closed "$(" straight to execution. + expect(getCommandDecision("echo $(( $(evil)", ["echo"])).toBe("malformed_command") + }) + + it("fails closed on a CR-only heredoc opener even when the first word is allowed", () => { + // A lone CR does not terminate the opener line, so the heredoc is + // unterminated: the input must not look well-formed to the allowlists. + expect(getCommandDecision("cat < { @@ -197,6 +265,17 @@ describe("getCommandDecisionDetailed", () => { expect(getCommandDecisionDetailed(" ", ["git"])).toEqual({ decision: "auto_approve" }) }) + it("reports malformed_command for an unclosed command substitution", () => { + // An opened "$(" the scanner cannot close means the input's structure is + // unaccounted for; the raw (unsplit) command is reported so no fragment of a + // partial decomposition can be approved in isolation. + const result = getCommandDecisionDetailed("echo $(whoami", ["echo"]) + + expect(result.decision).toBe("malformed_command") + expect(result.offendingCommand).toBe("echo $(whoami") + expect(result.parseError).toContain("command substitution") + }) + it("returns the same decision as getCommandDecision across representative inputs", () => { const cases: Array<[string, string[], string[] | undefined]> = [ ["git status", ["git"], []], diff --git a/src/core/auto-approval/commands.ts b/src/core/auto-approval/commands.ts index 9d7289c265..a6871b96d9 100644 --- a/src/core/auto-approval/commands.ts +++ b/src/core/auto-approval/commands.ts @@ -202,7 +202,16 @@ export function isAutoDeniedSingleCommand( } /** - * Command approval decision types + * Command approval decision types. + * + * `malformed_command` is the fail-closed outcome: the parser could not fully account + * for the command's structure (an unterminated quote or heredoc, an expansion + * delimiter -- $(, ${, backtick, <( or >( -- or an arithmetic expression ($(( or + * $[) opened but never closed, a stray ")" or "}" with no opener, a raw NUL byte, + * or substitution nesting beyond the re-scan depth cap), so no approval may rest + * on a partial decomposition. + * Unbalanced delimiters inside quoted regions do not qualify: a shell never parses + * them there. */ export type CommandDecision = "auto_approve" | "auto_deny" | "ask_user" | "malformed_command" @@ -224,7 +233,13 @@ export type CommandDecision = "auto_approve" | "auto_deny" | "ask_user" | "malfo * - `"auto_approve"`: All sub-commands are explicitly allowed and no dangerous patterns detected * - `"auto_deny"`: At least one sub-command is explicitly denied * - `"ask_user"`: Mixed or no matches found, requires user decision, or contains dangerous patterns - * - `"malformed_command"`: Command contains an unterminated quote -- a shell syntax error that must not be auto-approved + * - `"malformed_command"`: The parser cannot fully account for the command's structure -- an + * unterminated quote or heredoc, an expansion delimiter ($(...), ${...}, backtick, <( or >()) + * or arithmetic expression ($(( or $[) opened but never closed, a stray ")" or "}" with no + * opener, a raw NUL byte, or substitution + * nesting beyond the parser's re-scan depth cap. A command whose structure is unproven must + * never be approved on a partial decomposition. Delimiter characters inside quoted regions are + * exempt: a shell never parses them there, so unbalanced ones stay parseable. * * **Examples:** * ```typescript @@ -302,14 +317,17 @@ export function getCommandDecisionDetailed( } // Parse into sub-commands (split by &&, ||, ;, |). parseCommand also - // detects shell syntax errors (unterminated quotes, unclosed heredocs) and + // detects unaccountable input -- unterminated quotes or heredocs, expansion + // delimiters ($(), ${}, backticks, <( , >() or arithmetic expressions + // ($(( , $[) opened but never closed, a stray closer, a raw NUL byte, or + // too-deep substitution nesting) -- and // returns a non-null parseError in that case. const { commands: subCommands, parseError } = parseCommand(command) - // Reject commands with a shell syntax error. An unterminated quote means - // the shell would report a parse error; in a compound command it may - // partially execute the well-formed prefix before aborting. Returning a - // distinct decision lets callers surface a useful message to the agent + // Reject commands whose structure the parser could not fully account for. + // The shell would report a parse error on such input; in a compound command + // it may partially execute the well-formed prefix before aborting. Returning + // a distinct decision lets callers surface a useful message to the agent // rather than silently presenting the command for user approval. if (parseError !== null) { return { decision: "malformed_command", offendingCommand: command, parseError: parseError.message } diff --git a/src/eslint-suppressions.json b/src/eslint-suppressions.json index f1b2590f7f..c74b8e7f5e 100644 --- a/src/eslint-suppressions.json +++ b/src/eslint-suppressions.json @@ -1619,11 +1619,6 @@ "count": 1 } }, - "shared/parse-command.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 1 - } - }, "shared/support-prompt.ts": { "@typescript-eslint/no-explicit-any": { "count": 5 diff --git a/src/shared/__tests__/parse-command.spec.ts b/src/shared/__tests__/parse-command.spec.ts index fa41d31c6c..f57c64cf61 100644 --- a/src/shared/__tests__/parse-command.spec.ts +++ b/src/shared/__tests__/parse-command.spec.ts @@ -323,6 +323,23 @@ describe("parseCommand", () => { expect(result).toHaveLength(2) expect(result[result.length - 1]).toBe("echo world") }) + + it("parses CRLF heredocs for every delimiter form and extracts unquoted-body substitutions", () => { + // A CR ends the delimiter word in every delimiter form, so the + // terminator line (which ends at the CR like every other line) stays + // matchable and a well-formed CRLF heredoc never over-fires. + const { commands, parseError } = parseCommand("cat < { @@ -498,10 +515,333 @@ describe("parseCommand", () => { }) it("returns null for a balanced <<- heredoc with an indented terminator", () => { - // stripTabs must be true for <<- so the indented terminator line is - // recognized; without the fix every terminator line was unconditionally - // stripped of tabs, making <<- and << behave identically. + // <<- alone enables tab stripping; stripping unconditionally would make + // << and <<- indistinguishable at the terminator. expect(findUnterminatedQuote("sh <<-EOF\n\techo hello\n\tEOF")).toBeNull() }) }) + + describe("command substitution extraction", () => { + // Every command substitution must be extracted with balanced-paren + // awareness wherever it sits, so each inner command matches the + // allow/deny lists on its own. + + it("extracts the inner payload of a nested substitution as its own segment", () => { + // A non-balanced parser truncates the payload at the first ')' and hides + // the inner command from the lists. + expect(parseCommand("echo $(foo $(bar))").commands).toEqual(["echo", "foo", "bar"]) + }) + + it("extracts a substitution embedded inside a parameter expansion", () => { + // ${x:-$(cmd)} falls back to running the substitution when x is + // unset, so the payload must be matched independently even though + // the expansion itself stays verbatim in its host segment. + const input = "echo ${x:-$(rm -rf ~/work)}" + expect(parseCommand(input).commands).toEqual([input, "rm -rf ~/work"]) + }) + + it("extracts a substitution inside a parameter expansion inside a substitution", () => { + // Recursion: the outer $() payload is re-scanned, which finds the + // ${b:-$(c)} expansion and extracts c from inside it. + expect(parseCommand("$(a ${b:-$(c)} d)").commands).toEqual(["a ${b:-$(c)} d", "c"]) + }) + + it("extracts both sides of a process substitution pair", () => { + expect(parseCommand("diff <(sort a) <(sort b)").commands).toEqual(["diff", "sort a", "sort b"]) + }) + + it("extracts a nested process substitution", () => { + expect(parseCommand("cat <(a <(b))").commands).toEqual(["cat", "a", "b"]) + }) + + it("closes a backtick region at the next backtick (backticks do not nest)", () => { + // The second ` closes the first region; the third ` opens a second + // region. Middle text between regions stays in its host word. + expect(parseCommand("echo `a`b`c`").commands).toEqual(["echo", "a", "b", "c"]) + }) + + it("masks arithmetic without emitting segments from inside it", () => { + // $((...)) and $[...] are not command substitutions: the whole + // arithmetic stays inside the host word and never balances as an + // extraction delimiter. + expect(parseCommand("echo $((1+2))").commands).toEqual(["echo $((1+2))"]) + expect(parseCommand("echo $[1+2]").commands).toEqual(["echo $[1+2]"]) + }) + + it("extracts substitutions from an unquoted-delimiter heredoc body while the heredoc stays one segment", () => { + // The shell expands $(...) inside such bodies, so the payload must + // match the lists on its own; the heredoc itself remains opaque. + const input = "cat < { + // bash performs command substitutions inside $((…)) before evaluating + // the arithmetic, so the payload must match the lists on its own + // instead of riding the allowlisted host word. + const input = "echo $(( $(rm -rf /) ))" + const { commands, parseError } = parseCommand(input) + expect(parseError).toBeNull() + expect(commands).toEqual([input, "rm -rf /"]) + }) + + it("does not extract from a quoted-delimiter heredoc body (the shell suppresses expansion there)", () => { + const input = "cat <<'EOF'\n$(whoami)\nEOF" + const { commands, parseError } = parseCommand(input) + expect(parseError).toBeNull() + expect(commands).toEqual([input]) + }) + + it("extracts a substitution from a heredoc body opened inside a parameter expansion", () => { + // With x unset, bash expands the alternate word: the heredoc runs inside + // the expansion and executes $(evil), so the body payload must surface as + // its own segment even though the expansion host stays verbatim. + const input = "cat ${x:-y < { + // A quoted delimiter suppresses body expansion, so $(evil) is literal + // text the shell never runs; no payload segment may appear. + const input = "cat ${x:-y <<'EOF'\n$(evil)\nEOF\n}" + const { commands, parseError } = parseCommand(input) + expect(parseError).toBeNull() + expect(commands).toEqual([input]) + }) + + it("extracts a substitution after the delimiter on a heredoc opener line", () => { + // The opener line's remainder is expanded regardless of delimiter quoting: + // bash runs the substitution sitting between the delimiter and the newline. + const input = "cat < { + // Quoting suppresses expansion in the body only, never on the opener + // line, so <<'EOF' and <<- carry the same remainder obligation as <<. + const quoted = "cat <<'EOF' $(whoami)\nbody\nEOF" + expect(parseCommand(quoted).commands).toEqual([quoted, "whoami"]) + const dash = "cat <<-EOF\t$(whoami)\nbody\nEOF" + expect(parseCommand(dash).commands).toEqual([dash, "whoami"]) + }) + }) + + describe("fail-closed on unaccountable structure", () => { + // A command whose structure the scan cannot fully account for must cost + // a prompt (parseError -> malformed_command), never an approval built on + // a partial decomposition. + + it("reports an unclosed command substitution", () => { + const input = "echo $(foo" + const { commands, parseError } = parseCommand(input) + expect(commands).toEqual([input]) + expect(parseError?.quoteType).toBe("command-substitution") + }) + + it("reports an unclosed parameter expansion", () => { + const input = "echo ${x" + const { commands, parseError } = parseCommand(input) + expect(commands).toEqual([input]) + expect(parseError?.quoteType).toBe("parameter-expansion") + }) + + it("reports an unclosed backtick region", () => { + const input = "echo `foo" + const { commands, parseError } = parseCommand(input) + expect(commands).toEqual([input]) + expect(parseError?.quoteType).toBe("backtick") + }) + + it("reports an unclosed process substitution", () => { + const input = "diff <(sort" + const { commands, parseError } = parseCommand(input) + expect(commands).toEqual([input]) + expect(parseError?.quoteType).toBe("process-substitution") + }) + + it("reports a stray closing paren at depth zero", () => { + const input = "echo )" + const { commands, parseError } = parseCommand(input) + expect(commands).toEqual([input]) + expect(parseError?.quoteType).toBe("stray-closer") + }) + + it("reports a stray closing brace at depth zero", () => { + const input = "echo }" + const { commands, parseError } = parseCommand(input) + expect(commands).toEqual([input]) + expect(parseError?.quoteType).toBe("stray-closer") + }) + + it("treats a non-matching closer inside an open region as literal and still fails at end of input", () => { + // '}' cannot close $( ...; shell-faithful: it is literal content. + // The substitution never closes, so the command fails at EOF. + const input = "echo $(a}" + const { commands, parseError } = parseCommand(input) + expect(commands).toEqual([input]) + expect(parseError?.quoteType).toBe("command-substitution") + }) + + it("reports the widened expansion kinds with the opening index", () => { + expect(findUnterminatedQuote("echo $(x")).toEqual( + expect.objectContaining({ quoteType: "command-substitution", openIndex: 5 }), + ) + expect(findUnterminatedQuote("echo `x")).toEqual( + expect.objectContaining({ quoteType: "backtick", openIndex: 5 }), + ) + }) + + it("fails closed on a raw NUL byte anywhere in the input", () => { + // NUL is the scan's placeholder sentinel: a raw NUL glued to a + // substitution would degrade the masked placeholder to inert text and + // hide the payload, so the whole command is rejected before tokenizing. + const glued = "git status\x00$(whoami)" + const { commands, parseError } = parseCommand(glued) + expect(commands).toEqual([glued]) + expect(parseError?.quoteType).toBe("nul-byte") + + const backtickGlue = parseCommand("echo ok\x00`whoami`") + expect(backtickGlue.parseError?.quoteType).toBe("nul-byte") + + const loneNul = parseCommand("\x00") + expect(loneNul.parseError?.quoteType).toBe("nul-byte") + }) + + it("caps substitution re-scan depth at the documented boundary, failing closed past it", () => { + // Unbounded payload re-scanning burns quadratic time on generated + // nesting and finally overflows the call stack; the cap converts deep + // nesting to a parse error at exactly MAX_PAYLOAD_SCAN_DEPTH + 1 layers + // while leaving anything a hand-written command could contain parseable. + const nest = (layers: number) => "echo " + "$(a ".repeat(layers) + "x" + ")".repeat(layers) + expect(parseCommand(nest(64)).parseError).toBeNull() + expect(parseCommand(nest(65)).parseError?.quoteType).toBe("nesting-too-deep") + expect(parseCommand(nest(8000)).parseError?.quoteType).toBe("nesting-too-deep") + }) + + it("fails on an unclosed substitution inside an unquoted-delimiter heredoc body", () => { + // The terminator line is found, but the shell would never finish + // expanding the body, so whatever follows the $( is unaccountable. + const input = "cat < { + const input = "echo $(( $(evil)" + const { commands, parseError } = parseCommand(input) + expect(commands).toEqual([input]) + expect(parseError?.quoteType).toBe("arithmetic") + }) + + it("fails closed on an unclosed $(( that would swallow a later statement", () => { + // Routing the masked remainder to the host instead would split the + // input into two segments that can each match the allowlist; bash + // rejects the input outright, so failing closed only costs a prompt. + const swallowed = "echo $((1 )\nevilcmd arg" + const { commands, parseError } = parseCommand(swallowed) + expect(commands).toEqual([swallowed]) + expect(parseError?.quoteType).toBe("arithmetic") + // The matched control keeps its extraction behavior unchanged. + const control = "echo $(( $(evil) ))" + expect(parseCommand(control).commands).toEqual([control, "evil"]) + }) + + it("reports an unclosed $(( nested in a parameter expansion by its own kind", () => { + // The error must name the arithmetic opener: letting the outer + // region fail at end of input would report the wrong construct and + // silently drop the arithmetic's interior payloads. + const input = "echo ${x:-$(( $(evil) }" + const { commands, parseError } = parseCommand(input) + expect(commands).toEqual([input]) + expect(parseError?.quoteType).toBe("arithmetic") + }) + + it("fails closed on an unclosed $(( inside a heredoc body", () => { + const input = "cat < { + // `$[` is the deprecated sibling of `$((`: bash errors on a missing + // `]`, so an unclosed opener must fail closed exactly like `$((`. + const top = "echo $[ $(evil)" + expect(parseCommand(top).commands).toEqual([top]) + expect(parseCommand(top).parseError?.quoteType).toBe("arithmetic") + expect(parseCommand("cat < { + // A lone CR is not a bash line terminator, so the opener line never + // ends: the shell reports an unterminated heredoc. + const input = "cat < { + const input = "cat < { + // With an LF opener but CR-only line endings in the body, "EOF\r" is + // body text to bash; accepting it as the terminator would validate a + // heredoc the shell reports unterminated. + const input = "cat < { + // Requiring LF terminators keeps every CRLF case working: the CR is + // consumed as part of the pair, so the delimiter word and terminator + // line still match and the body payload is still extracted. + const { commands, parseError } = parseCommand("cat < { + // Unbalanced delimiter characters a shell never looks at inside quoted + // regions stay parseable rather than failing closed. + + it("does not fail on an unbalanced substitution inside single quotes", () => { + const input = "echo '$(x'" + const { commands, parseError } = parseCommand(input) + expect(parseError).toBeNull() + expect(commands).toEqual([input]) + }) + + it("does not fail on unbalanced delimiters inside double quotes", () => { + const input = 'echo "$(}"' + const { commands, parseError } = parseCommand(input) + expect(parseError).toBeNull() + expect(commands).toEqual([input]) + }) + + it("ignores a stray closer inside a comment and a substitution text inside a quoted-delimiter heredoc body", () => { + const input = "echo hi # )\ncat <<'EOF'\n$(x\nEOF" + const { commands, parseError } = parseCommand(input) + expect(parseError).toBeNull() + expect(commands[0]).toBe("echo hi") + expect(commands[commands.length - 1]).toContain("$(x") + }) + }) }) diff --git a/src/shared/parse-command.ts b/src/shared/parse-command.ts index 6b2fb4d267..141abc831b 100644 --- a/src/shared/parse-command.ts +++ b/src/shared/parse-command.ts @@ -3,23 +3,63 @@ import { parse } from "shell-quote" export type ShellToken = string | { op: string } | { command: string } /** - * The style of quoting that opened a region. + * The kind of structural region a malformed-command error refers to. + * + * Quote and heredoc kinds describe regions that were opened and never closed: * * - `posix-single` and `ansi-c` share the `'` delimiter but follow different * escaping rules, so they must be distinguished. * - `locale` and `double` share the `"` delimiter but `locale` is prefixed with - * `$` (like ANSI-C), making `$"..."` a distinct token for accurate restoration. + * `$`, making `$"..."` a distinct token for accurate restoration. * - `heredoc` covers `<(…` that was never closed. + * - `parameter-expansion`: a `${` that was never closed. + * - `backtick`: a backtick region that was never closed. + * - `arithmetic`: an arithmetic expression (`$((` or `$[`) that was never + * closed. + * - `stray-closer`: a `)` or `}` seen at nesting depth 0 outside any quoted + * region, comment, or heredoc body — a closer with no matching opener. + * + * Rejection kinds describe input the scan refuses outright rather than token + * (both only ever cost a prompt, never an approval): + * + * - `nul-byte`: a raw NUL byte anywhere in the command. NUL is the scan's + * placeholder sentinel, so an input containing one could corrupt the + * masked-text round trip. + * - `nesting-too-deep`: substitution payloads re-scanned beyond + * `MAX_PAYLOAD_SCAN_DEPTH` levels — generated input, not a human command. */ -export type QuoteType = "posix-single" | "ansi-c" | "double" | "locale" | "heredoc" +export type QuoteType = + | "posix-single" + | "ansi-c" + | "double" + | "locale" + | "heredoc" + | "command-substitution" + | "process-substitution" + | "parameter-expansion" + | "backtick" + | "arithmetic" + | "stray-closer" + | "nul-byte" + | "nesting-too-deep" /** * The result of parsing a command string. * * `commands` is the list of individual sub-commands produced by splitting on * unquoted newlines and chain operators. When `parseError` is non-null the - * command string is syntactically malformed (e.g. an unterminated quote) and + * command string is syntactically malformed (an unterminated quote/heredoc, an + * expansion delimiter or arithmetic expression that was opened but never + * closed, a stray closer, a raw NUL byte, or substitution nesting beyond the + * re-scan depth cap) and * `commands` contains the raw input as a single opaque token so callers can * surface the error without splitting unsafe fragments. * @@ -33,14 +73,15 @@ export interface ParseResult { } /** - * Describes the opening of a quoted region that is never closed. + * Describes a structural delimiter the scan could not account for: a quoted, + * heredoc, or expansion region that is never closed, or a closing delimiter at + * depth 0 with no opener. * - * - `quoteType`: the style of the unterminated quote. `"heredoc"` means a - * `< = { @@ -62,696 +103,1286 @@ function unterminatedQuoteMessage(quoteType: QuoteType, openIndex: number, comma double: 'double quote (")', locale: 'locale quote ($")', heredoc: "heredoc (<<)", + "command-substitution": "command substitution ($(...))", + "process-substitution": "process substitution (<(...) or >(...))", + "parameter-expansion": "parameter expansion (${...})", + backtick: "backtick (`)", + arithmetic: "arithmetic expression ($(( or $[)", + "stray-closer": `closing delimiter '${command[openIndex] ?? ""}' with no matching opener`, + "nul-byte": "NUL byte (control characters cannot appear in a shell command)", + "nesting-too-deep": `substitution nesting deeper than ${MAX_PAYLOAD_SCAN_DEPTH} levels`, } + const problem = + quoteType === "stray-closer" || quoteType === "nul-byte" || quoteType === "nesting-too-deep" + ? labels[quoteType] + : `unterminated ${labels[quoteType]}` const snippetStart = Math.max(0, openIndex - 10) const snippetEnd = Math.min(command.length, openIndex + 20) const prefix = snippetStart > 0 ? "..." : "" const suffix = snippetEnd < command.length ? "..." : "" const excerpt = prefix + command.slice(snippetStart, snippetEnd).replace(/\r?\n/g, "\\n") + suffix - return `Malformed command: unterminated ${labels[quoteType]} at position ${openIndex} -- near: \`${excerpt}\`. ` + return `Malformed command: ${problem} at position ${openIndex} -- near: \`${excerpt}\`. ` } /** - * Scan a command string left-to-right with a small state machine and report the - * first quoted region that is never closed, or `null` when every quoted region - * is properly terminated. + * Report the first structural problem in `command`, or `null` when every + * quoted region, heredoc, and expansion delimiter is properly closed and no + * closer appears without its opener. * - * A regex cannot reliably answer "is this command well-quoted?" because quoting - * is context-sensitive: a backslash escapes the next character outside single - * quotes, `#` may begin a comment that should be ignored, and an apostrophe - * inside double quotes (or vice versa) is literal text rather than a delimiter. - * This walk mirrors how a POSIX shell tokenizes quoting so that legitimate - * multi-line quoted arguments are accepted while genuinely unterminated quotes - * (a shell syntax error) are detected. + * A regex cannot answer "can a shell parse this?" because shell syntax is + * context-sensitive: a backslash escapes the next character outside single + * quotes, `#` may begin a comment that should be ignored, a quote inside + * double quotes is literal text, and nesting must be balanced. The shared + * state-machine walk in `scanShellSyntax` mirrors POSIX tokenization so that + * legitimate multi-line quoted arguments are accepted while genuinely + * malformed input is detected. * - * Rules implemented: - * - Outside any quote, a backslash escapes the following character, so `\'` and - * `\"` are literal and do not open a region. - * - Outside any quote, `#` begins a comment when it is at the start of the input - * or preceded by whitespace; the remainder of that line is ignored. A `#` that - * is attached to a word (e.g. `foo#bar`) is an ordinary character. - * - Single quotes are opaque: no escapes apply, and the region ends only at the - * next `'`. - * - Double quotes honor backslash escapes, so `\"` does not close the region. - * - ANSI-C quoting ($'...') behaves like a single-quoted region for delimiter - * purposes but honors backslash escapes, so `\'` does not close it. - * - Locale quoting ($"...") behaves like double quotes but is opened by `$"` so - * the leading `$` is part of the token (same pattern as ANSI-C). - * - Heredoc (`<()` region, in order. */ + substitutions: string[] + /** Raw text of each quoted region, restored verbatim. */ + quotes: string[] + /** Raw text of each `${...}` region, restored verbatim. */ + params: string[] + /** For each parameter expansion: raw inner substitutions to extract. */ + paramPayloads: string[][] + /** Raw text (opener line through terminator) of each heredoc. */ + heredocs: string[] + /** For each heredoc with an unquoted delimiter: body substitutions. */ + heredocPayloads: string[][] + variables: string[] + redirections: string[] + /** Masked arithmetic expressions: `$((...))` and `$[...]`. */ + arithmetic: string[] + /** For each `$((...))`: substitutions the shell expands inside it. */ + arithmeticPayloads: string[][] } /** - * Result returned by the single shared state-machine walk. - * - * `spans` contains every well-formed top-level quoted region found. - * `unterminatedQuote` is non-null when the walk ended inside an open region. + * Sentinel separating placeholder names in masked host text. The scan rejects + * any input containing a raw NUL (`nul-byte`) up front, so a placeholder name + * can never collide with command text (unlike a printable `__NAME_0__` + * marker, which an input could contain literally). */ -interface ScanResult { - spans: QuoteSpan[] - unterminatedQuote: UnterminatedQuote | null -} +const NUL = "\x00" /** - * Parse a heredoc delimiter word starting at position `start` in `command`. - * The delimiter may be: - * - Unquoted: `EOF` -- bare identifier characters - * - Single-quoted: `'EOF'` -- literal body, strip outer quotes - * - Double-quoted: `"EOF"` -- expandable body, strip outer quotes - * - Backslash-escaped: `\EOF` -- literal body, strip leading backslash - * - * Returns the bare delimiter word (for terminator line matching) and the index - * of the first character after the delimiter token. + * Cap on payload re-scan depth. Each substitution payload is re-scanned + * through the full path, so an unbounded cap lets a few tens of KB of + * generated input burn minutes of CPU (quadratic suffix re-scans) and finally + * overflow the call stack; exceeding it fails closed instead. No hand-written + * command nests a substitution 64 deep. */ -function parseHeredocDelimiter(command: string, start: number): { delimiter: string; endIndex: number } { - let i = start - let delimiter = "" +const MAX_PAYLOAD_SCAN_DEPTH = 64 - if (command[i] === "'") { - i++ // skip opening ' - while (i < command.length && command[i] !== "'" && command[i] !== "\n") { - delimiter += command[i++] - } - if (command[i] === "'") i++ // consume closing ' - } else if (command[i] === '"') { - i++ // skip opening " - while (i < command.length && command[i] !== '"' && command[i] !== "\n") { - delimiter += command[i++] - } - if (command[i] === '"') i++ // consume closing " - } else if (command[i] === "\\") { - i++ // skip backslash - while (i < command.length && command[i] !== "\n" && command[i] !== " " && command[i] !== "\t") { - delimiter += command[i++] - } - } else { - while (i < command.length && command[i] !== "\n" && command[i] !== " " && command[i] !== "\t") { - delimiter += command[i++] - } - } +const IDENT_START = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ_" +const IDENT_CHARS = `${IDENT_START}0123456789` +/** `$` followed by one of these is a complete special variable. */ +const SPECIAL_VAR_CHARS = "?!#$@*-0123456789" + +function isIdentStart(char: string): boolean { + return IDENT_START.includes(char) +} - return { delimiter, endIndex: i } +function isIdentChar(char: string): boolean { + return IDENT_CHARS.includes(char) } /** - * Single shared state-machine walk used by both `findUnterminatedQuote` and - * `maskTopLevelQuotes`. Walks `command` left-to-right, identifies every - * top-level quoted region (outside any other quote and outside `#` comments), - * and returns the list of spans together with an unterminated-quote descriptor - * if the walk ends inside an open region. + * One quote-aware, left-to-right scan of `text` that simultaneously tracks + * quote state, delimiter depth, and heredoc state, emitting masked host text, + * extracted substitution payloads, and a structural error from the same pass. * - * Rules (match POSIX shell tokenization): - * - Outside any quote, `\X` escapes the next character so a quote after `\` - * is literal and does not open a region. - * - `#` that follows whitespace (or is at position 0) starts a comment to end - * of line; quotes inside a comment are ignored. - * - `<<<` is a herestring (single-line redirect), not a heredoc -- skip it. - * - `<<` opens a heredoc whose body runs through the terminator line. - * - `$'...'` is ANSI-C quoting (escape-aware single quote). - * - `$"..."` is locale quoting (escape-aware double quote with `$` prefix). - * - `'...'` is POSIX single quoting (fully opaque, no escapes). - * - `"..."` is double quoting (escape-aware). + * Invariants the rest of the file depends on: * - * Supported quote styles reported in spans/unterminatedQuote: - * - `ansi-c` $'...' - * - `locale` $"..." - * - `posix-single` '...' - * - `double` "..." - * - `heredoc` <(` are extracted: on close, the raw inner text + * becomes a payload segment and the host keeps only an opaque boundary + * placeholder, so inner commands match the allow/deny lists on their own. + * Payloads are re-scanned, so nesting yields independent segments. + * - `${…}` masks its text (restored verbatim into the host segment) but the + * scan descends into it: substitutions inside a parameter expansion are + * still extracted and balance-checked, including substitutions inside a + * heredoc body opened within the expansion, whose payloads route to the + * expansion's payload list. + * - `$((…))` masks the expression as one host word and never balances as an + * extraction delimiter, but substitutions inside a closed `$((…))` are + * extracted and balance-checked, because the shell expands them there. An + * unclosed `$((` or `$[` fails closed (`arithmetic`): bash refuses the + * input, and masking the remainder to end of input could hide an unclosed + * `$(` riding inside it. Bare `$[…]`'s interior stays mask-only: never + * inspected for substitutions. Bare + * `(`…`)` grouping (subshell groups, array assignments) likewise stays host + * text: unclosed it is never an error, so a `)` matching an open bare `(` is + * not a stray closer either. + * - Fail-closed rule: any of the four extraction delimiters opened that the + * scan cannot close is an error, as is an unclosed arithmetic opener + * (`$((`/`$[`), a stray `)` or `}` at depth 0, a raw NUL byte anywhere in + * the input, or payload re-scanning deeper than + * `MAX_PAYLOAD_SCAN_DEPTH`. A closer that does not match the open region's + * kind is literal content (shell-faithful); the still-open region then fails + * at end of input. + * - Heredocs: the opener line through the terminator line mask as one opaque + * host word, so the heredoc stays a single segment no matter how many lines + * it spans. Lines end only at LF (a CR counts solely as the CR of a CRLF + * pair): a CR-only opener or terminator line does not validate a heredoc, + * because bash would report such a construct unterminated. An + * unquoted-delimiter body is expansion-active: substitutions + * opened inside it are extracted and must balance before the terminator. A + * quoted or backslash-escaped delimiter suppresses expansion entirely in the + * body (the shell does not expand those), so its body stays opaque and is + * never balance-checked. Bodies are not quote-parsed and carry no comments: + * a `"` or `#` inside a body is literal text. The opener line's remainder + * (text after the delimiter word) is walked for substitutions even when the + * delimiter is quoted: quoting suppresses expansion in the body only, so + * `cat <<'EOF' $(cmd)` still runs `cmd`. */ -function scanTopLevelQuotes(command: string): ScanResult { - const spans: QuoteSpan[] = [] - let i = 0 +function scanShellSyntax(text: string): ScanOutcome { + const outcome: ScanOutcome = { + host: "", + error: null, + substitutions: [], + quotes: [], + params: [], + paramPayloads: [], + heredocs: [], + heredocPayloads: [], + variables: [], + redirections: [], + arithmetic: [], + arithmeticPayloads: [], + } + const stack: RegionFrame[] = [] + // Open parameter-expansion frames on `stack`: lets `innermostParam` return + // without a stack walk while no expansion is open. + let paramFramesOpen = 0 + // Bare `(` grouping (subshell groups, array assignments) stays host text: + // it is not one of the extraction/fail-closed delimiters, so an unclosed + // `(` is never an error. Depth is tracked only so a `)` that matches an + // open bare `(` is not reported as a stray closer. + let parenDepth = 0 - while (i < command.length) { - const char = command[i] + const mark = (name: string): string => `${NUL}${name}${NUL}` - // Outside any quoted region: handle backslash, comments, and quote openers. - if (char === "\\") { - // Backslash escapes the next character; a quote after it is literal. - i += 2 - continue + const errorAt = (kind: QuoteType, openIndex: number): UnterminatedQuote => ({ + quoteType: kind, + openIndex, + message: unterminatedQuoteMessage(kind, openIndex, text), + }) + + // Reject a raw NUL before any masking: NUL is this scan's placeholder + // sentinel, so an input containing one could degrade a masked placeholder + // into inert literal text and hide a substitution from the lists. Rejecting + // at the scan head also covers `findUnterminatedQuote`, which shares it. + const nulIndex = text.indexOf(NUL) + if (nulIndex !== -1) { + outcome.error = errorAt("nul-byte", nulIndex) + return outcome + } + + const endOfLine = (from: number): number => { + let j = from + while (j < text.length && text[j] !== "\n" && text[j] !== "\r") { + j++ } + return j + } - if (char === "#" && (i === 0 || /\s/.test(command[i - 1]))) { - // Comment: skip to end of line. Quotes inside are not shell quoting. - while (i < command.length && command[i] !== "\n" && command[i] !== "\r") { - i++ + /** Open the quoted region starting at `i`, or return null if none. */ + const openQuote = (i: number): RegionFrame | null => { + if (text[i] === "$" && text[i + 1] === "'") { + return { kind: "ansi-c", openIndex: i, closer: "'", contentStart: i + 2, escaped: true, payloads: null } + } + if (text[i] === "$" && text[i + 1] === '"') { + return { kind: "locale", openIndex: i, closer: '"', contentStart: i + 2, escaped: true, payloads: null } + } + if (text[i] === "'") { + return { + kind: "posix-single", + openIndex: i, + closer: "'", + contentStart: i + 1, + escaped: false, + payloads: null, } - continue } + if (text[i] === '"') { + return { kind: "double", openIndex: i, closer: '"', contentStart: i + 1, escaped: true, payloads: null } + } + return null + } - // Herestring (<<<): single-line stdin redirect -- no body or terminator. - if (char === "<" && command[i + 1] === "<" && command[i + 2] === "<") { - i += 3 - continue + /** + * The innermost parameter-expansion frame on `frames`, if any. `paramOpen` + * is the caller's count of open parameter-expansion frames: when it is 0 + * the scan returns without walking the stack, so deep non-expansion + * nesting does not pay a stack scan per frame close. + */ + const innermostParam = (frames: RegionFrame[], paramOpen: number): RegionFrame | undefined => { + if (paramOpen === 0) { + return undefined + } + for (let f = frames.length - 1; f >= 0; f--) { + if (frames[f].kind === "parameter-expansion") { + return frames[f] + } } + return undefined + } - // Heredoc opener: <<[-]? followed by an optional-quoted delimiter word. - if (char === "<" && command[i + 1] === "<") { - const start = i - i += 2 // skip << - const stripTabs = command[i] === "-" - if (stripTabs) i++ - // Skip horizontal whitespace between << and the delimiter word. - while (i < command.length && (command[i] === " " || command[i] === "\t")) { - i++ + /** + * Consume an entire heredoc at `i` (opener line, body, terminator). The + * whole construct masks to one host word. Returns the index just past the + * terminator text, or an error for an unterminated heredoc or an unbalanced + * delimiter inside an expansion-active body. + */ + const skipHeredoc = (i: number, frames: RegionFrame[]): { end: number } | { error: UnterminatedQuote } => { + const start = i + i += 2 // consume << + const stripTabs = text[i] === "-" + if (stripTabs) { + i++ + } + while (i < text.length && (text[i] === " " || text[i] === "\t")) { + i++ + } + const { delimiter, endIndex, quoted } = parseHeredocDelimiter(text, i) + const payloads: string[] = [] + // The opener line's remainder after the delimiter is expanded by the shell + // even when the delimiter is quoted (quoting suppresses expansion in the + // body only), so walk it unconditionally; an error there fails the whole + // heredoc closed like a body error does. + const remainderEnd = endOfLine(endIndex) + const remainderError = walkBodyPayloads(endIndex, remainderEnd, payloads, 0) + if (remainderError) { + return { error: remainderError } + } + // The opener line must end at LF so the body starts on the next line. + // A CR is valid only as the CR of a CRLF pair: a lone CR is not a bash + // line terminator, and accepting one as the opener's end would validate + // CR-separated input the shell refuses as an unterminated heredoc. + i = remainderEnd + if (text[i] === "\r") { + i++ + } + if (text[i] !== "\n") { + return { error: errorAt("heredoc", start) } + } + i++ + let bodyEnd = text.length + if (delimiter.length > 0) { + let found = false + const bodyStart = i + while (i < text.length) { + const lineStart = i + while (i < text.length && text[i] !== "\n" && text[i] !== "\r") { + i++ + } + const rawLine = text.slice(lineStart, i) + // Leading tabs are stripped only for <<- heredocs. + const line = stripTabs ? rawLine.replace(/^\t*/, "") : rawLine + // The terminator line must be bounded by LF or EOF (a CR counts + // only as the CR of a CRLF pair): a CR-only-bounded "EOF\r" is + // body text to bash, so treating it as the terminator would + // validate a heredoc the shell reports as unterminated. + const boundedByEol = i >= text.length || text[i] === "\n" || (text[i] === "\r" && text[i + 1] === "\n") + if (line === delimiter && boundedByEol) { + found = true + bodyEnd = lineStart + break + } + if (i < text.length) { + i++ // consume the newline of a body line + } + } + if (!found) { + // The body never ends: nothing after the opener can be trusted. + return { error: errorAt("heredoc", start) } + } + if (!quoted) { + const bodyError = walkBodyPayloads(bodyStart, bodyEnd, payloads, 0) + if (bodyError) { + return { error: bodyError } + } + } + if (frames.length > 0) { + // Inside an expansion no H placeholder is emitted, so without this + // routing the collected payloads would never reach a re-scan. Hand + // them to the innermost parameter-expansion ancestor exactly as a + // directly nested substitution close does. A heredoc opened inside + // $(…)/backtick/<( has no such ancestor: the enclosing substitution's + // payload is re-scanned whole and recovers them there. + innermostParam(frames, paramFramesOpen)?.payloads?.push(...payloads) + } + const id = outcome.heredocs.push(text.slice(start, i)) - 1 + outcome.heredocPayloads[id] = payloads + // Inside an expansion region the host is masked wholesale anyway; only + // top-level heredocs need a placeholder in the host text. + if (frames.length === 0) { + outcome.host += mark(`H${id}`) } - const { delimiter, endIndex } = parseHeredocDelimiter(command, i) - i = endIndex - // Advance past the remainder of the opener line. - while (i < command.length && command[i] !== "\n") i++ - if (i < command.length) i++ // consume newline - if (delimiter.length > 0) { - let found = false - while (i < command.length) { - const lineStart = i - while (i < command.length && command[i] !== "\n" && command[i] !== "\r") { - i++ + return { end: i } + } + // No delimiter word: nothing can terminate the body, so mask the opener + // through end of input as one opaque word (an empty delimiter would + // terminate on an empty line, which is not reliably detectable here). + outcome.heredocs.push(text.slice(start, text.length)) + outcome.heredocPayloads[outcome.heredocs.length - 1] = payloads + if (frames.length === 0) { + outcome.host += mark(`H${outcome.heredocs.length - 1}`) + } + return { end: text.length } + } + + /** + * Walk a heredoc body region `[start, end)` collecting command + * substitutions the shell would expand there. + * + * Heredoc bodies are not quote-parsed: a `"` in the body is literal text, + * so this walk tracks only the expansion delimiters and backslash escapes. + * A closer with no opener inside the body is literal data (the shell prints + * it); a delimiter still open when the terminator line is reached is a + * structural error — the shell would never finish expanding the body, so + * whatever follows it is unaccountable. + * + * `depth` bounds the recursion through nested `$((…))` interiors; beyond + * `MAX_PAYLOAD_SCAN_DEPTH` the region fails closed instead of recursing into + * generated input. + */ + const walkBodyPayloads = ( + start: number, + end: number, + collect: string[], + depth: number, + ): UnterminatedQuote | null => { + if (depth > MAX_PAYLOAD_SCAN_DEPTH) { + return errorAt("nesting-too-deep", start) + } + const frames: RegionFrame[] = [] + let localParamOpen = 0 + let i = start + while (i < end) { + const char = text[i] + const top = frames[frames.length - 1] + if (top) { + if (top.escaped && char === "\\") { + i += 2 + continue + } + if (char === top.closer) { + const closed = frames.pop() as RegionFrame + if (closed.kind === "parameter-expansion") { + localParamOpen-- + if (frames.length === 0) { + collect.push(...(closed.payloads ?? [])) + } else { + innermostParam(frames, localParamOpen)?.payloads?.push(...(closed.payloads ?? [])) + } + } else { + // Slice the inner text only where a consumer exists: a close + // nested with no parameter-expansion ancestor is recovered + // when the enclosing payload is re-scanned, so slicing every + // close eagerly would cost O(n²) on adversarial nesting. + const paramAncestor = innermostParam(frames, localParamOpen) + if (frames.length === 0 || paramAncestor) { + const inner = text.slice(closed.contentStart, i).trim() + if (paramAncestor) { + paramAncestor.payloads?.push(inner) + } else { + collect.push(inner) + } + } } - // Strip leading tabs only for <<- heredocs. - const rawLine = command.slice(lineStart, i) - const line = stripTabs ? rawLine.replace(/^\t*/, "") : rawLine - // Do NOT advance past the terminator's newline -- leave it as a - // separator for any command that follows the heredoc. - if (line === delimiter) { - found = true - break + i++ + continue + } + // Inside a backtick region only its closer matters: backticks do not + // nest. + if (top.kind === "backtick") { + i++ + continue + } + } + if (char === "\\") { + i += 2 + continue + } + if (char === "`") { + frames.push({ + kind: "backtick", + openIndex: i, + closer: "`", + contentStart: i + 1, + escaped: true, + payloads: null, + }) + i++ + continue + } + if (char === "$") { + if (text[i + 1] === "(") { + if (text[i + 2] === "(") { + const arith = skipArithmetic(i) + // An unclosed opener fails closed: the shell refuses the + // input, and masking the rest of the body would hide it. + if (!arith.closed) { + return errorAt("arithmetic", i) + } + // $((…)) is expansion-active: the shell performs the + // substitutions inside it, so they must balance and be + // collected. + const arithError = walkBodyPayloads(i + 3, arith.end - 2, collect, depth + 1) + if (arithError) { + return arithError + } + i = arith.end + continue } - if (i < command.length) i++ // consume newline of a body line + frames.push({ + kind: "command-substitution", + openIndex: i, + closer: ")", + contentStart: i + 2, + escaped: true, + payloads: null, + }) + i += 2 + continue + } + if (text[i + 1] === "{") { + frames.push({ + kind: "parameter-expansion", + openIndex: i, + closer: "}", + contentStart: i + 2, + escaped: true, + payloads: [], + }) + localParamOpen++ + i += 2 + continue } - if (!found) { - return { - spans, - unterminatedQuote: { - quoteType: "heredoc", - openIndex: start, - message: unterminatedQuoteMessage("heredoc", start, command), - }, + if (text[i + 1] === "[") { + const close = text.indexOf("]", i + 2) + if (close === -1) { + return errorAt("arithmetic", i) } + i = close + 1 + continue } + i++ + continue } - spans.push({ start, end: i, quoteType: "heredoc" }) - continue + if ((char === "<" || char === ">") && text[i + 1] === "(") { + frames.push({ + kind: "process-substitution", + openIndex: i, + closer: ")", + contentStart: i + 2, + escaped: true, + payloads: null, + }) + i += 2 + continue + } + i++ + } + if (frames.length > 0) { + // Report the outermost unclosed delimiter: the first construct the + // shell could never finish expanding. + const outermost = frames[0] + return errorAt(outermost.kind, outermost.openIndex) } + return null + } - // ANSI-C quoting: $'...', escape-aware. - if (char === "$" && command[i + 1] === "'") { - const start = i - i += 2 // skip $' - let closed = false - while (i < command.length) { - if (command[i] === "\\") { - i += 2 // skip escaped char - } else if (command[i] === "'") { - i++ // consume closing ' - closed = true - break - } else { - i++ - } + /** + * Consume a `$((…))` or `$[…]` arithmetic expression. `closed` reports + * whether the closing delimiter was found; callers fail closed when it is + * missing (bash refuses an unclosed opener), and only a closed `$((…))` + * interior is expansion-walked. + */ + const skipArithmetic = (i: number): { end: number; closed: boolean } => { + if (text[i + 1] === "[") { + const close = text.indexOf("]", i + 2) + return close === -1 ? { end: text.length, closed: false } : { end: close + 1, closed: true } + } + // $(( … )): balance plain parens; an unbalanced scan runs to end of input. + let depth = 2 // the two opening parens + let j = i + 3 + while (j < text.length && depth > 0) { + if (text[j] === "(") { + depth++ + } else if (text[j] === ")") { + depth-- } - if (!closed) { - return { - spans, - unterminatedQuote: { - quoteType: "ansi-c", - openIndex: start, - message: unterminatedQuoteMessage("ansi-c", start, command), - }, - } + j++ + } + return { end: j, closed: depth === 0 } + } + + /** End index just past a `$name` or `$` token at `i`, or null. */ + const scanVariable = (i: number): number | null => { + const next = text[i + 1] + if (next === undefined) { + return null + } + if (isIdentStart(next)) { + let j = i + 1 + while (j < text.length && isIdentChar(text[j])) { + j++ } - spans.push({ start, end: i, quoteType: "ansi-c" }) - continue + return j + } + if (SPECIAL_VAR_CHARS.includes(next)) { + return i + 2 } + return null + } - // Locale quoting: $"...", escape-aware like double quotes. - if (char === "$" && command[i + 1] === '"') { - const start = i - i += 2 // skip $" - let closed = false - while (i < command.length) { - if (command[i] === "\\") { - i += 2 // skip escaped char - } else if (command[i] === '"') { - i++ // consume closing " - closed = true - break + /** End index past a `2>&1`-style redirection at `i`, or null. */ + const scanRedirection = (i: number): number | null => { + let j = i + if (text[j] === ">" && text[j + 1] === "&") { + j += 2 + while (j < text.length && "0123456789".includes(text[j])) { + j++ + } + return j + } + while (j < text.length && "0123456789".includes(text[j])) { + j++ + } + if (j > i && text[j] === ">" && text[j + 1] === "&") { + j += 2 + while (j < text.length && "0123456789".includes(text[j])) { + j++ + } + return j + } + return null + } + + let i = 0 + while (i < text.length) { + const char = text[i] + const top = stack[stack.length - 1] + + if (top) { + if (top.escaped && char === "\\") { + i += 2 + continue + } + if (char === top.closer) { + const closeEnd = i + 1 + const closed = stack.pop() as RegionFrame + if (closed.kind === "parameter-expansion") { + paramFramesOpen-- + } + if (QUOTE_FRAME_KINDS.includes(closed.kind)) { + // Quote regions restore verbatim; at the top level they mask to a + // single opaque word so their contents can never tokenize as syntax. + if (stack.length === 0) { + const id = outcome.quotes.push(text.slice(closed.openIndex, closeEnd)) - 1 + outcome.host += mark(`Q${id}`) + } + i = closeEnd + continue + } + if (stack.length === 0) { + if (closed.kind === "parameter-expansion") { + // The expansion's text stays verbatim in the host segment; its + // inner substitutions are extracted independently. + const id = outcome.params.push(text.slice(closed.openIndex, closeEnd)) - 1 + outcome.paramPayloads[id] = closed.payloads ?? [] + outcome.host += mark(`P${id}`) + } else { + const id = outcome.substitutions.push(text.slice(closed.contentStart, i).trim()) - 1 + outcome.host += mark(`S${id}`) + } } else { - i++ + // Nested inside an expansion region: a substitution inside a + // parameter expansion belongs to that expansion's payload list; + // anything deeper is recovered when the enclosing payload is + // re-scanned, so its inner text is sliced only where a consumer + // exists (otherwise deep nesting would cost O(n²)). + const paramAncestor = innermostParam(stack, paramFramesOpen) + if (closed.kind === "parameter-expansion") { + paramAncestor?.payloads?.push(...(closed.payloads ?? [])) + } else if (paramAncestor) { + paramAncestor.payloads?.push(text.slice(closed.contentStart, i).trim()) + } } + i = closeEnd + continue } - if (!closed) { - return { - spans, - unterminatedQuote: { - quoteType: "locale", - openIndex: start, - message: unterminatedQuoteMessage("locale", start, command), - }, + // A quote frame is opaque except for its own closer and escapes; a + // backtick region is opaque except for its closer (backticks do not + // nest). Only expansion frames descend for nested constructs. + if (QUOTE_FRAME_KINDS.includes(top.kind) || top.kind === "backtick") { + i++ + continue + } + // Inside an expansion region: balance nested constructs, never emit host + // text (the whole region is replaced by one placeholder when it closes). + const nestedQuote = openQuote(i) + if (nestedQuote) { + stack.push(nestedQuote) + i = nestedQuote.contentStart + continue + } + if (char === "$") { + if (text[i + 1] === "(") { + if (text[i + 2] === "(") { + const arith = skipArithmetic(i) + // Fail closed here rather than letting the outer region + // error at end of input: that would name the wrong opener + // and silently drop the arithmetic's interior payloads. + if (!arith.closed) { + outcome.error = errorAt("arithmetic", i) + return outcome + } + const arithPayloads: string[] = [] + const arithError = walkBodyPayloads(i + 3, arith.end - 2, arithPayloads, 0) + if (arithError) { + outcome.error = arithError + return outcome + } + // The enclosing region is masked wholesale, so route the + // substitutions to the innermost expansion ancestor like a + // nested close does. + innermostParam(stack, paramFramesOpen)?.payloads?.push(...arithPayloads) + i = arith.end + continue + } + stack.push({ + kind: "command-substitution", + openIndex: i, + closer: ")", + contentStart: i + 2, + escaped: true, + payloads: null, + }) + i += 2 + continue } + if (text[i + 1] === "{") { + stack.push({ + kind: "parameter-expansion", + openIndex: i, + closer: "}", + contentStart: i + 2, + escaped: true, + payloads: [], + }) + paramFramesOpen++ + i += 2 + continue + } + i++ + continue } - spans.push({ start, end: i, quoteType: "locale" }) - continue - } - - // POSIX single quote: fully opaque, ends at the next literal '. - if (char === "'") { - const start = i - i++ // skip opening ' - while (i < command.length && command[i] !== "'") { + if (char === "`") { + stack.push({ + kind: "backtick", + openIndex: i, + closer: "`", + contentStart: i + 1, + escaped: true, + payloads: null, + }) i++ + continue + } + if ((char === "<" || char === ">") && text[i + 1] === "(") { + stack.push({ + kind: "process-substitution", + openIndex: i, + closer: ")", + contentStart: i + 2, + escaped: true, + payloads: null, + }) + i += 2 + continue } - if (i >= command.length) { - // No closing quote found. - return { - spans, - unterminatedQuote: { - quoteType: "posix-single", - openIndex: start, - message: unterminatedQuoteMessage("posix-single", start, command), - }, + if (char === "<" && text[i + 1] === "<" && text[i + 2] !== "<") { + const result = skipHeredoc(i, stack) + if ("error" in result) { + outcome.error = result.error + return outcome } + i = result.end + continue } - i++ // consume closing ' - spans.push({ start, end: i, quoteType: "posix-single" }) + i++ continue } - // Double quote: escape-aware, ends at the next unescaped ". - if (char === '"') { - const start = i - i++ // skip opening " - let closed = false - while (i < command.length) { - if (command[i] === "\\") { - i += 2 // skip escaped char - } else if (command[i] === '"') { - i++ // consume closing " - closed = true - break - } else { - i++ + // Nothing is open: full scanning with host emission. + if (char === "\\") { + // A backslash escapes the next character; a quote after it is literal. + outcome.host += text.slice(i, i + 2) + i += 2 + continue + } + if (char === "#" && (i === 0 || /\s/.test(text[i - 1]))) { + // Comment: pass the rest of the line through untouched. Quotes and + // delimiters inside a comment are not shell syntax; shell-quote drops + // the comment text when tokenizing. + const end = endOfLine(i) + outcome.host += text.slice(i, end) + i = end + continue + } + if (char === "<" && text[i + 1] === "<" && text[i + 2] === "<") { + // Herestring (<<<): single-line stdin redirect with no body or + // terminator; the word after it is an ordinary shell word. + outcome.host += "<<<" + i += 3 + continue + } + if (char === "<" && text[i + 1] === "<") { + const result = skipHeredoc(i, stack) + if ("error" in result) { + outcome.error = result.error + return outcome + } + i = result.end + continue + } + if ((char === "<" || char === ">") && text[i + 1] === "(") { + stack.push({ + kind: "process-substitution", + openIndex: i, + closer: ")", + contentStart: i + 2, + escaped: true, + payloads: null, + }) + i += 2 + continue + } + if (char === "(") { + // Bare grouping/array-assignment paren: host text, never extracted, + // never an error (unclosed or not). The depth counter exists only so + // a matching `)` is not misread as a stray closer. + parenDepth++ + outcome.host += char + i++ + continue + } + if (char === ")" && parenDepth > 0) { + parenDepth-- + outcome.host += char + i++ + continue + } + if (char === ")" || char === "}") { + // A closer at depth 0 with no opener: the shell would refuse this + // input, so fail closed instead of approving a partial decomposition. + outcome.error = errorAt("stray-closer", i) + return outcome + } + if (char === "`") { + stack.push({ + kind: "backtick", + openIndex: i, + closer: "`", + contentStart: i + 1, + escaped: true, + payloads: null, + }) + i++ + continue + } + if (char === "$") { + const dollarQuote = openQuote(i) + if (dollarQuote) { + stack.push(dollarQuote) + i = dollarQuote.contentStart + continue + } + if (text[i + 1] === "(") { + if (text[i + 2] === "(") { + const arith = skipArithmetic(i) + // An unclosed opener fails closed: masking the remainder + // to end of input would hide its text behind the host word. + if (!arith.closed) { + outcome.error = errorAt("arithmetic", i) + return outcome + } + // The shell performs command substitutions inside a + // $((…)) exactly like anywhere else, so they are extracted + // as independent segments and must balance. + const arithPayloads: string[] = [] + const arithError = walkBodyPayloads(i + 3, arith.end - 2, arithPayloads, 0) + if (arithError) { + outcome.error = arithError + return outcome + } + const id = outcome.arithmetic.push(text.slice(i, arith.end)) - 1 + outcome.arithmeticPayloads[id] = arithPayloads + outcome.host += mark(`A${id}`) + i = arith.end + continue } + stack.push({ + kind: "command-substitution", + openIndex: i, + closer: ")", + contentStart: i + 2, + escaped: true, + payloads: null, + }) + i += 2 + continue } - if (!closed) { - return { - spans, - unterminatedQuote: { - quoteType: "double", - openIndex: start, - message: unterminatedQuoteMessage("double", start, command), - }, + if (text[i + 1] === "{") { + stack.push({ + kind: "parameter-expansion", + openIndex: i, + closer: "}", + contentStart: i + 2, + escaped: true, + payloads: [], + }) + paramFramesOpen++ + i += 2 + continue + } + if (text[i + 1] === "[") { + // $[…] stays mask-only: its interior is never inspected for + // substitutions. An unclosed opener still fails closed — the + // shell refuses it, and masking would swallow the remainder. + const arith = skipArithmetic(i) + if (!arith.closed) { + outcome.error = errorAt("arithmetic", i) + return outcome } + const id = outcome.arithmetic.push(text.slice(i, arith.end)) - 1 + outcome.arithmeticPayloads[id] = [] + outcome.host += mark(`A${id}`) + i = arith.end + continue + } + const varEnd = scanVariable(i) + if (varEnd !== null) { + const id = outcome.variables.push(text.slice(i, varEnd)) - 1 + outcome.host += mark(`V${id}`) + i = varEnd + continue } - spans.push({ start, end: i, quoteType: "double" }) + outcome.host += "$" + i++ continue } - - // Ordinary character -- advance. + const quote = openQuote(i) + if (quote) { + stack.push(quote) + i = quote.contentStart + continue + } + if (char === ">" || "0123456789".includes(char)) { + const redirEnd = scanRedirection(i) + if (redirEnd !== null) { + const id = outcome.redirections.push(text.slice(i, redirEnd)) - 1 + outcome.host += mark(`R${id}`) + i = redirEnd + continue + } + } + outcome.host += char i++ } - return { spans, unterminatedQuote: null } + if (stack.length > 0) { + // Delimiters still open at end of input: report the outermost one — the + // first construct the shell could never finish reading. + const outermost = stack[0] + outcome.error = errorAt(outermost.kind, outermost.openIndex) + } + + return outcome } /** - * Walk `command` and replace every top-level quoted region with a placeholder - * token. Returns the masked string and the array of original quoted substrings - * so callers can restore them later. + * Parse a heredoc delimiter word starting at position `start` in `command`. + * The delimiter may be: + * - Unquoted: `EOF` -- expansion-active body + * - Single-quoted: `'EOF'` -- literal body, strip outer quotes + * - Double-quoted: `"EOF"` -- literal body, strip outer quotes + * - Backslash-escaped: `\EOF` -- literal body, strip leading backslash * - * Delegates all state-machine logic to `scanTopLevelQuotes`, ensuring - * consistent quoting rules with `findUnterminatedQuote`. An unterminated quote - * is treated as a span that runs to the end of the string so the masked output - * is always well-formed and the subsequent split never exposes a fragment of - * a malformed command. + * Returns the bare delimiter word (for terminator line matching), whether the + * delimiter was quoted or escaped (a quoted/escaped delimiter suppresses all + * expansion in the body), and the index of the first character after the + * delimiter token. */ -function maskTopLevelQuotes(command: string): { masked: string; quotes: string[] } { - const { spans, unterminatedQuote } = scanTopLevelQuotes(command) - - // If the command has an unterminated quote, treat the unclosed region as a - // span running to the end of the string. This is safe because parseCommand - // already guards against unterminated quotes and returns the raw command as - // a single token before calling maskTopLevelQuotes -- but the fallback - // ensures maskTopLevelQuotes is robust even when called directly. - const effectiveSpans: QuoteSpan[] = - unterminatedQuote !== null - ? [ - ...spans, - { start: unterminatedQuote.openIndex, end: command.length, quoteType: unterminatedQuote.quoteType }, - ] - : spans - - const quotes: string[] = [] - let result = "" - let pos = 0 - - for (const span of effectiveSpans) { - // Copy the unquoted text between the previous span end and this span start. - result += command.slice(pos, span.start) - // Replace the quoted span with a placeholder. - quotes.push(command.slice(span.start, span.end)) - result += `__TOPLEVEL_QUOTE_${quotes.length - 1}__` - pos = span.end +function parseHeredocDelimiter( + command: string, + start: number, +): { delimiter: string; endIndex: number; quoted: boolean } { + let i = start + let delimiter = "" + let quoted = false + + // A CR ends the delimiter word: on CRLF input the terminator line is the + // bare word, so absorbing the CR into the delimiter would make it never + // match and turn a well-formed heredoc into a false syntax error. + if (command[i] === "'") { + quoted = true + i++ // skip opening ' + while (i < command.length && command[i] !== "'" && command[i] !== "\n" && command[i] !== "\r") { + delimiter += command[i++] + } + if (command[i] === "'") i++ // consume closing ' + } else if (command[i] === '"') { + quoted = true + i++ // skip opening " + while (i < command.length && command[i] !== '"' && command[i] !== "\n" && command[i] !== "\r") { + delimiter += command[i++] + } + if (command[i] === '"') i++ // consume closing " + } else if (command[i] === "\\") { + quoted = true + i++ // skip backslash + while ( + i < command.length && + command[i] !== "\n" && + command[i] !== "\r" && + command[i] !== " " && + command[i] !== "\t" + ) { + delimiter += command[i++] + } + } else { + while ( + i < command.length && + command[i] !== "\n" && + command[i] !== "\r" && + command[i] !== " " && + command[i] !== "\t" + ) { + delimiter += command[i++] + } } - // Copy any remaining text after the last span (or the whole string if no spans). - result += command.slice(pos) + return { delimiter, endIndex: i, quoted } +} - return { masked: result, quotes } +/** Look up the raw text a placeholder name stands for, or undefined. */ +function lookupPlaceholder(name: string, outcome: ScanOutcome, includeSubstitutions: boolean): string | undefined { + const index = Number(name.slice(1)) + switch (name[0]) { + case "Q": + return outcome.quotes[index] + case "H": + return outcome.heredocs[index] + case "P": + return outcome.params[index] + case "V": + return outcome.variables[index] + case "R": + return outcome.redirections[index] + case "A": + return outcome.arithmetic[index] + case "S": + // Substitutions are only restored inline on the crude-split fallback + // path; the normal path turns them into independent segments. + return includeSubstitutions ? outcome.substitutions[index] : undefined + default: + return undefined + } +} + +/** + * Replace every sentinel-delimited placeholder in `text` with its raw text. + * Split on the sentinel rather than a regex: a placeholder name is everything + * between two sentinels by construction. A raw NUL in the input is rejected up + * front by the scan (see QuoteType "nul-byte"), so no input can carry a + * sentinel to collide with here. + */ +function expandPlaceholders(text: string, outcome: ScanOutcome, includeSubstitutions: boolean): string { + if (!text.includes(NUL)) { + return text + } + const parts = text.split(NUL) + let out = "" + for (let p = 0; p < parts.length; p++) { + if (p % 2 === 0) { + out += parts[p] + continue + } + const value = lookupPlaceholder(parts[p], outcome, includeSubstitutions) + if (value !== undefined) { + out += value + } + } + return out } /** * Split a command string into individual sub-commands by * chaining operators (&&, ||, ;, |, or &) and unquoted newlines. * - * Uses shell-quote to properly handle: - * - Quoted strings (preserves quotes, including multi-line quoted strings) - * - Subshell commands ($(cmd), `cmd`, <(cmd), >(cmd)) - * - PowerShell redirections (2>&1) - * - Chain operators (&&, ||, ;, |, &) - * - Newlines as command separators (only when outside quoted strings) - * - * Key invariant: newlines that appear inside a quoted string (single or double) - * are part of that string argument and must NOT be treated as command separators. - * Only unquoted newlines split commands. For example: - * - * sh -c 'python3 -c " - * import sys - * print(sys.version) - * "' - * - * ...is a single command, not multiple commands split at each newline. + * One quote-aware scan (`scanShellSyntax`) does all structure analysis in a + * single left-to-right pass; `shell-quote` then tokenizes the masked host text: + * - Quoted strings stay opaque, including multi-line quoted strings, which is + * why only unquoted newlines split commands (the key invariant: newlines + * inside a quoted string are argument text, never separators). + * - Every command substitution — `$(…)`, backticks, `<(…)`, `>(…)` — is + * extracted with balanced-paren awareness wherever it sits (nested, inside a + * `${…}` expansion, inside a process substitution, or inside a heredoc with + * an unquoted delimiter) and becomes its own segment, so each inner command + * matches the allow/deny lists on its own. Parameter-expansion and heredoc + * hosts keep their verbatim text as one segment. + * - PowerShell-style redirections (2>&1), variables, and arithmetic stay + * inside their host word. * * Returns a `ParseResult` containing the list of sub-commands and an optional - * `parseError` that is non-null when the input contains a shell syntax error - * (currently: an unterminated quote or heredoc). When `parseError` is set the - * `commands` array contains the raw input as a single opaque token; callers - * should surface the error rather than proceeding with auto-approval. + * `parseError` that is non-null when the input is unaccountable (an + * unterminated quote or heredoc, an expansion delimiter or arithmetic + * expression opened but never closed, a stray closer, a raw NUL byte, or + * substitution nesting beyond the re-scan depth cap). When `parseError` is + * set the `commands` array contains + * the raw input as a single opaque token: a command whose structure the parser + * cannot fully account for must cost a prompt, never a free pass built on a + * partial decomposition. */ export function parseCommand(command: string): ParseResult { + // A NUL byte is not whitespace, so a NUL-bearing command still reaches the + // scan, where the nul-byte guard rejects it. if (!command?.trim()) { return { commands: [], parseError: null } } - // Run the shared state-machine scan once. It gives us both the list of - // top-level quoted spans (used by maskTopLevelQuotes) and the parse-error - // descriptor (used here to detect malformed input) in a single pass. - const { unterminatedQuote } = scanTopLevelQuotes(command) - - // Reject syntactically malformed input -- an unterminated quote is a shell - // syntax error. Return the raw input as a single opaque token so callers - // can surface the error to the agent rather than splitting unsafe fragments - // that might be auto-approved in isolation. - if (unterminatedQuote !== null) { - return { commands: [command], parseError: unterminatedQuote } + // Run the shared state-machine scan once. It yields the masked host text, + // the extracted substitution payloads, and the structural-error descriptor + // in a single pass. + const outcome = scanShellSyntax(command) + + // Reject syntactically malformed input. Return the raw input as a single + // opaque token so callers surface the error to the agent rather than + // splitting unsafe fragments that might be auto-approved in isolation. + if (outcome.error !== null) { + return { commands: [command], parseError: outcome.error } } - // Pre-escape any literal __ sequences present in the raw command so they - // cannot collide with the internal placeholder tokens (e.g. __QUOTE_0__) - // used during masking and splitting. \x00 (the null byte, U+0000) is the - // sentinel: it encodes end-of-string at the C/OS level, so the OS terminates - // any command string at the first \x00 -- meaning a real shell command can - // never contain one. It therefore cannot appear in any command text the - // parser receives and will never match a placeholder regex. The post-unescape - // step at the return converts \x00 back to __ in every output command. - const escapedCommand = command.replace(/__/g, "\x00") - - // Mask quoted strings before splitting on newlines so that newlines embedded - // inside a quoted argument are not mistaken for command separators. The - // masker delegates to scanTopLevelQuotes internally, ensuring identical - // quoting rules with the check above. - const { masked, quotes: topLevelQuotes } = maskTopLevelQuotes(escapedCommand) - - // Split on unquoted newlines (all line-ending formats). - const lines = masked.split(/\r\n|\r|\n/) const allCommands: string[] = [] - for (const line of lines) { + for (const line of outcome.host.split(/\r\n|\r|\n/)) { if (!line.trim()) { continue } - - // Restore top-level quote placeholders before per-line parsing so that - // parseCommandLine sees the original quoted content and can apply its own - // masking for operator splitting. - const restoredLine = line.replace(/__TOPLEVEL_QUOTE_(\d+)__/g, (_, i) => topLevelQuotes[parseInt(i)]) - - // If the restored line contains embedded newlines it means a top-level - // quote (e.g. a heredoc) spanned multiple lines. The entire restored - // string is a single atomic command -- passing it through parseCommandLine - // would let shell-quote split on the embedded newlines and << operators. - if (restoredLine.includes("\n")) { - allCommands.push(restoredLine) - continue + allCommands.push(...parseLine(line, outcome, 0)) + if (outcome.error !== null) { + return { commands: [command], parseError: outcome.error } } - - const lineCommands = parseCommandLine(restoredLine) - allCommands.push(...lineCommands) } - // Unescape \x00 back to __ in every output command. This reverses the - // pre-escape applied above to prevent literal __ tokens in the input from - // colliding with internal placeholder names. split/join is used instead of - // a regex literal to avoid triggering the no-control-regex lint rule. - return { commands: allCommands.map((cmd) => cmd.split("\x00").join("__")), parseError: null } + return { commands: allCommands, parseError: null } } /** - * Parse a single line of commands. + * Tokenize one masked host line into segments. + * + * A chain operator flushes the current segment. A substitution placeholder also + * flushes and takes the place of its payload's own segments — the payload is + * re-scanned through the same path, so nested substitutions become independent + * segments. Placeholders that do not force a split (quotes, parameter + * expansions, heredocs, variables, redirections, arithmetic) join the current + * word and are restored verbatim; a parameter expansion, heredoc, or closed + * `$((…))` additionally queues its extracted inner substitutions, which flush + * right after the host segment that still contains their verbatim text. + * + * Structural errors found while recursing into payloads are recorded on + * `outcome` (fail-closed); the caller checks it after every line. `depth` + * counts payload re-entries and is capped by `scanPayloadSegments`. */ -function parseCommandLine(command: string): string[] { - if (!command?.trim()) return [] - - // Storage for replaced content - const redirections: string[] = [] - const subshells: string[] = [] - const quotes: string[] = [] - const singleQuotes: string[] = [] - const arithmeticExpressions: string[] = [] - const variables: string[] = [] - const parameterExpansions: string[] = [] - - // First handle PowerShell redirections by temporarily replacing them - let processedCommand = command.replace(/\d*>&\d*/g, (match) => { - redirections.push(match) - return `__REDIR_${redirections.length - 1}__` - }) - - // Handle arithmetic expressions: $((...)) pattern - // Match the entire arithmetic expression including nested parentheses - processedCommand = processedCommand.replace(/\$\(\([^)]*(?:\)[^)]*)*\)\)/g, (match) => { - arithmeticExpressions.push(match) - return `__ARITH_${arithmeticExpressions.length - 1}__` - }) - - // Handle $[...] arithmetic expressions (alternative syntax) - processedCommand = processedCommand.replace(/\$\[[^\]]*\]/g, (match) => { - arithmeticExpressions.push(match) - return `__ARITH_${arithmeticExpressions.length - 1}__` - }) - - // Handle parameter expansions: ${...} patterns (including array indexing) - // This covers ${var}, ${var:-default}, ${var:+alt}, ${#var}, ${var%pattern}, etc. - processedCommand = processedCommand.replace(/\$\{[^}]+\}/g, (match) => { - parameterExpansions.push(match) - return `__PARAM_${parameterExpansions.length - 1}__` - }) - - // Handle process substitutions: <(...) and >(...) - processedCommand = processedCommand.replace(/[<>]\(([^)]+)\)/g, (_, inner) => { - subshells.push(inner.trim()) - return `__SUBSH_${subshells.length - 1}__` - }) - - // Handle locale quoting: $"...". This must run before variable masking for - // the same reason as ANSI-C: without it the generic double-quote masker would - // strip the outer quotes leaving a bare $ that the variable regex absorbs, - // corrupting the placeholder. The whole token (including $") is stored in the - // double-quote bucket so it is restored verbatim. - processedCommand = processedCommand.replace(/\$"(?:[^"\\]|\\.)*"/g, (match) => { - quotes.push(match) - return `__QUOTE_${quotes.length - 1}__` - }) - - // Handle ANSI-C quoting: $'...'. This must run before variable masking so the - // leading $ is captured as part of the quoted unit rather than being treated - // as a variable expansion (which would corrupt a following placeholder). - // ANSI-C strings interpret backslash escapes, so the pattern is escape-aware. - // The whole token (including the $ and quotes) is preserved in the single- - // quote bucket so it is restored verbatim. - processedCommand = processedCommand.replace(/\$'(?:[^'\\]|\\.)*'/g, (match) => { - singleQuotes.push(match) - return `__SQUOTE_${singleQuotes.length - 1}__` - }) - - // Handle simple variable references: $varname pattern - // This prevents shell-quote from splitting $count into separate tokens - processedCommand = processedCommand.replace(/\$[a-zA-Z_][a-zA-Z0-9_]*/g, (match) => { - variables.push(match) - return `__VAR_${variables.length - 1}__` - }) - - // Handle special bash variables: $?, $!, $#, $$, $@, $*, $-, $0-$9 - processedCommand = processedCommand.replace(/\$[?!#$@*\-0-9]/g, (match) => { - variables.push(match) - return `__VAR_${variables.length - 1}__` - }) - - // Then handle subshell commands $() and back-ticks - processedCommand = processedCommand - .replace(/\$\((.*?)\)/g, (_, inner) => { - subshells.push(inner.trim()) - return `__SUBSH_${subshells.length - 1}__` - }) - .replace(/`(.*?)`/g, (_, inner) => { - subshells.push(inner.trim()) - return `__SUBSH_${subshells.length - 1}__` - }) - - // Mask quoted strings (single and double) so their contents -- including - // operators like &&, |, ; and any embedded newlines -- are not treated as - // command separators. A single left-to-right scan with an alternation is used - // so that whichever quote opens first wins, preventing a quote of one style - // inside a string of the other style from starting a spurious match. - // - // Single quotes are matched literally (POSIX single quotes are fully opaque, - // no escaping inside them). Double quotes use an escape-aware pattern so that - // an escaped quote (\") does not prematurely terminate the match. Negated - // character classes match newlines, so multi-line quoted strings are captured - // as a single token. - processedCommand = processedCommand.replace(/'[^']*'|"(?:[^"\\]|\\.)*"/g, (match) => { - if (match.startsWith("'")) { - singleQuotes.push(match) - return `__SQUOTE_${singleQuotes.length - 1}__` - } - quotes.push(match) - return `__QUOTE_${quotes.length - 1}__` - }) - +function parseLine(line: string, outcome: ScanOutcome, depth: number): string[] { let tokens: ShellToken[] try { - tokens = parse(processedCommand) as ShellToken[] - } catch (error: any) { - // If shell-quote fails to parse, fall back to simple splitting - console.warn("shell-quote parse error:", error.message, "for command:", processedCommand) + tokens = parse(line) as ShellToken[] + } catch (error) { + // If shell-quote fails to parse, fall back to simple splitting. This path + // does not recurse into payloads; every placeholder restores to its raw + // (for substitutions: inner) text so callers never see internal markers. + const message = error instanceof Error ? error.message : String(error) + console.warn("shell-quote parse error:", message, "for command:", line) - // Simple fallback: split by common operators - const fallbackCommands = processedCommand + return line .split(/(?:&&|\|\||;|\||&)/) - .map((cmd) => cmd.trim()) + .map((cmd) => expandPlaceholders(cmd.trim(), outcome, true)) .filter((cmd) => cmd.length > 0) - - // Restore all placeholders for each command - return fallbackCommands.map((cmd) => - restorePlaceholders( - cmd, - quotes, - singleQuotes, - redirections, - arithmeticExpressions, - parameterExpansions, - variables, - subshells, - ), - ) } const commands: string[] = [] - let currentCommand: string[] = [] + let current: string[] = [] + let pendingPayloads: string[] = [] + + const flush = (): void => { + if (current.length > 0) { + commands.push(expandPlaceholders(current.join(" "), outcome, false)) + current = [] + } + for (const raw of pendingPayloads) { + commands.push(...scanPayloadSegments(raw, outcome, depth + 1)) + } + pendingPayloads = [] + } for (const token of tokens) { if (typeof token === "object" && "op" in token) { - // Chain operator - split command if (["&&", "||", ";", "|", "&"].includes(token.op)) { - if (currentCommand.length > 0) { - commands.push(currentCommand.join(" ")) - currentCommand = [] - } + flush() } else { - // Other operators (>) are part of the command - currentCommand.push(token.op) - } - } else if (typeof token === "string") { - // Check if it's a subshell placeholder - const subshellMatch = token.match(/__SUBSH_(\d+)__/) - if (subshellMatch) { - if (currentCommand.length > 0) { - commands.push(currentCommand.join(" ")) - currentCommand = [] + // Other operators (>, <) are part of the command word. + current.push(token.op) + } + continue + } + if (typeof token !== "string") { + continue + } + if (!token.includes(NUL)) { + current.push(token) + continue + } + // A word carrying placeholders: reconstruct non-substitution placeholders + // for verbatim restoration, and treat substitution placeholders as segment + // boundaries. + const parts = token.split(NUL) + let buf = "" + for (let p = 0; p < parts.length; p++) { + const part = parts[p] + if (p % 2 === 0) { + buf += part + continue + } + const index = Number(part.slice(1)) + if (part[0] === "S") { + if (buf) { + current.push(buf) + buf = "" } - commands.push(subshells[parseInt(subshellMatch[1])]) - } else { - currentCommand.push(token) + flush() + commands.push(...scanPayloadSegments(outcome.substitutions[index] ?? "", outcome, depth + 1)) + continue } + if (part[0] === "P") { + pendingPayloads.push(...(outcome.paramPayloads[index] ?? [])) + } + if (part[0] === "H") { + pendingPayloads.push(...(outcome.heredocPayloads[index] ?? [])) + } + if (part[0] === "A") { + pendingPayloads.push(...(outcome.arithmeticPayloads[index] ?? [])) + } + buf += `${NUL}${part}${NUL}` + } + if (buf) { + current.push(buf) } } + flush() - // Add any remaining command - if (currentCommand.length > 0) { - commands.push(currentCommand.join(" ")) - } - - // Restore all placeholders - return commands.map((cmd) => - restorePlaceholders( - cmd, - quotes, - singleQuotes, - redirections, - arithmeticExpressions, - parameterExpansions, - variables, - subshells, - ), - ) + return commands } /** - * Helper function to restore placeholders in a command string. + * Re-scan an extracted substitution payload through the same scan + tokenize + * path so nested substitutions become independent segments. Records structural + * errors on `outcome` (the outer command then fails closed). Each re-entry + * increments `depth`; past `MAX_PAYLOAD_SCAN_DEPTH` the command fails closed as + * `nesting-too-deep` instead of re-scanning generated nesting until the call + * stack overflows. */ -function restorePlaceholders( - command: string, - quotes: string[], - singleQuotes: string[], - redirections: string[], - arithmeticExpressions: string[], - parameterExpansions: string[], - variables: string[], - subshells: string[], -): string { - let result = command - // Restore double-quoted strings - result = result.replace(/__QUOTE_(\d+)__/g, (_, i) => quotes[parseInt(i)]) - // Restore single-quoted strings - result = result.replace(/__SQUOTE_(\d+)__/g, (_, i) => singleQuotes[parseInt(i)]) - // Restore redirections - result = result.replace(/__REDIR_(\d+)__/g, (_, i) => redirections[parseInt(i)]) - // Restore arithmetic expressions - result = result.replace(/__ARITH_(\d+)__/g, (_, i) => arithmeticExpressions[parseInt(i)]) - // Restore parameter expansions - result = result.replace(/__PARAM_(\d+)__/g, (_, i) => parameterExpansions[parseInt(i)]) - // Restore variable references - result = result.replace(/__VAR_(\d+)__/g, (_, i) => variables[parseInt(i)]) - result = result.replace(/__SUBSH_(\d+)__/g, (_, i) => subshells[parseInt(i)]) - return result +function scanPayloadSegments(raw: string, outcome: ScanOutcome, depth: number): string[] { + if (!raw.trim()) { + return [] + } + if (depth > MAX_PAYLOAD_SCAN_DEPTH) { + outcome.error = { + quoteType: "nesting-too-deep", + openIndex: 0, + message: unterminatedQuoteMessage("nesting-too-deep", 0, raw), + } + return [] + } + const nested = scanShellSyntax(raw) + if (nested.error !== null) { + outcome.error = nested.error + return [] + } + const parts: string[] = [] + for (const line of nested.host.split(/\r\n|\r|\n/)) { + if (!line.trim()) { + continue + } + parts.push(...parseLine(line, nested, depth)) + if (nested.error !== null) { + outcome.error = nested.error + return [] + } + } + return parts } diff --git a/webview-ui/src/utils/__tests__/command-parser.spec.ts b/webview-ui/src/utils/__tests__/command-parser.spec.ts index 0316e2b328..a3a2244026 100644 --- a/webview-ui/src/utils/__tests__/command-parser.spec.ts +++ b/webview-ui/src/utils/__tests__/command-parser.spec.ts @@ -175,4 +175,26 @@ describe("extractPatternsFromCommand", () => { expect(patterns).not.toContain("hello") }) }) + + it("should extract patterns from a command substitution segment", () => { + const patterns = extractPatternsFromCommand("echo $(whoami)") + expect(patterns).toContain("echo") + expect(patterns).toContain("whoami") + }) + + it("should extract only the leading command from an unclosed command substitution", () => { + // Fail-closed: when the parser cannot close a $( … ) it opened, the whole + // input stays one opaque token, so nothing interior to the malformed + // expansion can surface as an auto-approvable pattern. + const patterns = extractPatternsFromCommand("echo $(date\nfoo") + expect(patterns).toEqual(["echo"]) + expect(patterns).not.toContain("date") + expect(patterns).not.toContain("foo") + }) + + it("should extract patterns from a process substitution segment", () => { + const patterns = extractPatternsFromCommand("diff <(sort a)") + expect(patterns).toContain("diff") + expect(patterns).toContain("sort") + }) })