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,81 @@
"""Verification: prove access (or denial) without printing the value.
Positive: the approved consumer (via its approle-scoped token) CAN read the lane.
Negative: an unrelated/unscoped token CANNOT read the lane.
Each check returns a boolean + a non-secret evidence dict. The secret value is
never read into the result.
"""
from __future__ import annotations
from dataclasses import dataclass
from typing import Any
from secrets_engine.catalog import CatalogEntry
from secrets_engine.errors import VerificationError
from secrets_engine.openbao import OpenBaoClient
@dataclass
class VerifyResult:
check: str # "positive" | "negative"
passed: bool
detail: dict[str, Any]
def render(self) -> str:
status = "PASS" if self.passed else "FAIL"
return f" {self.check} check: {status} ({self.detail.get('reason', '')})"
def verify_positive(client: OpenBaoClient, entry: CatalogEntry, field: str) -> VerifyResult:
"""Approved consumer token must be able to read the field."""
try:
token = client.approle_login_token(entry.role_name)
except Exception as e: # backend errors -> failed verification, not a value leak
return VerifyResult(
"positive",
False,
{"reason": f"could not obtain approle token: {e}", "path": entry.path},
)
present = client.kv_field_present(entry.mount, entry.path, field, token=token)
return VerifyResult(
"positive",
present,
{
"reason": "approved consumer can read lane field"
if present
else "approved consumer could NOT read field",
"path": entry.path,
"field": field,
"role": entry.role_name,
},
)
def verify_negative(client: OpenBaoClient, entry: CatalogEntry) -> VerifyResult:
"""An unrelated token must be denied. Uses an empty (invalid) token."""
# An empty/garbage token stands in for an unrelated consumer.
denied = not client.kv_can_read(entry.mount, entry.path, token="se-unrelated-denied")
return VerifyResult(
"negative",
denied,
{
"reason": "unrelated token denied read"
if denied
else "unrelated token was ABLE to read (LEAK RISK)",
"path": entry.path,
},
)
def run_verification(
client: OpenBaoClient, entry: CatalogEntry, field: str, *, positive: bool, negative: bool
) -> list[VerifyResult]:
results: list[VerifyResult] = []
if positive:
results.append(verify_positive(client, entry, field))
if negative:
results.append(verify_negative(client, entry))
if not results:
raise VerificationError("no verification check selected (use --positive/--negative)")
return results