From 1741cdeea66ba79b16fe660a37d66ecb8a3da72e Mon Sep 17 00:00:00 2001 From: cynarAI <155274210+cynarAI@users.noreply.github.com> Date: Tue, 22 Sep 2026 22:09:59 +0200 Subject: [PATCH] fix(adapters): block kill/pkill via python -c and node -e (#78) The desktop-protection shell guard only walked nested shell -c programs, so interpreter inline code could still invoke kill/pkill. Inspect python/ node/perl/ruby/php -c/-e/-r payloads for the same lifecycle kills. Co-authored-by: Cursor Agent Co-authored-by: cynarAI --- .../src/executor-computer-safety.test.ts | 32 ++++++++++++++ packages/adapters/src/executor-shell-guard.ts | 43 +++++++++++++++++-- 2 files changed, 72 insertions(+), 3 deletions(-) diff --git a/packages/adapters/src/executor-computer-safety.test.ts b/packages/adapters/src/executor-computer-safety.test.ts index 9cb99ee..32f9df7 100644 --- a/packages/adapters/src/executor-computer-safety.test.ts +++ b/packages/adapters/src/executor-computer-safety.test.ts @@ -32,6 +32,38 @@ describe("computer lifecycle command guard", () => { } }); + it("rejects kill/pkill invoked through interpreter inline programs", () => { + for (const command of [ + `python -c 'import os; os.system("pkill chromium")'`, + `python3 -c 'import subprocess; subprocess.call(["pkill", "chromium"])'`, + `python -c 'import os; os.kill(1234, 9)'`, + `python3 -Oc 'import os; os.system("killall chrome")'`, + `node -e 'require("child_process").execSync("pkill chromium")'`, + `node --eval 'process.kill(1234, "SIGKILL")'`, + `node --eval=process.kill(1,"SIGKILL")`, + `nodejs -e 'require("child_process").spawnSync("xkill")'`, + `perl -e 'system("pkill chromium")'`, + `ruby -e 'system("pkill chromium")'`, + `php -r 'system("pkill chromium");'`, + `bash -c 'python -c "import os; os.system(\\"pkill chromium\\")"'`, + ]) { + expect(isProtectedComputerLifecycleCommand(command)).toBe(true); + } + }); + + it("keeps ordinary interpreter inline programs available", () => { + expect(isProtectedComputerLifecycleCommand(`python -c 'print("hello")'`)).toBe(false); + expect(isProtectedComputerLifecycleCommand(`python3 -c 'value = "$HOME"; print(value)'`)).toBe( + false, + ); + expect(isProtectedComputerLifecycleCommand(`node -e 'console.log(1 + 1)'`)).toBe(false); + expect(isProtectedComputerLifecycleCommand(`node -e 'const x = "kill switch"; console.log(x)'`)).toBe( + false, + ); + expect(isProtectedComputerLifecycleCommand("node scripts/check.js")).toBe(false); + expect(isProtectedComputerLifecycleCommand("python3 scripts/train.py --epochs 2")).toBe(false); + }); + it("keeps ordinary shell work available", () => { expect(isProtectedComputerLifecycleCommand("pwd && ls -la")).toBe(false); expect(isProtectedComputerLifecycleCommand("node scripts/check.js")).toBe(false); diff --git a/packages/adapters/src/executor-shell-guard.ts b/packages/adapters/src/executor-shell-guard.ts index 9c0ad40..1ba138a 100644 --- a/packages/adapters/src/executor-shell-guard.ts +++ b/packages/adapters/src/executor-shell-guard.ts @@ -8,6 +8,11 @@ import { parse as parseShellCommand } from "shell-quote"; 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)$/; +/** Lifecycle kill tokens inside an interpreter program string (not shell words). */ +const INLINE_PROGRAM_LIFECYCLE_KILL = + /\b(?:pkill|killall|xkill)\b|(?:os|process)\.kill\b|(['"`])kill(?:\s+-\w+|\s+\d+|\1)|\[\s*['"]kill['"]/i; const STATIC_SHELL_EXPANSIONS: Readonly> = { HOME: "/home/rakazo", LOGNAME: "rakazo", @@ -42,6 +47,33 @@ function shellCFlagProgram(words: string[], interpreterIndex: number): string | 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 INLINE_PROGRAM_LIFECYCLE_KILL.test(program); +} + function preserveShellCommandBoundaries(command: string): string { let quote: "'" | '"' | undefined; let result = ""; @@ -169,9 +201,14 @@ export function isProtectedComputerLifecycleCommand(command: string): boolean { for (let index = 0; index < words.length; index += 1) { const name = words[index]?.split("/").at(-1) ?? ""; - if (!SHELL_INTERPRETER_NAMES.test(name)) continue; - const program = shellCFlagProgram(words, index); - if (program && isProtectedComputerLifecycleCommand(program)) return true; + if (SHELL_INTERPRETER_NAMES.test(name)) { + const program = shellCFlagProgram(words, index); + if (program && isProtectedComputerLifecycleCommand(program)) return true; + continue; + } + if (!SCRIPT_INLINE_INTERPRETER_NAMES.test(name)) continue; + const program = scriptInlineProgram(words, index); + if (program && inlineProgramTargetsProtectedLifecycle(program)) return true; } return false; }