diff --git a/src/infra/exec-approvals-analysis.ts b/src/infra/exec-approvals-analysis.ts index 4a6ee599b8..e40525a439 100644 --- a/src/infra/exec-approvals-analysis.ts +++ b/src/infra/exec-approvals-analysis.ts @@ -2,6 +2,7 @@ import fs from "node:fs"; import os from "node:os"; import path from "node:path"; import type { ExecAllowlistEntry } from "./exec-approvals.js"; +import { splitShellArgs } from "../utils/shell-argv.js"; export const DEFAULT_SAFE_BINS = ["jq", "grep", "cut", "sort", "uniq", "head", "tail", "tr", "wc"]; @@ -582,75 +583,6 @@ export function isWindowsPlatform(platform?: string | null): boolean { return normalized.startsWith("win"); } -function tokenizeShellSegment(segment: string): string[] | null { - const tokens: string[] = []; - let buf = ""; - let inSingle = false; - let inDouble = false; - let escaped = false; - - const pushToken = () => { - if (buf.length > 0) { - tokens.push(buf); - buf = ""; - } - }; - - for (let i = 0; i < segment.length; i += 1) { - const ch = segment[i]; - if (escaped) { - buf += ch; - escaped = false; - continue; - } - if (!inSingle && !inDouble && ch === "\\") { - escaped = true; - continue; - } - if (inSingle) { - if (ch === "'") { - inSingle = false; - } else { - buf += ch; - } - continue; - } - if (inDouble) { - const next = segment[i + 1]; - if (ch === "\\" && isDoubleQuoteEscape(next)) { - buf += next; - i += 1; - continue; - } - if (ch === '"') { - inDouble = false; - } else { - buf += ch; - } - continue; - } - if (ch === "'") { - inSingle = true; - continue; - } - if (ch === '"') { - inDouble = true; - continue; - } - if (/\s/.test(ch)) { - pushToken(); - continue; - } - buf += ch; - } - - if (escaped || inSingle || inDouble) { - return null; - } - pushToken(); - return tokens; -} - function parseSegmentsFromParts( parts: string[], cwd?: string, @@ -658,7 +590,7 @@ function parseSegmentsFromParts( ): ExecCommandSegment[] | null { const segments: ExecCommandSegment[] = []; for (const raw of parts) { - const argv = tokenizeShellSegment(raw); + const argv = splitShellArgs(raw); if (!argv || argv.length === 0) { return null; } diff --git a/src/utils/shell-argv.test.ts b/src/utils/shell-argv.test.ts new file mode 100644 index 0000000000..156cf1bfdf --- /dev/null +++ b/src/utils/shell-argv.test.ts @@ -0,0 +1,19 @@ +import { describe, expect, it } from "vitest"; +import { splitShellArgs } from "./shell-argv.js"; + +describe("splitShellArgs", () => { + it("splits whitespace and respects quotes", () => { + expect(splitShellArgs(`qmd --foo "bar baz"`)).toEqual(["qmd", "--foo", "bar baz"]); + expect(splitShellArgs(`qmd --foo 'bar baz'`)).toEqual(["qmd", "--foo", "bar baz"]); + }); + + it("supports backslash escapes inside double quotes", () => { + expect(splitShellArgs(String.raw`echo "a\"b"`)).toEqual(["echo", `a"b`]); + expect(splitShellArgs(String.raw`echo "\$HOME"`)).toEqual(["echo", "$HOME"]); + }); + + it("returns null for unterminated quotes", () => { + expect(splitShellArgs(`echo "oops`)).toBeNull(); + expect(splitShellArgs(`echo 'oops`)).toBeNull(); + }); +}); diff --git a/src/utils/shell-argv.ts b/src/utils/shell-argv.ts index e52ca16bb7..d62b9b08e8 100644 --- a/src/utils/shell-argv.ts +++ b/src/utils/shell-argv.ts @@ -1,3 +1,9 @@ +const DOUBLE_QUOTE_ESCAPES = new Set(["\\", '"', "$", "`", "\n", "\r"]); + +function isDoubleQuoteEscape(next: string | undefined): next is string { + return Boolean(next && DOUBLE_QUOTE_ESCAPES.has(next)); +} + export function splitShellArgs(raw: string): string[] | null { const tokens: string[] = []; let buf = ""; @@ -32,6 +38,12 @@ export function splitShellArgs(raw: string): string[] | null { continue; } if (inDouble) { + const next = raw[i + 1]; + if (ch === "\\" && isDoubleQuoteEscape(next)) { + buf += next; + i += 1; + continue; + } if (ch === '"') { inDouble = false; } else {