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 |
| **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)
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.
- Inventing `warden secret`, `warden login`, `warden bao`, `warden tunnel` — they do not exist
- 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)

View file

@ -290,6 +290,8 @@ entries:
canon_ref: net-kingdom/docs/platform-identity-security-architecture.md
reviewed: "2026-07-02"
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
# (promoted 2026-07-02): policy workload-kv-read-llm-connect-provider-secrets and k8s
# auth role external-secrets-activity-core applied; ExternalSecret
@ -321,13 +323,20 @@ entries:
warden_executes: false
wiki_ref: wiki/playbooks/railiance-backup-offsite-lane.md#worker-checklist
canon_ref: railiance-platform/docs/workload-kv-access-lanes.md
reviewed: "2026-07-07"
status: draft
reviewed: "2026-07-16"
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.
# 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)"
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
lane: secret
# 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)."
- "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."
- "After rotation, clear EXPOSED taint: remove custom_metadata exposed_at/exposed_version (see `warden taint railiance-backup-offsite-lane`)."
- id: forgejo-admin-api-token
title: Forgejo operator/admin API token (PAT)
@ -355,7 +365,8 @@ entries:
canon_ref: railiance-platform/docs/workload-kv-access-lanes.md
reviewed: "2026-07-13"
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
# 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

View file

@ -2,6 +2,7 @@
from __future__ import annotations
import json
import os
from datetime import datetime, timedelta, timezone
from pathlib import Path
from typing import Annotated, List, Optional
@ -630,6 +631,9 @@ def _entry_summary(entry) -> dict:
"canon_ref": entry.canon_ref,
"reviewed": entry.reviewed,
"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`
# lets a caller gate before asking for the full block via `warden rotate-guide`.
"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")
def rotate_guide(
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]")
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
# 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
# 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
if not _sys.stdout.isatty() and not unsafe_stdout:

View file

@ -22,7 +22,7 @@ from typing import List, Optional
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.
# 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})"
)
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(
id=entry_id,
title=str(raw["title"]),
@ -326,6 +332,7 @@ def _parse_entry(raw: dict, index: int) -> RouteEntry:
exec_command=handoff["exec_command"],
pointer_command=handoff["pointer_command"],
rotation=_parse_rotation(entry_id, raw.get("rotation")),
risk=risk,
)

View file

@ -28,6 +28,13 @@ class RotationGuide:
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
class RouteEntry:
id: str
@ -69,11 +76,18 @@ class RouteEntry:
pointer_command: Optional[str] = None # e.g. "secrets-engine route <id> --json"
# Rotation / re-establishment guidance (WP-0026 T06) — advisory, no secret values.
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
def is_active(self) -> bool:
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
def has_rotation(self) -> bool:
"""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 "top-secret-token-value" not in r.output # value never shown
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.
result = runner.invoke(app, ["rotate-guide", "ssh-cert-host-access"])
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` |
| `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 |
| `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 |
| `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`):

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 |
| --- | --- |
| `object-storage-sts` | NK-WP-0007 vending path not production-exercised |
| `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
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
Date: 2026-07-07
Catalog: `railiance-backup-offsite-lane` (status `draft`, `resolvable: false` until verified)
Date: 2026-07-16
Catalog: `railiance-backup-offsite-lane` (status `active`, `resolvable: true`, `risk: high`)
Owner: `railiance-platform` (CCR-2026-0004)
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` |
| Path | `platform/workloads/railiance/backup/offsite-lane` |
| Fields | `NC_WEBDAV_TOKEN`, `NC_WEBDAV_URL`, `AGE_PRIVATE_KEY` |
| Policy | `workload-kv-read-railiance-backup-offsite-lane` |
| Fields | `NC_WEBDAV_TOKEN` (primary fetch), `NC_WEBDAV_URL`, `AGE_PRIVATE_KEY` (recovery escrow) |
| 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`) |
| 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
```
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
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
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**:
@ -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.
**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`).
```bash
# Positive: lane OIDC identity can read the data path
bao login -method=oidc -path=netkingdom role=railiance-backup-workload-kv-read
bao token capabilities "$(bao print token)" platform/data/workloads/railiance/backup/offsite-lane
# Positive: lane policy identity can read the data path
LANE=$(bao token create -policy=workload-kv-read-railiance-backup-offsite-lane -ttl=2m -field=token)
bao token capabilities "$LANE" platform/data/workloads/railiance/backup/offsite-lane
# → expect: read
bao token revoke "$LANE"
# 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
bao token capabilities "$DEFAULT_TOKEN" platform/data/workloads/railiance/backup/offsite-lane
# → 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
`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)"
domain: infotech
repo: ops-warden
status: active
status: finished
owner: codex
topic_slug: custodian
planning_priority: high
@ -140,11 +140,21 @@ limitation documented.
```task
id: WARDEN-WP-0026-T04
status: todo
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
@ -158,11 +168,19 @@ while still allowing wrapped/proxied access, verified via capabilities checks.
```task
id: WARDEN-WP-0026-T05
status: todo
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
@ -208,17 +226,19 @@ every active lane, and the scorecard fails if any active lane lacks guidance.
```task
id: WARDEN-WP-0026-T07
status: progress
status: done
priority: medium
state_hub_task_id: "9944f46d-3706-43a4-9300-7f63bf87c9ff"
```
Lessons-learned note written 2026-07-16:
`history/2026-07-16-credential-disclosure-lessons.md` (buildup context, exposure
accepted, three root causes). Remaining: capabilities-based live re-verify of
CCR-2026-0004 on `bao.coulomb.social` (uses T01 pattern) and its `rotation:` block
(depends on T06 registry) so the lane can promote to `resolvable: true` and unblock
RAILIANCE-WP-0015.
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: