GrepBoard/Manuals
51 manuals ·

Block dangerous commands with a Claude Code hook

·Claude Code 2.1.302 · Ubuntu 24.04 · jq 1.7·8 steps

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.

console
$ 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.

.claude/settings.json
{"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.

.claude/hooks/block-dangerous.sh
#!/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.

console
$ 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 codeResult
0Pass. No decision is made, so the normal permission flow applies
2Block. 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).

output
{  "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.

console
$ 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.txt

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

#claude-code#hooks