article

Stop Claude Code Reading Your .env: deny vs hook vs sandbox

TL;DR To keep Claude Code out of your .env, permissions.deny (Read(./.env)) stops the honest mistakes — the Read tool and a literal cat .env — but it is a string match, so node -e readFileSync('.env') or a filename assembled from fragments reads right through. A PreToolUse hook catches the script but still falls to the assembled name. Only the sandbox (sandbox.enabled with sandbox.filesystem.denyRead, Claude Code v2.1.187+) blocks every bypass at the OS level with EPERM. Ranking: sandbox > hook > deny. deny is a mistake-guard, not a security boundary.

My own .env.local had no protection at all. I found that out while trying to answer someone else’s question about how to lock theirs down — and realized I’d never actually tested whether the usual advice for protecting .env (or .env.local) from Claude Code actually works.

The usual advice is permissions.deny. Add Read(./.env) to your settings.json and Claude Code won’t read your secrets. A web search I ran while drafting a reply went further: it told me permissions.deny only blocks the Read tool, that cat .env is a different path that slips past it, and that a PreToolUse hook is “the only fully reliable protection.” I almost pasted that into a public answer.

Then I tested all three on this machine (Claude Code 2.1.207, macOS). Every result in this article comes from the same test environment shown below, and I reverted every setting afterward. Both of those claims turned out wrong. deny does block cat .env. And a hook is not reliable — it falls to the same trick deny does. The only defense that actually held was the sandbox.

Here’s the ranking up front: sandbox > hook > deny. deny is a mistake-guard, not a security boundary. This post is the measurement behind that, and a checker at the end so you can see where your own setup lands. It’s a direct sequel to my post on Claude Code MCP permissions — that one was about what the agent can reach; this one is about what happens when you try to slam one specific door.

The three defenses, in one line each

Before the numbers, what each thing actually is. This matters, because the reason two of them fail is baked into what they are.

Two of these read a string and decide. One of them guards the file itself. Keep that distinction in mind — it’s the whole result.

Tested environment

What I measured, and against what

I made a dummy file named .env with a fake secret in it (never my real .env.local). Then I tried to get the agent to read it five different ways, under each defense. These are the exact commands, from honest to adversarial:

  1. Read tool — the agent’s normal file read.
  2. cat .env — the literal filename in a Bash command.
  3. node -e "require('fs').readFileSync('.env')" — a script that opens the file, literal name.
  4. assembled filename (runtime filename construction with shell variables) — building the path from fragments so .env never appears as one string:
    A=".e"; B="nv"; cat "${A}${B}"
  5. node + assembled — the script and the assembled name together. The strongest.

The point of #4 and #5 isn’t that an attacker types them. It’s that a string-matching defense can’t tell the difference between “the agent innocently reads a file” and “a filename got built out of pieces.” If the literal .env never shows up in the text, a string match has nothing to match.

The results

The table below summarizes every read attempt — blocked or successful — against all three defenses under identical conditions.

Bypasspermissions.denyPreToolUse hooksandbox
Read toolblockedblockedblocked
cat <file> (literal)blockedblockedblocked
node -e readFileSync(<literal>)readblockedblocked
assembled filename (cat "${A}${B}")readreadblocked
node + assembled (strongest)readreadblocked (EPERM)

Read the columns top to bottom. deny holds for the two honest cases and breaks the moment a script opens the file. The hook survives the script — it can see readFileSync('.env') in the command string — but breaks on the assembled name. The sandbox blocks all five. Every adversarial read against it came back EPERM: operation not permitted.

One correction to that web search I almost trusted: it claimed cat .env slips past deny. In this version it doesn’t — deny blocks the literal Bash path fine. What leaks past deny is the script and the assembled path, not cat. Getting that detail wrong in a public reply is exactly the kind of thing this blog exists to avoid. A wrong fact is the most expensive thing you can publish.

Why deny and hook leak

Both deny and the hook are looking at text. That’s the entire story.

deny is a string match on the request, so a script reads through it. It matches the tool call and the command string. The Read tool is blocked (the path is right there in the call), and cat .env is blocked (the path is right there in the command). But node -e "require('fs').readFileSync('.env')" hands the file operation to Node — from deny’s point of view the risky thing is inside a script it doesn’t parse, and it lets the command run. Conclusion: deny catches literal paths, nothing more.

The hook greps the whole command, so it catches the script — but not an assembled name. It can see .env sitting inside the Node one-liner, so it rejects that. But grep only finds what’s literally there. Assemble the name from fragments and the literal .env never appears in the command:

A=".e"; B="nv"; cat "${A}${B}"

The hook’s grep sees A, B, and cat "${A}${B}". It does not see .env, because .env doesn’t exist until the shell expands the variables — after the hook has already decided. Bypassed. Conclusion: the hook raises the bar over deny but is still string matching, so “a hook is the only fully reliable protection” is false.

The sandbox is different in kind, not degree

The sandbox never reads your command. It doesn’t care how you spell the filename or whether you use a script. When the process actually calls open() on a denied path, the kernel refuses. EPERM. It doesn’t matter that the name was assembled at runtime, because by the time the name exists, the open is already happening — and that’s the thing being blocked, not the text that led to it.

I confirmed it’s targeted, not a blanket lockout: with the sandbox on and .env denied, package.json still read fine. It blocks the paths you name and nothing else.

That’s the difference between a string match and a boundary. A string match asks “does this request look dangerous?” A boundary asks “is this file allowed to be opened?” — and only the second question survives an adversary who controls how the request is phrased.

The real ranking, and when to use what

sandbox > hook > deny. But “use the strongest” isn’t quite the advice, because they solve slightly different problems.

The prompt-injection case is why the distinction isn’t academic. If a document the agent reads contains an instruction to exfiltrate .env, deny and the hook are exactly the layers a cleverly-phrased instruction can route around — assemble the name, use a script. The sandbox is the layer that doesn’t care what the instruction said.

Check your own setup

I’ll be honest about where this started: my real .env.local had none of these. If yours is the same, this is the two-minute fix. Answer three questions and you’ll see where you land and what to do next.

Is your .env actually protected from Claude Code?

Three questions about the defenses you’ve set up. Nothing leaves your browser.

Do you have a permissions.deny rule for .env (e.g. Read(./.env), Read(./.env.*))?
Do you have a PreToolUse hook that rejects Bash commands touching .env?
Is the sandbox on (sandbox.enabled with sandbox.filesystem.denyRead, Claude Code v2.1.187+)?
Answer all three

Tap yes/no on each to see where your setup lands.

One sentence to keep: a defense that reads your command is a mistake-guard; a defense that guards the file is a boundary. deny and hooks read the command. The sandbox guards the file. If your secrets actually matter, put the boundary in.

Put another way: if your threat model includes prompt injection or malicious instructions, only the sandbox is a real security boundary — a string match is not.

Tested on Claude Code 2.1.207, macOS. Sandbox requires v2.1.187+. All test config and the dummy file were reverted after the run. Drafted with AI assistance, measured and reviewed by me before publishing.