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

> Block rm -rf or curl | sh in Claude Code: a PreToolUse hook on Bash reads the command from stdin and exits 2. A tested script, JSON deny and pitfalls.

Canonical: https://ranwhat.com/guides/block-command-claude-code-pretooluse-hook
Last updated: 2026-11-08

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](https://ranwhat.com/install)

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

## 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.

## 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.

## 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.

## 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](https://ranwhat.com/guides/claude-code-hooks-security).

## 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.

## 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](https://ranwhat.com/watch), 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](https://ranwhat.com/guides/claude-code-hooks-security). For every other control around a coding agent, the [AI coding agent security checklist](https://ranwhat.com/guides/ai-coding-agent-security). The rest of the [guides](https://ranwhat.com/guides) are listed here.

## Sources

- [Hooks referencePreToolUse input, exit codes, permissionDecision, CLAUDE_PROJECT_DIR, security](https://code.claude.com/docs/en/hooks)
- [Hooks guideblocking with exit 2, executable scripts, case-sensitive matchers, testing by hand](https://code.claude.com/docs/en/hooks-guide)
- [Configure permissionsdeny rules and what a Bash rule does not match](https://code.claude.com/docs/en/permissions)
- [ranwhat referencewhat ranwhat hook install does and does not do](https://github.com/MatijaMiki/ranwhat/blob/main/docs/reference.md#ranwhat-hook-install-ask-before-a-high-risk-claude-code-call)
