DEV Community

myougaTheAxo
myougaTheAxo

Posted on

Claude Code Hooks: Auto-Format, Security Guards, and Test Triggers on Every Tool Call

Claude Code's Hooks system lets you run scripts automatically before or after every tool call — code writes, bash commands, file edits. Configure them once, and every interaction gets automatic quality enforcement.


What Hooks Can Do

  • Before bash execution → Block dangerous commands (rm -rf /, DROP DATABASE)
  • After file writes → Auto-format with Prettier/Ruff/gofmt
  • After file writes → Scan for leaked API keys
  • After source changes → Run related unit tests automatically

Hook Configuration

Add hooks to .claude/settings.json (project-level) or ~/.claude/settings.json (global):

{
  "hooks": {
    "PreToolUse": [
      {
        "matcher": "Bash",
        "hooks": [{
          "type": "command",
          "command": "python .claude/hooks/guard.py"
        }]
      }
    ],
    "PostToolUse": [
      {
        "matcher": "Write|Edit",
        "hooks": [{
          "type": "command",
          "command": "python .claude/hooks/format.py",
          "timeout": 30
        }]
      }
    ]
  }
}
Enter fullscreen mode Exit fullscreen mode

Exit Code Protocol

For PreToolUse hooks:

Exit Code Meaning
0 Allow — tool executes normally
2 Block — tool execution is cancelled
Other Warning — logged, but execution continues

Only PreToolUse can block. PostToolUse exit codes are ignored.


Guard Script: Block Destructive Commands

# .claude/hooks/guard.py
import json, sys

data = json.load(sys.stdin)
command = data.get("tool_input", {}).get("command", "")

BLOCKED = [
    "rm -rf /",
    "rm -rf ~",
    "DROP DATABASE",
    "git push --force origin main",
    "git push --force origin master",
]

for pattern in BLOCKED:
    if pattern in command:
        print(f"[BLOCKED] {pattern}", file=sys.stderr)
        sys.exit(2)

sys.exit(0)
Enter fullscreen mode Exit fullscreen mode

Auto-Format Script: Language-Aware Formatting

# .claude/hooks/format.py
import json, subprocess, sys
from pathlib import Path

data = json.load(sys.stdin)
file_path = data.get("tool_input", {}).get("file_path", "")
if not file_path:
    sys.exit(0)

p = Path(file_path)
FORMATTERS = {
    ".py":  ["ruff", "format", "--quiet"],
    ".ts":  ["npx", "prettier", "--write"],
    ".tsx": ["npx", "prettier", "--write"],
    ".js":  ["npx", "prettier", "--write"],
    ".go":  ["gofmt", "-w"],
    ".rs":  ["rustfmt"],
}

fmt = FORMATTERS.get(p.suffix)
if fmt:
    subprocess.run([*fmt, str(p)], capture_output=True)

sys.exit(0)
Enter fullscreen mode Exit fullscreen mode

Secret Scanner: Catch Leaked Keys Before They Hit Git

# .claude/hooks/scan_secrets.py
import json, re, sys
from pathlib import Path

data = json.load(sys.stdin)
file_path = data.get("tool_input", {}).get("file_path", "")
if not file_path:
    sys.exit(0)

PATTERNS = {
    "Anthropic": r"sk-ant-api\d{2}-[a-zA-Z0-9_-]{86}",
    "AWS":       r"AKIA[0-9A-Z]{16}",
    "GitHub":    r"ghp_[a-zA-Z0-9]{36}",
    "Stripe":    r"sk_(live|test)_[a-zA-Z0-9]{24}",
    "OpenAI":    r"sk-[a-zA-Z0-9]{48}",
}

EXCLUDES = [r"YOUR_KEY", r"REPLACE_ME", r"example", r"xxxx", r"test_"]

try:
    content = Path(file_path).read_text(errors="replace")
except Exception:
    sys.exit(0)

for name, pattern in PATTERNS.items():
    for match in re.findall(pattern, content):
        if not any(re.search(ex, match, re.I) for ex in EXCLUDES):
            print(f"[SECRET WARNING] {name} key detected: {match[:20]}...", file=sys.stderr)
            # To block instead of warn: sys.exit(2)

sys.exit(0)
Enter fullscreen mode Exit fullscreen mode

Test Runner: Auto-Test on Source Changes

# .claude/hooks/test_runner.py
import json, subprocess, sys
from pathlib import Path

data = json.load(sys.stdin)
file_path = data.get("tool_input", {}).get("file_path", "")
if not file_path:
    sys.exit(0)

p = Path(file_path)
if "src" not in p.parts or p.suffix != ".py" or p.name.startswith("test_"):
    sys.exit(0)

test_file = Path("tests") / f"test_{p.name}"
if not test_file.exists():
    sys.exit(0)

result = subprocess.run(
    ["pytest", str(test_file), "-q", "--no-header", "--tb=short"],
    capture_output=True, text=True, timeout=60
)
print(result.stdout)
if result.returncode != 0:
    print(result.stderr, file=sys.stderr)

sys.exit(0)
Enter fullscreen mode Exit fullscreen mode

Available Environment Variables

Variable Description
CLAUDE_PROJECT_DIR Project root directory
CLAUDE_TOOL_NAME Tool being executed
CLAUDE_TOOL_INPUT_FILE_PATH File path (Write/Edit tools)
CLAUDE_TOOL_INPUT_COMMAND Shell command (Bash tool)

Three Rules for Reliable Hooks

  1. Always set timeout — An infinite-looping hook will freeze Claude Code
  2. Catch exceptions — Hook bugs should never block the workflow (sys.exit(0) in except blocks)
  3. Keep hooks fast — Formatters (< 1s) are fine; full builds are not

Summary

The Hooks system turns Claude Code from a "code generator" into a "quality-enforced development environment." Set them up once, and every AI interaction automatically gets formatted, scanned for secrets, and tested.


If you want pre-built skills with built-in quality guardrails, the Security Pack (¥1,480) and Code Review Pack (¥980) are available on PromptWorks.

👉 prompt-works.jp

Myouga (@myougatheaxo) — Security-focused Claude Code engineer.

Top comments (0)