PreToolUse hooks can't reach the Bash file-command recognition that Read/Edit deny rules use, and the Bash-first directive routes reads there

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

Summary

PreToolUse hooks are matched by tool name — the hooks reference states that the matcher filters on tool name — so a hook registered on Read, Edit, Write or NotebookEdit stops being consulted once the traffic it was written to police moves to Bash.

The permission layer does not have that problem. From the permissions reference:

Read and Edit deny rules apply to Claude's built-in file tools and to file commands Claude Code recognizes in Bash, such as cat, head, tail, and sed.

So Claude Code already resolves file paths out of recognized Bash commands and applies file-scoped policy to them. The gap this report is about is that hooks cannot reach that recognition. Two mechanisms express file-scoped policy; one follows the traffic when it moves to Bash, and the other one does not.

That ordering is backwards from what the two mechanisms are for. A deny rule expresses a policy that can be written as a static path pattern. A hook exists for the policy that cannot: whether a file's last modification came from outside the repo, whether its content matches a classification, whether this is the fourth file the agent has opened in the same tree this turn. Those have no deny-rule expression, so today they are weaker against Bash-mediated file I/O than a policy simple enough to be a path glob.

What turns this from a latent gap into the default path is the directive in the system prompt for auto mode and bypass-permissions mode, quoted here from our own session's context:

Do your work through the Bash tool wherever it can accomplish the job: read files with cat, head, or sed -n, search with grep and find, and make file changes with sed, heredocs, or short scripts, rather than using the dedicated Read, Edit, or Write tools.

Models reach for cat on their own, so hooks were never fully covered — tool-name matchers never described Bash-mediated file I/O. What the directive changes is the distribution: Bash becomes the instructed primary path, and a file-scoped hook goes from occasionally skipped to effectively never consulted. Nothing reports the change. The hook does not error; it is simply no longer in the path, and a gating hook's silence is indistinguishable from its consent.

The hooks guide names the property this costs, in the sentence that introduces the feature: hooks give you deterministic control, "certain actions always happen rather than relying on the LLM to choose to run them." A file-scoped hook in these modes no longer has that property, and it loses it specifically because the model is now instructed to choose the path where the hook does not run.

Where each layer lands

Our reading of the documentation. The command lists it gives are by example, so we cannot enumerate the middle column from outside:

| | dedicated Read/Edit tool | recognized Bash file command (cat, head, tail, sed) | arbitrary subprocess (python3 -c, node -e) |
| :-- | :-- | :-- | :-- |
| Read/Edit deny rule | applies | applies | does not apply |
| Sandbox filesystem boundary | n/a | applies | applies |
| PreToolUse hook on Read/Edit/Write | applies | not consulted | not consulted |

The bottom right cell is the one with no remedy. The documentation is explicit that deny rules do not reach a script that opens files itself and points to the sandbox for OS-level enforcement, but a sandbox enforces a fixed boundary rather than a computed decision, so a hook's policy has nothing to fall back to there.

Why this is filed on the read side

The write side of this is #89251. We are filing the read side separately because the two fail differently and the read one cannot be undone: a skipped write guard produces a bad edit, which is recoverable, while a skipped read guard puts the content in the context window, and with a cloud model it has left the machine before anyone notices. The guards that sit on Read are usually confidentiality guards — secret files, credential paths, trees the agent is not supposed to look into.

To be accurate about precedence: the read case has already been raised in a comment on #88565, by someone who found that Bash reads defeat path-scoped rule injection too. That comment is about rules; this report is about the hook layer, where the remedy is different. We mention it because the observation is not ours first.

It is not only our configuration

The hooks guide's Block edits to protected files recipe — the documented way to stop Claude touching .env, package-lock.json and .git/ — registers "matcher": "Edit|Write" and reads .tool_input.file_path. In these modes it is routed around by default, and its own verification step ("ask Claude to add a comment to your .env file") stops demonstrating what it was written to demonstrate, because the edit arrives as sed -i or a heredoc.

We have not run that recipe verbatim and are not claiming we did. It follows from the two documented facts above, and #89251 and #88565 report the write-side behaviour from their own sessions. We raise it because it is the form of this that needs none of our configuration to check, and because it suggests the guidance and the published security recipe were not reconciled with each other.

Repro

  1. Register a PreToolUse hook with "matcher": "Read" that emits permissionDecision: "deny" for some path class.
  2. Start a session in auto mode or bypass-permissions mode — the two the directive is attested in.
  3. Ask for the content of such a path.

Observed on Claude Code 2.1.193 with auto mode active: the model followed the directive and read files with shell commands — sed -n, git diff, a python3 one-liner. None of the guards that classify those paths was consulted, because all of them matched on Read/Edit/Write. A direct Read of the same path class is denied by the same hook (verified live), so the hook itself is intact; the directive moves the traffic off the tool it watches.

Two specifics, before we changed anything: git diff .envrc returned content, and a python3 one-liner read a file under a tree one of those guards was written to cover. Neither drew a hook decision. After widening the matchers to include Bash and parsing the command string ourselves, the same class of read draws the intended decision (cat <path>/.env → deny, verified live through the harness).

Neither is evidence that the permission layer misbehaved, and we are not reporting it as such: a python3 one-liner is documented as out of reach for deny rules, and we do not know whether git diff is among the Bash file commands the permission layer recognizes, since that list is given by example. What we are reporting is that the hook layer never saw either call and has no way to.

One correction from re-testing our own guard while writing this up, since it bears on how far command parsing can go. Our extractor's documentation claimed it would catch a literal python3 -c "open('.env')". It does not: the quoted script is a single word, and a basename-driven classifier has nothing to match. That is now a regression test rather than a comment, and it is the same boundary the documentation draws — the interpreter case belongs to the sandbox, not to any amount of command parsing, in a hook exactly as in a deny rule.

Single observations, n=1 each; happy to re-run any of them under instructions.

Relation to existing reports

Cited so triage can merge rather than dupe-close, and so the overlap is visible up front. Corrections welcome if it reads differently from your side.

  • #89251 — same directive, write side, hooks on Write|Edit|NotebookEdit. It records that the identical text appears under While bypass permissions mode is active:, which is why this report no longer attributes the directive to auto mode alone.
  • #88565 — same directive, path-scoped rule injection rather than hooks. Quotes the directive verbatim from the 2.1.238 binary; its comments carry a third-party account of the same directive under bypass-permissions mode and the read-side observation noted above.
  • #88041 — the directive itself ("bashFirst" system prompt) steering edits to sed/heredoc.
  • #87356 — the general form of the first ask below: hooks are tool-call-scoped rather than filesystem-scoped.
  • #87575, #87971 — other consequences of the same directive.
  • #63786 — the original write-side bypass report, closed NOT_PLANNED by the inactivity bot.

What we believe is not on file yet is the hook-layer form of the read case, and the observation that the permission layer's Bash file-command recognition already exists while hooks cannot use it. That second point turns the first ask below from "please build a classifier for hooks" into "please expose the resolution you already perform."

What would close this

  1. Let hook matchers reach the Bash file-command recognition that deny rules already use. A matcher or if condition meaning "this call reads a file, whichever tool it came through" would put a Read guard back in the path without asking hook authors to re-derive path resolution Claude Code has already done. #87356 asks for the filesystem-scoped form of this; #88565 asks for the same resolution on the rules side.
  2. Scope the directive. Prefer the dedicated tools when PreToolUse hooks are registered for them, or when the permission config names paths, and keep Bash preference for everything else. (Same shape as #89251's options 1 and 2.)
  3. Document the interaction. The permissions page states which Bash commands deny rules recognize; the hooks page does not say that tool-name matchers miss Bash-mediated file I/O, and neither page notes that these modes make Bash the primary path for it.

What we changed on our side

Recorded because it may be the whole answer for readers whose policy is static, and because two things we had assumed turned out to be wrong.

  • Enabling the sandbox, and writing the deny rules it needs. Read and Edit deny rules merge into the sandbox filesystem boundary, which covers Bash commands and their child processes (permissions). For a fixed set of protected paths this is the right tool and we should have had it on. Worth stating for anyone reaching for the same remedy: the merge is only as good as what it merges, and one of our machines turned out to carry no deny entries at all — the protection there lived entirely in hooks, which is how it came to depend on a matcher.
  • Hooks do have an if field taking permission-rule syntax such as Bash(cat *), with defined handling of subcommands, $() and backticks. The first version of this report said hook authors must re-implement shell lexing from scratch; that was too strong and is withdrawn. What remains true is that if conditions match command shape rather than the file a command touches, so a guard whose policy is "any read of anything under this tree" still enumerates command forms and parses arguments itself. We hit real false negatives on quoting, redirects, fd prefixes and --flag=value in our first attempt.

Environment

  • Claude Code CLI 2.1.193 on macOS (darwin 25.5.0), Opus 5 session, auto mode active, sandboxing not enabled at the time.
  • Hooks registered in ~/.claude/settings.json (PreToolUse, matchers on tool names).
  • Current release is 2.1.246 and we will re-verify there. Nothing in the changelog between the two retracts or rescopes the directive. 2.1.211 fixed an adjacent case — auto mode overriding a hook's ask decision for unsandboxed Bash — but our hooks return deny and were never invoked at all, so that fix is not an alternative explanation for what we saw.
  • autoMode.classifyAllShell arrived in 2.1.193 as well. The docs describe it as suspending shell allow rules so the classifier evaluates shell commands regardless of the allow list; whether it also covers the built-in read-only commands is not stated. Either way it does not put hooks back in the path, and the classifier is not the deterministic layer hooks are documented to be.

---
<details>
<summary>Edit history</summary>

First revision, for accuracy: the original Repro asserted an untested counterfactual, and "cat .env drew no decision" was a paraphrase; both were replaced with what was actually observed.

Second revision, after checking this report against the existing ones and against the documentation. The directive is not specific to auto mode, so the title and framing were rescoped and the prior reports are now cited. The claim that hook authors must re-implement shell lexing was withdrawn. The section that asked which layer had blocked an ls was speculating about a mechanism that turns out to be documented; the report is now built on what that documentation implies, that the recognition exists and hooks cannot reach it.
</details>

View original on GitHub ↗