feat(mvp): working secrets-engine CLI for the whynot-design npm publish lane

Implements SECRETS-WP-0002 end to end as a uv-managed Python package:

- catalog: non-secret lane registry + strict validator (build/test/prod)
- stage roles + OpenBao ACL policies; guards refuse wildcards, sys/, identity/,
  admin names, and cross-stage paths before any backend call
- plan/apply: dry-run-first, idempotent policy + approle apply, decision-gated
- decisions: State Hub lookup with local-fixture fallback; non-secret evidence
  to JSONL + hub progress, scrubbed of any value
- provision/verify: mode-0600 file import + generated test values; positive/
  negative checks that never print the value
- exec delivery: `exec --catalog ... -- npm publish` injects the token via a
  temp .npmrc for the child only, cleaned up on exit/failure/interrupt
- ops-warden routing contract + hardening backlog docs
- 34 tests incl. live OpenBao integration; scripts/demo-e2e.sh runs the full
  chain against a throwaway bao dev server

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
tegwick 2026-06-28 12:28:45 +02:00
parent 58c24cff53
commit a852d3f1ff
47 changed files with 3743 additions and 122 deletions

View file

@ -0,0 +1,232 @@
"""OpenBao backend adapter.
Thin wrapper over the `bao` CLI. Isolated here so the rest of the engine speaks
in lanes/plans, not in OpenBao endpoint quirks (FR: "isolate backend adapter").
Auth resolution order for the token:
1. explicit bootstrap token file (--bootstrap-token-file), mode-checked;
2. BAO_TOKEN / VAULT_TOKEN environment variable;
3. otherwise unauthenticated (only dry-run / read-health works).
This adapter NEVER returns a secret value to its callers except through the
narrow `read_field_present()` (boolean) and the exec-delivery path, which writes
straight into a child process and never logs.
"""
from __future__ import annotations
import json
import os
import shutil
import stat
import subprocess
from dataclasses import dataclass
from pathlib import Path
from secrets_engine.errors import BackendError, ProvisioningError
def _check_token_file(path: Path) -> str:
"""Read a bootstrap token file after enforcing mode-0600 and out-of-repo."""
if not path.exists():
raise ProvisioningError(f"bootstrap token file not found: {path}")
st = path.stat()
if st.st_mode & 0o077:
raise ProvisioningError(
f"bootstrap token file {path} is group/other-accessible "
f"(mode {oct(st.st_mode & 0o777)}); must be 0600"
)
# Refuse a token file living inside a Git worktree.
for parent in path.resolve().parents:
if (parent / ".git").exists():
raise ProvisioningError(
f"bootstrap token file {path} is inside a Git worktree ({parent}); "
"store it outside any repo"
)
token = path.read_text(encoding="utf-8").strip()
if not token:
raise ProvisioningError(f"bootstrap token file {path} is empty")
return token
@dataclass
class OpenBaoClient:
addr: str
token: str = ""
bao_bin: str = ""
@classmethod
def resolve(
cls, addr: str, *, bootstrap_token_file: str | Path | None = None
) -> "OpenBaoClient":
token = ""
if bootstrap_token_file:
token = _check_token_file(Path(bootstrap_token_file))
else:
token = os.environ.get("BAO_TOKEN", os.environ.get("VAULT_TOKEN", ""))
bao_bin = shutil.which("bao") or shutil.which("vault") or ""
return cls(addr=addr, token=token, bao_bin=bao_bin)
# -- low level ---------------------------------------------------------
def _run(self, args: list[str], *, stdin: str | None = None) -> subprocess.CompletedProcess:
if not self.bao_bin:
raise BackendError(
"no 'bao' (or 'vault') CLI on PATH; cannot reach OpenBao backend"
)
env = dict(os.environ)
env["BAO_ADDR"] = self.addr
env["VAULT_ADDR"] = self.addr
if self.token:
env["BAO_TOKEN"] = self.token
env["VAULT_TOKEN"] = self.token
try:
return subprocess.run(
[self.bao_bin, *args],
input=stdin,
env=env,
capture_output=True,
text=True,
timeout=30,
)
except FileNotFoundError as e:
raise BackendError(f"backend binary not runnable: {e}") from e
except subprocess.TimeoutExpired as e:
raise BackendError(f"backend call timed out: {' '.join(args)}") from e
def _run_ok(self, args: list[str], *, stdin: str | None = None) -> str:
proc = self._run(args, stdin=stdin)
if proc.returncode != 0:
# stderr from bao does not contain the secret value for these calls.
raise BackendError(
f"bao {' '.join(args[:2])} failed (exit {proc.returncode}): "
f"{proc.stderr.strip() or proc.stdout.strip()}"
)
return proc.stdout
# -- health / capabilities --------------------------------------------
def is_reachable(self) -> bool:
if not self.bao_bin:
return False
proc = self._run(["status", "-format=json"])
# status returns non-zero when sealed but still reachable; treat any
# parseable JSON as reachable.
try:
json.loads(proc.stdout or "{}")
return True
except json.JSONDecodeError:
return proc.returncode == 0
# -- policies ----------------------------------------------------------
def write_policy(self, name: str, hcl: str) -> None:
self._run_ok(["policy", "write", name, "-"], stdin=hcl)
def read_policy(self, name: str) -> str | None:
proc = self._run(["policy", "read", name])
if proc.returncode != 0:
return None
return proc.stdout
# -- approle -----------------------------------------------------------
def ensure_approle_enabled(self) -> None:
proc = self._run(["auth", "list", "-format=json"])
if proc.returncode == 0:
try:
methods = json.loads(proc.stdout)
if "approle/" in methods:
return
except json.JSONDecodeError:
pass
enable = self._run(["auth", "enable", "approle"])
if enable.returncode != 0 and "already in use" not in enable.stderr:
raise BackendError(f"could not enable approle: {enable.stderr.strip()}")
def write_approle(self, role_name: str, policies: list[str], ttl: str = "30m") -> None:
self._run_ok(
[
"write",
f"auth/approle/role/{role_name}",
f"token_policies={','.join(policies)}",
f"token_ttl={ttl}",
f"token_max_ttl={ttl}",
"secret_id_num_uses=0",
"token_num_uses=0",
]
)
def approle_login_token(self, role_name: str) -> str:
"""Login as the approle and return a scoped child token. Used only for
verification / exec delivery; never logged."""
role_id = self._run_ok(
["read", "-field=role_id", f"auth/approle/role/{role_name}/role-id"]
).strip()
secret_id = self._run_ok(
["write", "-field=secret_id", "-f", f"auth/approle/role/{role_name}/secret-id"]
).strip()
token = self._run_ok(
[
"write",
"-field=token",
"auth/approle/login",
f"role_id={role_id}",
f"secret_id={secret_id}",
]
).strip()
return token
# -- KV v2 -------------------------------------------------------------
def kv_mount_exists(self, mount: str) -> bool:
proc = self._run(["secrets", "list", "-format=json"])
if proc.returncode != 0:
return False
try:
return f"{mount}/" in json.loads(proc.stdout)
except json.JSONDecodeError:
return False
def ensure_kv_mount(self, mount: str) -> None:
if self.kv_mount_exists(mount):
return
enable = self._run(["secrets", "enable", "-path", mount, "kv-v2"])
if enable.returncode != 0 and "already in use" not in enable.stderr:
raise BackendError(f"could not enable kv at {mount}: {enable.stderr.strip()}")
def kv_put(self, mount: str, path: str, field: str, value: str) -> None:
"""Write a single field. `value` is a secret and is passed via stdin-free
argv only as a key=value to the local CLI; it is never logged or returned."""
self._run_ok(["kv", "put", f"{mount}/{path}", f"{field}={value}"])
def kv_metadata_exists(self, mount: str, path: str) -> bool:
proc = self._run(["kv", "metadata", "get", "-format=json", f"{mount}/{path}"])
return proc.returncode == 0
def kv_field_present(self, mount: str, path: str, field: str, *, token: str | None = None) -> bool:
"""Return whether `field` exists at the path — WITHOUT returning its value.
If `token` is given, the read is attempted as that (scoped) token, so a
True/False result doubles as a positive/negative access check.
"""
client = self
if token is not None:
client = OpenBaoClient(addr=self.addr, token=token, bao_bin=self.bao_bin)
proc = client._run(["kv", "get", "-format=json", f"{mount}/{path}"])
if proc.returncode != 0:
return False
try:
doc = json.loads(proc.stdout)
except json.JSONDecodeError:
return False
data = doc.get("data", {}).get("data", {})
return field in data and bool(data[field])
def kv_can_read(self, mount: str, path: str, *, token: str) -> bool:
"""True iff `token` is permitted to read the path at all (no value used)."""
client = OpenBaoClient(addr=self.addr, token=token, bao_bin=self.bao_bin)
proc = client._run(["kv", "get", "-format=json", f"{mount}/{path}"])
return proc.returncode == 0
def kv_delete_metadata(self, mount: str, path: str) -> None:
self._run_ok(["kv", "metadata", "delete", f"{mount}/{path}"])