diff --git a/src/permission/classify-security.test.ts b/src/permission/classify-security.test.ts index 8dcc75e2f..6aff98bce 100644 --- a/src/permission/classify-security.test.ts +++ b/src/permission/classify-security.test.ts @@ -67,6 +67,45 @@ describe("isAutoAllowedShellCall — sensitive-path arguments", () => { }); }); +describe("clustered shell command options", () => { + test("classifies clustered command payloads like canonical command payloads", () => { + for (const options of ["-c", "-lc", "-xec", "-cc", "-cache"]) { + const call = shellCall(`bash ${options} "echo x > .env"`); + expect(autoShellRuleForCall(call)?.name).toBe("file-mutation"); + expect(autoShellRuleForCall(call)?.effect).toBe("deny"); + } + }); + + test("classifies interpreter-specific and conservative alphabetic clusters", () => { + for (const [shell, options] of [ + ["zsh", "-yc"], + ["dash", "-Vc"], + ["ksh", "-Gc"], + ["bash", "-zc"], + ["bash", "-lc"], + ["sh", "-ec"], + ]) { + expect( + autoShellRuleForCall(shellCall(`${shell} ${options} "echo x > .env"`)), + ).toMatchObject({ name: "file-mutation", effect: "deny" }); + } + }); + + test("classifies complete adjacent-fragment payloads", () => { + for (const command of [ + `bash -c "echo x "'> .env'`, + `bash -lc 'echo x '" > .env"`, + `bash -xec "echo x"' > .env'`, + `bash -cc echo" x > .env"`, + ]) { + expect(autoShellRuleForCall(shellCall(command))).toMatchObject({ + name: "file-mutation", + effect: "deny", + }); + } + }); +}); + describe("isAutoAllowedShellCall — environment dump", () => { test("does not auto-allow printenv (full env dump)", () => { expect(isAutoAllowedShellCall(shellCall("printenv"))).toBe(false); @@ -716,6 +755,19 @@ describe("env-assignment shell commands force ask in auto mode", () => { expect( autoShellRuleForCall(shellCall("env -i FOO=bar npm start"))?.name, ).toBe("env-assignment"); + expect(autoShellRuleForCall(shellCall("env -i FOO=bar ls"))?.name).toBe( + "env-assignment", + ); + }); + + test("env -u HOME with a following assignment still asks", () => { + expect( + autoShellRuleForCall(shellCall("env -u HOME FOO=bar ls"))?.name, + ).toBe("env-assignment"); + expect( + autoShellRuleForCall(shellCall("env -u HOME LD_PRELOAD=./evil.so ls")) + ?.name, + ).toBe("env-assignment"); }); test("stacked short flags (env -iS) with an embedded assignment ask", () => { @@ -780,6 +832,16 @@ describe("content inside an env -S payload never receives a weaker tier than it ); expect(rule?.name).toBe("dependency-install"); }); + + test("trailing env terminal flags remain arguments to split payloads", () => { + expect( + autoShellRuleForCall(shellCall(`env -S "rm -rf /" --version`))?.name, + ).toBe("recursive-rm"); + expect( + autoShellRuleForCall(shellCall(`env -S "npm install left-pad" --help`)) + ?.name, + ).toBe("dependency-install"); + }); }); describe("upload-shaped network shell commands force ask in auto mode", () => { diff --git a/src/shell/run-shell-authz.test.ts b/src/shell/run-shell-authz.test.ts index 554cf826d..70d4ebb49 100644 --- a/src/shell/run-shell-authz.test.ts +++ b/src/shell/run-shell-authz.test.ts @@ -101,6 +101,121 @@ describe("recursive rm detection", () => { }); }); +describe("clustered shell command options", () => { + const payload = `cat $'.envrc'`; + + for (const [shell, options] of [ + ["zsh", "-yc"], + ["dash", "-Vc"], + ["ksh", "-Gc"], + ] as const) { + test.skipIf(Bun.which(shell) === null)( + `${shell} ${options} executes the following argument as a payload`, + () => { + const result = Bun.spawnSync([shell, options, "printf clustered-ok"]); + expect(result.exitCode).toBe(0); + expect(result.stdout.toString()).toBe("clustered-ok"); + }, + ); + } + + test("reconstructs double-quoted payloads with canonical and clustered options", () => { + for (const options of ["-c", "-lc", "-xec", "-cc", "-cache"]) { + const command = `bash ${options} "cat \\$'.envrc'"`; + expect(expandShellSubjects(command)).toEqual({ + subjects: [command, payload], + opaque: false, + }); + } + }); + + test("supports repeated command options for sh-compatible interpreters", () => { + for (const shell of ["bash", "sh", "zsh", "dash", "ksh"]) { + const command = `${shell} -cc "find /"`; + expect(expandShellSubjects(command).subjects).toContain("find /"); + expect(runShellAuthzBlockReason(command)).toMatch( + /Open-ended shell search blocked/, + ); + } + }); + + test("inspects interpreter-specific and conservative alphabetic clusters", () => { + for (const [shell, options] of [ + ["zsh", "-yc"], + ["dash", "-Vc"], + ["ksh", "-Gc"], + ["bash", "-zc"], + ["bash", "-lc"], + ["sh", "-ec"], + ]) { + const openEnded = `${shell} ${options} "find /"`; + expect(expandShellSubjects(openEnded).subjects).toContain("find /"); + expect(runShellAuthzBlockReason(openEnded)).toMatch( + /Open-ended shell search blocked/, + ); + expect( + runShellAuthzBlockReason(`${shell} ${options} "rm -rf /"`), + ).toMatch(/Destructive command blocked/); + } + }); + + test("reconstructs the complete shell word from adjacent fragments", () => { + const cases = [ + { command: `bash -c "rm "'-rf /'`, payload: "rm -rf /" }, + { command: `bash -lc 'rm '"-rf /"`, payload: "rm -rf /" }, + { command: `bash -xec "fi"'nd /'`, payload: "find /" }, + { command: `bash -cc fi"nd /"`, payload: "find /" }, + { + command: `env -u -c bash -c "fi"'nd /'`, + payload: "find /", + }, + ]; + + for (const { command, payload: expectedPayload } of cases) { + expect(expandShellSubjects(command)).toEqual({ + subjects: [command, expectedPayload], + opaque: false, + }); + } + }); + + test("does not treat true lookalikes as command options", () => { + for (const command of [ + `bash script-c "find /"`, + `bash --rcfile "find /"`, + `bash -y+c "find /"`, + `bash -c1 "find /"`, + `bash script.sh -c "find /"`, + ]) { + expect(expandShellSubjects(command)).toEqual({ + subjects: [command], + opaque: false, + }); + } + }); + + test("hard-denies complete adjacent-fragment payloads", () => { + for (const command of [ + `bash -c "rm "'-rf /'`, + `bash -lc 'rm '"-rf /"`, + `bash -xec "fi"'nd /'`, + `bash -cc fi"nd /"`, + ]) { + expect(runShellAuthzBlockReason(command)).toMatch( + /Destructive command blocked|Open-ended shell search blocked/, + ); + } + }); + + test("hard-denies clustered command payloads like canonical command payloads", () => { + for (const options of ["-c", "-lc", "-xec", "-cc", "-cache"]) { + expect(runShellAuthzBlockReason(`bash ${options} "find /"`)).toMatch( + /Open-ended shell search blocked/, + ); + } + }); +}); + describe("stdin-blocking with quote-aware tokenizeSegment", () => { test("unquoted readers with no file operand are blocked", () => { expect(runShellAuthzBlockReason("cat")).toMatch(/standard input/); @@ -388,6 +503,35 @@ describe("authz hard-deny peels glued and trailing env -S forms", () => { ); }); + test("split payloads own trailing terminal-looking arguments", () => { + const cases = [ + { + command: `env -S "find ." --help`, + subject: "find . --help", + reason: openEnded, + }, + { + command: `env -S "rm -rf /" --version`, + subject: "rm -rf / --version", + reason: destructive, + }, + { + command: `env -S "npm install left-pad" --help`, + subject: "npm install left-pad --help", + }, + ]; + + for (const { command, subject, reason } of cases) { + expect(expandShellSubjects(command)).toEqual({ + subjects: [command, subject], + opaque: false, + }); + if (reason !== undefined) { + expect(runShellAuthzBlockReason(command)).toMatch(reason); + } + } + }); + test("G7: soft-allow non-catastrophic rm inside -S is not hard-denied", () => { expect( runShellAuthzBlockReason(`env -S "rm -rf node_modules"`), @@ -433,6 +577,71 @@ describe("authz hard-deny peels glued and trailing env -S forms", () => { ); }); + test("transparent time options and end-of-options expose hard-denied utilities", () => { + expect(runShellAuthzBlockReason(`time -p find .`)).toMatch(openEnded); + expect(runShellAuthzBlockReason(`time -- find .`)).toMatch(openEnded); + expect(runShellAuthzBlockReason(`/usr/bin/time -o report find .`)).toMatch( + openEnded, + ); + expect(runShellAuthzBlockReason(`/usr/bin/time -ao report find .`)).toMatch( + openEnded, + ); + expect(runShellAuthzBlockReason(`time -f %e find .`)).toMatch(openEnded); + }); + + test("clustered env and timeout value options expose hard-denied utilities", () => { + expect(runShellAuthzBlockReason(`env -iu PATH find .`)).toMatch(openEnded); + expect(runShellAuthzBlockReason(`timeout -vs KILL 1 find .`)).toMatch( + openEnded, + ); + }); + + test("valid GNU timeout durations expose hard-denied utilities", () => { + for (const duration of [ + ".5s", + "1", + "1e3", + "1e3s", + "0x1p4", + "2m", + "3h", + "4d", + "inf", + "infinity", + ]) { + expect(runShellAuthzBlockReason(`timeout ${duration} find .`)).toMatch( + openEnded, + ); + } + }); + + test("env value operands are parsed before terminal modes", () => { + expect(runShellAuthzBlockReason(`env -u --help find .`)).toMatch(openEnded); + expect(runShellAuthzBlockReason(`env -C --version find .`)).toMatch( + openEnded, + ); + }); + + test("env continues assignment parsing after end-of-options", () => { + expect(runShellAuthzBlockReason(`env -- FILE=x find .`)).toMatch(openEnded); + expect(runShellAuthzBlockReason(`env -i -- FILE=x find .`)).toMatch( + openEnded, + ); + }); + + test("terminal wrapper modes do not peel their operands as commands", () => { + for (const command of [ + "command --help find .", + "command -p -v find", + "command -pv find", + "env --help find", + "nice --help find", + "timeout --help find", + ]) { + expect(runShellAuthzBlockReason(command)).toBeUndefined(); + } + }); + test("G13: empty/whitespace -S payload with trailing utility is hard-denied", () => { // Runtime still executes the trailing utility; do not opaque-drop it. expect(runShellAuthzBlockReason(`env -S " " find /`)).toMatch(openEnded); @@ -463,6 +672,22 @@ describe("authz hard-deny peels glued and trailing env -S forms", () => { expect(runShellAuthzBlockReason(`env -S -v cat`)).toMatch(stdinHang); }); + test("G15b: clustered env value shorts inside -S consume the flag operand", () => { + expect(runShellAuthzBlockReason(`env -S "-iu PATH find /"`)).toMatch( + openEnded, + ); + expect(runShellAuthzBlockReason(`env -S "-iu PATH rm -rf /"`)).toMatch( + destructive, + ); + expect(runShellAuthzBlockReason(`env -S "-iC /tmp find /"`)).toMatch( + openEnded, + ); + expect(runShellAuthzBlockReason(`env -iu PATH find /`)).toMatch(openEnded); + expect(runShellAuthzBlockReason(`env -S "-i -u PATH find /"`)).toMatch( + openEnded, + ); + }); + test("G16: env -S quoted rm flags still hard-deny catastrophic targets", () => { expect(runShellAuthzBlockReason(`env -S "rm '-rf' '/'"`)).toMatch( destructive, diff --git a/src/shell/run-shell-authz.ts b/src/shell/run-shell-authz.ts index 8cbe46173..d2e358f0a 100644 --- a/src/shell/run-shell-authz.ts +++ b/src/shell/run-shell-authz.ts @@ -2,6 +2,13 @@ // enforcement owner (hard deny at the top of its verdict path). import { splitChainedCommand, tokenize } from "../permission/command.js"; +import { + peelTransparentCommand, + programBasename, + skipEnvArguments, +} from "./transparent-command.js"; + +export { programBasename } from "./transparent-command.js"; function skipMatching( tokens: readonly string[], @@ -318,16 +325,6 @@ const RECURSIVE_FLAG = /^(--recursive|-[A-Za-z]*[rR][A-Za-z]*)$/; // Interpreters whose `-c` / `--command` payload is an independent shell subject. // Exported so tests and callers share one explicit list with the peeler. export const SHELL_INTERPRETERS = new Set(["bash", "sh", "zsh", "dash", "ksh"]); -// Transparent prefixes that sit in front of a real program without changing it. -const PREFIX_WRAPPERS = new Set([ - "command", - "env", - "builtin", - "time", - "nice", - "nohup", - "timeout", -]); // Max recursive peel depth for nested wrappers. Exported so the depth cap is a // named policy knob tests can assert against, not a magic number. export const MAX_PEEL_DEPTH = 4; @@ -375,12 +372,6 @@ function isDangerousTarget(token: string): boolean { return false; } -export function programBasename(token: string): string { - const bare = token.replace(/['"]/g, ""); - const slash = bare.lastIndexOf("/"); - return slash >= 0 ? bare.slice(slash + 1) : bare; -} - // Payload we cannot statically inspect: empty, a bare expansion, or a leading // command substitution. Argument-position expansions like `rm -rf $HOME` stay // parseable so catastrophic-target checks still fire. @@ -472,8 +463,100 @@ function nestedInterpreterPayloadOpaque( } const SHELL_SEPARATE_VALUE_FLAGS = new Set(["-O", "-o"]); +const SHELL_COMMAND_OPTION_CLUSTER = /^-[A-Za-z]*c[A-Za-z]*$/; + +function isClusteredShellCommandOption(token: string): boolean { + return SHELL_COMMAND_OPTION_CLUSTER.test(token); +} -function peelShellDashC(tokens: string[], start: number): PeelOutcome { +function shellWords(segment: string): string[] | undefined { + const words: string[] = []; + let word = ""; + let wordStarted = false; + let quote: "'" | '"' | undefined; + + const push = (): void => { + if (wordStarted) words.push(word); + word = ""; + wordStarted = false; + }; + + for (let index = 0; index < segment.length; index++) { + const char = segment[index] ?? ""; + if (quote === "'") { + if (char === "'") quote = undefined; + else word += char; + continue; + } + if (quote === '"') { + if (char === '"') { + quote = undefined; + continue; + } + if (char !== "\\") { + word += char; + continue; + } + const next = segment[index + 1]; + if (next === undefined) return undefined; + if (next === "$" || next === "`" || next === '"' || next === "\\") { + word += next; + index++; + continue; + } + if (next === "\n") { + index++; + continue; + } + word += char; + continue; + } + if (char === "'" || char === '"') { + quote = char; + wordStarted = true; + continue; + } + if (char === "\\") { + const next = segment[index + 1]; + if (next === undefined) return undefined; + wordStarted = true; + if (next !== "\n") word += next; + index++; + continue; + } + if (/\s/.test(char)) { + push(); + continue; + } + wordStarted = true; + word += char; + } + if (quote !== undefined) return undefined; + push(); + return words; +} + +function shellCommandPayload( + segment: string, + optionToken: string, + optionOccurrence: number, +): string | undefined { + const words = shellWords(segment); + if (words === undefined) return undefined; + let seen = 0; + for (let index = 0; index < words.length; index++) { + if (words[index] !== optionToken) continue; + seen++; + if (seen === optionOccurrence) return words[index + 1]; + } + return undefined; +} + +function peelShellDashC( + tokens: string[], + start: number, + rawSegment: string, +): PeelOutcome { let i = start; while (i < tokens.length) { const t = tokens[i]; @@ -483,9 +566,14 @@ function peelShellDashC(tokens: string[], start: number): PeelOutcome { break; } if (t === "-c" || t === "--command") { - const payload = tokens[i + 1]; - if (payload === undefined || isOpaquePayload(payload)) - return { kind: "opaque" }; + const tokenPayload = tokens[i + 1]; + if (tokenPayload === undefined) return { kind: "opaque" }; + const optionOccurrence = tokens + .slice(0, i + 1) + .filter((token) => token === t).length; + const payload = + shellCommandPayload(rawSegment, t, optionOccurrence) ?? tokenPayload; + if (isOpaquePayload(payload)) return { kind: "opaque" }; const rest = tokens.slice(i + 2); if (nestedInterpreterPayloadOpaque(payload, rest)) return { kind: "opaque" }; @@ -503,12 +591,15 @@ function peelShellDashC(tokens: string[], start: number): PeelOutcome { i += 2; continue; } - // Clustered short flags that include `c` (`-lc`, `-ic`, …): `c` takes the - // next token as the command string, matching bash/sh/zsh. - if (/^-[A-Za-z]*c[A-Za-z]*$/.test(t)) { - const payload = tokens[i + 1]; - if (payload === undefined || isOpaquePayload(payload)) - return { kind: "opaque" }; + if (isClusteredShellCommandOption(t)) { + const tokenPayload = tokens[i + 1]; + if (tokenPayload === undefined) return { kind: "opaque" }; + const optionOccurrence = tokens + .slice(0, i + 1) + .filter((token) => token === t).length; + const payload = + shellCommandPayload(rawSegment, t, optionOccurrence) ?? tokenPayload; + if (isOpaquePayload(payload)) return { kind: "opaque" }; const rest = tokens.slice(i + 2); if (nestedInterpreterPayloadOpaque(payload, rest)) return { kind: "opaque" }; @@ -561,8 +652,10 @@ function peelXargs(tokens: string[], start: number): PeelOutcome { // (payload is the rest of the same token). const ENV_BOOL_SHORT = new Set(["i", "0", "v"]); -// Env flags that consume the following argv token as a value. Shared by the -// -S peel walker and the transparent-prefix skip so they cannot drift. +// Env flags that consume the following argv token as a value. The -S locator +// uses exact-token matches to walk up to -S. After the payload is extracted, +// peelEnvSplitUtility uses skipEnvArguments so clustered value shorts +// (`-iu NAME`) cannot drift from the transparent prefix skip. const ENV_VALUE_FLAGS = new Set([ "-u", "--unset", @@ -647,17 +740,20 @@ function expandEnvSplitSeparators(payload: string): string | null { } // After folding the -S payload with any trailing utility tokens, re-parse the -// result the way env does: expand `\_`, tokenize (dequote), skip env flags / -// assignments / end-of-options, and land on the real program hard-deny matchers -// expect (`env -S -v find /` → `find /`, `env -S "rm '-rf' '/'"` → `rm -rf /`). +// result the way env does: expand `\_`, tokenize (dequote), then skip flags / +// assignments / end-of-options with skipEnvArguments so clustered value shorts +// (`-iu NAME`) match the transparent prefix skip, and land on the program +// hard-deny matchers expect (`env -S -v find /` → `find /`, +// `env -S "rm '-rf' '/'"` → `rm -rf /`). function peelEnvSplitUtility(command: string): PeelOutcome { const expanded = expandEnvSplitSeparators(command); if (expanded === null || isOpaquePayload(expanded)) return { kind: "opaque" }; const tokens = tokenize(expanded); - let i = 0; - i = skipMatching(tokens, i, (t) => ENV_ASSIGNMENT.test(t)); + const parsed = skipEnvArguments(tokens, 0, []); + if (parsed.terminal) return { kind: "opaque" }; + let i = parsed.executableIndex; + if (i < 0) return { kind: "opaque" }; while (i < tokens.length && (tokens[i] === "--" || tokens[i] === "-")) i++; - i = skipEnvFlagsAndAssignments(tokens, i); if (i >= tokens.length) return { kind: "opaque" }; const utility = rejoinTokens(tokens.slice(i)); if (utility === null) return { kind: "opaque" }; @@ -684,7 +780,8 @@ function finishEnvSplitPayload( raw = payload; } else { if (isOpaquePayload(rest.join(" "))) return { kind: "opaque" }; - raw = rejoinTokens([payload, ...rest]); + const trailing = rejoinTokens(rest); + raw = trailing === null ? null : `${payload} ${trailing}`; } if (raw === null) return { kind: "opaque" }; return peelEnvSplitUtility(raw); @@ -781,96 +878,18 @@ function peelEnvSplitString(tokens: string[], start: number): PeelOutcome { return { kind: "none" }; } -// Skip env's own flags and NAME=value arguments so a transparent -// `env -i FOO=bar cmd` peel lands on `cmd`, not on the `-i` flag token. -function skipEnvFlagsAndAssignments(tokens: string[], start: number): number { - let i = start; - while (i < tokens.length) { - const t = tokens[i]; - if (t === undefined) break; - if (t === "--") return i + 1; - if (ENV_ASSIGNMENT.test(t)) { - i++; - continue; - } - const afterValue = advancePastEnvValueFlag(tokens, i); - if (afterValue !== null) { - i = afterValue; - continue; - } - if (t.startsWith("-") && t !== "-") { - i++; - continue; - } - break; - } - return i; -} - // Peel one layer of transparent prefix / shell -c / xargs / env -S from a // single segment. function peelOnce(segment: string): PeelOutcome { const tokens = tokenize(segment); - let i = 0; - i = skipMatching(tokens, i, (t) => ENV_ASSIGNMENT.test(t)); - - let strippedPrefix = false; - while (i < tokens.length) { - const current = tokens[i]; - if (current === undefined) break; - const base = programBasename(current); - if (base === "env") { - // Prefer split-string peel: the whole payload is one quoted argument - // that env re-splits itself, so the transparent-prefix path below - // would only rejoin `-S '…'` and leave the real command invisible. - const splitPeel = peelEnvSplitString(tokens, i + 1); - if (splitPeel.kind !== "none") return splitPeel; - strippedPrefix = true; - i = skipEnvFlagsAndAssignments(tokens, i + 1); - continue; - } - if (base === "timeout") { - strippedPrefix = true; - i++; - // Optional duration (10, 30s, 1m, …) and common long/short flags. - while (i < tokens.length) { - const t = tokens[i]; - if (t === undefined) break; - if (/^\d/.test(t)) { - i++; - continue; - } - if (t.startsWith("-") && t !== "-") { - // Flags that take a value: -k / --kill-after / -s / --signal. - if ( - t === "-k" || - t === "--kill-after" || - t === "-s" || - t === "--signal" || - t.startsWith("--kill-after=") || - t.startsWith("--signal=") - ) { - i++; - if (!t.includes("=") && i < tokens.length) { - const next = tokens[i]; - if (next !== undefined && !next.startsWith("-")) i++; - } - continue; - } - i++; - continue; - } - break; - } - continue; - } - if (PREFIX_WRAPPERS.has(base) && base !== "env" && base !== "timeout") { - strippedPrefix = true; - i++; - continue; - } - break; + const transparent = peelTransparentCommand(tokens); + for (const wrapperIndex of transparent.wrapperIndexes) { + if (programBasename(tokens[wrapperIndex] ?? "") !== "env") continue; + const splitPeel = peelEnvSplitString(tokens, wrapperIndex + 1); + if (splitPeel.kind !== "none") return splitPeel; } + const i = transparent.executableIndex; + const strippedPrefix = transparent.wrapperIndexes.length > 0; if (i >= tokens.length) return strippedPrefix ? { kind: "opaque" } : { kind: "none" }; @@ -889,16 +908,25 @@ function peelOnce(segment: string): PeelOutcome { // wrapper as opaque rather than risk peeling a truncated, misleading payload. if (segment.includes("`") || segment.includes("$(")) return { kind: "opaque" }; - const shellPeel = peelShellDashC(tokens, i + 1); + const shellPeel = peelShellDashC(tokens, i + 1, segment); if (shellPeel.kind !== "none") return shellPeel; // Interpreter without -c (e.g. `bash script.sh`) — not a peelable wrapper. return { kind: "none" }; } if (prog === "xargs") return peelXargs(tokens, i + 1); - // Prefix-only peel: `env FOO=1 rm -rf build` → `rm -rf build`. + // Prefix-only peel: `env FOO=1 rm -rf build` → `FOO=1 rm -rf build`. + // Rejoin keeps NAME=value tokens skipEnvArguments collected so + // `env -u HOME FOO=bar ls` still surfaces the assignment to auto-mode ask. if (strippedPrefix) { - const command = rejoinTokens(tokens.slice(i)); + const innerTokens = + transparent.assignmentValues.length === 0 + ? tokens.slice(i) + : [ + ...tokens.slice(0, i).filter((t) => ENV_ASSIGNMENT.test(t)), + ...tokens.slice(i), + ]; + const command = rejoinTokens(innerTokens); if (command === null) return { kind: "opaque" }; return { kind: "inner", command }; } diff --git a/src/shell/transparent-command.test.ts b/src/shell/transparent-command.test.ts new file mode 100644 index 000000000..f652c201c --- /dev/null +++ b/src/shell/transparent-command.test.ts @@ -0,0 +1,189 @@ +import { describe, expect, test } from "bun:test"; + +import { peelTransparentCommand } from "./transparent-command.js"; + +interface PeelCase { + name: string; + tokens: string[]; + executableIndex: number; + assignmentValues?: string[]; + wrapperIndexes?: number[]; + terminal?: boolean; +} + +function expected({ + executableIndex, + assignmentValues = [], + wrapperIndexes = [], + terminal = false, +}: PeelCase) { + return { executableIndex, assignmentValues, wrapperIndexes, terminal }; +} + +describe("peelTransparentCommand", () => { + const cases: PeelCase[] = [ + { + name: "collects assignments across an env wrapper", + tokens: ["OUTER=one", "env", "INNER=two", "find", "."], + executableIndex: 3, + assignmentValues: ["one", "two"], + wrapperIndexes: [1], + }, + { + name: "lets a clustered env value flag consume the next operand", + tokens: ["env", "-iu", "PATH", "find", "."], + executableIndex: 3, + wrapperIndexes: [0], + }, + { + name: "lets a clustered time value flag consume the next operand", + tokens: ["/usr/bin/time", "-ao", "report", "find", "."], + executableIndex: 3, + wrapperIndexes: [0], + }, + { + name: "lets a clustered timeout value flag consume the next operand", + tokens: ["timeout", "-vs", "KILL", "1", "find", "."], + executableIndex: 4, + wrapperIndexes: [0], + }, + { + name: "ends env option parsing when assignments begin", + tokens: ["env", "A=x", "--help", "find"], + executableIndex: 2, + assignmentValues: ["x"], + wrapperIndexes: [0], + }, + { + name: "lets an env split payload own trailing help arguments", + tokens: ["env", "-S", "find .", "--help"], + executableIndex: 2, + wrapperIndexes: [0], + }, + { + name: "lets a clustered env split payload own trailing version arguments", + tokens: ["env", "-iS", "rm -rf /", "--version"], + executableIndex: 2, + wrapperIndexes: [0], + }, + { + name: "consumes exactly one timeout duration", + tokens: ["timeout", "1", "2", "find", "."], + executableIndex: 2, + wrapperIndexes: [0], + }, + { + name: "does not accept an uppercase timeout suffix", + tokens: ["timeout", "1S", "find", "."], + executableIndex: 1, + wrapperIndexes: [0], + }, + ...["1e3", "1e3s", "0x1p4", "2.5", ".5s", "inf", "infinity"].map( + (duration): PeelCase => ({ + name: `peels GNU timeout duration ${duration}`, + tokens: ["timeout", duration, "find", "."], + executableIndex: 2, + wrapperIndexes: [0], + }), + ), + ...["+1", "+1e3s", "+0x1p4", "+.5s"].map((duration): PeelCase => ({ + name: `peels leading-plus GNU timeout duration ${duration}`, + tokens: ["timeout", duration, "find", "."], + executableIndex: 2, + wrapperIndexes: [0], + })), + ...["-u", "--unset", "-C", "--chdir", "--argv0", "-f", "--file", "-P"].map( + (option): PeelCase => ({ + name: `treats ${option} terminal-looking operand as an env option value`, + tokens: ["env", option, "--help", "find", "."], + executableIndex: 3, + wrapperIndexes: [0], + }), + ), + { + name: "recognizes env help after a value option", + tokens: ["env", "-u", "NAME", "--help", "find", "."], + executableIndex: 0, + terminal: true, + }, + { + name: "does not treat S inside an env option value as split mode", + tokens: ["env", "-uS", "--help", "find", "."], + executableIndex: 0, + terminal: true, + }, + { + name: "recognizes env version mode", + tokens: ["env", "--version", "find", "."], + executableIndex: 0, + terminal: true, + }, + { + name: "does not treat an env option after end-of-options as terminal", + tokens: ["env", "--", "--help", "find", "."], + executableIndex: 2, + wrapperIndexes: [0], + }, + { + name: "recognizes command help mode", + tokens: ["command", "--help", "find", "."], + executableIndex: 0, + terminal: true, + }, + ...[["-p", "-v"], ["-pv"]].map((options): PeelCase => ({ + name: `recognizes command query mode ${options.join(" ")}`, + tokens: ["command", ...options, "find", "."], + executableIndex: 0, + terminal: true, + })), + { + name: "peels command portability mode when it executes a utility", + tokens: ["command", "-p", "find", "."], + executableIndex: 2, + wrapperIndexes: [0], + }, + { + name: "skips GNU time output operand", + tokens: ["/usr/bin/time", "-o", "report", "find", "."], + executableIndex: 3, + wrapperIndexes: [0], + }, + { + name: "skips GNU time format operand", + tokens: ["time", "-f", "%e", "find", "."], + executableIndex: 3, + wrapperIndexes: [0], + }, + { + name: "keeps time portability option transparent", + tokens: ["time", "-p", "find", "."], + executableIndex: 2, + wrapperIndexes: [0], + }, + { + name: "keeps time end-of-options transparent", + tokens: ["time", "--", "find", "."], + executableIndex: 2, + wrapperIndexes: [0], + }, + ]; + + for (const entry of cases) { + test(entry.name, () => { + expect(peelTransparentCommand(entry.tokens)).toEqual(expected(entry)); + }); + } + + test("stops before wrappers rejected by the caller", () => { + expect( + peelTransparentCommand(["env", "nice", "find", "."], { + acceptsWrapper: (_token, program) => program !== "nice", + }), + ).toEqual({ + executableIndex: 1, + assignmentValues: [], + wrapperIndexes: [0], + terminal: false, + }); + }); +}); diff --git a/src/shell/transparent-command.ts b/src/shell/transparent-command.ts new file mode 100644 index 000000000..954561a74 --- /dev/null +++ b/src/shell/transparent-command.ts @@ -0,0 +1,408 @@ +export interface TransparentCommand { + executableIndex: number; + assignmentValues: string[]; + wrapperIndexes: number[]; + terminal: boolean; +} + +export interface TransparentCommandOptions { + acceptsWrapper?: (token: string, program: string) => boolean; +} + +const ENV_ASSIGNMENT = /^[A-Za-z_][A-Za-z0-9_]*=(.*)$/s; + +const ENV_VALUE_FLAGS = new Set([ + "-u", + "--unset", + "-C", + "--chdir", + "--argv0", + "-f", + "--file", + "-P", +]); +const ENV_BOOLEAN_SHORT_OPTIONS = new Set(["0", "i", "v"]); +const ENV_VALUE_SHORT_OPTIONS = new Set(["C", "P", "S", "a", "f", "u"]); + +function isEnvValueEqualsFlag(token: string): boolean { + return ( + token.startsWith("--unset=") || + token.startsWith("--chdir=") || + token.startsWith("--argv0=") || + token.startsWith("--file=") + ); +} + +function advancePastValueFlag( + tokens: readonly string[], + index: number, + flags: ReadonlySet, +): number | undefined { + const token = tokens[index]; + if (token === undefined || !flags.has(token)) return undefined; + return Math.min(index + 2, tokens.length); +} + +function advancePastShortOption( + tokens: readonly string[], + index: number, + booleanOptions: ReadonlySet, + valueOptions: ReadonlySet, +): number | undefined { + const token = tokens[index]; + if ( + token === undefined || + !token.startsWith("-") || + token.startsWith("--") || + token === "-" + ) { + return undefined; + } + + const options = token.slice(1); + for (let optionIndex = 0; optionIndex < options.length; optionIndex++) { + const option = options[optionIndex] ?? ""; + if (booleanOptions.has(option)) continue; + if (!valueOptions.has(option)) return undefined; + return optionIndex + 1 < options.length + ? index + 1 + : Math.min(index + 2, tokens.length); + } + return index + 1; +} + +function envSplitPayloadIndex( + tokens: readonly string[], + index: number, +): number | undefined { + const token = tokens[index]; + if (token === "-S" || token === "--split-string") return index + 1; + if ( + token === undefined || + !token.startsWith("-") || + token.startsWith("--") || + token === "-" + ) { + return undefined; + } + + const splitIndex = token.indexOf("S", 1); + if (splitIndex < 1) return undefined; + const beforeSplit = token.slice(1, splitIndex); + const afterSplit = token.slice(splitIndex + 1); + const isBooleanCluster = (options: string): boolean => + [...options].every((option) => ENV_BOOLEAN_SHORT_OPTIONS.has(option)); + return isBooleanCluster(beforeSplit) && isBooleanCluster(afterSplit) + ? index + 1 + : undefined; +} + +export function skipEnvArguments( + tokens: readonly string[], + start: number, + assignmentValues: string[], +): { executableIndex: number; terminal: boolean } { + let index = start; + let optionsEnded = false; + while (index < tokens.length) { + const token = tokens[index]; + if (token === undefined) break; + if (!optionsEnded && token === "--") { + optionsEnded = true; + index++; + continue; + } + const assignment = ENV_ASSIGNMENT.exec(token); + if (assignment !== null) { + assignmentValues.push(assignment[1] ?? ""); + optionsEnded = true; + index++; + continue; + } + if (optionsEnded) break; + const splitPayloadIndex = envSplitPayloadIndex(tokens, index); + if (splitPayloadIndex !== undefined) { + return { executableIndex: splitPayloadIndex, terminal: false }; + } + const afterValue = advancePastValueFlag(tokens, index, ENV_VALUE_FLAGS); + if (afterValue !== undefined) { + index = afterValue; + continue; + } + const afterShortOption = advancePastShortOption( + tokens, + index, + ENV_BOOLEAN_SHORT_OPTIONS, + ENV_VALUE_SHORT_OPTIONS, + ); + if (afterShortOption !== undefined) { + index = afterShortOption; + continue; + } + if (TERMINAL_LONG_OPTIONS.has(token)) { + return { executableIndex: start - 1, terminal: true }; + } + if ( + isEnvValueEqualsFlag(token) || + (token.startsWith("-") && token !== "-") + ) { + index++; + continue; + } + break; + } + return { executableIndex: index, terminal: false }; +} + +const NICE_VALUE_FLAGS = new Set(["-n", "--adjustment"]); +const TIMEOUT_VALUE_FLAGS = new Set(["-k", "--kill-after", "-s", "--signal"]); +const TIME_VALUE_FLAGS = new Set(["-o", "--output", "-f", "--format"]); +const NICE_BOOLEAN_SHORT_OPTIONS = new Set(); +const NICE_VALUE_SHORT_OPTIONS = new Set(["n"]); +const TIMEOUT_BOOLEAN_SHORT_OPTIONS = new Set(["f", "v"]); +const TIMEOUT_VALUE_SHORT_OPTIONS = new Set(["k", "s"]); +const TIME_BOOLEAN_SHORT_OPTIONS = new Set(["a", "p", "q", "v"]); +const TIME_VALUE_SHORT_OPTIONS = new Set(["f", "o"]); +const TERMINAL_LONG_OPTIONS = new Set(["--help", "--version"]); +const TIMEOUT_DURATION = + /^\+?(?:(?:(?:\d+(?:\.\d*)?|\.\d+)(?:[eE][+-]?\d+)?|0[xX](?:[\da-fA-F]+(?:\.[\da-fA-F]*)?|\.[\da-fA-F]+)[pP][+-]?\d+)(?:[smhd])?|[iI][nN][fF](?:[iI][nN][iI][tT][yY])?)$/; + +interface ParsedWrapperArguments { + executableIndex: number; + terminal: boolean; +} + +function commandArguments( + tokens: readonly string[], + start: number, +): ParsedWrapperArguments { + let index = start; + while (index < tokens.length) { + const token = tokens[index]; + if (token === undefined) break; + if (token === "--") return { executableIndex: index + 1, terminal: false }; + if (!token.startsWith("-") || token === "-") break; + if (TERMINAL_LONG_OPTIONS.has(token) || /^-[^-]*[vV]/.test(token)) { + return { executableIndex: start - 1, terminal: true }; + } + index++; + } + return { executableIndex: index, terminal: false }; +} + +function skipWrapperOptions( + tokens: readonly string[], + start: number, + valueFlags: ReadonlySet, + booleanShortOptions: ReadonlySet, + valueShortOptions: ReadonlySet, +): ParsedWrapperArguments { + let index = start; + while (index < tokens.length) { + const token = tokens[index]; + if (token === undefined) break; + if (token === "--") { + return { executableIndex: index + 1, terminal: false }; + } + const afterValue = advancePastValueFlag(tokens, index, valueFlags); + if (afterValue !== undefined) { + index = afterValue; + continue; + } + const afterShortOption = advancePastShortOption( + tokens, + index, + booleanShortOptions, + valueShortOptions, + ); + if (afterShortOption !== undefined) { + index = afterShortOption; + continue; + } + if (token.startsWith("--") && token.includes("=")) { + index++; + continue; + } + if (TERMINAL_LONG_OPTIONS.has(token)) { + return { executableIndex: start - 1, terminal: true }; + } + if (token.startsWith("-") && token !== "-") { + index++; + continue; + } + break; + } + return { executableIndex: index, terminal: false }; +} + +function skipTimeoutArguments( + tokens: readonly string[], + start: number, +): ParsedWrapperArguments { + let index = start; + let optionsEnded = false; + while (index < tokens.length) { + const token = tokens[index]; + if (token === undefined) break; + if (TIMEOUT_DURATION.test(token)) { + return { executableIndex: index + 1, terminal: false }; + } + if (optionsEnded) break; + if (token === "--") { + optionsEnded = true; + index++; + continue; + } + const afterValue = advancePastValueFlag(tokens, index, TIMEOUT_VALUE_FLAGS); + if (afterValue !== undefined) { + index = afterValue; + continue; + } + const afterShortOption = advancePastShortOption( + tokens, + index, + TIMEOUT_BOOLEAN_SHORT_OPTIONS, + TIMEOUT_VALUE_SHORT_OPTIONS, + ); + if (afterShortOption !== undefined) { + index = afterShortOption; + continue; + } + if (token.startsWith("--") && token.includes("=")) { + index++; + continue; + } + if (TERMINAL_LONG_OPTIONS.has(token)) { + return { executableIndex: start - 1, terminal: true }; + } + if (token.startsWith("-") && token !== "-") { + index++; + continue; + } + break; + } + return { executableIndex: index, terminal: false }; +} + +export function programBasename(token: string): string { + const bare = token.replace(/["']/g, ""); + const slash = Math.max(bare.lastIndexOf("/"), bare.lastIndexOf("\\")); + return slash >= 0 ? bare.slice(slash + 1) : bare; +} + +export function peelTransparentCommand( + tokens: readonly string[], + options: TransparentCommandOptions = {}, +): TransparentCommand { + const assignmentValues: string[] = []; + const wrapperIndexes: number[] = []; + let index = 0; + + while (index < tokens.length) { + const assignment = ENV_ASSIGNMENT.exec(tokens[index] ?? ""); + if (assignment === null) break; + assignmentValues.push(assignment[1] ?? ""); + index++; + } + + while (index < tokens.length) { + const token = tokens[index] ?? ""; + const program = programBasename(token); + if (options.acceptsWrapper?.(token, program) === false) break; + if (program === "env") { + const parsed = skipEnvArguments(tokens, index + 1, assignmentValues); + if (parsed.terminal) { + return { + executableIndex: index, + assignmentValues, + wrapperIndexes, + terminal: true, + }; + } + wrapperIndexes.push(index); + index = parsed.executableIndex; + continue; + } + if (program === "command") { + const parsed = commandArguments(tokens, index + 1); + if (parsed.terminal) { + return { + executableIndex: index, + assignmentValues, + wrapperIndexes, + terminal: true, + }; + } + wrapperIndexes.push(index); + index = parsed.executableIndex; + continue; + } + if (program === "nice") { + const parsed = skipWrapperOptions( + tokens, + index + 1, + NICE_VALUE_FLAGS, + NICE_BOOLEAN_SHORT_OPTIONS, + NICE_VALUE_SHORT_OPTIONS, + ); + if (parsed.terminal) { + return { + executableIndex: index, + assignmentValues, + wrapperIndexes, + terminal: true, + }; + } + wrapperIndexes.push(index); + index = parsed.executableIndex; + continue; + } + if (program === "timeout") { + const parsed = skipTimeoutArguments(tokens, index + 1); + if (parsed.terminal) { + return { + executableIndex: index, + assignmentValues, + wrapperIndexes, + terminal: true, + }; + } + wrapperIndexes.push(index); + index = parsed.executableIndex; + continue; + } + if (program === "time") { + const parsed = skipWrapperOptions( + tokens, + index + 1, + TIME_VALUE_FLAGS, + TIME_BOOLEAN_SHORT_OPTIONS, + TIME_VALUE_SHORT_OPTIONS, + ); + if (parsed.terminal) { + return { + executableIndex: index, + assignmentValues, + wrapperIndexes, + terminal: true, + }; + } + wrapperIndexes.push(index); + index = parsed.executableIndex; + continue; + } + if (["builtin", "nohup"].includes(program)) { + wrapperIndexes.push(index); + index++; + continue; + } + break; + } + + return { + executableIndex: index, + assignmentValues, + wrapperIndexes, + terminal: false, + }; +}