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:

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:

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:

Key points:

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:

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:

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:

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:

✅ OK:

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:

What goes in personal:

Wrap-up

Accident prevention and automation belong in settings.json, not in CLAUDE.md.

Next is Part 3: Persuasion. What CLAUDE.md should contain, what it shouldn’t, and how to decide rule granularity.