fix(adapters): block residual kill bypasses on graphical computers (#81)

python -m, stdin programs, and deno/bun eval or kill-named scripts could still tear down a managed graphical desktop after the shell guard tokenized the command.

Co-authored-by: Cursor Agent <cursoragent@cursor.com>
Co-authored-by: cynarAI <cynarAI@users.noreply.github.com>
This commit is contained in:
cynarAI 2026-09-22 22:16:52 +02:00 committed by GitHub
parent 155a42eef2
commit 5a03ae014c
2 changed files with 489 additions and 7 deletions

View File

@ -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");

View File

@ -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<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;
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<string>,
): { 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);
}
/**