Skip to content

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 jq in shell scripts

Prerequisites

  • jq installed on your system (brew install jq or sudo apt install jq)
  • Completed Labs 2.1-2.4 (or familiarity with Claude Code basics)
  • The sample project node-express-mongoose-demo set 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 code 2 = block. Hooks can also output structured JSON on stdout.
  • STDERR: Error messages written to stderr are shown to Claude when a hook blocks.

Setup

bash
# 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
claude

Task 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:

bash
#!/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 0

Make it executable:

bash
chmod +x .claude/hooks/check-todos.sh

Step 2: Configure the hook in .claude/settings.json

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.json configured 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:

bash
#!/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 0

Make it executable:

bash
chmod +x .claude/hooks/block-console-log.sh

Step 2: Update .claude/settings.json

Add the PreToolUse section alongside the existing PostToolUse:

json
{
  "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.log is 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:

bash
#!/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 0

Make it executable:

bash
chmod +x .claude/hooks/db-approval-gate.sh

Step 2: Add to the PreToolUse section in .claude/settings.json

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:

IssueCauseFix
Hook doesn't fireInvalid JSON in settings.jsonValidate with `cat .claude/settings.json
Hook doesn't fireScript not executableRun chmod +x .claude/hooks/your-script.sh
Hook doesn't fireWrong matcherCheck tool name matches (e.g., Write not write)
JSON parsing failsjq not installedInstall: brew install jq or apt install jq
Block doesn't workWrong exit codeUse exit 2 (not exit 1) for PreToolUse blocks
No error shownWriting to stdout instead of stderrUse >&2 for error messages in blocking hooks

Manual testing tip: You can test a hook script directly:

bash
echo '{"tool_name":"Write","tool_input":{"file_path":"test.js","content":"console.log(1)"}}' | .claude/hooks/block-console-log.sh
echo $?  # Should print 2

Tips

  • 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:

  1. Auto-Formatter: Create a PostToolUse hook that runs npx prettier --write on every file Claude touches.
  2. Secret Scanner: Create a PreToolUse hook that blocks writes containing patterns like AKIA (AWS keys) or -----BEGIN.*PRIVATE KEY.
  3. Branch Guard: Create a PreToolUse hook on the Bash tool that blocks git push to main.

Deliverables

At the end of this lab, you should have:

  1. Three hook scripts in .claude/hooks/ (reporter, guardrail, gate)
  2. A .claude/settings.json with all three levels configured
  3. 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.

Claude for Coders Training Course