기본 콘텐츠로 건너뛰기

Claude Code PreToolUse Hooks: Stop Bad Edits Fast

💡 Key Takeaways
  • Prompt vs. Enforcement: CLAUDE.md instructions drift under long contexts, but shell-based PreToolUse hooks enforce deterministic guardrails every time.
  • Self-Correction Loop: Exiting with status code 2 returns standard error output directly back into Claude's context, allowing the agent to adapt and pick safe alternatives automatically.
  • Layered Protection: Combine command blocking (Git/destructive bash) with pre-write linting (ESLint/Prettier) to keep CI clean without human babysitting.

You told Claude Code to never touch the main branch. You even wrote it in bold, in CLAUDE.md, right at the top. Three hours into an autonomous refactor session, it ran git push --force anyway.

If that scenario sounds familiar, you already know the uncomfortable truth about agentic coding tools: instructions are not enforcement. CLAUDE.md is a prompt, and prompts drift. The longer an agent runs, the more context it accumulates, and the more likely it is to "forget" a rule it agreed to five turns ago. This is exactly the gap that Claude Code hooks were built to close, and the PreToolUse event in particular is where the real safety net lives.

Why Your Agent Needs Guardrails (The Limits of CLAUDE.md)

Think of CLAUDE.md like a sticky note on a junior developer's monitor that says "please run tests before committing." Most days they'll read it. On a chaotic day, buried in seventeen browser tabs, they might just... not. That's not malice, that's context drift, and Claude Code suffers from the exact same failure mode during long agentic loops.

A PreToolUse hook, by contrast, is not a suggestion sitting in the model's context window. It's an actual shell script that the Claude Code CLI is forced to execute before it's allowed to run Edit, Write, or Bash. There's no "forgetting" a shell script. It either lets the action through, or it doesn't.

Claude Code's hook system covers the entire session lifecycle — from SessionStart when a project loads, to UserPromptSubmit when you type a request, through PreToolUse and PostToolUse around every tool call, all the way to SessionEnd. For safety guardrails and linting, PreToolUse is the one that matters most, because it's your last checkpoint before damage happens.

📌 Hook Interception Sequence
  1. User Prompt triggers agent reasoning.
  2. Claude decides to execute a tool (Bash, Edit, Write).
  3. PreToolUse Hook runs:
    • exit 0 → Approved → Tool executes normally.
    • exit 2 → Blocked → stderr error returned to Claude → Claude self-corrects and tries again.
  4. Tool finishes execution and returns output to PostToolUse.

Core Architecture: How PreToolUse Hooks Intercept Tool Calls

Picture an airport security checkpoint. Every passenger (tool call) has to walk through the scanner before boarding (execution). The scanner doesn't care how convincing your excuse is — if the metal detector beeps, you get pulled aside. PreToolUse hooks work identically: they inspect the request, decide pass or fail, and there's zero room for negotiation with the model.

Mechanically, here is what happens right before Claude Code calls Edit, Write, or Bash:

  1. Claude Code pauses execution of that specific tool call.
  2. It serializes the request into a JSON object.
  3. That JSON is piped into your hook script through stdin.
  4. Your script inspects the data and exits with a status code.

The JSON payload typically includes tool_name (e.g., "Bash" or "Write") and tool_input, which holds the actual command string or the file path and content being written. Your script parses this, runs whatever validation logic you want, and then makes a decision using exit codes:

  • exit 0 — approved, Claude proceeds exactly as planned.
  • exit 2 — blocked, and whatever your script wrote to stderr is piped straight back into Claude's context as the reason for the failure.
💡 The Secret Weapon: The Stderr Feedback Loop

It's not just a binary block — it's a feedback loop. Claude doesn't just get denied; it gets told why, and on frontier models like Claude Opus 5 or Claude Sonnet 5, that error text is usually enough for the model to self-correct on the very next turn without you typing a single word.

Enforcing Safety Guardrails: Blocking Destructive Commands

Let's build the guardrail everyone actually needs first: stopping destructive Git operations and filesystem wipes before they ever reach your terminal.

First, register the hook in .claude/settings.json:

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

The matcher field tells Claude Code to only run this hook when the tool being called is Bash. Now the actual enforcement script:

#!/usr/bin/env bash
# block-destructive-bash.sh
# Reads the PreToolUse JSON payload from stdin and blocks
# high-risk shell commands before Claude Code can execute them.

set -euo pipefail  # Fail fast on errors, unset vars, and pipe failures

# 1. Read the full JSON payload piped in from Claude Code
INPUT_JSON=$(cat)

# 2. Extract the actual shell command Claude wants to run
COMMAND=$(echo "$INPUT_JSON" | jq -r '.tool_input.command // empty')

# 3. Define a regex of commands we never want an agent running unattended
BLOCKLIST_REGEX='(git push .*--force|git reset --hard|rm -rf /|rm -rf \*)'

# 4. Check the command against the blocklist
if echo "$COMMAND" | grep -qE "$BLOCKLIST_REGEX"; then
  # 5. Write a clear reason to stderr — Claude will read this and adapt
  echo "BLOCKED: '$COMMAND' matches a destructive command pattern." >&2
  echo "Use a non-destructive alternative (e.g., git push --force-with-lease)." >&2
  # 6. exit 2 = hard deny. Claude Code halts the tool call immediately.
  exit 2
fi

# 7. Anything not on the blocklist passes through untouched
exit 0

Run a force-push attempt with this hook active, and your console will show something like:

> Claude attempted: git push --force origin main
> [PreToolUse Hook] BLOCKED: 'git push --force origin main' matches a destructive command pattern.
> Claude: I'll use --force-with-lease instead to avoid overwriting remote history.

That's the entire value proposition in one log snippet. No human had to jump in. The agent got denied, read the reason, and picked the safer command on its own.

Building an Automated Pre-Commit Linting Pipeline

Blocking bad Bash commands solves the "catastrophe" problem. The next-most-common headache is subtler: Claude writes a file that technically saves fine but fails your linter — broken formatting, unused imports, or an inconsistent quote style that breaks your CI pipeline an hour later.

You can intercept this at the Edit and Write tool level instead of Bash. The hook receives the target file_path and the new content from tool_input, runs it through your linter of choice (ESLint, Prettier, or Python's ruff), and blocks the write if it fails:

#!/usr/bin/env bash
# auto-lint-guard.sh
# Validates file content against project linting rules before Claude
# is allowed to write it to disk.

set -euo pipefail

INPUT_JSON=$(cat)

# Extract the file path and proposed content from the tool call
FILE_PATH=$(echo "$INPUT_JSON" | jq -r '.tool_input.file_path // empty')
CONTENT=$(echo "$INPUT_JSON" | jq -r '.tool_input.content // empty')

# Only lint files we actually care about (skip markdown, config, etc.)
if [[ "$FILE_PATH" != *.ts && "$FILE_PATH" != *.tsx ]]; then
  exit 0
fi

# Write proposed content to a temp file so we can lint it
# without touching the real file until it's validated
TMP_FILE=$(mktemp --suffix=.ts)
echo "$CONTENT" > "$TMP_FILE"

# Run ESLint against the staged content
if ! npx eslint "$TMP_FILE" --quiet; then
  echo "LINT FAILED for $FILE_PATH — fix the errors above before writing." >&2
  rm -f "$TMP_FILE"
  exit 2
fi

rm -f "$TMP_FILE"
exit 0

Now every single file write goes through a real linter before it ever touches your working directory. If it fails, Claude sees the exact ESLint stdout in its context and revises the code in the next turn — the same loop you'd expect from a diligent teammate re-reading their own pull request comments.

If you want a deeper dive into structuring multi-step agentic prompts around workflows like this one, our earlier guide to agentic prompt design and tool configuration covers several patterns that pair well with hooks.

Best Practices & Performance Optimization for Hooks

PreToolUse hooks run synchronously, which means every millisecond of your script's execution time is a millisecond Claude Code sits there waiting. Anthropic's performance guidance recommends keeping Bash-validation hooks under 50ms. Favor jq and regex matching over spawning heavy subprocesses, and reserve slower operations (like full linter runs) specifically for file-write hooks where a short pause is expected and acceptable.

Criteria CLAUDE.md Rules Claude Code PreToolUse Hooks
Type Contextual prompt instruction Shell/executable program
Reliability ~90% (probabilistic, context-dependent) 100% (deterministic enforcement)
Best for Code style, naming conventions, architecture notes Security blocks, syntax checks, Git protection
Failure mode Model forgets or drifts under long context Execution halted outright via exit 2
⚠️ Rule of Thumb: If breaking the rule would cost you a bad PR review, CLAUDE.md is fine. If breaking the rule would cause a production incident or wipe unstaged work, write a PreToolUse hook.

Wrapping Up: From Assistant to Production-Grade Teammate

The difference between a Claude Code setup that feels like a chaotic intern and one that feels like a senior engineer you trust with main usually boils down to exactly this — guardrails that don't rely on the model remembering to behave. PreToolUse hooks turn soft suggestions into hard, OS-level enforcement, and the exit 2 feedback loop means Claude actually learns from every blocked attempt instead of just getting stuck.

💡 Next Action Steps

Start small. Drop the destructive-command blocker into .claude/settings.json today, watch it catch its first force-push attempt, and layer on the linting hook once that feels solid.

If you've built your own hook that caught something risky or have questions about integrating custom parsers, share your thoughts below!

댓글

이 블로그의 인기 게시물

Gemini Many-Shot Prompting: Why 500 Examples Beat Fine-Tuning

No More Git Conflicts: Automate PR Reviews with Cline

기밀 유출 없는 DeepSeek R1 무료 로컬 실행법