Denies Bash commands that match destructive patterns (recursive rm on roots, force push, hard reset, destructive SQL, disk formatting) before they run. A…

Denies Bash commands that match destructive patterns before they run. A tool.call hook under a { tool: "Bash" } matcher: on a match it returns { deny } without calling next, so nothing beneath (other plugins, PreToolUse shell hooks, the tool itself) runs; otherwise it passes through.
patterns: string extra regex sources, comma-separated, e.g. "\\bkubectl\\s+delete\\b"
Declared in .claude-plugin/plugin.json (userConfig). Set them in /config, in user settings (~/.claude/settings.json, not project settings), with --settings <file> or in managed settings:
{ "pluginConfigs": { "block-destructive-commands@skills-dir": { "options": { } } } }
npx claude-code-templates@latest --mod security/block-destructive-commands
claude
It is written to .claude/skills/block-destructive-commands/, which Claude Code auto-loads as block-destructive-commands@skills-dir. For one session with hot reload: claude --plugin-dir .claude/skills/block-destructive-commands. claude plugin validate .claude/skills/block-destructive-commands prints every event it hooks and every $ call it makes.
Requirements. Mods are on by default in Claude Code 2.1.287+. Typed against Anthropic's declarations: https://github.com/anthropics/claude-code/tree/main/mods
hooks/block-destructive-commands.ts 73 lines1/**
2 * block-destructive-commands — Claude Mod
3 *
4 * Denies Bash commands that match destructive patterns before they run.
5 * A `tool.call` hook under a `{ tool: "Bash" }` matcher: on a match it returns
6 * `{ deny }` without calling `next`, so nothing beneath (other plugins,
7 * PreToolUse shell hooks, the tool itself) runs; otherwise it passes through.
8 *
9 * Needs Claude Code >= 2.1.287. Typed
10 * against Anthropic's declarations: https://github.com/anthropics/claude-code/tree/main/mods
11 *
12 * Options (plugin.json "userConfig" / hooks module options):
13 * patterns: string extra regex sources, comma-separated, e.g. "\\bkubectl\\s+delete\\b"
14 */
15import type { Register } from 'claude-code'
16
17/** A list option: a string[] or a comma-separated string (what a manifest's `userConfig` string field holds); empty means unset. */
18function strings(value: unknown): string[] | undefined {
19 const list = Array.isArray(value)
20 ? value.filter((v): v is string => typeof v === 'string')
21 : typeof value === 'string'
22 ? value.split(',').map((s) => s.trim()).filter(Boolean)
23 : []
24 return list.length > 0 ? list : undefined
25}
26
27interface Rule { pattern: RegExp; reason: string }
28
29const DEFAULT_RULES: readonly Rule[] = [
30 // rm with a recursive flag anywhere in its arguments (-r, -rf, -r -f, --recursive, -fR ...)
31 // and a root/home/cwd target, optionally quoted or with a trailing slash ("~/", "$HOME/", "/").
32 // The target may be quoted in whole or in part ("$HOME"/., '~'/) and end in "/" or "/.".
33 { pattern: /\brm\s+(?=(?:\S+\s+)*?(?:-[a-z]*r[a-z]*|--recursive)\b)(?:\S+\s+)*?["']?(?:\/|~|\$HOME|\$\{HOME\}|\.\.?)["']?(?:\/\.?)?["']?(?:\s|$|;|&|\|)/i, reason: 'recursive delete of a root, home or working directory' },
34 { pattern: /\brm\s+.*--no-preserve-root\b/i, reason: 'rm with --no-preserve-root' },
35 // -f alone or inside a short-option cluster (-fu, -uf); --force-with-lease is the safe form
36 { pattern: /\bgit\s+push\b(?!.*--force-with-lease).*(--force\b|\s-[a-zA-Z]*f[a-zA-Z]*\b)/, reason: 'force push' },
37 { pattern: /\bgit\s+(reset\s+--hard|clean\s+(?:-[a-zA-Z]*f|.*--force\b))/, reason: 'history or working-tree destruction' },
38 { pattern: /\b(DROP|TRUNCATE)\s+(TABLE|DATABASE|SCHEMA)\b/i, reason: 'destructive SQL' },
39 { pattern: /\bmkfs(\.|\s)/, reason: 'filesystem format' },
40 { pattern: /\bdd\s+.*\bof=["']?\/dev\//, reason: 'raw disk write' },
41 { pattern: /\bchmod\s+(-R\s+)?777\b/, reason: 'world-writable permissions' },
42]
43
44export const register: Register = (on, options) => {
45 // a custom pattern that does not compile is skipped, never a reason to lose the built-in rules
46 const custom: Rule[] = []
47 for (const p of strings(options.patterns) ?? []) {
48 try {
49 custom.push({ pattern: new RegExp(p), reason: `custom pattern ${p}` })
50 } catch {
51 // ignored: the defaults still apply
52 }
53 }
54 const rules = [...DEFAULT_RULES, ...custom]
55
56 on('tool.call', { tool: 'Bash' }, ($, e, next) => {
57 for (const { pattern, reason } of rules) {
58 if (pattern.test(e.command)) {
59 $.ui.log(`[block-destructive-commands] denied Bash call: ${reason}`)
60 // The model receives this text as the tool's error result.
61 return {
62 deny:
63 `Blocked by block-destructive-commands (${reason}). ` +
64 'If this is intentional, ask the user to run it manually.',
65 }
66 }
67 }
68
69 // Nothing matched: let the rest of the chain (and the real tool) run.
70 return next(e)
71 })
72}
73