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:

ToolHookEffect
Readbash .claude/hooks/pre-read-safety.shBlocks Read of a plaintext .env credential file before it loads
Bashbash .claude/hooks/pre-bash-safety.shBlocks 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 .env or 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_SECRET

Windows / 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 missing

SOPS-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):

  1. Copy pre-read-safety.sh and pre-read-safety-windows.ps1 into that repo’s .claude/hooks/.
  2. Add a PreToolUse entry with "matcher": "Read"bash .claude/hooks/pre-read-safety.sh in that repo’s .claude/settings.json (mirror the Read matcher block from p24-infra’s .claude/settings.json). Use the bash script on the matcher — Read runs on both Linux workers and Windows local, and a PowerShell command there would error (and thus block every read) on Linux.
  3. Confirm .claude/settings.json permissions.allow does not list a bare unrestricted "Read" that would bypass intent — the hook is the enforcement, but keep the two consistent.
  4. 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.sh and pre-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 by et-operational-platform#1626).
  • Keep the exception list in sync with this playbook and the enforcement note in static-api-key-incident-rotation.md.