--- id: WARDEN-WP-0026 type: workplan title: "Credential disclosure hygiene + rotation guidance (Strand A)" domain: infotech repo: ops-warden status: finished owner: codex topic_slug: custodian planning_priority: high planning_order: 26 created: "2026-07-16" updated: "2026-07-16" state_hub_workstream_id: "331c7620-bd34-5acd-9135-591985b568e5" --- # 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 `. 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 ` 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 ```task id: WARDEN-WP-0026-T01 status: done priority: high state_hub_task_id: "1cb22a40-b7c6-560a-a805-7766a5786dcc" ``` 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 ` (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) ```task id: WARDEN-WP-0026-T02 status: done priority: high state_hub_task_id: "bcb7da96-0a28-5484-bf3e-06e97acf5873" ``` 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 unwrap`s 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 ` (full table) is the anti-pattern; the sanctioned path is `warden access … --field ` 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) ```task id: WARDEN-WP-0026-T03 status: done priority: medium state_hub_task_id: "d90b0628-fa1d-527d-99c3-28a7ed933e52" ``` 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 ```task id: WARDEN-WP-0026-T04 status: done priority: high state_hub_task_id: "827fa67d-5f69-5fac-bdce-9903b1b909fb" ``` 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 ```task id: WARDEN-WP-0026-T05 status: done priority: medium state_hub_task_id: "09ef8727-31da-59ac-aac7-2d47924569fe" ``` 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 ` (+ `--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=` 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 ```task id: WARDEN-WP-0026-T06 status: done priority: high state_hub_task_id: "a2e1544e-e501-57eb-a40e-9a2147cef12a" ``` 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 ` (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 ` (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 ` 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) ```task id: WARDEN-WP-0026-T07 status: done priority: medium state_hub_task_id: "62d8286f-7954-52a8-bce6-6a16072e5246" ``` 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 `draft`→`active`, `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.