diff --git a/.claude/rules/credential-routing.md b/.claude/rules/credential-routing.md index fe6678f..09efa23 100644 --- a/.claude/rules/credential-routing.md +++ b/.claude/rules/credential-routing.md @@ -56,6 +56,20 @@ stdout unless you pass `--unsafe-stdout` (interactive human sessions only): | **Env (exec)** | `warden access --exec -- ` | value injected into the child process env only | | **Wrapping token** | `warden access --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 ` 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 diff --git a/AGENTS.md b/AGENTS.md index da2913e..c305dcf 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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 ` reports EXPOSED metadata without reading secret data. +- Playbooks: `wiki/playbooks/agent-read-boundary.md`, `wiki/playbooks/exposed-taint.md`. ### Other capabilities (reuse-surface) diff --git a/registry/routing/catalog.yaml b/registry/routing/catalog.yaml index d6a9520..6adaa55 100644 --- a/registry/routing/catalog.yaml +++ b/registry/routing/catalog.yaml @@ -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= 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 diff --git a/src/warden/cli.py b/src/warden/cli.py index fa2f7ea..6236da1 100644 --- a/src/warden/cli.py +++ b/src/warden/cli.py @@ -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: diff --git a/src/warden/routing/catalog.py b/src/warden/routing/catalog.py index fde5313..3589256 100644 --- a/src/warden/routing/catalog.py +++ b/src/warden/routing/catalog.py @@ -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, ) diff --git a/src/warden/routing/models.py b/src/warden/routing/models.py index f87a8d6..9120f77 100644 --- a/src/warden/routing/models.py +++ b/src/warden/routing/models.py @@ -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 --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).""" diff --git a/src/warden/taint.py b/src/warden/taint.py new file mode 100644 index 0000000..abd18f7 --- /dev/null +++ b/src/warden/taint.py @@ -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, + ) diff --git a/tests/test_proxy.py b/tests/test_proxy.py index 0f7bbcd..be0d190 100644 --- a/tests/test_proxy.py +++ b/tests/test_proxy.py @@ -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 diff --git a/tests/test_routing.py b/tests/test_routing.py index 76fcc10..a3f7587 100644 --- a/tests/test_routing.py +++ b/tests/test_routing.py @@ -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" diff --git a/tests/test_taint.py b/tests/test_taint.py new file mode 100644 index 0000000..362eb79 --- /dev/null +++ b/tests/test_taint.py @@ -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 — cannot query taint.""" + result = runner.invoke(app, ["taint", "openbao-api-key", "--json"]) + assert result.exit_code == 2 diff --git a/wiki/CredentialRouting.md b/wiki/CredentialRouting.md index dc35d6c..772e69d 100644 --- a/wiki/CredentialRouting.md +++ b/wiki/CredentialRouting.md @@ -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 `. **Draft** (hidden from default lookup until owner path ships — `warden route list --all`): diff --git a/wiki/playbooks/agent-read-boundary.md b/wiki/playbooks/agent-read-boundary.md new file mode 100644 index 0000000..5795c53 --- /dev/null +++ b/wiki/playbooks/agent-read-boundary.md @@ -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 --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` diff --git a/wiki/playbooks/catalog-lane-promotion.md b/wiki/playbooks/catalog-lane-promotion.md index 4e71d3f..e1352fd 100644 --- a/wiki/playbooks/catalog-lane-promotion.md +++ b/wiki/playbooks/catalog-lane-promotion.md @@ -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. diff --git a/wiki/playbooks/exposed-taint.md b/wiki/playbooks/exposed-taint.md new file mode 100644 index 0000000..4875d22 --- /dev/null +++ b/wiki/playbooks/exposed-taint.md @@ -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 ` and verifying the new version: + +```bash +# Rewrite metadata without the exposed_* keys (preserve any other custom_metadata). +bao kv metadata put platform/workloads/ +# Or put only non-taint keys you still need. +warden taint # 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) diff --git a/wiki/playbooks/railiance-backup-offsite-lane.md b/wiki/playbooks/railiance-backup-offsite-lane.md index d000b82..5bde5ee 100644 --- a/wiki/playbooks/railiance-backup-offsite-lane.md +++ b/wiki/playbooks/railiance-backup-offsite-lane.md @@ -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`. \ No newline at end of file +`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`). diff --git a/workplans/WARDEN-WP-0026-credential-disclosure-hygiene.md b/workplans/WARDEN-WP-0026-credential-disclosure-hygiene.md index f82db7f..c0bd296 100644 --- a/workplans/WARDEN-WP-0026-credential-disclosure-hygiene.md +++ b/workplans/WARDEN-WP-0026-credential-disclosure-hygiene.md @@ -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 ` (+ `--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 @@ -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: