Raising Claude Code - Part 2: Enforcement
In Part 1 I drew the line between “persuasion” and “enforcement.”
This is the enforcement side — permissions.deny and Hooks.
“Enforcement” here means “executed by the harness, regardless of Claude’s judgment.” It splits into two roles:
- Prohibition —
permissions.denyphysically blocks dangerous operations - Automation — Hooks auto-run things like lint/format after edits (and can optionally block, too)
Hooks are primarily an automation mechanism, not a pre-execution blocker like permissions.deny. Worth pinning down upfront.
Why enforcement is necessary
Even if you write “rm -rf is forbidden” in CLAUDE.md, Claude can still break it.
Reasons:
- As the session grows long, CLAUDE.md drifts out of attention
- Situational judgment — “this is an emergency” — overrides the prohibition
- Sometimes it’s never re-read at all
In other words, persuasion has limits.
Operations that are one-strike fatal (rm -rf /, git push --force, leaking .env, etc.) need to be stopped by enforcement, not persuasion.
Write permissions.deny first
.claude/settings.json:
{
"permissions": {
"deny": [
"Bash(rm -rf*)",
"Bash(git push --force*)",
"Read(.env)",
"Read(.env.*)",
"Read(**/credentials*)",
"Read(**/secrets*)"
]
}
}
Properties:
- Blocked before execution by the harness
- Doesn’t depend on Claude’s judgment
Bash()matches against the command string;Read()matches against paths (gitignore-style**works)
Key points:
permissions.allowskips the confirmation prompt for commands that match it (e.g.,Bash(npm test)). Useful for commands you run constantly- But never write
Bash(*)inallow. Commands indenyare still blocked, but every other command runs with no confirmation prompt at all. If you want to allow, only allow specific patterns (Bash(npm run *),Bash(git status), etc.) - Deliberately don’t add a deny rule for DB clients. SQL substring matches like
Bash(*DROP TABLE*)slip past with quotes, comments, or case changes. Denying the client itself (Bash(psql*),Bash(mysql*)) blocks even harmlessSELECTs and breaks normal development. The realistic answer is operational: don’t give Claude the production connection string in the first place - Block
.envand credentials on theRead()side. This is the first line of defense against the connection string leaking through the filesystem
How Hooks work
Hooks are not invoked manually like slash commands. They are lifecycle event hooks that fire automatically inside Claude Code, running configured shell commands in the background.
They are configured under the hooks key in .claude/settings.json. There is no dedicated hooks directory.
Main event types:
PreToolUse/PostToolUse— before/after a tool runs (Edit, Write, Bash, etc.)UserPromptSubmit— when the user submits a promptStop/SubagentStop— when a response finishesSessionStart/SessionEnd— session start/endPreCompact— just before context compactionNotification— on notifications
Example: every time Edit or Write modifies a file, run lint on it.
{
"hooks": {
"PostToolUse": [{
"matcher": "Edit|Write",
"hooks": [{
"type": "command",
"command": "FILE=$(jq -r '.tool_input.file_path'); npm run lint -- \"$FILE\""
}]
}]
}
}
What this means:
PostToolUse— the event that fires after a tool runsmatcher: "Edit|Write"— only react when the tool used was Edit or Writecommand— the shell command to run at that moment
JSON arrives on stdin
Easy to miss.
When a hook fires, Claude Code pipes a JSON payload describing the event into the command’s standard input (stdin). It is NOT passed via something like a $CLAUDE_FILE_PATHS environment variable.
For a PostToolUse event on Edit, the JSON on stdin looks like:
{
"tool_name": "Edit",
"tool_input": {
"file_path": "/path/to/edited/file.ts",
"old_string": "...",
"new_string": "..."
}
}
To pull a value out of JSON in a shell, use jq:
jq -r '.tool_input.file_path'
What that means:
jq— a command-line JSON parser-r— print the extracted string raw, without surrounding quotes.tool_input.file_path— the path to read inside the JSON
The example above wraps that in $( ... ) to capture the file path into a shell variable, then passes it on to npm run lint -- "$FILE".
Don’t put heavy work in Hooks
Get this wrong and your dev experience falls apart.
❌ Bad:
- Running the entire test suite in
PostToolUse→ minutes of waiting per edit - Running the full build every time → same problem
- Whole-repo
git statusscans on a huge monorepo → lag
✅ OK:
- Lint / format only the file that was edited
- Type-check (only over the fast, narrow scope)
- Secret scanner (diff only)
Start with just lint and format. Tests should be considered separately, at a different moment (e.g., a Stop hook).
Separate team-shared and personal settings
.claude/settings.json → tracked in Git (shared by the team)
.claude/settings.local.json → ignored by Git (personal)
Add settings.local.json to .gitignore.
What should be team-shared:
- Deny rules for dangerous commands
- Shared lint/format hooks
What goes in personal:
- Settings that include absolute paths
- Personal credentials
- Hooks you’re still experimenting with
Wrap-up
Accident prevention and automation belong in settings.json, not in CLAUDE.md.
- Dangerous operations → physically blocked with
permissions.deny - Quality checks after edits → run automatically via Hooks
- But don’t put heavy work in a Hook
- Split team-shared and personal across
settings.json/settings.local.json
Next is Part 3: Persuasion. What CLAUDE.md should contain, what it shouldn’t, and how to decide rule granularity.