DEV Community

If your file-guard hook is registered on Read, it never sees cat

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 python3 on 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 -c line with the filename spelled out in the command string was blocked in my environment (2.1.246) - Not fair to write: therefore deny stops 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: - deny reaches 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 cl

Read on DEV Community ↗ ← Back to News

Comments

No comments yet. Start the discussion.