ktg-plugin-marketplace/plugins/llm-security/hooks/scripts/pre-bash-destructive.mjs
Kjell Tore Guttormsen 8ec320f40c feat(governance): add policy-as-code — .llm-security/policy.json for distributable hook configuration
New policy-loader.mjs reads .llm-security/policy.json with deep-merge against
defaults that exactly match existing hardcoded values. Integrated into all 7 hooks:
- pre-prompt-inject-scan: injection.mode (env var still takes precedence)
- post-session-guard: trifecta.mode, window_size, long_horizon_window
- pre-edit-secrets: secrets.additional_patterns
- pre-bash-destructive: destructive.additional_blocked
- pre-write-pathguard: pathguard.additional_protected
- pre-install-supply-chain: supply_chain.additional_blocked_packages
- post-mcp-verify: mcp.volume_threshold_bytes, mcp.trusted_servers

Backward compatible: no policy file = identical behavior to v5.1.0.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-10 13:37:02 +02:00

213 lines
8.2 KiB
JavaScript

#!/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);