Skip to main content
Learn from production-ready hook implementations used in Claude Code and its plugin ecosystem.

Security Reminder Hook

This hook warns about potential security issues when editing files. Used in the security-guidance plugin.

Configuration

hooks.json
{
  "description": "Security reminder hook that warns about potential security issues",
  "hooks": {
    "PreToolUse": [
      {
        "matcher": "Edit|Write|MultiEdit",
        "hooks": [
          {
            "type": "command",
            "command": "python3 ${CLAUDE_PLUGIN_ROOT}/hooks/security_reminder_hook.py"
          }
        ]
      }
    ]
  }
}

Implementation

security_reminder_hook.py
#!/usr/bin/env python3
"""Security reminder hook for Claude Code."""

import json
import os
import sys
from datetime import datetime

# Security patterns to check
SECURITY_PATTERNS = [
    {
        "ruleName": "github_actions_workflow",
        "path_check": lambda path: ".github/workflows/" in path
        and (path.endswith(".yml") or path.endswith(".yaml")),
        "reminder": """You are editing a GitHub Actions workflow file.

Security risks:
1. **Command Injection**: Never use untrusted input directly in run: commands
2. **Use environment variables**: Instead of ${{ github.event.issue.title }}

Example UNSAFE:
run: echo "${{ github.event.issue.title }}"

Example SAFE:
env:
  TITLE: ${{ github.event.issue.title }}
run: echo "$TITLE"

Risky inputs:
- github.event.issue.body
- github.event.pull_request.title  
- github.event.comment.body
- github.event.commits.*.message
- github.head_ref
""",
    },
    {
        "ruleName": "child_process_exec",
        "substrings": ["child_process.exec", "exec(", "execSync("],
        "reminder": """Security Warning: child_process.exec() can lead to 
command injection vulnerabilities.

Instead of:
  exec(`command ${userInput}`)

Use:
  import { execFileNoThrow } from './utils/execFileNoThrow.js'
  await execFileNoThrow('command', [userInput])

Only use exec() if you need shell features and input is guaranteed safe.
""",
    },
    {
        "ruleName": "eval_injection",
        "substrings": ["eval("],
        "reminder": """Security Warning: eval() executes arbitrary code.

Consider using JSON.parse() for data or alternative patterns.
Only use eval() if you truly need to evaluate arbitrary code.
""",
    },
    {
        "ruleName": "innerHTML_xss",
        "substrings": [".innerHTML =", ".innerHTML="],
        "reminder": """Security Warning: Setting innerHTML with untrusted 
content can lead to XSS vulnerabilities.

Use textContent for plain text or safe DOM methods.
If you need HTML, use a sanitizer library like DOMPurify.
""",
    },
    {
        "ruleName": "os_system_injection",
        "substrings": ["os.system", "from os import system"],
        "reminder": """Security Warning: os.system should only be used 
with static arguments, never with user-controlled input.
""",
    },
]


def get_state_file(session_id):
    """Get session-specific state file."""
    return os.path.expanduser(f"~/.claude/security_warnings_state_{session_id}.json")


def load_state(session_id):
    """Load shown warnings from file."""
    state_file = get_state_file(session_id)
    if os.path.exists(state_file):
        try:
            with open(state_file, "r") as f:
                return set(json.load(f))
        except (json.JSONDecodeError, IOError):
            return set()
    return set()


def save_state(session_id, shown_warnings):
    """Save shown warnings to file."""
    state_file = get_state_file(session_id)
    try:
        os.makedirs(os.path.dirname(state_file), exist_ok=True)
        with open(state_file, "w") as f:
            json.dump(list(shown_warnings), f)
    except IOError:
        pass  # Fail silently


def check_patterns(file_path, content):
    """Check if file matches any security patterns."""
    normalized_path = file_path.lstrip("/")

    for pattern in SECURITY_PATTERNS:
        # Check path-based patterns
        if "path_check" in pattern and pattern["path_check"](normalized_path):
            return pattern["ruleName"], pattern["reminder"]

        # Check content-based patterns
        if "substrings" in pattern and content:
            for substring in pattern["substrings"]:
                if substring in content:
                    return pattern["ruleName"], pattern["reminder"]

    return None, None


def extract_content(tool_name, tool_input):
    """Extract content from tool input."""
    if tool_name == "Write":
        return tool_input.get("content", "")
    elif tool_name == "Edit":
        return tool_input.get("new_string", "")
    elif tool_name == "MultiEdit":
        edits = tool_input.get("edits", [])
        return " ".join(edit.get("new_string", "") for edit in edits)
    return ""


def main():
    # Check if enabled
    if os.environ.get("ENABLE_SECURITY_REMINDER", "1") == "0":
        sys.exit(0)

    try:
        data = json.loads(sys.stdin.read())
    except json.JSONDecodeError:
        sys.exit(0)

    session_id = data.get("session_id", "default")
    tool_name = data.get("tool_name", "")
    tool_input = data.get("tool_input", {})

    # Only process file tools
    if tool_name not in ["Edit", "Write", "MultiEdit"]:
        sys.exit(0)

    file_path = tool_input.get("file_path", "")
    if not file_path:
        sys.exit(0)

    content = extract_content(tool_name, tool_input)
    rule_name, reminder = check_patterns(file_path, content)

    if rule_name and reminder:
        warning_key = f"{file_path}-{rule_name}"
        shown_warnings = load_state(session_id)

        # Only show once per session
        if warning_key not in shown_warnings:
            shown_warnings.add(warning_key)
            save_state(session_id, shown_warnings)
            print(reminder, file=sys.stderr)
            sys.exit(2)  # Block execution

    sys.exit(0)  # Allow execution


if __name__ == "__main__":
    main()

Key Features

  • Session-scoped warnings - Only shows each warning once per session
  • Pattern matching - Checks both file paths and content
  • Configurable - Can be disabled via ENABLE_SECURITY_REMINDER=0
  • Exit code 2 - Blocks execution but allows Claude to see the warning

Bash Command Validator

Validates bash commands and suggests better alternatives.

Configuration

hooks.json
{
  "hooks": {
    "PreToolUse": [
      {
        "matcher": "Bash",
        "hooks": [
          {
            "type": "command",
            "command": "python3 /path/to/bash_command_validator.py"
          }
        ]
      }
    ]
  }
}

Implementation

bash_command_validator.py
#!/usr/bin/env python3
"""Bash command validator hook."""

import json
import re
import sys

VALIDATION_RULES = [
    (
        r"^grep\b(?!.*\|)",
        "Use 'rg' (ripgrep) instead of 'grep' for better performance",
    ),
    (
        r"^find\s+\S+\s+-name\b",
        "Use 'rg --files | rg pattern' instead of 'find -name'",
    ),
]


def validate_command(command: str) -> list[str]:
    """Check command against validation rules."""
    issues = []
    for pattern, message in VALIDATION_RULES:
        if re.search(pattern, command):
            issues.append(message)
    return issues


def main():
    try:
        data = json.load(sys.stdin)
    except json.JSONDecodeError as e:
        print(f"Error: Invalid JSON: {e}", file=sys.stderr)
        sys.exit(1)  # Show error to user only

    tool_name = data.get("tool_name", "")
    if tool_name != "Bash":
        sys.exit(0)

    tool_input = data.get("tool_input", {})
    command = tool_input.get("command", "")

    if not command:
        sys.exit(0)

    issues = validate_command(command)
    if issues:
        for message in issues:
            print(f"• {message}", file=sys.stderr)
        sys.exit(2)  # Block and show to Claude


if __name__ == "__main__":
    main()

Usage

When Claude tries to use grep:
# Claude tries:
grep "pattern" file.txt

# Hook blocks with message:
 Use 'rg' (ripgrep) instead of 'grep' for better performance

# Claude adjusts:
rg "pattern" file.txt

Ralph Wiggum Loop Hook

Implements continuous self-referential AI loops by blocking session exit.

Configuration

hooks.json
{
  "description": "Ralph Wiggum stop hook for self-referential loops",
  "hooks": {
    "Stop": [
      {
        "hooks": [
          {
            "type": "command",
            "command": "${CLAUDE_PLUGIN_ROOT}/hooks/stop-hook.sh"
          }
        ]
      }
    ]
  }
}

Implementation (Simplified)

stop-hook.sh
#!/bin/bash
set -euo pipefail

# Read hook input
HOOK_INPUT=$(cat)

# Check if loop is active
RALPH_STATE=".claude/ralph-loop.local.md"
if [[ ! -f "$RALPH_STATE" ]]; then
  exit 0  # No loop, allow exit
fi

# Parse state (YAML frontmatter)
FRONTMATTER=$(sed -n '/^---$/,/^---$/{ /^---$/d; p; }' "$RALPH_STATE")
ITERATION=$(echo "$FRONTMATTER" | grep '^iteration:' | sed 's/iteration: *//')
MAX_ITERATIONS=$(echo "$FRONTMATTER" | grep '^max_iterations:' | sed 's/max_iterations: *//')
COMPLETION=$(echo "$FRONTMATTER" | grep '^completion_promise:' | sed 's/completion_promise: *//')

# Check if max iterations reached
if [[ $MAX_ITERATIONS -gt 0 ]] && [[ $ITERATION -ge $MAX_ITERATIONS ]]; then
  echo "🛑 Ralph loop: Max iterations ($MAX_ITERATIONS) reached."
  rm "$RALPH_STATE"
  exit 0
fi

# Get last assistant message
TRANSCRIPT=$(echo "$HOOK_INPUT" | jq -r '.transcript_path')
LAST_OUTPUT=$(grep '"role":"assistant"' "$TRANSCRIPT" | tail -1 | \
  jq -r '.message.content | map(select(.type == "text")) | map(.text) | join("\n")')

# Check for completion promise
if [[ -n "$COMPLETION" ]]; then
  PROMISE_TEXT=$(echo "$LAST_OUTPUT" | perl -0777 -pe 's/.*?<promise>(.*?)<\/promise>.*/$1/s')
  if [[ "$PROMISE_TEXT" = "$COMPLETION" ]]; then
    echo "✅ Ralph loop: Detected <promise>$COMPLETION</promise>"
    rm "$RALPH_STATE"
    exit 0
  fi
fi

# Continue loop
NEXT_ITERATION=$((ITERATION + 1))
PROMPT=$(awk '/^---$/{i++; next} i>=2' "$RALPH_STATE")

# Update iteration count
sed "s/^iteration: .*/iteration: $NEXT_ITERATION/" "$RALPH_STATE" > tmp
mv tmp "$RALPH_STATE"

# Block exit and feed prompt back
jq -n \
  --arg prompt "$PROMPT" \
  --arg msg "🔄 Ralph iteration $NEXT_ITERATION | To stop: <promise>$COMPLETION</promise>" \
  '{
    "decision": "block",
    "reason": $prompt,
    "systemMessage": $msg
  }'

exit 0

How It Works

  1. State file - .claude/ralph-loop.local.md contains iteration count and prompt
  2. Stop interception - Hook runs when user tries to exit
  3. Loop control - Checks iteration limit and completion promise
  4. Prompt feedback - Feeds the same prompt back to continue loop
  5. Exit conditions - Max iterations or completion promise detected

Usage

# Start a Ralph loop
/ralph-loop "Continuously improve the codebase" --max-iterations 10

# Loop runs automatically until:
# - Max iterations reached
# - Completion promise detected in output
# - User cancels with /cancel-ralph

HTTP Audit Hook

Posts tool usage to external audit system.

Configuration

hooks.json
{
  "hooks": {
    "PreToolUse": [
      {
        "matcher": "*",
        "hooks": [
          {
            "type": "http",
            "url": "https://audit.company.com/api/log",
            "method": "POST",
            "headers": {
              "Authorization": "Bearer ${AUDIT_TOKEN}",
              "Content-Type": "application/json"
            }
          }
        ]
      }
    ]
  }
}

Request Body

{
  "session_id": "abc123",
  "tool_name": "Bash",
  "tool_input": {
    "command": "git push"
  },
  "timestamp": "2026-03-03T10:30:00Z",
  "user_email": "[email protected]"
}

Response

{
  "decision": "allow",
  "audit_id": "aud-12345"
}
Or to block:
{
  "decision": "block",
  "reason": "This command requires approval from security team",
  "additionalContext": "Ticket #SEC-456 has been created for approval"
}

Session Setup Hook

Configures environment on session start.

Configuration

hooks.json
{
  "hooks": {
    "SessionStart": [
      {
        "hooks": [
          {
            "type": "command",
            "command": "bash ${CLAUDE_PLUGIN_ROOT}/setup-session.sh"
          }
        ]
      }
    ]
  }
}

Implementation

setup-session.sh
#!/bin/bash
set -euo pipefail

# Load hook input (contains session_id, working_directory)
read -r HOOK_INPUT

WORKING_DIR=$(echo "$HOOK_INPUT" | jq -r '.working_directory')
SESSION_ID=$(echo "$HOOK_INPUT" | jq -r '.session_id')

cd "$WORKING_DIR"

# Configure environment
export PROJECT_ENV="development"
export SESSION_ID="$SESSION_ID"

# Load environment variables
if [[ -f .env.local ]]; then
  source .env.local
  echo "✅ Loaded .env.local" >&2
fi

# Start development services
if command -v docker-compose &> /dev/null; then
  if [[ -f docker-compose.yml ]]; then
    docker-compose up -d db redis
    echo "✅ Started Docker services" >&2
  fi
fi

# Install dependencies if needed
if [[ -f package.json ]] && [[ ! -d node_modules ]]; then
  npm install
  echo "✅ Installed npm dependencies" >&2
fi

echo "Session environment configured" >&2
exit 0

Worktree Setup Hook

Configures newly created git worktrees.

Configuration

hooks.json
{
  "hooks": {
    "WorktreeCreate": [
      {
        "hooks": [
          {
            "type": "command",
            "command": "bash ./scripts/setup-worktree.sh"
          }
        ]
      }
    ]
  }
}

Implementation

setup-worktree.sh
#!/bin/bash
set -euo pipefail

# Read worktree info
read -r HOOK_INPUT

WORKTREE_PATH=$(echo "$HOOK_INPUT" | jq -r '.worktree_path')
BRANCH=$(echo "$HOOK_INPUT" | jq -r '.branch_name')

cd "$WORKTREE_PATH"

echo "Setting up worktree: $BRANCH" >&2

# Install dependencies (use cache from main worktree)
if [[ -f package.json ]]; then
  # Link node_modules from main repo
  MAIN_REPO=$(git rev-parse --git-common-dir | xargs dirname)
  if [[ -d "$MAIN_REPO/node_modules" ]]; then
    ln -s "$MAIN_REPO/node_modules" node_modules
    echo "✅ Linked node_modules from main repo" >&2
  else
    npm install
    echo "✅ Installed dependencies" >&2
  fi
fi

# Copy configuration
if [[ -f "$MAIN_REPO/.env.local" ]]; then
  cp "$MAIN_REPO/.env.local" .env.local
  echo "✅ Copied .env.local" >&2
fi

echo "Worktree ready" >&2
exit 0

Best Practices Demonstrated

All examples include:
  • set -euo pipefail in bash scripts
  • try/except blocks in Python
  • Graceful fallbacks for missing data
  • Clear error messages to stderr
  • Session-scoped state files
  • Cleanup of old state
  • Atomic file operations
  • Fallback to defaults when state missing
  • Fast validation checks first
  • Background processes for slow work
  • Caching when appropriate
  • Minimal I/O operations
  • Clear, actionable error messages
  • Emoji indicators for status
  • Informative stderr output
  • Appropriate exit codes

Next Steps

Configuration Files

Learn about settings.json and configuration

Plugin Development

Create plugins with custom hooks

Build docs developers (and LLMs) love