diff --git a/.claude/rules/credential-routing.md b/.claude/rules/credential-routing.md index 06e6b43..f8722a3 100644 --- a/.claude/rules/credential-routing.md +++ b/.claude/rules/credential-routing.md @@ -40,12 +40,17 @@ Requires the `warden` CLI from `~/ops-warden` (`uv tool install .` or `uv run wa | I need… | Owner | ops-warden role | | --- | --- | --- | | SSH cert (`adm`/`agt`/`atm`) | ops-warden | **Issue** — `warden sign` | -| API key, DB password, provider token | OpenBao (`railiance-platform`) | **Assist** — `warden access --fetch/--exec` proxies as you; OpenBao keeps custody | +| Provisioned secret-exec lane (e.g. npm publish) | **secrets-engine** | **Route** — primary is `secrets-engine exec --catalog -- `; `warden access --exec` is the transparent fallback | +| Generic API key / DB password / provider token | OpenBao (`railiance-platform`) | **Assist** — `warden access --fetch/--exec` proxies as you; OpenBao keeps custody | | Login / OIDC / MFA | key-cape / Keycloak | **Assist** — `warden access --fetch` runs the login as you | | Authorization decision | flex-auth | Route only | | activity-core → issue-core emission | activity-core + issue-core | Route — `warden route show activity-core-issue-sink` | | SSH tunnel | ops-bridge (+ `cert_command` from warden) | Route only | +For an owned lane, `warden route find --json` / `warden access ` surface +`exec_owner`, the `secrets-engine exec` command, and the `resolvable` flag. Run the +secrets-engine command; ops-warden routes to it and requests/holds no token. + ### Anti-patterns (do not do these) - `POST /messages/` to `ops-warden` asking for `ISSUE_CORE_API_KEY`, `OPENROUTER_API_KEY`, etc. diff --git a/SCOPE.md b/SCOPE.md index 5c7eacc..c0921fb 100644 --- a/SCOPE.md +++ b/SCOPE.md @@ -236,6 +236,10 @@ repos' lanes (see Known gaps). conformance checker, dev doubles); canon landing owner-driven - **ops-bridge cert_command:** WP-0016 shipped to pilot-ready (readiness gate + offline contract smoke + handoff); live cutover is ops-bridge's +- **Access front door:** WP-0017 discoverability + WP-0018 first concrete lane + (`whynot-design-npm-publish`), **production-exercised** — whynot-design published + `@whynot/design@0.4.0` through the conduit. WP-0019 routes provisioned secret-exec + lanes to **secrets-engine** (`secrets-engine exec`), proxy as transparent fallback - **Active work:** none open in ops-warden; remaining distance is other repos' lanes - **Integration docs:** cert_command migration, token hygiene, principals drift (`wiki/playbooks/`) - **Latest assessment:** `history/2026-06-24-intent-scope-gap-analysis.md` @@ -284,6 +288,7 @@ Downstream: `ops-bridge` (primary), kaizen agents, CI automations, human operato | `railiance-platform` | OpenBao deployment and platform secrets | | `flex-auth` | Authorization; policy package shipped (FLEX-WP-0006); runtime deploy FLEX-WP-0007 | | `key-cape` | Identity / IAM Profile lightweight mode | +| `secrets-engine` | Owner-native secret-exec front door (`secrets-engine exec/route`); ops-warden routes provisioned secret lanes to it (WP-0019) | | `state-hub` | Workstream registry | --- diff --git a/registry/routing/catalog.yaml b/registry/routing/catalog.yaml index 3ff6ae9..ad11dd4 100644 --- a/registry/routing/catalog.yaml +++ b/registry/routing/catalog.yaml @@ -89,6 +89,12 @@ entries: policy_ref: "flex-auth check secret.read:whynot-design" exec_capable: true lane: secret + # Owner-native exec front door (WP-0019, secrets-engine SECRETS-WP-0003, decision + # e6381a56): route-primary, proxy-fallback. The secrets-engine exec is the primary + # path; warden access --fetch/--exec remains a transparent fallback. + exec_owner: secrets-engine + exec_command: "secrets-engine exec --catalog whynot-design-npm-publish -- " + pointer_command: "secrets-engine route whynot-design-npm-publish --json" - id: flex-auth-policy-check title: Authorization decision — may this actor perform this action diff --git a/src/warden/cli.py b/src/warden/cli.py index 95191e0..de0a59f 100644 --- a/src/warden/cli.py +++ b/src/warden/cli.py @@ -556,6 +556,17 @@ def _entry_summary(entry) -> dict: # resolvable: can `warden access --fetch` run this now with no <…> to fill? # Lets an automated caller gate on readiness before attempting a fetch. "resolvable": entry.resolvable, + # Owner-native exec front door (WP-0019): when present, this subsystem's exec is + # the PRIMARY path; ops-warden's proxy is the transparent fallback. + **( + { + "exec_owner": entry.exec_owner, + "exec_command": entry.exec_command, + "pointer_command": entry.pointer_command, + } + if entry.has_native_exec + else {} + ), "wiki_ref": entry.wiki_ref, "canon_ref": entry.canon_ref, "reviewed": entry.reviewed, @@ -677,6 +688,11 @@ def route_show( if entry.warden_executes: summary["steps"] = entry.steps summary["cert_command"] = entry.cert_command + elif entry.has_native_exec: + summary["next_action"] = ( + f"primary: run via {entry.exec_owner} — `{entry.exec_command}`; ops-warden " + f"routes to the owner (fallback: `warden access --exec`). See `{entry.wiki_ref}`." + ) elif entry.exec_capable: summary["next_action"] = ( f"ops-warden can proxy this as the caller: `warden access --fetch`" @@ -756,6 +772,12 @@ def _access_json(entry, expanded, gate: str, domain: Optional[str]) -> dict: if entry.warden_executes: payload["next_action"] = "ops-warden issues this directly — see cert_command" payload["cert_command"] = entry.cert_command + elif entry.has_native_exec: + payload["next_action"] = ( + f"primary: run via {entry.exec_owner} — `{entry.exec_command}`; " + "ops-warden routes to the owner (fallback: `warden access --exec`). " + "ops-warden holds no token." + ) elif expanded.exec_capable: verb = "fetch" if entry.lane != "login" else "login" payload["next_action"] = ( @@ -994,22 +1016,39 @@ def access( console.print(f" wiki : {entry.wiki_ref}") console.print(f" canon : {entry.canon_ref}") - if expanded.exec_capable: - proxy = f"warden access {need!r}" - if domain: - proxy += f" --domain {domain}" - hint = ( - "add --fetch to proxy as the caller" - if entry.lane != "login" - else "add --fetch to run the interactive login as the caller" + proxy = f"warden access {need!r}" + if domain: + proxy += f" --domain {domain}" + + if entry.has_native_exec: + console.print( + f" exec : [bold]{entry.exec_command}[/bold] " + f"[cyan](via {entry.exec_owner} — primary)[/cyan]" ) - console.print(f" proxy : [dim]{proxy} --fetch[/dim] [yellow]({hint})[/yellow]") + if entry.pointer_command: + console.print(f" pointer : [dim]{entry.pointer_command}[/dim]") + if expanded.exec_capable: + label = "fallback" if entry.has_native_exec else "proxy" + hint = ( + "transparent conduit — fetches as you" + if entry.lane != "login" + else "runs the interactive login as you" + ) + console.print(f" {label:<8} : [dim]{proxy} --fetch[/dim] [yellow]({hint})[/yellow]") if expanded.path_template and "<" in expanded.path_template: console.print( " note : remaining <…> placeholders are owner-confirmed names " f"(coordinate with {entry.owner_repo})." ) - if expanded.exec_capable: + + if entry.has_native_exec: + console.print( + f"\n[green]Primary:[/green] run it via [bold]{entry.exec_owner}[/bold] — " + f"[bold]{entry.exec_command}[/bold]. ops-warden routes to the owner and holds no token.\n" + f"[dim]Fallback:[/dim] [bold]{proxy} --exec -- [/bold] — ops-warden's transparent " + "conduit (runs the fetch as you, holds nothing)." + ) + elif expanded.exec_capable: verb = "fetch this for you" if entry.lane != "login" else "run this login for you" console.print( f"\n[green]ops-warden can {verb}[/green] as the caller — " diff --git a/src/warden/routing/catalog.py b/src/warden/routing/catalog.py index d53446b..d44f16d 100644 --- a/src/warden/routing/catalog.py +++ b/src/warden/routing/catalog.py @@ -26,7 +26,11 @@ from warden.routing.models import RouteEntry # Structured handoff string fields (WP-0014) — templates and pointers only. # Every one is scanned for accidental secret material; see _assert_no_secret_material. -_HANDOFF_STR_FIELDS = ("auth_method", "path_template", "fetch_command", "policy_ref") +_HANDOFF_STR_FIELDS = ( + "auth_method", "path_template", "fetch_command", "policy_ref", + # Owner-native exec front door (WP-0019) — pointer commands, screened too. + "exec_command", "pointer_command", +) # Known secret-bearing token prefixes — a literal here means a value leaked into # the catalog (which is git-tracked and agent-visible). Templates use `<...>`. @@ -265,6 +269,9 @@ def _parse_entry(raw: dict, index: int) -> RouteEntry: exec_capable=exec_capable, policy_ref=handoff["policy_ref"], lane=lane, + exec_owner=str(raw["exec_owner"]) if raw.get("exec_owner") else None, + exec_command=handoff["exec_command"], + pointer_command=handoff["pointer_command"], ) diff --git a/src/warden/routing/models.py b/src/warden/routing/models.py index 7a8c21b..54f216f 100644 --- a/src/warden/routing/models.py +++ b/src/warden/routing/models.py @@ -43,11 +43,23 @@ class RouteEntry: # no identity yet), no caller-auth precheck (the point is to get one), # run interactively as the caller; warden never captures the token. lane: str = "secret" + # Owner-native exec front door (WP-0019). When `exec_owner` is set, that subsystem + # (e.g. secrets-engine) provides the PRIMARY way to run a secret-backed command; the + # catalog routes to it and keeps ops-warden's own --fetch/--exec proxy as a transparent + # fallback (route-primary, proxy-fallback). Pointers/templates only — never a value. + exec_owner: Optional[str] = None # subsystem owning the native exec (e.g. secrets-engine) + exec_command: Optional[str] = None # e.g. "secrets-engine exec --catalog -- " + pointer_command: Optional[str] = None # e.g. "secrets-engine route --json" @property def is_active(self) -> bool: return self.status == "active" + @property + def has_native_exec(self) -> bool: + """True when an owner-native exec front door is the primary path for this lane.""" + return bool(self.exec_owner and self.exec_command) + @property def has_handoff(self) -> bool: """True when structured assist fields are present (advisory richness).""" diff --git a/tests/test_access.py b/tests/test_access.py index e85d70b..f5765d9 100644 --- a/tests/test_access.py +++ b/tests/test_access.py @@ -77,6 +77,15 @@ def test_access_advisory_output(monkeypatch): assert "never holds" in r.stdout +def test_access_native_exec_shows_primary_and_fallback(monkeypatch): + """A secrets-engine-owned lane leads with the native exec; proxy is the fallback.""" + monkeypatch.setenv("WARDEN_ROUTING_CATALOG", str(_repo_catalog())) + r = runner.invoke(app, ["access", "whynot-design-npm-publish"]) + assert r.exit_code == 0 + assert "secrets-engine exec --catalog whynot-design-npm-publish" in r.stdout + assert "Primary" in r.stdout and "Fallback" in r.stdout + + def test_access_route_only_lane_says_owner_vends(monkeypatch): """A non-exec lane (host principal deploy) keeps the advise-only framing.""" monkeypatch.setenv("WARDEN_ROUTING_CATALOG", str(_repo_catalog())) diff --git a/tests/test_routing.py b/tests/test_routing.py index 91f1dda..eff25e7 100644 --- a/tests/test_routing.py +++ b/tests/test_routing.py @@ -99,6 +99,32 @@ def test_find_exact_id_wins_over_keyword_collision(): assert catalog.find("whynot-design-npm-publish", limit=1)[0].id == "whynot-design-npm-publish" +def test_native_exec_owner_on_npm_lane(): + """secrets-engine is the owner-native exec front door for the npm lane (WP-0019).""" + catalog = load_catalog(_repo_catalog()) + e = catalog.get("whynot-design-npm-publish") + assert e.has_native_exec is True + assert e.exec_owner == "secrets-engine" + assert "secrets-engine exec --catalog whynot-design-npm-publish" in e.exec_command + assert "secrets-engine route" in e.pointer_command + # The proxy fallback is still available (exec_capable + resolvable). + assert e.exec_capable is True and e.resolvable is True + + +def test_lanes_without_native_exec(): + catalog = load_catalog(_repo_catalog()) + assert catalog.get("openbao-api-key").has_native_exec is False + assert catalog.get("ssh-cert-host-access").has_native_exec is False + + +def test_cli_show_native_exec_json(repo_catalog_env): + result = runner.invoke(app, ["route", "show", "whynot-design-npm-publish", "--json"]) + data = json.loads(result.stdout) + assert data["exec_owner"] == "secrets-engine" + assert "secrets-engine exec" in data["exec_command"] + assert "primary" in data["next_action"] and "secrets-engine" in data["next_action"] + + def test_no_double_source_rule_rejects_routed_steps(tmp_path): bad = dict(ROUTED_ENTRY) bad["steps"] = ["do a thing on OpenBao"] # non-SSH entry must not carry steps diff --git a/wiki/playbooks/whynot-design-npm-publish.md b/wiki/playbooks/whynot-design-npm-publish.md index 7f116e2..228e9b5 100644 --- a/wiki/playbooks/whynot-design-npm-publish.md +++ b/wiki/playbooks/whynot-design-npm-publish.md @@ -38,13 +38,24 @@ this token** — it is the access front door: `warden access` proxies the read f Your token must carry the `whynot-design` group bound claim; a non-whynot identity is denied by policy (verified negative case). -2. **Fetch or run via the front door** — keyed by the stable catalog id, zero placeholders: +2. **Run via the owner-native front door (primary).** secrets-engine owns the secret-exec + for this lane (SECRETS-WP-0003, decision e6381a56); ops-warden routes to it: ```bash - warden access whynot-design-npm-publish --fetch # stream the token to you - warden access whynot-design-npm-publish --exec -- npm publish # inject into the child only + secrets-engine route whynot-design-npm-publish --json # pointer / readiness + secrets-engine exec --catalog whynot-design-npm-publish -- npm publish ``` - The value transits to you (or the child env) and never enters ops-warden's memory, disk, - or audit log (metadata-only audit). + + **ops-warden transparent fallback** — same lane via the `warden access` proxy (fetches as + you, holds nothing). Field-verified flags (whynot-design, @whynot/design@0.4.0): + ```bash + # --exec needs the env-var name; --no-policy is required while the gate is advisory + # (policy.enabled=false), else the call exits 4. + warden access whynot-design-npm-publish --no-policy --field NPM_AUTH_TOKEN \ + --exec -- npm publish + warden access whynot-design-npm-publish --no-policy --field NPM_AUTH_TOKEN --fetch + ``` + On either path the value transits to you (or the child env) and never enters + ops-warden's memory, disk, or audit log. 3. **Readiness gate (for automated callers).** Before attempting `--fetch`, check the flag: ```bash diff --git a/workplans/WARDEN-WP-0019-route-to-secrets-engine.md b/workplans/WARDEN-WP-0019-route-to-secrets-engine.md new file mode 100644 index 0000000..3c7c6c4 --- /dev/null +++ b/workplans/WARDEN-WP-0019-route-to-secrets-engine.md @@ -0,0 +1,86 @@ +--- +id: WARDEN-WP-0019 +type: workplan +title: "Route secret-exec lanes to secrets-engine (route-primary, proxy fallback)" +domain: infotech +repo: ops-warden +status: finished +owner: claude +topic_slug: custodian +planning_priority: high +planning_order: 19 +created: "2026-06-29" +updated: "2026-06-29" +--- + +# WARDEN-WP-0019 — Route secret-exec lanes to secrets-engine + +**Trigger:** secrets-engine (SECRETS-WP-0003, msg 765a03f0) shipped a native secret-exec +front door — `secrets-engine route --json` and `secrets-engine exec --catalog -- +` with canonical decision ids — and asked ops-warden to **route to it**. This is the +owner-native execution lane that ops-warden's `warden access --exec` proxy was filling as a +stopgap (WP-0014). whynot-design already published `@whynot/design@0.4.0` through the proxy +on this same lane, so both paths resolve today. + +**Decision (Bernd, 2026-06-29): route-primary, proxy-fallback.** For lanes secrets-engine +owns, ops-warden surfaces `secrets-engine exec/route` as the **primary** path and keeps its +own `warden access --exec` as a documented **transparent fallback**. ops-warden stays the +discovery front door; secrets-engine is the exec owner. Boundary unchanged: ops-warden holds +or stores no token on either path. + +**Out of scope:** ops-warden invoking `secrets-engine exec` itself (it routes/points, the +caller runs it); changing the proxy's security model; the production policy-gate flip. + +--- + +## Tasks + +### T1 — Catalog + CLI: surface the owner-native exec front door + +```task +id: WARDEN-WP-0019-T01 +status: done +priority: high +``` + +- [x] `RouteEntry` gains `exec_owner` / `exec_command` / `pointer_command` (pointers only, + screened by `_assert_no_secret_material`) and a `has_native_exec` property. +- [x] `whynot-design-npm-publish` entry: `exec_owner: secrets-engine`, + `exec_command: secrets-engine exec --catalog whynot-design-npm-publish -- `, + `pointer_command: secrets-engine route whynot-design-npm-publish --json`. Keep the + existing `fetch_command`/`exec_capable` (the proxy fallback). +- [x] `warden access`: when `exec_owner` is set, render the secrets-engine exec as the + **primary** line and the `warden access --exec` proxy as the **fallback**; JSON gains + `exec_owner`/`exec_command`/`pointer_command`. `route find/show` JSON too. +- [x] Tests in `tests/test_routing.py` / `tests/test_access.py`. + +### T2 — Agent rule, SCOPE, playbook + +```task +id: WARDEN-WP-0019-T02 +status: done +priority: medium +``` + +- [x] `.claude/rules/credential-routing.md`: add secrets-engine as the secret-exec owner; + for OpenBao-backed secret lanes the route is "secrets-engine `exec` (primary), + ops-warden `warden access --exec` (transparent fallback)". +- [x] SCOPE: add secrets-engine to Related Repos + the routing model; note the + whynot-design lane is **production-exercised** (real 0.4.0 publish), not just resolvable. +- [x] `wiki/playbooks/whynot-design-npm-publish.md`: lead with the secrets-engine exec + command; fix the fallback one-liner per whynot-design's field notes + (`--field NPM_AUTH_TOKEN`, and `--no-policy` while `policy.enabled=false`). + +--- + +## Acceptance + +- `warden access whynot-design-npm-publish` shows the secrets-engine exec as primary and the + warden proxy as fallback; `--json` carries `exec_owner`/`exec_command`. +- The credential-routing rule names secrets-engine as the secret-exec owner. +- No secret material anywhere; ops-warden holds no token on either path. + +## See also + +- secrets-engine SECRETS-WP-0003, decision e6381a56, `docs/whynot-design-real-publish-closeout.md` +- `WARDEN-WP-0014` (proxy), `WARDEN-WP-0017` (discoverability), `WARDEN-WP-0018` (lane activation)