How Claude Code Hooks Work: A Practical Guide to PreToolUse Gates

A practical walkthrough of intercepting tool calls in Claude Code

Claude Code can run shell commands, edit files, and push to git on your behalf. That power is the point — and the risk. Hooks are the mechanism for putting deterministic guardrails around it: small scripts the harness runs at defined points in the agent's loop, able to inspect — and block — what the agent is about to do.

This guide covers the PreToolUse hook specifically, because it's the one that can stop a dangerous action before it executes.

The hook lifecycle

Claude Code fires hooks at several lifecycle events. The most useful for safety are:

EventFiresCan block?
PreToolUseBefore a tool call runsYes — exit 2
PostToolUseAfter a tool call returnsNo (observe/log)
SessionStartWhen a session beginsNo (inject context)
StopWhen the agent finishes a turnYes — can require more work

How a PreToolUse hook receives the tool call

When the agent is about to call a tool, the harness serializes the call as JSON and pipes it to your hook on stdin. For a Bash command, that looks like:

{
  "tool_name": "Bash",
  "tool_input": { "command": "git push --force origin main" }
}

Your hook reads that JSON, decides, and signals its verdict through the exit code:

A minimal command gate

Here's a complete PreToolUse hook that blocks force-pushes to main:

#!/usr/bin/env bash
set -euo pipefail

cmd=$(jq -r '.tool_input.command // empty')
[ -z "$cmd" ] && exit 0

if echo "$cmd" | grep -qE 'git\s+push\s+.*(--force|-f)\b.*\bmain\b'; then
  echo "BLOCKED: force-push to main. Ask the user first." >&2
  exit 2
fi
exit 0

Register it in your plugin's hooks.json:

{
  "hooks": {
    "PreToolUse": [
      { "matcher": "Bash",
        "hooks": [{ "type": "command",
          "command": "${CLAUDE_PLUGIN_ROOT}/hooks/gate.sh" }] }
    ]
  }
}

Three patterns worth gating

1. Secrets before a push

The highest-value gate. Before any git push, scan the outgoing commits — not the working tree — for credential-shaped strings and forbidden filenames (.env, private keys). Use gitleaks if available, with a regex layer as a portable fallback. A single leaked key that reaches a public remote must be treated as compromised even after force-removal, so catching it pre-push is the whole game.

2. Blast-radius commands

Some commands are correct in intent but catastrophic when a variable is empty or a path is wrong: rm -rf "$DIR" with $DIR unset, git reset --hard origin/main discarding local work, chmod 777, curl ... | sh. Gate the patterns and make the agent surface them to a human.

3. Shared state an agent shouldn't touch

If you run multiple agent sessions, one can "clean up" another's git worktree that merely looks orphaned. Gate writes to .claude/worktrees/ and refuse git add -A in repos that contain them, since -A silently stages worktree gitlinks.

A gotcha: don't assume jq is installed

Hooks run in whatever shell the user has. jq is common but not guaranteed. Make the JSON extraction degrade gracefully:

extract_cmd() {
  if command -v jq >/dev/null 2>&1; then
    jq -r '.tool_input.command // empty'
  elif command -v python3 >/dev/null 2>&1; then
    python3 -c 'import json,sys; print(json.load(sys.stdin).get("tool_input",{}).get("command",""))'
  fi
}

A hook that errors out because a dependency is missing fails open — the dangerous command runs anyway. Test your hooks against the no-jq case.

Don't want to write and maintain these yourself?
CC Powerpack ships all three gates as a tested, one-command Claude Code plugin — secret scanning, dangerous-command gating, and worktree protection. Get the free hooks on GitHub →

← Back to CC Powerpack