PreToolUse hook on Write/Edit/MultiEdit/NotebookEdit is not enforced (PostToolUse on same matcher fires reliably)

Status Open
Reported on v2.1.245
Maintainer reply None cached
Activity 3 comments · opened Sep 2, 2026

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 bypassPermissions and 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:Write clearly registers and fires: Hooks: Registering async hook async_hook_NNNNN (PostToolUse:Write) with timeout 30000ms.
  • PreToolUse never appears anywhere in either log — no registration line, no invocation, no output — for the identical Write call, from the identical .claude/settings.json block (same matcher, just the other entry in the hooks array). For comparison, SessionStart/SessionEnd hooks both log clean Hook <name> (<Type>) success/completed lines every run, so hook execution in general is visible in this log; PreToolUse specifically 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.

View original on GitHub ↗

3 Comments

yurukusa · 1 month ago

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.json in a throwaway directory, not in ~/.claude:

{
  "hooks": {
    "PreToolUse": [
      { "matcher": "Write|Edit|MultiEdit|NotebookEdit",
        "hooks": [{ "type": "command", "command": "bash <dir>/.claude/hooks/deny-write.sh" }] }
    ],
    "PostToolUse": [
      { "matcher": "Write|Edit|MultiEdit|NotebookEdit",
        "hooks": [{ "type": "command", "command": "bash <dir>/.claude/hooks/post-write.sh" }] }
    ]
  }
}

The PreToolUse script logs its stdin to a canary file first — so "not invoked" and
"invoked and ignored" stay distinguishable — and then denies unconditionally:

#!/bin/bash
INPUT=$(cat)
echo "$(date +%s) PRE $INPUT" >> "$CANARY"
printf '%s\n' '{"hookSpecificOutput":{"hookEventName":"PreToolUse","permissionDecision":"deny","permissionDecisionReason":"repro 91574"}}'
exit 0

Then, from that directory:

claude -p 'Create a file named repro.txt in the current directory containing exactly the
word BLOCKED_TEST. Use the Write tool. Do not use Bash.' --permission-mode bypassPermissions

Result.

| | observed |
|---|---|
| repro.txt | not created |
| PreToolUse canary | one PRE line, with the full Write tool 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.

yurukusa · 25 days ago

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.

  • This issue's repro: {"permissionDecision":"deny","permissionDecisionReason":"test"} — top level.
  • What I ran: {"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 on PreToolUse with matcher Write, --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 names block and approve as that field's values, and I only fired block).

What this does not explain. The second half of the report — PreToolUse never 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.json from the report and change only the hook body. exit 2 carries no
JSON, so it cannot be misparsed:

#!/bin/bash
# same registration as the repro; only the body changes
cat >/dev/null
echo "$(date +%s) PRE fired" >> /tmp/91574-canary.log
echo "denied" >&2
exit 2
  • if the write is now blocked → the hook was always being consulted, and the top-level

shape was the problem;

  • if the write still goes through and /tmp/91574-canary.log has a line → it is being

consulted and something else is dropping the refusal;

  • if the write goes through and the log is empty → it really is never invoked, which is

what your --debug hooks trace 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:

# ignored on 2.1.278 — this is the shape in the repro
printf '%s\n' '{"permissionDecision":"deny","permissionDecisionReason":"repro 91574"}'
exit 0

# refuses on 2.1.278
printf '%s\n' '{"hookSpecificOutput":{"hookEventName":"PreToolUse","permissionDecision":"deny","permissionDecisionReason":"repro 91574"}}'
exit 0

Either answer is worth more than my "works for me" was.

rulereceipt · 24 days ago

@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


 So depending on which mechanism the CLI honours when both are present, the refusal could land with no reason attached. The command stops and the model is told nothing about why. That is the failure this thread already named one comment over, about a gate misnaming its own cause — a gate you cannot audit is worse than a rule you can misread. I now write the reason to both channels and accept a duplicate line as the cost.

Which is the gap I would flag in your matrix, because I could not close it myself. You measured nested-JSON with exit 0, and stderr with exit 2. Nobody has measured nested-JSON with exit 2 — and that is the combination anyone following the documentation will produce, since the exit-code table names 2 as the blocking code while the output schema shows the nested body. I cannot separate the two mechanisms from outside: my hook blocks, and I have no way to tell which half did it, or whether my stdout reason survives.

  If you still have the throwaway HOME set up, one row would settle it: nested hookSpecificOutput printed to stdout AND exit 2, with a distinguishable string in each channel, then read which one comes back to the model.

  On the second half of the original report, I have nothing useful. My own contract test is also inconclusive, and for a dull reason worth naming so nobody repeats it: hooks load at session start, and I wrote the settings file into a session that had already been running for three weeks. It could never have fired. The dates were in front of me the whole time.