ops-warden/workplans/WARDEN-WP-0026-credential-disclosure-hygiene.md
tegwick b971403dad
All checks were successful
CI Smoke / host-smoke (push) Successful in 0s
CI Smoke / container-smoke (push) Successful in 1s
WARDEN-WP-0026 finish Strand A (T04/T05/T07)
Promote railiance-backup-offsite-lane to active/resolvable after
capabilities-safe re-verify. Add catalog risk=high, agent read-boundary
(exit 7 + OpenBao policy companion), EXPOSED taint via warden taint, and
close WP-0026.
2026-07-16 23:26:26 +02:00

12 KiB

id type title domain repo status owner topic_slug planning_priority planning_order created updated state_hub_workstream_id
WARDEN-WP-0026 workplan Credential disclosure hygiene + rotation guidance (Strand A) infotech ops-warden finished codex custodian high 26 2026-07-16 2026-07-16 1adb34af-d5f8-42c5-89b4-44593995a281

Credential disclosure hygiene + rotation guidance (Strand A)

Origin

Follow-up to a credential-disclosure incident on 2026-07-16 while verifying CCR-2026-0004 (railiance offsite backup lane). A negative policy test was run as BAO_TOKEN=$(bao token create -policy=default -field=token) bao kv get <path>. The token create was denied (workload role lacks it), BAO_TOKEN fell back to the caller's privileged login token, and bao kv get printed all three field values (NC_WEBDAV_TOKEN, NC_WEBDAV_URL, AGE_PRIVATE_KEY) into an agent session transcript. Buildup mode — exposure accepted, learnings captured.

Root causes: (1) a deny-test that read the secret data path at all; (2) a silent privileged-token fallback; (3) the read landed in a logged context.

This is Strand A (disclosure hygiene). Tamper-resistant policy governance and one-command mass rotation/lockdown (Strand B) are deliberately deferred — see "Out of scope" below.

Goal

Make accidental secret disclosure structurally hard, and make ops-warden the authoritative source for how each secret is rotated or re-established — advisory knowledge held next to the routing catalog, not in OpenBao.

Design guardrails (binding on acceptance)

  • Verification never reads secret data. Use bao token capabilities (allow/deny) instead of kv get for positive/negative lane tests.
  • Masking is defense-in-depth, not a boundary. Any display filter is a wrapper convenience; raw bao kv get <path> remains the documented anti-pattern.
  • Rotation guidance covers both rotate (provider re-mint) and re-establish (regenerate from source, e.g. a new age keypair).
  • Coverage gate: every active (and newly promoted) catalog lane must carry rotation guidance, enforced by a scorecard check.

Out of scope (Strand B — deferred, not built here)

  • Executable one-command rotation of all tainted secrets.
  • Graded lockdown / break-glass seal + re-key with a designed trust-root.
  • Policy time-travel / policy-as-code reconcile-to-past-commit. (Drift detection may be revisited separately; the reconcile machinery is out.)

Task: Capabilities-based lane verification

id: WARDEN-WP-0026-T01
status: done
priority: high
state_hub_task_id: "9329e72d-c07c-41ce-88ce-e8602eb72c43"

Done 2026-07-16: canonical capabilities-safe verification pattern added to wiki/playbooks/catalog-lane-promotion.md (fleet promotion checklist criterion 8

  • dedicated section), and applied to the railiance-backup-offsite-lane and forgejo-admin-api-token playbook verify sections. Positive/negative proven via bao token capabilities against the KV v2 data path — never bao kv get; the denied default token-create is documented as a pass, not a fallback trigger. Live CCR-2026-0004 re-verify carried under T07.

Replace secret-reading verify flows with capability checks. Positive test: approved identity has read on the KV data path. Negative test: a default-only identity is deny. Both via bao token capabilities <token> <path> (or the self endpoint) — never bao kv get. Update wiki/playbooks/*lane*.md verify sections and any lane-verify helper.

Done when: the documented and tooled verify path for any lane proves allow/deny without reading a secret value, and the CCR-2026-0004 re-verify uses it (see T07).

Task: Safe access transport (no stdout values)

id: WARDEN-WP-0026-T02
status: done
priority: high
state_hub_task_id: "3f28c573-8e58-4851-8aa0-925f9367f266"

Done 2026-07-16: sanctioned transports added to warden access so a value never lands on stdout — --out FILE (mode-0600 file), --exec (child env, pre-existing), and --wrap (single-use OpenBao response-wrapping token via bao kv get -wrap-ttl, caller bao unwraps in their own context). Raw --fetch to a non-TTY stdout is now refused (exit 6) unless --unsafe-stdout is passed (interactive human only). proxy_fetch_to_file/proxy_fetch_wrapped/build_wrapped_fetch in proxy.py; tests in tests/test_proxy.py. Anti-pattern + transports documented fleet-wide in .claude/rules/credential-routing.md and wiki/OperatorAccessAssist.md (G2).

warden access / fetch paths must emit values only into an env var, a file, or a response-wrapping token (bao … -wrap-ttl), never a stdout table. Add a wrapping-token transport for values that must move between processes. Record in canon (credential-routing rules) that raw bao kv get <path> (full table) is the anti-pattern; the sanctioned path is warden access … --field <F> into env.

Done when: the sanctioned fetch path cannot print a value to a terminal, and the anti-pattern is documented fleet-wide.

Task: Masking display filter (defense-in-depth)

id: WARDEN-WP-0026-T03
status: done
priority: medium
state_hub_task_id: "21ab08d5-7782-4567-a08d-980211dd7851"

Done 2026-07-16: warden/mask.py (fingerprint/mask_value — presence, length, 8-char sha256 prefix; never the value) + proxy_fetch_fingerprint and a warden access … --fingerprint masked status view (bypasses the stdout guard because it emits no value). Lets two parties compare sha256 prefixes to confirm a shared value (e.g. rotation landed) without disclosure. Explicitly labelled defense-in-depth — raw bao kv get bypasses it — in wiki/OperatorAccessAssist.md and the module docstring. Tests in tests/test_mask.py + a CLI test in tests/test_proxy.py.

In the warden wrapper, mask KV data values by default when any listing/status is shown — display presence, length, and a short non-reversible hash instead of the value. Explicitly labelled as defense-in-depth (raw bao bypasses it).

Done when: wrapper-mediated output never shows a raw KV value, with the limitation documented.

Task: Agent read-boundary on high-risk lanes

id: WARDEN-WP-0026-T04
status: done
priority: high
state_hub_task_id: "f95d4381-f995-4c9d-b438-70b4f0ed90c5"

Done 2026-07-16: Catalog risk: high|standard (default standard). High-risk: railiance-backup-offsite-lane, forgejo-admin-api-token, openrouter-llm-connect. OpenBao policy agent-high-risk-boundary (railiance-platform + live write) grants metadata/capabilities only and denies data-read on those paths — verified with minted agent token (data=deny, metadata=read). warden access with WARDEN_AGENT_ID set refuses raw value stream on high-risk lanes (exit 7); --out/--exec/--wrap/--fingerprint remain. Playbook: wiki/playbooks/agent-read-boundary.md. Tests in tests/test_routing.py + tests/test_proxy.py.

Repo: railiance-platform (OpenBao policy/roles). Agent identities receive capabilities/metadata and wrapping tokens on high-risk lanes, not raw data reads. Align with the existing credential-routing rule ("ops-warden proxies reads as the caller and must not retain values"). Classify which lanes are high-risk (recovery escrow like AGE_PRIVATE_KEY, upload tokens).

Done when: at least the high-risk lanes deny raw data reads to agent roles while still allowing wrapped/proxied access, verified via capabilities checks.

Task: EXPOSED taint convention

id: WARDEN-WP-0026-T05
status: done
priority: medium
state_hub_task_id: "e989f8f0-930e-4d2f-9037-221e04f34199"

Done 2026-07-16: Convention documented in wiki/playbooks/exposed-taint.md (exposed_at, exposed_version, exposed_reason, exposed_ref on KV v2 custom_metadata). First worked mark applied to platform/workloads/railiance/backup/offsite-lane version 2 (disclosure incident). warden taint <id> (+ --json) reports taint via metadata-only bao kv metadata get — never secret data (src/warden/taint.py). Tests in tests/test_taint.py.

Repo: railiance-platform (OpenBao) + ops-warden surface. Establish a KV v2 custom_metadata convention to mark a tainted secret: exposed_at=<datetime> and the affected version. Identify semi-automatic candidates from the OpenBao audit log (reads from agent/shared contexts). warden surfaces taint status for a lane (advisory; no auto-rotation here).

Done when: a secret can be marked EXPOSED via a documented convention and warden reports whether a lane is currently tainted.

Task: Rotation / re-establishment guidance registry

id: WARDEN-WP-0026-T06
status: done
priority: high
state_hub_task_id: "b8a43aee-18c7-46cd-9cfe-1f73234746d3"

Done 2026-07-16: rotation: block (method rotate|re-establish, ordered steps, owner, automatable) added to the routing model/parser (RotationGuide, RouteEntry.rotation, vends_secret), screened for secret material in a prose-safe mode. warden rotate-guide <id> (human + --json) surfaces the guidance; warden route show --json carries has_rotation + rotation. Coverage enforced by the new catalog_rotation_coverage scorecard check (every active secret-vending lane must have a block) and promotion checklist criterion 9. Rotation blocks authored for all 7 active vending lanes + the draft railiance-backup lane (re-establish example: age keypair regen + re-encrypt). Tests in tests/test_routing.py. Also fixed a pre-existing keyword collision (bare npm on the forgejo-admin lane → forgejo-npm) so "npm token" routes to the generic lane again.

Give every catalog lane structured-but-advisory renewal guidance, held in the ops-warden registry (not in OpenBao). Add a rotation: block per catalog entry capturing: method (rotate | re-establish), ordered steps (provider re-mint / keygen / OpenBao write / re-encrypt-and-reupload where relevant), owner, and automatable (bool, for future Strand-B). Surface via warden rotate-guide <id> (and/or warden route … --rotate). Add a scorecard coverage check: every active lane must have a rotation: block; flag any that don't.

Done when: warden rotate-guide <id> returns actionable renewal steps for every active lane, and the scorecard fails if any active lane lacks guidance.

Task: Incident lessons + first worked lane (CCR-2026-0004)

id: WARDEN-WP-0026-T07
status: done
priority: medium
state_hub_task_id: "9944f46d-3706-43a4-9300-7f63bf87c9ff"

Done 2026-07-16: Lessons note present. Capabilities-safe live re-verify on bao.coulomb.social: lane-policy token → read on data path; default + agent boundary → deny; field keys present (no values printed). Catalog promoted draftactive, fetch_command pinned to NC_WEBDAV_TOKEN (no placeholders) so resolvable: true; risk: high; rotation guidance + EXPOSED taint on v2. CCR-2026-0004 evidence + access_frontdoor.resolvable: true / readiness: ready. Playbook + wiki/CredentialRouting.md updated. Operator may still rotate the exposed values optionally (buildup) — not a promotion blocker.

Write a short lessons-learned note (buildup context; exposure accepted; the three root causes). Apply T01 + T06 to CCR-2026-0004 as the first worked lane: re-verify it the capabilities-safe way so it can finally promote to resolvable: true (unblocking RAILIANCE-WP-0015), and ensure its rotation: block (rotate Nextcloud token; re-establish age keypair + re-encrypt artifacts) is present. Rotation of the exposed values is the operator's optional call, not a blocker (buildup).

Done when: the lessons note exists, CCR-2026-0004 has capabilities-based verify + rotation guidance, and its promotion path is unblocked.

References

  • CCR-2026-0004-railiance-backup-offsite-lane.yaml (railiance-platform)
  • wiki/playbooks/railiance-backup-offsite-lane.md
  • .claude/rules/credential-routing.md (the-custodian, fleet-inlined)
  • RAILIANCE-WP-0015 (railiance-apps) — cnpg backup coverage, gated on CCR-2026-0004
  • Strand B (deferred): tamper-resistant governance, one-command rotation/lockdown, policy time-travel — capture separately if/when justified.