← Back to Payloads
Tutorial2026-07-09

Block Dangerous Commands in Claude Code with a 20-Line Hook

Claude Code will happily `rm -rf` the wrong directory. Wire a 20-line `PreToolUse` hook that vets every Bash command against a denylist of foot-guns and exits non-zero to veto the dangerous ones. Twenty lines, no seatbelt excuses.
Quick Access
Install command
$ mrt install tutorial
Browse related skills
Block Dangerous Commands in Claude Code with a 20-Line Hook

Block Dangerous Commands in Claude Code with a 20-Line Hook

Hey guys, Mr. Technology here.

Claude Code will happily rm -rf the wrong directory if you let it. The agent is doing what you asked, it is just doing it without the seatbelt you forgot to install. Claude Code hooks run shell scripts on tool events before the tool executes. Wire one up that vetoes the dangerous ones, and you have an approval gate that never sleeps and never says "sure, looks fine, ship it."

What we are building

A PreToolUse hook for Bash that scans every command the agent wants to run, matches it against a denylist of foot-guns, and exits non-zero to block execution. Pattern matchers cover the classics: recursive force-delete on root or home, fork bombs, raw disk wipe, curl-pipe-bash, filesystem format, force-push to main. Anything not on the list goes through. Twenty lines of Python, one settings file, zero dependencies.

Step 1 — The hook script

Drop this somewhere stable, like ~/.claude/hooks/block_dangerous.py:

python
#!/usr/bin/env python3
import json, re, sys
# Claude Code pipes the tool call to the hook on stdin as JSON
payload = json.load(sys.stdin)
cmd = payload.get("tool_input", {}).get("command", "")
PATTERNS = [
    r"\brm\s+-rf?\s+/(?:\s|$)",                  # rm -rf /
    r"\brm\s+-rf?\s+~(?:\s|$)",                  # rm -rf ~
    r":\(\)\s*\{.*\};:",                          # fork bomb
    r"\bdd\s+.*of=/dev/(sd|nvme|hd)",            # disk wipe
    r"curl\s+.*\|\s*bash",                        # curl | bash
    r"\bmkfs\.",                                  # format filesystem
    r"\bchmod\s+-R\s+777\s+/(?:\s|$)",          # world-writable root
    r"\bgit\s+push\s+.*--force.*\bmain\b",      # force-push to main
]
for pat in PATTERNS:
    if re.search(pat, cmd):
        print(f"BLOCKED by hook: matched /{pat}/", file=sys.stderr)
        sys.exit(2)  # exit code 2 = deny
sys.exit(0)  # allow

Make it executable: chmod +x ~/.claude/hooks/block_dangerous.py. Exit code 2 is the magic number — that tells Claude Code to deny the tool call and feed the stderr message back to the model. Exit 0 means proceed. Anything else is treated as a soft error and the tool still runs, so do not improvise.

Step 2 — Wire it in

Edit ~/.claude/settings.json (create it if missing):

json
{
  "hooks": {
    "PreToolUse": [
      {
        "matcher": "Bash",
        "hooks": [
          {
            "type": "command",
            "command": "python3 /Users/you/.claude/hooks/block_dangerous.py"
          }
        ]
      }
    ]
  }
}

Restart Claude Code. From this point every Bash invocation flows through the hook before reaching your shell.

Step 3 — Test it

Ask the agent to "clean up the build directory." Fine — passes. Then ask it to "delete the node_modules folder to save space." Hook lets that through, scoped target. Now ask it to "wipe the disk and reinstall macOS." The hook kills it:

BLOCKED by hook: matched /\bdd\s+.*of=\/dev\/(sd|nvme|hd)/

The agent receives the deny, reads the reason, and pivots. No silent data loss, no 3am git push --force origin main panic.

What you have

A working veto layer that catches the obvious catastrophes without getting in the way of normal work. The denylist is plain regex — add a pattern, restart, done. Pair this with a PostToolUse hook that appends every command to ~/.claude/audit.log and you have a paper trail for when the agent does something unexpected. Twenty lines today, fewer regret spirals next month.

Mr. Technology

Related Dispatches