From 155a42eef2c1244243ad03891339712ac70e758b Mon Sep 17 00:00:00 2001 From: cynarAI <155274210+cynarAI@users.noreply.github.com> Date: Tue, 22 Sep 2026 22:15:47 +0200 Subject: [PATCH] fix(adapters): block kill/pkill via interpreter script files (#79) The desktop-protection shell guard inspected shell syntax and nested bash -c, but not script files passed to python, node, perl, ruby, or php. Read small workspace scripts and reject kill-family contents, and fall back to a kill/pkill/killall/xkill filename when the path cannot be read. Co-authored-by: Cursor Agent Co-authored-by: cynarAI --- .../src/executor-computer-safety.test.ts | 93 +++- packages/adapters/src/executor-shell-guard.ts | 493 +++++++++++++++++- packages/adapters/src/executor.ts | 43 +- 3 files changed, 604 insertions(+), 25 deletions(-) diff --git a/packages/adapters/src/executor-computer-safety.test.ts b/packages/adapters/src/executor-computer-safety.test.ts index 32f9df7..9c4f58b 100644 --- a/packages/adapters/src/executor-computer-safety.test.ts +++ b/packages/adapters/src/executor-computer-safety.test.ts @@ -1,5 +1,9 @@ import { describe, expect, it } from "vitest"; import { isProtectedComputerLifecycleCommand } from "./executor.js"; +import { + isProtectedComputerLifecycleCommandReadingScripts, + protectedScriptWorkspacePath, +} from "./executor-shell-guard.js"; describe("computer lifecycle command guard", () => { it("rejects commands that can destroy a graphical bot's desktop", () => { @@ -51,6 +55,51 @@ describe("computer lifecycle command guard", () => { } }); + it("rejects kill-family teardown in interpreter script files", () => { + const files = new Map([ + ["/tmp/kill.py", "import os\nos.system('pkill chromium')\n"], + ["evil.js", "process.kill(1, 'SIGKILL')\n"], + ["./scripts/stop.py", "import subprocess\nsubprocess.call(['killall', 'chrome'])\n"], + ]); + const readScript = (scriptPath: string) => files.get(scriptPath); + + for (const command of [ + "python /tmp/kill.py", + "node evil.js", + "python3 ./scripts/stop.py", + "python3.12 ./scripts/stop.py", + "bash -c 'python evil.js'", + "bash -lc 'node evil.js'", + "node -r evil.js -e 'console.log(1)'", + ]) { + expect(isProtectedComputerLifecycleCommand(command, readScript)).toBe(true); + } + // Filename heuristic when the executor cannot read the script (container /tmp). + expect(isProtectedComputerLifecycleCommand("python /tmp/kill.py")).toBe(true); + expect(isProtectedComputerLifecycleCommand("perl /tmp/pkill.pl")).toBe(true); + expect(isProtectedComputerLifecycleCommand("node /tmp/killall.js")).toBe(true); + expect(isProtectedComputerLifecycleCommand("ruby xkill.rb")).toBe(true); + expect(isProtectedComputerLifecycleCommand("/usr/bin/python3 /tmp/kill.py")).toBe(true); + }); + + it("prefers script contents over the filename heuristic", async () => { + await expect( + isProtectedComputerLifecycleCommandReadingScripts( + "python kill.py", + async () => "print('hello')\n", + ), + ).resolves.toBe(false); + await expect( + isProtectedComputerLifecycleCommandReadingScripts("python kill.py", async () => undefined), + ).resolves.toBe(true); + await expect( + isProtectedComputerLifecycleCommandReadingScripts( + "bash -c 'node evil.js'", + async () => "process.kill(1, 'SIGKILL')\n", + ), + ).resolves.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( @@ -60,8 +109,48 @@ describe("computer lifecycle command guard", () => { 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 interpreter scripts available", () => { + const files = new Map([ + ["scripts/check.js", "console.log('ok')\n"], + ["scripts/train.py", "print('epochs')\n"], + ["kill.py", "print('hello')\n"], + ["notes/harmless.py", "value = 'kill switch'\nprint(value)\n"], + ["evil.js", "console.log('not a lifecycle command')\n"], + ]); + const readScript = (scriptPath: string) => files.get(scriptPath); + + expect(isProtectedComputerLifecycleCommand("node scripts/check.js", readScript)).toBe(false); + expect( + isProtectedComputerLifecycleCommand("python3 scripts/train.py --epochs 2", readScript), + ).toBe(false); + expect( + isProtectedComputerLifecycleCommand("python -W ignore scripts/train.py", readScript), + ).toBe(false); + expect(isProtectedComputerLifecycleCommand("python kill.py", readScript)).toBe(false); + expect(isProtectedComputerLifecycleCommand("python notes/harmless.py", readScript)).toBe(false); + expect(isProtectedComputerLifecycleCommand("node evil.js", readScript)).toBe(false); + expect(isProtectedComputerLifecycleCommand("echo python /tmp/kill.py")).toBe(false); + expect(isProtectedComputerLifecycleCommand("node /tmp/plot.js")).toBe(false); + expect(isProtectedComputerLifecycleCommand("python -c 'print(\"hello\")'")).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"); + expect(protectedScriptWorkspacePath("stop.py", "bots/bot-1")).toBe("bots/bot-1/stop.py"); + expect(protectedScriptWorkspacePath("evil.js", "/home/rakazo/notes")).toBe("notes/evil.js"); + expect(protectedScriptWorkspacePath("/home/rakazo/scripts/stop.py", undefined)).toBe( + "scripts/stop.py", + ); + expect(protectedScriptWorkspacePath("/home/user/rakazo-home/evil.js", undefined)).toBe( + "evil.js", + ); + expect(protectedScriptWorkspacePath("evil.js", "/tmp")).toBe("tmp/evil.js"); + expect(protectedScriptWorkspacePath("/tmp/kill.py", undefined)).toBeUndefined(); + expect(protectedScriptWorkspacePath("../secret.py", "notes")).toBeUndefined(); + expect(protectedScriptWorkspacePath("evil.js", "/home/user")).toBe("evil.js"); }); it("keeps ordinary shell work available", () => { diff --git a/packages/adapters/src/executor-shell-guard.ts b/packages/adapters/src/executor-shell-guard.ts index 1ba138a..bb7396d 100644 --- a/packages/adapters/src/executor-shell-guard.ts +++ b/packages/adapters/src/executor-shell-guard.ts @@ -6,13 +6,31 @@ * 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)$/; -/** Lifecycle kill tokens inside an interpreter program string (not shell words). */ -const INLINE_PROGRAM_LIFECYCLE_KILL = +/** + * 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)\.kill\b|(['"`])kill(?:\s+-\w+|\s+\d+|\1)|\[\s*['"]kill['"]/i; +/** 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", @@ -37,12 +55,18 @@ const SAFE_SHELL_CONTROL_OPS = new Set([ "&>", ]); -function shellCFlagProgram(words: string[], interpreterIndex: number): string | undefined { +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 word.slice("--command=".length); + 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 words[index + 1]; + if (word === "--command" || /^-[^-]*c/.test(word)) return original[index + 1]; } return undefined; } @@ -71,7 +95,397 @@ function scriptInlineProgram(words: string[], interpreterIndex: number): string 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); + 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 ("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); } function preserveShellCommandBoundaries(command: string): string { @@ -100,7 +514,16 @@ function preserveShellCommandBoundaries(command: string): string { return result; } -function tokenizeProtectedShellCommand(command: string): string[] | "dynamic" { +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[]; +} + +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 @@ -112,16 +535,21 @@ function tokenizeProtectedShellCommand(command: string): string[] | "dynamic" { { splitUnquoted: true }, ); const words: string[] = []; + const original: string[] = []; + const commandIndexes: number[] = []; 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 word = entry.toLowerCase(); + 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. - words.push(word === "." && (!commandPosition || redirectTarget) ? "./" : word); + const pathDot = folded === "." && (!commandPosition || redirectTarget); + const word = pathDot ? "./" : folded; + words.push(word); + original.push(pathDot ? "./" : entry); if (redirectTarget) { redirectTarget = false; continue; @@ -145,7 +573,10 @@ function tokenizeProtectedShellCommand(command: string): string[] | "dynamic" { /^[a-z_][a-z0-9_]*=/.test(word)) ) { // Shell prefixes and assignments leave the command word pending. - } else commandPosition = false; + } else { + if (commandPosition) commandIndexes.push(words.length - 1); + commandPosition = false; + } continue; } if ("expansion" in entry) { @@ -155,6 +586,7 @@ function tokenizeProtectedShellCommand(command: string): string[] | "dynamic" { } if ("op" in entry && entry.op === "glob") { words.push(entry.pattern.toLowerCase()); + original.push(entry.pattern); continue; } if ("op" in entry && SAFE_SHELL_CONTROL_OPS.has(entry.op)) { @@ -166,15 +598,19 @@ function tokenizeProtectedShellCommand(command: string): string[] | "dynamic" { } return "dynamic"; } - return words; + return { words, original, commandIndexes }; } catch { return "dynamic"; } } -export function isProtectedComputerLifecycleCommand(command: string): boolean { - const words = tokenizeProtectedShellCommand(command); - if (words === "dynamic") return true; +export function isProtectedComputerLifecycleCommand( + command: string, + readScript?: ProtectedScriptReader, +): boolean { + const tokens = tokenizeProtectedShellCommand(command); + if (tokens === "dynamic") return true; + const { words } = tokens; const commandNames = words.map((word) => word.split("/").at(-1)); if (commandNames.some((word) => /^(?:kill|pkill|killall|xkill)$/.test(word ?? ""))) { @@ -202,13 +638,38 @@ 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)) { - const program = shellCFlagProgram(words, index); - if (program && isProtectedComputerLifecycleCommand(program)) return true; + 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 false; } + +/** + * 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)); +} diff --git a/packages/adapters/src/executor.ts b/packages/adapters/src/executor.ts index 368481e..13a5ec4 100644 --- a/packages/adapters/src/executor.ts +++ b/packages/adapters/src/executor.ts @@ -170,7 +170,11 @@ import { } from "./executor-computer.js"; import { recordEffect, completeEffect, uncertainEffectError } from "./executor-effects.js"; import { deploymentKeyFor, resolveModelKey, withModelCredentialLock } from "./executor-model.js"; -import { isProtectedComputerLifecycleCommand } from "./executor-shell-guard.js"; +import { + isProtectedComputerLifecycleCommandReadingScripts, + PROTECTED_SCRIPT_SCAN_BYTES, + protectedScriptWorkspacePath, +} from "./executor-shell-guard.js"; import { PAGE_BROWSER_TOOL_NAMES, filterPageBrowserTools, @@ -2338,17 +2342,42 @@ export function createRunExecutor(deps: ExecutorDeps) { } if (name === "shell") { const command = String(args.command ?? args.cmd ?? ""); - if (graphical && isProtectedComputerLifecycleCommand(command)) { - return finish({ - error: - "This command was not run: the desktop-protection guard detected a protected command or shell syntax it cannot inspect. Shell access is still available. For ordinary repository work, use direct commands with explicit paths, without sourcing or command substitution. Do not stop or restart browser/desktop processes.", - }); - } const cwd = resolveBotWorkspaceCwd( computerMode, bot.id, args.cwd ? String(args.cwd) : undefined, ); + if ( + graphical && + (await isProtectedComputerLifecycleCommandReadingScripts( + command, + async (scriptPath) => { + const stored = protectedScriptWorkspacePath(scriptPath, cwd); + if (!stored) return undefined; + if (teamBotIds) { + try { + assertTeamWorkspacePathAllowed(stored, teamBotIds); + } catch { + return undefined; + } + } + try { + const bytes = await deps.sandbox.readFile(computer, stored, context, { + maxBytes: PROTECTED_SCRIPT_SCAN_BYTES, + }); + if (bytes.byteLength > PROTECTED_SCRIPT_SCAN_BYTES) return undefined; + return new TextDecoder("utf-8", { fatal: false }).decode(bytes); + } catch { + return undefined; + } + }, + )) + ) { + return finish({ + error: + "This command was not run: the desktop-protection guard detected a protected command or shell syntax it cannot inspect. Shell access is still available. For ordinary repository work, use direct commands with explicit paths, without sourcing or command substitution. Do not stop or restart browser/desktop processes.", + }); + } if (teamBotIds) { if (cwd) assertTeamWorkspacePathAllowed(cwd, teamBotIds); assertTeamShellCommandAllowed(command, teamBotIds);