WARDEN-WP-0026 finish Strand A (T04/T05/T07)
All checks were successful
CI Smoke / host-smoke (push) Successful in 0s
CI Smoke / container-smoke (push) Successful in 1s

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.
This commit is contained in:
tegwick 2026-07-16 23:26:26 +02:00
parent 7d0c7c7684
commit b971403dad
16 changed files with 689 additions and 31 deletions

View file

@ -56,6 +56,20 @@ stdout unless you pass `--unsafe-stdout` (interactive human sessions only):
| **Env (exec)** | `warden access <need> --exec -- <cmd>` | value injected into the child process env only | | **Env (exec)** | `warden access <need> --exec -- <cmd>` | value injected into the child process env only |
| **Wrapping token** | `warden access <need> --wrap` | a single-use, short-TTL OpenBao wrapping token to `bao unwrap` in your own context | | **Wrapping token** | `warden access <need> --wrap` | a single-use, short-TTL OpenBao wrapping token to `bao unwrap` in your own context |
### Agent read-boundary on high-risk lanes (WP-0026 T04)
When `WARDEN_AGENT_ID` is set and the catalog lane is `risk: high`, raw value
streaming is refused (exit 7). Use `--out` / `--exec` / `--wrap` / `--fingerprint`
only. OpenBao policy `agent-high-risk-boundary` denies data-read on those paths
for agent tokens (metadata/capabilities only). See
`wiki/playbooks/agent-read-boundary.md`.
### EXPOSED taint (WP-0026 T05)
`warden taint <catalog-id>` reports KV v2 `custom_metadata` (`exposed_at`,
`exposed_version`, …) without reading secret data. Convention:
`wiki/playbooks/exposed-taint.md`.
### Other capabilities (reuse-surface) ### Other capabilities (reuse-surface)
Non-credential capabilities are usually discovered through **reuse-surface** federation Non-credential capabilities are usually discovered through **reuse-surface** federation

View file

@ -148,6 +148,15 @@ Requires the `warden` CLI from `~/ops-warden` (`uv tool install .` or `uv run wa
- `POST /messages/` to `ops-warden` asking for `ISSUE_CORE_API_KEY`, `OPENROUTER_API_KEY`, etc. - `POST /messages/` to `ops-warden` asking for `ISSUE_CORE_API_KEY`, `OPENROUTER_API_KEY`, etc.
- Inventing `warden secret`, `warden login`, `warden bao`, `warden tunnel` — they do not exist - Inventing `warden secret`, `warden login`, `warden bao`, `warden tunnel` — they do not exist
- Pasting secrets into Git, State Hub, workplans, logs, or chat - Pasting secrets into Git, State Hub, workplans, logs, or chat
- **Reading a secret value onto a captured stdout.** Prefer `bao token capabilities`
for verify, and `warden access … --out` / `--exec` / `--wrap` for use (WP-0026).
### Agent read-boundary + EXPOSED taint (WP-0026 T04/T05)
- High-risk lanes (`risk: high` in catalog): with `WARDEN_AGENT_ID` set, raw value
streaming is refused. Use sanctioned transports only.
- `warden taint <id>` reports EXPOSED metadata without reading secret data.
- Playbooks: `wiki/playbooks/agent-read-boundary.md`, `wiki/playbooks/exposed-taint.md`.
### Other capabilities (reuse-surface) ### Other capabilities (reuse-surface)

View file

@ -290,6 +290,8 @@ entries:
canon_ref: net-kingdom/docs/platform-identity-security-architecture.md canon_ref: net-kingdom/docs/platform-identity-security-architecture.md
reviewed: "2026-07-02" reviewed: "2026-07-02"
status: active status: active
# High-risk: provider API key with spend impact + prompt-adjacent (WP-0026 T04).
risk: high
# Concrete, owner-confirmed lane — railiance-platform CCR-2026-0003 / RAILIANCE-WP-0010 # Concrete, owner-confirmed lane — railiance-platform CCR-2026-0003 / RAILIANCE-WP-0010
# (promoted 2026-07-02): policy workload-kv-read-llm-connect-provider-secrets and k8s # (promoted 2026-07-02): policy workload-kv-read-llm-connect-provider-secrets and k8s
# auth role external-secrets-activity-core applied; ExternalSecret # auth role external-secrets-activity-core applied; ExternalSecret
@ -321,13 +323,20 @@ entries:
warden_executes: false warden_executes: false
wiki_ref: wiki/playbooks/railiance-backup-offsite-lane.md#worker-checklist wiki_ref: wiki/playbooks/railiance-backup-offsite-lane.md#worker-checklist
canon_ref: railiance-platform/docs/workload-kv-access-lanes.md canon_ref: railiance-platform/docs/workload-kv-access-lanes.md
reviewed: "2026-07-07" reviewed: "2026-07-16"
status: draft status: active
# High-risk: WebDAV upload token + AGE recovery escrow (WP-0026 T04).
risk: high
# CCR-2026-0004: policy + OIDC role applied; values provisioned 2026-07-07. # CCR-2026-0004: policy + OIDC role applied; values provisioned 2026-07-07.
# Promote to active after positive/negative caller verification. # Capabilities-safe re-verify 2026-07-16 (WP-0026 T07): lane-policy token
# capabilities=read on data path; default-policy and agent-high-risk-boundary = deny;
# field keys present (NC_WEBDAV_TOKEN, NC_WEBDAV_URL, AGE_PRIVATE_KEY) via metadata
# lengths only — no value read. Primary fetch field is NC_WEBDAV_TOKEN (AGE is
# recovery escrow; fetch only for restore drills with --field not required —
# use bao as caller or extend fetch). EXPOSED taint set on version 2 (T05).
auth_method: "caller's own OpenBao token (OIDC netkingdom role railiance-backup-workload-kv-read)" auth_method: "caller's own OpenBao token (OIDC netkingdom role railiance-backup-workload-kv-read)"
path_template: "platform/workloads/railiance/backup/offsite-lane" path_template: "platform/workloads/railiance/backup/offsite-lane"
fetch_command: "bao kv get -field=<FIELD> platform/workloads/railiance/backup/offsite-lane" fetch_command: "bao kv get -field=NC_WEBDAV_TOKEN platform/workloads/railiance/backup/offsite-lane"
exec_capable: true exec_capable: true
lane: secret lane: secret
# Mixed lane: NC_WEBDAV_TOKEN rotates (provider re-mint); AGE_PRIVATE_KEY # Mixed lane: NC_WEBDAV_TOKEN rotates (provider re-mint); AGE_PRIVATE_KEY
@ -344,6 +353,7 @@ entries:
- "Re-establish AGE_PRIVATE_KEY: generate a new keypair (`age-keygen`), decrypt existing offsite artifacts with the old key and re-encrypt to the new recipient, then `bao kv put ... AGE_PRIVATE_KEY=@file` (mode-0600 file, shred after)." - "Re-establish AGE_PRIVATE_KEY: generate a new keypair (`age-keygen`), decrypt existing offsite artifacts with the old key and re-encrypt to the new recipient, then `bao kv put ... AGE_PRIVATE_KEY=@file` (mode-0600 file, shred after)."
- "Because AGE_PRIVATE_KEY is recovery escrow, retain the old key offline until re-encryption of all retained backups is confirmed." - "Because AGE_PRIVATE_KEY is recovery escrow, retain the old key offline until re-encryption of all retained backups is confirmed."
- "Verify capabilities-safe on the data path (`bao token capabilities`); run a restore drill against a re-encrypted artifact." - "Verify capabilities-safe on the data path (`bao token capabilities`); run a restore drill against a re-encrypted artifact."
- "After rotation, clear EXPOSED taint: remove custom_metadata exposed_at/exposed_version (see `warden taint railiance-backup-offsite-lane`)."
- id: forgejo-admin-api-token - id: forgejo-admin-api-token
title: Forgejo operator/admin API token (PAT) title: Forgejo operator/admin API token (PAT)
@ -355,7 +365,8 @@ entries:
canon_ref: railiance-platform/docs/workload-kv-access-lanes.md canon_ref: railiance-platform/docs/workload-kv-access-lanes.md
reviewed: "2026-07-13" reviewed: "2026-07-13"
status: active status: active
resolvable: true # High-risk: site-admin PAT (WP-0026 T04).
risk: high
# CCR-2026-0006: approved by platform-operator 2026-07-12; policy # CCR-2026-0006: approved by platform-operator 2026-07-12; policy
# workload-kv-read-forgejo-admin + OIDC role forgejo-admin-workload-kv-read live on # workload-kv-read-forgejo-admin + OIDC role forgejo-admin-workload-kv-read live on
# bao.coulomb.social; PAT attended-minted and stored under field API_TOKEN at # bao.coulomb.social; PAT attended-minted and stored under field API_TOKEN at

View file

@ -2,6 +2,7 @@
from __future__ import annotations from __future__ import annotations
import json import json
import os
from datetime import datetime, timedelta, timezone from datetime import datetime, timedelta, timezone
from pathlib import Path from pathlib import Path
from typing import Annotated, List, Optional from typing import Annotated, List, Optional
@ -630,6 +631,9 @@ def _entry_summary(entry) -> dict:
"canon_ref": entry.canon_ref, "canon_ref": entry.canon_ref,
"reviewed": entry.reviewed, "reviewed": entry.reviewed,
"status": entry.status, "status": entry.status,
# Agent read-boundary (WP-0026 T04) — high-risk lanes deny raw agent data reads.
"risk": entry.risk,
"high_risk": entry.is_high_risk,
# Renewal guidance (WP-0026 T06) — advisory, no secret values. `has_rotation` # Renewal guidance (WP-0026 T06) — advisory, no secret values. `has_rotation`
# lets a caller gate before asking for the full block via `warden rotate-guide`. # lets a caller gate before asking for the full block via `warden rotate-guide`.
"has_rotation": entry.has_rotation, "has_rotation": entry.has_rotation,
@ -803,6 +807,63 @@ def route_show(
) )
@app.command("taint")
def taint_show(
entry_id: Annotated[str, typer.Argument(help="Catalog entry id (see `warden route list`)")],
output_json: Annotated[bool, typer.Option("--json", help="Output JSON")] = False,
) -> None:
"""Report whether a lane's OpenBao secret is marked EXPOSED (WP-0026 T05).
Reads KV v2 *metadata only* (custom_metadata: exposed_at, exposed_version, ).
Never reads secret data. Advisory does not rotate or clear taint.
"""
from warden.taint import TaintError, fetch_taint_status
catalog = _load_catalog()
entry = catalog.get(entry_id)
if entry is None:
# Drafts are findable by exact id via get even when not listed.
err.print(
f"[red]Unknown routing id {entry_id!r}.[/red] Try: warden route find {entry_id!r} --all"
)
raise typer.Exit(1)
try:
status = fetch_taint_status(entry)
except TaintError as e:
err.print(f"[red]taint status unavailable:[/red] {e}")
raise typer.Exit(2)
if output_json:
print(json.dumps(status.to_dict(), indent=2))
return
console.print(f"[bold]Taint status — {entry.title}[/bold] ([cyan]{entry.id}[/cyan])")
console.print(f" path : {status.path}")
if status.error:
console.print(f" [yellow]query error[/yellow] : {status.error}")
console.print(
" [dim]Need caller OpenBao auth with metadata-read on the path "
"(agent-high-risk-boundary allows metadata; workload-kv-read allows both).[/dim]"
)
raise typer.Exit(3)
if status.tainted:
console.print(" tainted : [red]yes (EXPOSED)[/red]")
console.print(f" exposed_at : {status.exposed_at}")
console.print(f" exposed_version : {status.exposed_version}")
console.print(f" exposed_reason : {status.exposed_reason}")
console.print(f" exposed_ref : {status.exposed_ref}")
console.print(f" current_version : {status.current_version}")
console.print(
"\n[yellow]Advisory:[/yellow] rotate/re-establish per "
f"`warden rotate-guide {entry.id}` then clear custom_metadata keys "
"(exposed_at, exposed_version, …). No auto-rotation (Strand B)."
)
else:
console.print(" tainted : [green]no[/green]")
console.print(f" current_version : {status.current_version}")
@app.command("rotate-guide") @app.command("rotate-guide")
def rotate_guide( def rotate_guide(
entry_id: Annotated[str, typer.Argument(help="Catalog entry id (see `warden route list`)")], entry_id: Annotated[str, typer.Argument(help="Catalog entry id (see `warden route list`)")],
@ -1059,11 +1120,30 @@ def _access_proxy(
err.print(f"[red]{e}[/red]") err.print(f"[red]{e}[/red]")
raise typer.Exit(2) raise typer.Exit(2)
# T04 — agent identity on a high-risk lane: never stream raw secret data.
# Agents may use sanctioned transports (--out / --exec / --wrap / --fingerprint).
agent_id = os.environ.get("WARDEN_AGENT_ID", "").strip()
raw_value_stream = (
not is_login and not do_exec and not wrap and not out_path and not fingerprint
)
if raw_value_stream and entry.is_high_risk and agent_id:
err.print(
f"[red]Agent read-boundary:[/red] {entry.id!r} is risk=high; "
f"agent identity {agent_id!r} must not stream raw secret data.\n"
"Use a sanctioned transport (value stays off the session transcript):\n"
" --out FILE write to a mode-0600 file\n"
" --exec -- CMD inject into a child process env only\n"
" --wrap single-use OpenBao wrapping token (unwrap out-of-band)\n"
" --fingerprint masked presence/length/hash only\n"
"OpenBao policy `agent-high-risk-boundary` also denies data-read for agents."
)
raise typer.Exit(7)
# T02 — the sanctioned fetch transports (file / env / wrapping token) never put a # T02 — the sanctioned fetch transports (file / env / wrapping token) never put a
# secret value on stdout. Streaming a value to stdout is the documented anti-pattern: # secret value on stdout. Streaming a value to stdout is the documented anti-pattern:
# allowed only to an interactive terminal, and only with an explicit acknowledgment # allowed only to an interactive terminal, and only with an explicit acknowledgment
# when stdout is captured/piped (the logged-context disclosure risk). # when stdout is captured/piped (the logged-context disclosure risk).
if not is_login and not do_exec and not wrap and not out_path and not fingerprint: if raw_value_stream:
import sys as _sys import sys as _sys
if not _sys.stdout.isatty() and not unsafe_stdout: if not _sys.stdout.isatty() and not unsafe_stdout:

View file

@ -22,7 +22,7 @@ from typing import List, Optional
import yaml import yaml
from warden.routing.models import RotationGuide, RouteEntry from warden.routing.models import VALID_RISK, RotationGuide, RouteEntry
# Structured handoff string fields (WP-0014) — templates and pointers only. # Structured handoff string fields (WP-0014) — templates and pointers only.
# Every one is scanned for accidental secret material; see _assert_no_secret_material. # Every one is scanned for accidental secret material; see _assert_no_secret_material.
@ -303,6 +303,12 @@ def _parse_entry(raw: dict, index: int) -> RouteEntry:
f"entry {entry_id!r} has invalid lane {lane!r} (expected one of {_VALID_LANES})" f"entry {entry_id!r} has invalid lane {lane!r} (expected one of {_VALID_LANES})"
) )
risk = str(raw.get("risk", "standard")).strip() or "standard"
if risk not in VALID_RISK:
raise CatalogError(
f"entry {entry_id!r} has invalid risk {risk!r} (expected one of {VALID_RISK})"
)
return RouteEntry( return RouteEntry(
id=entry_id, id=entry_id,
title=str(raw["title"]), title=str(raw["title"]),
@ -326,6 +332,7 @@ def _parse_entry(raw: dict, index: int) -> RouteEntry:
exec_command=handoff["exec_command"], exec_command=handoff["exec_command"],
pointer_command=handoff["pointer_command"], pointer_command=handoff["pointer_command"],
rotation=_parse_rotation(entry_id, raw.get("rotation")), rotation=_parse_rotation(entry_id, raw.get("rotation")),
risk=risk,
) )

View file

@ -28,6 +28,13 @@ class RotationGuide:
automatable: bool = False automatable: bool = False
# Risk classes for agent read-boundary (WARDEN-WP-0026 T04).
# high — recovery escrow, upload tokens, admin PATs, high-spend provider keys.
# Agent identities must not hold raw data-read (metadata/capabilities only).
# standard — ordinary workload secrets (ESO-fed, non-escrow); normal least-privilege.
VALID_RISK = ("standard", "high")
@dataclass @dataclass
class RouteEntry: class RouteEntry:
id: str id: str
@ -69,11 +76,18 @@ class RouteEntry:
pointer_command: Optional[str] = None # e.g. "secrets-engine route <id> --json" pointer_command: Optional[str] = None # e.g. "secrets-engine route <id> --json"
# Rotation / re-establishment guidance (WP-0026 T06) — advisory, no secret values. # Rotation / re-establishment guidance (WP-0026 T06) — advisory, no secret values.
rotation: Optional[RotationGuide] = None rotation: Optional[RotationGuide] = None
# Agent read-boundary risk class (WP-0026 T04). high → agents use wrap/out/exec only.
risk: str = "standard" # "standard" | "high"
@property @property
def is_active(self) -> bool: def is_active(self) -> bool:
return self.status == "active" return self.status == "active"
@property
def is_high_risk(self) -> bool:
"""True when this lane is on the agent raw-read deny list (WP-0026 T04)."""
return self.risk == "high"
@property @property
def has_rotation(self) -> bool: def has_rotation(self) -> bool:
"""True when this lane carries renewal guidance (WP-0026 T06).""" """True when this lane carries renewal guidance (WP-0026 T06)."""

149
src/warden/taint.py Normal file
View file

@ -0,0 +1,149 @@
"""EXPOSED taint convention for OpenBao KV secrets (WARDEN-WP-0026 T05).
Convention (KV v2 ``custom_metadata`` on the secret, never on secret *data*):
* ``exposed_at`` ISO-8601 UTC datetime when disclosure was recognized
* ``exposed_version`` KV version that was (or may have been) disclosed
* ``exposed_reason`` short machine-safe reason slug (optional)
* ``exposed_ref`` pointer to a lessons note / CCR / incident doc (optional)
A lane is **tainted** when ``exposed_at`` is set and non-empty. Clearing taint
(after rotation) is an operator action: remove those keys from custom_metadata.
ops-warden only *reports* taint it never auto-rotates (Strand B / WP-0027).
This module only shells out to ``bao kv metadata get`` (or equivalent). It never
reads secret data values.
"""
from __future__ import annotations
import json
import os
import subprocess
from dataclasses import dataclass
from typing import Any, Optional
from warden.routing.models import RouteEntry
# Canonical custom_metadata keys (WP-0026 T05).
EXPOSED_AT = "exposed_at"
EXPOSED_VERSION = "exposed_version"
EXPOSED_REASON = "exposed_reason"
EXPOSED_REF = "exposed_ref"
_TAINT_KEYS = (EXPOSED_AT, EXPOSED_VERSION, EXPOSED_REASON, EXPOSED_REF)
@dataclass(frozen=True)
class TaintStatus:
"""Advisory taint view for a lane — no secret values."""
lane_id: str
path: str
tainted: bool
exposed_at: Optional[str] = None
exposed_version: Optional[str] = None
exposed_reason: Optional[str] = None
exposed_ref: Optional[str] = None
current_version: Optional[int] = None
error: Optional[str] = None
def to_dict(self) -> dict[str, Any]:
return {
"id": self.lane_id,
"path": self.path,
"tainted": self.tainted,
"exposed_at": self.exposed_at,
"exposed_version": self.exposed_version,
"exposed_reason": self.exposed_reason,
"exposed_ref": self.exposed_ref,
"current_version": self.current_version,
**({"error": self.error} if self.error else {}),
}
class TaintError(Exception):
"""Raised when taint status cannot be determined (auth, path, tool)."""
def kv_metadata_path(path_template: str) -> str:
"""Return the logical KV path suitable for ``bao kv metadata get``.
Catalog paths are logical (``platform/workloads/...``), not API data paths.
"""
return path_template.strip().strip("/")
def parse_custom_metadata(meta: dict[str, Any]) -> TaintStatus:
"""Build a TaintStatus from a ``bao kv metadata get -format=json`` data blob.
``meta`` is the ``data`` object (with ``custom_metadata``, ``current_version``).
"""
custom = meta.get("custom_metadata") or {}
if not isinstance(custom, dict):
custom = {}
exposed_at = (custom.get(EXPOSED_AT) or "").strip() or None
return TaintStatus(
lane_id="",
path="",
tainted=bool(exposed_at),
exposed_at=exposed_at,
exposed_version=(custom.get(EXPOSED_VERSION) or "").strip() or None,
exposed_reason=(custom.get(EXPOSED_REASON) or "").strip() or None,
exposed_ref=(custom.get(EXPOSED_REF) or "").strip() or None,
current_version=meta.get("current_version"),
)
def fetch_taint_status(entry: RouteEntry, *, bao_bin: str = "bao") -> TaintStatus:
"""Query OpenBao metadata for a catalog entry (never reads secret data).
Uses the caller's ``BAO_TOKEN`` / ``VAULT_TOKEN`` / ``~/.vault-token`` — same
G1 rule as the access proxy. Requires ``path_template`` on the entry.
"""
if not entry.path_template or "<" in entry.path_template:
raise TaintError(
f"{entry.id!r} has no concrete path_template — cannot query taint metadata."
)
path = kv_metadata_path(entry.path_template)
try:
proc = subprocess.run(
[bao_bin, "kv", "metadata", "get", "-format=json", path],
capture_output=True,
text=True,
env=os.environ.copy(),
check=False,
)
except FileNotFoundError as e:
raise TaintError(f"{bao_bin!r} not found on PATH") from e
if proc.returncode != 0:
err = (proc.stderr or proc.stdout or "metadata get failed").strip().splitlines()
# Never echo tokens if somehow present.
safe = " ".join(err[:3])[:300]
return TaintStatus(
lane_id=entry.id,
path=path,
tainted=False,
error=safe or f"bao exit {proc.returncode}",
)
try:
payload = json.loads(proc.stdout)
except json.JSONDecodeError as e:
raise TaintError(f"invalid JSON from bao metadata get: {e}") from e
data = payload.get("data") if isinstance(payload, dict) else None
if not isinstance(data, dict):
raise TaintError("bao metadata response missing data object")
status = parse_custom_metadata(data)
return TaintStatus(
lane_id=entry.id,
path=path,
tainted=status.tainted,
exposed_at=status.exposed_at,
exposed_version=status.exposed_version,
exposed_reason=status.exposed_reason,
exposed_ref=status.exposed_ref,
current_version=status.current_version,
)

View file

@ -383,3 +383,39 @@ def test_access_fingerprint_masks_and_bypasses_stdout_guard(monkeypatch, tmp_pat
assert r.exit_code == 0 assert r.exit_code == 0
assert "top-secret-token-value" not in r.output # value never shown assert "top-secret-token-value" not in r.output # value never shown
assert "hidden" in r.output and "sha256:" in r.output assert "hidden" in r.output and "sha256:" in r.output
def test_access_agent_high_risk_raw_stream_refused(tmp_path, monkeypatch):
"""WP-0026 T04: WARDEN_AGENT_ID + risk=high refuses raw value stream (exit 7)."""
_proxy_env(monkeypatch, tmp_path)
monkeypatch.setenv("VAULT_TOKEN", "caller-token")
monkeypatch.setenv("WARDEN_AGENT_ID", "grok")
# Prefer high-risk lane; use --unsafe-stdout so T02 would allow if T04 failed.
r = runner.invoke(
app,
[
"access", "railiance-backup-offsite-lane",
"--fetch", "--no-policy", "--unsafe-stdout",
],
)
assert r.exit_code == 7, r.output
assert "agent read-boundary" in r.output.lower() or "risk=high" in r.output.lower()
def test_access_agent_high_risk_fingerprint_allowed(tmp_path, monkeypatch):
"""Agents may use --fingerprint on high-risk lanes (no raw value)."""
_proxy_env(monkeypatch, tmp_path)
monkeypatch.setenv("VAULT_TOKEN", "caller-token")
monkeypatch.setenv("WARDEN_AGENT_ID", "grok")
class _Fake:
returncode = 0
stdout = "should-not-appear"
monkeypatch.setattr("warden.proxy.subprocess.run", lambda *a, **k: _Fake())
r = runner.invoke(
app,
["access", "railiance-backup-offsite-lane", "--fingerprint", "--no-policy"],
)
assert r.exit_code == 0, r.output
assert "should-not-appear" not in r.output

View file

@ -531,3 +531,47 @@ def test_rotate_guide_cli_ssh_lane_is_graceful():
# SSH renewal is re-issuance, not a static rotation — exit 0, not an error. # SSH renewal is re-issuance, not a static rotation — exit 0, not an error.
result = runner.invoke(app, ["rotate-guide", "ssh-cert-host-access"]) result = runner.invoke(app, ["rotate-guide", "ssh-cert-host-access"])
assert result.exit_code == 0 assert result.exit_code == 0
# ---------------------------------------------------------------------------
# Agent read-boundary + risk class (WARDEN-WP-0026 T04)
# ---------------------------------------------------------------------------
def test_high_risk_lanes_classified():
catalog = load_catalog(_repo_catalog())
high = {e.id for e in catalog.entries if e.is_high_risk}
assert "railiance-backup-offsite-lane" in high
assert "forgejo-admin-api-token" in high
assert "openrouter-llm-connect" in high
# Ordinary workload secrets stay standard unless reclassified.
assert catalog.get("issue-core-ingestion-api-key").is_high_risk is False
def test_invalid_risk_rejected(tmp_path):
bad = dict(ROUTED_ENTRY, risk="critical")
with pytest.raises(CatalogError, match="risk"):
load_catalog(_write_catalog(tmp_path, [SSH_ENTRY, bad]))
def test_backup_lane_promoted_and_resolvable():
"""WP-0026 T07 — CCR-2026-0004 lane is active, resolvable, high-risk, has rotation."""
catalog = load_catalog(_repo_catalog())
e = catalog.get("railiance-backup-offsite-lane")
assert e is not None
assert e.status == "active"
assert e.resolvable is True
assert e.is_high_risk is True
assert e.has_rotation is True
assert e.rotation.method == "re-establish"
assert "NC_WEBDAV_TOKEN" in (e.fetch_command or "")
assert "<" not in (e.fetch_command or "")
def test_route_show_json_includes_risk():
result = runner.invoke(app, ["route", "show", "railiance-backup-offsite-lane", "--json"])
assert result.exit_code == 0
payload = json.loads(result.stdout)
assert payload["risk"] == "high"
assert payload["high_risk"] is True
assert payload["resolvable"] is True
assert payload["status"] == "active"

70
tests/test_taint.py Normal file
View file

@ -0,0 +1,70 @@
"""Tests for EXPOSED taint convention (WARDEN-WP-0026 T05)."""
from __future__ import annotations
import json
from pathlib import Path
import pytest
from typer.testing import CliRunner
from warden.cli import app
from warden.taint import (
EXPOSED_AT,
TaintStatus,
parse_custom_metadata,
kv_metadata_path,
)
runner = CliRunner()
def test_parse_custom_metadata_tainted():
status = parse_custom_metadata({
"custom_metadata": {
EXPOSED_AT: "2026-07-16T00:00:00Z",
"exposed_version": "2",
"exposed_reason": "test",
"exposed_ref": "history/x.md",
},
"current_version": 2,
})
assert status.tainted is True
assert status.exposed_at == "2026-07-16T00:00:00Z"
assert status.exposed_version == "2"
assert status.current_version == 2
def test_parse_custom_metadata_clean():
status = parse_custom_metadata({"custom_metadata": None, "current_version": 1})
assert status.tainted is False
assert status.exposed_at is None
def test_parse_empty_exposed_at_not_tainted():
status = parse_custom_metadata({"custom_metadata": {EXPOSED_AT: " "}, "current_version": 1})
assert status.tainted is False
def test_kv_metadata_path_strips():
assert kv_metadata_path(" platform/workloads/x ") == "platform/workloads/x"
def test_taint_status_to_dict():
s = TaintStatus(
lane_id="x", path="p", tainted=True,
exposed_at="t", exposed_version="1", current_version=1,
)
d = s.to_dict()
assert d["tainted"] is True
assert d["id"] == "x"
def test_taint_cli_unknown_id():
result = runner.invoke(app, ["taint", "no-such-lane-xyz"])
assert result.exit_code == 1
def test_taint_cli_template_lane_errors():
"""openbao-api-key has <placeholders> — cannot query taint."""
result = runner.invoke(app, ["taint", "openbao-api-key", "--json"])
assert result.exit_code == 2

View file

@ -95,10 +95,14 @@ run the owner's tool as the caller and preserve owner custody.
| `activity-core-issue-sink` | "activity-core + issue-core own emission — pair `ISSUE_CORE_*` env vars" | See `wiki/playbooks/activity-core-issue-sink.md` | | `activity-core-issue-sink` | "activity-core + issue-core own emission — pair `ISSUE_CORE_*` env vars" | See `wiki/playbooks/activity-core-issue-sink.md` |
| `inter-hub-bootstrap-ssh` | "Inter-Hub bootstrap SSH envelope — attended vs unattended branches" | See `wiki/InterHubBootstrapAccessLane.md` | | `inter-hub-bootstrap-ssh` | "Inter-Hub bootstrap SSH envelope — attended vs unattended branches" | See `wiki/InterHubBootstrapAccessLane.md` |
| `issue-core-ingestion-api-key` | "railiance-platform OpenBao KV + ESO deliver `ISSUE_CORE_API_KEY` — here is the path" | ESO consumes in-cluster; `warden access issue-core-ingestion-api-key --fetch ISSUE_CORE_API_KEY` as yourself | | `issue-core-ingestion-api-key` | "railiance-platform OpenBao KV + ESO deliver `ISSUE_CORE_API_KEY` — here is the path" | ESO consumes in-cluster; `warden access issue-core-ingestion-api-key --fetch ISSUE_CORE_API_KEY` as yourself |
| `openrouter-llm-connect` | "railiance-platform OpenBao KV + ESO deliver `OPENROUTER_API_KEY` to activity-core" | ESO consumes in-cluster; `warden access openrouter-llm-connect --fetch OPENROUTER_API_KEY` as yourself | | `openrouter-llm-connect` | "railiance-platform OpenBao KV + ESO deliver `OPENROUTER_API_KEY` to activity-core" | ESO consumes in-cluster; `warden access openrouter-llm-connect --fetch OPENROUTER_API_KEY` as yourself (`risk: high`) |
| `reuse-surface-hub-write-token` | "railiance-platform OpenBao KV + ESO deliver `REUSE_SURFACE_TOKEN` to reuse-surface" | ESO consumes in-cluster; `warden access reuse-surface-hub-write-token --fetch` as yourself | | `reuse-surface-hub-write-token` | "railiance-platform OpenBao KV + ESO deliver `REUSE_SURFACE_TOKEN` to reuse-surface" | ESO consumes in-cluster; `warden access reuse-surface-hub-write-token --fetch` as yourself |
| `railiance-backup-offsite-lane` | "railiance-platform OpenBao KV for Nextcloud WebDAV + age recovery" | `warden access railiance-backup-offsite-lane --out FILE` (`risk: high`; agents: no raw stream) |
| `forgejo-admin-api-token` | "railiance-platform OpenBao KV for Forgejo admin PAT" | `warden access forgejo-admin-api-token --out FILE` (`risk: high`) |
Promotion criteria: `wiki/playbooks/catalog-lane-promotion.md`. Promotion criteria: `wiki/playbooks/catalog-lane-promotion.md`.
High-risk / agent boundary: `wiki/playbooks/agent-read-boundary.md`.
EXPOSED taint: `wiki/playbooks/exposed-taint.md` · `warden taint <id>`.
**Draft** (hidden from default lookup until owner path ships — `warden route list --all`): **Draft** (hidden from default lookup until owner path ships — `warden route list --all`):

View file

@ -0,0 +1,77 @@
# Agent read-boundary on high-risk lanes
Date: 2026-07-16
Workplan: WARDEN-WP-0026 T04
OpenBao policy: `railiance-platform/openbao/policies/agent-high-risk-boundary.hcl`
Coding agents must not hold **raw data-read** on high-risk secrets. They may
inspect **capabilities** and **metadata**, and may receive values only through
sanctioned transports (file / exec env / response-wrapping token) under a human
operator identity.
---
## Risk classification (catalog `risk:`)
| Class | Criteria | Catalog default |
| --- | --- | --- |
| `high` | Recovery escrow (e.g. age private keys), upload tokens to external stores, site-admin PATs, high-spend provider keys | explicit `risk: high` |
| `standard` | Ordinary workload secrets (ESO-fed API keys without escrow/admin blast radius) | omitted / `standard` |
**Current high-risk lanes (ops-warden catalog):**
| Catalog id | Why high |
| --- | --- |
| `railiance-backup-offsite-lane` | Nextcloud upload + `AGE_PRIVATE_KEY` recovery escrow |
| `forgejo-admin-api-token` | Forgejo site-admin PAT |
| `openrouter-llm-connect` | Provider key (spend + prompt-adjacent) |
Keep this table in sync with `risk: high` rows in `registry/routing/catalog.yaml`
and path denials in `agent-high-risk-boundary.hcl`.
---
## OpenBao side
1. **Operator OIDC roles** keep `workload-kv-read-*` for the lane (data `read`).
2. **Agent identities** attach `agent-high-risk-boundary` (or equivalent) and
**must not** also attach the lane's `workload-kv-read-*` policy.
3. Verify with capabilities only (never `kv get` for deny tests):
```bash
# Agent-shaped token
AGENT=$(bao token create -policy=agent-high-risk-boundary -ttl=5m -field=token)
bao token capabilities "$AGENT" platform/data/workloads/railiance/backup/offsite-lane
# → deny
bao token capabilities "$AGENT" platform/metadata/workloads/railiance/backup/offsite-lane
# → read
bao token revoke "$AGENT"
```
Wrapped/proxied access for agents: a human operator (or credential broker with
response-wrap) fetches under an operator identity and delivers via
`warden access … --wrap` / `--out` / `--exec`. Agents do not unwrap into chat.
---
## ops-warden side
When `WARDEN_AGENT_ID` is set and the lane is `risk: high`, `warden access --fetch`
**refuses raw value streaming** (exit 7). Use:
```bash
export WARDEN_AGENT_ID=grok # or claude, codex
warden access railiance-backup-offsite-lane --no-policy --out /tmp/nc.token
warden access railiance-backup-offsite-lane --no-policy --wrap
warden access railiance-backup-offsite-lane --no-policy --fingerprint
```
`warden route show <id> --json` includes `"risk"` and `"high_risk"`.
---
## See also
- `.claude/rules/credential-routing.md` — safe transports
- `wiki/playbooks/exposed-taint.md` — EXPOSED metadata convention
- `history/2026-07-16-credential-disclosure-lessons.md`

View file

@ -87,13 +87,17 @@ out on the OpenBao-delivered value, positive + negative verification audit-logge
--- ---
## Draft lanes (2026-07-02) ## Draft lanes (2026-07-16)
| Catalog `id` | Blocker | | Catalog `id` | Blocker |
| --- | --- | | --- | --- |
| `object-storage-sts` | NK-WP-0007 vending path not production-exercised | | `object-storage-sts` | NK-WP-0007 vending path not production-exercised |
| `database-dynamic-credentials` | OpenBao database engine role paths TBD per workload | | `database-dynamic-credentials` | OpenBao database engine role paths TBD per workload |
**Promoted 2026-07-16:** `railiance-backup-offsite-lane` — CCR-2026-0004
capabilities-safe re-verify (WP-0026 T07); primary field `NC_WEBDAV_TOKEN`;
`risk: high` + EXPOSED taint on version 2 (operator may rotate optionally).
Re-run promotion when the owning repo closes the blocker; do not promote on Re-run promotion when the owning repo closes the blocker; do not promote on
playbook prose alone. playbook prose alone.

View file

@ -0,0 +1,71 @@
# EXPOSED taint convention (OpenBao KV v2)
Date: 2026-07-16
Workplan: WARDEN-WP-0026 T05
Mark a secret as **EXPOSED** when it may have landed in a logged or shared context
(agent transcript, chat, CI log). Taint is **advisory**: it does not revoke access
or rotate values. Strand B (`WARDEN-WP-0027`) may later drive rotation of tainted
lanes; Strand A only records and reports.
---
## Custom metadata keys
Set on the KV v2 secret **metadata** (never in secret data values):
| Key | Required | Meaning |
| --- | --- | --- |
| `exposed_at` | yes | ISO-8601 UTC when disclosure was recognized |
| `exposed_version` | recommended | KV version that was (or may have been) disclosed |
| `exposed_reason` | optional | short slug, e.g. `agent-session-kv-get-disclosure` |
| `exposed_ref` | optional | pointer to lessons note / CCR / incident doc |
A lane is **tainted** when `exposed_at` is present and non-empty.
---
## Mark EXPOSED
```bash
bao kv metadata put \
-custom-metadata=exposed_at=2026-07-16T00:00:00Z \
-custom-metadata=exposed_version=2 \
-custom-metadata=exposed_reason=agent-session-kv-get-disclosure \
-custom-metadata=exposed_ref=history/2026-07-16-credential-disclosure-lessons.md \
platform/workloads/railiance/backup/offsite-lane
```
## Report taint (no secret values)
```bash
warden taint railiance-backup-offsite-lane
warden taint railiance-backup-offsite-lane --json
# or:
bao kv metadata get platform/workloads/railiance/backup/offsite-lane
```
## Clear taint (after rotation)
After following `warden rotate-guide <id>` and verifying the new version:
```bash
# Rewrite metadata without the exposed_* keys (preserve any other custom_metadata).
bao kv metadata put platform/workloads/<lane-path>
# Or put only non-taint keys you still need.
warden taint <id> # expect tainted: no
```
---
## Semi-automatic candidates
Reads of high-risk paths from agent/shared identities in the OpenBao audit log
are candidates for marking EXPOSED. Marking remains an operator decision; do not
auto-taint from noisy audit alone without human review.
## See also
- `history/2026-07-16-credential-disclosure-lessons.md`
- `wiki/playbooks/catalog-lane-promotion.md` (capabilities-safe verify)
- OpenBao policy `agent-high-risk-boundary` (WP-0026 T04)

View file

@ -1,7 +1,7 @@
# Railiance Offsite Backup Lane # Railiance Offsite Backup Lane
Date: 2026-07-07 Date: 2026-07-16
Catalog: `railiance-backup-offsite-lane` (status `draft`, `resolvable: false` until verified) Catalog: `railiance-backup-offsite-lane` (status `active`, `resolvable: true`, `risk: high`)
Owner: `railiance-platform` (CCR-2026-0004) Owner: `railiance-platform` (CCR-2026-0004)
Nextcloud WebDAV upload token and URL for age-encrypted offsite backups (Option A). Nextcloud WebDAV upload token and URL for age-encrypted offsite backups (Option A).
@ -15,9 +15,11 @@ Used by `railiance-backup` (workstation) and `forgejo-backup` (platform).
| --- | --- | | --- | --- |
| Mount | `platform` | | Mount | `platform` |
| Path | `platform/workloads/railiance/backup/offsite-lane` | | Path | `platform/workloads/railiance/backup/offsite-lane` |
| Fields | `NC_WEBDAV_TOKEN`, `NC_WEBDAV_URL`, `AGE_PRIVATE_KEY` | | Fields | `NC_WEBDAV_TOKEN` (primary fetch), `NC_WEBDAV_URL`, `AGE_PRIVATE_KEY` (recovery escrow) |
| Policy | `workload-kv-read-railiance-backup-offsite-lane` | | Policy | `workload-kv-read-railiance-backup-offsite-lane` (operator OIDC) |
| Agent policy | `agent-high-risk-boundary`**deny** data-read; metadata only |
| OIDC role | `railiance-backup-workload-kv-read` (`groups=net-kingdom-admins`) | | OIDC role | `railiance-backup-workload-kv-read` (`groups=net-kingdom-admins`) |
| Risk | `high` (upload token + age recovery escrow) |
--- ---
@ -29,7 +31,16 @@ Used by `railiance-backup` (workstation) and `forgejo-backup` (platform).
bao login -method=oidc -path=netkingdom role=railiance-backup-workload-kv-read bao login -method=oidc -path=netkingdom role=railiance-backup-workload-kv-read
``` ```
2. **Export for a backup run** (value streams to your shell — never paste into chat): 2. **Export for a backup run** (sanctioned transports — never paste into chat):
```bash
# Preferred: file or exec injection via warden
warden access railiance-backup-offsite-lane --no-policy --out /tmp/nc.token
# or:
warden access railiance-backup-offsite-lane --no-policy --exec -- env | grep -v .
```
If you must use raw bao in an interactive human shell (not an agent session):
```bash ```bash
export RAILIANCE_BACKUP_NC_TOKEN=$( export RAILIANCE_BACKUP_NC_TOKEN=$(
@ -40,10 +51,11 @@ Used by `railiance-backup` (workstation) and `forgejo-backup` (platform).
) )
``` ```
3. **Or proxy via warden access** (after catalog promotion): 3. **Proxy via warden access** (catalog active + resolvable):
```bash ```bash
warden access railiance-backup-offsite-lane --no-policy --fetch --field NC_WEBDAV_TOKEN warden access railiance-backup-offsite-lane --no-policy --fetch --out /tmp/nc.token
# Primary field is NC_WEBDAV_TOKEN. AGE_PRIVATE_KEY is recovery escrow only.
``` ```
4. **Run backup**: 4. **Run backup**:
@ -57,6 +69,8 @@ Used by `railiance-backup` (workstation) and `forgejo-backup` (platform).
``` ```
`AGE_PRIVATE_KEY` in the same path is recovery escrow — fetch only for restore drills. `AGE_PRIVATE_KEY` in the same path is recovery escrow — fetch only for restore drills.
**Agents** (`WARDEN_AGENT_ID` set) cannot stream raw high-risk values; use `--out` /
`--exec` / `--wrap`. See `wiki/playbooks/agent-read-boundary.md`.
--- ---
@ -67,17 +81,51 @@ value to "confirm" it triggered the 2026-07-16 disclosure of `NC_WEBDAV_TOKEN` /
`NC_WEBDAV_URL` / `AGE_PRIVATE_KEY` (see `history/2026-07-16-credential-disclosure-lessons.md`). `NC_WEBDAV_URL` / `AGE_PRIVATE_KEY` (see `history/2026-07-16-credential-disclosure-lessons.md`).
```bash ```bash
# Positive: lane OIDC identity can read the data path # Positive: lane policy identity can read the data path
bao login -method=oidc -path=netkingdom role=railiance-backup-workload-kv-read LANE=$(bao token create -policy=workload-kv-read-railiance-backup-offsite-lane -ttl=2m -field=token)
bao token capabilities "$(bao print token)" platform/data/workloads/railiance/backup/offsite-lane bao token capabilities "$LANE" platform/data/workloads/railiance/backup/offsite-lane
# → expect: read # → expect: read
bao token revoke "$LANE"
# Negative: default-only identity is denied (no value is read) # Negative: default-only identity is denied (no value is read)
DEFAULT_TOKEN=$(bao token create -policy=default -field=token) # if this is denied, that IS the pass — do NOT fall back DEFAULT_TOKEN=$(bao token create -policy=default -field=token) # if this is denied, that IS the pass — do NOT fall back
bao token capabilities "$DEFAULT_TOKEN" platform/data/workloads/railiance/backup/offsite-lane bao token capabilities "$DEFAULT_TOKEN" platform/data/workloads/railiance/backup/offsite-lane
# → expect: deny # → expect: deny
bao token revoke "$DEFAULT_TOKEN"
# Agent boundary: data deny, metadata allow
AGENT=$(bao token create -policy=agent-high-risk-boundary -ttl=2m -field=token)
bao token capabilities "$AGENT" platform/data/workloads/railiance/backup/offsite-lane # deny
bao token capabilities "$AGENT" platform/metadata/workloads/railiance/backup/offsite-lane # read
bao token revoke "$AGENT"
``` ```
The capability lists contain no secret material — safe to record on The capability lists contain no secret material — safe to record on
`CCR-2026-0004` as promotion evidence. Full pattern: `CCR-2026-0004` as promotion evidence. Full pattern:
`wiki/playbooks/catalog-lane-promotion.md#capabilities-safe-lane-verification`. `wiki/playbooks/catalog-lane-promotion.md#capabilities-safe-lane-verification`.
### Evidence recorded 2026-07-16 (WP-0026 T07)
| Check | Result |
| --- | --- |
| Policy `workload-kv-read-railiance-backup-offsite-lane` present | pass |
| OIDC role `railiance-backup-workload-kv-read` bound to `net-kingdom-admins` + lane policy | pass |
| Lane-policy token capabilities on data path | `read` |
| Default-policy token capabilities on data path | `deny` |
| Agent-boundary token on data / metadata | `deny` / `read` |
| Field presence (keys only, lengths; no values) | `NC_WEBDAV_TOKEN`, `NC_WEBDAV_URL`, `AGE_PRIVATE_KEY` present |
| EXPOSED taint on version 2 | set (see `warden taint railiance-backup-offsite-lane`) |
| Rotation guidance | `warden rotate-guide railiance-backup-offsite-lane` |
---
## Taint / rotation
```bash
warden taint railiance-backup-offsite-lane
warden rotate-guide railiance-backup-offsite-lane
```
Rotation of exposed values is the **operator's optional call** (buildup mode);
promotion is not blocked on rotation. After rotation, clear `exposed_*`
custom_metadata keys (`wiki/playbooks/exposed-taint.md`).

View file

@ -4,7 +4,7 @@ type: workplan
title: "Credential disclosure hygiene + rotation guidance (Strand A)" title: "Credential disclosure hygiene + rotation guidance (Strand A)"
domain: infotech domain: infotech
repo: ops-warden repo: ops-warden
status: active status: finished
owner: codex owner: codex
topic_slug: custodian topic_slug: custodian
planning_priority: high planning_priority: high
@ -140,11 +140,21 @@ limitation documented.
```task ```task
id: WARDEN-WP-0026-T04 id: WARDEN-WP-0026-T04
status: todo status: done
priority: high priority: high
state_hub_task_id: "f95d4381-f995-4c9d-b438-70b4f0ed90c5" 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 **Repo: railiance-platform (OpenBao policy/roles).** Agent identities receive
`capabilities`/metadata and wrapping tokens on high-risk lanes, not raw data `capabilities`/metadata and wrapping tokens on high-risk lanes, not raw data
reads. Align with the existing credential-routing rule ("ops-warden proxies reads reads. Align with the existing credential-routing rule ("ops-warden proxies reads
@ -158,11 +168,19 @@ while still allowing wrapped/proxied access, verified via capabilities checks.
```task ```task
id: WARDEN-WP-0026-T05 id: WARDEN-WP-0026-T05
status: todo status: done
priority: medium priority: medium
state_hub_task_id: "e989f8f0-930e-4d2f-9037-221e04f34199" 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 **Repo: railiance-platform (OpenBao) + ops-warden surface.** Establish a KV v2
`custom_metadata` convention to mark a tainted secret: `exposed_at=<datetime>` `custom_metadata` convention to mark a tainted secret: `exposed_at=<datetime>`
and the affected `version`. Identify semi-automatic candidates from the OpenBao and the affected `version`. Identify semi-automatic candidates from the OpenBao
@ -208,17 +226,19 @@ every active lane, and the scorecard fails if any active lane lacks guidance.
```task ```task
id: WARDEN-WP-0026-T07 id: WARDEN-WP-0026-T07
status: progress status: done
priority: medium priority: medium
state_hub_task_id: "9944f46d-3706-43a4-9300-7f63bf87c9ff" state_hub_task_id: "9944f46d-3706-43a4-9300-7f63bf87c9ff"
``` ```
Lessons-learned note written 2026-07-16: Done 2026-07-16: Lessons note present. Capabilities-safe live re-verify on
`history/2026-07-16-credential-disclosure-lessons.md` (buildup context, exposure `bao.coulomb.social`: lane-policy token → `read` on data path; default + agent
accepted, three root causes). Remaining: capabilities-based live re-verify of boundary → `deny`; field keys present (no values printed). Catalog promoted
CCR-2026-0004 on `bao.coulomb.social` (uses T01 pattern) and its `rotation:` block `draft``active`, `fetch_command` pinned to `NC_WEBDAV_TOKEN` (no placeholders)
(depends on T06 registry) so the lane can promote to `resolvable: true` and unblock so `resolvable: true`; `risk: high`; rotation guidance + EXPOSED taint on v2.
RAILIANCE-WP-0015. 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 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: root causes). Apply T01 + T06 to `CCR-2026-0004` as the first worked lane: