I added Read(./.env) to my deny list, felt responsible, and went back to work.
An hour later I asked Claude Code why DATABASE_URL was undefined in my test runner. It tried the Read tool on .env, got refused, said "I can't read that file directly," and then ran grep DATABASE_URL .env in Bash. I had approved Bash(grep:*) weeks earlier because I was tired of clicking "yes" on every search. My production connection string scrolled past in the transcript.
Nothing was broken. Claude Code deny rules did exactly what they are designed to do. I had just assumed they did something else.
TL;DR
-
Claude Code deny rules are per tool.
Read(./.env)blocks the built-in Read tool. It does not blockcat .env,grep KEY .env, orpython -c "open('.env')"run through Bash. - Bash permissions match command strings, not files. A Bash rule never knows which files a command will touch.
-
Path syntax bites too. In permission rules,
/pathis relative to the settings file,//pathis absolute, and./.envonly matches the.envat the project root. -
Fix in layers: correct file-tool globs, a
PreToolUsehook that blocks Bash commands mentioning.env, and, for real secrets, keep them out of the directory the agent works in.
What do Claude Code deny rules actually block?
Claude Code deny rules block a specific tool from acting on a specific target. A permission rule has the shape Tool(specifier), and the specifier means something different for each tool.
-
Read(...)andEdit(...)take file path patterns, written in gitignore style. -
Bash(...)takes a command string pattern, likeBash(npm run test:*). -
WebFetch(...)takes a domain, likeWebFetch(domain:example.com).
Rules are checked deny first, then ask, then allow. So a deny always beats an allow for the same tool. That part works as advertised.
The catch is the phrase "for the same tool." A Read deny rule is a statement about the Read tool. It says nothing about the Bash tool, which runs arbitrary programs, and arbitrary programs can open any file your user account can open.
Why did Claude Code read my .env through Bash?
Because Bash is a different tool with a different rule namespace. When Claude runs grep DATABASE_URL .env, Claude Code checks that command string against your Bash(...) rules. Your Read(./.env) rule is never consulted.
I sat down and tried every boring way an agent might read a file, all in one project with Read(./.env) denied and a few common Bash allows in place:
| What Claude ran | Gated by Read(./.env)? |
What happened |
|---|---|---|
Read tool on .env
|
Yes | Refused |
cat .env |
No | Prompted, then printed |
grep DATABASE_URL .env |
No | Ran silently (I had allowed grep) |
head -n 5 .env |
No | Prompted, then printed |
python3 -c "print(open('.env').read())" |
No | Prompted, then printed |
node -e "require('dotenv').config(); console.log(process.env)" |
No | Prompted, then printed every variable |
One out of six went through the rule I wrote. The other five depended entirely on whether I read each Bash prompt carefully. On approval number forty of the afternoon, I do not.
To be fair to Claude, it wasn't sneaking. It was debugging, the Read tool failed, and grep is the obvious next move for a model trying to answer my question. Getting past a single tool failure is exactly what you want from an agent, right up until the file is your secrets.
Can a Bash deny rule like Bash(cat .env) fix it?
No. Bash rules match the command text, so they block one spelling and miss the rest. Bash(cat .env) does nothing about:
cat ./.envcat .env.local-
less .env,head .env,tail .env,awk 1 .env while read l; do echo "$l"; done < .envcd config && cat ../.env
The Claude Code docs themselves warn that Bash patterns trying to constrain arguments are fragile, and give a curl URL example that can be bypassed by reordering flags or using a variable. Denying a filename by spelling is the same problem. You'd be writing an allow-list of every program on your machine in reverse.
What path syntax do Claude Code permission rules use?
This was my second mistake, and it's sneakier because the rule looks correct. File rules follow gitignore-style patterns with four anchors:
| Pattern | Meaning |
|---|---|
//Users/me/app/.env |
Absolute path from filesystem root |
~/app/.env |
Relative to your home directory |
/app/.env |
Relative to the settings file's location, not root |
./.env or .env
|
Relative to the current working directory |
If you copy an absolute path from your terminal and paste it as Read(/Users/me/app/.env), you've written a rule relative to wherever that settings file lives. It looks fine and matches nothing.
And ./.env only matches the root file. In a monorepo with apps/api/.env and apps/web/.env.local, it covers neither. You want ** patterns:
{
"permissions": {
"deny": [
"Read(**/.env*)",
"Edit(**/.env*)"
]
}
}
That also blocks .env.example, which is usually fine. If you need it readable, Claude can still get the variable names from your config loader's code.
How do I actually stop Claude Code from reading .env files?
Use three layers. Each one covers a hole the previous one leaves.
Layer 1: correct globs for the file tools. The JSON above. This stops the Read and Edit tools across the whole tree.
Layer 2: a PreToolUse hook on Bash. Hooks see the full command before it runs and can block it, and they apply even to commands you previously allowed. Save this as .claude/hooks/block-env.sh and chmod +x it:
#!/usr/bin/env bash
# Block Bash commands that mention a .env file.
cmd=$(jq -r '.tool_input.command // ""')
if printf '%s' "$cmd" | grep -Eq '(^|[^[:alnum:]_])\.env'; then
echo "Blocked: this command touches a .env file. Ask the user for the specific value you need." >&2
exit 2
fi
exit 0
Then register it in .claude/settings.json:
{
"hooks": {
"PreToolUse": [
{
"matcher": "Bash",
"hooks": [
{
"type": "command",
"command": "\"$CLAUDE_PROJECT_DIR\"/.claude/hooks/block-env.sh"
}
]
}
]
}
}
The regex catches .env, ./.env, ../.env, .env.local and < .env, but not process.env, because the character before the dot there is a letter. The stderr message goes back to Claude, so instead of trying a sixth variant it usually stops and asks me. That message is doing real work. Tell the model what to do instead, not just "no."
Layer 3: keep the real secret out of reach. The hook is a string match. cat .e""nv, a base64'd filename, or a script that loads dotenv internally will get past it. Claude isn't trying to do any of that, but a string match can't promise it won't. So for credentials that would actually hurt:
- Keep production values out of the working directory. Use a secret manager CLI that injects variables at process start, and keep only dev values in the repo's
.env. - Run the agent in a container or VM that never mounts the file. A process can't read what isn't there.
Should I stop allowing Bash commands like grep and cat?
Not necessarily, but know what you traded. Bash(grep:*) means "grep anything, anywhere, without asking," including your home directory, ~/.aws/credentials, and ~/.ssh. That's a reasonable trade for a sandboxed side project. It's a bad one in a repo where a production .env sits at the root.
My current setup: broad Bash allows only in throwaway repos. In real projects, the hook is on, real secrets live outside the tree, and I actually read prompts that touch dotfiles.
Do Claude Code deny rules protect my .env file?
Partly. A Claude Code deny rule like Read(**/.env*) stops the built-in Read and Edit tools, but it does not stop Bash, because Bash permissions match command strings and never see which files a command opens. Any cat, grep, head or python command you approve, or have pre-allowed, can still read the file. Also check your path anchors: /path is relative to the settings file and //path is absolute. To actually protect secrets, combine ** glob deny rules for the file tools, a PreToolUse hook that blocks Bash commands mentioning .env, and, for anything truly sensitive, keep the file out of the agent's working directory entirely.
Written by the developer behind Preterview, an interview prep platform.
Top comments (0)