# Claude Code Deny Rules Blocked Read(.env). Bash Read It Anyway

> Source: <https://dev.to/ji_ai/claude-code-deny-rules-blocked-readenv-bash-read-it-anyway-4d3b>
> Published: 2026-10-05 04:38:33+00:00

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.*
