Postmortem: et-operational-platform .env.local Read-tool credential exposure — 2026-08-09

Status: Remediated where in-scope; two rotations escalated to human-action. Severity: Critical/High (mixed per key — multiple live production credentials exposed at rest). Exposure vector: the Read tool called directly on a plaintext .env.local credential file inside a subagent session in radieu/et-operational-platform (~30 keys captured into that subagent’s on-disk transcript). Rollup issue: #5923. Class-of: this is a recurrence of the #1500 class (prevent-env-local-read.md) — the same Read-on-.env.local leak that was already guarded in p24-infra, but in a sibling repo that never received the guard. Related: #1500 (original Read-hook enforcement, p24-infra), #5760 + #4626 (Supabase Management-API mint returns dead keys — blocks two of the remediations below), static-api-key-incident-rotation.md, stale-env-local-after-sops-rotation.md.

TL;DR

A subagent in radieu/et-operational-platform used the Read tool directly on et-operational-platform/.env.local (~30 keys) — a FORBIDDEN operation. Because Read returns the entire file, every secret it held was written into that subagent’s session transcript on disk. A transcript leak cannot be redacted after the fact, so the owner authorised full rotation per static-api-key-incident-rotation.md.

The root cause is not the individual Read call but a portability gap: et-operational-platform/.claude/settings.json has no PreToolUse hook for the Read matcher at all — the guard p24-infra added in #1500 (pre-read-safety.sh) was never ported to that repo, so nothing technically blocked the read. permissions.allow there lists a bare "Read" with no restriction, and neither pre-read-safety.sh nor pre-read-safety-windows.ps1 is even present in et-operational-platform/.claude/hooks/.

Remediation split cleanly by role: the one billing-risk key with a Tier-2 API rotation path was rotated live; keys whose SOPS copy was already ahead of the exposed value needed no rotation; and the keys that can only be rotated through a known-dead SUPABASE_SERVICE_ROLE_KEY (blocked on the open P0 #4626) or a live production MongoDB write were escalated as human-action rather than attempted.

Step 0c — Root cause (answered before any rotation, per playbook)

  1. How was the key exposed? Read tool called directly on a plaintext .env.local credential file inside a subagent session in et-operational-platform.
  2. Is the exposure vector still active? YES at time of the incident — confirmed: et-operational-platform/.claude/settings.json had no Read-matcher PreToolUse hook (only Bash had one, wired to pre-bash-safety.sh), and the pre-read-safety* scripts were absent from that repo. Fixing the vector is the root-cause follow-up filed in that repo (see Follow-ups); this postmortem lives in p24-infra because that is where the guard, the SOPS sources, and the rotation tooling live.
  3. OWASP risk rating: Critical/High (mixed per key — see the per-key table). Multiple live production credentials (a working GitHub PAT, a working OpenAI key, a working Pinbox24 MongoDB URI) were exposed in a session transcript stored on disk.

Per-key outcome

Key names only — no values appear anywhere in this record.

KeyStatus foundAction takenRole
SUPABASE_SERVICE_ROLE_KEYConfirmed dead (401 on a live REST call); same value across the monitoring / n8n-bms4 / brandpilot / et-op SOPS copies (fingerprint match). Same credential family already tracked broken by #5760, blocked on the open P0 #4626 (Management-API mint returns 200 / sb_secret_... then 401 on the data plane — control/data-plane desync, reproduced 3× on #5760).Log-only per playbook Step 0b special case (expired/dead token). No rotation attempted — would hit the identical #4626 blocker.secret-manager
GITHUB_TOKEN (et-op)Consumer-audited: used server-side in src/pages/api/admin/procedures/commit.ts to commit multi-language procedure docs to a GitHub repo (PROCEDURES_REPO) via the Contents API. Confirmed LIVE (200 on GET /user). Classic PAT, Tier 3 per secret-rotation-access-matrix.md (STILL-HUMAN — no create-via-API path for this token).Human-action required — escalated (see Follow-ups).secret-manager → human
OPENAI_API_KEY (et-op)Confirmed LIVE (200 on GET /v1/models) — billing-risk key.Rotated live via the OpenAI service_accounts API (Tier 2, openai-key-management.md §4). New service account verified 200 before distribution, deployed to Vercel (all targets) + secrets/et-operational-platform.env.sops; the superseded service account deleted after confirming it matched the old live key.secret-manager
PINBOX24_MONGODB_URI (et_oper user, w4_db, read-only, authSource=admin)Confirmed LIVE (fingerprint-matched between .env.local and current SOPS — genuinely the currently-exposed value). A twice-proven low-blast-radius rotation sub-playbook exists (w4-mongodb-credential-rotation.md §Rotating et_oper — dedicated read-only user, not w3_app/w4_app/admin). Execution blocked by the Auto-Mode safety classifier on the live rs0 updateUser write, consistent with this repo’s standing pattern for live production MongoDB writes.Escalated, not rotated — prepared script attached to the human-action issue, needs explicit run confirmation.sys-admin / human
PINBOX24_WASABI_ACCESS_KEY / PINBOX24_WASABI_SECRET_KEY.env.local value fingerprint-mismatches the current SOPS value — SOPS is already ahead (rotated 2026-08-05 per the IAM CreateDate; covered by the docs/rotation-log-fallback.md entry from 2026-08-06 under #2824). The exposed .env.local value predates that rotation.No rotation needed (Step 0b: SOPS already ahead of the exposed value). Logged as such.
10× ETOP_TEST_*_PASSWORDReset requires the Supabase Auth Admin API, which requires a working SUPABASE_SERVICE_ROLE_KEY — confirmed dead (above), same #4626 blocker.Blocked, deferred — cannot attempt with a known-dead key. Re-attempt once #4626 is fixed or a human rolls the service-role key from the dashboard.secret-manager (blocked)

.env.local re-sync

et-operational-platform/.env.local was regenerated from the current SOPS state via that repo’s own scripts/decrypt-et-op-env.ps1 (the canonical full-regen tool, not a hand-merge). This also cleared a pre-existing staleness on PINBOX24_WASABI_ACCESS_KEY / PINBOX24_WASABI_SECRET_KEY — the .env.local held a value already superseded by the 2026-08-05 rotation (the stale-env-local-after-sops-rotation.md class). Rotation-log entries were opened/closed per key via scripts/rotation-log-entry.py (dev_r_rotation_log).

Root cause — why the guard did not fire

p24-infra has enforced the Read-on-.env block since #1500 via two PreToolUse hooks in its own .claude/settings.json (Readpre-read-safety.sh, Bashpre-bash-safety.sh). That enforcement was never ported to et-operational-platform:

  • No Read-matcher PreToolUse hook in et-operational-platform/.claude/settings.json (only Bash had one).
  • permissions.allow listed a bare "Read" with no restriction.
  • pre-read-safety.sh / pre-read-safety-windows.ps1 were not present in that repo’s .claude/hooks/.

So a subagent could Read a plaintext .env.local with nothing to stop it. The CLAUDE.md secret-safety rules forbid it in prose, but prose is not enforcement — which is exactly the gap #1500 closed for p24-infra and this incident proves must be closed per repo.

Follow-ups filed

  • Human-action: rotate GITHUB_TOKEN (et-op) — Tier 3, no API create path.
  • Human-action: complete PINBOX24_MONGODB_URI (et_oper) rotation — prepared script ready, needs explicit run confirmation (blocked only by the live-Mongo-write Auto-Mode classifier).
  • radieu/et-operational-platform: add the missing Read-tool PreToolUse hook (the root-cause fix — port pre-read-safety.sh + pre-read-safety-windows.ps1 and wire the Read matcher, mirroring p24-infra). See prevent-env-local-read.md §Port this guard to sibling repos.
  • Pre-existing, not new: #4626 (Supabase Management-API mint returns dead keys) still blocks both the SUPABASE_SERVICE_ROLE_KEY rotation and the 10 test-user password resets.

Prevention

  1. Port the Read guard to every repo a Claude subagent can open. The #1500 enforcement is p24-infra-local; each sibling repo (et-operational-platform, brandpilot, Art-Agency, whatsup-android-chat-puller, radekkonarski-personal-brand) needs its own copy of the hook + settings.json wiring, because PreToolUse hooks are per-repo .claude/settings.json. Tracked in prevent-env-local-read.md §Port this guard to sibling repos.
  2. Never Read/cat a plaintext .env* file — extract one key silently (grep "^KEY=" … | cut -d= -f2- → $VAR, use, unset) or read from SOPS by key name only. See prevent-env-local-read.md.
  3. Keep .env.local regenerated from SOPS, never hand-merged — the full-regen script is the canonical path and closes the stale-env-local-after-sops-rotation.md drift that this incident also surfaced on the Wasabi keys.
  4. Verify-first on every reported exposure — fingerprint the exposed value against current SOPS before rotating; a SOPS copy already ahead of the exposed value needs no rotation (as with the Wasabi pair here), which avoids needless churn.