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 (or failed) entry in Supabase dev_r_rotation_log, written via scripts/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:

  1. Read through all steps below.
  2. Find every place the key is used — code, GH Secrets, containers, n8n, Vercel env vars.
  3. 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-platform and 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 names

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

  1. How was the key exposed? Common vectors: LLM transcript (sops -d bare stdout), Read tool on credential file, curl verbose output, git history, printenv / env enumeration, log file containing startup env dump.

  2. 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.sh rules
    • 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.
  3. OWASP risk rating — determines response urgency Use OWASP Risk Rating Methodology: Likelihood (threat agent + exploitability) × Impact (technical + business)

    RatingResponse
    CriticalRotate immediately; consider disabling the Supabase project temporarily while rotating
    HighRotate in current session, no deferral
    MediumRotate within 24h
    LowSchedule 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 20

For 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 call

This 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:

PriorityKeyWhy first
1SUPABASE_SERVICE_KEYDatabase + all app data
2GH_TOKEN / GH_PATRepo write + workflow secrets access
3VERCEL_TOKENProduction deploy + env vars
4SUPABASE_ACCESS_TOKENManagement API — can change schema/keys
5ANTHROPIC_API_KEYBilling risk
6SENTRY_AUTH_TOKENError 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:

  1. Generate/get new value at the provider (dashboard, CLI, API)
  2. Update SOPS file immediately (source of truth) — safe edit pattern from CLAUDE.md. See CLAUDE.md §Key → SOPS file mapping for the correct file. → Note SOPS commit hash for the close --sops-commit call
  3. Push SOPS commit to devsecrets-sync.yml auto-deploys to vps-i1 and bms-4. git add secrets/<file>.env.sopsand also git add docs/rotation-log-fallback.md when 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 through secret-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.
  4. 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-infraRecord on close via --gh-secret-name KEY_NAME
  5. Update any Vercel env vars if the key is used by a Vercel project
  6. Update n8n credentials if the key is used by n8n workflows (scripts/sync-n8n-supabase-creds.py for Supabase keys)
  7. Restart running containers that hold the old key in memory
  8. 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"
  9. Verify secrets-sync.yml GH Actions run completed — confirm new value is live on all VPS servers.
  10. Revoke the old key at the provider — only after confirming the new value works everywhereClose the log entry (Step 5): rotation-log-entry.py close "$ROW_ID" flips pendingcompleted

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 401

Step 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 pendingcompleted, 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 preview

The 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:

  1. How did the key appear in the session transcript? (direct paste, file read, curl output)
  2. Add a .claude/settings.json ignore rule to prevent that file/pattern being read again
  3. If key was in git history: run git log -S "<partial-key>" --all to find the commit; notify if public

Escalation

If you cannot rotate a key (provider down, MFA issue, account locked):

  1. File GitHub issue: 🔴 CRITICAL: <KEY_NAME> potentially compromised, rotation blocked
  2. Disable the affected service temporarily if possible (e.g., pause Vercel deploy, disable Supabase project)
  3. Contact provider support immediately

Prevention

SOPS files (secrets/*.env.sops) are encrypted ciphertext and safe to Read — they contain no plaintext secrets. The primary remaining exposure vector is sops -d bare stdout (now blocked by the pre-bash-safety hook). 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-WithSopsSecret decrypts 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 by Test-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 bespoke Select-String/sops -d | grep extraction wherever a value must reach a command. See docs/adr/003-sops-secret-access-broker.md.

FORBIDDEN operations (cause full secret exposure in chat)

  • Read tool targeting .env.local, .env, *.sops, or any credential file
  • cat / Get-Content / type on credential files
  • Grep with output_mode: "content" on credential files
  • printenv, env, set (enumerate all env vars)
  • docker inspect / kubectl get secret without value filtering
  • bash -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 live GMAIL_TOOLS_GCP_OAUTH_CLIENT_SECRET and GMAIL_TOOLS_OAUTH_TOKEN (incl. a freshly-issued access_token) into the session transcript — see docs/secrets-rotation-log.md 2026-08-03 entry. If a script needs debugging, read it statically or add scoped, non-credential echo/log lines instead of blanket tracing.
  • docker logs <container> | grep <mongo/connected/db-health-term> — Node/Mongoose apps in this stack (W3 v32-prod/s3-v32-prod, W4 v42-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 mongo or grep -i connected — is exactly what catches that credential-bearing line. A bare docker logs <container> with no filter at all is even more exposed (dumps the whole log). Root cause of the live w3_app MongoDB 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 — see docs/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 --decrypt at all (issue #5544). Every past SOPS exposure in this repo — including #5538 (QUEUE_API_KEY, rotated via #5539) — was a hand-rolled sops --decrypt … | grep/Select-String run 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-manager Agent()/Workflow agent prompt must forbid sops -d outright — see CLAUDE.md §Role Enforcement → Research/investigation subagents must NOT decrypt SOPS and docs/playbooks/role-delegation-architecture.md §Research subagents.

Hook-enforced (issue #5165): docker inspect <container> with no --format, a --format that itself selects .Config.Env (or a whole .Config dump — Config contains Env), and docker exec <container> env / printenv with no key argument are all blocked by pre-bash-safety.sh / pre-bash-safety-windows.ps1 — local or run as a remote command string over ssh. Before #5165, the --format allowlist only checked that a --format flag was present, not what it targeted, so --format '{{json .Config.Env}}' (the exact string that leaked DB_URI / JWT_TOKEN_SECRET / the Wasabi s3Bucket_api_* pair / MAILGUN_PASSWORD from s3-v32-prod-renamed on bms-1) sailed straight through. Scoped --format fields (.State.Status, .Config.Image, .Mounts, …) and single-key printenv KEY_NAME stay allowed.

Hook-enforced (issue #5209): docker logs <container> with no pipe/filter at all, and docker logs | grep for mongo/mongodb/connect(ed|ing) are both blocked by the same two hooks, local or over SSH — unless the pipeline also pipes through a redaction sed (matching REDACT or 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 — the curl alias binds to Invoke-WebRequest, not real curl (issue #5717). PowerShell aliases curl/wget to Invoke-WebRequest, which does not accept a bare -H "Header: value" flag — only a typed -Headers <hashtable>. Passing -H throws a ParameterBindingException whose 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 the sops-invoke.ps1 broker’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: a curl -H GitLab API call inside a secret-manager session printed the live GITLAB_ADMIN_PAT value this way (#5717). Always use Invoke-RestMethod/Invoke-WebRequest -Headers @{ "Header-Name" = $token } (a real hashtable) in PowerShell — never a curl-style flag string — and if a real curl.exe binary is required, call it explicitly via curl.exe (not the bare curl token, 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_PAT lockout in #5717: POST /personal_access_tokens/self/rotate succeeded 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 returned 401. Always chain mint → capture → sops-set.ps1 write → 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 plain mongodb:// scheme this stack actually uses, so it failed silently and printed the full w3_app password (#5223). Two rules:

  1. 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 helper Protect-MongoUri in scripts/lib/sops-common.psm1 (matches mongodb://, mongodb+srv://, and any //user:pass@ form because it does not look at the scheme at all).
  2. 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 on Protect-MongoUri in 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 use

Confirm 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/False

Enforcement settings

Enforcement is implemented as PreToolUse hooks in .claude/settings.json (issue #1500):

  • Read toolbash .claude/hooks/pre-read-safety.sh — blocks Read on any plaintext .env credential file (.env, .env.local, .env.production, …) before it loads into the transcript. Encrypted secrets/*.env.sops and non-secret .env.example / .env.template / .env.sample are allowed. (pre-read-safety-windows.ps1 is the native-Windows equivalent.)
  • Bash toolbash .claude/hooks/pre-bash-safety.sh — also blocks cat/less/more/ editors targeting a plaintext .env file (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 = ''