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:

IncidentRoot causeFix landed
#2040sops -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 echoedGet-SopsKeyFingerprint/Test-SopsKeyExists + hook
#3714Same class via Select-String … -ne $nullhook extended
#5223Ad-hoc redaction regex anchored on mongodb+srv://, missed plain mongodb:// → full V32_DB_URI printedProtect-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.sops with no safe sink (pipe to grep/Out-Null, redirect, exec-env, or --output <file> before the positional) → blocked (#2040).
  • Hand-rolled Where-Object/Select-String … -ne $null presence checks → blocked (#3545/#3714).
  • sops --encrypt outside sops-set.ps1blocked (#3545 follow-up).
  • sops updatekeys on a dotenv *.env.sopsblocked (#4601).
  • Read/cat/Grep -o/Grep output_mode=content on 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-WithSopsSecret decrypts 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 surface

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

  1. 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 references process.env.GITLAB_PASS by name only and never runs a command that decrypts/prints the value.
  2. 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 of process.env.GITLAB_PASS occurrences 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 explicitly type=password selector on the same statement).
    • Reject unless total === allowed && allowed >= 1. This catches accidental console.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.
  3. 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=password field does not leak via a DOM snapshot, but a mid-.fill screenshot on some sites still can, so the login step is never captured at all.
  4. 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.ps1 via $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.sh blocks any sops --encrypt outside sops-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), and worker-issue.md Step 0 itself. A hook sees only one command at a time and cannot tell “extract into $VAR then use safely” from “extract into $VAR to pass to the broker” — both are sops -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 … | grep extraction is not on a line that also invokes Invoke-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 of sys-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):

FileWhat to change
docs/playbooks/secret-manager.md§Windows SOPS operations — add broker as the read/use path
docs/playbooks/static-api-key-incident-rotation.mdreference Invoke-WithSopsSecret + Test-SopsSecretRedaction
.claude/task-playbooks/credential-rotation.mdpoint “use the value” steps at the broker
.claude/task-playbooks/credential-design.mdWindows write pattern already → sops-set.ps1; add broker for use
docs/playbooks/playwright-rotation-template.mdsteps 7–8 → sops-set.ps1 (write); the GitLab carve-out → the new broker wrapper
docs/playbooks/gitlab-token-rotation.mdreplace grep '^GITLAB_ADMIN_PAT=' …_plaintext | cut + curl -H "PRIVATE-TOKEN: $GL_TOKEN" with a broker call
docs/playbooks/secrets-design-standard.mdadd the broker to the decision tree for “code that uses a secret”
CLAUDE.md (global + project) safe-extraction tablenote 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 blocknote 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

  1. Tier 3 op catalogue (scripts/secret-ops/*.ps1) for highest-blast-radius keys.
  2. Bash broker sibling (scripts/lib/sops-common.sh :: sops_invoke_with_secret) for Linux workers.
  3. (Candidate) warn-not-block read-side hook advisory + the bulk *-rotation.md migration 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.