* fix(ci): restore browser preview module and onboarding e2e The web bundle imported a browser-preview store that was never added, so production builds and every Playwright signup failed before the shell rendered. Mobile check failed because Expo packages were a patch behind the SDK. Restore the store, type the optional desktop bridge, and walk whichever onboarding step scripted CI actually opens. Co-authored-by: cynarAI <cynarAI@users.noreply.github.com> * fix(ci): stop the blank web client and the send 500 The core barrel pulled git checkout into the browser, so node:child_process crashed the page before sign-up. Serve that module from a node export instead. Add the missing run tool-call budget columns that threads/send writes. Guard an indexed shell flag for typecheck, and expect the files key the desktop preload already exposes. Co-authored-by: cynarAI <cynarAI@users.noreply.github.com> * fix(ci): isolate pnpm setup and the takeover membership The Team Computer takeover journey inserted a space member the default-space trigger had already created, so shared Postgres rejected the unique pair. Self-hosted jobs now keep a per-job pnpm home and store, and artifact uploads no longer fail a job that already passed. Co-authored-by: cynarAI <cynarAI@users.noreply.github.com> * fix(ci): clear typecheck, lint, and unit failures Inbound channel rosters can mark a participant list as authoritative. Answered channel asks stay off the group, and clearing a thread waits until delegated returns land. Lint, unit, and web e2e contracts follow the current shell, composer, and settings. Co-authored-by: cynarAI <cynarAI@users.noreply.github.com> * fix(web): align e2e with the shipped shell and onboarding Web E2E still expected older focus buttons, nested project rows, and settings that never opened MCP. Persist the signed-in space, list non-default spaces under Projekte, and keep the quote control on screen. Co-authored-by: cynarAI <cynarAI@users.noreply.github.com> * fix(web): open group reply from a revealed hover rail The first user row covered its Reply control until the rail accepted pointer events. The group transcript now reveals that rail, then replies. Co-authored-by: cynarAI <cynarAI@users.noreply.github.com> * fix(web): stop the shell render loop and settle computer and mention UI A working bot rewrote preview state on every render, the computer menu never stayed still, and the mention picker did not expose its expanded state or option name. Co-authored-by: cynarAI <cynarAI@users.noreply.github.com> * fix(ci): stop idle probes hanging and accept a follow-up send Background probes walked every process fd and timed out on the shared runner. Linux now tracks the marker with flock and /proc/locks. A new composer draft can send while an earlier send is still refreshing, and group members stay mentionable. Co-authored-by: cynarAI <cynarAI@users.noreply.github.com> * fix(adapters): detect background work when flock ids are padded The idle probe compared /proc/locks with an unpadded device key, so majors or minors under 16 never matched. It deleted the live marker and reported idle while the command still held the lock. Probe by taking the lock, and print the cancel key the way the kernel does. Co-authored-by: cynarAI <cynarAI@users.noreply.github.com> * fix(adapters): stabilize background-work probe tests on self-hosted CI Clear stale marker files before each probe scenario and allow longer polling so launch can acquire flock on busy persistent runners. Co-authored-by: cynarAI <cynarAI@users.noreply.github.com> * fix(web): do not block onboarding on spaces list Render the first onboarding step from me and integration setup alone. Resolve the optional skip-to-app redirect after that so Playwright signups are not stuck on the loading shell when spaces.list is slow. Co-authored-by: cynarAI <cynarAI@users.noreply.github.com> * fix(ci): restore green gates after second main rebase Re-apply biome formatting on Shell/SortableGroup and strict TypeScript assertions in computer-idle tests dropped when skipping the duplicate conflict-resolution commit. Co-authored-by: cynarAI <cynarAI@users.noreply.github.com> * fix(web): wait for onboarding bootstrap before E2E proceeds Expose data-onboarding-step on the onboarding shell and have Playwright wait until loading finishes. Ignore ad-hoc screenshot scripts on self-hosted runners so they do not fail lint. Co-authored-by: cynarAI <cynarAI@users.noreply.github.com> * fix(web): fall back when onboarding bootstrap stalls Race me/integration setup against a timeout and promote loading to the emoji step after 20s so Playwright is not blocked on a hung RPC. Co-authored-by: cynarAI <cynarAI@users.noreply.github.com> * fix(web): render onboarding immediately for E2E signups Start on the emoji step instead of a loading shell that waits on RPC. Bootstrap can still redirect once me and integration setup resolve. Co-authored-by: cynarAI <cynarAI@users.noreply.github.com> * fix(ci): stabilize web e2e on self-hosted runners Per-job pnpm stores broke node_modules symlinks when setup-node restored cached installs, so Vite could not resolve react during dependency optimization. Keep a runner-scoped store, clear stale Vite caches, and pin Playwright to one worker in CI to reduce onboarding RPC contention. Add Vite resolve aliases for react/react-dom, wait for an interactive onboarding step in E2E helpers, and include the React hook smoke spec from main. Co-authored-by: cynarAI <cynarAI@users.noreply.github.com> * fix(web): resolve React aliases through pnpm package layout Root node_modules/react does not exist under pnpm, so Vite aliases pointed at missing paths and broke production builds and the Playwright webServer. Resolve react and react-dom from the web package and map jsx-runtime to the actual entry files. Co-authored-by: cynarAI <cynarAI@users.noreply.github.com> * fix(core): type the lazy child_process spawn cache Main's lazy require left spawn typed as null after assignment, which broke Typecheck on the rebased branch. Co-authored-by: cynarAI <cynarAI@users.noreply.github.com> * fix(db): mock git-checkout export path in unit tests Vitest resolves vi.mock against package exports; the .js suffix is not declared for @rakazo/core/git-checkout. Co-authored-by: cynarAI <cynarAI@users.noreply.github.com> * fix(web): stabilize E2E after main sidebar and onboarding changes Use first() for duplicate transcript lines, wait for interactive onboarding before model-required assertions, and allow project sidebar to settle after create. Extend the Playwright job timeout so the single-worker suite can finish. Co-authored-by: cynarAI <cynarAI@users.noreply.github.com> * fix(web): restore Projekte sidebar and integrations MCP entry Use the default space for Unterhaltungen/Projekte separation instead of the active space header, persist the bootstrapped space in localStorage, wire SettingsOverlay to MCP management, and compare hover tones on inactive rows. Co-authored-by: cynarAI <cynarAI@users.noreply.github.com> * fix(web): satisfy biome format on homeSpaceId line Co-authored-by: cynarAI <cynarAI@users.noreply.github.com> * fix(web): align sidebar E2E with nested Projekte shell layout Scope Unterhaltungen assertions to conversation rows only, target project-space Chief actions under .sidebar-projekte, and stabilize hover-tone polling. Co-authored-by: cynarAI <cynarAI@users.noreply.github.com> * fix: satisfy lint after main merge (format and unused var) Co-authored-by: cynarAI <cynarAI@users.noreply.github.com> * fix(web): show project-space 1:1 bots in Projekte sidebar Include lone bots in projectSidebarGroups, render bot rows in Projekte, and stabilize E2E for nested sidebar layout and duplicate group entries. Co-authored-by: cynarAI <cynarAI@users.noreply.github.com> * fix(web): disambiguate duplicate group sidebar buttons in E2E Co-authored-by: cynarAI <cynarAI@users.noreply.github.com> * fix(web): align spaces and group-chats E2E with Projekte sidebar IA Co-authored-by: cynarAI <cynarAI@users.noreply.github.com> * fix(web): dedupe Projekte sidebar rows and stabilize E2E specs - Deduplicate project space and group-chat entries in projectSidebarGroups - Scope group-chats/spaces E2E clicks to first Projekte match after IA split - Wait for shell ready before mention-picker keyboard test; restore main locators - Fix PrivacySettingsOverlay lint and align biome schema with CLI 2.5.13 Co-authored-by: cynarAI <cynarAI@users.noreply.github.com> * fix(web): restrict project drag handle and stabilize space E2E - Move SortableGroup drag listeners to ProjectDragGrip only so sidebar clicks work - Scope space delete actions to project-space rows; wait for menu in E2E - Switch group settings E2E via direct navigation after unsaved draft check - Merge main (DataTrustPanel) and fix Shell import order for CI lint Co-authored-by: cynarAI <cynarAI@users.noreply.github.com> * fix(web): stabilize message hover Escape assertion in E2E Assert the More menu closes and the hover rail stays visible instead of requiring focus restoration, which is flaky across Chromium builds. Co-authored-by: cynarAI <cynarAI@users.noreply.github.com> * chore: format apps/api/src/app.ts after main merge Co-authored-by: cynarAI <cynarAI@users.noreply.github.com> * fix(web): stabilize team-computer E2E send after bot switch Wait for shell ready and use the Send button instead of Enter after switching bots and closing the computer overlay. The prior run timed out on threads/send with only an apps/api format commit in between. Co-authored-by: cynarAI <cynarAI@users.noreply.github.com> --------- Co-authored-by: Cursor Agent <cursoragent@cursor.com> Co-authored-by: cynarAI <cynarAI@users.noreply.github.com>
1067 lines
35 KiB
TypeScript
1067 lines
35 KiB
TypeScript
/**
|
|
* 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<Record<string, string>> = {
|
|
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<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 = "";
|
|
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<string | undefined>,
|
|
): Promise<boolean> {
|
|
const paths = collectInterpreterScriptPaths(command);
|
|
if (paths === "dynamic") return true;
|
|
const cache = new Map<string, string | undefined>();
|
|
for (const scriptPath of paths) {
|
|
try {
|
|
cache.set(scriptPath, await readScript(scriptPath));
|
|
} catch {
|
|
cache.set(scriptPath, undefined);
|
|
}
|
|
}
|
|
return isProtectedComputerLifecycleCommand(command, (scriptPath) => cache.get(scriptPath));
|
|
}
|