diff --git a/packages/adapters/src/executor-computer-safety.test.ts b/packages/adapters/src/executor-computer-safety.test.ts index 9c4f58b..82f3bfa 100644 --- a/packages/adapters/src/executor-computer-safety.test.ts +++ b/packages/adapters/src/executor-computer-safety.test.ts @@ -136,6 +136,108 @@ describe("computer lifecycle command guard", () => { expect(isProtectedComputerLifecycleCommand("python -c 'print(\"hello\")'")).toBe(false); }); + it.each([ + `python -m timeit 'os.kill(1, 9)'`, + `python3 -m timeit 'import os; os.system("pkill chromium")'`, + `python -m runpy -c 'import os; os.kill(1, 9)'`, + `python3 -m trace -c 'os.system("pkill chromium")'`, + `python -m timeit 'subprocess.call(["pkill", "chromium"])'`, + `python -m timeit 'subprocess.call(["kill", "-9", "1"])'`, + "python -m scripts.kill", + "python -m kill.main", + "python3 -m pkg.pkill", + "/usr/bin/python3 -m os.kill", + `python -W ignore -m timeit 'os.kill(1, 9)'`, + `python3.12 -O -m timeit 'os.kill(1, 9)'`, + "command python3 -m os.kill", + "FOO=1 python -m os.kill", + `echo 'import os; os.kill(1, 9)' | python -`, + `echo 'pkill chromium' | python3 -`, + `printf '%s\\n' 'import os; os.system("pkill chromium")' | python3 -`, + `echo 'process.kill(1)' | node -`, + `echo 'process.kill(1)' | node --require preload.js -`, + "python - <<'PY'\np = \"pkill chromium\"\nPY", + `echo 'os.kill(1)' | python > /tmp/out -`, + `echo 'system("pkill chromium")' | perl -`, + `echo 'system("pkill chromium")' | perl -I lib -`, + `echo 'system("pkill chromium")' | ruby -`, + `deno eval 'Deno.kill(1, "SIGKILL")'`, + `deno eval 'process.kill(1, "SIGKILL")'`, + `deno eval --ext=ts 'Deno.kill(1, "SIGKILL")'`, + `echo 'Deno.kill(1)' | deno run -`, + `bun -e 'process.kill(1, "SIGKILL")'`, + `bun --eval 'require("child_process").execSync("pkill chromium")'`, + `bun --eval='process.kill(1)'`, + `bun -e'process.kill(1)'`, + "deno run kill.ts", + "deno run --allow-net kill.ts", + "deno run --config deno.json kill.ts", + "deno --quiet run kill.ts", + "deno run -- kill.ts", + "deno test kill.ts", + "bun /tmp/pkill.ts", + "bun run ./kill.ts", + "bun src/xkill.js", + `eval "pkill chromium"`, + `bash -c 'python -m timeit "os.kill(1, 9)"'`, + `bash -c 'echo "os.kill(1)" | python -'`, + `bash -c 'deno run kill.ts'`, + ])("rejects kill-family python -m, stdin, and deno/bun bypasses: %s", (command) => { + expect(isProtectedComputerLifecycleCommand(command)).toBe(true); + }); + + it.each([ + "python -m pip", + "python3 -m pip install", + "python -O -m http.server", + "python3 -q -m json.tool", + "python -m os", + "python -m os.path", + "python -Ompip install", + "python -W ignore -m pip", + "python3.12 -m pip", + "python -m pytest kill.py", + `python -m timeit 'print("kill switch")'`, + `python -m timeit 'print("process.killer")'`, + "python -m myapp.kill_switch", + "python -", + `echo 'print("hello")' | python -`, + `echo 'print("kill switch")' | python3 -`, + "python - <<'PY'\nprint hello\nPY", + `echo 'os.kill(1)' | python -m pip`, + `echo 'os.kill(1)' | python script.py`, + `python -m pip && echo 'os.kill(1)'`, + "python3 scripts/train.py", + "node scripts/check.js", + `echo 'console.log(1)' | node -`, + `node -e 'console.log("kill switch")'`, + `python -c 'print("kill switch")'`, + `deno eval 'console.log(1)'`, + `deno eval 'eval(1)'`, + `deno eval 'console.log("kill switch")'`, + `deno eval --ext=ts 'console.log("kill switch")'`, + "deno run src/main.ts", + "deno run --allow-net=localhost src/main.ts", + "deno run src/main.ts -- kill.ts", + "deno fmt kill.ts", + "deno fmt src/kill.ts", + "bun src/index.ts", + "bun run dev", + "bun run src/index.ts -- kill.ts", + `bun -e 'console.log(1)'`, + `bun -e 'console.log("kill switch")'`, + "bun -p '1+1'", + "bun install", + "echo python -m os.kill", + "echo deno run kill.ts", + `echo 'puts "hello"' | ruby -`, + "ruby scripts/check.rb", + `perl -e 'print "kill switch"'`, + "command python3 -m http.server", + ])("keeps ordinary python -m, stdin, and deno/bun work: %s", (command) => { + expect(isProtectedComputerLifecycleCommand(command)).toBe(false); + }); + it("maps interpreter scripts onto the workspace file API", () => { expect(protectedScriptWorkspacePath("./scripts/stop.py", undefined)).toBe("scripts/stop.py"); expect(protectedScriptWorkspacePath("evil.js", "notes")).toBe("notes/evil.js"); diff --git a/packages/adapters/src/executor-shell-guard.ts b/packages/adapters/src/executor-shell-guard.ts index bb7396d..9d438e7 100644 --- a/packages/adapters/src/executor-shell-guard.ts +++ b/packages/adapters/src/executor-shell-guard.ts @@ -16,7 +16,87 @@ const SCRIPT_INLINE_INTERPRETER_NAMES = /^(?:python3?|node|nodejs|perl|ruby|php) * command-like forms so prose such as "kill switch" stays allowed. */ const SCRIPT_LIFECYCLE_KILL = - /\b(?:pkill|killall|xkill)\b|(?:os|process)\.kill\b|(['"`])kill(?:\s+-\w+|\s+\d+|\1)|\[\s*['"]kill['"]/i; + /\b(?:pkill|killall|xkill)\b|(?:os|process|deno)\.kill\b|(['"`])kill(?:\s+-\w+|\s+\d+|\1)|\[\s*['"]kill['"]/i; +const KILL_MODULE_PART = /^(?:kill|pkill|killall|xkill)$/; +const PYTHON_COMMAND = /^python(?:3(?:\.\d+)?)?$/; +/** Python short options that do not take a value, after the guard lowercases argv. */ +const PYTHON_NO_ARG = new Set(["b", "d", "e", "h", "i", "o", "p", "q", "s", "u", "v", "x"]); +const NODE_COMMAND = /^(?:node|nodejs)$/; +const DENO_VALUE_FLAGS = new Set([ + "--config", + "-c", + "--import-map", + "--lock", + "--cert", + "--location", + "--seed", + "--env-file", + "--preload", + "--ext", + "--coverage", +]); +const DENO_RUNNERS = new Set(["run", "test", "bench", "serve"]); +const DENO_NON_EXEC = new Set([ + "fmt", + "lint", + "check", + "cache", + "doc", + "completions", + "upgrade", + "install", + "uninstall", + "compile", + "repl", + "help", + "task", + "info", + "init", + "publish", + "coverage", +]); +const BUN_VALUE_FLAGS = new Set([ + "--cwd", + "--config", + "--bunfig", + "--preload", + "-r", + "--env-file", + "--define", + "--target", + "--outfile", + "--outdir", + "--port", + "--filter", + "--conditions", + "--tsconfig-override", + "-d", +]); +const BUN_RUNNERS = new Set(["run", "test"]); +const PERL_STDIN_VALUE_LETTERS = new Set(["i", "m"]); +const RUBY_STDIN_VALUE_LETTERS = new Set(["r", "i"]); +const BUN_NON_EXEC = new Set([ + "install", + "add", + "remove", + "rm", + "update", + "link", + "unlink", + "pm", + "why", + "patch", + "outdated", + "init", + "create", + "upgrade", + "x", + "repl", + "help", + "info", + "publish", + "completions", +]); /** Cap for workspace script reads. Larger files are not content-scanned. */ export const PROTECTED_SCRIPT_SCAN_BYTES = 65_536; @@ -488,6 +568,288 @@ export function protectedScriptWorkspacePath( return safeWorkspacePath(base ? `${base}/${slash}` : slash); } +interface ProtectedShellCommand { + /** Argv for one simple command, without redirect targets. */ + argv: string[]; + /** Index of the command name after prefixes and assignments. */ + commandAt: number | null; +} + +type PythonInvocation = + | { kind: "module"; payload: string[] } + | { kind: "stdin" } + | { kind: "other" }; + +function flagName(word: string): string { + const equals = word.indexOf("="); + return equals === -1 ? word : word.slice(0, equals); +} + +function modulePayloadTargetsKill(payload: string[]): boolean { + const moduleName = payload[0] ?? ""; + if (moduleName.split(".").some((part) => KILL_MODULE_PART.test(part))) return true; + return SCRIPT_LIFECYCLE_KILL.test(payload.join(" ")); +} + +/** + * `python -m` runs the module (and tools such as runpy/timeit can run the + * following arguments). Inline `-c` is not scanned here. `-` reads the + * program from stdin. + */ +function pythonInvocation(args: string[]): PythonInvocation { + for (let index = 0; index < args.length; index += 1) { + const word = args[index] ?? ""; + if (word === "--") return args[index + 1] === "-" ? { kind: "stdin" } : { kind: "other" }; + if (word === "-") return { kind: "stdin" }; + if (!word.startsWith("-")) return { kind: "other" }; + if (word.startsWith("--")) { + if (word === "--check-hash-based-pycs") index += 1; + continue; + } + const body = word.slice(1); + for (let cursor = 0; cursor < body.length; cursor += 1) { + const letter = body[cursor] ?? ""; + const rest = body.slice(cursor + 1); + if (letter === "c") return { kind: "other" }; + if (letter === "m") { + const payload = rest.length > 0 ? [rest, ...args.slice(index + 1)] : args.slice(index + 1); + if (!payload[0]) return { kind: "other" }; + return { kind: "module", payload }; + } + if (letter === "w") { + if (rest.length === 0) index += 1; + break; + } + if (!PYTHON_NO_ARG.has(letter)) return { kind: "other" }; + } + } + return { kind: "other" }; +} + +function pythonTargetsKill(args: string[], command: string): boolean { + const invocation = pythonInvocation(args); + switch (invocation.kind) { + case "module": + return modulePayloadTargetsKill(invocation.payload); + case "stdin": + return SCRIPT_LIFECYCLE_KILL.test(command); + case "other": + return false; + default: { + const unreachable: never = invocation; + return unreachable; + } + } +} + +/** True when `node` reads the program from stdin (`-`), not from `-e` / `--eval`. */ +function nodeReadsProgramStdin(args: string[]): boolean { + for (let index = 0; index < args.length; index += 1) { + const word = args[index] ?? ""; + if (word === "--") return args[index + 1] === "-"; + if (word === "-") return true; + if (!word.startsWith("-")) return false; + const name = flagName(word); + if ( + NODE_INLINE_FLAGS.has(name) || + word.startsWith("--eval=") || + word.startsWith("--print=") || + /^-[ep]/.test(word) + ) { + return false; + } + if (!word.includes("=") && NODE_VALUE_FLAGS.has(name)) index += 1; + } + return false; +} + +function perlOrRubyReadsProgramStdin(args: string[], valueLetters: ReadonlySet): boolean { + for (let index = 0; index < args.length; index += 1) { + const word = args[index] ?? ""; + if (word === "--") return args[index + 1] === "-"; + if (word === "-") return true; + if (!word.startsWith("-")) return false; + if (word.startsWith("--")) continue; + const body = word.slice(1); + for (let cursor = 0; cursor < body.length; cursor += 1) { + const letter = body[cursor] ?? ""; + const rest = body.slice(cursor + 1); + if (letter === "e") return false; + if (!valueLetters.has(letter)) continue; + if (rest.length === 0) { + const next = args[index + 1] ?? ""; + if (next && next !== "-" && !next.startsWith("-")) index += 1; + } + break; + } + } + return false; +} + +function nextPositional( + args: string[], + start: number, + separateValue: ReadonlySet, +): { word: string; next: number } | undefined { + let index = start; + while (index < args.length) { + const word = args[index] ?? ""; + if (word === "--") { + const operand = args[index + 1]; + return operand === undefined ? undefined : { word: operand, next: index + 2 }; + } + if (word !== "-" && word.startsWith("-")) { + const name = flagName(word); + index += !word.includes("=") && separateValue.has(name) ? 2 : 1; + continue; + } + return { word, next: index + 1 }; + } + return undefined; +} + +function denoEvalProgram(args: string[]): string | undefined { + const first = nextPositional(args, 0, DENO_VALUE_FLAGS); + if (first?.word !== "eval") return undefined; + const program = nextPositional(args, first.next, DENO_VALUE_FLAGS); + if (!program || program.word === "-") return undefined; + return program.word; +} + +function denoReadsProgramStdin(args: string[]): boolean { + const first = nextPositional(args, 0, DENO_VALUE_FLAGS); + if (!first || DENO_NON_EXEC.has(first.word)) return false; + if (first.word === "eval" || DENO_RUNNERS.has(first.word)) { + return nextPositional(args, first.next, DENO_VALUE_FLAGS)?.word === "-"; + } + return first.word === "-"; +} + +function looksLikeScriptPath(path: string): boolean { + return ( + !path.startsWith("-") && (path.includes("/") || /\.(?:[cm]?js|jsx|tsx?|mts|cts)$/.test(path)) + ); +} + +function denoBunKillFamilyScriptStem(path: string): boolean { + if (!looksLikeScriptPath(path)) return false; + const base = path.split(/[/\\]/).at(-1) ?? ""; + const dot = base.lastIndexOf("."); + const stem = (dot > 0 ? base.slice(0, dot) : base).toLowerCase(); + return KILL_MODULE_PART.test(stem); +} + +function denoKillScript(args: string[]): boolean { + const first = nextPositional(args, 0, DENO_VALUE_FLAGS); + if (!first || DENO_NON_EXEC.has(first.word) || first.word === "eval") return false; + const operand = DENO_RUNNERS.has(first.word) + ? nextPositional(args, first.next, DENO_VALUE_FLAGS)?.word + : first.word; + return operand !== undefined && denoBunKillFamilyScriptStem(operand); +} + +function denoTargetsKill(args: string[], command: string): boolean { + const program = denoEvalProgram(args); + if (program !== undefined) return SCRIPT_LIFECYCLE_KILL.test(program); + if (denoReadsProgramStdin(args)) return SCRIPT_LIFECYCLE_KILL.test(command); + return denoKillScript(args); +} + +function bunInlineProgram( + args: string[], +): { present: true; program: string | undefined } | { present: false } { + for (let index = 0; index < args.length; index += 1) { + const word = args[index] ?? ""; + if (word === "-" || word === "--" || !word.startsWith("-")) return { present: false }; + if (word.startsWith("--eval=")) return { present: true, program: word.slice("--eval=".length) }; + if (word.startsWith("--print=")) + return { present: true, program: word.slice("--print=".length) }; + if (word === "--eval" || word === "-e" || word === "--print" || word === "-p") { + return { present: true, program: args[index + 1] }; + } + const glued = /^-([ep])(.+)$/.exec(word); + if (glued?.[2]) return { present: true, program: glued[2] }; + const name = flagName(word); + if (!word.includes("=") && BUN_VALUE_FLAGS.has(name)) index += 1; + } + return { present: false }; +} + +function bunOperand(args: string[]): string | undefined { + const first = nextPositional(args, 0, BUN_VALUE_FLAGS); + if (!first || BUN_NON_EXEC.has(first.word)) return undefined; + if (BUN_RUNNERS.has(first.word)) return nextPositional(args, first.next, BUN_VALUE_FLAGS)?.word; + return first.word; +} + +function bunTargetsKill(args: string[], command: string): boolean { + const inline = bunInlineProgram(args); + if (inline.present) return inline.program !== undefined && SCRIPT_LIFECYCLE_KILL.test(inline.program); + const operand = bunOperand(args); + if (operand === "-") return SCRIPT_LIFECYCLE_KILL.test(command); + return operand !== undefined && denoBunKillFamilyScriptStem(operand); +} + +function interpreterArgs( + simple: ProtectedShellCommand, +): { name: string; args: string[] } | undefined { + if (simple.commandAt === null) return undefined; + const name = simple.argv[simple.commandAt]?.split("/").at(-1) ?? ""; + return { name, args: simple.argv.slice(simple.commandAt + 1) }; +} + +function exactCommandWordCount(words: readonly string[], name: string): number { + return words.filter((word) => word.split("/").at(-1) === name).length; +} + +/** `deno eval` is Deno's subcommand. Other `eval` words are the shell builtin. */ +function denoEvalSubcommandCount(commands: readonly ProtectedShellCommand[]): number { + let count = 0; + for (const simple of commands) { + const invocation = interpreterArgs(simple); + if (invocation?.name !== "deno") continue; + if (nextPositional(invocation.args, 0, DENO_VALUE_FLAGS)?.word === "eval") count += 1; + } + return count; +} + +/** + * Residual kill-family payloads: `python -m`, stdin programs embedded in the + * same shell line, and deno/bun eval or kill-named script paths. Inline + * `-c`/`-e` for python/node/perl/ruby/php is handled separately. + */ +function interpreterTargetsKill( + commands: readonly ProtectedShellCommand[], + command: string, +): boolean { + for (const simple of commands) { + const invocation = interpreterArgs(simple); + if (!invocation) continue; + const { name, args } = invocation; + if (PYTHON_COMMAND.test(name) && pythonTargetsKill(args, command)) return true; + if (NODE_COMMAND.test(name) && nodeReadsProgramStdin(args) && SCRIPT_LIFECYCLE_KILL.test(command)) { + return true; + } + if ( + name === "perl" && + perlOrRubyReadsProgramStdin(args, PERL_STDIN_VALUE_LETTERS) && + SCRIPT_LIFECYCLE_KILL.test(command) + ) { + return true; + } + if ( + name === "ruby" && + perlOrRubyReadsProgramStdin(args, RUBY_STDIN_VALUE_LETTERS) && + SCRIPT_LIFECYCLE_KILL.test(command) + ) { + return true; + } + if (name === "deno" && denoTargetsKill(args, command)) return true; + if (name === "bun" && bunTargetsKill(args, command)) return true; + } + return false; +} + function preserveShellCommandBoundaries(command: string): string { let quote: "'" | '"' | undefined; let result = ""; @@ -521,6 +883,8 @@ interface ProtectedShellTokens { original: string[]; /** Indexes of command names after prefixes and assignments. */ commandIndexes: number[]; + /** One simple command per shell operator boundary. */ + commands: ProtectedShellCommand[]; } function tokenizeProtectedShellCommand(command: string): ProtectedShellTokens | "dynamic" { @@ -537,6 +901,8 @@ function tokenizeProtectedShellCommand(command: string): ProtectedShellTokens | const words: string[] = []; const original: string[] = []; const commandIndexes: number[] = []; + const commands: ProtectedShellCommand[] = []; + let current: ProtectedShellCommand = { argv: [], commandAt: null }; let commandPosition = true; let redirectTarget = false; for (const [index, entry] of parsed.entries()) { @@ -554,6 +920,7 @@ function tokenizeProtectedShellCommand(command: string): ProtectedShellTokens | redirectTarget = false; continue; } + current.argv.push(word); const next = parsed[index + 1]; if ( commandPosition && @@ -574,7 +941,10 @@ function tokenizeProtectedShellCommand(command: string): ProtectedShellTokens | ) { // Shell prefixes and assignments leave the command word pending. } else { - if (commandPosition) commandIndexes.push(words.length - 1); + if (commandPosition) { + commandIndexes.push(words.length - 1); + current.commandAt = current.argv.length - 1; + } commandPosition = false; } continue; @@ -585,12 +955,16 @@ function tokenizeProtectedShellCommand(command: string): ProtectedShellTokens | return "dynamic"; } if ("op" in entry && entry.op === "glob") { - words.push(entry.pattern.toLowerCase()); + const pattern = entry.pattern.toLowerCase(); + words.push(pattern); original.push(entry.pattern); + current.argv.push(pattern); continue; } if ("op" in entry && SAFE_SHELL_CONTROL_OPS.has(entry.op)) { if (["&&", "||", ";", "|", "&"].includes(entry.op)) { + commands.push(current); + current = { argv: [], commandAt: null }; commandPosition = true; redirectTarget = false; } else redirectTarget = true; @@ -598,7 +972,8 @@ function tokenizeProtectedShellCommand(command: string): ProtectedShellTokens | } return "dynamic"; } - return { words, original, commandIndexes }; + commands.push(current); + return { words, original, commandIndexes, commands }; } catch { return "dynamic"; } @@ -610,7 +985,7 @@ export function isProtectedComputerLifecycleCommand( ): boolean { const tokens = tokenizeProtectedShellCommand(command); if (tokens === "dynamic") return true; - const { words } = tokens; + const { words, commands } = tokens; const commandNames = words.map((word) => word.split("/").at(-1)); if (commandNames.some((word) => /^(?:kill|pkill|killall|xkill)$/.test(word ?? ""))) { @@ -618,7 +993,12 @@ export function isProtectedComputerLifecycleCommand( } // eval/source/. can hide protected commands inside an expansion string that the // outer tokenizer keeps as a single word (e.g. eval "pkill chromium"). - if (words.includes(".") || commandNames.some((word) => /^(?:eval|source)$/.test(word ?? ""))) { + // A `deno eval` subcommand is not that builtin; extra `eval` words still are. + if ( + words.includes(".") || + commandNames.some((word) => word === "source") || + exactCommandWordCount(words, "eval") > denoEvalSubcommandCount(commands) + ) { return true; } if ( @@ -649,7 +1029,7 @@ export function isProtectedComputerLifecycleCommand( for (const scriptPath of executedScriptPaths(tokens)) { if (scriptTargetsProtectedLifecycle(scriptPath, readScript)) return true; } - return false; + return interpreterTargetsKill(commands, command); } /**