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)
- How was the key exposed?
Readtool called directly on a plaintext.env.localcredential file inside a subagent session inet-operational-platform. - Is the exposure vector still active? YES at time of the incident — confirmed:
et-operational-platform/.claude/settings.jsonhad noRead-matcherPreToolUsehook (onlyBashhad one, wired topre-bash-safety.sh), and thepre-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 inp24-infrabecause that is where the guard, the SOPS sources, and the rotation tooling live. - 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.
| Key | Status found | Action taken | Role |
|---|---|---|---|
SUPABASE_SERVICE_ROLE_KEY | Confirmed 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_*_PASSWORD | Reset 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 (Read → pre-read-safety.sh, Bash → pre-bash-safety.sh). That
enforcement was never ported to et-operational-platform:
- No
Read-matcherPreToolUsehook inet-operational-platform/.claude/settings.json(onlyBashhad one). permissions.allowlisted a bare"Read"with no restriction.pre-read-safety.sh/pre-read-safety-windows.ps1were 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 missingRead-toolPreToolUsehook (the root-cause fix — portpre-read-safety.sh+pre-read-safety-windows.ps1and wire theReadmatcher, mirroringp24-infra). Seeprevent-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_KEYrotation and the 10 test-user password resets.
Prevention
- Port the
Readguard to every repo a Claude subagent can open. The #1500 enforcement isp24-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.jsonwiring, becausePreToolUsehooks are per-repo.claude/settings.json. Tracked inprevent-env-local-read.md§Port this guard to sibling repos. - Never
Read/cata plaintext.env*file — extract one key silently (grep "^KEY=" … | cut -d= -f2- → $VAR, use,unset) or read from SOPS by key name only. Seeprevent-env-local-read.md. - Keep
.env.localregenerated from SOPS, never hand-merged — the full-regen script is the canonical path and closes thestale-env-local-after-sops-rotation.mddrift that this incident also surfaced on the Wasabi keys. - 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.