#!/usr/bin/env python3 """Verify the OpenBao half of the agent read-boundary (ADR-0004, WARDEN-WP-0032-T06). `warden access` exits 7 on every `risk: high` lane, but that only protects the ops-warden path. The OpenBao policy `agent-high-risk-boundary` is what protects a direct `bao kv get` -- the actual 2026-07-16 disclosure vector. This script compares the high-risk lanes in the routing catalog against the paths that policy actually denies. Read-only and capabilities-only by construction: it reads the *policy document* and lane metadata. It never reads a secret value, and it never mints a token. See `.claude/rules/credential-routing.md` -- verifying a lane with a read is the mistake this whole control exists to prevent. Prefers the policy deployed on the server (`bao policy read`); falls back to the file in railiance-platform and says loudly that it did, because deployment drift is exactly what this check exists to catch. Exit codes: 0 every high-risk lane with a concrete path is denied; 1 at least one is not; 2 the policy could not be obtained from either source. """ from __future__ import annotations import argparse import json import re import subprocess import sys from pathlib import Path REPO = Path(__file__).resolve().parent.parent CATALOG = REPO / "registry" / "routing" / "catalog.yaml" POLICY_NAME = "agent-high-risk-boundary" POLICY_FILE = Path.home() / "railiance-platform" / "openbao" / "policies" / f"{POLICY_NAME}.hcl" # A path_template with a placeholder is a pattern, not an address -- it names the # shape of a lane rather than one secret, so there is nothing for a policy to deny. PLACEHOLDER = re.compile(r"[<>{}]|\*") def read_deployed_policy() -> tuple[str | None, str]: """Return (policy_text, source). Server first, file second, neither third.""" try: proc = subprocess.run( ["bao", "policy", "read", "-format=json", POLICY_NAME], capture_output=True, text=True, timeout=20, ) except (FileNotFoundError, subprocess.TimeoutExpired) as exc: server_err = str(exc) else: if proc.returncode == 0: try: return json.loads(proc.stdout)["policy"], "server" except (json.JSONDecodeError, KeyError): return proc.stdout, "server" server_err = (proc.stderr or proc.stdout).strip().splitlines()[:1] server_err = server_err[0] if server_err else f"exit {proc.returncode}" if POLICY_FILE.exists(): print(f" ! could not read the deployed policy ({server_err})") print(f" ! falling back to the FILE at {POLICY_FILE}") print(" ! deployment drift cannot be detected in this mode\n") return POLICY_FILE.read_text(), "file" return None, f"unavailable ({server_err})" def denied_data_paths(policy_text: str) -> set[str]: """Paths the policy denies. Only a `deny` on a KV *data* path is a read-boundary.""" denied: set[str] = set() for match in re.finditer( r'path\s+"([^"]+)"\s*\{[^}]*?capabilities\s*=\s*\[([^\]]*)\]', policy_text, re.DOTALL, ): path, caps = match.group(1), match.group(2) if "deny" in {c.strip().strip('"\'') for c in caps.split(",")}: denied.add(path) return denied def to_data_path(path_template: str) -> str | None: """Catalog path -> KV v2 data path. `/rest` -> `/data/rest`.""" if PLACEHOLDER.search(path_template) or " " in path_template: return None mount, _, rest = path_template.partition("/") return f"{mount}/data/{rest}" if rest else None def main() -> int: parser = argparse.ArgumentParser(description=__doc__) parser.add_argument("--json", action="store_true", help="machine-readable output") args = parser.parse_args() import yaml # local import so --help works without the dep entries = yaml.safe_load(CATALOG.read_text())["entries"] high = [e for e in entries if e.get("risk") == "high"] policy_text, source = read_deployed_policy() if policy_text is None: print(f"FAIL: policy {POLICY_NAME} could not be obtained from server or file: {source}") print(" Run `bao login -method=oidc`, or check out railiance-platform.") return 2 denied = denied_data_paths(policy_text) covered, uncovered, no_address = [], [], [] for entry in high: template = entry.get("path_template") data_path = to_data_path(template) if template else None if data_path is None: no_address.append(entry["id"]) elif data_path in denied: covered.append((entry["id"], data_path)) else: uncovered.append((entry["id"], data_path)) if args.json: print(json.dumps({ "policy": POLICY_NAME, "policy_source": source, "high_risk_lanes": len(high), "denied_data_paths": sorted(denied), "covered": [{"id": i, "path": p} for i, p in covered], "uncovered": [{"id": i, "path": p} for i, p in uncovered], "no_concrete_address": no_address, "ok": not uncovered, }, indent=2)) return 1 if uncovered else 0 print(f"agent read-boundary — OpenBao half ({POLICY_NAME})\n") print(f" policy source: {source}" + (" <-- live" if source == "server" else " <-- NOT the deployed policy")) print(f" high-risk lanes: {len(high)}") print(f" denied data paths: {len(denied)}") print(f" covered: {len(covered)}") print(f" NOT covered: {len(uncovered)}") print(f" no concrete address: {len(no_address)}\n") if covered: print("COVERED — a direct `bao kv get` is denied for an agent token") for lane_id, path in sorted(covered): print(f" {lane_id:34} {path}") print() if uncovered: print("NOT COVERED — graded high, but the policy does not deny the data path") for lane_id, path in sorted(uncovered): print(f" {lane_id:34} {path}") print() if no_address: print("NO CONCRETE ADDRESS — a pattern or a non-KV lane, nothing to deny") print(" " + ", ".join(sorted(no_address)) + "\n") if uncovered: print(f"RESULT: FAIL — {len(uncovered)} high-risk lane(s) outside the OpenBao boundary.") print(" The policy is railiance-platform's; ops-warden reports rather than amends" " (RISK-F-0004).") return 1 print("RESULT: PASS — every high-risk lane with a concrete path is denied.") return 0 if __name__ == "__main__": sys.exit(main())