#!/usr/bin/env node // Hook: pre-bash-destructive.mjs // Event: PreToolUse (Bash) // Purpose: Block or warn about destructive shell commands. // // 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 message to stderr // - Allow (exit 0): everything else import { readFileSync } from 'node:fs'; import { normalizeBashExpansion } from '../../scanners/lib/bash-normalize.mjs'; import { getPolicyValue } from '../../scanners/lib/policy-loader.mjs'; // --------------------------------------------------------------------------- // BLOCK rules — exit 2, command is not executed. // Each rule: { name, pattern, description } // --------------------------------------------------------------------------- const BLOCK_RULES = [ { name: 'Filesystem root destruction (rm -rf /)', pattern: /\brm\s+(?:-[a-zA-Z]*f[a-zA-Z]*\s+|--force\s+)*-[a-zA-Z]*r[a-zA-Z]*\s+(?:\/|~|\$HOME)\b/, description: '`rm -rf /`, `rm -rf ~`, and `rm -rf $HOME` would destroy the entire filesystem ' + 'or home directory. This command is 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, creating a severe ' + 'security vulnerability. Use the minimal permission set required (e.g. 644, 755).', }, { name: 'Pipe-to-shell (curl|sh, wget|sh, curl|bash)', // Matches: curl ... | sh, curl ... | bash, wget ... | sh, etc. // Also catches variations with xargs sh, xargs bash pattern: /(?:curl|wget)\b[^|]*\|\s*(?:bash|sh|zsh|ksh|dash)\b/, description: 'Piping remote content directly into a shell interpreter allows ' + 'arbitrary remote code execution without inspection. Download the script first, ' + 'review it, then execute explicitly.', }, { name: 'Fork bomb', pattern: /:\(\)\s*\{\s*:\s*\|\s*:&\s*\}\s*;?\s*:/, description: 'This is a fork bomb that will exhaust system process resources and require a hard reboot. Blocked.', }, { name: 'Filesystem format (mkfs)', pattern: /\bmkfs(?:\.[a-z0-9]+)?\s/, description: '`mkfs` formats a filesystem, destroying all data on the target device. ' + 'This is an irreversible operation and is blocked.', }, { name: 'Raw disk overwrite via dd', // dd if=... of=/dev/sd* or of=/dev/nvme* or similar block devices pattern: /\bdd\b[^&|;]*\bof=\/dev\/(?:sd|nvme|hd|vd|xvd|mmcblk)[a-z0-9]*/, description: '`dd` writing to a raw block device (/dev/sd*, /dev/nvme*) will destroy partition ' + 'tables and data on that disk. Blocked to prevent accidental disk wipe.', }, { name: 'Direct device write (> /dev/sd* etc.)', pattern: />\s*\/dev\/(?:sd|nvme|hd|vd|xvd|mmcblk)[a-z0-9]*/, description: 'Writing directly to a block device via shell redirection destroys disk data. Blocked.', }, { name: 'eval with variable/command expansion (potential injection)', // eval $VAR, eval $(cmd), eval `cmd`, eval "$VAR" pattern: /\beval\s+(?:`|\$[\({]|"[^"]*\$)/, description: '`eval` with variable or command substitution executes dynamically constructed ' + 'strings, which is a common code injection vector. Blocked. ' + 'Refactor to use explicit commands instead.', }, // Policy-defined additional blocked patterns ...getPolicyValue('destructive', 'additional_blocked', []).map(entry => ({ name: entry.name || 'Custom blocked pattern', pattern: new RegExp(entry.pattern), description: entry.description || 'Blocked by policy.', })), ]; // --------------------------------------------------------------------------- // WARN rules — exit 0 with advisory message on stderr. // Command is allowed to proceed but the user/agent is informed. // --------------------------------------------------------------------------- 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. This can destroy commits ' + 'for all collaborators on shared branches. 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 and ' + 'moves the branch pointer. Ensure you have no unsaved work.', }, { name: 'Recursive remove (rm -rf, non-root non-home target)', // Warn for rm -rf that doesn't hit /, ~, or $HOME (those are BLOCKED above) 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 and directories without recovery. ' + 'Verify the target path before proceeding.', }, { name: 'Docker system prune', pattern: /\bdocker\s+system\s+prune\b/, description: 'WARNING: `docker system prune` removes all stopped containers, unused images, ' + 'networks, and build cache. This may delete data needed for local development.', }, { name: 'npm publish', pattern: /\bnpm\s+publish\b/, description: 'WARNING: `npm publish` releases a package to the public npm registry. ' + 'Confirm the version, changelog, and that no secrets are bundled.', }, { name: 'DROP TABLE or DROP DATABASE (SQL)', pattern: /\bDROP\s+(?:TABLE|DATABASE|SCHEMA)\b/i, description: 'WARNING: SQL DROP statements permanently delete database objects and all their data. ' + 'Ensure you have a recent backup and are targeting the correct environment.', }, { name: 'DELETE without WHERE (SQL)', pattern: /\bDELETE\s+FROM\s+\w+(?:\s*;|\s*$)/i, description: 'WARNING: DELETE FROM without a WHERE clause deletes all rows in the table. ' + 'Ensure this is intentional and backed up.', }, ]; // --------------------------------------------------------------------------- // Normalize command: strip ANSI, collapse whitespace, for pattern matching. // We do NOT strip quotes entirely — patterns are designed to work with raw input. // --------------------------------------------------------------------------- function normalizeCommand(cmd) { return cmd // Remove ANSI escape codes .replace(/\x1B\[[0-9;]*m/g, '') // Collapse runs of whitespace (including newlines from heredocs) to single space .replace(/\s+/g, ' ') .trim(); } // --------------------------------------------------------------------------- // 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); } // First strip bash evasion techniques (empty quotes, ${} expansion, backslash splitting), // then apply standard normalization (ANSI strip, whitespace collapse). const deobfuscated = normalizeBashExpansion(command); const normalized = normalizeCommand(deobfuscated); // Check BLOCK rules first for (const rule of BLOCK_RULES) { if (rule.pattern.test(normalized)) { process.stderr.write( `BLOCKED: Destructive command detected — ${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( `SECURITY ADVISORY: Potentially risky command detected.\n` + ` Command: ${normalized.slice(0, 200)}${normalized.length > 200 ? '...' : ''}\n` + warnings.join('\n') + '\n' + ` Proceeding — verify intent before confirming.\n` ); } // Allow (with or without warnings) process.exit(0);