Claude Code Deny Rules Blocked Read(.env). Bash Read It Anyway A developer found that Claude Code's Read(./.env) deny rule only blocks the built-in Read tool, not Bash commands, and that five of six common file-read approaches — including cat, grep, head, and python3 -c — bypassed the rule entirely. The writeup also details how file path patterns are anchored relative to the settings file and how Bash rules match command text, making filename-based denials fragile. 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 loader'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: bash /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: 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 https://preterview.com/en , an interview prep platform.