computer, laptop, tech, blue computer, blue laptop, blue tech, computer, laptop, tech, tech, tech, tech, tech

Claude Code Hooks: Block Dangerous Commands Before They Run

What Claude Code hooks do

Claude Code hooks are shell commands that Claude Code runs automatically at set points in its lifecycle. They are deterministic. A prompt asking Claude to avoid something is a request, but a hook is code that runs every time. For guarding against destructive commands, that difference matters.

The hook that matters here is PreToolUse. It fires after Claude has decided to call a tool but before the tool runs, and it can stop the call.

How a PreToolUse hook sees a command

Claude Code passes the pending tool call to your script as JSON on standard input. For a Bash call, the fields you need are tool_name and tool_input.command. If your script exits with code 2, the call is blocked and whatever you wrote to standard error is fed back to Claude, so it knows why and can choose a safer approach. Exit code 0 lets the call continue.

A script that blocks risky commands

Save this as .claude/hooks/block-dangerous.sh in your project. It needs jq.

#!/usr/bin/env bash
# Read the tool call JSON from stdin
input=$(cat)
cmd=$(echo "$input" | jq -r '.tool_input.command // empty')

# Patterns we never want run unattended
blocked=(
  'rm -rf /'
  'rm -rf ~'
  'git push --force'
  'git push -f'
  'git reset --hard'
  'DROP TABLE'
  'mkfs'
)

for pattern in "${blocked[@]}"; do
  if [[ "$cmd" == *"$pattern"* ]]; then
    echo "Blocked by hook: '$pattern' is not allowed. Propose a safer alternative." >&2
    exit 2
  fi
done

exit 0

Make it executable:

chmod +x .claude/hooks/block-dangerous.sh

Wire it up in settings.json

Add the hook to .claude/settings.json for the project, or ~/.claude/settings.json to apply it everywhere. The matcher limits it to Bash calls.

{
  "hooks": {
    "PreToolUse": [
      {
        "matcher": "Bash",
        "hooks": [
          {
            "type": "command",
            "command": ".claude/hooks/block-dangerous.sh"
          }
        ]
      }
    ]
  }
}

Run /hooks inside Claude Code to confirm the hook is registered. Hook settings are read when a session starts, so restart the session after editing the file.

Test it before you trust it

You can run the script by hand without Claude at all, which is the quickest way to check the logic:

echo '{"tool_name":"Bash","tool_input":{"command":"git push --force origin main"}}' \
  | .claude/hooks/block-dangerous.sh
echo "exit code: $?"

You should see your message on stderr and an exit code of 2. Then ask Claude in a throwaway repo to force push and watch it get refused and adapt.

Limits to be aware of

  • A string match is a blunt tool. git push --force-with-lease would not match git push --force exactly, and a determined command can be rewritten to slip past a pattern. Treat this as a safety net, not a sandbox.
  • Hooks run with your full user permissions, so keep the script small and review it like any other code that executes on your machine.
  • Use the hook alongside Claude Code’s permission rules, not instead of them.

Other hooks worth knowing

PreToolUse is the one for blocking, but the same mechanism covers other events. PostToolUse runs after a tool finishes, which is perfect for running a formatter such as Prettier every time Claude edits a file; set the matcher to Edit|Write. UserPromptSubmit runs when you send a prompt, and Stop runs when Claude finishes responding, so you can fire a desktop notification when a long task is done.

Keep each hook fast. They run synchronously, and a slow script makes every tool call feel sluggish.

If you want a ready-made tool for the same job, see the post on guarding AI coding agents against destructive commands. For the lifecycle events Claude Code exposes beyond PreToolUse, the Claude Code hooks documentation lists every one.


Leave a Reply