PreToolUse hook on Write/Edit/MultiEdit/NotebookEdit is not enforced (PostToolUse on same matcher fires reliably)
Summary
A synchronous PreToolUse hook configured in .claude/settings.json for Write|Edit|MultiEdit|NotebookEdit is not enforced (and, in later testing, does not appear to be invoked at all) by the CLI — a tool call proceeds even when the hook returns {"permissionDecision":"deny",...}. The PostToolUse hook on the identical matcher fires reliably every time.
Environment
claude --version: 2.1.245 (also reproduced on earlier builds over the past ~2 weeks)- OS: macOS (Darwin 25.3.0)
- Reproduced under both
--permission-mode bypassPermissionsand normal permission mode with--allowedTools
Expected behavior
A PreToolUse hook matching Write that returns a JSON deny decision should block the write, exactly like PostToolUse reliably fires on the same matcher.
Actual behavior
The write proceeds. Two independent lines of evidence:
1. Direct instrumentation of the real subprocess (strongest evidence). Instrumented the hook script itself with a canary (unconditional env dump + captured $result) at the very top, before any of our own opt-in logic. For a real Write call from a live session, the canary captured result=DENY:... — the hook computed the correct deny decision — and the write still completed successfully.
2. --debug hooks trace. Ran claude -p "<prompt that triggers one Write>" --debug hooks --debug-file <path>, twice (once per permission mode above). In both debug logs:
PostToolUse:Writeclearly registers and fires:Hooks: Registering async hook async_hook_NNNNN (PostToolUse:Write) with timeout 30000ms.PreToolUsenever appears anywhere in either log — no registration line, no invocation, no output — for the identicalWritecall, from the identical.claude/settings.jsonblock (same matcher, just the other entry in thehooksarray). For comparison,SessionStart/SessionEndhooks both log cleanHook <name> (<Type>) success/completedlines every run, so hook execution in general is visible in this log;PreToolUsespecifically is the omission.
Minimal repro
.claude/settings.json:
{
"hooks": {
"PreToolUse": [
{
"matcher": "Write|Edit|MultiEdit|NotebookEdit",
"hooks": [{ "type": "command", "command": "echo '{\"permissionDecision\":\"deny\",\"permissionDecisionReason\":\"test\"}'" }]
}
],
"PostToolUse": [
{
"matcher": "Write|Edit|MultiEdit|NotebookEdit",
"hooks": [{ "type": "command", "command": "echo hit >> /tmp/posttooluse-fired.txt" }]
}
]
}
}
Ask Claude to write any file. Expected: denied, file not created. Observed: file is created; /tmp/posttooluse-fired.txt gets a line (PostToolUse fired); no evidence PreToolUse ran.
Impact
Any project relying on a PreToolUse hook to enforce a safety/correctness invariant on Write-family tools (in our case, denying an edit that resolves into the wrong git checkout/worktree) currently gets zero real enforcement from it, silently — the hook can be fully correct, correctly registered, and still provide no protection.
Happy to share the full instrumented reproduction (canary diff + debug logs) if useful.
3 Comments
I could not reproduce this on Linux, with the canary in place — which makes it look either
macOS-specific or fixed between 2.1.245 and 2.1.258. I could not separate those two,
so here is exactly what I ran.
Setup. Claude Code 2.1.258, Linux 6.6.87.2 (WSL2, Ubuntu 24.04). Hooks declared in
a project-level
.claude/settings.jsonin a throwaway directory, not in~/.claude:The PreToolUse script logs its stdin to a canary file first — so "not invoked" and
"invoked and ignored" stay distinguishable — and then denies unconditionally:
Then, from that directory:
Result.
| | observed |
|---|---|
|
repro.txt| not created || PreToolUse canary | one
PREline, with the fullWritetool input || PostToolUse canary | no line — consistent with the call being blocked before it ran |
| permission mode |
bypassPermissions, and the deny still held |So on this build the deny is enforced, and the hook is demonstrably invoked rather than
skipped.
Two variables differ from your report and I cannot separate them: the OS (macOS 25.3.0
vs Linux/WSL2) and the version (2.1.245 vs 2.1.258). Since you saw it "on earlier builds
over the past ~2 weeks", a fix landing in between is plausible but not something my run can
show. If you still see it on 2.1.258, that isolates it to macOS, which is the more useful
outcome for whoever picks this up.
One thing worth adding to the report either way, because it is cheap and it separates the
two failure modes you describe: log the hook's stdin to a file on its first line, before any
of your own logic. "The hook was never invoked" and "the hook returned deny and the write
happened anyway" need very different fixes, and the canary is the only thing that tells them
apart from outside.
Following up on my own comment above, because I think I obscured something in it.
Back then I said I could not reproduce this on Linux, and offered "macOS-specific, or fixed between 2.1.245 and 2.1.258" as the two explanations. I missed a third one that was sitting in plain sight: my repro script and the repro in this issue do not emit the same JSON.
{"permissionDecision":"deny","permissionDecisionReason":"test"}— top level.{"hookSpecificOutput":{"hookEventName":"PreToolUse","permissionDecision":"deny",...}}— nested.I changed the shape without saying so, and then reported "works for me." That is on me.
So I measured both, back to back, changing nothing else.
Setup. Claude Code 2.1.278, Linux 6.6.87.2 (WSL2, Ubuntu 24.04). Throwaway
HOME, one hook registered at a time onPreToolUsewith matcherWrite,--allowedTools Write. Each hook appends a line to a log before printing, so "never invoked" and "invoked and ignored" stay separable. The verdict is whether the file exists on disk afterwards, not what the transcript says.| hook prints | hook ran | file created |
|---|---|---|
| nothing,
exit 0(control) | yes | yes ||
{"permissionDecision":"deny","permissionDecisionReason":…}— top level | yes | yes — not enforced (2 runs) ||
{"hookSpecificOutput":{"hookEventName":"PreToolUse","permissionDecision":"deny",…}}| yes | no || stderr +
exit 2| yes | no |The top-level form is invoked and ignored. That matches the canary evidence in the original report exactly: the hook ran, computed its deny, and the write went through anyway. An unrecognized body is not an error — the hook exits 0, there is no verdict, and the call proceeds.
While I was there I checked two neighbours the same way, in case anyone lands here from a search: the older
{"decision":"block","reason":…}does still refuse on 2.1.278, and{"decision":"DENY"}/{"decision":"deny"}do not (same silent pass-through; the reference namesblockandapproveas that field's values, and I only firedblock).What this does not explain. The second half of the report —
PreToolUsenever appearing in--debug hooks, not even a registration line — is not something I can account for, and it is not what a wrong-shaped body looks like. My hooks logged and the debug trace showed them. So there may well be two separate things here.A way to separate them, if the original reporter can still run it. Keep the same
.claude/settings.jsonfrom the report and change only the hook body.exit 2carries noJSON, so it cannot be misparsed:
shape was the problem;
/tmp/91574-canary.loghas a line → it is beingconsulted and something else is dropping the refusal;
what your
--debug hookstrace says, and the shape is a red herring.For completeness, here are the two bodies I compared, so the difference is visible rather
than described:
Either answer is worth more than my "works for me" was.
@yurukusa — your table cost me a defect, so here is what it found and one combination it does not cover.
I ship a PreToolUse hook that refuses a tool call when it breaks a rule in the user's CLAUDE.md. It emits the nested hookSpecificOutput form and exits 2 — both of the shapes your table says refuse. That looked safe, and it was not, because those two paths carry the message differently.
On the exit-2 path the documented channel back to the model is stderr. My hook wrote the whole explanation into the JSON on stdout and nothing to stderr at all:
```
deny -> exit 2 stdout 379 bytes stderr 0 bytes