Block dangerous commands with a Claude Code hook
Inspect each Bash command Claude Code is about to run with a PreToolUse hook, and block rm -rf or force pushes with exit code 2, via a settings file and script.
A hook is a user-defined command that Claude Code runs at fixed points, such as before or after a tool call. Here a PreToolUse hook checks each Bash command before it runs and blocks the dangerous ones.
[1] Create a directory for the hook script in the project folder.
$ mkdir -p .claude/hooks[2] Register the hook in .claude/settings.json. The structure has three levels: event (PreToolUse) → matcher group (matcher) → handler list (hooks). matcher filters on the tool name; "Bash" selects only the Bash tool.
{"hooks": { "PreToolUse": [ { "matcher": "Bash", "hooks": [ { "type": "command", "command": "\"$CLAUDE_PROJECT_DIR\"/.claude/hooks/block-dangerous.sh" } ] } ]}}[3] Write the hook script. Claude Code passes the hook input as JSON on standard input. For a Bash call the command is in tool_input.command; read it with jq.
#!/bin/bashcommand=$(jq -r '.tool_input.command // empty') if echo "$command" | grep -Eq 'rm +(-[a-zA-Z]*r[a-zA-Z]*f|-[a-zA-Z]*f[a-zA-Z]*r) |git +push +.*--force|DROP +TABLE'; then echo "Blocked: dangerous command — $command" >&2 exit 2fiexit 0[4] Make it executable and pipe JSON into it directly to check the exit codes, without Claude Code.
$ chmod +x .claude/hooks/block-dangerous.sh$ echo '{"tool_name":"Bash","tool_input":{"command":"rm -rf /srv/app/cache"}}' | .claude/hooks/block-dangerous.sh; echo "exit=$?"Blocked: dangerous command — rm -rf /srv/app/cacheexit=2$ echo '{"tool_name":"Bash","tool_input":{"command":"ls -la"}}' | .claude/hooks/block-dangerous.sh; echo "exit=$?"exit=0[5] The exit code decides the outcome.
| Exit code | Result |
|---|---|
0 | Pass. No decision is made, so the normal permission flow applies |
2 | Block. The tool call does not run and the stderr text is passed to Claude |
Anything else (1, …) | Non-blocking error. The command still runs; only a hook error notice appears in the transcript |
[6] Instead of an exit code you can return a JSON decision. Exit 0 and print the JSON below to stdout. permissionDecision is one of allow, deny, ask or defer. Use one approach per hook (exit 2 blocks even if the JSON says allow).
{ "hookSpecificOutput": { "hookEventName": "PreToolUse", "permissionDecision": "deny", "permissionDecisionReason": "Destructive command blocked by hook" }}[7] Check that a real session is blocked. Ask for a dangerous command with claude -p in the project folder (./cache holds one test file). Even with Bash pre-approved through --allowedTools Bash, the hook's block wins.
$ ls cachea.txt$ claude -p "Run this exact shell command and report the result: rm -rf ./cache" --max-turns 3 --output-format json --allowedTools Bash$ ls cachea.txtClaude received the stderr text Blocked: dangerous command — rm -rf ./cache and reported that it could not run the command. The file was still there.
[8] Type /hooks in a session to list the configured hooks grouped by event. Confirm yours appears under PreToolUse.
Other events use the same structure: PostToolUse (for example, run a formatter after an edit), UserPromptSubmit (inspect prompts), SessionStart (inject context) and Notification (alert when input is needed). The full list is in the hooks reference.