Claude Code hooks: the cookbook starter (one hook, real config)

This is a starter recipe — one hook, the real config, and the two ways it broke on me before I got it right. The full cookbook ships once I’ve run 12 hooks in production for at least 14 days each.

The hook: block writes outside the project root

Goal: if Claude Code tries to Write or Edit a file outside the current working tree, refuse it. Cheap insurance against the “wrote to /etc/hosts because of a bad path” class of mistakes.

.claude/settings.json:

{
  "hooks": {
    "PreToolUse": [
      {
        "matcher": "Write|Edit",
        "hooks": [
          {
            "type": "command",
            "command": "node .claude/hooks/guard-cwd.js"
          }
        ]
      }
    ]
  }
}

.claude/hooks/guard-cwd.js — reads the tool-use payload from stdin and exits non-zero if the target path escapes process.cwd():

const fs = require('fs');
const path = require('path');

const input = JSON.parse(fs.readFileSync(0, 'utf8'));
const target = input.tool_input?.file_path;

if (!target) process.exit(0);

const resolved = path.resolve(target);
const cwd = process.cwd();

if (!resolved.startsWith(cwd + path.sep) && resolved !== cwd) {
  console.error(JSON.stringify({
    decision: 'block',
    reason: `path ${resolved} is outside project root ${cwd}`,
  }));
  process.exit(2);
}

Two ways it broke

  1. Symlinked node_modules in a monorepo. path.resolve followed the link and the resolved path was outside the repo. Fix: fs.realpathSync(cwd) for the comparison root, or whitelist symlinked subdirs explicitly.
  2. The hook ran on Edits that didn’t change the path — Claude Code sends the same payload structure for in-place edits, so the guard correctly let those through, but I initially misread “no error” as “the hook didn’t run.” Add a console.error log line during development so you can confirm execution.

What’s next

Eleven more hooks lined up, each shipped only after a real run on real code. Subscribe to the RSS feed if you want them as they land.