WARDEN-WP-0026 T02: safe access transports (no secret values on stdout)
- 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:
parent
c749561b75
commit
359ca1bd0e
6 changed files with 281 additions and 11 deletions
|
|
@ -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.
|
- `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
|
- Inventing `warden secret`, `warden login`, `warden bao`, `warden tunnel` — they do not exist
|
||||||
- Pasting secrets into Git, State Hub, workplans, logs, or chat
|
- Pasting secrets into Git, State Hub, workplans, logs, or chat
|
||||||
|
- **Reading a secret value onto a captured stdout.** `bao kv get <path>` (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 <need> --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 <need> --out FILE` | value written to a mode-0600 file, never shown |
|
||||||
|
| **Env (exec)** | `warden access <need> --exec -- <cmd>` | value injected into the child process env only |
|
||||||
|
| **Wrapping token** | `warden access <need> --wrap` | a single-use, short-TTL OpenBao wrapping token to `bao unwrap` in your own context |
|
||||||
|
|
||||||
### Other capabilities (reuse-surface)
|
### Other capabilities (reuse-surface)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -956,6 +956,10 @@ def _access_proxy(
|
||||||
do_exec: bool,
|
do_exec: bool,
|
||||||
child_argv: list,
|
child_argv: list,
|
||||||
no_policy: bool,
|
no_policy: bool,
|
||||||
|
out_path: Optional[str] = None,
|
||||||
|
wrap: bool = False,
|
||||||
|
wrap_ttl: str = "5m",
|
||||||
|
unsafe_stdout: bool = False,
|
||||||
) -> None:
|
) -> None:
|
||||||
"""Proxy a non-SSH credential fetch as the caller (WP-0014 T3).
|
"""Proxy a non-SSH credential fetch as the caller (WP-0014 T3).
|
||||||
|
|
||||||
|
|
@ -965,9 +969,12 @@ def _access_proxy(
|
||||||
"""
|
"""
|
||||||
from warden.proxy import (
|
from warden.proxy import (
|
||||||
ProxyError,
|
ProxyError,
|
||||||
|
build_wrapped_fetch,
|
||||||
caller_auth_present,
|
caller_auth_present,
|
||||||
proxy_exec,
|
proxy_exec,
|
||||||
proxy_fetch,
|
proxy_fetch,
|
||||||
|
proxy_fetch_to_file,
|
||||||
|
proxy_fetch_wrapped,
|
||||||
resolve_fetch_command,
|
resolve_fetch_command,
|
||||||
write_audit,
|
write_audit,
|
||||||
)
|
)
|
||||||
|
|
@ -1035,11 +1042,38 @@ def _access_proxy(
|
||||||
else:
|
else:
|
||||||
err.print("[yellow]Proxying ungated[/yellow] (--no-policy; gate not enforced).")
|
err.print("[yellow]Proxying ungated[/yellow] (--no-policy; gate not enforced).")
|
||||||
|
|
||||||
try:
|
# Wrapping (WP-0026 T02) uses its own command shape; the value-bearing transports
|
||||||
resolved = resolve_fetch_command(entry, domain=domain, field=field, path=path)
|
# share the resolved fetch command.
|
||||||
except ProxyError as e:
|
if wrap and not is_login:
|
||||||
err.print(f"[red]{e}[/red]")
|
try:
|
||||||
raise typer.Exit(2)
|
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")
|
action = "login" if is_login else ("exec" if do_exec else "fetch")
|
||||||
err.print(
|
err.print(
|
||||||
|
|
@ -1052,6 +1086,18 @@ def _access_proxy(
|
||||||
err.print("[red]--exec needs a command after `--`[/red], e.g. `-- npm publish`.")
|
err.print("[red]--exec needs a command after `--`[/red], e.g. `-- npm publish`.")
|
||||||
raise typer.Exit(2)
|
raise typer.Exit(2)
|
||||||
rc = proxy_exec(resolved, env_var=field or "", child_argv=child_argv)
|
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:
|
else:
|
||||||
rc = proxy_fetch(resolved)
|
rc = proxy_fetch(resolved)
|
||||||
except ProxyError as e:
|
except ProxyError as e:
|
||||||
|
|
@ -1087,7 +1133,7 @@ def access(
|
||||||
output_json: Annotated[bool, typer.Option("--json", help="Output JSON (stable, secret-free)")] = False,
|
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,
|
all_entries: Annotated[bool, typer.Option("--all", help="Include draft entries")] = False,
|
||||||
do_fetch: Annotated[
|
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,
|
] = False,
|
||||||
do_exec: Annotated[
|
do_exec: Annotated[
|
||||||
bool,
|
bool,
|
||||||
|
|
@ -1099,6 +1145,21 @@ def access(
|
||||||
path: Annotated[
|
path: Annotated[
|
||||||
Optional[str], typer.Option("--path", help="Override the owner-side path template")
|
Optional[str], typer.Option("--path", help="Override the owner-side path template")
|
||||||
] = None,
|
] = 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[
|
no_policy: Annotated[
|
||||||
bool,
|
bool,
|
||||||
typer.Option("--no-policy", help="Acknowledge proxying when the flex-auth gate is not enforced"),
|
typer.Option("--no-policy", help="Acknowledge proxying when the flex-auth gate is not enforced"),
|
||||||
|
|
@ -1134,7 +1195,7 @@ def access(
|
||||||
|
|
||||||
entry = matches[0]
|
entry = matches[0]
|
||||||
|
|
||||||
if do_fetch or do_exec:
|
if do_fetch or do_exec or out_path or wrap:
|
||||||
_access_proxy(
|
_access_proxy(
|
||||||
entry,
|
entry,
|
||||||
domain=domain,
|
domain=domain,
|
||||||
|
|
@ -1143,6 +1204,10 @@ def access(
|
||||||
do_exec=do_exec,
|
do_exec=do_exec,
|
||||||
child_argv=list(ctx.args),
|
child_argv=list(ctx.args),
|
||||||
no_policy=no_policy,
|
no_policy=no_policy,
|
||||||
|
out_path=out_path,
|
||||||
|
wrap=wrap,
|
||||||
|
wrap_ttl=wrap_ttl,
|
||||||
|
unsafe_stdout=unsafe_stdout,
|
||||||
)
|
)
|
||||||
return
|
return
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -205,6 +205,102 @@ def proxy_fetch(resolved: ResolvedFetch) -> int:
|
||||||
return completed.returncode
|
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:
|
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.
|
"""Fetch the value and inject it into a child command's environment only.
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -287,3 +287,80 @@ def test_invalid_lane_rejected(tmp_path):
|
||||||
import pytest
|
import pytest
|
||||||
with pytest.raises(CatalogError, match="invalid lane"):
|
with pytest.raises(CatalogError, match="invalid lane"):
|
||||||
load_catalog(p)
|
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=<FIELD> <path_template>",
|
||||||
|
path_template="platform/workloads/<domain>/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()
|
||||||
|
|
|
||||||
|
|
@ -22,14 +22,21 @@ audited"). It does **not** move secret custody into ops-warden.
|
||||||
```console
|
```console
|
||||||
# advisory — works with no config; never fetches a value
|
# advisory — works with no config; never fetches a value
|
||||||
$ warden access "npm token" --domain coulomb_social
|
$ warden access "npm token" --domain coulomb_social
|
||||||
# proxy a secret read as the caller (gated + audited); value streams to stdout
|
# --- sanctioned transports (WP-0026 T02): value never hits stdout ---
|
||||||
$ warden access "npm token" --domain coulomb_social --field NPM_AUTH_TOKEN --path <p> --fetch
|
# write the value to a mode-0600 file
|
||||||
|
$ warden access "npm token" --domain coulomb_social --field NPM_AUTH_TOKEN --path <p> --fetch --out ./npm.token
|
||||||
# run a child command with the secret in its env only (à la `op run`)
|
# 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
|
$ 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 <p> --wrap # then: bao unwrap <token>
|
||||||
# interactive login (login lane): no token required, no secret-read gate
|
# interactive login (login lane): no token required, no secret-read gate
|
||||||
$ warden access "login oidc" --domain coulomb_social --fetch
|
$ 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.
|
`--json` gives a stable, secret-free shape for agentic operators.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
@ -60,7 +67,7 @@ prevent, and duplicates OpenBao.
|
||||||
| | Guardrail | How it is enforced |
|
| | 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. |
|
| **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. |
|
| **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,
|
The catalog side enforces a fourth, upstream guard: **handoff fields are templates,
|
||||||
|
|
|
||||||
|
|
@ -88,11 +88,20 @@ it (see T07).
|
||||||
|
|
||||||
```task
|
```task
|
||||||
id: WARDEN-WP-0026-T02
|
id: WARDEN-WP-0026-T02
|
||||||
status: todo
|
status: done
|
||||||
priority: high
|
priority: high
|
||||||
state_hub_task_id: "3f28c573-8e58-4851-8aa0-925f9367f266"
|
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
|
`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
|
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
|
wrapping-token transport for values that must move between processes. Record in
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue