#!/usr/bin/env node // Hook: pre-bash-executor.mjs // Event: PreToolUse (Bash) // Purpose: Block or warn about destructive shell commands. Wired universally // (every Voyage session, not just execution) by deliberate decision — these // are session-agnostic safety rails (rm -rf /, fork bombs, …). The CC if: // path-scoping mechanism now works, but narrowing to execute-only would only // weaken protection with no offsetting benefit. See cc-upgrade matrix CC-15/F2. // // Protocol: // - Read JSON from stdin: { tool_name, tool_input } // - tool_input.command — the shell command string // - BLOCK (exit 2): catastrophic/irreversible operations // - WARN (exit 0): risky but recoverable operations — advisory to stderr // - Allow (exit 0): everything else // // Based on llm-security's pre-bash-destructive.mjs with executor-specific additions. // bash-normalize logic copied inline (MIT) — cannot import from separate plugin. import { readFileSync } from 'node:fs'; // --------------------------------------------------------------------------- // Bash normalization (from llm-security/scanners/lib/bash-normalize.mjs) // Strips bash evasion techniques: empty quotes, ${} expansion, backslash splitting. // --------------------------------------------------------------------------- function normalizeBashExpansion(cmd) { if (!cmd || typeof cmd !== 'string') return cmd || ''; let result = cmd // Strip empty single quotes: w''get -> wget .replace(/''/g, '') // Strip empty double quotes: r""m -> rm .replace(/""/g, '') // Single-char ${x} -> x (evasion: c${u}rl -> curl, assumes x=x) .replace(/\$\{(\w)\}/g, '$1') // Multi-char ${ANYTHING} -> '' (unknown value, strip entirely) .replace(/\$\{[^}]*\}/g, '') // Strip backtick subshell with empty/whitespace content .replace(/`\s*`/g, ''); // Iteratively strip backslash between word chars (c\u\r\l needs 2 passes) let prev; do { prev = result; result = result.replace(/(\w)\\(\w)/g, '$1$2'); } while (result !== prev); return result; } // --------------------------------------------------------------------------- // BLOCK rules — exit 2, command is not executed. // --------------------------------------------------------------------------- const BLOCK_RULES = [ { name: 'Filesystem root/home destruction (rm -rf /)', // Matches rm with both -r and -f flags targeting /, ~, or $HOME. // Uses (?:\s|$) instead of \b because / and ~ are non-word chars. pattern: /\brm\s+(?:-[a-zA-Z]*f[a-zA-Z]*\s+|--force\s+)*-[a-zA-Z]*r[a-zA-Z]*\s+(?:\/|~|\$HOME)(?:\s|$)/, description: '`rm -rf /`, `rm -rf ~`, and `rm -rf $HOME` would destroy the filesystem ' + 'or home directory. Unconditionally blocked.', }, { name: 'World-writable chmod (chmod 777)', pattern: /\bchmod\s+(?:-[a-zA-Z]+\s+)*777\b/, description: '`chmod 777` grants full read/write/execute to all users. ' + 'Use minimal permissions (e.g. 644, 755).', }, { name: 'Pipe-to-shell (curl|bash, wget|sh)', pattern: /(?:curl|wget)\b[^|]*\|\s*(?:bash|sh|zsh|ksh|dash)\b/, description: 'Piping remote content into a shell allows arbitrary remote code execution. ' + 'Download first, review, then execute.', }, { name: 'Fork bomb', pattern: /:\(\)\s*\{\s*:\s*\|\s*:&\s*\}\s*;?\s*:/, description: 'Fork bomb — exhausts system process resources. Blocked.', }, { name: 'Filesystem format (mkfs)', pattern: /\bmkfs(?:\.[a-z0-9]+)?\s/, description: '`mkfs` formats a filesystem, destroying all data. Blocked.', }, { name: 'Raw disk overwrite via dd', pattern: /\bdd\b[^&|;]*\bof=\/dev\/(?:sd|nvme|hd|vd|xvd|mmcblk)[a-z0-9]*/, description: '`dd` writing to a raw block device destroys disk data. Blocked.', }, { name: 'Direct device write (> /dev/sd*)', pattern: />\s*\/dev\/(?:sd|nvme|hd|vd|xvd|mmcblk)[a-z0-9]*/, description: 'Shell redirection to a block device destroys disk data. Blocked.', }, { name: 'eval with variable/command expansion', pattern: /\beval\s+(?:`|\$[\({]|"[^"]*\$)/, description: '`eval` with variable or command substitution is a code injection vector. ' + 'Refactor to use explicit commands.', }, // --- Executor-specific additions --- { name: 'System shutdown/reboot', // Anchored to command position — start of string/line, or after a // separator (`;`, `|`, `&`, `&&`), with optional `sudo` and an optional // absolute path. An unanchored \b match blocked the bare word anywhere, // including quoted grep patterns, heredoc data, and commit messages. // // Runs against commandView, not the whitespace-collapsed string: collapsing // \s+ to ' ' would erase the newline separator before the pattern ever saw // it, and quoted spans must read as data, not as command position. commandView: true, pattern: /(?:^|[\n;|&])\s*(?:sudo\s+(?:-[a-zA-Z]+\s+)*)?(?:[\w./-]*\/)?(?:shutdown|reboot|halt|poweroff)\b/, description: 'System shutdown/reboot commands are blocked during execution.', }, { name: 'Cron persistence', pattern: /\bcrontab\b|>\s*\/etc\/cron/, description: 'Writing to crontab or /etc/cron* creates persistent scheduled tasks. ' + 'Blocked during execution.', }, { name: 'Base64-encoded execution', pattern: /\bbase64\b[^|]*\|\s*(?:bash|sh|zsh)\b/, description: 'Base64-decoded content piped to shell is obfuscated code execution. Blocked.', }, { name: 'Kill all processes (kill -9 -1)', pattern: /\b(?:kill|pkill)\s+-9\s+-1\b/, description: 'Killing all user processes with signal 9. Blocked.', }, { name: 'History destruction', pattern: /\bhistory\s+-c\b|>\s*~\/\.bash_history\b|>\s*~\/\.zsh_history\b/, description: 'Clearing shell history or truncating history files. Blocked.', }, ]; // --------------------------------------------------------------------------- // WARN rules — exit 0 with advisory message on stderr. // --------------------------------------------------------------------------- const WARN_RULES = [ { name: 'Force push (git push --force)', pattern: /\bgit\s+push\b[^|&;]*(?:--force|-f)\b/, description: 'WARNING: `git push --force` rewrites remote history. Prefer `--force-with-lease`.', }, { name: 'Hard reset (git reset --hard)', pattern: /\bgit\s+reset\s+--hard\b/, description: 'WARNING: `git reset --hard` permanently discards uncommitted changes.', }, { name: 'Recursive remove (rm -rf, non-root)', pattern: /\brm\s+(?:-[a-zA-Z]*f[a-zA-Z]*\s+|--force\s+)*-[a-zA-Z]*r[a-zA-Z]*\s+/, description: 'WARNING: `rm -rf` permanently deletes files. Verify the target path.', }, { name: 'Docker system prune', pattern: /\bdocker\s+system\s+prune\b/, description: 'WARNING: `docker system prune` removes all stopped containers and unused images.', }, { name: 'npm publish', pattern: /\bnpm\s+publish\b/, description: 'WARNING: `npm publish` releases a package to the public registry.', }, { name: 'DROP TABLE or DROP DATABASE (SQL)', pattern: /\bDROP\s+(?:TABLE|DATABASE|SCHEMA)\b/i, description: 'WARNING: SQL DROP permanently deletes database objects.', }, { name: 'DELETE without WHERE (SQL)', pattern: /\bDELETE\s+FROM\s+\w+(?:\s*;|\s*$)/i, description: 'WARNING: DELETE FROM without WHERE deletes all rows.', }, // --- Executor-specific additions --- { name: 'Dependency installation during execution', pattern: /\b(?:npm\s+install\s+--save|pip3?\s+install\s+(?!-e\s+\.)|cargo\s+add)\b/, description: 'WARNING: Installing dependencies during plan execution is unusual. ' + 'Verify this is intentional.', }, ]; // --------------------------------------------------------------------------- // Normalize: strip ANSI, collapse whitespace // --------------------------------------------------------------------------- function normalizeCommand(cmd) { return cmd .replace(/\x1B\[[0-9;]*m/g, '') .replace(/\s+/g, ' ') .trim(); } // --------------------------------------------------------------------------- // Command-position view — for rules that must distinguish a command from data // that merely names one. Whitespace is NOT collapsed, so newline stays a // separator. Quoted spans and heredoc bodies become data; the argument of a // shell wrapper stays a command; backslash-escaped names are seen through. // --------------------------------------------------------------------------- // `out` ends with the `-c` of a shell invocation — the next quoted span is a // command string, not data. Optional leading `sudo` and absolute path. const SHELL_C_TAIL = /(?:^|[\s;|&])(?:sudo\s+(?:-[a-zA-Z]+\s+)*)?(?:[\w./-]*\/)?(?:ba|z|k|da|a)?sh\s+(?:-[a-zA-Z]+\s+)*-c\s*$/; // Drop heredoc bodies, keeping the operator line. Their newlines are not // command separators, and without this every heredoc line that happens to // start with a matched word reads as command position. Runs before the quote // scan, since a body may contain quotes that would desync it. function stripHeredocBodies(cmd) { return cmd.replace( /(<<-?\s*(['"]?)(\w+)\2[^\n]*\n)[\s\S]*?(?:\n[ \t]*\3[ \t]*(?=\n|$)|$)/g, (_match, head) => head, ); } function commandPositionView(cmd) { const src = stripHeredocBodies(cmd); let out = ''; let i = 0; while (i < src.length) { const ch = src[i]; if (ch === "'" || ch === '"') { const close = src.indexOf(ch, i + 1); const inner = close === -1 ? src.slice(i + 1) : src.slice(i + 1, close); // Unterminated quote — treat the remainder as one span and stop. out += SHELL_C_TAIL.test(out) ? `;${inner};` : ' '; i = close === -1 ? src.length : close + 1; } else { out += ch; i += 1; } } return ( out // `xargs [flags] ` puts at command position with no separator // in front of it. Flags taking a separate argument (`-I {}`) are not // parsed — the separator lands before the argument, not the command. .replace(/\bxargs((?:\s+-[a-zA-Z0-9-]+)*)/g, 'xargs$1 ;') // `\name` runs name — the backslash only suppresses alias expansion. // normalizeBashExpansion covers the between-word-chars case; this covers // a backslash at command position. .replace(/\\(\w)/g, '$1') ); } // --------------------------------------------------------------------------- // Main // --------------------------------------------------------------------------- let input; try { const raw = readFileSync(0, 'utf-8'); input = JSON.parse(raw); } catch { // Cannot parse stdin — fail open. process.exit(0); } const command = input?.tool_input?.command; if (!command || typeof command !== 'string') { process.exit(0); } // Strip bash evasion, then normalize whitespace const deobfuscated = normalizeBashExpansion(command); const normalized = normalizeCommand(deobfuscated); const commandView = commandPositionView(deobfuscated.replace(/\x1B\[[0-9;]*m/g, '')); // Check BLOCK rules first for (const rule of BLOCK_RULES) { if (rule.pattern.test(rule.commandView ? commandView : normalized)) { process.stderr.write( `[voyage] BLOCKED: ${rule.name}\n` + ` Command: ${normalized.slice(0, 200)}${normalized.length > 200 ? '...' : ''}\n` + ` ${rule.description}\n` ); process.exit(2); } } // Check WARN rules (advisory — still exit 0) const warnings = []; for (const rule of WARN_RULES) { if (rule.pattern.test(normalized)) { warnings.push(` [WARN] ${rule.name}: ${rule.description}`); } } if (warnings.length > 0) { process.stderr.write( `[voyage] SECURITY ADVISORY: Potentially risky command.\n` + ` Command: ${normalized.slice(0, 200)}${normalized.length > 200 ? '...' : ''}\n` + warnings.join('\n') + '\n' ); } process.exit(0);