/** * Shell-aware bash risk analysis for the permission gate. * * Parses the command with shell-quote (tokenizes operators, globs, quotes), * splits compound commands on && || ; and analyzes each segment with the * command/argument position respected. This avoids regex false-positives * (e.g. "sudo" in a comment or quoted string) while catching patterns a * flat regex list misses (flags in any position/order, subcommand checks). * * Philosophy: flag destructive operations, not routine work. Regular git * usage (add/commit/push/pull/status/…) is never flagged — only destructive * git subcommands are. Output redirection and plain pipes are NOT flagged * (far too common in normal work); pipe-into-shell is. * * Severity — the authoritative inventory. This high list is also the * subagent hard-block list (see index.ts). * * HIGH (unrecoverable / catastrophic): * - pipe into a shell interpreter (curl … | sh, … | bash/zsh/fish) — RCE * - fork bomb * - sudo (any position: env prefixes, path prefixes, backslash escapes) * - rm/rmdir/unlink with -r/-R/--recursive or -f/--force * - find -delete; find … -exec rm/unlink; … | xargs rm/unlink * - git reset --hard; git clean -f; git push --force(-with-lease); git rm; * git reflog expire; git gc --prune * - dd with of=; mkfs*; newfs_*; wipefs * - diskutil eraseDisk/eraseVolume/zeroDisk/secureErase/reformat * - parted / fdisk / gdisk / sgdisk / gpt / asr / cryptsetup / zpool * - cp/mv/truncate/rsync/tee/shred/install writing into /dev/(sd|hd|nvme|disk) * - redirect into a device file (… > /dev/sda) * - shutdown / reboot / halt / poweroff * - kubectl delete; terraform|pulumi|cdk destroy; aws s3 rm --recursive; * gcloud delete * * MEDIUM (destructive but scoped or recoverable — prompt interactively, * pass in subagents): * - plain rm/rmdir/unlink; truncate; sed -i; perl -pi * - kill/pkill/killall (SIGKILL noted); mv/cp -f * - chmod/chown -R; chmod/chown 777 * - git checkout/restore . or -- * - unparseable command (cannot analyze safely) * * NOT flagged at all: routine git (add/commit/push/pull/status/…), shell * redirection and plain pipes, package installs/removals, reads. * * No pi imports here so this module stays testable standalone. */ import { parse as shellParse } from "shell-quote"; export type Severity = "high" | "medium"; export type Risk = { severity: Severity; reasons: string[] }; type OpToken = { op: string; [k: string]: unknown }; type Token = string | OpToken; function isOpToken(t: Token): t is OpToken { return typeof t === "object" && t !== null && "op" in t; } function tokensToStrings(tokens: Token[]): string[] { return tokens.filter((t): t is string => typeof t === "string"); } function splitOnOps(tokens: Token[], splitOps: string[], keepTrailing = true): Token[][] { const out: Token[][] = []; let current: Token[] = []; for (const t of tokens) { if (isOpToken(t) && splitOps.includes(t.op)) { if (current.length) out.push(current); current = []; continue; } current.push(t); } if (keepTrailing && current.length) out.push(current); return out; } // Flags that mean "recursive" no matter how they were combined (rm -rf, rm -Rf, rm -fr, --recursive) const isRecursiveArg = (a: string) => (a.startsWith("-") && !a.startsWith("--") && /[rR]/.test(a.slice(1))) || a === "--recursive"; const isForcedArg = (a: string) => (a.startsWith("-") && !a.startsWith("--") && /f/.test(a.slice(1))) || a === "--force" || a.startsWith("--force-"); function analyzeSegment(seg: Token[]): Risk | null { const reasons: string[] = []; let severity: Severity = "medium"; const ops = seg.filter(isOpToken).map((o) => o.op); const args = tokensToStrings(seg); if (args.length === 0) return null; // Skip leading VAR=value environment assignments to find the real command // ("FOO=1 rm -rf x" must be treated as rm, not FOO=1), and strip path // prefixes / line-continuation backslashes (/bin/rm, \rm → rm). let cmdStart = 0; while (cmdStart < args.length && /^[A-Za-z_][A-Za-z0-9_]*=/.test(args[cmdStart]!)) cmdStart++; if (cmdStart >= args.length) return null; const cmd = args[cmdStart]!.replace(/^\\+/, "").replace(/^\/.*\//, ""); const rest = args.slice(cmdStart + 1); // ── Remote code execution ──────────────────────────────────────────────── // Anything piped into a shell interpreter. if ( ops.includes("|") && (args.includes("sh") || args.includes("bash") || args.includes("zsh") || args.includes("fish")) ) { severity = "high"; reasons.push("pipe into a shell (possible remote code execution)"); } // ── Privilege escalation ───────────────────────────────────────────────── if (cmd === "sudo") { severity = "high"; reasons.push("sudo (elevated privileges)"); } // ── File deletion ──────────────────────────────────────────────────────── if (cmd === "rm" || cmd === "rmdir" || cmd === "unlink") { reasons.push(`${cmd} (file deletion)`); if (rest.some(isRecursiveArg)) { severity = "high"; reasons.push("recursive delete"); } if (rest.some(isForcedArg)) { severity = "high"; reasons.push("forced delete (-f/--force)"); } if (ops.includes("glob")) { reasons.push("glob expansion (may delete many files)"); } } if (cmd === "find" && rest.includes("-delete")) { severity = "high"; reasons.push("find -delete (bulk deletion)"); } // find -exec rm — bulk deletion that hides the rm from segment analysis. if (cmd === "find" && (rest.includes("-exec") || rest.includes("-execdir"))) { const execIdx = rest.findIndex((a) => a === "-exec" || a === "-execdir"); const execCmd = execIdx !== -1 ? rest[execIdx + 1] : undefined; if (execCmd === "rm" || execCmd === "unlink") { severity = "high"; reasons.push("find -exec rm (bulk deletion)"); } } // xargs rm — same: the rm is an argument, not the segment command. xargs // may appear anywhere in the pipeline, so scan for it and check the first // non-flag argument after it ("ls | xargs -0 rm"). const xargsIdx = args.indexOf("xargs"); if (xargsIdx !== -1) { let k = xargsIdx + 1; while (k < args.length && args[k]!.startsWith("-")) k++; if (args[k] === "rm" || args[k] === "unlink") { severity = "high"; reasons.push("xargs rm (bulk deletion)"); } } // ── Destructive git only — routine git is never flagged ────────────────── if (cmd === "git") { const sub = rest[0]; const subArgs = rest.slice(1); if (sub === "reset" && subArgs.includes("--hard")) { severity = "high"; reasons.push("git reset --hard (discards uncommitted changes)"); } if (sub === "clean" && subArgs.some(isForcedArg)) { severity = "high"; reasons.push("git clean -f (deletes untracked files)"); } if (sub === "push" && subArgs.some(isForcedArg)) { severity = "high"; reasons.push("git push --force (rewrites remote history)"); } if (sub === "rm") { severity = "high"; reasons.push("git rm (deletes tracked files)"); } if (sub === "reflog" && subArgs.includes("expire")) { severity = "high"; reasons.push("git reflog expire (removes recovery history)"); } if (sub === "gc" && subArgs.some((a) => a.startsWith("--prune"))) { severity = "high"; reasons.push("git gc --prune (permanently deletes objects)"); } if ((sub === "checkout" || sub === "restore") && (subArgs.includes(".") || subArgs.includes("--"))) { reasons.push("git checkout/restore . (can overwrite working tree)"); } } // ── Raw disk / filesystem ──────────────────────────────────────────────── if (cmd === "dd" && rest.some((a) => a.startsWith("of="))) { severity = "high"; reasons.push("dd with output file (can overwrite data/devices)"); } if (cmd.startsWith("mkfs") || cmd.startsWith("newfs_")) { severity = "high"; reasons.push("filesystem formatting"); } if (cmd === "wipefs") { severity = "high"; reasons.push("wipefs (disk signature wipe)"); } if ( cmd === "diskutil" && rest.some((a) => ["eraseDisk", "eraseVolume", "zeroDisk", "secureErase", "reformat"].includes(a)) ) { severity = "high"; reasons.push("destructive disk operation (diskutil erase)"); } if (cmd === "parted" || cmd === "fdisk" || cmd === "gdisk" || cmd === "sgdisk" || cmd === "gpt") { reasons.push(`${cmd} (partition table management)`); } if (cmd === "asr") { severity = "high"; reasons.push("asr (Apple Software Restore; can overwrite volumes)"); } if (cmd === "cryptsetup") { severity = "high"; reasons.push("cryptsetup (disk encryption management; luksFormat is unrecoverable)"); } if (cmd === "zpool") { severity = "high"; reasons.push("zpool (ZFS pool management; destroy is unrecoverable)"); } // ── System control ─────────────────────────────────────────────────────── if (cmd === "shutdown" || cmd === "reboot" || cmd === "halt" || cmd === "poweroff") { severity = "high"; reasons.push("system power operation"); } if (cmd === "kill" || cmd === "pkill" || cmd === "killall") { reasons.push(`${cmd} (process termination)`); if (rest.some((a) => a === "-9" || a === "-KILL" || a.startsWith("--signal=KILL"))) { reasons.push("SIGKILL (-9)"); } } // ── Infrastructure teardown ────────────────────────────────────────────── if (cmd === "kubectl" && rest[0] === "delete") { severity = "high"; reasons.push("kubectl delete (resource deletion)"); } if (cmd === "terraform" && rest[0] === "destroy") { severity = "high"; reasons.push("terraform destroy (infrastructure teardown)"); } if ((cmd === "pulumi" || cmd === "cdk") && rest[0] === "destroy") { severity = "high"; reasons.push(`${cmd} destroy (infrastructure teardown)`); } if (cmd === "aws" && rest[0] === "s3" && rest[1] === "rm" && rest.includes("--recursive")) { severity = "high"; reasons.push("aws s3 rm --recursive (bulk deletion)"); } if (cmd === "gcloud" && rest.includes("delete")) { severity = "high"; reasons.push("gcloud delete (resource deletion)"); } // ── Scoped-but-risky file operations ───────────────────────────────────── if (cmd === "truncate") { reasons.push("truncate (can erase file contents)"); } if (cmd === "sed" && (rest.includes("-i") || rest.includes("--in-place"))) { reasons.push("sed -i (in-place file modification)"); } if (cmd === "perl" && (rest.includes("-pi") || (rest.includes("-p") && rest.includes("-i")))) { reasons.push("perl -pi (in-place file modification)"); } if (cmd === "chmod" || cmd === "chown") { if (rest.some(isRecursiveArg)) reasons.push(`${cmd} -R (recursive permission/ownership change)`); if (rest.some((a) => a.includes("777") || a.includes("a+rwx"))) reasons.push(`${cmd} 777 (world-writable)`); } if ((cmd === "mv" || cmd === "cp") && rest.some(isForcedArg)) { reasons.push(`${cmd} -f (can overwrite files)`); } // ── Redirection into a device file ─────────────────────────────────────── for (let i = 0; i < seg.length - 1; i++) { const t = seg[i]!; if (isOpToken(t) && [">", ">>", "2>", "2>>", "&>"].includes(t.op)) { const target = seg[i + 1]; if (typeof target === "string" && /^\/dev\/(sd|hd|nvme|disk)/i.test(target)) { severity = "high"; reasons.push("overwrite device file (redirect to /dev/...)"); } } } // ── Writes into block devices via file-manipulating commands ──────────── // (cp /dev/sda, truncate -s 0 /dev/nvme0, mv x /dev/disk1, … — the plain // redirection check misses these. Readers like ls/stat/cat are unaffected.) const BLOCK_DEV = /^\/dev\/(sd|hd|nvme|disk)/i; if ( ["truncate", "cp", "mv", "rsync", "install", "tee", "shred"].includes(cmd) && rest.some((a) => BLOCK_DEV.test(a)) ) { severity = "high"; reasons.push("write to device file"); } if (reasons.length === 0) return null; return { severity, reasons }; } /** * Root commands of a command string: the first token of every simple command * across && || ; | chains (e.g. "cd a && rm -rf b | wc" → ["cd", "rm", "wc"]). * Returns [] when the command cannot be parsed. */ export function commandRoots(command: string): string[] { let tokens: Token[]; try { tokens = shellParse(command) as Token[]; } catch { return []; } const roots = new Set(); for (const seg of splitOnOps(tokens, ["&&", "||", ";", "|"], true)) { const strs = tokensToStrings(seg); let j = 0; while (j < strs.length && /^[A-Za-z_][A-Za-z0-9_]*=/.test(strs[j]!)) j++; const first = strs[j]?.replace(/^\\+/, "").replace(/^\/.*\//, ""); if (first) roots.add(first); } return [...roots]; } /** * Analyze a full bash command. Returns null when nothing risky was found. */ export function analyzeBashCommand(command: string): Risk | null { let tokens: Token[]; try { tokens = shellParse(command) as Token[]; } catch { // If we cannot parse it, treat it as questionable rather than safe. return { severity: "medium", reasons: ["unparseable command (unable to analyze safely)"] }; } const reasons: string[] = []; let severity: Severity = "medium"; // Fork bomb — tokenizers mangle it, so check the raw string. if (/:\(\)\s*\{\s*:\|:&\s*\}\s*;?:/.test(command)) { severity = "high"; reasons.push("fork bomb"); } for (const seg of splitOnOps(tokens, ["&&", "||", ";"])) { const segRisk = analyzeSegment(seg); if (!segRisk) continue; if (segRisk.severity === "high") severity = "high"; reasons.push(...segRisk.reasons); } const uniq = [...new Set(reasons)]; if (uniq.length === 0) return null; return { severity, reasons: uniq }; }