I watched my production database password scroll past in a Claude Code transcript. I had a deny rule for .env. I had copied it straight from the docs. Claude never touched the Read tool. It ran grep -r DATABASE_URL . because I asked it why the app couldn't connect, and grep did exactly what grep does.
Claude Code deny rules are real and they work. They just guard a narrower door than most people think. This post covers what Read(./.env) actually blocks, the 7 commands I tested against it, and the setup that closed the gap on my machine.
TL;DR
- Claude Code deny rules like
Read(./.env)apply to Claude's built-in file tools. They are not a filesystem lock. - The Bash tool is matched by its own rules.
cat .envorgrep -rgets through if your Bash allow list lets those commands run. - Bash deny patterns like
Bash(cat .env)are plain prefix matches, socat ./.envorhead .envgets past them. - A
PreToolUsehook that exits with code 2 blocks the obvious Bash routes. The sandbox or keeping plaintext secrets out of the project is the only real boundary. - Secrets exported into the shell you launch
claudefrom are inherited by every Bash command it runs.
What does a Claude Code deny rule actually block?
A Claude Code deny rule blocks a specific tool call pattern, not a file. Read(./.env) means "refuse when the Read tool targets this path." The rule says nothing about what a shell process does with that path.
This is the snippet almost everyone has in .claude/settings.json:
{
"permissions": {
"deny": [
"Read(./.env)",
"Read(./.env.*)",
"Read(./secrets/**)"
]
}
}
It does its job. Ask Claude to open .env with the Read tool and it gets refused, every time. The trouble is the mental model. I read that config as "Claude cannot see .env." What it really says is "this one tool cannot open .env." Those are very different promises.
Why does Claude Code's Bash tool ignore Read deny rules?
Bash is a separate tool with separate rules. Permission checks happen per tool: a Bash call is compared against Bash(...) patterns, and a Read call is compared against Read(...) patterns. A Read deny rule never sees a Bash call.
Once a shell command runs, it is just a process on your machine with your user's permissions. cat, grep, python3, node, sed: none of them know a JSON file somewhere said .env was off limits.
So the question becomes: what does your Bash allow list look like? Mine had grown the way everyone's does. I got tired of approving searches, so I clicked "don't ask again" a few times. Here's what had piled up:
{
"permissions": {
"allow": [
"Bash(grep:*)",
"Bash(cat:*)",
"Bash(ls:*)",
"Bash(npm run test:*)"
],
"deny": [
"Read(./.env)",
"Read(./.env.*)"
]
}
}
Every one of those allows felt harmless when I added it.
I tested 7 ways to read .env. The deny rule stopped 2.
I put a fake .env and .env.local in a scratch repo, with the settings above, and asked Claude Code to run each of these as a separate step. You can repeat this in two minutes on your own install, and you should, because your allow list is not my allow list.
| # | What ran | Result |
|---|---|---|
| 1 | Read tool on .env
|
Denied |
| 2 | Read tool on .env.local
|
Denied |
| 3 | cat .env |
Ran, no prompt |
| 4 | grep -r DATABASE_URL . |
Ran, no prompt, printed the secret line |
| 5 | grep KEY .env.local |
Ran, no prompt |
| 6 | head -3 .env |
Asked me for permission |
| 7 | python3 -c "print(open('.env').read())" |
Asked me for permission |
Two blocked. Three ran silently. Two depended on me reading a permission prompt carefully at 11pm.
Number 4 is the scary one, because nobody asked for .env. Claude was doing reasonable debugging. Plain grep -r does not care about .gitignore, so it walks straight into the file your .gitignore was hiding from git. The secret lands in the tool output, which lands in the conversation context, which lands in your session log on disk.
Can I fix it with a Bash deny rule like Bash(cat .env)?
Partially, and not reliably. Bash permission patterns are prefix string matches on the command. Bash(cat .env) stops exactly cat .env. It does not stop:
cat ./.env-
cat .envwith two spaces -
head .env,tail .env,less .env,awk 1 .env -
grep -rfrom the project root cp .env /tmp/x && cat /tmp/x
Claude Code is smart about shell operators. An allow rule for Bash(safe-cmd:*) won't approve safe-cmd && something-else. But no string pattern can enumerate every program that can open a file. The docs themselves warn that Bash patterns trying to constrain arguments are fragile, and this is why.
The honest takeaway: deny rules are for steering, not for security. Use them, but don't stop there.
How do I block .env reads in Claude Code Bash commands?
Use a PreToolUse hook on the Bash tool that inspects the command and exits with code 2. Exit 2 blocks the call and sends your stderr message back to Claude, which then picks a different approach. Exit 1 only logs an error and lets the command run, which surprises a lot of people.
Save this as .claude/hooks/no-dotenv.py:
#!/usr/bin/env python3
import json
import re
import sys
data = json.load(sys.stdin)
cmd = data.get("tool_input", {}).get("command", "")
touches_env = re.search(r"\.env\b", cmd)
blind_grep = re.search(r"\bgrep\s+(-\w*r|--recursive)", cmd) and "--exclude" not in cmd
if touches_env or blind_grep:
print(
"Blocked: this command can read .env. "
"Ask the user for the specific value you need, "
"or use grep with --exclude='.env*'.",
file=sys.stderr,
)
sys.exit(2)
sys.exit(0)
Wire it up in .claude/settings.json:
{
"hooks": {
"PreToolUse": [
{
"matcher": "Bash",
"hooks": [
{
"type": "command",
"command": "python3 \"$CLAUDE_PROJECT_DIR\"/.claude/hooks/no-dotenv.py"
}
]
}
]
}
}
I reran the 7 tests with the hook in place. All five Bash variants were blocked before they ran, including the grep that only reached .env by accident. Claude's next move on the grep case was to rerun it with --exclude='.env*', which is exactly the behaviour I wanted.
Better still, the stderr message is a nudge. Claude reads it and adjusts, so the hook teaches as it blocks.
Is a hook enough to protect secrets from Claude Code?
No. A hook is still string matching, and string matching loses to creativity. cat .e* slips past my regex. So does python3 -c "open('.'+'env')". Claude isn't trying to break in, so in practice the hook catches the accidents, which are the real risk. But "catches accidents" is not a security boundary.
Here's the layering I ended up with, from weakest to strongest:
- Read deny rules. Keep them. They stop the built-in tools and signal intent.
-
Trim the Bash allow list. I removed
Bash(cat:*)andBash(grep:*). Searches go through Claude's built-in search tools, which are covered by the Read rules. Approving the occasionalcatcosts me a second. -
The
PreToolUsehook. Catches accidental reads and redirects Claude. -
The sandbox. Claude Code's sandbox enforces filesystem and network rules at the OS level for Bash and every child process it spawns. That is the first layer where
python3,awkandcatall hit the same wall. Check the sandbox docs for how your version maps deny paths. - No plaintext secrets in the project at all. This is the only layer that can't be bypassed. A secrets manager that injects values at process start means there is no file to leak.
The leak nobody checks: your shell environment
One more trap. The Bash tool inherits the environment of the shell you started claude from. If you ran export OPENAI_API_KEY=... in that terminal, or your .zshrc exports production credentials, then printenv or env prints them, no file involved. Not a single rule above helps.
I now start Claude Code from a terminal with nothing sensitive exported. Inject credentials into the command that needs them, not into the parent shell.
So do Claude Code deny rules protect my .env file?
Claude Code deny rules protect .env from Claude's built-in file tools only. Read(./.env) is matched against Read tool calls, so the Bash tool can still read the file with cat, grep -r, head or any script whenever your Bash allow list or a hurried approval lets the command run. Bash deny patterns are prefix matches and easy to sidestep. To actually keep secrets out of the transcript, trim broad Bash allows, add a PreToolUse hook that exits with code 2 on commands touching .env, turn on the sandbox for OS-level enforcement, and keep plaintext secrets out of the project directory and out of the shell environment you launch claude from.
Written by the developer behind Preterview, an interview prep platform.
Top comments (3)
The
blind_grepcheck has a substring trap:"--exclude" not in cmdis also false for--exclude-dir=node_modules, sogrep -rn --exclude-dir=node_modules DATABASE_URL .passes the hook and still walks into.env. Checking for--exclude=with the equals sign fixes that case, and matching--exclude=.envor--exclude='.env*'specifically also stops--exclude=*.logfrom counting as safe. The other side is false positives:\.env\balso matchesprocess.env, sogrep -n "process.env" src/config.tsgets blocked, and that is the exact search you want while debugging config. Last, if the script itself crashes it exits 1, which the hooks docs treat as a non-blocking error, so the command runs. Adding"onFailure": "block"to the hook entry (v2.1.295 or later) makes a broken script fail closed.The table of 7 commands makes the gap really clear, especially that grep -r DATABASE_URL printed the secret with no prompt. One addition that worked for me: keep real secrets out of the project directory entirely and load only a dummy .env.example in the repo, so even an allowed cat or grep has nothing sensitive to find. Did you try running Claude Code inside the sandbox with the PreToolUse hook, and did it get in the way of normal workflows?
도구별 규칙과 실제 파일 접근 경계를 구분한 설명이 중요하네요. 후크가 실수를 줄이는 장치라는 한계까지 적은 점도 좋습니다. 회귀 시험에는 실제 비밀값 대신 식별 가능한 가짜 값을 두고, 파일 경로뿐 아니라 부모 프로세스 환경에서 값이 유입되는 경우도 따로 점검하면 노출 여부를 안전하게 확인할 수 있겠습니다.