PreToolUse hook on `Edit|Write` is invoked and reaches a verdict, but its exit 2 is not enforced (Windows, agent-frontmatter and settings.json carriers)

Status Open
Reported on v2.1.229
Maintainer reply None cached
Activity 0 comments · opened Aug 14, 2026

Preflight Checklist

  • [x] I have searched existing issues and this hasn't been reported yet
  • [x] This is a single bug report (please file separate reports for different bugs)
  • [x] I am using the latest version of Claude Code

What's Wrong?

Related, closed: #40580 (same mechanism, Linux/WSL2, closed as not planned), #13744 (closed as duplicate). Related but a different phenomenon: #18392 — there the frontmatter hook never runs at all and writes no log; here it runs, writes its log, and only its exit code is dropped.

Summary: A PreToolUse hook on Edit|Write is invoked, receives well-formed input, reads tool_input.file_path and exits 2 — and the write happens anyway. The chain up to the verdict is intact; only the enforcement is missing.

Environment (measured 2026-08-14):

  • Claude Code 2.1.229 (read from session metadata; both measurement sessions carry this version throughout)
  • Claude for Windows 1.30096.1 (194d93) — Claude Code runs in the Windows desktop app, not in a terminal session. (This is why the Terminal/Shell dropdown is set to "other"; it cannot express this.)
  • OS: Windows 11 Pro, 10.0.26200
  • Hook shell: Windows PowerShell 5.1 (powershell.exe; PowerShell 7 is not installed on this machine)
  • Workspace trust confirmed (see the controls in "Steps to Reproduce")

Observed: The hook is invoked, receives well-formed input, reads tool_input.file_path, and exits 2. The write happens anyway. The tool returns File created successfully at: …, the file exists afterwards, there is no error message, and the hook's stderr is not surfaced anywhere the agent or the user can see.

Measured from both known carriers:

| Carrier | Result |
|---|---|
| Hook in an agent definition's frontmatter (.claude/agents/*.md) | Write to a path the hook denies: File created successfully, file exists |
| PreToolUse block in .claude/settings.json, calling a separate .ps1 file | Same, and repeated after a full app restart to guarantee the hook was loaded: same outcome |

The two things a triager will suspect first — both measured out.

  1. The -Command exit-code trap. Not this case. powershell -Command was measured to return exit 1 instead of 2 in the deny case, and exit 1 is a hook error, which lets the write through. That is why a separate .ps1 invoked with -File was used throughout. The reported behaviour is with exit 2 confirmed.
  2. Permission mode — this is not a bypassed permission. The project's .claude/settings.json does carry "defaultMode": "bypassPermissions", and a triager reading the config will stop right there. That key was never in effect in these sessions: the mode selector stood at Accept Edits throughout, and the separate opt-in Allow bypass permissions mode was off — the bypass row in the selector still offered "Enable", i.e. the mode was not even selectable. Both were read off the running UI on 2026-08-14. The settings key is inert here because it is gated behind that opt-in (and there is a separately reported bug class, #75235, about this key not taking effect on Windows/desktop at all). So the observed behaviour is what happens in Accept Edits mode, with bypass never active.

Why this is not a duplicate of #40580. Same mechanism class, but four things differ, and each of them is a reason the earlier "not planned" closure was premature rather than a reason to close this one the same way:

  • Carrier: #40580 was a hook in user settings. This report measures the agent-frontmatter carrier — the one documented as the per-agent scoping mechanism — and the settings.json carrier as a second, independent instance of the same outcome.
  • Tool: #40580 was Read. This is Edit|Write, i.e. the destructive side, where an unenforced deny is a data-loss surface and not an information-disclosure one.
  • Currency: reproduced on 2026-08-14, Claude Code 2.1.229, on Windows — not the Linux/WSL2 configuration of the original report, and long after it was closed.
  • Evidence: #40580 had no way to prove the hook ran. This report has a reproducible trace artefact written by the hook itself, which converts the central open question of that issue ("was the hook even invoked?") into a measured yes.

Impact. A PreToolUse hook is the only mechanism offering path-level write scoping: tools/disallowedTools restrict tool kinds, and permissions.deny applies session-wide and cannot be varied per agent. With exit 2 unenforced, a multi-agent setup's per-agent folder boundaries are documented and inert — every agent can in fact write to every path, and the boundary holds only by the agents' voluntary compliance.

The severity is in the silence: there is no error, no red state, and the hook's stderr never reaches the user. A setup can run for weeks believing it is guarded. Any positive self-test of the hook script stays green throughout, because the script is correct — what fails is the platform's handling of its exit code.

What Should Happen?

A PreToolUse hook matching Edit|Write that exits 2 blocks the tool call, and its stderr is surfaced as the blocking reason. This is what the hooks reference documents.

Applied to the reproduction below: the write in step 5 should not be performed, the target file should not exist afterwards, the tool should report a block rather than File created successfully at: …, and the hook's distinctive stderr message should be visible as the reason. The hook's own trace line from step 6 (… DENY <path>) should be the only effect of the attempt.

Error Messages/Logs

**There is no error message, and that absence is the finding.** No error text, no red state, no hook stderr reaching the agent or the user, nothing in the transcript. What exists instead of a message are three witnesses:

1. **Tool return, verbatim:** No error text, no stderr, no trace of the hook's message.

2. **The file exists afterwards**, confirmed by `Glob`, on a path that was confirmed absent immediately before.

3. **The hook's own trace file — decisive.** The script writes one line per invocation. That file had never existed before this test and only the hook writes it.
- after the denied frontmatter write: `… build DENY docs/legal/probe-guard-p3.txt`
- after the settings.json probe: `2026-08-14T03:25:33+02:00 build ALLOW work/probe-settings-hook-p4.txt`

Correct agent label, correct path in both.

⇒ The hook ran, got well-formed input, parsed `tool_input.file_path`, and produced a verdict. The chain up to the verdict is intact; only the enforcement is missing.

Steps to Reproduce

  1. Write a PowerShell hook script hooks/guard.ps1 that: reads the hook JSON from stdin, takes tool_input.file_path; appends a trace line to a file of its own (timestamp, verdict, path) — this is the load-bearing part of the repro; writes a distinctive message to stderr and exit 2 when the path lies outside an allowed prefix, exit 0 otherwise.
  2. Register it as PreToolUse, matcher Edit|Write, as powershell -NoProfile -ExecutionPolicy Bypass -File hooks/guard.ps1 -Allow work — either in an agent definition's frontmatter, or as a PreToolUse block in .claude/settings.json. Use a script file with -File, not powershell -Command (see the -Command exit-code trap in "What's Wrong?").
  3. Restart the app so the hook is definitely loaded. Confirm the folder is a trusted workspace.
  4. Delete the target file and confirm it is absent, so the next step measures a creation, not an overwrite.
  5. Have an agent write to a denied path using the Write tool — e.g. docs/other-owner/probe.txt. Not via a shell command; a shell write does not match the hook and its success would look like the result.
  6. Read the hook's own trace file.

Result: step 5 returns File created successfully at: …; the file exists; no error text; no hook stderr anywhere. Step 6 shows a fresh trace line with the correct path and a DENY verdict.

Controls run alongside (already ruled out)

  • The hook script itself. Run by hand with the identical stdin JSON: exit 2 and the correct stderr text. Counter-probe on an allowed path: exit 0. A self-test harness calling the guard with a synthetic foreign path (requiring exit 2) and an owned path (requiring exit 0) runs green.
  • **Untrusted workspace — ruled out by measurement and by the documented failure shape.** Measured: the repository appears in .claude.json under three path spellings, all three carrying hasTrustDialogAccepted: true, re-read on 2026-08-14; no trust dialog appeared at session start, consistent with there being nothing left to confirm. Documented: the hooks reference introduces its table "What runs before you trust a folder" with "The columns are the two situations in which you haven't trusted the folder itself", and for subagents states that "the subagent still runs, but Claude Code skips its frontmatter hooks and logs an error to the debug log". A skipped hook leaves no trace file — the documented untrusted behaviour is precisely the opposite of what was observed. The trace line therefore rules the untrusted case out on its own, independently of .claude.json.
  • Overwrite instead of creation. The target file was deleted and confirmed absent before the write.
  • Shell bypass. The write was performed with the Write tool, explicitly not through a shell command.
  • Hook not loaded. The settings.json variant was repeated after a full application restart; same outcome.

Limits

Stated deliberately, because two of these limits are what got #40580 closed.

The settings.json probe was fail-open by design and wrote no trace of its own on the pass-through path; taken alone it cannot distinguish "hook invoked, exit 2 not enforced" from "hook never invoked". For the agent-frontmatter carrier that ambiguity does not exist — the trace line proves both the invocation and the DENY verdict, and the write succeeded regardless. Beyond that: only the Write tool was exercised (Edit was in the matcher but not separately measured), and the writes were performed by subagents, not by the main session.

Claude Model

Opus

Is this a regression?

I don't know

Last Working Version

_No response_

Claude Code Version

2.1.229

Platform

Anthropic API

Operating System

Windows

Terminal/Shell

Other

Additional Information

Host, not terminal: Claude Code runs in Claude for Windows 1.30096.1 (194d93) on Windows 11 Pro 10.0.26200. There is no terminal session involved — the Terminal/Shell dropdown above reads "other" for that reason. The hook processes are started as powershell.exe, i.e. Windows PowerShell 5.1; PowerShell 7 is not installed on this machine.

Hook registration, both carriers, identical command line:

  • carrier A: hooks.PreToolUse in an agent definition's frontmatter under .claude/agents/*.md, matcher Edit|Write
  • carrier B: a PreToolUse block in .claude/settings.json with the same matcher, calling a separate .ps1 file

Permission configuration: .claude/settings.json contains "defaultMode": "bypassPermissions". It was inert in these sessions — the mode selector stood at Accept Edits and the Allow bypass permissions mode opt-in was off; full reasoning in "What's Wrong?", suspect 2. Compare #75235 for that key not taking effect on Windows/desktop.

Trust configuration: .claude.json lists the repository under three path spellings, each with hasTrustDialogAccepted: true (re-read 2026-08-14).

Trace artefact: the hook appends one line per invocation (timestamp, agent label, verdict, path) to a file only it writes; that file did not exist before the test. It is the artefact that distinguishes this report from #40580 and from #18392.

View original on GitHub ↗