All writing

The .env That Vanished: What Claude Code's deny Rules Actually Protect

I had what looked like a tidy, secure setup: secrets denied to the model, and a hook that quietly copied my .env into every new worktree. Then the .env stopped showing up. I was certain I knew why — confident enough to write the explanation down and tell other people. I was wrong, and the way I found out is the whole point of this post.

My setup looked like good hygiene. I run Claude Code against real codebases, and I don't want an agent reading my secrets, so my settings.json denies them outright:

"permissions": {
  "deny": ["Read(./.env)", "Read(./.env.*)", "Read(./secrets/**)"]
}

I also lean hard on git worktrees. When I want the agent to take on a task in isolation, I spin up a worktree with claude -w so it gets its own checkout and never touches my working tree. The one snag: a worktree is a fresh checkout, so it doesn't have my gitignored .env. To fix that I wrote a small WorktreeCreate hook — a script Claude runs whenever it creates a worktree — that copies the .env across:

if [ -f "$project_dir/.env" ]; then
  cp "$project_dir/.env" "$worktree_path/.env" 2>/dev/null || true
fi
echo "$worktree_path"

Clean, in theory. The hook runs at the OS level, so it copies the file and the new worktree comes up ready to run. Except it didn't. The worktrees kept coming up without a .env, and my dev server inside them died on a missing key.

The theory I was sure about

I had an explanation immediately, and it felt airtight. I denied Read(./.env). My hook needs to read .env to copy it. So my own deny rule is blocking my own hook. The two facts sat right next to each other; the causal arrow between them was obvious.

It was obvious enough that I treated it as known. I wrote it up as a tip — the kind of thing you post with confidence: "Heads up: adding your .env to Claude Code's deny array silently breaks its worktree file-copying, because the engine that does the copy is also banned from reading the file. Use an OS-level hook to route around the sandbox." It reads like a real finding. It has a mechanism. It has a fix. It was also completely wrong, and the only reason I know that is that I eventually stopped theorizing and watched what actually happened.

The tell I ignored

A clean story that explains a bug is not evidence that the story is true. "Deny blocks read, hook needs read, therefore deny breaks hook" is a syllogism, not an observation. I had three facts and a plausible line connecting them — and I never checked whether the line was real.

Instrument, don't guess

The thing that finally broke the deadlock was deciding the kind of failure would tell me everything. If the deny rule was really the cause, the copy would fail with a permission errorEPERM, "operation not permitted." If it was something dumber, it would fail some other way. So I replaced my hook with an instrumented copy that logged exactly what it saw and, crucially, the exact error from the cp:

# can THIS process even read the file?
cat "$project_dir/.env" >/dev/null \
  && echo "cat .env: OK (readable)" \
  || echo "cat .env: FAILED rc=$?"

# does the destination exist yet?
echo "target dir exists: $( [ -d "$worktree_path" ] && echo YES || echo NO )"

# do the copy and keep the real error
cp "$project_dir/.env" "$worktree_path/.env"   # no 2>/dev/null this time

Then I created a worktree with the deny rule fully in place, and read the trace. Here is what it printed, trimmed to the parts that matter:

CLAUDE_PROJECT_DIR=/private/tmp/wt-test
whoami=pavel  uid=501                  # runs as me, not a locked-down user
--- source .env checks ---
test -f project/.env: YES
cat .env: OK (readable)                # ← the hook reads .env just fine
--- target dir checks (before) ---
test -d worktree_path: NO              # ← the worktree dir isn't there yet
--- cp attempt ---
cp: /private/tmp/wt-test/.worktrees/wtA/.env: No such file or directory
cp FAILED rc=1                         # ← ENOENT, not EPERM

Two lines demolished my theory in one read.

  • cat .env: OK — the hook read the very file my deny rule supposedly forbade, no problem, running as my own user. The deny rule wasn't stopping anything here.
  • No such file or directory — the copy failed with ENOENT, not a permission error. The destination directory didn't exist. The secret had nothing to do with it.

My confident mechanism was fiction. The deny rule was a bystander I'd convicted on circumstantial evidence.

What deny actually gates

Here is the mental model I'd gotten wrong, and the one that's actually correct.

Claude Code's deny array is an in-process gate on the model's own tool calls. When the LLM decides to use the Read tool on ./.env, Claude checks that request against your permission rules before it executes, and refuses. The rule lives entirely inside the agent's tool-dispatch logic. It governs what the model is allowed to ask for.

A hook is not the model asking for anything. It's a shell script Claude spawns as an ordinary OS process — running as me, uid 501, with my full filesystem access. The kernel copying a file has no idea that, one layer up, an LLM is forbidden from reading it. The two things live on completely different planes.

The correction

deny is not an OS sandbox. It restricts the agent's tools, not the processes those tools (or your hooks) shell out to. A Read(./.env) deny rule stops the model from reading your secret. It does nothing to a cp, a cat, a node, or any other subprocess — those inherit your real permissions.

That distinction matters well beyond my hook. If your threat model is "I don't want the LLM slurping my secrets into its context," deny rules are exactly the right tool. But if you ever catch yourself reaching for deny rules to achieve real isolation — to stop code from touching a file at the OS level — you're at the wrong layer. The agent's permission system and the operating system's permission system are not the same fence, and only one of them is load-bearing against a process that already runs as you.

So what actually ate the .env?

Once I knew it was ENOENT, the real bug was almost embarrassing. When you configure a WorktreeCreate hook, Claude hands worktree creation to your hook and uses whatever path the hook prints on stdout. The hook owns making the directory. Mine never did. It computed $worktree_path, immediately tried to cp a file into that directory — which didn't exist yet — and the copy failed.

And then the part that kept me in the dark for so long: my own defensive reflex.

cp "$project_dir/.env" "$worktree_path/.env" 2>/dev/null || true

I'd muted the copy's stderr and OR-ed the failure away, on the theory that "a missing .env shouldn't break worktree creation." Reasonable instinct, terrible outcome: it took the one error that would have told me the truth on day one — no such file or directory — and threw it in the bin. The hook reported success and produced nothing. I'd built a machine for hiding the evidence from myself.

The fix is one line plus letting errors speak:

if [ -f "$project_dir/.env" ]; then
  mkdir -p "$worktree_path"                       # create it first
  cp "$project_dir/.env" "$worktree_path/.env" >&2 \
    || echo "worktree-setup: failed to copy .env into $worktree_path" >&2
fi
echo "$worktree_path"   # stdout stays reserved for the path Claude reads

With that in place I recreated the worktree — deny rule still active — and the .env was sitting there. The fix had nothing to do with permissions, sandboxes, or routing around the agent. It was mkdir -p.

What I believedWhat was true
The deny rule blocked my hook from reading .env The hook read .env fine; deny only gates the model's tools
Failure was a permission error (EPERM) Failure was a missing directory (ENOENT)
The fix was an OS-level workaround for the sandbox The fix was mkdir -p before the copy

What I'm taking from it

I left the wrong tip up. I'm not interested in pretending I had it right the whole time — being wrong in public and then showing the correction is more useful to other people than a stream of clean, confident takes that quietly skip the part where the author was fooled.

The durable lessons, the ones I actually changed my behavior over:

  • A plausible mechanism is not a verified one. "Deny blocks read, hook needs read, therefore…" is a story. Stories that explain the symptom are the easiest thing in the world to invent and the hardest to doubt.
  • The kind of failure is the cheapest, highest-signal clue you have. EPERM and ENOENT point at completely different worlds. One trace that preserved the errno collapsed a week of theorizing into thirty seconds.
  • Stop swallowing errors you haven't read yet. 2>/dev/null || true is how a bug hides in plain sight. "Best effort" should still tell you when the effort failed.
  • Know which fence is load-bearing. An agent's permission rules and the OS's permission rules are different layers. Don't reach for one expecting the guarantees of the other.

The agent never touched my secret. The operating system was doing exactly what I told it. The only thing standing between me and a working worktree was a directory I forgot to create — and a habit of looking away from the one message that would have said so.

Written by Pavel Kerbel — Software Engineer. More writing