From 359ca1bd0e13a61be1323bb66b6603a9869b3ef7 Mon Sep 17 00:00:00 2001 From: tegwick Date: Thu, 16 Jul 2026 14:51:56 +0200 Subject: [PATCH] WARDEN-WP-0026 T02: safe access transports (no secret values on stdout) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - proxy.py: proxy_fetch_to_file (mode-0600 file), build_wrapped_fetch + proxy_fetch_wrapped (single-use OpenBao response-wrapping token), _capture_value helper, is_bao_kv_fetch. - warden access: --out FILE, --wrap [--wrap-ttl], --unsafe-stdout. Raw --fetch to a non-TTY stdout is refused (exit 6) — captured/piped output is the disclosure risk; sanctioned transports are --out / --exec / --wrap. - canon: anti-pattern (secret value onto captured stdout) + transport table in .claude/rules/credential-routing.md; OperatorAccessAssist.md examples + G2 updated. - tests: file/wrap/build + stdout-guard in tests/test_proxy.py. 293 pass. Co-Authored-By: Claude Opus 4.8 --- .claude/rules/credential-routing.md | 16 ++++ src/warden/cli.py | 79 +++++++++++++-- src/warden/proxy.py | 96 +++++++++++++++++++ tests/test_proxy.py | 77 +++++++++++++++ wiki/OperatorAccessAssist.md | 13 ++- ...N-WP-0026-credential-disclosure-hygiene.md | 11 ++- 6 files changed, 281 insertions(+), 11 deletions(-) diff --git a/.claude/rules/credential-routing.md b/.claude/rules/credential-routing.md index fd2f804..fe6678f 100644 --- a/.claude/rules/credential-routing.md +++ b/.claude/rules/credential-routing.md @@ -39,6 +39,22 @@ Requires the `warden` CLI from `~/ops-warden` (`uv tool install .` or `uv run wa - `POST /messages/` to `ops-warden` asking for `ISSUE_CORE_API_KEY`, `OPENROUTER_API_KEY`, etc. - Inventing `warden secret`, `warden login`, `warden bao`, `warden tunnel` — they do not exist - Pasting secrets into Git, State Hub, workplans, logs, or chat +- **Reading a secret value onto a captured stdout.** `bao kv get ` (full table) + or `bao kv get -field=X` piped/redirected/run in an agent session dumps the value + into a logged context — the 2026-07-16 disclosure. To *verify* a lane use + `bao token capabilities` (allow/deny), never a read (WP-0026 T01). + +### Safe fetch transports (WP-0026 T02) + +When a value must actually move, use a sanctioned transport that keeps it off +stdout. `warden access --fetch` refuses to stream a value to a non-terminal +stdout unless you pass `--unsafe-stdout` (interactive human sessions only): + +| Transport | Command | Result | +| --- | --- | --- | +| **File** | `warden access --out FILE` | value written to a mode-0600 file, never shown | +| **Env (exec)** | `warden access --exec -- ` | value injected into the child process env only | +| **Wrapping token** | `warden access --wrap` | a single-use, short-TTL OpenBao wrapping token to `bao unwrap` in your own context | ### Other capabilities (reuse-surface) diff --git a/src/warden/cli.py b/src/warden/cli.py index da75b53..1b5fe74 100644 --- a/src/warden/cli.py +++ b/src/warden/cli.py @@ -956,6 +956,10 @@ def _access_proxy( do_exec: bool, child_argv: list, no_policy: bool, + out_path: Optional[str] = None, + wrap: bool = False, + wrap_ttl: str = "5m", + unsafe_stdout: bool = False, ) -> None: """Proxy a non-SSH credential fetch as the caller (WP-0014 T3). @@ -965,9 +969,12 @@ def _access_proxy( """ from warden.proxy import ( ProxyError, + build_wrapped_fetch, caller_auth_present, proxy_exec, proxy_fetch, + proxy_fetch_to_file, + proxy_fetch_wrapped, resolve_fetch_command, write_audit, ) @@ -1035,11 +1042,38 @@ def _access_proxy( else: err.print("[yellow]Proxying ungated[/yellow] (--no-policy; gate not enforced).") - try: - resolved = resolve_fetch_command(entry, domain=domain, field=field, path=path) - except ProxyError as e: - err.print(f"[red]{e}[/red]") - raise typer.Exit(2) + # Wrapping (WP-0026 T02) uses its own command shape; the value-bearing transports + # share the resolved fetch command. + if wrap and not is_login: + try: + resolved = build_wrapped_fetch(entry, path=path, ttl=wrap_ttl) + except ProxyError as e: + err.print(f"[red]{e}[/red]") + raise typer.Exit(2) + else: + try: + resolved = resolve_fetch_command(entry, domain=domain, field=field, path=path) + except ProxyError as e: + err.print(f"[red]{e}[/red]") + raise typer.Exit(2) + + # T02 — the sanctioned fetch transports (file / env / wrapping token) never put a + # secret value on stdout. Streaming a value to stdout is the documented anti-pattern: + # allowed only to an interactive terminal, and only with an explicit acknowledgment + # when stdout is captured/piped (the logged-context disclosure risk). + if not is_login and not do_exec and not wrap and not out_path: + import sys as _sys + + if not _sys.stdout.isatty() and not unsafe_stdout: + err.print( + "[red]Refusing to stream a secret value to a non-terminal stdout[/red] " + "(captured/piped output is a disclosure risk). Use a sanctioned transport:\n" + " --out FILE write the value to a mode-0600 file\n" + " --exec -- CMD inject it into a child process env\n" + " --wrap return a single-use OpenBao wrapping token to unwrap yourself\n" + "Override only for an interactive human session: --unsafe-stdout." + ) + raise typer.Exit(6) action = "login" if is_login else ("exec" if do_exec else "fetch") err.print( @@ -1052,6 +1086,18 @@ def _access_proxy( err.print("[red]--exec needs a command after `--`[/red], e.g. `-- npm publish`.") raise typer.Exit(2) rc = proxy_exec(resolved, env_var=field or "", child_argv=child_argv) + elif wrap: + token = proxy_fetch_wrapped(resolved) + # The wrapping token is not the secret value — safe to hand back on stdout. + print(token) + err.print( + f"[dim]wrapping token (single-use, ttl {wrap_ttl}) — unwrap in your own " + f"context: [bold]bao unwrap {''}[/bold][/dim]" + ) + rc = 0 + elif out_path: + rc = proxy_fetch_to_file(resolved, Path(out_path)) + err.print(f"[dim]value written to {out_path} (mode 0600); not shown[/dim]") else: rc = proxy_fetch(resolved) except ProxyError as e: @@ -1087,7 +1133,7 @@ def access( 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") + bool, typer.Option("--fetch", help="Proxy the fetch as the caller (pair with --out/--wrap; raw stdout is guarded)") ] = False, do_exec: Annotated[ bool, @@ -1099,6 +1145,21 @@ def access( path: Annotated[ Optional[str], typer.Option("--path", help="Override the owner-side path template") ] = None, + out_path: Annotated[ + Optional[str], + typer.Option("--out", help="Sanctioned transport: write the value to this mode-0600 file, not stdout"), + ] = None, + wrap: Annotated[ + bool, + typer.Option("--wrap", help="Sanctioned transport: return a single-use OpenBao wrapping token (bao unwrap)"), + ] = False, + wrap_ttl: Annotated[ + str, typer.Option("--wrap-ttl", help="TTL for the --wrap response-wrapping token") + ] = "5m", + unsafe_stdout: Annotated[ + bool, + typer.Option("--unsafe-stdout", help="Acknowledge streaming a value to a captured/piped stdout (anti-pattern)"), + ] = False, no_policy: Annotated[ bool, typer.Option("--no-policy", help="Acknowledge proxying when the flex-auth gate is not enforced"), @@ -1134,7 +1195,7 @@ def access( entry = matches[0] - if do_fetch or do_exec: + if do_fetch or do_exec or out_path or wrap: _access_proxy( entry, domain=domain, @@ -1143,6 +1204,10 @@ def access( do_exec=do_exec, child_argv=list(ctx.args), no_policy=no_policy, + out_path=out_path, + wrap=wrap, + wrap_ttl=wrap_ttl, + unsafe_stdout=unsafe_stdout, ) return diff --git a/src/warden/proxy.py b/src/warden/proxy.py index cfa422a..eb91f4a 100644 --- a/src/warden/proxy.py +++ b/src/warden/proxy.py @@ -205,6 +205,102 @@ def proxy_fetch(resolved: ResolvedFetch) -> int: return completed.returncode +def _capture_value(resolved: ResolvedFetch) -> str: + """Run the fetch and return its stdout (the value) minus one trailing newline. + + The value transits warden's memory (the accepted proxy tradeoff for the + non-stdout transports) but is never written to disk or log by this function. + """ + env = _caller_env() + if resolved.argv is not None: + fetched = subprocess.run( # noqa: S603 + resolved.argv, stdout=subprocess.PIPE, stderr=None, stdin=None, + env=env, check=False, text=True, + ) + else: + fetched = subprocess.run( # noqa: S602 + resolved.shell_cmd, shell=True, stdout=subprocess.PIPE, stderr=None, + stdin=None, env=env, check=False, text=True, + ) + if fetched.returncode != 0: + raise ProxyError( + f"fetch failed (exit {fetched.returncode}) — check caller auth and the path." + ) + value = fetched.stdout + if value.endswith("\n"): + value = value[:-1] + return value + + +def proxy_fetch_to_file(resolved: ResolvedFetch, out_path: Path) -> int: + """Fetch the value and write it to ``out_path`` at mode 0600 — never to stdout. + + A sanctioned transport (WP-0026 T02): the value goes to a private file the + caller controls, not a terminal or a logged stream. The file is created with + O_EXCL semantics widened to truncate-if-owned so a re-fetch overwrites, but the + mode is forced to 0600 before any bytes are written. + """ + value = _capture_value(resolved) + # Open with restrictive mode from the start; do not echo the value anywhere. + fd = os.open(str(out_path), os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o600) + try: + os.chmod(out_path, 0o600) # enforce even if the file pre-existed with looser mode + with os.fdopen(fd, "w") as fh: + fh.write(value) + finally: + value = "" # noqa: F841 — best-effort scrub of the local reference + return 0 + + +def is_bao_kv_fetch(entry: RouteEntry) -> bool: + """True when a lane's fetch is a plain ``bao kv get`` (wrappable, WP-0026 T02).""" + return bool(entry.fetch_command and entry.fetch_command.strip().startswith("bao kv get")) + + +def build_wrapped_fetch( + entry: RouteEntry, *, path: Optional[str] = None, ttl: str = "5m" +) -> ResolvedFetch: + """Build a response-wrapping fetch: ``bao kv get -wrap-ttl= -format=json ``. + + Response wrapping returns a single-use, short-TTL *wrapping token* instead of the + secret value — the sanctioned way to move a value between processes (WP-0026 T02). + The caller unwraps it in their own context (`bao unwrap`). Only valid for plain + ``bao kv get`` lanes; the whole secret is wrapped (a per-field ``-field`` read + cannot be wrapped). + """ + if not is_bao_kv_fetch(entry): + raise ProxyError( + f"{entry.id!r} is not a plain `bao kv get` lane — response wrapping " + "(--wrap) is unavailable. Use --out FILE or --exec instead." + ) + target = path or entry.path_template + if not target or _PLACEHOLDER.search(target): + raise ProxyError( + "--wrap needs a concrete path — supply --path or a resolved path_template." + ) + return ResolvedFetch(argv=["bao", "kv", "get", f"-wrap-ttl={ttl}", "-format=json", target]) + + +def proxy_fetch_wrapped(resolved: ResolvedFetch) -> str: + """Run a wrapping fetch and return the wrapping *token* (not the secret value). + + The token is single-use and short-lived; it is not itself the credential, so it + is safe to hand back on stdout. Parses OpenBao's ``-format=json`` wrap_info. + """ + raw = _capture_value(resolved) + try: + data = json.loads(raw) + token = data["wrap_info"]["token"] + except (json.JSONDecodeError, KeyError, TypeError) as e: + raise ProxyError( + "could not parse a wrapping token from the fetch output " + "(is response wrapping supported for this path?)." + ) from e + if not token: + raise ProxyError("empty wrapping token returned.") + return str(token) + + def proxy_exec(resolved: ResolvedFetch, *, env_var: str, child_argv: List[str]) -> int: """Fetch the value and inject it into a child command's environment only. diff --git a/tests/test_proxy.py b/tests/test_proxy.py index cfc356e..a6aa937 100644 --- a/tests/test_proxy.py +++ b/tests/test_proxy.py @@ -287,3 +287,80 @@ def test_invalid_lane_rejected(tmp_path): import pytest with pytest.raises(CatalogError, match="invalid lane"): load_catalog(p) + + +# --------------------------------------------------------------------------- +# Safe access transports (WARDEN-WP-0026 T02) — no secret values on stdout +# --------------------------------------------------------------------------- + +from warden.proxy import ( # noqa: E402 + build_wrapped_fetch, + is_bao_kv_fetch, + proxy_fetch_to_file, + proxy_fetch_wrapped, +) + + +def test_fetch_to_file_writes_mode_0600_and_no_stdout(tmp_path, capsys): + out = tmp_path / "secret.out" + rc = proxy_fetch_to_file(ResolvedFetch(shell_cmd="printf 'sekret'"), out) + assert rc == 0 + assert out.read_text() == "sekret" + assert oct(out.stat().st_mode & 0o777) == "0o600" + # nothing printed to stdout/stderr by the transport itself + captured = capsys.readouterr() + assert "sekret" not in captured.out and "sekret" not in captured.err + + +def test_fetch_to_file_forces_0600_on_preexisting_loose_file(tmp_path): + out = tmp_path / "pre.out" + out.write_text("old") + out.chmod(0o644) + proxy_fetch_to_file(ResolvedFetch(shell_cmd="printf 'new'"), out) + assert out.read_text() == "new" + assert oct(out.stat().st_mode & 0o777) == "0o600" + + +def test_wrapped_fetch_returns_token_not_value(): + payload = '{"wrap_info":{"token":"hvs.WRAP"}}' + token = proxy_fetch_wrapped(ResolvedFetch(shell_cmd=f"printf '%s' '{payload}'")) + assert token == "hvs.WRAP" + + +def test_wrapped_fetch_bad_output_raises(): + with pytest.raises(ProxyError, match="wrapping token"): + proxy_fetch_wrapped(ResolvedFetch(shell_cmd="printf 'not-json'")) + + +def test_build_wrapped_fetch_only_for_bao_kv(): + bao = _entry(fetch_command="bao kv get -field=API_TOKEN platform/x", path_template="platform/x") + assert is_bao_kv_fetch(bao) + argv = build_wrapped_fetch(bao, ttl="9m").argv + assert argv == ["bao", "kv", "get", "-wrap-ttl=9m", "-format=json", "platform/x"] + + piped = _entry(fetch_command="kubectl get secret x -o json | base64 -d", path_template="x") + assert not is_bao_kv_fetch(piped) + with pytest.raises(ProxyError, match="response wrapping"): + build_wrapped_fetch(piped) + + +def test_build_wrapped_fetch_refuses_placeholder_path(): + e = _entry(fetch_command="bao kv get -field= ", + path_template="platform/workloads//x") + with pytest.raises(ProxyError, match="concrete path"): + build_wrapped_fetch(e) + + +def test_access_fetch_to_nonterminal_stdout_is_refused(tmp_path, monkeypatch): + """The anti-pattern: streaming a value to captured stdout is refused (exit 6).""" + _proxy_env(monkeypatch, tmp_path) + monkeypatch.setenv("VAULT_TOKEN", "caller-token") # G1 caller-auth precheck + # The guard trips before the fetch runs; make a real bao call fail loudly if reached. + monkeypatch.setattr( + "warden.proxy.subprocess.run", + lambda *a, **k: (_ for _ in ()).throw(AssertionError("fetch ran despite stdout guard")), + ) + # CliRunner captures stdout (not a tty), so the guard trips without --unsafe-stdout. + r = runner.invoke(app, ["access", "whynot-design-npm-publish", "--fetch", "--no-policy"]) + assert r.exit_code == 6 + assert "sanctioned transport" in r.output.lower() or "refusing" in r.output.lower() diff --git a/wiki/OperatorAccessAssist.md b/wiki/OperatorAccessAssist.md index baaa915..c7a7113 100644 --- a/wiki/OperatorAccessAssist.md +++ b/wiki/OperatorAccessAssist.md @@ -22,14 +22,21 @@ audited"). It does **not** move secret custody into ops-warden. ```console # advisory — works with no config; never fetches a value $ warden access "npm token" --domain coulomb_social -# proxy a secret read as the caller (gated + audited); value streams to stdout -$ warden access "npm token" --domain coulomb_social --field NPM_AUTH_TOKEN --path

--fetch +# --- sanctioned transports (WP-0026 T02): value never hits stdout --- +# write the value to a mode-0600 file +$ warden access "npm token" --domain coulomb_social --field NPM_AUTH_TOKEN --path

--fetch --out ./npm.token # run a child command with the secret in its env only (à la `op run`) $ warden access "npm token" --field NPM_AUTH_TOKEN --exec -- npm publish +# return a single-use OpenBao wrapping token to unwrap in your own context +$ warden access "npm token" --path

--wrap # then: bao unwrap # interactive login (login lane): no token required, no secret-read gate $ warden access "login oidc" --domain coulomb_social --fetch ``` +> **Raw `--fetch` to stdout is the anti-pattern.** It is refused when stdout is +> captured or piped (a logged-context disclosure risk); pass `--unsafe-stdout` only +> for an interactive human terminal. Prefer `--out` / `--exec` / `--wrap`. + `--json` gives a stable, secret-free shape for agentic operators. --- @@ -60,7 +67,7 @@ prevent, and duplicates OpenBao. | | Guardrail | How it is enforced | | --- | --- | --- | | **G1** | **Caller identity, never warden's** | The proxy runs the owner's tool with the caller's own environment; ops-warden injects no token of its own. Secret lanes require the caller to already hold a credential (`caller_auth_present`), else they fail with the auth pointer. | -| **G2** | **Transit only — no persistence/logging of values** | `--fetch` runs with **inherited stdout** (never a pipe), so the value streams to the caller and never enters warden's memory. `--exec` reads the value solely to place it in a child process's env (the accepted `--exec` tradeoff) — never to disk or log. The audit record is **metadata only**. | +| **G2** | **Transit only — no persistence/logging of values** | Sanctioned transports keep the value off stdout: `--out` writes it to a mode-0600 file, `--exec` injects it into a child process env, `--wrap` returns a single-use OpenBao wrapping token (not the value). Raw `--fetch` to stdout is refused for captured/piped output (`--unsafe-stdout` overrides for a human terminal). warden never writes the value to disk or log; the audit record is **metadata only**. (WP-0026 T02) | | **G3** | **Policy gate before fetch** | `check_fetch_policy` (flex-auth) runs before any secret-lane fetch. With `policy.enabled: false` the proxy refuses unless `--no-policy` is given to acknowledge proxying ungated. | The catalog side enforces a fourth, upstream guard: **handoff fields are templates, diff --git a/workplans/WARDEN-WP-0026-credential-disclosure-hygiene.md b/workplans/WARDEN-WP-0026-credential-disclosure-hygiene.md index cbe74de..5b2ca1b 100644 --- a/workplans/WARDEN-WP-0026-credential-disclosure-hygiene.md +++ b/workplans/WARDEN-WP-0026-credential-disclosure-hygiene.md @@ -88,11 +88,20 @@ it (see T07). ```task id: WARDEN-WP-0026-T02 -status: todo +status: done priority: high state_hub_task_id: "3f28c573-8e58-4851-8aa0-925f9367f266" ``` +Done 2026-07-16: sanctioned transports added to `warden access` so a value never +lands on stdout — `--out FILE` (mode-0600 file), `--exec` (child env, pre-existing), +and `--wrap` (single-use OpenBao response-wrapping token via `bao kv get -wrap-ttl`, +caller `bao unwrap`s in their own context). Raw `--fetch` to a non-TTY stdout is now +refused (exit 6) unless `--unsafe-stdout` is passed (interactive human only). +`proxy_fetch_to_file`/`proxy_fetch_wrapped`/`build_wrapped_fetch` in `proxy.py`; tests +in `tests/test_proxy.py`. Anti-pattern + transports documented fleet-wide in +`.claude/rules/credential-routing.md` and `wiki/OperatorAccessAssist.md` (G2). + `warden access` / fetch paths must emit values only into an env var, a file, or a **response-wrapping token** (`bao … -wrap-ttl`), never a stdout table. Add a wrapping-token transport for values that must move between processes. Record in