To stop Claude Code from running a specific Bash command, add a `PreToolUse` hook that matches the `Bash` tool, reads the command from its JSON input, and either returns `permissionDecision: "deny"` or exits with code 2 when the command matches. Per Claude Code's hooks docs, `PreToolUse` runs before the tool runs, and it's an event that can block the tool call. If the script exits 0 with no output, there's no decision and the normal permission flow applies. This post walks through that hook setup.
The setup
This follows the `block-rm` example in the hooks docs. Add the hook to `.claude/settings.json`:
{
"hooks": {
"PreToolUse": [
{
"matcher": "Bash",
"hooks": [
{
"type": "command",
"if": "Bash(rm *)",
"command": "${CLAUDE_PROJECT_DIR}/.claude/hooks/block-rm.sh"
}
]
}
]
}
}Then save the script to `.claude/hooks/block-rm.sh`:
#!/bin/bash
# .claude/hooks/block-rm.sh
COMMAND=$(jq -r '.tool_input.command')if echo "$COMMAND" | grep -q 'rm -rf'; then jq -n '{ hookSpecificOutput: { hookEventName: "PreToolUse", permissionDecision: "deny", permissionDecisionReason: "Destructive command blocked by hook" } }' else exit 0 # no decision; normal permission flow applies fi ```
Make it executable with `chmod +x .claude/hooks/block-rm.sh`. The script uses `jq`, so it needs to be on your PATH.
Exit code 2 or a deny decision
| Exit code 2 | `permissionDecision: "deny"` | |
|---|---|---|
| Blocks the call | Yes | Yes |
| How | Exit with code 2 | Exit 0 and print JSON with `hookSpecificOutput` |
| Reason for Claude | The hook's stderr | `permissionDecisionReason` |
| When to use | A simple block | Finer control and an explicit reason |
The docs describe JSON output as the finer-grained option: "Instead of exiting with code 2 to block, exit 0 and" return a decision.
Where to put it
From the docs' hook locations:
- `~/.claude/settings.json`: all your projects, not shared.
- `.claude/settings.json`: this project, can be committed so your team gets it.
- `.claude/settings.local.json`: this project, gitignored.
Pitfalls
- Script errors don't block. An exit code other than 0 or 2 with no JSON decision is a non-blocking error, so the command goes through.
- Silence isn't approval. Exit 0 with no output only means the hook has no decision; the normal permission flow still runs.
- Forgetting `chmod +x` or `jq`. The docs' example needs both.
- Treating a pattern match as a guarantee. The hook checks the strings you tell it to check. Other events, failure modes and Cursor hooks are covered in Cursor hooks vs rules.
How to check it
Ask Claude Code to run a command your hook should block and confirm it's denied with your reason. Then run a command that shouldn't match and confirm it goes through the normal permission flow.
Where the kit fits
The Operator Pack ($47) ships markdown seats + skills + install.sh across Cursor, Claude Code, and Grok Bot. You can also start at /free. Cheat Sheet for the skim checklist mindset.
Soft next step
Pick the one command you never want run unasked, write a `PreToolUse` hook for it, and commit it in `.claude/settings.json`. Soft ladder: /free, then the Operator Pack ($47). Related: Cursor hooks vs rules, skill vs rule, CLAUDE.md and AGENTS.md symlink.