feat(WARDEN-WP-0014): T3 — OpenBao proxy lane (--fetch / --exec)

Adds transparent, policy-gated, audited proxy of a non-SSH credential
through `warden access`, for exec_capable lanes. Three guardrails in code:

- G1 caller identity: runs the owner's tool with the caller's own env;
  warden injects no token of its own (caller_auth_present check).
- G2 transit-only: --fetch inherits stdout (never PIPE) so the value
  never enters warden's memory or any log; --exec injects into the child
  env only. Audit (access-audit.log) is metadata-only.
- G3 policy gate: check_fetch_policy runs before any fetch; with
  policy.enabled=false the proxy refuses unless --no-policy is given.

resolve_fetch_command refuses unresolved <…> placeholders rather than
guess owner-side names. New warden/proxy.py + policy.check_fetch_policy;
tests/test_proxy.py asserts all three guardrails. 168 passed, lint clean.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-06-27 16:26:03 +02:00
parent 830a775bcf
commit 6dfa69e310
5 changed files with 588 additions and 11 deletions

View File

@@ -737,8 +737,121 @@ def _access_json(entry, expanded, gate: str, domain: Optional[str]) -> dict:
return payload
@app.command("access")
def _access_proxy(
entry,
*,
domain: Optional[str],
field: Optional[str],
path: Optional[str],
do_exec: bool,
child_argv: list,
no_policy: bool,
) -> None:
"""Proxy a non-SSH credential fetch as the caller (WP-0014 T3).
Enforces the three guardrails: caller identity (no warden token), policy gate
before fetch, and transit-only (no value persisted or logged). All warden chatter
goes to stderr so --fetch stdout carries only the secret.
"""
from warden.proxy import (
ProxyError,
caller_auth_present,
proxy_exec,
proxy_fetch,
resolve_fetch_command,
write_audit,
)
from warden.policy import check_fetch_policy
if not entry.exec_capable:
err.print(
f"[red]{entry.id!r} is not exec_capable.[/red] "
"Use `warden access` (advisory) and obtain it from the owner directly."
)
raise typer.Exit(2)
# Proxy is privileged — require a real config for policy posture + audit sink.
try:
cfg = load_config()
except ConfigError as e:
err.print(
f"[red]Proxy requires warden.yaml[/red] (policy gate + audit sink): {e}\n"
"Advisory mode works without it: drop --fetch/--exec."
)
raise typer.Exit(2)
# G1 — caller identity. ops-warden adds no token of its own.
if not caller_auth_present():
err.print(
"[red]No caller credential found[/red] (VAULT_TOKEN/BAO_TOKEN or ~/.vault-token). "
f"Authenticate first: {entry.auth_method or 'see the owner auth path'}."
)
raise typer.Exit(3)
# G3 — policy gate before fetch.
decision_id = None
if cfg.policy.enabled:
try:
decision_id = check_fetch_policy(
cfg.policy, need_id=entry.id, owner_repo=entry.owner_repo, domain=domain
)
except CAError as e:
err.print(f"[red]Policy gate denied the fetch:[/red] {e}")
raise typer.Exit(4)
err.print(f"[green]flex-auth allow[/green] (decision {decision_id}).")
elif not no_policy:
err.print(
"[yellow]flex-auth gate is not enforced[/yellow] (policy.enabled=false). "
"Re-run with [bold]--no-policy[/bold] to proxy ungated, or enable the gate."
)
raise typer.Exit(4)
else:
err.print("[yellow]Proxying ungated[/yellow] (--no-policy; gate not enforced).")
try:
argv = resolve_fetch_command(entry, domain=domain, field=field, path=path)
except ProxyError as e:
err.print(f"[red]{e}[/red]")
raise typer.Exit(2)
action = "exec" if do_exec else "fetch"
err.print(
f"[dim]proxy {action}: {entry.id}{entry.owner_repo} "
f"(caller identity; value not persisted)[/dim]"
)
try:
if do_exec:
if not child_argv:
err.print("[red]--exec needs a command after `--`[/red], e.g. `-- npm publish`.")
raise typer.Exit(2)
rc = proxy_exec(argv, env_var=field or "", child_argv=child_argv)
else:
rc = proxy_fetch(argv)
except ProxyError as e:
err.print(f"[red]{e}[/red]")
raise typer.Exit(5)
finally:
try:
write_audit(
cfg.state_dir,
need_id=entry.id,
owner_repo=entry.owner_repo,
domain=domain,
action=action,
decision_id=decision_id,
)
except OSError as e:
err.print(f"[yellow]audit write failed:[/yellow] {e}")
raise typer.Exit(rc)
@app.command(
"access",
context_settings={"allow_extra_args": True, "ignore_unknown_options": True},
)
def access(
ctx: typer.Context,
need: Annotated[str, typer.Argument(help="Free-text need, e.g. 'npm token', 'db password'")],
domain: Annotated[
Optional[str],
@@ -746,13 +859,34 @@ def access(
] = None,
output_json: Annotated[bool, typer.Option("--json", help="Output JSON (stable, secret-free)")] = False,
all_entries: Annotated[bool, typer.Option("--all", help="Include draft entries")] = False,
do_fetch: Annotated[
bool, typer.Option("--fetch", help="Proxy the fetch as the caller; value streams to stdout")
] = False,
do_exec: Annotated[
bool,
typer.Option("--exec", help="Run the trailing command (after --) with the secret in its env"),
] = False,
field: Annotated[
Optional[str], typer.Option("--field", help="Secret field / env-var name, e.g. NPM_AUTH_TOKEN")
] = None,
path: Annotated[
Optional[str], typer.Option("--path", help="Override the owner-side path template")
] = None,
no_policy: Annotated[
bool,
typer.Option("--no-policy", help="Acknowledge proxying when the flex-auth gate is not enforced"),
] = False,
) -> None:
"""Operator front door: how to obtain any credential, gated and audited.
Advisory by default — renders the owner, auth method, path template, command
skeleton, and policy gate status for the best-matching need. ops-warden issues
the SSH lane directly and **routes every other need to its owner** — it never
holds or vends the secret value. (Proxy fetch arrives in WP-0014 T3.)
holds or vends the secret value.
With --fetch / --exec it proxies the fetch *as the caller* for exec_capable lanes:
the flex-auth gate runs first, ops-warden adds no credential of its own, the value
is never persisted or logged, and only metadata is audited.
"""
from warden.access import expand_handoff, policy_gate_status
@@ -766,6 +900,19 @@ def access(
raise typer.Exit(1)
entry = matches[0]
if do_fetch or do_exec:
_access_proxy(
entry,
domain=domain,
field=field,
path=path,
do_exec=do_exec,
child_argv=list(ctx.args),
no_policy=no_policy,
)
return
expanded = expand_handoff(entry, domain)
gate = policy_gate_status()