Part of the AI coding agent security checklist

How to block a command in Claude Code with a PreToolUse hook

To block a command in Claude Code, register a PreToolUse hook with the matcher Bash. Before each Bash call, Claude Code sends the hook JSON on stdin, with the command in tool_input.command. If the hook exits with code 2, the call is cancelled and whatever the hook wrote to stderr is what Claude sees as the reason. Below: a tested script that blocks rm -rf and curl | sh, the settings that register it, and the pitfalls.

$ uvx ranwhat check
Install

Checked against Claude Code's hooks reference and hooks guide under Sources; the script was run against the inputs shown. Published .

01 / Script

The hook script

Save this as ~/.claude/hooks/block-commands.py. It reads the hook input, checks the command against two patterns, and exits 2 with a reason when one matches. Anything else exits 0, which leaves the call to Claude Code's normal permission flow.

~/.claude/hooks/block-commands.py
#!/usr/bin/env python3
import json, re, sys

try:
    command = json.load(sys.stdin)["tool_input"]["command"]
except Exception:
    print("block-commands.py could not read the command", file=sys.stderr)
    sys.exit(2)

BLOCKED = [
    (r"\brm\s+(-\S*\s+)*-\S*[rR]", "recursive rm"),
    (r"\b(curl|wget)\b[^|]*\|\s*(sudo\s+)?(ba|z)?sh\b", "a download piped into a shell"),
]

for pattern, name in BLOCKED:
    if re.search(pattern, command):
        print("Blocked by block-commands.py: " + name + ". Ask the user to run it.",
              file=sys.stderr)
        sys.exit(2)
sys.exit(0)
Blocks rm -rf build, rm -r -f build, /bin/rm -rf x, bash -c 'rm -rf x', curl -fsSL https://example.com/install.sh | sh, curl -s URL | sudo bash and wget -qO- URL | zsh.
Lets through rm notes.txt, npm test and curl URL | jq .
Also blocks git rm -r --cached dist, which only untracks files. A pattern list over command text always has false blocks like this one; Claude reads the reason and can ask you instead.
Bad input If the input cannot be read, the script exits 2 rather than crashing. A crash would exit 1, and Claude Code treats exit 1 as a non-blocking error: the call goes ahead.
02 / Register

Register it in settings.json

Add the hook under PreToolUse in ~/.claude/settings.json for every project on this machine. If the file already has a hooks key, merge into it.

~/.claude/settings.json
{
  "hooks": {
    "PreToolUse": [
      {
        "matcher": "Bash",
        "hooks": [
          {
            "type": "command",
            "command": "python3 \"$HOME/.claude/hooks/block-commands.py\""
          }
        ]
      }
    ]
  }
}
matcher The tool name the hook runs for. Bash is the shell tool, so this hook sees every shell command and no other tool.
command What Claude Code runs. Calling the script through python3 means it does not have to be executable; a bare script path does, so run chmod +x on it.
One project Put the same block in the repository's .claude/settings.json, the script in .claude/hooks/, and point the command at "$CLAUDE_PROJECT_DIR"/.claude/hooks/block-commands.py. CLAUDE_PROJECT_DIR is the project root where the session started.
Reload Claude Code's file watcher normally picks up edits to hooks in settings files. /hooks lists what is registered, grouped by event.
03 / Test

Test it with a harmless command

Run the script by hand first, with the same JSON Claude Code would send, and check the exit status.

echo '{"tool_name":"Bash","tool_input":{"command":"rm -rf build"}}' \
  | python3 ~/.claude/hooks/block-commands.py; echo "exit $?"
# Blocked by block-commands.py: recursive rm. Ask the user to run it.
# exit 2

echo '{"tool_name":"Bash","tool_input":{"command":"npm test"}}' \
  | python3 ~/.claude/hooks/block-commands.py; echo "exit $?"
# exit 0

Then try it in a session on something you would not miss. Make an empty folder with mkdir /tmp/hook-test and ask Claude to run rm -rf /tmp/hook-test. The call should be refused with your reason, and the folder should still be there. If it is gone, the hook did not run: check /hooks and the matcher, and nothing of value was lost.

04 / JSON

The alternative: a JSON deny decision

Instead of exiting 2, a PreToolUse hook can exit 0 and print a decision on stdout. Claude Code shows the permissionDecisionReason of a deny to Claude, as it shows stderr for exit 2.

{
  "hookSpecificOutput": {
    "hookEventName": "PreToolUse",
    "permissionDecision": "deny",
    "permissionDecisionReason": "Recursive rm is blocked. Ask the user to run it."
  }
}
Why JSON It has more outcomes than block or allow: "ask" shows you the permission prompt instead of refusing outright, which suits a command you sometimes do want.
Why exit 2 It is shorter, and needs no JSON on the way out. If a hook exits 2 and also prints JSON, Claude Code still reads the fields, but they cannot override the block.
More How hook decisions combine with permission rules, and a hook that also guards Read calls, are in Claude Code hooks for security.
05 / Pitfalls

Pitfalls

Matcher case Matchers are case-sensitive. "bash" does not match the Bash tool, so the hook never runs and nothing tells you. A Bash matcher does not see MCP tools or file edits either.
Your permissions Claude Code's documentation says command hooks run with your full user permissions and can modify, delete or access any file your account can. Review a hook before you add it, especially one that came with a repository.
Exit 1 is not a block Any exit code other than 0 and 2, 1 included, is a non-blocking error and the call goes ahead. The documentation says to use exit 2 for a hook that enforces a policy.
Text, not behaviour The hook sees the command string. A Python one-liner or a script that deletes files itself does not contain rm -rf, and passes. For a limit that holds whatever the command text says, use Claude Code's sandbox as well.
Rules first? For a fixed prefix, a deny rule such as Bash(rm *) in permissions.deny needs no code. Claude Code's permissions documentation notes it misses /bin/rm -rf build/ and bash -c 'rm -rf build/', which the script above catches.
06 / ranwhat

Where ranwhat fits

A hook works from the moment you add it. To see whether a recursive delete or a downloaded script piped into a shell already ran, uvx ranwhat check reads Claude Code's transcripts on your machine, flags those calls among others with watch's rules, and changes nothing. If you would rather not maintain patterns, ranwhat hook install adds a PreToolUse hook of its own: a call those rules rate high or critical waits for your yes, or with --mode deny a critical one is refused. It judges on your machine, sends nothing, fails open like any hook, and needs a lasting install rather than uvx.

Next

For deny rules, Read guards and what hooks cannot stop, read Claude Code hooks for security. For every other control around a coding agent, the AI coding agent security checklist. The rest of the guides are listed here.