Block Claude Code from Reading .env with deny Rules
Add Read(./.env) to permissions.deny in settings.json so Claude Code cannot read secret files, then run it to see what is denied and what still leaks via Bash.
To keep Claude Code from opening .env, add a rule to permissions.deny. Some reads are blocked and some are not, so test both.
[1] Create fake secrets in a test folder. Every value is fake.
$ cd /srv/app$ printf 'DEMO_KEY=not-a-real-key\n' > .env$ mkdir secrets && printf 'DB_PASS=not-a-real-password\n' > secrets/db.txt[2] Read the file with no rules in place. -p runs one prompt and exits; --output-format json also returns the list of denied calls as permission_denials.
$ claude -p "Read the file .env in the current directory and tell me the value of DEMO_KEY. Reply with just the value, or say why you could not." --max-turns 3 --output-format json > run1.json$ jq -r '.result' run1.json | head -1`not-a-real-key`$ jq -c '.permission_denials' run1.json[][3] Add the deny rules to the project's .claude/settings.json. Settings files are strict JSON: a // comment or trailing comma gives a Settings Error.
{ "permissions": { "deny": [ "Read(./.env)", "Read(./secrets/**)", "Bash(curl:*)" ] }}The :* in Bash(curl:*) means the same as Bash(curl *). In a deny rule, Read(secrets/**) matches a secrets directory at any depth under the current directory.
[4] Send the same request again.
$ claude -p "Read the file .env in the current directory and tell me the value of DEMO_KEY. Reply with just the value, or say why you could not." --max-turns 3 --output-format json > run2.json$ jq -c '.permission_denials[] | {tool_name, tool_input}' run2.json{"tool_name":"Read","tool_input":{"file_path":"/srv/app/.env"}}.result explained that a deny rule blocked the read and contained no DEMO_KEY value.
[5] Make Claude read the same file through Bash. cat is a read-only command that normally runs without asking, and it was allowed here with --allowedTools, yet it is blocked. The docs say Read deny rules also apply to Bash file commands such as cat, head, tail, sed and tee, and to < and > redirection targets. Deny is evaluated before allow.
$ claude -p 'Run the shell command `cat .env` and tell me the output. Do not use the Read tool.' --max-turns 3 --output-format json --allowedTools "Bash(cat *)" > run3.json$ jq -c '.permission_denials[] | {tool_name, tool_input}' run3.json{"tool_name":"Bash","tool_input":{"command":"cat .env","description":"Print contents of .env file"}}[6] Try a command that does not name the file: a script that opens it itself.
$ claude -p 'Run the shell command `python3 -c "print(open(\".env\").read())"` and tell me the output.' --max-turns 3 --output-format json --allowedTools "Bash(python3 *)" > run4.json$ jq -c '.permission_denials' run4.json[]$ jq -r '.result' run4.json | grep DEMODEMO_KEY=not-a-real-keyNothing was denied and the value came back. The docs state the same limit: Read and Edit deny rules do not apply to commands that read files without naming them, like grep -r pattern ., or to arbitrary subprocesses such as a Python or Node script that opens files itself.
[7] Close the gap with what the docs recommend. To block every process from a path at the OS level, enable the sandbox. To inspect the full command yourself, use a PreToolUse hook. A matching deny rule still blocks even if a hook returns allow, so the two work together. Hook setup is in Block dangerous commands with Claude Code hooks.
Bash(curl:*) has the same character. In the docs' table, Bash(curl *) stops curl https://example.com but not /usr/bin/curl … or sh -c 'curl …'. A Bash deny rule stops the form Claude usually writes; it is not a security boundary.
[8] With several settings files, the higher one overrides the same key. The order is managed settings → claude --settings → .claude/settings.local.json (project local) → .claude/settings.json (shared project) → ~/.claude/settings.json (user). A deny is the exception: if any scope denies a call, it is blocked, so a project deny beats a user allow. Put team rules in .claude/settings.json and personal ones in .claude/settings.local.json.