A .gitignore keeps .env out of git, not out of Claude Code; Claude reads anything the filesystem allows. Add explicit deny rules for secret-shaped reads and the obvious Bash escape hatches before you ever turn on auto mode.
1. Deny rules in .claude/settings.json
Commit this at the repo root so every contributor inherits the same defense:
{
"permissions": {
"deny": [
"Read(.env)",
"Read(.env.*)",
"Read(**/.env)",
"Read(**/.env.*)",
"Read(**/secrets/**)",
"Read(**/*credentials*)",
"Bash(cat .env*)",
"Bash(cat **/.env*)",
"Bash(env)",
"Bash(printenv)",
"Bash(git diff*)",
"Bash(git show*)"
]
}
}
The list covers both direct file reads and the obvious shell escapes. Read(.env) blocks Claude’s Read tool from opening the file; it does not block cat .env (that goes through the Bash tool, which is why the Bash-shape rules are listed alongside). git diff and git show are blocked because either, with a path argument like .env, prints the diff or contents of the file across the requested commit range, exposing whatever was tracked there once. env and printenv are blocked because they exfiltrate every variable Claude Code inherits from your shell.
2. Allowlist Bash if you can stomach the friction
Deny rules are reactive: every shell utility you forget is a hole. The cleaner shape leans on the default permission mode (which already prompts for unlisted Bash) and just enumerates the safe pre-approvals:
{
"permissions": {
"allow": [
"Bash(npm test*)",
"Bash(npm run *)",
"Bash(git status*)",
"Bash(git log*)",
"Bash(git add *)",
"Bash(git commit *)"
],
"deny": [
"Read(.env*)",
"Read(**/.env*)",
"Read(**/secrets/**)",
"Bash(cat .env*)",
"Bash(cat **/.env*)",
"Bash(env)",
"Bash(printenv)"
]
}
}
In default mode, Bash commands with side effects prompt you unless they match an allow rule. Built-in read-only commands (cat, grep, find, diff, read-only git shapes) auto-run regardless of allow rules unless a deny rule blocks them. That is exactly why the deny block stays in this section: cat .env is a read-only Bash command, so it runs silently unless you deny it explicitly. Permission rules can target any tool (Read, Edit, MultiEdit, Bash, MCP via mcp__server__tool), but in practice the allowlist shape revolves around side-effecting Bash patterns and the deny block does the secret-leak work.
3. PreToolUse hook as the fallback
Permission rules are evaluated by Claude Code; a hook runs in your shell and sees the actual tool input. Both can target MCP tools (rules via mcp__server__tool patterns), but a hook catches the long tail of tool inputs you did not think to enumerate explicitly.
.claude/hooks/block-secret-reads.sh:
#!/usr/bin/env bash
set -euo pipefail
input=$(cat)
target=$(printf '%s' "$input" | jq -r '
.tool_input.file_path
// .tool_input.path
// .tool_input.command
// empty
')
case "$target" in
# Secret-shaped paths via Read or any tool that takes a path.
*.env*|*credentials*|*/secrets/*) deny=1 ;;
# Bash commands that exfiltrate environment or .env contents.
env|printenv) deny=1 ;;
"env "*|"printenv "*|"env;"*|"printenv;"*) deny=1 ;;
*cat*\.env*|*"git diff"*|*"git show"*) deny=1 ;;
*) deny=0 ;;
esac
if [ "${deny:-0}" = "1" ]; then
printf 'blocked: tool input references a secret-shaped path or command\n' >&2
exit 2 # PreToolUse exit 2 refuses the tool call before it runs.
fi
exit 0
Wire it:
{
"hooks": {
"PreToolUse": [
{
"matcher": "Read|Bash|mcp__.*",
"hooks": [
{
"type": "command",
"command": "\"$CLAUDE_PROJECT_DIR\"/.claude/hooks/block-secret-reads.sh"
}
]
}
]
}
}
chmod +x .claude/hooks/block-secret-reads.sh
The hook receives the event JSON on stdin, pulls the path or command, and refuses with exit 2 if it pattern-matches. Belt and suspenders for when a deny rule slips.
4. Verify the defense
Two checks in a fresh session:
Read the contents of
.envand report what it contains.
The deny rule should refuse. If it does not, your glob is wrong.
Run
envand tell me which API tokens are set.
Auto mode is the place this fails first; verify in the mode you actually use day-to-day, not only in default.
Footguns
Auto mode auto-approves Read tools by default; the classifier never even sees a .env request. Auto mode evaluates allow/deny rules first, then auto-approves read-only tools and in-cwd edits without prompting, and only sends the rest to the classifier. That means Read(.env) is auto-approved by default unless you explicitly deny it; the classifier is never the layer that protects you from secret-file reads. Without the deny block above, auto mode is exactly where secret leakage happens first. If you run auto mode in any repo with a .env, the deny rules in step 1 are not optional.
.gitignore is irrelevant to Claude Code. New users reach for .gitignore to “hide” .env. .gitignore controls what git tracks; Claude Code reads files through the OS filesystem and ignores it entirely. The two are independent boundaries.
A wide deny like Bash(cat *) blocks cat src/foo.ts too. Be specific: Bash(cat .env*), not Bash(cat *). Claude Code matches deny patterns against the literal command string the model wrote, so a too-broad pattern is noisy without being safer. Test every new deny pattern against a real benign command before relying on it.
Secrets land in Claude’s context through files you did not deny. A prompt that says “look at the deploy script” produces a Read(deploy.sh), which Claude is allowed to read. The script body might contain aws s3 cp s3://prod/secrets.env . plus an inlined fallback token. The PreToolUse hook above does not help here: it sees the tool input (the path deploy.sh), not the file contents that come back. Deny rules and hooks only block files whose names you anticipated. Defense-in-depth: deny rules cover the obvious file shapes, the hook catches secret-shaped paths and commands, and your real production secrets never live anywhere a developer machine can read them in the first place.
The deny list grows and nobody curates it. Six months from now, half the rules will be redundant and half the shell utilities you adopted in the meantime will be unblocked. Schedule a quarterly review (git log .claude/settings.json is your audit trail). Or migrate to allowlist mode and invert the maintenance burden into a deliberate add-as-needed flow.
When NOT to spend this effort
- The repo has no real secrets. A pure open-source library with no
.env, nosecrets/, and no service-account JSON does not need this. Friction without a payoff. - You only ever use
defaultpermission mode and your.envis in the same directory. Default mode auto-approves Read tools and read-only Bash commands; it does not prompt beforeRead(.env)orcat .env. The deny block in step 1 is still the layer that protects you, even in default mode. Skip it only if there genuinely is no.env, nosecrets/, no service-account JSON in the tree. - Your secrets live in a real secret manager. AWS Secrets Manager, GCP Secret Manager, Vault: if your dev machine pulls credentials at runtime via a short-lived token, file-level deny rules are not the most important layer. Focus deny rules on
Bash(aws *)andBash(gcloud *)patterns instead. - You think allowlist mode replaces deny rules. It does not. The default permission mode auto-runs read-only commands even with an allow list in place, so
Read(.env)andcat .envstill need an explicit deny. Allowlist mode reduces the Bash surface area; it does not eliminate the secret-leak path.