Playbook — prevent the Read tool from loading .env credential files
Issue: #1500
Related: static-api-key-incident-rotation.md,
stale-env-local-after-sops-rotation.md,
incident 2026-08-09 (et-op .env.local Read exposure)
Trigger
You need to read a value from a credential file (.env.local, .env, …) during a Claude
Code session, or you are reviewing why a Read on such a file was blocked.
Why this guard exists
On 2026-06-26 the Read tool was used to load the full .env.local file to extract one SSH
credential. Because Read returns the entire file, every secret it contained (Supabase
keys, GitHub PATs, ANTHROPIC_API_KEY, Vercel token, server passwords) was written into the
session transcript on disk (~/.claude/projects/*/*.jsonl). A transcript leak cannot be
redacted after the fact — the only safe behaviour is to never load the whole file.
The CLAUDE.md secret-safety rules already forbid cat .env, but nothing technically enforced
the same restriction for the Read tool. This playbook documents the enforcement added in #1500.
What is enforced
Two PreToolUse hooks in .claude/settings.json:
| Tool | Hook | Effect |
|---|---|---|
Read | bash .claude/hooks/pre-read-safety.sh | Blocks Read of a plaintext .env credential file before it loads |
Bash | bash .claude/hooks/pre-bash-safety.sh | Blocks cat/less/more/editors dumping a plaintext .env file |
pre-read-safety-windows.ps1 is the native-Windows (PowerShell) equivalent of the Read hook,
kept for setups where Git Bash is not on PATH. The settings.json Read matcher invokes the
bash script because Read is a single tool used on both Linux workers and Windows local; a
PowerShell command on that matcher would error (and therefore block every read) on Linux.
Blocked (plaintext credential files)
.env.env.local.env.production,.env.development,.env.staging- any basename ending in
.envor matching.env.<suffix>not in the allowlist below
NOT blocked (exception list)
secrets/*.env.sops— encrypted with age; reading returns ciphertext, not secrets.env.example,.env.template,.env.sample,.env.dist— placeholders, no real values- every normal source file (
.ts,.yml,.md,.json, …) — unaffected
The matching is on the .env token specifically (\.env($|\.)), so files like
src/environment.ts are not matched.
Safe alternative when blocked
Extract a single key without ever printing its value.
Linux / bash:
THE_SECRET=$(grep "^KEY_NAME=" .env.local | cut -d= -f2-)
some-command --token "$THE_SECRET"
unset THE_SECRETWindows / PowerShell:
$val = (Select-String "^KEY_NAME=" .env.local).Line.Split("=",2)[1]
some-command --token $val
$val = ""Confirm a key exists without revealing it:
grep -q "^KEY_NAME=" .env.local && echo exists || echo missingSOPS-encrypted files (not blocked, but still extract one key — never dump all):
sops --decrypt --input-type dotenv --output-type dotenv secrets/monitoring.env.sops \
| grep "^KEY_NAME=" | cut -d= -f2-If a secret value already leaked into the transcript
Stop, reference the key name only from then on, and follow
static-api-key-incident-rotation.md to assess and rotate.
Verifying the guard
# Should BLOCK (exit 1):
CLAUDE_TOOL_INPUT='{"file_path":"/x/.env.local"}' bash .claude/hooks/pre-read-safety.sh; echo $?
# Should ALLOW (exit 0):
CLAUDE_TOOL_INPUT='{"file_path":"secrets/monitoring.env.sops"}' bash .claude/hooks/pre-read-safety.sh; echo $?
CLAUDE_TOOL_INPUT='{"file_path":".env.example"}' bash .claude/hooks/pre-read-safety.sh; echo $?Port this guard to sibling repos
This enforcement is per-repo. #1500 only wired it into p24-infra. PreToolUse hooks live in
each repo’s own .claude/settings.json, so a Claude subagent that opens a different repo runs
with that repo’s hooks — not p24-infra’s. A sibling repo without the Read hook has no technical
block at all; the CLAUDE.md secret-safety prose is not enforcement.
This gap caused a real recurrence: on 2026-08-09 a subagent in
radieu/et-operational-platform used Read directly on that repo’s plaintext .env.local (~30
keys) — et-operational-platform/.claude/settings.json had no Read-matcher PreToolUse hook
and the pre-read-safety* scripts were not present in its .claude/hooks/. Full writeup:
incident 2026-08-09 (rollup #5923).
To port the guard into any repo a Claude subagent can open (et-operational-platform, brandpilot, Art-Agency, whatsup-android-chat-puller, radekkonarski-personal-brand):
- Copy
pre-read-safety.shandpre-read-safety-windows.ps1into that repo’s.claude/hooks/. - Add a
PreToolUseentry with"matcher": "Read"→bash .claude/hooks/pre-read-safety.shin that repo’s.claude/settings.json(mirror theReadmatcher block from p24-infra’s.claude/settings.json). Use the bash script on the matcher —Readruns on both Linux workers and Windows local, and a PowerShell command there would error (and thus block every read) on Linux. - Confirm
.claude/settings.jsonpermissions.allowdoes not list a bare unrestricted"Read"that would bypass intent — the hook is the enforcement, but keep the two consistent. - Verify with the block/allow probes in §Verifying the guard inside that repo’s checkout.
Each porting job is a separate PR in the target repo (a p24-infra worker cannot edit another
repo’s .claude/settings.json from here) — file it as a follow-up issue in that repo.
Prevention / maintenance
- When adding a new credential filename convention, update the block/allow patterns in
both
pre-read-safety.shandpre-read-safety-windows.ps1. - Worker deployment of these hooks to all VPS nodes is tracked in issue #1510.
- Porting the guard to every sibling repo a subagent can open — see §Port this guard to sibling
repos above (recurrence: incident 2026-08-09 / #5923). Live
rollout status for the 4 remaining sibling repos (brandpilot, Art-Agency,
whatsup-android-chat-puller, radekkonarski-personal-brand) is tracked in
../security/env-read-guard-sibling-rollout.md(parent #5938; et-op is tracked separately byet-operational-platform#1626). - Keep the exception list in sync with this playbook and the enforcement note in
static-api-key-incident-rotation.md.