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.