I wrote a PreToolUse hook to keep one file out of the model's context. Registered it on
Read, exit 2. Asked Claude Code to read the file with the Read tool. It stopped. Good.
Then I asked it to read the same file with cat. The hook was never called. Not "called
and passed" — never called. The contents went straight into the transcript.
What did stop the cat was a one-line deny rule in settings.json. The side where I
wrote a script — the side that can hold any rule I want — went right past; the side where I
wrote a single path held.
That surprised me enough to build a rig and measure it. Twenty-odd conditions on Claude
Code 2.1.246, Ubuntu 24.04.4 on WSL2, 2026-08-31. Below is what came out, including the two
controls that nearly made me publish a wrong conclusion.
Re-checked on 2026-09-03, Claude Code 2.1.258. The tables below are from 2.1.246, and a
version had shipped since. Publishing an old measurement as if it were current is its own
kind of wrong, so before posting I re-ran the rows the argument rests on. F (blocker hook on
Read, read via cat) and E (deny rule, read via cat) came out identical. U (blocker
hook on Read, read via the Read tool) has no surviving August output, so every number
for U in this piece is from the 2.1.258 run. D and P, which I had never measured at all,
went in at the same time. Five rows, in the appendix.
A note on who "I" is here. I don't write code. I run Claude Code more or less
unattended — the rig below was built and fired by the Claude Code instance that runs in this
environment; the reading and the writing are its work too, published under my name.
Why I care about this particular question
Hooks are how I stop the accidents: block rm -rf, keep .env out of context, refuse
git push --force. So "how far does the hook I wrote actually reach" is not a style
question here. It decides what breaks.
The prompt for this came from anthropics/claude-code#89716, filed 2026-08-26. Two
claims: permission deny rules reach file commands inside Bash, while PreToolUse hooks
match on the tool name, so once reading moves to Bash they stop being consulted. The issue
is labelled platform:macos and reports 2.1.193. I'm on Linux and 2.1.246 — different
environment, different version. When I found it, it had no comments. (There's one there
now — mine, with the A–G table below.)
The rig
Each condition got its own directory, its own settings.json, its own hook log. Each ran
once through claude -p --output-format json (except where a row below says otherwise),
non-interactive, all in auto mode — no human prompt; a classifier decides permissions
instead. That last detail matters later. Every August condition also carried
"allow": ["Bash(cat:*)", "Read"], so that what was being tested was the deny/hook layer
and not an unrelated permission prompt. (That line is in my comment on the issue, which is
why I can still quote it — the August rig itself is gone.)
Two files in the working directory:
-
secret.txt— containsthe answer is 4242 -
harmless.txt— containsnothing to hide here 7777
Two hooks: a watcher that dumps stdin and exits 0, and a blocker that dumps stdin and
exits 2.
One rule I set before running anything: a zero-byte hook log does not mean "the hook did
not fire." It is indistinguishable from "the run never happened." So exit codes and
stdout went to separate files per condition. A zero-byte log only means something when
there's proof next to it that the run occurred.
Reading
| # | Hook registered on |
deny rule |
How it read | Hook fired | Secret reached the model |
|---|---|---|---|---|---|
| A |
Read (watcher) |
none | Read tool | yes | yes |
| B |
Read (watcher) |
none | cat ./secret.txt |
no | yes |
| C |
Bash (watcher) |
none | cat ./secret.txt |
yes | yes |
| U † |
Read (blocker, exit 2) |
none | Read tool | yes | blocked |
| D | Read |
Read(./secret.txt) |
Read tool | – | blocked |
| E | Read |
Read(./secret.txt) |
cat ./secret.txt |
– | blocked |
| F |
Read (blocker, exit 2) |
none | cat ./secret.txt |
no | yes |
† Every number for U is from the 2.1.258 run in the appendix; I have no surviving August
output for that condition.
– means I didn't read the hook log for that condition in August. I went back and measured
D on 2.1.258 — the log is empty, even though the hook was registered on the very tool that
was used. Details in the appendix.
A is the floor check: the wiring works. C shows the same script fires when you move the
registration to Bash. So the problem isn't the script. It's where it's registered.
Put U and F side by side. Same blocker, same registration on Read. Through the Read tool
it fires and stops the read. Through cat it is never called, so there is nothing to
refuse. That's one hook, alive and dead, one row apart.
Both claims in the issue reproduced on my machine.
Two controls that nearly changed the conclusion
This is the part I actually wanted to write down.
Control 1: the message says "directory", the rule says one file
When D was blocked, the text that came back was:
File is in a directory that is denied by your permission settings.
The rule is Read(./secret.txt). One file. The message says directory.
If that's what really happened, then E ("deny reaches cat") proves nothing — the whole
directory would have been sealed and cat being blocked is trivial.
So I ran the same rule, same directory, different file:
| # |
deny rule |
How it read | Result |
|---|---|---|---|
| G | Read(./secret.txt) |
cat ./harmless.txt |
went through (7777 printed) |
It went through. Via Bash, at least, the rule is still per-file; only the wording of the
message is misleading. (I did not run the matching control on the Read-tool side, so I
can't say the same wording appears there.)
Without that one run I would have published "deny reaches cat" resting on "the directory
was sealed." Same sentence, different thing underneath.
Control 2: I almost cited the model as evidence
The docs describe the reach of deny as file commands Claude Code recognizes in Bash,
"such as cat, head, tail, and sed." What about outside the "such as"?
| # |
deny rule |
How it read | Result |
|---|---|---|---|
| H | yes | head -1 ./secret.txt |
blocked |
| I | yes | sed -n 1p ./secret.txt |
blocked |
| J | yes | python3 -c "print(open('secret.txt').read())" |
blocked |
When J was blocked, the model's reply said it had been refused by the auto mode
classifier. Take that at face value and you get a completely different story: the rule
did nothing, the classifier just dislikes python3 -c.
The habit that saved me was moving one variable. Drop the rule, fire the same line:
| # |
deny rule |
How it read | Result |
|---|---|---|---|
| K | none | same python3 line |
went through (4242 printed) |
J and K were each fired three times; every run matched.
So the rule's presence does change the outcome. But when I opened the actual refusal
strings, two different mechanisms were sitting inside J:
python3 one-liner → Permission for this action was denied by the Claude Code auto mode classifier.
cat secret.txt → Permission to use Bash with command cat secret.txt has been denied.
The string that stopped cat is the permission-rule string, same as in E, H and I. The
string that stopped python3 names the classifier — the thing that decides permissions in
auto mode in place of a human, which is a model. I first dismissed that line as the model
guessing about itself. It isn't. It's the refusal the system returned.
Both can be true at once: the rule's presence may make the classifier more cautious. I
can't separate those with what I ran. So I get three legs, not one:
- Presence of the rule changes the outcome (K shows that)
- What stopped
python3on the spot was the classifier, not the rule string (J shows that) - The classifier is a model. You don't build a wall on top of a model's mood
I did write "the rule stopped it" once, off K alone. Moving one variable tells you
whether the outcome changes. It does not tell you what produced it. Attribution comes
from the evidence at the scene — here, the refusal string.
And then the docs stopped me from overstating it
Separately, I was about to write that deny reaches wider than the four commands the
docs list, because python3 got blocked. Before writing it I went and fetched the sentence
I was going to quote. In full:
Read and Edit deny rules apply to Claude's built-in file tools and to file commands
Claude Code recognizes in Bash, such ascat,head,tail, andsed.
They don't apply to arbitrary subprocesses that read or write files indirectly, like a
Python or Node script that opens files itself. For OS-level enforcement that blocks all
processes from accessing a path, enable the sandbox.
(Emphasis mine. The bold half is the part I would have missed.)
The docs say Python and Node scripts that open files themselves are out of scope. My J is
exactly that Python one-liner. And it was blocked — by the classifier, as we just saw. No
contradiction: the rule never reached it. Something else happened to be standing in that spot.
Had I not opened the refusal string, I'd have generalized "so deny stops Python scripts
too" — a sentence that contradicts the documented contract.
- Fair to write: a
python3 -cline with the filename spelled out in the command string was blocked in my environment (2.1.246) - Not fair to write: therefore
denystops Python scripts
In a safety write-up, erring toward "stronger than it is" is the expensive direction.
Understate it and readers add a layer they didn't need. Overstate it and they skip one they
did. The contract is the thing to design against, and the contract points at the sandbox
for this case.
So the conclusion isn't "deny is wide." It's:
-
denyreaches the file commands Claude Code recognizes in Bash (confirmed forcat,head,sed) - Subprocesses that open files themselves are documented as out of scope. On my rig the classifier happened to stop one, but the classifier is a model — don't count on it. Drop to the OS layer (sandbox) if you need that closed
- And a
PreToolUsehook registered onRead/Editis standing in front of neither
Writing looks the same
Everything above is reads. Same rig, hook registered on Edit:
| # | Hook registered on |
deny rule |
How it wrote | Hook fired | File changed |
|---|---|---|---|---|---|
| M |
Edit (watcher) |
none | Edit tool | yes | yes |
| N |
Edit (watcher) |
none | sed -i "s/4242/9999/" |
no | yes |
| O | Edit |
Edit(./secret.txt) |
sed -i |
– | blocked |
| P | Edit |
Edit(./secret.txt) |
Edit tool | no ‡ | blocked |
‡ measured on 2.1.258 (appendix); – is not measured.
Same shape. The hook on Edit does not see sed -i. The deny rule does. N was run twice
with identical settings; both runs matched.
But deny has a gap too
sed -i was caught. What about other ways to write? Same Edit(./secret.txt) rule, a few
more shapes — and this time I varied the permission mode too, because it turned out to
matter:
| # | How it wrote (Edit(./secret.txt) deny rule in place) |
Result |
|---|---|---|
| S | shell redirect (> ./secret.txt) |
blocked by the deny rule |
| T | a common file-writing command, under bypassPermissions
|
went through — file modified, permission_denials empty |
| T′ | the same command, under the default classifier mode | blocked (3/3) — but by the model classifier, not the deny rule |
The redirect the deny rule caught. The other command it does not recognize — so with the
classifier out of the way (bypassPermissions, where the deny rule is the only gate), the
file changed. To be sure this is command-specific and not just "bypass turns the deny rule
off," I ran sed -i — a command the deny rule does recognize — under the same
bypassPermissions, and it was blocked. So the deny rule's Bash matching is alive under
bypass; it simply does not cover this one command.
In the default mode the same write was blocked, three times out of three — but the refusal
string read "Blocked by classifier," not the deny rule. So in normal use you are usually
still caught, by the model classifier reacting to the deny rule being there. That is the
trap in "move it to deny and relax": the thing saving you is a model, and the moment you
relax to acceptEdits or bypassPermissions trusting the deny rule, it is the only gate
left — and it lets this write through.
I'm not naming the command in T. Printing it here hands every reader a one-word way
around somebody's deny rule. The behavior itself is documented (see the quote below):
the deny rule covers a recognized, non-exhaustive set of Bash file commands, and the
documented way to close the rest is the OS sandbox — so this is a known limitation, not a
vulnerability I'm sitting on. What you need in order to act is the shape, not the word:
the list of commands Claude Code recognizes is not exhaustive, so an Edit() deny rule is
not a guarantee that Bash can't write that file. deny reaches much further than a hook
on Read/Edit, but it is not everything, and "move it to deny and relax" is not the
lesson. Closing that properly is again the OS layer.
If you want to know whether your own setup has this gap, you don't need my word: put a
deny rule on a throwaway file and — running with --permission-mode bypassPermissions
so the deny rule is the only gate — try writing to it several different ways, checking
permission_denials in the JSON output each time. (In the default mode the classifier
steps in first and hides the deny rule's reach, so you won't see the gap.) An empty denial
list next to a changed file is the signal.
The per-file check holds on the write side too. Under the same Edit(./secret.txt) rule,
sed -i on a different file in the same directory went through and changed it. So the
rule is per-file here as well, exactly as G showed for reads.
One more thing, and it's testimony rather than measurement. In M the model volunteered
that auto mode instructs it to make file changes through Bash, and that it used the Edit
tool only because I had named the tool in the prompt. The issue quotes the same auto-mode
guidance. I did not run the control — don't name a tool, see which one it picks — so I
can't put a number on how often the hand goes to Bash unprompted.
The implication survives the weaker evidence anyway: if your hook is on Edit only, it is
watching the road the model takes when you tell it which road to take.
So which one is stronger
-
denyrule (one path insettings.json) — reached the built-in tools andcat,head,sed,sed -i,>, even underbypassPermissions. Missed at least one other everyday write command, which went through under bypass (the default-mode classifier caught it, but that's the classifier, not the deny rule). Subprocesses that open files themselves are documented as out of scope -
PreToolUsehook (a shell script you write) — registered onRead/Edit, reaches nothing that goes through Bash
The awkward part is that the expressiveness runs the other way.
A deny rule holds static path patterns. This file, this extension, this subtree. That's
the whole vocabulary.
A hook can hold anything. Inspect contents. Refuse the fourth file in one session. Refuse
if the last modification came from outside the repo. Every policy you can't express as a
path has to live in a hook — and that container, while it's registered on Read/Edit,
has zero reach over reads and writes that go through Bash.
The narrowness isn't about policy complexity. A hook sees exactly the tool name it was
registered under; move the same script to Bash and it fires (condition C). There's no
"watch this file" unit for hooks, and there is one in the permission layer. That's all it
is. But "that's all" is enough to put everyone who thinks they've fenced off .env inside
the blast radius.
What to check today
Open your settings.json and look at where your hooks are registered.
- Is a hook meant to protect files registered only on
Read,Edit,Write? Then it is not watchingcat. - If the policy is expressible as a static path, move it to
permissions.deny— and write it as aRead(path)orEdit(path)rule. Per the docs, file permissions are checked against those two only: a path rule forWrite,NotebookEdit,Globor the legacyMultiEditis accepted but never consulted (it does warn at startup). UseEdit(docs/**)where you'd reach forWrite(docs/**), andRead(docs/**)forGlob(docs/**). That layer reaches further than a hook — further, but not everywhere: see the write gap above. A stronger fence, not a closed one. - If it isn't expressible as a path, register the same hook on
Bashas well — and note that you'll have to re-extract the path from the command string yourself. (The permission layer already extracts paths from commands; per the issue, that result isn't handed to hooks.) - If the file must not be readable no matter which way the model gets there, none of the above closes it. The docs point at the sandbox for OS-level enforcement — that's the layer that doesn't care which command was used.
Number 3 is the only move available right now at the hook layer. It means writing the
policy twice and parsing command strings by hand. It isn't a nice shape. It still beats
registering on Read and feeling safe.
To be exact about what I measured: condition C shows a Bash-registered hook is
consulted. I did not run an exit-2 blocker there, so I can't tell you from my own rig that
it refuses. And hand-parsing command strings will miss cases — quoting, pipes, sh -c,
symlinks, relative paths. Expect it to leak the same way the recognition list does.
What I did not measure
- One machine (Ubuntu 24.04.4 on WSL2), three versions: 2.1.246 for the bulk, 2.1.258 for
the five rows in the appendix and for the write-side re-runs (T / T′ and the
sed -icontrol), and the report's 2.1.193 elsewhere - Most of it in auto (default) mode. The write-side rows T / T′ and the
sed -icontrol I also ran underbypassPermissions, to separate the deny rule from the classifier; I did not run manual mode - I don't have the list of commands
denyrecognizes either. I knowcat,head,sed,sed -iand>were caught, and that at least one other common write command was not - As J showed, who refused varies by scene. "Blocked" can be the rule or the classifier, and you can't tell without opening the refusal string
- Issue #89716 was still open as of 2026-09-03. A future version can change every table here
Appendix: five rows re-run on 2.1.258 (2026-09-03)
Rebuilt on the same shape, one run each. Two differences from August: I didn't restate the
allow list, and these runs inherit my global settings.json instead of an isolated
--settings file. The per-condition instruments — hook log, exit code, stdout,
permission_denials — are still per-condition.
| # | Setup | Result on 2.1.258 |
|---|---|---|
| U | blocker hook on Read, no deny, Read tool
|
hook fired (728-byte log, "tool_name":"Read"), 4242 appeared 0 times, blocked, permission_denials records a refusal with tool_name: Read
|
| F | blocker hook on Read, no deny, cat
|
hook log 0 bytes, 4242 appeared 1 time, permission_denials empty
|
| E |
deny: ["Read(./secret.txt)"], no hook, cat
|
4242 appeared 0 times, permission_denials records a refusal with tool_name: Bash, command cat ./secret.txt
|
| D |
deny + watcher hook, both on Read, Read tool
|
hook log 0 bytes, 4242 appeared 0 times, blocked |
| P |
deny + watcher hook, both on Edit, Edit tool
|
hook log 0 bytes, file unchanged, blocked |
(E was re-run without the hook — its firing column had never been read anyway. The outcome
is what matched.)
Three things worth pulling out.
Row F: the zero means something. A zero-byte hook log only counts as evidence because
the same run also printed 4242 and reported no denials. The run happened; the hook simply
wasn't asked.
Rows D and P: the hook log stays empty even when the registration matched. The watcher
was on exactly the tool that was used, and the log is still zero bytes — a deny-blocked
call never gets that far.
And the refusal record splits three ways. This is the part I'd check on your own
machine, because it decides whether your audit trail is real:
| What refused | Where | Recorded in permission_denials? |
|---|---|---|
| a hook (exit 2) | at the tool (U, Read tool) |
yes — tool_name: Read
|
a deny rule |
at the tool (D via Read, P via Edit) | no |
a deny rule |
inside Bash (E, cat) |
yes — tool_name: Bash
|
Only the middle row goes missing from both channels. The hook log is empty and the refusal
isn't in permission_denials; it shows up only as prose in the model's answer. In D that
line came back verbatim as:
File is in a directory that is denied by your permission settings.
To be precise about D and P: permission_denials wasn't empty in those runs. It held the
Bash commands the model then tried in order to explain itself — and in P that command ended
with a cat of the protected file, which is the one attempt that did get recorded. So the
honest claim is narrower than "nothing is logged": the tool-side deny refusal itself
appeared in neither channel in these two runs. One run each, one version. Worth checking
before you trust either channel as a record.
The rig is nothing special — separate directories, separate settings, exit codes and stdout
saved next to the hook logs. If you rebuild it you can get your own numbers on your own
version, which is the only way any of this stays true.
Top comments (0)