/** * Shell command safety guard — detects dangerous lifecycle commands * (kill, systemctl stop, browser profile access, etc.) by tokenizing * the command through shell-quote. * * Extracted from executor.ts to reduce file size. */ import { parse as parseShellCommand } from "shell-quote"; import { normalizeWorkspacePath } from "./computer-support.js"; const SHELL_INTERPRETER_NAMES = /^(?:bash|sh|dash|zsh|ksh|fish)$/; /** Interpreters whose `-c` / `-e` / `-r` payloads can shell out to kill/pkill. */ const SCRIPT_INLINE_INTERPRETER_NAMES = /^(?:python3?|node|nodejs|perl|ruby|php)$/; /** * Kill-family teardown inside interpreter source. Bare `kill` only counts in * command-like forms so prose such as "kill switch" stays allowed. */ const SCRIPT_LIFECYCLE_KILL = /\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; type ScriptInterpreter = "python" | "node" | "perl" | "ruby" | "php"; export type ProtectedScriptReader = (scriptPath: string) => string | undefined; /** * Managed graphical computers expose a workspace file API, not the container * root. Absolute script paths under these homes are workspace files. * `/home/user` alone is only a cwd alias (E2B/Box default); a script at * `/home/user/evil.js` is not the workspace file `evil.js`. */ const WORKSPACE_SCRIPT_ROOTS = ["/home/user/rakazo-home", "/home/rakazo"] as const; const STATIC_SHELL_EXPANSIONS: Readonly> = { HOME: "/home/rakazo", LOGNAME: "rakazo", PATH: "/home/rakazo/.local/bin:/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin", PWD: "/home/rakazo", TMPDIR: "/tmp", USER: "rakazo", WORKSPACE: "/home/rakazo/workspace", XDG_CONFIG_HOME: "/home/rakazo/.config", }; const SAFE_SHELL_CONTROL_OPS = new Set([ "&&", "||", ";", "|", "&", ">", "<", ">>", ">&", "<&", "&>", ]); function shellCFlagProgram( words: string[], original: string[], interpreterIndex: number, ): string | undefined { for (let index = interpreterIndex + 1; index < words.length; index += 1) { const word = words[index] ?? ""; if (word.startsWith("--command=")) { return (original[index] ?? word).slice("--command=".length); } // bash -c / -lc / -ce and fish --command: the next argument is the program string. if (word === "--command" || /^-[^-]*c/.test(word)) return original[index + 1]; } return undefined; } /** * Extract an inline program from python/node/perl/ruby/php-style argv * (`-c`/`-e`/`-r`/`-p`, `--eval`, glued forms like `-e'…'`). */ function scriptInlineProgram(words: string[], interpreterIndex: number): string | undefined { for (let index = interpreterIndex + 1; index < words.length; index += 1) { const word = words[index] ?? ""; if (word.startsWith("--eval=")) return word.slice("--eval=".length); if (word.startsWith("--command=")) return word.slice("--command=".length); if (word === "--eval" || word === "--command") return words[index + 1]; // Glued short form: node -e'require…' / python -c'…' / php -r'…' const glued = /^-(?:[^-]*?)([cerp])(.+)$/.exec(word); if (glued?.[1] && glued[2] !== undefined && glued[2] !== "") return glued[2]; // python -O -c CODE / node -e CODE / perl -e / ruby -e / php -r / node -p if ( /^-[^-]*[cerp]$/.test(word) || word === "-c" || word === "-e" || word === "-r" || word === "-p" ) { return words[index + 1]; } } return undefined; } function inlineProgramTargetsProtectedLifecycle(program: string): boolean { // Interpreter source is not shell syntax — do not re-tokenize as a shell command // (f-strings, template literals, and `$…` would fail-closed incorrectly). return SCRIPT_LIFECYCLE_KILL.test(program); } /** python/node/perl/ruby/php. Inline `-c` / `-e` / `-r` programs are not script files. */ function scriptInterpreterFamily(commandName: string): ScriptInterpreter | undefined { if (/^python(?:3(?:\.\d+)?)?$/.test(commandName)) return "python"; if (commandName === "node" || commandName === "nodejs") return "node"; if (commandName === "perl") return "perl"; if (commandName === "ruby") return "ruby"; if (commandName === "php") return "php"; return undefined; } function killFamilyScriptStem(scriptPath: string): boolean { const base = scriptPath.split(/[/\\]/).at(-1) ?? ""; const dot = base.lastIndexOf("."); const stem = (dot > 0 ? base.slice(0, dot) : base).toLowerCase(); return /^(?:kill|pkill|killall|xkill)$/.test(stem); } function scriptTargetsProtectedLifecycle( scriptPath: string, readScript: ProtectedScriptReader | undefined, ): boolean { if (!readScript) return killFamilyScriptStem(scriptPath); let contents: string | undefined; try { contents = readScript(scriptPath); } catch { contents = undefined; } if (typeof contents !== "string") return killFamilyScriptStem(scriptPath); return SCRIPT_LIFECYCLE_KILL.test(contents.slice(0, PROTECTED_SCRIPT_SCAN_BYTES)); } const NODE_INLINE_FLAGS = new Set(["-e", "--eval", "-p", "--print"]); const NODE_VALUE_FLAGS = new Set([ "-r", "--require", "--import", "--experimental-loader", "--loader", "--experimental-policy", "--policy", "--test-reporter", "--test-reporter-destination", "--watch-path", "--env-file", "--env-file-if-exists", "-C", "--conditions", "--cpu-prof-dir", "--cpu-prof-name", "--heap-prof-dir", "--heap-prof-name", "--diagnostic-dir", "--report-dir", "--report-filename", "--redirect-warnings", "--input-type", "--tls-cipher-list", "--openssl-config", "--secure-heap", "--v8-pool-size", "--trace-event-file-pattern", ]); const NODE_PRELOAD_FLAGS = new Set([ "-r", "--require", "--import", "--experimental-loader", "--loader", ]); function nextOriginal(original: string[], index: number): string | undefined { const value = original[index]; return value === undefined || value === "" ? undefined : value; } /** Script files a python/node/perl/ruby/php invocation would execute. Inline code is omitted. */ function scriptPathsForInterpreter( family: ScriptInterpreter, words: string[], original: string[], start: number, end: number, ): string[] { switch (family) { case "python": return pythonScriptPaths(words, original, start, end); case "node": return nodeScriptPaths(words, original, start, end); case "perl": return perlScriptPaths(words, original, start, end); case "ruby": return rubyScriptPaths(words, original, start, end); case "php": return phpScriptPaths(words, original, start, end); default: { const unreachable: never = family; return unreachable; } } } function pythonScriptPaths( words: string[], original: string[], start: number, end: number, ): string[] { for (let index = start; index < end; index += 1) { const word = words[index] ?? ""; if (word === "--") { const script = nextOriginal(original, index + 1); return script && script !== "-" ? [script] : []; } if (word === "-" || word === "--version" || word === "--help") return []; if (!word.startsWith("-")) { const script = nextOriginal(original, index); return script ? [script] : []; } if (word.startsWith("--check-hash-based-pycs")) { if (!word.includes("=")) index += 1; continue; } if (word.startsWith("--")) continue; const body = word.slice(1); let inline = false; let skipValue = false; for (let cursor = 0; cursor < body.length; cursor += 1) { const letter = body[cursor]; const rest = body.slice(cursor + 1); if (letter === "c" || letter === "m") { inline = true; break; } if (letter === "W" || letter === "X") { if (rest.length === 0) skipValue = true; break; } } if (inline) return []; if (skipValue) index += 1; } return []; } function nodeScriptPaths( words: string[], original: string[], start: number, end: number, ): string[] { const executed: string[] = []; let inline = false; for (let index = start; index < end; index += 1) { const word = words[index] ?? ""; if (word === "--") { if (!inline) { const script = nextOriginal(original, index + 1); if (script && script !== "-") executed.push(script); } return executed; } if (word === "-") return executed; if (!word.startsWith("-")) { if (!inline) { const script = nextOriginal(original, index); if (script) executed.push(script); } return executed; } const equals = word.indexOf("="); const flag = equals === -1 ? word : word.slice(0, equals); if (NODE_INLINE_FLAGS.has(flag) || /^-[ep].+$/.test(word)) { inline = true; if (equals === -1 && NODE_INLINE_FLAGS.has(flag)) index += 1; continue; } const shortPreload = /^-r(.+)$/.exec(word); if (shortPreload?.[1]) { executed.push((original[index] ?? word).slice(2)); continue; } if (NODE_VALUE_FLAGS.has(flag)) { const spelled = original[index] ?? word; const value = equals === -1 ? nextOriginal(original, index + 1) : spelled.slice(spelled.indexOf("=") + 1); if (equals === -1) index += 1; if (value && NODE_PRELOAD_FLAGS.has(flag)) executed.push(value); } } return executed; } function perlScriptPaths( words: string[], original: string[], start: number, end: number, ): string[] { let inline = false; for (let index = start; index < end; index += 1) { const word = words[index] ?? ""; if (word === "--") { const script = nextOriginal(original, index + 1); return !inline && script && script !== "-" ? [script] : []; } if (word === "-") return []; if (!word.startsWith("-")) { const script = nextOriginal(original, index); return !inline && script ? [script] : []; } 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" || letter === "E") { inline = true; if (rest.length === 0) index += 1; break; } if ((letter === "I" || letter === "m" || letter === "M") && rest.length === 0) { index += 1; break; } if (letter === "I" || letter === "m" || letter === "M") break; } } return []; } function rubyScriptPaths( words: string[], original: string[], start: number, end: number, ): string[] { const executed: string[] = []; let inline = false; for (let index = start; index < end; index += 1) { const word = words[index] ?? ""; if (word === "--") { if (!inline) { const script = nextOriginal(original, index + 1); if (script && script !== "-") executed.push(script); } return executed; } if (word === "-") return executed; if (!word.startsWith("-")) { if (!inline) { const script = nextOriginal(original, index); if (script) executed.push(script); } return executed; } if (word.startsWith("--disable") || word.startsWith("--enable")) { if (!word.includes("=")) index += 1; continue; } 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" || letter === "E") { inline = true; if (rest.length === 0) index += 1; break; } if (letter === "r") { const library = rest || nextOriginal(original, index + 1); if (rest.length === 0) index += 1; if (library) executed.push(library); break; } if (letter !== undefined && "ICWK".includes(letter)) { if (rest.length === 0) index += 1; break; } } } return executed; } function phpScriptPaths(words: string[], original: string[], start: number, end: number): string[] { let inline = false; for (let index = start; index < end; index += 1) { const word = words[index] ?? ""; if (word === "--") { const script = nextOriginal(original, index + 1); return !inline && script ? [script] : []; } if (word === "-r" || (word.startsWith("-r") && !word.startsWith("--"))) { inline = true; if (word === "-r") index += 1; continue; } if (word === "-f" || (word.startsWith("-f") && word.length > 2 && !word.startsWith("--"))) { const script = word === "-f" ? nextOriginal(original, index + 1) : (original[index] ?? word).slice(2); return script ? [script] : []; } if (word === "-d" || word === "-c" || word === "--define" || word === "--php-ini") { index += 1; continue; } if (word.startsWith("-")) continue; const script = nextOriginal(original, index); return !inline && script ? [script] : []; } return []; } function executedScriptPaths(tokens: ProtectedShellTokens): string[] { const paths: string[] = []; for (let cursor = 0; cursor < tokens.commandIndexes.length; cursor += 1) { const commandIndex = tokens.commandIndexes[cursor]; if (commandIndex === undefined) continue; const end = tokens.commandIndexes[cursor + 1] ?? tokens.words.length; const name = tokens.words[commandIndex]?.split("/").at(-1) ?? ""; const family = scriptInterpreterFamily(name); if (!family) continue; paths.push( ...scriptPathsForInterpreter(family, tokens.words, tokens.original, commandIndex + 1, end), ); } return paths; } function collectInterpreterScriptPaths(command: string): string[] | "dynamic" { const tokens = tokenizeProtectedShellCommand(command); if (tokens === "dynamic") return "dynamic"; const paths = executedScriptPaths(tokens); for (let index = 0; index < tokens.words.length; index += 1) { const name = tokens.words[index]?.split("/").at(-1) ?? ""; if (!SHELL_INTERPRETER_NAMES.test(name)) continue; const program = shellCFlagProgram(tokens.words, tokens.original, index); if (!program) continue; const nested = collectInterpreterScriptPaths(program); if (nested === "dynamic") return "dynamic"; paths.push(...nested); } return paths; } function workspaceRelativeCwd(cwd: string | undefined): string | undefined { if (!cwd || cwd === "." || cwd === "/") return ""; const slash = cwd.replace(/\\/g, "/").replace(/\/+$/, "") || "/"; if (slash === "/home/user" || slash === "/home/rakazo" || slash === "/home/user/rakazo-home") { return ""; } for (const root of WORKSPACE_SCRIPT_ROOTS) { if (slash.startsWith(`${root}/`)) return safeWorkspacePath(slash.slice(root.length + 1)); } // Docker, E2B, and Box contain other absolute cwds inside the workspace. return safeWorkspacePath(slash); } function safeWorkspacePath(value: string): string | undefined { try { const normalized = normalizeWorkspacePath(value); return normalized || undefined; } catch { return undefined; } } /** * Map an interpreter script argument onto the computer workspace file API. * Returns undefined for container-absolute paths outside the workspace * (`/tmp/kill.py`): that file is not the workspace object `tmp/kill.py`. */ export function protectedScriptWorkspacePath( scriptPath: string, cwd: string | undefined, ): string | undefined { const slash = scriptPath.replace(/\\/g, "/"); if (!slash || slash.includes("\0")) return undefined; if (slash.startsWith("/")) { for (const root of WORKSPACE_SCRIPT_ROOTS) { if (slash.startsWith(`${root}/`)) return safeWorkspacePath(slash.slice(root.length + 1)); } return undefined; } const base = workspaceRelativeCwd(cwd); if (base === undefined) return undefined; 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 = ""; for (let index = 0; index < command.length; index += 1) { const character = command[index]; const next = command[index + 1]; if (character === "\\" && quote !== "'") { if (next === "\n") { index += 1; continue; } // Keep escaped characters intact; an escaped quote is not a boundary. result += character; if (next !== undefined) { result += next; index += 1; } continue; } if (character === quote) quote = undefined; else if (!quote && (character === "'" || character === '"')) quote = character; result += character === "\n" && !quote ? "\n;" : character; } return result; } interface ProtectedShellTokens { /** Lowercased words. A path `.` is rewritten to `./` so it is not the source builtin. */ words: string[]; /** Original spelling at the same indexes, for script paths and nested programs. */ 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" { try { // shell-quote treats newlines as whitespace. Preserve command boundaries for // the dot builtin, after folding shell line continuations. Retaining the // newline also preserves comment handling (comments remain fail-closed). const separated = preserveShellCommandBoundaries(command); const parsed = parseShellCommand<{ expansion: string }>( separated, (name) => STATIC_SHELL_EXPANSIONS[name] ?? { expansion: name }, { splitUnquoted: true }, ); 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()) { if (typeof entry === "string") { // Backtick fragments are not fully tokenized; treat them as dynamic. if (entry.includes("`")) return "dynamic"; const folded = entry.toLowerCase(); // `find .`, `git add .`, and `git -C .` use a path, not the // executable `. script` builtin. Keep the path out of the builtin scan. const pathDot = folded === "." && (!commandPosition || redirectTarget); const word = pathDot ? "./" : folded; words.push(word); original.push(pathDot ? "./" : entry); if (redirectTarget) { redirectTarget = false; continue; } current.argv.push(word); const next = parsed[index + 1]; if ( commandPosition && /^\d+$/.test(word) && typeof next === "object" && "op" in next && /^[<>]/.test(next.op) ) { // A leading file descriptor belongs to a redirect, not the command. } else if (commandPosition && /^(?:then|do|else)$/.test(word)) { commandPosition = true; } else if (commandPosition && (word === "coproc" || word === "function")) return "dynamic"; else if ( commandPosition && (/^(?:command|builtin|exec|time|if|elif|while|until|!|\{)$/.test(word) || word.startsWith("-") || /^[a-z_][a-z0-9_]*=/.test(word)) ) { // Shell prefixes and assignments leave the command word pending. } else { if (commandPosition) { commandIndexes.push(words.length - 1); current.commandAt = current.argv.length - 1; } commandPosition = false; } continue; } if ("expansion" in entry) { // Unknown expansions and command substitutions are resolved by bash // after this guard runs, so their eventual value cannot be inspected. return "dynamic"; } if ("op" in entry && entry.op === "glob") { 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; continue; } return "dynamic"; } commands.push(current); return { words, original, commandIndexes, commands }; } catch { return "dynamic"; } } export function isProtectedComputerLifecycleCommand( command: string, readScript?: ProtectedScriptReader, ): boolean { const tokens = tokenizeProtectedShellCommand(command); if (tokens === "dynamic") return true; const { words, commands } = tokens; const commandNames = words.map((word) => word.split("/").at(-1)); if (commandNames.some((word) => /^(?:kill|pkill|killall|xkill)$/.test(word ?? ""))) { return true; } // eval/source/. can hide protected commands inside an expansion string that the // outer tokenizer keeps as a single word (e.g. eval "pkill chromium"). // 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 ( commandNames.some((word) => word === "systemctl" || word === "service") && words.some((word) => /^(?:stop|restart|kill)$/.test(word)) ) { return true; } if ( words.some((word) => /(?:\.browser-profiles|--user-data-dir|\/tmp\/\.x11-unix|\/tmp\/\.x\d+-lock)/.test(word), ) ) { return true; } for (let index = 0; index < words.length; index += 1) { const name = words[index]?.split("/").at(-1) ?? ""; if (SHELL_INTERPRETER_NAMES.test(name)) { const program = shellCFlagProgram(words, tokens.original, index); if (program && isProtectedComputerLifecycleCommand(program, readScript)) return true; continue; } if (!SCRIPT_INLINE_INTERPRETER_NAMES.test(name)) continue; const program = scriptInlineProgram(words, index); if (program && inlineProgramTargetsProtectedLifecycle(program)) return true; } for (const scriptPath of executedScriptPaths(tokens)) { if (scriptTargetsProtectedLifecycle(scriptPath, readScript)) return true; } return interpreterTargetsKill(commands, command); } /** * Same guard as {@link isProtectedComputerLifecycleCommand}, after reading * interpreter script files. Unreadable paths fall back to a kill-family filename. * Callers must cap reads at {@link PROTECTED_SCRIPT_SCAN_BYTES}. */ export async function isProtectedComputerLifecycleCommandReadingScripts( command: string, readScript: (scriptPath: string) => Promise, ): Promise { const paths = collectInterpreterScriptPaths(command); if (paths === "dynamic") return true; const cache = new Map(); for (const scriptPath of paths) { try { cache.set(scriptPath, await readScript(scriptPath)); } catch { cache.set(scriptPath, undefined); } } return isProtectedComputerLifecycleCommand(command, (scriptPath) => cache.get(scriptPath)); }