Claude Code hooks: a starter recipe with real config
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:
{
"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
- Symlinked
node_modulesin a monorepo.path.resolvefollowed the link and the resolved path was outside the repo. Fix:fs.realpathSync(cwd)for the comparison root, or whitelist symlinked subdirs explicitly. - Silent success looked like the hook wasn’t running. In-place
Edits send the same payload structure asWrites — 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 aconsole.errorlog during development to confirm execution.
What’s next
Recipe #2 ships next — a PostToolUse hook that logs Claude Code skill 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.