Hooks: Deterministic Policy Enforcement for Claude Code Teams
$ grep -n "^##" 2026-03-claude-code-hooks-deterministic-policy-enforcement.md>
- 12:The Linter That Forgot
- 18:The Hook System: What Fires When
- 39:The Exit Code That Breaks Everything
- 68:Five Hooks You Can Deploy This Week
- 72:Hook 1: Prevent Hardcoded Secrets (PreToolUse Block)
- 99:Hook 2: Auto-Format Python Files (PostToolUse)
- 124:Hook 3: Block Wrong Package Manager (PreToolUse Block)
- 143:Hook 4: Enforce Tests Before PR (PreToolUse Block)
- 168:Hook 5: Add Dry-Run to Risky Commands (Input Modification)
- 190:The Escape Hatch That Doesn't Exist
- 196:Governing Hooks Centrally
- 213:Audit Logging via PostToolUse
- 233:Practical Configuration
- 294:What Comes Next
Part 3 of 5 in The Claude Code Enterprise Stack series
Last Tuesday, Claude forgot to run your tests before committing. The week before, it ran them but got impatient with a slow suite and skipped the final assertion. Yesterday, it decided the changes were "too small to need testing."
Three incidents. Three excuses. One root cause: probabilistic compliance, which is a polite name for hoping the AI feels like following the rules today.
You've configured the hierarchy (Part 1) and documented your standards in CLAUDE.md (Part 2). But documentation is a suggestion. Hooks are enforcement — every time, no exceptions, no judgment calls.
I learned this the dull way, on my own machines. I run a PreToolUse deny hook that blocks any command touching .env or secrets/, and I trust it precisely because it doesn't depend on me — or the model — remembering the rule at 2am. The CLAUDE.md note asking nicely never once saved me from myself. The hook that exits 2 every single time does. The whole argument, learned in one tired evening: stop asking the model to be disciplined, and make the discipline structural.
The Linter That Forgot
You document a standard in CLAUDE.md — "always format Python files with Black after edits." Claude reads it, understands it, chooses to follow it. Most of the time. Sometimes it deprioritizes formatting, or doesn't realize the standard applies to this file, or just... doesn't.
Imagine your linter worked that way. "ESLint, please enforce semicolons. Unless you're busy." You'd lose your mind. A hook, by contrast, fires after every Edit to a Python file and runs Black — no decision, no compliance to forget. That's the gap: CLAUDE.md says "please," hooks say "you will."
The Hook System: What Fires When
Claude Code exposes lifecycle points where you can inject automation. Most observe and react. One can stop the world.
| Event | When It Fires | Can Block? | Best For |
|---|---|---|---|
PreToolUse | Before Claude uses any tool | Yes | Security gates, validation |
PostToolUse | After a tool succeeds | No | Audit logging, auto-formatting |
SessionStart | When session begins | No | Setup, initialization |
SessionEnd | When session ends | No | Cleanup, final logging |
UserPromptSubmit | User submits a prompt | No | Input validation, logging |
PermissionRequest | Claude requests permission | Yes | Custom approval workflows |
Stop | Claude finishes responding | No | Quality gates, output validation |
SubagentStart | A subagent starts | No | Logging, context setup |
SubagentStop | A subagent finishes | No | Logging, cleanup |
FileChanged | A file was edited | No | Auto-format, validation |
ConfigChange | Settings were modified | No | Audit logging |
Notification | Event notification | No | External integrations |
For most teams the ones that matter are PreToolUse (the only one that blocks), PostToolUse (logging and automation), and FileChanged (convenience). The rest are for specialized scenarios.
The Exit Code That Breaks Everything
This is where most teams stumble, and where the damage is invisible. Every hook is a script, and its exit code decides what happens next:
- Exit 0: Allow. The action proceeds normally.
- Exit 2: Block. Claude cannot proceed. The error message from stderr appears in the UI.
- Exit 1: Non-blocking error. The action proceeds anyway.
Rendering diagram...
Read that last line again. Exit 1 means the action proceeds. A security hook that exits 1 looks like it works — Claude sees the error, the hook appears to fire — but the action goes through. A locked door with no wall around it.
Every security-critical hook — secrets, destructive commands, review gates — must exit 2. That single digit is the difference between a policy that works and one that's quietly broken while everyone assumes it's protecting them.
Five Hooks You Can Deploy This Week
Theory done. Here are five hooks you can copy, paste, and use today.
Hook 1: Prevent Hardcoded Secrets (PreToolUse Block)
Fires before any Bash command. If the command contains patterns like password=, api_key=, or secret=, it blocks entirely.
#!/bin/bash
# Hook: Security gate - block hardcoded secrets in Bash commands
SUSPICIOUS_PATTERNS=("password=" "api_key=" "secret=" "token=" "AUTH=" "CREDENTIALS=")
# Get the bash command that Claude is about to run
if [[ ! -z "$CLAUDE_TOOL_ARGS" ]]; then
for pattern in "${SUSPICIOUS_PATTERNS[@]}"; do
if echo "$CLAUDE_TOOL_ARGS" | grep -qi "$pattern"; then
echo "Error: Detected potential secret in command"
echo "Pattern: $pattern"
echo "Use environment variables or secrets manager instead"
exit 2 # BLOCK the action
fi
done
fi
exit 0 # ALLOW
Deploy as .claude/hooks/security-check.sh (project-scoped) or in managed settings (org-scoped). It catches the vast majority of common credential mistakes before they reach a PR.
Hook 2: Auto-Format Python Files (PostToolUse)
Fires after every Edit to a Python file and runs Black.
#!/bin/bash
# Hook: Post-edit auto-formatting for Python files
# Only trigger for Python files
if [[ "$CLAUDE_FILE_PATH" == *.py ]]; then
# Check if the file exists (it should, since it was just edited)
if [[ -f "$CLAUDE_FILE_PATH" ]]; then
# Run Black silently
black "$CLAUDE_FILE_PATH" 2>/dev/null
if [[ $? -eq 0 ]]; then
echo "Auto-formatted: $CLAUDE_FILE_PATH"
fi
fi
fi
exit 0 # Never block; this is convenience
Zero configuration from Claude. File gets edited, Black runs, nobody thinks about it.
Hook 3: Block Wrong Package Manager (PreToolUse Block)
Your project uses bun. Claude tries npm. This catches it:
#!/bin/bash
# Hook: Enforce bun for this project (block npm)
if [[ "$CLAUDE_TOOL_ARGS" == *"npm "* ]]; then
echo "Error: This project uses bun, not npm"
echo "Use: bun instead of npm"
exit 2 # BLOCK
fi
exit 0
No more "why is npm not found?" conversations, no more cleaning up package-lock.json files that shouldn't exist.
Hook 4: Enforce Tests Before PR (PreToolUse Block)
Matches when Claude tries to create a GitHub PR, and runs your test suite first:
#!/bin/bash
# Hook: Block PR creation if tests don't pass
if [[ "$CLAUDE_TOOL_NAME" == "mcp__github__create_pr" ]]; then
echo "Running tests before PR creation..."
if npm run test &>/dev/null; then
echo "Tests passed. Proceeding with PR."
exit 0 # ALLOW
else
echo "Error: Tests failed. PR blocked."
exit 2 # BLOCK
fi
fi
exit 0
Every Claude-generated PR goes through your test suite first. No broken PRs slip through. The hook doesn't forget.
Hook 5: Add Dry-Run to Risky Commands (Input Modification)
Starting in Claude Code v2.0.10, PreToolUse hooks can modify inputs, not just block them:
#!/bin/bash
# Hook: Add --dry-run to production deploy commands
if [[ "$CLAUDE_TOOL_ARGS" == *"npm run deploy"* ]]; then
# Instead of blocking, we modify the command to add --dry-run
MODIFIED_ARGS="${CLAUDE_TOOL_ARGS} --dry-run"
# Return JSON decision
echo "{\"modifiedInput\": \"$MODIFIED_ARGS\"}"
exit 0
fi
exit 0
Claude tries to deploy to production; your hook transparently adds --dry-run. The deploy runs, but safely, and Claude doesn't even realize it intervened. Test input modification thoroughly before you rely on it.
The Escape Hatch That Doesn't Exist
A subtle but critical detail: hooks fire recursively. When Claude spawns a subagent (via the @agent-name syntax), every tool the subagent uses also triggers your hooks. A PreToolUse hook that blocks Bash(rm -rf) blocks it for the subagent too.
That kills the obvious workaround — spinning up a subagent to go around the hooks. There is no around. And with allowManagedHooksOnly: true, user-installed plugins can't register their own hooks to bypass your gates. Only managed hooks run.
Governing Hooks Centrally
Hooks get far more powerful when you control them from the top. In your managed settings:
{
"allowManagedHooksOnly": true,
"allowedHttpHookUrls": [
"https://audit-log.company.com/*",
"https://slack.company.com/webhooks/*"
],
"disableAllHooks": false
}
allowManagedHooksOnly: true is the lever: set it, and Claude Code ignores all user, project, and plugin hooks — only your managed ones run. No malicious hook from the marketplace, no rogue hook committed to a repo, no plugin hook that conflicts with your policies. Allowlist HTTP hook URLs to stop data exfiltration, or set disableAllHooks: true to lock down a sensitive environment entirely.
Audit Logging via PostToolUse
For compliance, you need visibility into what Claude Code actually did — not what it said it did. A minimal audit hook:
#!/bin/bash
# Hook: Audit all tool usage to centralized log
AUDIT_LOG="/var/log/claude-code-audit.log"
# Create entry with timestamp, tool name, args
ENTRY="{\"timestamp\":\"$(date -u +%Y-%m-%dT%H:%M:%SZ)\",\"tool\":\"$CLAUDE_TOOL_NAME\",\"user\":\"$(whoami)\",\"project\":\"$CLAUDE_PROJECT_DIR\"}"
echo "$ENTRY" >> "$AUDIT_LOG"
exit 0
Every tool use logs an entry. Feed these into your SIEM and you have the audit trail enterprises require for SOC 2.
Practical Configuration
Two ways to wire hooks in.
Project-scoped (team-shared in git):
{
".claude/settings.json": {
"hooks": {
"PreToolUse": [
{
"matcher": "Bash",
"hooks": [
{
"type": "command",
"command": "${CLAUDE_PROJECT_DIR}/.claude/hooks/security-check.sh"
}
]
},
{
"matcher": "Edit|Write",
"hooks": [
{
"type": "command",
"command": "${CLAUDE_PROJECT_DIR}/.claude/hooks/auto-format.sh"
}
]
}
]
}
}
}
Managed (enforced organization-wide):
{
"managed-settings.json": {
"hooks": {
"PreToolUse": [
{
"matcher": "Bash",
"hooks": [
{
"type": "command",
"command": "/etc/claude-code/hooks/security-gate.sh"
}
]
}
]
}
}
}
The matcher syntax is flexible: Bash matches all Bash commands, Edit|Write matches both tools, mcp__* matches all MCP tools, mcp__github__* matches GitHub MCP tools specifically.
Combined, hooks compound. Block hardcoded secrets, auto-format on every edit, enforce tests before PR creation, deploy managed-only org-wide — and new repos inherit all of it instantly. One-time configuration; the policies enforce themselves after that. You've moved from a collaborative tool to infrastructure, and infrastructure doesn't have opinions about whether this particular commit needs testing.
What Comes Next
In Part 4, we'll package hooks alongside skills and agents into a private marketplace: clone the repo, and a single .claude/settings.json installs the security hooks, auto-formatting, test enforcement, and custom skills. New developers inherit the whole automation layer. Clone and go.
But before that — try one hook this week. Pick one real problem you keep seeing Claude cause: forgotten tests, formatting, wrong package manager. Write the hook, deploy it, watch it fire. Not "Claude, please remember." Just: it's handled.
Series Navigation
- Post 1: Why Configuration Matters More Than Models
- Post 2: Building Your First CLAUDE.md
- Post 3: Hooks: Deterministic Policy Enforcement (you are here)
- Post 4: Skills, Agents, and Private Marketplaces
- Post 5: Measuring ROI and Scaling Beyond Day One
The Cutler.sg Newsletter
Weekly notes on AI, engineering leadership, and building in Singapore. No fluff.
Skills, Agents, and Private Marketplaces: Scaling Team Capabilities
How to package team workflows into reusable skills, distribute specialized agents, and build a private marketplace that scales capabilities across your org without reinventing the wheel.
Building Your First CLAUDE.md: Team Standards as Code
CLAUDE.md is your team's shared playbook—the single file where coding standards, architecture decisions, and Claude Code behaviors live. Done right, it eliminates context-switching and makes Claude Code predictably effective across your entire team.
Why Your Claude Code Configuration Matters More Than Your Model Choice
Enterprise Claude Code isn't about picking the right model—it's about designing a configuration hierarchy that scales from individual workflows to organization-wide policy without friction.