WARDEN-WP-0026 T02: safe access transports (no secret values on stdout)
All checks were successful
CI Smoke / host-smoke (push) Successful in 1s
CI Smoke / container-smoke (push) Successful in 2s

- 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 <noreply@anthropic.com>
This commit is contained in:
tegwick 2026-07-16 14:51:56 +02:00
parent c749561b75
commit 359ca1bd0e
6 changed files with 281 additions and 11 deletions

View file

@ -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 {'<token>'}[/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

View file

@ -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=<ttl> -format=json <path>``.
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.