Chapter 25 | PreToolUse Hook: Dynamic Validation

9 MIN READ | UPDATED: 2026-05-15

Chapter 25: PreToolUse hook — Dynamic Validation

Learning Objectives

Write scripts that can intercept "seemingly legitimate but actually dangerous" bash commands.

What is a Hook?

sequenceDiagram
    participant CC as Claude Code
    participant Hook as PreToolUse hook
    participant Tool as Real Bash

    CC->>Hook: stdin: tool call JSON
    Hook->>Hook: Parse, Evaluate
    alt Pass
        Hook-->>CC: exit 0
        CC->>Tool: Execute
    else Intercept
        Hook-->>CC: exit 2 + stderr
        CC->>CC: Display rejection reason, do not execute
    end

→ A Hook is an external script triggered by Claude Code before a tool call.

Registration Method

"hooks": {
  "PreToolUse": [
    {
      "matcher": "Bash",
      "hooks": [
        {
          "type": "command",
          "command": "$CLAUDE_PROJECT_DIR/.claude/hooks/guard-bash.py"
        }
      ]
    }
  ]
}

matcher = Tool name (Bash / Edit / Write / ...).

Input JSON Structure

{
  "tool_name": "Bash",
  "tool_input": {
    "command": "rm -rf /tmp/foo",
    "description": "..."
  },
  "session_id": "...",
  "transcript_path": "..."
}

Exit Code Control

exit Meaning
0 Pass, continue
2 Intercept, stderr content displayed to user
Other Error, but usually treated as pass (does not block main flow)

Example: Full Analysis of guard-bash.py

#!/usr/bin/env python3
import json, os, re, shlex, sys
from pathlib import Path

PROJECT_ROOT = Path(
    os.environ.get("CLAUDE_PROJECT_DIR", "/Users/amanda/work/openspec")
).resolve()

DESTRUCTIVE = {"rm", "mv", "cp"}
SHELL_WRAPPERS = {"bash", "sh", "zsh", "dash"}


def is_inside_project(path_str):
    if path_str.startswith("~"):
        target = Path(path_str).expanduser()
    elif path_str.startswith("/"):
        target = Path(path_str)
    else:
        target = PROJECT_ROOT / path_str
    try:
        target.resolve().relative_to(PROJECT_ROOT)
        return True
    except ValueError:
        return False


def check_command(cmd):
    """Recursively validate a shell command string."""
    segments = re.split(r"\s*(?:;|&&|\|\||\|)\s*", cmd)
    for seg in segments:
        seg = seg.strip()
        try:
            tokens = shlex.split(seg)
        except ValueError:
            continue
        if not tokens:
            continue
        head = tokens[0]
        if head == "sudo" and len(tokens) > 1:
            head, tokens = tokens[1], tokens[1:]
        # Recursively handle bash -c "inner"
        if head in SHELL_WRAPPERS and len(tokens) >= 3 and tokens[1] == "-c":
            check_command(tokens[2])
            continue
        if head not in DESTRUCTIVE:
            continue
        for arg in tokens[1:]:
            if arg.startswith("-"):
                continue
            if not is_inside_project(arg):
                print(f"BLOCKED: `{head}` targets '{arg}' outside project ({PROJECT_ROOT})",
                      file=sys.stderr)
                sys.exit(2)


data = json.load(sys.stdin)
if data.get("tool_name") != "Bash":
    sys.exit(0)

cmd = data.get("tool_input", {}).get("command", "")
if cmd:
    check_command(cmd)
sys.exit(0)

Key Techniques

1. shlex.split instead of str.split
   → Correctly handles quotes, spaces, and escapes

2. Command delimiter regex: ; && || |
   → Validate compound commands segment by segment

3. bash -c "inner" recursion
   → Prevents circumvention via indirect calls

4. resolve() then relative_to(PROJECT_ROOT)
   → Paths outside the project will raise ValueError → Reject

Testing the Hook

# Should pass
echo '{"tool_name":"Bash","tool_input":{"command":"rm src/foo.py"}}' \
  | ./.claude/hooks/guard-bash.py
echo "exit=$?"   # 0

# Should intercept
echo '{"tool_name":"Bash","tool_input":{"command":"rm /tmp/foo"}}' \
  | ./.claude/hooks/guard-bash.py
echo "exit=$?"   # 2

Must test locally before deployment.

Scenarios Hooks Cannot Cover

✗ eval "$DANGEROUS_VAR"        Variable content is invisible to the hook
✗ python -c "os.remove('/etc/x')"  Non-shell path
✗ xargs rm < some-list         Indirect call
✗ ln -s Symbolic link escape   Link points outside project but absolute path is within project

→ These can be mitigated with a deny list (prohibit eval / xargs / etc.) or by using Docker.

Anti-patterns

❌ Hook over-inference ("intercept if it looks dangerous")
   → Harms legitimate commands

❌ Hook uses complex regex to parse shell
   → There will always be missed edge cases; please use shlex

❌ Hook exits with 1 / 3
   → Claude Code behavior might be undefined; please only use 0 or 2

❌ Hook writes files / changes state itself
   → Hooks should be pure functions, without side effects

❌ Hook runs slowly (>1s)
   → Every Bash command will be slowed down

What You Can Do Now

  • Write a hook that is project-root-aware
  • Test the hook to ensure "what should pass, passes; what should be intercepted, is intercepted"
  • Understand what hooks cannot cover and how to mitigate it

The next chapter will use hooks in another direction — notifications.