Playbook: Static API Key Compromise — Incident Rotation
STOP. Read this playbook before doing anything. Every credential rotation incident — regardless of how minor it seems — must start here. Do not rotate ad-hoc, do not update just one store, do not defer distribution. Audit trail: every rotation must end with a
completed(orfailed) entry in Supabasedev_r_rotation_log, written viascripts/rotation-log-entry.py— never by hand-editing a markdown file.
Trigger: Suspected or confirmed exposure of one or more static API keys (GH_TOKEN, VERCEL_TOKEN, SUPABASE_SERVICE_KEY, SUPABASE_ACCESS_TOKEN, SENTRY_AUTH_TOKEN, ANTHROPIC_API_KEY).
Reference: Full per-key procedures → docs/password-rotation-procedures.md
What triggers this playbook
- LLM session transcript contains secret values (prior incident: 2026-05-08)
- GitHub Security Advisory alert for a PAT
- Unauthorized Vercel deploy / Supabase schema change with no matching commit
- Audit log shows API calls from unexpected IP / time
- Credential appears in git history (even if squashed)
Step 0 — READ THIS PLAYBOOK FIRST. Then run the consumer audit.
MANDATORY before touching any credential:
- Read through all steps below.
- Find every place the key is used — code, GH Secrets, containers, n8n, Vercel env vars.
- Only then generate a new value.
A rotation that misses even one consumer leaves a broken service. The prior incident (2026-06-28, VERCEL_TOKEN) skipped
radieu/et-operational-platformand the vps-i1 container restart — discovered in post-rotation audit.
Consumer audit (run before generating a new value)
# 1. Find all code references
cd C:\code_2026\p24-infra
grep -r "KEY_NAME" --include="*.yml" --include="*.yaml" --include="*.py" --include="*.sh" --include="*.env*" -l
# 2. Check which GH repos have this secret
gh secret list --repo radieu/p24-infra | Select-String "KEY_NAME"
gh secret list --repo radieu/et-operational-platform | Select-String "KEY_NAME"
# 3. Check docker-compose env usage
Select-String -Path "monitoring\docker-compose.yml" -Pattern "KEY_NAME"
# 4. Check n8n credentials (if key is used by n8n)
# Search in n8n-bms4.env.sops key namesPer-key consumer lists are in docs/password-rotation-procedures.md §<KEY_NAME>.
Cross-reference both before proceeding.
Step 0b — Check if SOPS is already ahead (avoid unnecessary rotation)
Before rotating, confirm the exposed value is actually the current live value. A token visible in a running process command line may be stale — the process started with an old value while SOPS already holds a newer rotated value.
$env:SOPS_AGE_KEY_FILE = "C:\Users\konar\.age\p24-infra-keys.txt"
# Check if key was already recently rotated — look at the rotation log first:
python scripts\rotation-log-entry.py list --limit 5 --repo p24-infra # filter output for KEY_NAME
# Then compare: does SOPS already have a different (newer) value than what was in the process?
# Safe check — only prints True/False, not the value:
$sopsVal = (sops --decrypt --input-type dotenv --output-type dotenv secrets\monitoring.env.sops `
| Select-String "^KEY_NAME=").ToString().Split("=",2)[1]
$sopsVal -ne "<first-few-chars-of-exposed-value>" # True = SOPS already rotated, no action needed
$sopsVal = ""If SOPS already has a different value: skip to Step 5 (log the incident, no rotation needed). If SOPS matches the exposed value: proceed with full rotation below.
Special case — exposed token is confirmed expired
If the token visible in chat/logs/git is demonstrably expired (API returns 401/403, or creation date + TTL ≤ today), rotation is not required. An expired token cannot be used to authenticate — the exposure carries zero practical risk.
Required action: log only.
Record a completed entry in dev_r_rotation_log (no rotation performed) via the CLI:
ROW_ID=$(python3 scripts/rotation-log-entry.py open --secret KEY_NAME \
--reason "expired-token-exposure — token already expired at time of exposure; no rotation performed (<where it appeared>)" \
--reason-category exposure)
python3 scripts/rotation-log-entry.py close "$ROW_ID" --verify-result "expired token — no rotation needed"Then close the incident. Do NOT spend time rotating a dead token — that rotation would be a no-op and creates false urgency. Reserve rotation effort for live tokens.
Step 0c — Root cause hypothesis (< 5 min — do NOT skip)
Supabase requirement (and general best practice): “Make sure you have fully considered the situation and have remediated the root cause of the suspicion or vulnerability first” — Supabase API Keys guide
Run this before generating any new value. A rotation without RCA risks the same vector exposing the new key immediately.
Answer these three questions in writing (add to the GH issue body):
-
How was the key exposed? Common vectors: LLM transcript (
sops -dbare stdout),Readtool on credential file, curl verbose output, git history,printenv/envenumeration, log file containing startup env dump. -
Is the exposure vector still active?
- Process still running with old key in args? →
Get-CimInstance Win32_Process | Select-Object CommandLine | Select-String "KEY_NAME" - File still readable without guard? → check
.claude/hooks/pre-read-safety.shrules - Key still in git history? →
git log -S "<first-6-chars>" --all --oneline - Remediate the vector before proceeding. If it’s a hook gap, fix the hook. If it’s a process, kill it. If it’s a file, restrict access or delete the plaintext copy.
- Process still running with old key in args? →
-
OWASP risk rating — determines response urgency Use OWASP Risk Rating Methodology: Likelihood (threat agent + exploitability) × Impact (technical + business)
Rating Response Critical Rotate immediately; consider disabling the Supabase project temporarily while rotating High Rotate in current session, no deferral Medium Rotate within 24h Low Schedule in next rotation cycle; document as known exposure
Only after all three are answered and the vector is remediated: proceed to Step 1.
Step 1 — Confirm scope + open a log entry immediately
Check which keys may be exposed. If LLM session: search Claude conversation transcript for sk-, eyJ, ghp_, sbp_, sb_secret_, SG.:
# Open the .jsonl transcript and grep for token patterns
Select-String -Path "C:\Users\konar\.claude\projects\*\*.jsonl" `
-Pattern "sk-ant-|ghp_|sbp_|sb_secret_|eyJhbGc|SG\." | Select-Object -First 20For each positive match: list the key name, NOT the value.
Then immediately open a log entry in Supabase dev_r_rotation_log — before touching any key.
dev_r_rotation_log is the single rotation log for everyone (desktop sessions and workers alike);
write it via scripts/rotation-log-entry.py, never by hand-editing a markdown file. The open call
creates a pending row so there is always a traceable record even if the session is interrupted:
# Open BEFORE touching any key — prints the row id (a uuid, or "fallback:<uuid>" if the API was down):
ROW_ID=$(python3 scripts/rotation-log-entry.py open --secret KEY_NAME \
--reason "<free text / #issue>" --reason-category exposure --rotator secret-manager)
echo "Rotation log row: $ROW_ID" # keep this id for the close/fail callThis pending row is your working checklist — close it (Step 5) only once every distribution step is done. A rotation with no log entry does not exist as far as the audit trail is concerned.
The script writes to Supabase dev_r_rotation_log directly. If the Supabase API is unreachable
it auto-falls-back to docs/rotation-log-fallback.md and returns a fallback:<uuid> id that the
later close/fail correlate against the same file entry; on the next successful API call the
pending fallback entries are drained automatically (a daily reimport-check cron is the backstop).
Never hand-edit docs/rotation-log-fallback.md or docs/secrets-rotation-log.md. If a fallback write
happened during this rotation, stage docs/rotation-log-fallback.md in the SOPS commit (Step 3) so
it reaches the reimport cron.
Step 2 — Rotation order (highest blast-radius first)
Rotate in this sequence — do NOT wait for one to fully propagate before starting the next if you have two windows open:
| Priority | Key | Why first |
|---|---|---|
| 1 | SUPABASE_SERVICE_KEY | Database + all app data |
| 2 | GH_TOKEN / GH_PAT | Repo write + workflow secrets access |
| 3 | VERCEL_TOKEN | Production deploy + env vars |
| 4 | SUPABASE_ACCESS_TOKEN | Management API — can change schema/keys |
| 5 | ANTHROPIC_API_KEY | Billing risk |
| 6 | SENTRY_AUTH_TOKEN | Error data + deploy tracking |
Step 3 — Rotate each key AND distribute immediately
For each key, follow the procedure in docs/password-rotation-procedures.md §<KEY_NAME>.
A key rotated in SOPS only is NOT rotated. Rotation is complete only when every consumer has the new value and the old key is revoked. Distribution is mandatory and must happen in the same session, not deferred.
Actions per key — all that apply, in order:
- Generate/get new value at the provider (dashboard, CLI, API)
- Update SOPS file immediately (source of truth) — safe edit pattern from CLAUDE.md.
See
CLAUDE.md §Key → SOPS file mappingfor the correct file. → Note SOPS commit hash for theclose --sops-commitcall - Push SOPS commit to
dev→secrets-sync.ymlauto-deploys to vps-i1 and bms-4.git add secrets/<file>.env.sops— and alsogit add docs/rotation-log-fallback.mdwhen that file has pending (uncommitted) entries (an offline fallback write from this rotation only reaches the daily reimport cron if it travels in this commit). This close-out never routes throughsecret-manager.md’s Handoff, so stage the fallback file here directly. If this rotation produced no SOPS commit to piggyback on, open a small dedicated PR containing just the fallback-file update. - Update GH Secret — use
--body, never pipe (|) (PS 5.1 pipe adds BOM to stdin):gh secret set KEY_NAME --body $env:NEW --repo radieu/p24-infra→ Record on close via--gh-secret-name KEY_NAME - Update any Vercel env vars if the key is used by a Vercel project
- Update n8n credentials if the key is used by n8n workflows (
scripts/sync-n8n-supabase-creds.pyfor Supabase keys) - Restart running containers that hold the old key in memory
- Kill any local processes (MCP servers, dev tools) that started with the old key in their args:
Get-CimInstance Win32_Process | Select-Object CommandLine | Select-String "KEY_NAME" - Verify
secrets-sync.ymlGH Actions run completed — confirm new value is live on all VPS servers. - Revoke the old key at the provider — only after confirming the new value works everywhere
→ Close the log entry (Step 5):
rotation-log-entry.py close "$ROW_ID"flipspending→completed
BOM-safe pattern — patching a live server .env (e.g. bms-4 claude-session-manager)
The original exposure that created this playbook’s most recent incident (SUPABASE_SERVICE_ROLE_KEY,
#5760, 2026-08-06) was caused by a PowerShell echo "KEY=$val" | ssh ... "cat >>" pipeline — PS 5.1’s
default encoding for piped stdin adds a BOM, which corrupted the line and caused
claude-session-manager to log the raw assignment (including the value) to its systemd journal on
parse failure. Never use echo | ssh (or any string-pipe-to-ssh) to write a server .env file.
Safe pattern instead — write the patch line locally with explicit no-BOM UTF-8, transport it as a
file via scp, then splice it into the remote file with plain Linux text tools (no re-encoding step
on either side):
$env:NEW_VALUE = "<value, extracted via the safe pattern, never printed>"
$tempPath = "$env:TEMP\patch-$([guid]::NewGuid().ToString('N')).env"
[System.IO.File]::WriteAllText($tempPath, "SUPABASE_SERVICE_ROLE_KEY=$($env:NEW_VALUE)`n", [System.Text.UTF8Encoding]::new($false))
scp -i C:\Users\konar\.ssh\id_ed25519 $tempPath root@54.36.123.110:/tmp/supabase-key-patch.env
ssh -i C:\Users\konar\.ssh\id_ed25519 root@54.36.123.110 "cp /opt/claude-session-manager/.env /opt/claude-session-manager/.env.bak-$(date +%s) && grep -v '^SUPABASE_SERVICE_ROLE_KEY=' /opt/claude-session-manager/.env > /tmp/env-new && cat /tmp/supabase-key-patch.env >> /tmp/env-new && mv /tmp/env-new /opt/claude-session-manager/.env && rm -f /tmp/supabase-key-patch.env && systemctl restart claude-session-manager && sleep 2 && systemctl is-active claude-session-manager"
Remove-Item $tempPath -Force
$env:NEW_VALUE = ""Verify with systemctl is-active (expects active) and a journalctl -u claude-session-manager --since <restart-time> count-only check for invalid environment assignment (never read the raw lines — see the safe secret-handling table in the global CLAUDE.md).
Step 4 — Verify no active sessions using old key
# GitHub — list recent PAT usage
gh api /user -H "Authorization: Bearer <OLD_TOKEN>" 2>&1 # should 401
# Supabase — check if old service key still works
Invoke-RestMethod "https://mwkqmgadqnkkihjdeqsi.supabase.co/rest/v1/dev_r_services?limit=1" `
-Headers @{ apikey = "<OLD_KEY>"; Authorization = "Bearer <OLD_KEY>" } # should 401
# Vercel — old token should 401
Invoke-RestMethod "https://api.vercel.com/v2/user" `
-Headers @{ Authorization = "Bearer <OLD_TOKEN>" } # should 401Step 5 — Close the log entry + verify audit trail
The pending entry was opened in Step 1. Now close it via scripts/rotation-log-entry.py — this flips
pending → completed, records the timestamp, the SOPS commit, and the verification outcome. Use the
same $ROW_ID returned by open (including a fallback:<uuid> id if the API was down at open time):
python3 scripts/rotation-log-entry.py close "$ROW_ID" \
--sops-commit "$(git rev-parse HEAD)" \
--gh-secret-name KEY_NAME \
--verify-result "SOPS + GH Secret + vps-i1 via secrets-sync + <other consumers> confirmed live"
# If the rotation could not complete, record the failure instead:
# python3 scripts/rotation-log-entry.py fail "$ROW_ID" --error "<what went wrong>"Verify no rotation is left stalled — confirm no pending row is lingering (a pending row older
than 2h triggers the Alertmanager stalled-rotation alert; human-action rows are a distinct status
and are excluded):
python3 scripts/rotation-log-entry.py list --pending-only
# Should not list this rotation after a successful close.If a fallback write happened during this rotation (open/close returned a fallback:<uuid> id
because the API was down), confirm the pending fallback entries are drained back into Supabase:
python3 scripts/rotation-log-entry.py reimport # idempotent; add --dry-run to previewThe daily reimport-check cron is the backstop if you skip this. Never hand-edit
docs/rotation-log-fallback.md.
Verify audit trail is complete — confirm each of the following shows the new value is live:
# 1. SOPS has the new value (canary decrypt — no output means OK)
sops --decrypt --input-type dotenv --output-type dotenv secrets\<file>.env.sops | Out-Null
# 2. Rotation log entry is closed (completed, not pending)
python scripts\rotation-log-entry.py list --limit 3 # filter output for KEY_NAME
# 3. GH Secret updated (if applicable)
gh secret list --repo radieu/p24-infra | Select-String "KEY_NAME"
# 4. No running process still holds the old value
Get-CimInstance Win32_Process | Select-Object CommandLine | Select-String "KEY_NAME"
# If a process is found: kill it (it will restart with the new value from env/SOPS)Update dev_r_services.last_rotated + next_due:
UPDATE dev_r_services
SET last_rotated = '<date>',
next_due = '<date + rotation_freq>'
WHERE service_name IN ('SUPABASE_SERVICE_KEY', 'GH_TOKEN', 'VERCEL_TOKEN',
'SUPABASE_ACCESS_TOKEN', 'ANTHROPIC_API_KEY', 'SENTRY_AUTH_TOKEN');Step 6 — Root cause analysis
After keys are rotated:
- How did the key appear in the session transcript? (direct paste, file read, curl output)
- Add a
.claude/settings.jsonignore rule to prevent that file/pattern being read again - If key was in git history: run
git log -S "<partial-key>" --allto find the commit; notify if public
Escalation
If you cannot rotate a key (provider down, MFA issue, account locked):
- File GitHub issue:
🔴 CRITICAL: <KEY_NAME> potentially compromised, rotation blocked - Disable the affected service temporarily if possible (e.g., pause Vercel deploy, disable Supabase project)
- Contact provider support immediately
Prevention
SOPS files (
secrets/*.env.sops) are encrypted ciphertext and safe toRead— they contain no plaintext secrets. The primary remaining exposure vector issops -dbare stdout (now blocked by thepre-bash-safetyhook). The safe pattern for reading a single key from SOPS is shown below.
The Read tool and cat are equally dangerous on credential files — both dump full content into chat.
Preferred path for “use a secret in a command” (#5298, ADR 003): the broker
scripts/sops-invoke.ps1/Invoke-WithSopsSecretdecrypts inside its own process and redacts every fetched value (raw + URL-encoded + base64 + JSON-escaped) out of the wrapped command’s output. Every incident in the table at the top of this playbook was a hand-rolled extraction that got the safe pattern slightly wrong; the broker removes that hand-rolling. Its redactor is canary-guarded byTest-SopsSecretRedaction— which follows this section’s own §Prevention rule (“test your redaction on a known-safe string first”): it redacts synthetic sentinels (including a multiline value and base64/URL-encoded forms) and fails closed if any survives. Use the broker instead of a bespokeSelect-String/sops -d | grepextraction wherever a value must reach a command. Seedocs/adr/003-sops-secret-access-broker.md.
FORBIDDEN operations (cause full secret exposure in chat)
Readtool targeting.env.local,.env,*.sops, or any credential filecat/Get-Content/typeon credential filesGrepwithoutput_mode: "content"on credential filesprintenv,env,set(enumerate all env vars)docker inspect/kubectl get secretwithout value filteringbash -x/sh -x/set -x(shell command tracing) run — locally or over SSH — against any script that reads or uses credentials. Trace output echoes every expanded variable, including ones the script itself never intentionally prints. Confirmed 2026-08-02/03: tracing the gmail-tools OAuth token-refresh preflight over SSH on bms-4 printed the liveGMAIL_TOOLS_GCP_OAUTH_CLIENT_SECRETandGMAIL_TOOLS_OAUTH_TOKEN(incl. a freshly-issuedaccess_token) into the session transcript — seedocs/secrets-rotation-log.md2026-08-03 entry. If a script needs debugging, read it statically or add scoped, non-credentialecho/log lines instead of blanket tracing.docker logs <container> | grep <mongo/connected/db-health-term>— Node/Mongoose apps in this stack (W3v32-prod/s3-v32-prod, W4v42-prod, and siblings) log the full connection string, password included, on every connect/reconnect (Mongoose connected to mongoDB server: mongodb://w3_app:<PASSWORD>@host,host/db?...). The natural command to confirm DB health —grep -i mongoorgrep -i connected— is exactly what catches that credential-bearing line. A baredocker logs <container>with no filter at all is even more exposed (dumps the whole log). Root cause of the livew3_appMongoDB password exposure, 2026-08-02/03 (#5209). Safe alternative: redact the credential before it can print (| sed -E 's#://[^@]+@#://[REDACTED]@#g'), use a count-only grep (grep -c, never emits the matched line), search for a term that cannot match the connection-string line (error/fail), or prefer a state/HTTP check that never touches app logs at all — seedocs/playbooks/pinbox24-w3-w4-health-verification.md§2.2.- Any command whose stdout/stderr may contain a secret value
Research-only subagents must never
sops --decryptat all (issue #5544). Every past SOPS exposure in this repo — including #5538 (QUEUE_API_KEY, rotated via #5539) — was a hand-rolledsops --decrypt … | grep/Select-Stringrun inside what was nominally a research subagent (consumer audit, design spec, distribution-chain mapping), not an actual credential write. Such tasks only ever need key names and file paths, never a plaintext value. The canonical control is a prompting convention: any non-secret-managerAgent()/Workflowagent prompt must forbidsops -doutright — seeCLAUDE.md §Role Enforcement → Research/investigation subagents must NOT decrypt SOPSanddocs/playbooks/role-delegation-architecture.md§Research subagents.
Hook-enforced (issue #5165):
docker inspect <container>with no--format, a--formatthat itself selects.Config.Env(or a whole.Configdump — Config contains Env), anddocker exec <container> env/printenvwith no key argument are all blocked bypre-bash-safety.sh/pre-bash-safety-windows.ps1— local or run as a remote command string overssh. Before #5165, the--formatallowlist only checked that a--formatflag was present, not what it targeted, so--format '{{json .Config.Env}}'(the exact string that leakedDB_URI/JWT_TOKEN_SECRET/ the Wasabis3Bucket_api_*pair /MAILGUN_PASSWORDfroms3-v32-prod-renamedon bms-1) sailed straight through. Scoped--formatfields (.State.Status,.Config.Image,.Mounts, …) and single-keyprintenv KEY_NAMEstay allowed.
Hook-enforced (issue #5209):
docker logs <container>with no pipe/filter at all, anddocker logs | grepformongo/mongodb/connect(ed|ing)are both blocked by the same two hooks, local or over SSH — unless the pipeline also pipes through a redactionsed(matchingREDACTor a#://...@credential-stripping pattern) or the grep is count-only (-c/--count). A search term that cannot match the connection-string line (error/fail) stays allowed.
curl -H "..."inside PowerShell — thecurlalias binds toInvoke-WebRequest, not real curl (issue #5717). PowerShell aliasescurl/wgettoInvoke-WebRequest, which does not accept a bare-H "Header: value"flag — only a typed-Headers <hashtable>. Passing-Hthrows aParameterBindingExceptionwhose message embeds the fully-expanded, literal argument string — including a secret value interpolated into that string (e.g.-H "PRIVATE-TOKEN: $token") — in plain text. This bypasses thesops-invoke.ps1broker’s redaction entirely, because the broker redacts the wrapped command’s stdout/stderr, not a PowerShell-native binding exception thrown before the command body ever executes. Confirmed 2026-08-06: acurl -HGitLab API call inside a secret-manager session printed the liveGITLAB_ADMIN_PATvalue this way (#5717). Always useInvoke-RestMethod/Invoke-WebRequest -Headers @{ "Header-Name" = $token }(a real hashtable) in PowerShell — never a curl-style flag string — and if a realcurl.exebinary is required, call it explicitly viacurl.exe(not the barecurltoken, which resolves to the alias first).
A provider call that mints a brand-new secret value must capture AND persist it in the SAME tool/process call (issue #5717). Shell state (env vars, local variables) does not survive between separate tool invocations — each is a fresh process. A rotation flow that (a) calls a provider’s mint/rotate endpoint in one command and (b) writes the returned value into SOPS in a later, separate command will lose the value the instant step (a)‘s process exits, with no way to retrieve it afterward (the provider only returns a freshly-minted secret once). This caused a self-inflicted
GITLAB_ADMIN_PATlockout in #5717:POST /personal_access_tokens/self/rotatesucceeded and returned a new token, but only its metadata (id,expires_at) was captured before the call ended — the token value itself was never persisted, and a follow-up call using the now-stale SOPS value returned401. Always chain mint → capture →sops-set.ps1write → canary inside one script block, and only print success/failure status, never the value.
Redaction must be scheme-agnostic, and tested before it is trusted (issue #5223). A redaction that only masks one URI scheme is a silent leak waiting to happen: on 2026-08-03 an ad-hoc Windows dev-session regex anchored on
mongodb+srv://did not match the plainmongodb://scheme this stack actually uses, so it failed silently and printed the fullw3_apppassword (#5223). Two rules:
- Never anchor the credential match on the scheme. Strip the whole
//user:pass@authority segment regardless of scheme —sed -E 's#://[^@]+@#://[REDACTED]@#g'(bash) or the canonical PowerShell helperProtect-MongoUriinscripts/lib/sops-common.psm1(matchesmongodb://,mongodb+srv://, and any//user:pass@form because it does not look at the scheme at all).- Test the redaction on a known-safe fixture before trusting it — redact a synthetic string with a sentinel password and assert the sentinel is absent from the output. The helper ships this as
Test-MongoUriRedaction(a fail-closed canary); call it before relying onProtect-MongoUriin any session where a leak would matter.
Safe patterns
Delivering a new value to SOPS (replaces .env.local workflow)
Option A — single key (preferred): human pastes in terminal, Claude distributes to SOPS + GH Secret:
# Human runs this in terminal ONLY — never pastes value into chat:
$env:NEW_VALUE = "paste-new-credential-here"
# Claude then runs the SOPS update:
$env:SOPS_AGE_KEY_FILE = "C:\Users\konar\.age\p24-infra-keys.txt"
$plain = sops --decrypt --input-type dotenv --output-type dotenv secrets\<file>.env.sops
[System.IO.File]::WriteAllText("$PWD\secrets\edit-temp.env.sops", ($plain -join "`n") + "`n", [System.Text.UTF8Encoding]::new($false))
$content = [System.IO.File]::ReadAllText("$PWD\secrets\edit-temp.env.sops")
$content = $content -replace "(?m)^KEY_NAME=.*$", "KEY_NAME=$env:NEW_VALUE"
[System.IO.File]::WriteAllText("$PWD\secrets\edit-temp.env.sops", $content, [System.Text.UTF8Encoding]::new($false))
$enc = sops --encrypt --input-type dotenv --output-type dotenv secrets\edit-temp.env.sops
[System.IO.File]::WriteAllText("$PWD\secrets\<file>.env.sops", ($enc -join "`n") + "`n", [System.Text.UTF8Encoding]::new($false))
sops --decrypt --input-type dotenv --output-type dotenv secrets\<file>.env.sops | Out-Null
if ($LASTEXITCODE -ne 0) { throw "SOPS corrupt — do NOT commit" }
Remove-Item "$PWD\secrets\edit-temp.env.sops"
# Claude uses $env:NEW_VALUE for GH Secret, then clears it:
gh secret set KEY_NAME --body "$env:NEW_VALUE" --repo radieu/p24-infra
$env:NEW_VALUE = ""Option B — multi-key edit: Claude decrypts to secrets/*-edit.env.sops (temp file inside secrets/ to match .sops.yaml path_regex), human edits in VSCode, Claude re-encrypts + canary + deletes temp.
Extract one key for use in a command (read-only, does not write to .env.local):
$env:THE_SECRET = (sops --decrypt --input-type dotenv --output-type dotenv secrets\file.env.sops | Select-String "^KEY_NAME=").ToString().Split("=",2)[1]
some-command --token $env:THE_SECRET
$env:THE_SECRET = "" # clear immediately after useConfirm a key exists without revealing value:
(sops --decrypt --input-type dotenv --output-type dotenv secrets\file.env.sops | Select-String "^KEY_NAME=") -ne $null # outputs True/FalseEnforcement settings
Enforcement is implemented as PreToolUse hooks in .claude/settings.json (issue #1500):
Readtool →bash .claude/hooks/pre-read-safety.sh— blocksReadon any plaintext.envcredential file (.env,.env.local,.env.production, …) before it loads into the transcript. Encryptedsecrets/*.env.sopsand non-secret.env.example/.env.template/.env.sampleare allowed. (pre-read-safety-windows.ps1is the native-Windows equivalent.)Bashtool →bash .claude/hooks/pre-bash-safety.sh— also blockscat/less/more/ editors targeting a plaintext.envfile (the Bash-side equivalent of the Read guard).
Full detail and the exception list: docs/playbooks/prevent-env-local-read.md.
When to rotate
Any time secret values appeared in Claude chat output — even in your own private session — rotate all exposed keys. The transcript is stored on disk and could be accessed if the workstation is compromised. Follow Steps 1–5 above.
Audit Log — Log to infra_operations
After this operation completes, log it to the infra_operations audit table.
Python (Linux server — bms-4, vps-i1, vps-h1, or similar):
import sys
sys.path.insert(0, '/opt/p24-infra')
from scripts.lib.log_op import log_op
log_op(
actor="claude", # "radieu" for manual human ops, "claude" for agent
op_type="credential_rotation",
resource="EXPOSED_KEY",
result="success", # "success" | "failed" | "skipped"
detail="Incident rotation — exposed static key invalidated, new key deployed to all consumers",
env="local",
gh_issue=2730,
)PowerShell (Windows dev machine):
$env:SUPABASE_URL = (Get-Content "C:\code_2026\p24-infra\.env.local" | Select-String "^SUPABASE_URL=").ToString().Split("=",2)[1].Trim()
$env:SUPABASE_SERVICE_KEY = (Get-Content "C:\code_2026\p24-infra\.env.local" | Select-String "^SUPABASE_SERVICE_KEY=").ToString().Split("=",2)[1].Trim()
python -c "
import os, sys
sys.path.insert(0, 'C:/code_2026/p24-infra')
from scripts.lib.log_op import log_op
log_op('claude', 'credential_rotation', 'EXPOSED_KEY', 'success', 'Incident rotation — exposed static key invalidated, new key deployed to all consumers', 'local')
"
$env:SUPABASE_URL = ''; $env:SUPABASE_SERVICE_KEY = ''