Lab 2.5: Building Guardrails with Hooks
Module: 2.5 - Hooks | ← SlidesDuration: 30 minutes Sample Project: node-express-mongoose-demo
⭐ Optional/Bonus Lab
Learning Objectives
By the end of this lab, you will be able to:
- Configure deterministic hooks in
.claude/settings.json - Create reporting hooks that provide non-blocking feedback (Level 1)
- Implement guardrail hooks that block forbidden code patterns (Level 2)
- Build approval gates for high-risk operations (Level 3)
- Parse hook JSON input using
jqin shell scripts
Prerequisites
jqinstalled on your system (brew install jqorsudo apt install jq)- Completed Labs 2.1-2.4 (or familiarity with Claude Code basics)
- The sample project
node-express-mongoose-demoset up
How Hooks Work
Hooks are shell commands configured in .claude/settings.json. They run automatically when Claude triggers specific events.
- Input: Hooks receive JSON on stdin with
tool_name,tool_input, and session context. - Output: Exit code
0= allow, exit code2= block. Hooks can also output structured JSON on stdout. - STDERR: Error messages written to stderr are shown to Claude when a hook blocks.
Setup
# Navigate to the sample project
cd sample-projects/node-express-mongoose-demo
# Ensure jq is installed
jq --version
# Create a directory for your hook scripts
mkdir -p .claude/hooks
# Start Claude Code
claudeTask 1: Create a PostToolUse Reporter Hook
Time: 8 minutes
Level 1: Report. Create a hook that detects TODO comments in files Claude writes. It warns but does not block — the action already happened.
Step 1: Create the hook script
Create a file at .claude/hooks/check-todos.sh:
#!/bin/bash
# Read JSON from stdin
INPUT=$(cat)
# Extract file_path from tool input
FILE_PATH=$(echo "$INPUT" | jq -r '.tool_input.file_path // empty')
if [ -n "$FILE_PATH" ] && [ -f "$FILE_PATH" ]; then
if grep -q "TODO" "$FILE_PATH"; then
echo "WARNING: File $FILE_PATH contains TODO comments. Track these in the backlog." >&2
fi
fi
# PostToolUse: action already happened, exit 0
exit 0Make it executable:
chmod +x .claude/hooks/check-todos.shStep 2: Configure the hook in .claude/settings.json
{
"hooks": {
"PostToolUse": [
{
"matcher": "Write|Edit",
"hooks": [
{
"type": "command",
"command": ".claude/hooks/check-todos.sh"
}
]
}
]
}
}Step 3: Restart Claude and test
Create a file called todo-test.js with a function that adds two numbers.
Include a TODO comment about adding input validation later.Claude writes the file. The hook runs after the write and prints the TODO warning.
Success criteria:
- [ ] Hook script created and executable
- [ ]
settings.jsonconfigured with PostToolUse hook - [ ] Warning appears after Claude writes a file containing "TODO"
- [ ] The write is NOT blocked (PostToolUse cannot block)
Task 2: Create a PreToolUse Guardrail Hook
Time: 10 minutes
Level 2: Guardrails. Create a hook that BLOCKS writes containing console.log statements. Claude sees the error and automatically adjusts.
Step 1: Create the guardrail script
Create .claude/hooks/block-console-log.sh:
#!/bin/bash
INPUT=$(cat)
TOOL_NAME=$(echo "$INPUT" | jq -r '.tool_name')
# Only check Write tool (content is available in tool_input)
if [ "$TOOL_NAME" != "Write" ]; then
exit 0
fi
CONTENT=$(echo "$INPUT" | jq -r '.tool_input.content // empty')
if echo "$CONTENT" | grep -q "console.log"; then
echo "BLOCKED: console.log statements are forbidden. Use a proper logger instead." >&2
exit 2 # Exit code 2 = block the action
fi
exit 0Make it executable:
chmod +x .claude/hooks/block-console-log.shStep 2: Update .claude/settings.json
Add the PreToolUse section alongside the existing PostToolUse:
{
"hooks": {
"PostToolUse": [
{
"matcher": "Write|Edit",
"hooks": [
{
"type": "command",
"command": ".claude/hooks/check-todos.sh"
}
]
}
],
"PreToolUse": [
{
"matcher": "Write",
"hooks": [
{
"type": "command",
"command": ".claude/hooks/block-console-log.sh"
}
]
}
]
}
}Step 3: Restart Claude and test
Create a file called debug-test.js that logs "hello world" using console.log.Watch what happens: Claude tries to write -> hook blocks it (exit 2) -> Claude sees the error message -> Claude rewrites without console.log.
Success criteria:
- [ ] Hook script created and executable
- [ ] Writing a file with
console.logis blocked - [ ] Claude sees the stderr message and adjusts automatically
- [ ] The rewritten file avoids
console.log
Task 3: Create a PreToolUse Approval Gate
Time: 7 minutes
Level 3: Approval gate. Require explicit human confirmation before Claude modifies database models or migrations.
Step 1: Create the gate script
Create .claude/hooks/db-approval-gate.sh:
#!/bin/bash
INPUT=$(cat)
FILE_PATH=$(echo "$INPUT" | jq -r '.tool_input.file_path // empty')
# Check if the file path touches database-related code
if [[ "$FILE_PATH" == *"models/"* ]] || [[ "$FILE_PATH" == *"migration"* ]] || [[ "$FILE_PATH" == *"schema"* ]]; then
# Output JSON to request human approval
cat <<'GATE'
{
"hookSpecificOutput": {
"hookEventName": "PreToolUse",
"permissionDecision": "ask",
"permissionDecisionReason": "This modifies database-related code. Please review and approve."
}
}
GATE
exit 0
fi
exit 0Make it executable:
chmod +x .claude/hooks/db-approval-gate.shStep 2: Add to the PreToolUse section in .claude/settings.json
"PreToolUse": [
{
"matcher": "Write",
"hooks": [
{
"type": "command",
"command": ".claude/hooks/block-console-log.sh"
}
]
},
{
"matcher": "Write|Edit",
"hooks": [
{
"type": "command",
"command": ".claude/hooks/db-approval-gate.sh"
}
]
}
]Step 3: Restart Claude and test
Add a new field called 'lastLogin' of type Date to the User model in app/models/user.js.Claude attempts the write -> hook detects a models/ path -> you get prompted to approve or deny.
Success criteria:
- [ ] Hook script created and executable
- [ ] Modifying a file in
app/models/triggers a human approval prompt - [ ] You can approve or deny the change
Task 4: Test All Three Levels
Time: 5 minutes
Run through all three guardrails in sequence to verify they work together.
Test 1: Reporter (Level 1)
Create a file utils/helpers.js with a helper function. Add a TODO comment.Expected: File is written. Warning appears about the TODO.
Test 2: Guardrail (Level 2)
Add console.log debugging to utils/helpers.js.Expected: Write is blocked. Claude retries without console.log.
Test 3: Approval Gate (Level 3)
Add a 'createdAt' timestamp field to the Article model.Expected: You are prompted to approve the model change.
Success criteria:
- [ ] All three hooks triggered in the expected order
- [ ] Reporter warns but allows
- [ ] Guardrail blocks and Claude recovers
- [ ] Gate asks for human approval
Debugging Hooks
If a hook doesn't work, check these common issues:
| Issue | Cause | Fix |
|---|---|---|
| Hook doesn't fire | Invalid JSON in settings.json | Validate with `cat .claude/settings.json |
| Hook doesn't fire | Script not executable | Run chmod +x .claude/hooks/your-script.sh |
| Hook doesn't fire | Wrong matcher | Check tool name matches (e.g., Write not write) |
| JSON parsing fails | jq not installed | Install: brew install jq or apt install jq |
| Block doesn't work | Wrong exit code | Use exit 2 (not exit 1) for PreToolUse blocks |
| No error shown | Writing to stdout instead of stderr | Use >&2 for error messages in blocking hooks |
Manual testing tip: You can test a hook script directly:
echo '{"tool_name":"Write","tool_input":{"file_path":"test.js","content":"console.log(1)"}}' | .claude/hooks/block-console-log.sh
echo $? # Should print 2Tips
- Keep hooks fast. Slow hooks make Claude feel sluggish.
- Use stderr for error messages (
>&2) — Claude reads stderr when a hook blocks. - Use stdout for structured JSON — for decisions like
permissionDecision: "ask". - Test scripts manually first before adding them to settings.json.
- Start with Level 1 (report). Upgrade to Level 2/3 only when needed.
Stretch Goals
If you finish early:
- Auto-Formatter: Create a PostToolUse hook that runs
npx prettier --writeon every file Claude touches. - Secret Scanner: Create a PreToolUse hook that blocks writes containing patterns like
AKIA(AWS keys) or-----BEGIN.*PRIVATE KEY. - Branch Guard: Create a PreToolUse hook on the
Bashtool that blocksgit pushtomain.
Deliverables
At the end of this lab, you should have:
- Three hook scripts in
.claude/hooks/(reporter, guardrail, gate) - A
.claude/settings.jsonwith all three levels configured - Evidence that each level works: warnings, blocks, and approval prompts
Next Steps
After completing this lab, move on to Lab 2.7: Modernization Mini-Project - the Day 2 capstone.