# Claude Code hooks: a starter recipe with real config

> One Claude Code hook, the actual JSON, what it blocks, and the two failure modes I hit. The full cookbook ships after 12 hooks in production.

**Canonical URL**: https://agentcookbooks.com/blog/claude-code-hooks-cookbook/

**Published**: 2026-04-28

**Tags**: claude-code, hooks, cookbook

---

Starter recipe: one Claude Code hook, the real `.claude/settings.json` JSON, the Node script behind it, and the two ways it broke before it worked. The hook is a `PreToolUse` matcher on `Write|Edit` that refuses any file path resolving outside the current project root — cheap insurance against the "wrote to `/etc/hosts` because of a bad path" class of mistakes. Both failure modes are real, hit on this same project: a symlinked `node_modules` in a monorepo that defeats `path.resolve`, and a silent-pass that read like "the hook didn't run" when in fact it correctly let an in-place `Edit` through. The fix for each is a one-line change. Full cookbook ships after 12 hooks have each run in production for 14 days. Recipe #1 of 12 below.

## 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`:

```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()`:

```js
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. **Silent success looked like the hook wasn't running.** In-place `Edit`s send the same payload structure as `Write`s — the guard correctly let them through, but with no stderr output I read "no error" as "didn't run" and started debugging a working hook. Add a `console.error` log during development to confirm execution.

## What's next

[Recipe #2 ships next](/blog/claude-code-skill-activation-hook/) — a PostToolUse hook that logs [Claude Code skill](/skills/) activations to disk. Eleven more hooks lined up after that, each shipped only after a real run on real code. Subscribe to RSS for the rest.