ADR 003: SOPS Secret-Access Broker + Playwright Secret Carve-Out
Status: Accepted — Tier 2 implemented in #5298 (Invoke-WithSopsSecret + Test-SopsSecretRedaction + Test-PlaywrightSecretUsage in sops-common.psm1, scripts/sops-invoke.ps1, scripts/secret-broker/Invoke-PlaywrightWithSecret.ps1, scripts/rotate/gitlab-admin-pat.js, docs/playbooks/gitlab-token-playwright-broker.md, non-bulk playbook migrations). Tier 3, the bash sibling, and the bulk *-rotation.md sweep remain deferred follow-ups (see §Follow-up issues to open).
Date: 2026-08-03
Issue: #5298
Relates: #2040, #3545, #3714, #5165, #5209, #5223 (leak incidents), 3275 (sops-set.ps1 write path), #4601 (updatekeys), 4706 (role capability scoping), companion PR for pre-grep-safety.sh
Context
Every historical SOPS leak in this repo was a hand-rolled extraction command that got the safe pattern slightly wrong, run inside an interactive PowerShell dev-session:
| Incident | Root cause | Fix landed |
|---|---|---|
| #2040 | sops -d n8n-bms4.env.sops dumped all values to stdout (misplaced trailing flag) | pre-bash-safety.sh SOPS-stdout guard |
| #3545 | ($sm | Where-Object {…}) -ne $null returned the matching KEY=value lines (collection-vs-scalar) → WASABI_ADMIN keys echoed | Get-SopsKeyFingerprint/Test-SopsKeyExists + hook |
| #3714 | Same class via Select-String … -ne $null | hook extended |
| #5223 | Ad-hoc redaction regex anchored on mongodb+srv://, missed plain mongodb:// → full V32_DB_URI printed | Protect-MongoUri/Test-MongoUriRedaction + //[^/]*@ scheme-agnostic mask |
| #5165, #5209 | (same class — hand-rolled extraction/redaction near a decrypted value) | — |
The common denominator: no shared, tested, reusable path exists for “use a decrypted secret in a
command”. A presence check has one (Test-SopsKeyExists); a redaction has one (Protect-MongoUri);
a write has one (scripts/sops-set.ps1, enforced by a hook that blocks any sops --encrypt
outside it). But “fetch value → hand it to a command” is still hand-rolled everywhere, which is
exactly where the mistakes creep in.
What Tier 1 (hooks) already does — and does not do
.claude/hooks/pre-bash-safety.sh / pre-read-safety.sh / pre-grep-safety.sh block known-bad
command shapes before they run:
sops -d …env.sopswith no safe sink (pipe togrep/Out-Null, redirect,exec-env, or--output <file>before the positional) → blocked (#2040).- Hand-rolled
Where-Object/Select-String … -ne $nullpresence checks → blocked (#3545/#3714). sops --encryptoutsidesops-set.ps1→ blocked (#3545 follow-up).sops updatekeyson a dotenv*.env.sops→ blocked (#4601).Read/cat/Grep -o/Grep output_mode=contenton a plaintext.env*→ blocked.
Hooks are reactive and pattern-based. They do not remove the underlying capability: a session
that legitimately holds the age key can still legally decrypt a value into $VAR via the
documented safe pattern and end up holding it. Tier 1 stops you from constructing a leaking
command; it does not stop the value from entering the process at all.
Decision (proposed)
Add a broker layer (Tier 2) so Claude’s own logic is never the process that receives the plaintext value. Three tiers, of which only Tier 2 (incl. the Playwright carve-out) is in scope for #5298:
- Tier 1 — hook deny-list (already in flight; out of scope, do not touch): reactive, command-shape.
- Tier 2 — broker (this ADR):
Invoke-WithSopsSecretdecrypts inside the broker’s own process, runs caller logic that references the value only via an env var name, and redacts the value out of anything that logic would otherwise hand back. Plus the GitLab Playwright carve-out, which is a specialisation of the same broker. - Tier 3 — fixed op catalogue (
scripts/secret-ops/*.ps1): explicitly deferred to a follow-up (see §Tier 3).
Tier 2 — Invoke-WithSopsSecret
Location: extend scripts/lib/sops-common.psm1 (it already owns the read-side helpers
Get-SopsKeyFingerprint/Test-SopsKeyExists and the redaction helpers Protect-MongoUri/
Test-MongoUriRedaction). Keeping all secret-plumbing in the one already-Pester-tested module — not
a new module — means the broker inherits that test harness and its import site (sops-set.ps1
already Import-Modules it). Add a thin CLI scripts/sops-invoke.ps1 mirroring sops-set.ps1 for
cross-process / command-line invocation.
Signature (proposed):
function Invoke-WithSopsSecret {
param(
[Parameter(Mandatory)][string]$SopsFile, # secrets/<name>.env.sops
# Map of ENV-VAR-NAME -> SOPS-KEY-NAME. The Action sees $env:ENV_VAR_NAME only.
# Env-var indirection (not a scriptblock parameter) is deliberate: the value never
# flows through a positional/pipeline binding that Claude-authored code could echo,
# and it mirrors the Playwright carve-out rule (child-process env var, never CLI arg).
[Parameter(Mandatory)][hashtable]$Keys, # @{ GL_TOKEN = 'GITLAB_ADMIN_PAT' }
[Parameter(Mandatory)][scriptblock]$Action, # references $env:GL_TOKEN; must never print it
[int]$TimeoutSec = 120,
[switch]$AllowMultiline # opt-in for values with embedded newlines
)
# 1. Decrypt $SopsFile ONCE via sops -d (piped, never to stdout); extract each requested key
# with Get-Kv into a LOCAL map. Never returned, never printed, cleared in `finally`.
# 2. Set process env vars ENV_NAME=value for the lifetime of $Action only.
# 3. Run $Action, capturing BOTH stdout and stderr into $captured (merged, so a wrapped
# command that echoes to stderr cannot bypass the net).
# 4. Immediately clear the env vars and local value map (finally block, fail-closed).
# 5. REDACT: for every fetched value, replace all literal occurrences in $captured with
# '<redacted:ENV_NAME>'. Redact EVERY encoding a value can reach a log/trace in, not just raw:
# - raw literal
# - [Uri]::EscapeDataString() (URL-encoded — tokens often reach logs this way)
# - base64 and JSON-string-escaped forms (values surface encoded inside Playwright trace
# .zip/JSON artifacts, not only as raw stdout)
# Use a literal (non-regex, non-line-anchored) replace so an -AllowMultiline value with
# embedded newlines cannot slip a line past a line-anchored pattern — that anchoring blind
# spot is exactly the #5223 failure mode. Refuse to return output if the pre-flight canary
# (Test-SopsSecretRedaction) has not passed this session (fail-closed).
# 6. Return [ordered]@{ ExitCode = <int>; Output = <redacted string> }. NEVER the value.
}Mandatory canary — Test-SopsSecretRedaction (new, alongside Test-MongoUriRedaction): redacts
a synthetic sentinel through the same redactor and asserts the sentinel is absent and the
<redacted:…> marker is present, before the broker trusts its own redaction — per
static-api-key-incident-rotation.md §Prevention (“test your redaction on a known-safe string first”).
Invoke-WithSopsSecret calls it once per session and fails closed if it throws. Uses synthetic
fixtures only — never a real credential. The canary fixtures must include a multiline value and
assert the redactor removes it (guards the line-anchored blind spot) and a value whose base64/
URL-encoded forms also appear, asserting all encodings are stripped.
Usage — replaces the ad-hoc sops -d | grep | cut → $VAR → cmd $VAR one-off:
$r = Invoke-WithSopsSecret -SopsFile secrets/administration.env.sops -Keys @{ GL_TOKEN = 'GITLAB_ADMIN_PAT' } -Action {
# references the value only by env-var NAME; the broker holds the plaintext, not this block
curl -s -H "PRIVATE-TOKEN: $env:GL_TOKEN" https://gitlab.com/api/v4/user
}
if ($r.ExitCode -ne 0) { throw "GitLab call failed" }
$r.Output # already redacted — safe to surfacePlaywright carve-out — GitLab SaaS token creation (in scope, Tier 2)
GitLab.com SaaS blocks PAT creation via API for some flows — it is UI-only — so a real login credential must reach a live browser. This is the one confirmed case where a plaintext value must leave the broker’s process; even so it must never re-enter any tool output Claude reads.
Broker wrapper location: scripts/secret-broker/Invoke-PlaywrightWithSecret.ps1 (new
scripts/secret-broker/ directory). It is a specialisation of Invoke-WithSopsSecret:
- Value delivery — decrypts the login secret in-process and sets it only as an env var in
the child Node process running Playwright (never a CLI arg — avoids
ps/Task Manager exposure). The automation JS referencesprocess.env.GITLAB_PASSby name only and never runs a command that decrypts/prints the value. - Script lint before execution (regex v1) — the wrapper refuses to run a Playwright JS where the
env-var name appears anywhere except as the argument to a
.fill()on a password locator. v1 algorithm (regex is fine; it is belt-and-braces with the output-redaction net below):total= count ofprocess.env.GITLAB_PASSoccurrences in the script.allowed= count matching\.fill\(\s*process\.env\.GITLAB_PASS\s*\)where the receiving locator resolves to a password field (input[type="password"],getByLabel(/password/i), or an explicitlytype=passwordselector on the same statement).- Reject unless
total === allowed && allowed >= 1. This catches accidentalconsole.log, assertions,page.evaluate, template-literal interpolation, or assignment to another variable. - Document plainly that v1 lint is heuristic; the redaction net (step 4) is the guarantee.
- Capture surfaces — no trace/video/screenshot may span the login step. The wrapper (not the JS)
controls the Playwright context: tracing/video start only after the post-login redirect. A
masked
type=passwordfield does not leak via a DOM snapshot, but a mid-.fillscreenshot on some sites still can, so the login step is never captured at all. - Output filtering — the wrapper holds the plaintext (it decrypted it), so it — and only it —
greps the full Playwright stdout/stderr and any trace/artifact file for the literal value
(raw + URL-encoded) and redacts before Claude sees anything. It returns only pass/fail + a
non-secret artifact (the created token’s public ID/name — never the token value; the token value
itself flows into
sops-set.ps1via$env:NEW_VALUE, never to chat).
Invocation: a new task-playbook docs/playbooks/gitlab-token-playwright-broker.md (not a slash
command for v1 — keep it a documented flow invoked by the owning role). Register
rotation_type='playwright' in dev_r_services, consistent with the existing
playwright-rotation-template.md convention. The JS itself lives at scripts/rotate/gitlab-admin-pat.js
and follows the existing scripts/rotate/*.js contract (<SERVICE>_PASS env in, TOKEN_OUT_FILE out,
never log the value). Ownership: authoring the JS/wrapper/playbook is dev-coder work; running a
GitLab PAT rotation is a secret-manager (SOPS write) + sys-admin/infra-task operation per the
role matrix and docs/w3-w4-stack-operations.md — the ADR designs it; it does not execute it.
Generalisation, not a framework: the wrapper is parameterised by (SOPS file, key, env-var name, JS path, password-locator matcher) so a second UI-only dashboard later reuses it — but no generic multi-service abstraction is built speculatively. GitLab SaaS is the only confirmed case today.
Tier 3 — deferred (explicit call)
Out of scope for #5298. Split to a follow-up issue. Tier 3 is a fixed, human-reviewed op
catalogue (scripts/secret-ops/*.ps1, e.g. rotate-ssh-password.ps1) for the highest-blast-radius
keys (root passwords, admin PATs, MongoDB rs0 admin) where Claude supplies only non-secret
parameters and gets back a structured pass/fail, touching no value-handling code at all. Rationale
for deferral: it is a materially bigger lift, it is only justified for a handful of keys, and Tier 2
plus the existing sops-set.ps1 write path already close the incident class that motivated #5298.
A follow-up issue should scope the initial op set.
Enforcement — can hooks require the broker? (honest answer)
Partially. Do not present this as solved.
- Writes: already fully enforced.
pre-bash-safety.shblocks anysops --encryptoutsidesops-set.ps1. Nothing to add. - Reads/use: not fully hook-enforceable without breaking the blessed pattern. The documented
safe extraction —
VAR=$(sops -d … | grep '^KEY=' | cut -d= -f2-); cmd "$VAR"; unset VAR— is relied on across ~60 rotation playbooks,CLAUDE.md(global + project), andworker-issue.mdStep 0 itself. A hook sees only one command at a time and cannot tell “extract into$VARthen use safely” from “extract into$VARto pass to the broker” — both aresops -d | grep. Forcing the broker via a hook would mean blocking the safe pattern that every playbook currently prescribes, which is not viable in one step. - What is incrementally possible (candidate, not required for #5298 to close): a warn-not-block
advisory when a
sops -d … | grepextraction is not on a line that also invokesInvoke-WithSopsSecret/sops-invoke.ps1. This is brittle across multi-line command sequences and would generate false positives on the legitimate pattern, so it is proposed as a nudge, not a gate. - Where enforcement actually already bites: role capability scoping (#4706,
ROLE_CAP_ENFORCE=1) withholds the age key entirely from roles that must not decrypt (dev-coder,dev-reviewer, …). For those roles the broker is moot — they cannot decrypt at all. For the one role that can (secret-manager, plus the read-only verification grant ofsys-admin/sys-security), the broker is a discipline + documented default, not a cage.
Verdict: Tier 2 is required-by-convention and documented, fully enforced on the write side, and not fully hook-enforceable on the read side. The plan should ship the broker + migrate the playbooks to prescribe it, and record read-side hook enforcement as a known, explicitly-tracked gap (candidate follow-up), rather than claim it is closed.
Known gap — PowerShell-only vs bash/Linux workers
sops-common.psm1 / sops-set.ps1 are PowerShell, used on the Windows dev machine. Every
historical incident (#2040/#3545/#3714/#5223) was an interactive PowerShell dev-session — so a
PowerShell-first broker covers the highest-risk surface. But secret use also happens on Linux
workers in bash (worker-issue.md Step 0’s VAR=$(sops -d … )). A PowerShell-only broker does not
cover that path.
Recommendation: v1 = PowerShell Invoke-WithSopsSecret (matches the issue’s cmdlet framing and
the incident surface). Track a bash sibling scripts/lib/sops-common.sh :: sops_invoke_with_secret
as a follow-up so the Linux worker path gets the same broker. Flagging this openly rather than
shipping a broker that silently only covers half the surface.
Migration list — playbooks that document hand-rolled extraction
These prescribe (or contain) a hand-rolled sops -d | grep/Select-String/cut extraction and
should be updated to point at the broker as the preferred path for “use a secret in a command”
(keeping the raw pattern only as a documented fallback where the broker is not yet available, e.g.
bash workers until the sibling lands):
| File | What to change |
|---|---|
docs/playbooks/secret-manager.md | §Windows SOPS operations — add broker as the read/use path |
docs/playbooks/static-api-key-incident-rotation.md | reference Invoke-WithSopsSecret + Test-SopsSecretRedaction |
.claude/task-playbooks/credential-rotation.md | point “use the value” steps at the broker |
.claude/task-playbooks/credential-design.md | Windows write pattern already → sops-set.ps1; add broker for use |
docs/playbooks/playwright-rotation-template.md | steps 7–8 → sops-set.ps1 (write); the GitLab carve-out → the new broker wrapper |
docs/playbooks/gitlab-token-rotation.md | replace grep '^GITLAB_ADMIN_PAT=' …_plaintext | cut + curl -H "PRIVATE-TOKEN: $GL_TOKEN" with a broker call |
docs/playbooks/secrets-design-standard.md | add the broker to the decision tree for “code that uses a secret” |
CLAUDE.md (global + project) safe-extraction table | note the broker as preferred for “use a secret in a command”; keep raw pattern as fallback |
infra/agent-prompts/worker-issue.md Step 0 safe-extraction block | note the broker for PowerShell sessions; bash keeps the raw pattern until the bash sibling lands |
~60 docs/playbooks/*-rotation.md (ionos, cloudflare, vercel, mailgun, …) | bulk/low-priority: the curl -H "…: $TOKEN" after a grep-extract pattern → broker, migrated opportunistically, not in one PR |
The bulk *-rotation.md sweep is deliberately not a single migration — it is an opportunistic
backlog, migrated as each rotation is next touched, to avoid a 60-file blast radius.
Consequences
Positive
- One tested, canary-guarded path for “use a decrypted secret”; the value never enters Claude-authored logic’s process as a return/printable value.
- Output-redaction net catches a wrapped command that over-echoes — the exact failure mode of 5223.
- Reuses the existing module, test harness, and redaction discipline; low new surface.
- The Playwright carve-out is contained: one confirmed case, generalised by parameters, not a framework.
Negative / risks
- Read-side use is not fully hook-enforceable (see §Enforcement) — adoption is convention-led.
- PowerShell-first leaves the bash/Linux worker path uncovered until the sibling lands (tracked).
- v1 lint is heuristic; the redaction net is the actual guarantee.
- Migration is broad; staged to contain blast radius.
Follow-up issues to open
- Tier 3 op catalogue (
scripts/secret-ops/*.ps1) for highest-blast-radius keys. - Bash broker sibling (
scripts/lib/sops-common.sh :: sops_invoke_with_secret) for Linux workers. - (Candidate) warn-not-block read-side hook advisory + the bulk
*-rotation.mdmigration sweep.
Implementation scope for #5298 (once this ADR is accepted)
In scope: Invoke-WithSopsSecret + Test-SopsSecretRedaction in sops-common.psm1 (+ Pester tests);
scripts/sops-invoke.ps1 CLI; scripts/secret-broker/Invoke-PlaywrightWithSecret.ps1 +
scripts/rotate/gitlab-admin-pat.js; docs/playbooks/gitlab-token-playwright-broker.md; migrate the
non-bulk playbooks in the table above. Out of scope: Tier 3, bash sibling, bulk rotation sweep,
Tier 1 hooks.