/** * Layer 2 — pre-flight interactive-command classification (R-BASH-6/7/8). * * Pure, no I/O, so the false-positive surface is unit-testable. This is the part * of BashGuard most likely to be wrong, and R-BASH-8 is explicit about the cost: * "false positives get the guard disabled". * * Two invariants throughout: * - Quoted text is invisible. `echo "run npm run dev"` is not a dev server. * - Every segment of `;`, `&&`, `||` and every stage of a pipeline is judged on * its own leading command. `cat x | less` is interactive even though `cat` * is not. * * The verdict is a block, never a warning (R-BASH-7), and every block names the * working form. A block that does not teach the correct command just costs a * turn and teaches nothing. */ /** Why a command was refused, and what to run instead. */ export interface InteractiveVerdict { /** Coarse class from the R-BASH-6 table, for logging and tests. */ kind: "always" | "flag" | "prompts" | "long-running"; /** The matched command form, e.g. "git commit" or "npm run dev". */ pattern: string; /** Model-facing message. Always names the non-interactive alternative. */ message: string; } /** * Placeholder standing in for a quoted span. * * Blanking quoted spans is lossy in a way that matters here: * `ssh host 'ls /tmp'` and `ssh host` both reduce to `ssh host`, and the first is * the *recommended* non-interactive form. Blanking would make the recommended * form the blocked one — a false positive of exactly the kind R-BASH-8 warns * about. This placeholder keeps the operator-blindness R-BASH-8 requires while * preserving the fact that an argument was there. */ const QUOTED = "\u0000q"; /** Split shell chains so every command and pipeline stage is classified. */ function splitSegments(command: string): string[] { return command .split(/(?:\|\||&&|[;|&\n])/g) .map((segment) => segment.trim()) .filter((segment) => segment.length > 0); } /** Like `stripQuoted`, but each quoted span leaves one opaque, operator-free token. */ export function maskQuoted(command: string): string { let out = ""; let quote: '"' | "'" | undefined; let escaped = false; for (const ch of command) { if (escaped) { escaped = false; out += "x"; continue; } if (ch === "\\") { escaped = true; continue; } if (quote !== undefined) { if (ch === quote) quote = undefined; continue; } if (ch === '"' || ch === "'") { quote = ch; // Emitted once at the opening quote, so an empty '' still yields a token. out += QUOTED; continue; } out += ch; } return out; } /** Segments with quoted spans masked rather than blanked. Splits on the same operators as bash-gate. */ function maskedSegmentsOf(command: string): string[] { return splitSegments(maskQuoted(command)); } /** * Transparent wrappers whose own name is never the interesting command. * * `command less f` and `env less f` were trivial layer-2 bypasses (BUG-7); the * real command is the next word. `sudo` is deliberately NOT in this set — it is * itself a signal and is matched separately, on the leading position only. * * `nohup` is deliberately NOT here either: `nohup npm run dev > log 2>&1 &` is * the detached recipe this very classifier tells the model to use. Looking * through `nohup` would block the recommended form — the exact false positive * R-BASH-8 warns about. * * `timeout` and `nice` take a leading numeric argument, so their skip rule needs * the argument-consuming logic in `words()` rather than a bare name match. */ const WRAPPERS = new Set(["command", "env", "setsid", "stdbuf", "time", "ionice"]); /** Wrappers that consume a leading value argument before the real command. */ const VALUE_WRAPPERS = new Set(["timeout", "nice"]); /** * Words of a segment with leading env assignments, group punctuation and * transparent wrappers stripped, so the returned array starts at the command * that actually runs. * * `sudo` is kept when it leads, because `sudo` itself is the verdict. */ function words(segment: string): string[] { const all = segment .split(/\s+/) .filter((w) => w.length > 0) // `( vim f )` and `{ vim f; }` reduce to `vim f`: a group or subshell does // not change which program runs, and both were bypasses (BUG-7). The // separators were already consumed by splitSegments, so only the bare // punctuation tokens remain. .filter((w) => w !== "(" && w !== ")" && w !== "{" && w !== "}"); let i = 0; while (i < all.length) { const w = all[i]; if (w === undefined) break; if (/^[A-Za-z_][A-Za-z0-9_]*=/.test(w)) { i += 1; continue; } const base = baseOf(w); // `sudo` must stay visible, but only in the leading slot: see the note on // WRAPPERS. Skipping a wrapper cannot expose a later `sudo` as leading, // because a wrapper's own arguments are the command it runs. if (WRAPPERS.has(base)) { // `command -v vim` / `command -V vim` do not RUN vim, they print where it // is — exactly like `which vim`. Looking through them would block a pure // lookup, so this wrapper stops being transparent when asked to describe. if (base === "command" && all.slice(i + 1).some((x) => x === "-v" || x === "-V")) { return []; } i += 1; // The wrapper's own flags are not the command's: `stdbuf -o0 less f` runs // `less`. Attached values (`-o0`) and separate ones are both covered, // because a bare non-flag word is where the real command starts. while (i < all.length) { const next = all[i]; if (next === undefined || !next.startsWith("-")) break; i += 1; } continue; } if (VALUE_WRAPPERS.has(base)) { i += 1; // `timeout 5 cmd`, `timeout -k 2 5 cmd`, `nice -n 10 cmd`: skip the // wrapper's flags and any bare numeric values (timeout takes two when // `-k` is used), then stop at the first non-numeric word — the command. while (i < all.length) { const next = all[i]; if (next === undefined) break; if (next.startsWith("-")) { i += 1; continue; } // A duration is numeric, optionally with an s/m/h/d suffix; anything // else is already the command. if (/^\d+(\.\d+)?[smhd]?$/.test(next)) { i += 1; continue; } break; } continue; } break; } return all.slice(i); } /** Basename of the command word, so `/usr/bin/less` matches `less`. */ function baseOf(word: string): string { return word.split("/").pop() ?? word; } /** Non-flag arguments after the command word. */ function argsOf(w: string[]): string[] { return w.slice(1).filter((x) => !x.startsWith("-")); } function hasFlag(w: string[], ...flags: string[]): boolean { return w.some((x) => flags.includes(x)); } /** True when any word starts with one of the given short-flag letters, e.g. `-im` contains `-i`. */ function hasShortFlag(w: string[], letter: string): boolean { return w.some((x) => /^-[A-Za-z]+$/.test(x) && x.slice(1).includes(letter)); } /** * True when the command is only asking a binary to describe itself. * * `less --version`, `man --help` and `vim --version` all print to stdout and * exit 0 — they never open a TTY. Blocking them was BUG-4, and the block message * ("use cat/head/rg") did not even name a working alternative, so it violated * R-BASH-7 as well. Applied to every always-interactive family, not just REPLs. * * `-v`/`-V` are excluded deliberately: for a pager or editor they are not * version flags (`less -V` is, but `vim -v` is ex-mode and `grep -v` is invert), * so honouring them would trade a false positive for a false negative on the * ambiguous letter. `--version`/`--help` are unambiguous across every tool here. */ function isSelfDescribing(w: string[]): boolean { return w.slice(1).some((x) => x === "--version" || x === "--help"); } /** * Commands where a short `-w` genuinely means `--watch`. * * BUG-1: `hasFlag(w, "--watch", "-w")` blocked `grep -w` (word-regexp), * `wc -w` (word count), `chmod -w` (remove write), `sort -w`, `curl -w` * (write-out format), `xargs -w`, `man -w` (show path), and critically * `npm test -w pkg` / `pnpm -w install`, where `-w` selects a workspace. * * Verified by reading each tool's own `--help`: `tsc -w` and `rollup -w` are * watch; `jest`, `vite` and `esbuild` have no short `-w` at all. So short `-w` * is allowlisted per tool rather than assumed, and `--watch` alone is generic. */ const SHORT_W_IS_WATCH = new Set(["tsc", "rollup"]); const DETACH_RECIPE = (command: string, log: string): string => `To run it in the background:\n nohup ${command} > ${log} 2>&1 &\n sleep 5 && tail -20 ${log}\nThen poll ${log}. Remember to kill the process when done.`; /** Editors and pagers: block unconditionally, there is no non-interactive form. */ const EDITORS = new Map([ ["vim", "read the file with the read tool, or edit it with the edit tool"], ["vi", "read the file with the read tool, or edit it with the edit tool"], ["nvim", "read the file with the read tool, or edit it with the edit tool"], ["nano", "read the file with the read tool, or edit it with the edit tool"], ["pico", "read the file with the read tool, or edit it with the edit tool"], ["emacs", "read the file with the read tool, or edit it with the edit tool"], ["ed", "read the file with the read tool, or edit it with the edit tool"], ]); const PAGERS = new Map([ ["less", "drop the pager and use `cat`, `head -n`, or `rg`"], ["more", "drop the pager and use `cat`, `head -n`, or `rg`"], ["most", "drop the pager and use `cat`, `head -n`, or `rg`"], ["man", "use ` --help`, or `man | cat`"], ]); /** Full-screen monitors. No useful captured output even if they did exit. */ const TUI_MONITORS = new Map([ ["top", "use `ps aux --sort=-%cpu | head -20`"], ["htop", "use `ps aux --sort=-%cpu | head -20`"], ["btop", "use `ps aux --sort=-%cpu | head -20`"], ["atop", "use `ps aux --sort=-%cpu | head -20`"], ["lazygit", "use plain `git` subcommands"], ["tig", "use `git log --oneline` (GIT_PAGER is already cat)"], ["gitui", "use plain `git` subcommands"], ]); /** REPLs: interactive only when given no script and no eval flag. */ const REPLS = new Map([ ["python", ["-c", "-m"]], ["python3", ["-c", "-m"]], ["node", ["-e", "-p", "--eval", "--print"]], ["irb", ["-e"]], ["ruby", ["-e"]], ["php", ["-r"]], ["ghci", []], ["sqlite3", []], ["bc", []], ["R", ["-e"]], ]); /** Long-running by nature: named script/binary -> a log path for the recipe. */ const WATCHERS = new Map([ ["nodemon", "/tmp/nodemon.log"], ["vite", "/tmp/vite.log"], ["webpack", "/tmp/webpack.log"], ["rollup", "/tmp/rollup.log"], ["esbuild", "/tmp/esbuild.log"], ["tsc-watch", "/tmp/tsc-watch.log"], ["watchexec", "/tmp/watchexec.log"], ["watch", "/tmp/watch.log"], ["serve", "/tmp/serve.log"], ["http-server", "/tmp/http-server.log"], ["ng", "/tmp/ng.log"], ["nuxt", "/tmp/nuxt.log"], ["next", "/tmp/next.log"], ["gatsby", "/tmp/gatsby.log"], ["remix", "/tmp/remix.log"], ["jekyll", "/tmp/jekyll.log"], ["hugo", "/tmp/hugo.log"], ]); /** Script names that keep running until killed, for `npm run