The rules loaded. The guard didn't.
A Claude Code hook is the part of a setup that can say no. On 24 September 2026 it turned out one of mine had never run in the folder where these articles are drafted, while every CLAUDE.md above that folder loaded as normal. This is what a PreToolUse hook is, where Claude Code looks for it, how the gap was found and closed, and what the hook still cannot see.
Start a project Book a 15-min intro call
// what loads, from where
Where Claude Code looks for each file
Claude Code reads CLAUDE.md files from the working directory and from every directory above it. The documentation states that "CLAUDE.md and CLAUDE.local.md files in the directory hierarchy above the working directory are loaded at launch."1
Settings come from one place. Claude Code "reads the shared .claude/settings.json from the session's primary working directory, so to use a file committed at the repository root, start Claude Code there."2 Hooks are configured in settings files.3
A session started in a subfolder therefore receives the instructions from above it and not the hooks.
The documentation is explicit about which of the two can enforce anything. CLAUDE.md files are treated "as context, not enforced configuration. To block an action regardless of what Claude decides, use a PreToolUse hook instead."1
// the hook
What the guard is
My pipeline runs several Claude Code sessions against one repository. Some files in it are frozen, such as a site's design tokens, which every page reads from. Each project lists its own frozen files in its profile. The develop-ed.com profile freezes the token stylesheet and 50 keep-this-text patterns.
The guard is one PreToolUse hook, registered twice: once for the Edit, Write and MultiEdit tools, and once for Bash. Before a tool runs, Claude Code passes the call to the hook as JSON on standard input. To refuse, the guard prints a decision and exits 0:
{
"hookSpecificOutput": {
"hookEventName": "PreToolUse",
"permissionDecision": "deny",
"permissionDecisionReason": "'…/src/styles/tokens.css' is a FROZEN file (frozen by developed). It must not be auto-edited. …"
}
}
That is the guard's real refusal, with the path and the last sentence shortened. To allow, it exits 0 and prints nothing. The hooks reference lists allow, deny and ask as the three decisions and requires a reason for a deny.3 It also notes that the exit code alone is weaker than it looks: "Claude Code treats exit code 1 as a non-blocking error and proceeds with the action."3 The guard's refusals travel as JSON, so they don't depend on the exit code.
If a profile can't be read, the guard doesn't open up. It prints a warning and still refuses a hardcoded floor, .htaccess, so frozen protection never disappears without a sign.
// the gap
The folder where it never ran
A test on 24 September 2026 settled a suspicion. A hook defined in the repository's root settings fired in a session started at the root, and never in a session started one folder down.
The article desk is one folder down. Every session started there had loaded all the instructions, the repository's CLAUDE.md and the desk's own, and none of the hooks. From that folder, the frozen list was protected by a written rule and nothing else.
The fix is one file: a .claude/settings.json in the desk's folder, with the hooks copied verbatim from the root file and the project named explicitly, so the guard enforces the site's frozen list rather than every project's at once. That copy is a second statement of the same hook, and its own comment says so: if the root hook changes, update this copy.
// the probe
Proving it runs
The first probe asked a fresh session to edit the token stylesheet, replacing a string that wasn't in the file. If the guard worked, the edit would be refused. If it didn't, the edit would fail on the missing string and nothing would change. It looked like a safe test.
It came back with the missing-string error, not the refusal.
The debug log showed why. Claude Code rejected the edit at input validation, logging Edit tool validation error, and no hook ran before it. An edit that can't apply never reaches a PreToolUse hook, so that probe couldn't have shown a refusal, whatever the guard did. It was a check with only one possible answer, which is the problem in A check that has never failed is not tested.
The second probe used the Bash side instead: false && echo probe >> followed by the stylesheet's path. The guard reads the command text and refuses it. If the guard had failed, the shell would still never have run the write, because false stops the &&. The debug log recorded the hook running on a harmless Write and allowing it, then running on that command and returning deny. The stylesheet's checksum was the same before, during and after.
One more result came for free. The session that added the fix picked up the new settings without a restart. The hooks reference says direct edits to hooks in settings files are "normally picked up automatically by the file watcher."3 The guard then refused two of that session's own commands, a checksum and a log append, because each put the stylesheet's path next to a > redirect.
// the census
What the guard still cannot see
The guard's source ends with a census: a written list of what it cannot see, so its gaps don't have to be found by losing a file.
- Indirection. A variable holding the filename,
xargs, or a script that names the file inside itself. The hook sees one command string and expands nothing. - Names that are built, not written. Base64, string concatenation, a one-liner that assembles the path from parts.
- Git commands that rewrite the whole working tree without naming a path.
reset --hard,stash pop, switching branches. These get a warning, never a refusal, becausegit revertis the documented rollback, and a guard that blocks the rollback is one people switch off. - Anything outside the Bash and Edit tools. An MCP server, another editor, a scheduled job.
- A write command it doesn't recognise. New tools arrive faster than the list.
- Keep-this-text patterns on the Bash side. Patterns are checked on edits only. A Bash write that drops one is invisible to that check.
- Files nobody froze. The protection is only as good as the profiles.
The census also records one deliberate over-refusal. A python -c one-liner that names a frozen file is refused even when it only reads, because the hook can't parse Python to tell a read from a write.
// when the guard was wrong
Two times the guard was the problem
On 1 August 2026 a lane that claimed to protect the live copy of each site was removed. It only ran when a profile gave an absolute local path to that copy, and every profile described the server path in prose instead, so the lane never executed. Removing it changed no decision across a 42-command test set. The guard's docstring gives the reason: "Protection that is not there reads worse than no protection."
The same day, git commands that only touch the index stopped counting as writes. git reset HEAD -- . unstages files without changing a byte on disk, but the guard refused it, because . resolved to the repository root and the root contains every frozen file. That command is the recovery for the repository's worst known hazard: a crashed hook stranding another session's untracked files in the shared index. It took three attempts during the pass that fixed it.
// limits
What this does not establish
- Whether Claude Code validates an edit before running hooks on every version. It was observed once, on version 2.1.281, on 24 September 2026.
- How many setups lose their hooks this way. Nobody has measured it.
- Whether this guard has ever stopped a write that would otherwise have happened. These records don't count one.
| Claim | Source | Read |
|---|---|---|
| CLAUDE.md files in the directory hierarchy above the working directory load at launch; CLAUDE.md is context, not enforced configuration | Claude Code documentation, How Claude remembers your project | 24 Sep 2026 |
| The shared .claude/settings.json is read from the session's primary working directory | Claude Code documentation, Settings files and precedence | 24 Sep 2026 |
| PreToolUse runs before a tool executes and can block it; allow, deny and ask; exit code 1 without JSON does not block; hook edits are picked up by the file watcher | Claude Code documentation, Hooks reference | 24 Sep 2026 |
Three sources, all Claude Code's own documentation, read against version 2.1.281. Everything else comes from this pipeline's own records: the guard's source, its change history and the session log of 24 September 2026.
Tell me what you need built.
Remote across the EU and UK, in English. Reply within one business day.
Start a project Book a 15-min intro call
The service this describes: custom software and automation