cd /news/ai-safety/if-your-file-guard-hook-is-registere… Β· home β€Ί topics β€Ί ai-safety β€Ί article
[ARTICLE Β· art-119187] src=dev.to β†— pub= topic=ai-safety verified=true sentiment=Β· neutral

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

A developer found that Claude Code's PreToolUse hooks registered on the Read tool do not fire when file contents are accessed via Bash commands like cat, allowing secrets to reach the model. Testing on Claude Code 2.1.246 and 2.1.258 showed that permission deny rules in settings.json do block such access, while hooks do not. The findings were prompted by an issue on the anthropics/claude-code repository.

read16 min views1 publishedSep 2, 2026

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,

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.

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.)

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

β€” contains the answer is 4242

harmless.txt

β€” contains nothing 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

| # | 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.

This is the part I actually wanted to write down.

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.

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

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:

python3

on the spot was the classifier, not the rule string (J shows that)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.

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 aFor OS-level enforcement that blocks all

Python or Node script that opens files itself.

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.

python3 -c

line with the filename spelled out in the command string was blocked in my environment (2.1.246)deny

stops Python scriptsIn 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 for cat

, head

, sed

)PreToolUse

hook registered on Read

/Edit

is standing in front of neitherEverything 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.

deny

has a gap too sed -i

was caught. What about other ways to write? Same Edit(./secret.txt)

rule, two

more shapes:

# How it wrote Result
S shell redirect (> ./secret.txt )
blocked (permission-rule string)
T another everyday command that writes to a named file went through β€” file modified, permission_denials empty, 2/2 runs

The redirect was caught. The other one wasn't recognized at all, and the file changed.

I'm not naming the command in T. Printing it here hands every reader a one-word way

around somebody's deny

rule, and Claude Code's own SECURITY.md

directs verified findings

to HackerOne β€” which is where something like this belongs, rather than a blog post. 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 notdeny

and relax" is not the lesson. Closing that properly isIf 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 try writing to it several different ways, checking

permission_denials

in the JSON output each time. 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.

deny

rulesettings.json

) β€” reached the built-in tools cat

, head

, sed

, sed -i

, >

. Missed at least one other everyday write command. Subprocesses that open files themselves are documented as out of scopePreToolUse

hookRead

/Edit

, reaches nothing that goes through BashThe 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.

Open your settings.json

and look at where your hooks are registered.

Read

, Edit

, Write

? Then it is not watching cat

.permissions.deny

β€” and write it as a Read(path)

or Edit(path)

rule. Per the docs, file permissions are checked against those two only: a path rule for Write

, NotebookEdit

, Glob

or the legacy MultiEdit

is Edit(docs/**)

where you'd reach for Write(docs/**)

, and Read(docs/**)

for Glob(docs/**)

. That layer reaches further than a hook β€” further, but not everywhere: see the write gap above. A stronger fence, not a closed one.Bash

as 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.)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

sh -c

,deny

recognizes either. I know cat

, head

, sed

, sed -i

and >

were caught, and that at least one other common write command was notRebuilt 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

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.

── more in #ai-safety 4 stories Β· sorted by recency
── more on @claude code 3 stories trending now
sponsored brought to you by zahid.host 4,200+ EU-deployed projects
reading about agents? ship yours in a single git push.

Run your AI side-project on zahid.host

EU-based hosting, git-push deploys, automatic HTTPS, no cold starts. Free tier with a custom domain β€” perfect for shipping the agent you just read about.

$git push zahid main
β†’ Live at https://your-agent.zahid.host βœ“
Get free account β†’ Pricing
from €0/mo Β· no card required
LIVE [news/if-your-file-guard-h…] indexed:0 read:16min 2026-09-02 Β· β€”