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
|
|
@ -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.
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue