Status: Single source of truth for all p24-infra secret operations. Role: Spawn a /role-secret-manager session for any secret add, rotate, retrieve, or distribute task. Last updated: 2026-07-01
What the secret-manager session does
The secret-manager is the only session that touches SOPS files, GH Secrets, or credential values.
No other session (dev, sys-admin, content, etc.) is authorised to edit secrets/*.env.sops.
Scope of the role:
Add / update / rotate secrets in SOPS
Distribute new values: SOPS → GH Secrets → live servers → Vercel → n8n
Check rotation tiers and create human-action issues when a human must act
Log every rotation to Supabase dev_r_rotation_log via scripts/rotation-log-entry.py
(the single log; auto-falls-back to docs/rotation-log-fallback.md only when the API is
unreachable — never hand-edit either markdown file)
Respond to exposure incidents (1-hour SLA)
When to spawn a secret-manager session
Any session (dev, infra-task, brandpilot, et-op, etc.) that needs to:
Add a new API key / token / password to the ecosystem
Rotate an existing credential (scheduled, exposure, or preventive)
Retrieve which SOPS file holds a specific key
Distribute a value to servers, Vercel, or n8n after a human has rotated it
Audit what credentials exist and their rotation status
Pattern: The requesting session creates a GH issue describing the operation, then either:
a) Spawns a /role-secret-manager inline session, or
b) Opens a new Claude Code session in C:\code_2026\p24-infra and runs /role-secret-manager
The secret-manager session handles everything from that point — the requesting session does NOT touch credentials.
Startup sequence (secret-manager session)
Acquire env lock — MANDATORY before any SOPS write (protects against concurrent sessions):
Vercel et-lager project via secrets-sync.yml sync-et-lager
Cloudflare API tokens (DNS + Workers deploy) — split from monitoring.env.sops (#3178 Track B, PR-E, 2026-08-02)
secrets/cloudflare.env.sops
dev + CI + runners (6-recipient rule; no narrowing yet)
NOT YET DEPLOYED — no secrets-sync.yml job exists (path excluded from auto-trigger); values are duplicates of the still-live copies in monitoring.env.sops until PR-D/PR-G land
Traccar + MySQL credentials — split from monitoring.env.sops (#3178 Track B, PR-E, 2026-08-02)
secrets/traccar.env.sops
dev + CI + runners
NOT YET DEPLOYED (same caveats). docs/sops-templates/traccar.keys notes a pre-existing gap: MYSQL_ROOT_PASSWORD is currently empty in monitoring.env.sops
IONOS API, Wasabi admin IAM, CF Global Key, GH_PAT_ADMIN, GITLAB_ADMIN_PAT (canonical copy), GCP SA key, MongoDB rs0 admin
secrets/administration.env.sops
developer local ONLY (no CI, no runners)
Not deployed — operator decrypt on demand
GITLAB_ADMIN_PAT — narrow copy, 2 recipients only (#4796)
secrets/pinbox24-gitlab.env.sops
developer + bms-4 infra-task worker key ONLY (custom exact-anchored .sops.yaml rule — not CI, not vps-i1/vps-h1/dev-laptop)
Not deployed by secrets-sync.yml (not a listed target job) — delivered to bms-4 for free by the existing git pull origin main inside sync-bms-4 (runs on every push touching any non-excluded secrets/*.env.sops, including this one); worker decrypts on demand from /opt/p24-infra/secrets/pinbox24-gitlab.env.sops. Same token value as administration.env.sops — rotate both atomically.
ANTHROPIC_ADMIN_API_KEY — narrow copy, 2 recipients only, isolated from monitoring.env.sops’s env_file: .env blast radius into alertmanager/mezmo-exporter/caddy (#5697/#5685)
secrets/anthropic-admin.env.sops
dev + CI (AGE_KEY_GHA) ONLY — same custom exact-anchored .sops.yaml rule pattern as pinbox24-gitlab.env.sops
vps-i1 /opt/p24-infra/monitoring/.env.anthropic-admin (0600 root:root) via secrets-sync.yml’s dedicated sync-anthropic-admin job — recreates cost-exporter only, never the full monitoring stack. See anthropic-admin-api-key.md.
SOPS health canary — test keys only (SOPS_CANARY, SOPS_CANARY_TIMESTAMP)
secrets/.canary.env.sops
dev only
scripts/remediation/test-sops-canary.sh
Lookup order for any secret: SOPS file → on-server .env.bak (may be stale) → .env.local (emergency only)
Role / worker credentials
Each Claude worker role gets its own SOPS file with scoped credentials — loaded by /role-<name> skill at session start.
Provider master-key coverage (which master key rotates which provider)
Three provider management/admin keys let the secret-manager mint or rotate downstream keys via API
(verified #4400, 2026-07-30 — all authenticate 200). Reachability matters: a standard bms-4
secret-manager worker (age key ~/.age/p24-infra-keys.txt) can decrypt monitoring, brandpilot,
whatsup — but notadministration or role-secret-manager.
Canonical reference:sops-windows-patterns.md — single source of truth for write safety and write method comparison. Patterns below are preserved for operational self-containment.
scripts/sops-set.ps1 (issue #3269) wraps the exact decrypt → edit → encrypt → canary →
atomic-move sequence documented in the fallback patterns below via
scripts/lib/sops-common.psm1’s Update-SopsKey / Update-SopsKeys. Use it for every
add/rotate instead of hand-running the raw pattern — it is unit-tested (Pester,
scripts/tests-ps1/sops-common.Tests.ps1) and never echoes a secret value.
This is not optional guidance — a .claude/hooks/pre-bash-safety* guard (issue #3545)
blocks a direct sops --encrypt ... .env.sops call that doesn’t go through sops-set.ps1
(or Update-SopsKey/Update-SopsKeys/Write-SopsFileContent directly). Hand-rolled encrypt
round trips skip the tested safety net and are exactly how #3275’s recipient-widening bug
got introduced in the first place.
Single key (value via -Value or $env:NEW_VALUE):
$env:SOPS_AGE_KEY_FILE = "C:\Users\konar\.age\p24-infra-keys.txt"$env:NEW_VALUE = "paste-here" # human sets this, never in chat.\scripts\sops-set.ps1 -SopsFile secrets\<file>.env.sops -Key KEY_NAME$env:NEW_VALUE = ""
Batch — multiple keys in one file, one decrypt/encrypt round trip:
.\scripts\sops-set.ps1 -SopsFile secrets\<file>.env.sops -Key KEY_NAME -Value x -DryRun
Run from the repo root with a relative-SopsFile path (e.g. secrets\<file>.env.sops) —
Update-SopsKey/Update-SopsKeys build their temp-file paths by joining the current location
with $SopsFile, so an absolute path will fail. Exits non-zero on any failure; only key
names and OK/FAIL are ever printed, never values.
secrets/administration.env.sops is now safe to write via sops-set.ps1 (fixed by
issue #3275 / PR referencing it). Update-SopsKey/Update-SopsKeys detect exact-filename
-anchored .sops.yaml rules (Test-SopsSingleRecipientRule in sops-common.psm1) — e.g.
path_regex: (^|[/\\])secrets[/\\]administration\.env\.sops$, restricted to a single
developer-only age recipient — and route those writes through an in-place-safe path
instead of the generic *-edit-tmp.env.sops / *-enc-tmp.env.sops temp-file naming.
The plaintext temp file is staged under a throwaway directory OUTSIDE secrets/ whose
path still ends in secrets/administration.env.sops (sops resolves creation_rules from
the positional input path passed to --encrypt, not from --output — verified
empirically while building the fix), so it correctly resolves to the single-recipient
rule while plaintext never touches the real secrets/ directory. Every write (single-
recipient or general) is now also verified post-write: the resulting file’s embedded
sops_age__list_N__map_recipient= count is compared against the matched rule’s expected
recipient count, and any mismatch triggers an automatic rollback to a pre-write backup
plus a thrown error — the real file is never left in a widened or corrupted state.
The manual fallback pattern below is kept for reference / emergencies (e.g. sops or
age-keygen unavailable) but is no longer required for administration.env.sops.
sops-set.ps1 is the enforced path to put a value into SOPS. Its counterpart
scripts/sops-invoke.ps1 (issue #5298) is the preferred path to use a value
from SOPS in a command — replacing the ad-hoc
$v = (sops -d ... | Select-String '^KEY=')... ; cmd $v one-off that was behind
every historical leak (#2040/#3545/#3714/#5223). The broker decrypts inside its
own process, exposes each value only as an env var for the wrapped command’s
lifetime, and redacts every fetched value (raw + URL-encoded + base64 +
JSON-escaped) out of the command’s merged stdout/stderr before returning it. The
value is never printed and never returned. See
docs/adr/003-sops-secret-access-broker.md.
The broker also underpins the Playwright carve-out for UI-only credential
flows (scripts/secret-broker/Invoke-PlaywrightWithSecret.ps1) —
gitlab-token-playwright-broker.md.
Bash/Linux workers: a bash broker sibling is not yet built (ADR 003 §Known
gap), so on Linux workers the documented safe-extraction pattern
(VAR=$(sops -d … | grep '^KEY=' | cut -d= -f2-); cmd "$VAR"; unset VAR)
remains the correct fallback until it lands.
Fallback — manual pattern (only if the script can’t be used at all)
Add a new key to an existing SOPS file
$env:SOPS_AGE_KEY_FILE = "C:\Users\konar\.age\p24-infra-keys.txt"$SOPS_FILE = "$PWD\secrets\<file>.env.sops"$TEMP_PLAIN = "$PWD\secrets\<file>-edit-tmp.env.sops" # must match path_regex$TEMP_ENC = "$PWD\secrets\<file>-enc-tmp.env.sops" # encrypted staging file# 1. Decrypt to variable (never print)$plain = sops --decrypt --input-type dotenv --output-type dotenv $SOPS_FILEif ($LASTEXITCODE -ne 0) { throw "Decrypt failed" }# 2. Append new key in memory (value from $env:NEW_VALUE — never hardcoded or printed)$content = ($plain -join "`n") + "`n" + "NEW_KEY=$env:NEW_VALUE" + "`n"$plain = @() # clear plaintext from memory# 3. Write plaintext to a TEMP file — never to the production SOPS path[System.IO.File]::WriteAllText($TEMP_PLAIN, $content, [System.Text.UTF8Encoding]::new($false))$content = ""# 4. Encrypt to a second temp — SOPS writes binary, no PowerShell string pipelinesops --encrypt --input-type dotenv --output-type dotenv --output $TEMP_ENC $TEMP_PLAINif ($LASTEXITCODE -ne 0) { @($TEMP_PLAIN, $TEMP_ENC) | Where-Object { $_ -and (Test-Path $_) } | ForEach-Object { Remove-Item $_ -Force -ErrorAction SilentlyContinue } throw "Encrypt failed"}Remove-Item $TEMP_PLAIN -Force -ErrorAction SilentlyContinue # plaintext gone# 5. Canary on encrypted temp — production file untouched until this passessops --decrypt --input-type dotenv --output-type dotenv $TEMP_ENC | Out-Nullif ($LASTEXITCODE -ne 0) { Remove-Item $TEMP_ENC -Force -ErrorAction SilentlyContinue throw "SOPS corrupt on temp — NOT overwriting production file"}# 6. Atomic move — production file replaced only after canary passesMove-Item $TEMP_ENC $SOPS_FILE -Force# 7. Clear sensitive vars$env:NEW_VALUE = ""Write-Host "Done"
Update an existing key value
Same pattern as above, but step 2 uses -replace instead of append:
Check if a key exists / verify its identity (MANDATORY — never hand-roll this)
Never write (... | Where-Object {...}) -ne $null or -eq $null against decrypted SOPS
output. When the left side of -ne/-eq is a collection (which Where-Object always
returns), PowerShell applies the operator elementwise and returns the matching elements
themselves — the raw KEY=value secret line — not a boolean. This is exactly how
WASABI_ADMIN_ACCESS_KEY/WASABI_ADMIN_SECRET_KEY were exposed in full on 2026-07-09
(issue #3545): an agent’s presence-check one-liner echoed the real values to tool output.
A .claude/hooks/pre-bash-safety* guard now blocks this pattern outright — use the safe,
tested helpers from scripts/lib/sops-common.psm1 instead:
Import-Module .\scripts\lib\sops-common.psm1 -Force# Boolean presence only:Test-SopsKeyExists -SopsFile secrets\<file>.env.sops -KeyName KEY_NAME# -> $true / $false, never prints the value# Presence + length + a one-way SHA-256 fingerprint (first 8 hex chars) — useful for# "is this the same value as last time" / "did this differ across two SOPS files"# (e.g. the #3426 MongoDB-admin-credential-drift investigation) WITHOUT ever exposing# a character of the actual value, not even a truncated fragment (a `head -c N` on a# credential file was separately blocked this same session for exactly that reason —# partial output still counts as exposure):Get-SopsKeyFingerprint -SopsFile secrets\<file>.env.sops -KeyName KEY_NAME# -> @{ Exists = $true; Length = 42; Fingerprint = "a3f9c1e2" }
NEVER use >, Out-File, or Set-Content for SOPS files — they add CRLF + BOM.
Always use [System.IO.File]::WriteAllText($path, $content, [System.Text.UTF8Encoding]::new($false)).
administration.env.sops is developer-only. Its .sops.yaml path_regex restricts it to one recipient.
The in-place encrypt pattern works correctly because the file path matches the first creation_rule.
Do NOT use a temp file with a different name in secrets/ for administration — it would match the
second rule (5 recipients) and encrypt with CI + runner keys. Always encrypt administration in-place.
Pitfall — Select-String against a hand-off file can silently return MULTIPLE matches (#5835)
When extracting a value from a human hand-off file (.env.local) with
Select-String -Pattern "^KEY=", always check .Count (or inspect .LineNumber) before
trusting the result — never assume exactly one match. If the key appears on more than one
line (a stale/corrupted leftover line plus a freshly-appended correct one, or any other
duplicate), the returned object is an array of MatchInfo, and this is where it gets
dangerous:
$line.Line on that array auto-enumerates the property, silently returning a string
array, not a scalar.
A later -match / -replace / string-method call against that array can return wrong or
leaked data instead of erroring — e.g. $array -match 'pattern' (elementwise, like the
documented -ne $null bug in this same file’s “Check if a key exists” section) returns the
full matching line(s) themselves, not a boolean. In #5835 this printed a corrupted
hand-off line’s full content (including a real token) straight into an agent’s tool output.
Depending on which element “wins” implicitly, a naive .Split("=",2)[1] extraction can also
pick up the wrong line’s value without any error at all — in #5835 this caused a rotated
GH_TOKEN to be distributed to 3 SOPS files + a GH Secret using a corrupted/garbage value,
which then broke every consumer (gh auth login --with-token → 401) until caught and
corrected in a follow-up PR.
Safe pattern:
$matches = Select-String -Path $path -Pattern "^KEY_NAME="if ($matches.Count -ne 1) { Write-Host "Expected exactly 1 match for KEY_NAME, found $($matches.Count) -- inspect LineNumbers before proceeding" $matches | ForEach-Object { Write-Host "LineNumber=$($_.LineNumber) LineLength=$($_.Line.Length)" } # metadata only, never content # Resolve manually: pick the correct LineNumber explicitly, e.g. # $chosen = ($matches | Where-Object { $_.LineNumber -eq 39 }).Line exit 1}$env:NEW_VALUE = $matches.Line.Split("=",2)[1]
Ground-truth verify any extracted credential (HTTP status / auth check only, never printed)
before writing it to SOPS or a GH Secret — this is what caught the #5835 mis-extraction on
the very next distribution step, before the wrong value could do more damage than one failed
secrets-sync.yml job.
How to receive a new credential from a human
The human should NOT paste the value into chat. Instead:
# Human runs this in their terminal — value never goes to chat$env:NEW_VALUE = "paste-here"
Then the secret-manager session uses $env:NEW_VALUE in the SOPS add/update pattern above.
After the SOPS operation: $env:NEW_VALUE = "" to clear.
Distribution chain — after any SOPS update
Every changed credential must propagate to ALL applicable destinations before the old value is revoked:
SOPS commit — git add secrets/<file>.env.sops → git commit → PR to main
Revoke old value at provider — only after all destinations confirmed live
Rotation tier decision tree
Before starting any rotation, read docs/playbooks/secret-rotation-access-matrix.md:
New rotation triggered
│
├─ Tier 1 (✅ fully autonomous)?
│ └─ YES → run `scripts/rotate-credentials.py` or per-key API script
│
├─ Tier 2 (⚠️ semi-autonomous)?
│ ├─ Prerequisite met (TOTP? Playwright session? Admin key valid)?
│ │ ├─ YES → proceed as Tier 1 via Playwright or admin API
│ │ └─ NO → escalate to Tier 3 for this key
│
└─ Tier 3 (❌ manual only)?
├─ Create GH issue: label "human-action" (+ "critical" if exposure)
│ Title: "sec: rotate KEY_NAME — [exposure|scheduled] YYYY-MM-DD"
├─ Human rotates at provider dashboard → sets $env:NEW_VALUE in terminal
└─ secret-manager session: SOPS update → distribution → old key revoked
Exposure SLA: 1 hour. Any credential value visible in chat, logs, or git must be rotated within 1 hour.
Adding a new credential to the ecosystem
Choose the SOPS file based on the scope (see file map above)
Check rotation tier — add to docs/playbooks/secret-rotation-access-matrix.md
Add to SOPS using scripts/sops-set.ps1 (§“Preferred: sops-set.ps1 (canonical tool)”
above) — safe for all SOPS files including administration.env.sops (issue #3275)
Add to GH Secrets if used in CI/CD
Reference in config (docker-compose.yml or app config) via env var — never hardcode
Register in Supabasedev_r_services table (compliance_workbook='yes')
Add rotation schedule to dev_r_services.next_due and rotation_type
Log the bootstrap to Supabase dev_r_rotation_log via scripts/rotation-log-entry.py
(never hand-edit any markdown log file):
ROW_ID=$(python3 scripts/rotation-log-entry.py open --secret KEY_NAME \ --reason "bootstrap — new credential" --reason-category manual --rotator secret-manager)python3 scripts/rotation-log-entry.py close "$ROW_ID" --sops-commit "$(git rev-parse HEAD)"
Writes to dev_r_rotation_log; if the API is unreachable the script auto-falls-back to
docs/rotation-log-fallback.md (returning a fallback:<uuid> id), drained back on the next
successful API call.
Nowe narzędzia do tworzenia środowisk SOPS od zera i weryfikacji kompletności.
Szybki start — sprawdź co brakuje w SOPS
# Sprawdź WSZYSTKIE środowiska (monitoring, n8n-bms4, et-lager, et-op, brandpilot, art-agency).\scripts\bootstrap\validate-sops-env.ps1 -Env all# Lub jedno środowisko.\scripts\bootstrap\validate-sops-env.ps1 -Env monitoring
Wynik: lista brakujących kluczy z [Tier1]/[Tier2]/[Tier3] i notką. Tier 3 = wymaga human-action.
Uzupełnij brakujące klucze
# 1. Ustaw Tier 3 klucze ręcznie (wartości TYLKO w terminalu — nie w chacie):$env:VALUE_SUPABASE_SERVICE_ROLE_KEY = "sb_secret_..."$env:VALUE_GH_TOKEN = "ghp_..."# itd. — pełna lista P0/P1/P2 w docs/playbooks/sops-audit-report.md# 2. Uruchom bootstrap tylko dla brakujących kluczy:.\scripts\bootstrap\new-sops-env.ps1 -Env monitoring -OnlyMissing# 3. Tylko Tier 1 (autonomous) — bez interakcji:.\scripts\bootstrap\new-sops-env.ps1 -Env monitoring -OnlyMissing -TierFilter 1# 4. Dry run — podgląd bez zmian:.\scripts\bootstrap\new-sops-env.ps1 -Env monitoring -DryRun
Tier-routing w bootstrap script
Tier
Co robi script
1
Generuje autonomicznie (random hex, bcrypt, API call)
2
Drukuje instrukcję jak uruchomić skrypt Playwright; jeśli $env:VALUE_KEY ustawiony — użyje go
3
Czeka na $env:VALUE_KEY ustawiony przez człowieka; bez niego pomija
Aby dodać nowe środowisko do walidatora: stwórz docs/sops-templates/<env>.keys według wzoru z istniejących plików.
Audit — aktualny stan SOPS
docs/playbooks/sops-audit-report.md — snapshot z 2026-07-05: lista P0/P1/P2 broken/stale kluczy, dostępne admin keys umożliwiające autonomiczną rotację, skonsolidowane $env:VALUE_* do ustawienia.
Regeneruj po każdej sesji rotacji: uruchom validate-sops-env.ps1 -Env all i zaktualizuj plik.
Supabase dev_r_rotation_log is the single rotation log for everyone (desktop sessions and
workers alike). Every rotation — successful, failed, or escalated — must be recorded there via
scripts/rotation-log-entry.py, never by hand-editing a markdown file. Open the pending entry
before touching the key so a traceable record survives even an interrupted session:
# 1. Open (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 scheduled|exposure|preventive|manual] [--rotator secret-manager])# 2. Close as completed after distribution:python3 scripts/rotation-log-entry.py close "$ROW_ID" \ --sops-commit "$(git rev-parse HEAD)" [--gh-secret-name KEY_NAME] --verify-result "<outcome>"# or mark it failed:python3 scripts/rotation-log-entry.py fail "$ROW_ID" --error "<what went wrong>"
reason is now free text; the optional reason_category (scheduled/exposure/preventive/manual) is a
coarse label used for reporting only. Status values: pending / completed / failed /
human-action.
Live-write rotations use acquire/check, NOT plain open (MANDATORY — ADR 004)
open above is fail-open logging. It does not stop a second session rotating the same secret
at the same time. For a live-write rotation — one that pairs a secrets/*.env.sops write with a
non-idempotent live server-side write (MongoDB changeUserPassword/updateUser, a Wasabi/S3 IAM
key mint, any provider “regenerate” that mints a value in place) — you MUST hold the fail-closed
per-(repo, secret) advisory lock instead. Two sessions each mint a different value and silently
diverge (live server vs SOPS/Vercel) — the #5925 race ADR 004
exists to stop. Replace open with acquire as the first step (before touching the credential),
and re-check with checkimmediately before the live write:
# 1. Acquire — this REPLACES `open` (it opens the pending row itself). Fail-CLOSED exit codes:# 0 = acquired ($LOCK_ID is the pending row id) · 3 = denied (another session holds it — STOP,# reconcile, do NOT blind-overwrite) · 4 = could-not-verify Supabase (fail-closed, do NOT proceed).LOCK_ID=$(python3 scripts/rotation-log-entry.py acquire --secret KEY_NAME \ --repo p24-infra --reason "<free text / #issue>" --rotation-type manual) \ || { echo "acquire denied/unverified — STOP, do NOT rotate"; exit 1; }# 2. Layer-2 re-check immediately before the non-idempotent live write — lock still held?python3 scripts/rotation-log-entry.py check "$LOCK_ID" \ || { echo "lock lost — abort the live write and reconcile"; exit 1; }# 3. Release with the SAME close/fail verbs on $LOCK_ID after distribution (there is no separate# release verb — closing/failing the pending row frees the lock for the next rotation).python3 scripts/rotation-log-entry.py close "$LOCK_ID" --sops-commit "$(git rev-parse HEAD)"
--repo is the short name the lock is keyed on (p24-infra, or the requester’s repo such as
et-operational-platform for a cross-repo secret like PINBOX24_MONGODB_URI) — matched exactly.
SOPS-only edits keep using plain open. Adding a key, editing a non-live config value, a
distribution-only sync, or a gap-audit is git-mergeable and does NOT acquire the lock (ADR 004
scope). The lock is as narrow as the race: same (repo, secret), live server-side write only.
On the Windows dev machine use python and test $LASTEXITCODE. Full CLI reference (all verbs,
exit codes, fallback semantics): secret-manager-rotation-log.md.
Offline fallback: if the Supabase API is unreachable, the script writes the entry to
docs/rotation-log-fallback.md instead and returns a fallback:<uuid> id that 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). If your fallback write must
travel in a commit to reach the cron, stage it with the rotation’s SOPS commit — see
docs/playbooks/secret-manager-rotation-log.md. Never hand-edit docs/rotation-log-fallback.md.
Rotation automation
Auto-rotation runs Monday 06:00 UTC via credential-rotation.yml:
gh workflow run credential-rotation.yml --repo radieu/p24-infra# Force all + live run:gh workflow run credential-rotation.yml --repo radieu/p24-infra -f force_all=true -f dry_run=false
Safe-handling rules (absolute — no exceptions)
Never display a secret value in chat. Reference KEY_NAME only.
Never hardcode values in docker-compose, scripts, or n8n node parameters.
Never use >, Out-File, Set-Content for SOPS files — CRLF/BOM corruption.
Never commit plaintext .env — only encrypted secrets/*.env.sops in git.
Never use sops --set for dotenv files — it does not work (JSON path only).
If a secret value appears in chat: stop immediately, reference KEY NAME only, rotate within 1h.
Follow docs/playbooks/static-api-key-incident-rotation.md.