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.
Read(./.env) blocks the built-in Read tool. It does not block cat .env, grep KEY .env, or python -c "open('.env')" run through Bash./path is relative to the settings file, //path is absolute, and ./.env only matches the .env at the project root.PreToolUse hook that blocks Bash commands mentioning 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(...) and Edit(...) take file path patterns, written in gitignore style.Bash(...) takes a command string pattern, like Bash(npm run test:*). WebFetch(...) takes a domain, like WebFetch(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.
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.
No. Bash rules match the command text, so they block one spelling and miss the rest. Bash(cat .env) does nothing about:
cat ./.env`` cat .env.local``less .env, head .env, tail .env, awk 1 .env
while read l; do echo "$l"; done < .env`` cd 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.
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 's code.
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
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:
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.
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.