fix: contain attended OpenBao login output
Assistant: codex Assistant-Model: gpt-5.6-sol Assistant-Session: 01a0290b-3241-74c3-b868-6049545af836
This commit is contained in:
parent
461f580813
commit
0fae0904ce
9 changed files with 497 additions and 75 deletions
|
|
@ -1169,13 +1169,20 @@ def _access_json(entry, expanded, gate: str, domain: Optional[str]) -> dict:
|
|||
"ops-warden holds no token."
|
||||
)
|
||||
elif expanded.exec_capable:
|
||||
verb = "fetch" if entry.lane != "login" else "login"
|
||||
payload["next_action"] = (
|
||||
f"ops-warden can proxy this {verb} as the caller: "
|
||||
f"`warden access <need> --fetch`"
|
||||
+ ("" if entry.lane == "login" else " (or `--exec -- <cmd>`)")
|
||||
+ f". Runs {entry.owner_repo}'s tool with your identity; ops-warden holds no value."
|
||||
)
|
||||
if entry.lane == "login":
|
||||
payload["next_action"] = (
|
||||
"Run the attended login and reviewed operation only inside the "
|
||||
"contained envelope: `warden access <need> --exec -- "
|
||||
"<reviewed-command>`. The private helper is preflighted, all output "
|
||||
"is suppressed, and the session is revoked and removed afterward."
|
||||
)
|
||||
else:
|
||||
payload["next_action"] = (
|
||||
"ops-warden can proxy this fetch as the caller: "
|
||||
"`warden access <need> --fetch` (or `--exec -- <cmd>`). "
|
||||
f"Runs {entry.owner_repo}'s tool with your identity; "
|
||||
"ops-warden holds no value."
|
||||
)
|
||||
else:
|
||||
payload["next_action"] = (
|
||||
f"obtain from {entry.owner_repo} ({entry.subsystem}); "
|
||||
|
|
@ -1210,6 +1217,7 @@ def _access_proxy(
|
|||
build_wrapped_fetch,
|
||||
caller_auth_present,
|
||||
proxy_exec,
|
||||
proxy_attended_login_exec,
|
||||
proxy_fetch,
|
||||
proxy_fetch_fingerprint,
|
||||
proxy_fetch_to_file,
|
||||
|
|
@ -1240,18 +1248,17 @@ def _access_proxy(
|
|||
decision_id = None
|
||||
|
||||
if is_login:
|
||||
# Login lane: interactive auth bootstrap. No caller-auth precheck (you have no
|
||||
# token yet — that's the point) and no secret-read gate (it needs an identity
|
||||
# this flow establishes). --exec is meaningless here.
|
||||
if do_exec:
|
||||
# Login lane: the authentication and reviewed child command share one
|
||||
# isolated token-helper home. No credential may persist beyond that child.
|
||||
if not do_exec or not child_argv:
|
||||
err.print(
|
||||
"[red]--exec is not valid for a login lane[/red] "
|
||||
f"({entry.id!r} is interactive auth). Use --fetch."
|
||||
"[red]A login lane requires --exec -- <reviewed-command>[/red] "
|
||||
f"({entry.id!r} cannot create a persistent login-only handoff)."
|
||||
)
|
||||
raise typer.Exit(2)
|
||||
err.print(
|
||||
"[dim]login lane — interactive auth bootstrap; no secret-read gate, "
|
||||
"token stays in the caller's own store.[/dim]"
|
||||
"[dim]login lane — contained OIDC and reviewed command; private helper, "
|
||||
"suppressed output, deterministic self-revocation.[/dim]"
|
||||
)
|
||||
else:
|
||||
if no_policy:
|
||||
|
|
@ -1343,7 +1350,9 @@ def _access_proxy(
|
|||
f"(caller identity; value not persisted)[/dim]"
|
||||
)
|
||||
try:
|
||||
if do_exec:
|
||||
if is_login:
|
||||
rc = proxy_attended_login_exec(resolved, child_argv=child_argv)
|
||||
elif do_exec:
|
||||
if not child_argv:
|
||||
err.print("[red]--exec needs a command after `--`[/red], e.g. `-- npm publish`.")
|
||||
raise typer.Exit(2)
|
||||
|
|
@ -1524,8 +1533,13 @@ def access(
|
|||
console.print(f" auth : {expanded.auth_method}")
|
||||
if expanded.path_template:
|
||||
console.print(f" path : {expanded.path_template}")
|
||||
if expanded.fetch_command:
|
||||
if expanded.fetch_command and entry.lane != "login":
|
||||
console.print(f" fetch : {expanded.fetch_command}")
|
||||
elif expanded.fetch_command:
|
||||
console.print(
|
||||
" login : [dim]internal to the contained --exec envelope; "
|
||||
"do not invoke separately[/dim]"
|
||||
)
|
||||
if expanded.policy_ref:
|
||||
console.print(f" policy : {expanded.policy_ref} [dim]({gate})[/dim]")
|
||||
console.print(f" wiki : {entry.wiki_ref}")
|
||||
|
|
@ -1544,12 +1558,16 @@ def access(
|
|||
console.print(f" pointer : [dim]{entry.pointer_command}[/dim]")
|
||||
if expanded.exec_capable:
|
||||
label = "fallback" if entry.has_native_exec else "proxy"
|
||||
hint = (
|
||||
"transparent conduit — fetches as you"
|
||||
if entry.lane != "login"
|
||||
else "runs the interactive login as you"
|
||||
)
|
||||
console.print(f" {label:<8} : [dim]{proxy} --fetch[/dim] [yellow]({hint})[/yellow]")
|
||||
if entry.lane == "login":
|
||||
console.print(
|
||||
f" {label:<8} : [dim]{proxy} --exec -- <reviewed-command>[/dim] "
|
||||
"[yellow](contained login + command; output suppressed)[/yellow]"
|
||||
)
|
||||
else:
|
||||
console.print(
|
||||
f" {label:<8} : [dim]{proxy} --fetch[/dim] "
|
||||
"[yellow](transparent conduit — fetches as you)[/yellow]"
|
||||
)
|
||||
if expanded.path_template and "<" in expanded.path_template:
|
||||
console.print(
|
||||
" note : remaining <…> placeholders are owner-confirmed names "
|
||||
|
|
@ -1564,14 +1582,21 @@ def access(
|
|||
"conduit (runs the fetch as you, holds nothing)."
|
||||
)
|
||||
elif expanded.exec_capable:
|
||||
verb = "fetch this for you" if entry.lane != "login" else "run this login for you"
|
||||
console.print(
|
||||
f"\n[green]ops-warden can {verb}[/green] as the caller — "
|
||||
f"[bold]{proxy} --fetch[/bold]"
|
||||
+ ("" if entry.lane == "login" else f" (or [bold]{proxy} --exec -- <cmd>[/bold])")
|
||||
+ f". It runs {entry.owner_repo}'s tool with [bold]your[/bold] identity; the "
|
||||
"value streams to you and ops-warden never holds, caches, or logs it."
|
||||
)
|
||||
if entry.lane == "login":
|
||||
console.print(
|
||||
"\n[green]Contained attended login[/green] — "
|
||||
f"[bold]{proxy} --exec -- <reviewed-command>[/bold]. The login, "
|
||||
"command, and revocation use a private helper with suppressed output; "
|
||||
"the session is removed afterward."
|
||||
)
|
||||
else:
|
||||
console.print(
|
||||
"\n[green]ops-warden can fetch this for you[/green] as the caller — "
|
||||
f"[bold]{proxy} --fetch[/bold] (or "
|
||||
f"[bold]{proxy} --exec -- <cmd>[/bold]). It runs "
|
||||
f"{entry.owner_repo}'s tool with [bold]your[/bold] identity; the "
|
||||
"value streams to you and ops-warden never holds, caches, or logs it."
|
||||
)
|
||||
else:
|
||||
console.print(
|
||||
f"\n[yellow]ops-warden does not hold this secret.[/yellow] "
|
||||
|
|
|
|||
|
|
@ -142,9 +142,13 @@ def _autonomous_commands(entry: RouteEntry, domain: Optional[str]) -> List[str]:
|
|||
|
||||
expanded = expand_handoff(entry, domain=domain)
|
||||
if entry.lane == "login":
|
||||
cmds.append(f"warden access {entry.id} --fetch")
|
||||
cmds.append(
|
||||
f"warden access {entry.id} --exec -- <reviewed-command>"
|
||||
)
|
||||
if expanded.fetch_command:
|
||||
cmds.append(f"# attended owner login: {expanded.fetch_command}")
|
||||
cmds.append(
|
||||
"# owner login is contained by warden; do not invoke it separately"
|
||||
)
|
||||
return cmds
|
||||
if entry.has_native_exec and entry.exec_command:
|
||||
cmds.append(entry.exec_command)
|
||||
|
|
@ -175,14 +179,20 @@ def _autonomous_commands(entry: RouteEntry, domain: Optional[str]) -> List[str]:
|
|||
def _founder_for_entry(entry: RouteEntry, need: str, domain: Optional[str]) -> FounderAct:
|
||||
expanded = expand_handoff(entry, domain=domain)
|
||||
if entry.lane == "login":
|
||||
contained_command = (
|
||||
f"warden access {entry.id} --exec -- <reviewed-command>"
|
||||
)
|
||||
return FounderAct(
|
||||
kind="oidc_login",
|
||||
summary=f"Interactive OIDC/MFA login via {entry.owner_repo}",
|
||||
details={
|
||||
"lane_id": entry.id,
|
||||
"auth_method": expanded.auth_method,
|
||||
"fetch_command": expanded.fetch_command,
|
||||
"desk_hint": f"warden desk --from-plan (act=oidc_login) or: {expanded.fetch_command}",
|
||||
"fetch_command": contained_command,
|
||||
"desk_hint": (
|
||||
"warden desk --from-plan (act=oidc_login); execute only through: "
|
||||
+ contained_command
|
||||
),
|
||||
},
|
||||
)
|
||||
if entry.lane == "ceremony":
|
||||
|
|
|
|||
|
|
@ -8,11 +8,12 @@ intact. Three guardrails are enforced here in code:
|
|||
caller's own environment. ops-warden injects no token of its own; if the caller has
|
||||
no credential, the underlying tool fails and we surface the auth pointer. We never
|
||||
add a `*_TOKEN` warden owns to the child environment.
|
||||
* **G2 — transit only, no persistence/logging of values.** ``proxy_fetch`` runs the
|
||||
tool with **inherited** stdout/stderr (never a pipe), so the value streams to the
|
||||
caller and never enters warden's memory. ``proxy_exec`` reads the value solely to
|
||||
place it in a child process's environment (the accepted proxy tradeoff) and never
|
||||
writes it to disk or log. The audit record is metadata only.
|
||||
* **G2 — bounded transports, no logging of values.** Ordinary ``proxy_fetch`` runs
|
||||
the tool with inherited stdout/stderr so the value never enters warden's memory;
|
||||
sanctioned exec/file transports hold it only for their bounded handoff. The
|
||||
high-risk attended-login lane is stricter: it captures every client byte inside
|
||||
an isolated helper session, permits no output, self-revokes, and cleans up. Audit
|
||||
records are metadata only.
|
||||
* **G3 — policy gate before fetch.** The CLI runs ``check_fetch_policy`` before
|
||||
calling anything here; this module refuses to run an unresolved command template.
|
||||
|
||||
|
|
@ -24,7 +25,10 @@ import json
|
|||
import os
|
||||
import re
|
||||
import shlex
|
||||
import shutil
|
||||
import stat
|
||||
import subprocess
|
||||
import tempfile
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
|
|
@ -33,6 +37,9 @@ from typing import List, Optional
|
|||
from warden.routing.models import RouteEntry
|
||||
|
||||
_PLACEHOLDER = re.compile(r"<[^>]+>")
|
||||
_OPENBAO_TOKEN = re.compile(rb"\b(?:hvs|hvb|hvr)\.[A-Za-z0-9_-]{8,}\b")
|
||||
_ATTENDED_LOGIN_ROOT = ".warden-attended-login"
|
||||
_TOKEN_HELPER_NAME = ".vault-token"
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
|
|
@ -205,6 +212,267 @@ def proxy_fetch(resolved: ResolvedFetch) -> int:
|
|||
return completed.returncode
|
||||
|
||||
|
||||
def _assert_owned_mode(path: Path, *, mode: int, directory: bool) -> None:
|
||||
"""Require a caller-owned, non-symlink path with an exact private mode."""
|
||||
info = path.lstat()
|
||||
expected_type = stat.S_ISDIR if directory else stat.S_ISREG
|
||||
if not expected_type(info.st_mode) or stat.S_ISLNK(info.st_mode):
|
||||
raise ProxyError("attended login private storage has an unsafe path type")
|
||||
if hasattr(os, "getuid") and info.st_uid != os.getuid():
|
||||
raise ProxyError("attended login private storage is not caller-owned")
|
||||
if stat.S_IMODE(info.st_mode) != mode:
|
||||
raise ProxyError("attended login private storage has an unsafe mode")
|
||||
|
||||
|
||||
def _prepare_attended_login_home() -> tuple[Path, Path, bool]:
|
||||
"""Create and prove an isolated token-helper home before authentication."""
|
||||
home = Path.home()
|
||||
try:
|
||||
home_info = home.lstat()
|
||||
except OSError as exc:
|
||||
raise ProxyError(
|
||||
"attended login requires a usable writable default home before OIDC"
|
||||
) from exc
|
||||
if (
|
||||
not stat.S_ISDIR(home_info.st_mode)
|
||||
or stat.S_ISLNK(home_info.st_mode)
|
||||
or stat.S_IMODE(home_info.st_mode) & 0o222 == 0
|
||||
):
|
||||
raise ProxyError(
|
||||
"attended login requires a usable writable default home before OIDC"
|
||||
)
|
||||
|
||||
# Prove the default home itself is writable. A pre-existing writable child must
|
||||
# not let a newly read-only HOME reach the OIDC process.
|
||||
probe_fd = -1
|
||||
probe_path: Path | None = None
|
||||
probe_cleanup_error: OSError | None = None
|
||||
try:
|
||||
probe_fd, probe_name = tempfile.mkstemp(prefix=".warden-home-probe-", dir=home)
|
||||
probe_path = Path(probe_name)
|
||||
os.write(probe_fd, b"preflight")
|
||||
os.fsync(probe_fd)
|
||||
except OSError as exc:
|
||||
raise ProxyError(
|
||||
"attended login requires a usable writable default home before OIDC"
|
||||
) from exc
|
||||
finally:
|
||||
if probe_fd >= 0:
|
||||
os.close(probe_fd)
|
||||
if probe_path is not None:
|
||||
try:
|
||||
probe_path.unlink()
|
||||
except OSError as exc:
|
||||
probe_cleanup_error = exc
|
||||
if probe_cleanup_error is not None:
|
||||
raise ProxyError("attended login home preflight cleanup failed") from probe_cleanup_error
|
||||
|
||||
root = home / _ATTENDED_LOGIN_ROOT
|
||||
root_created = False
|
||||
try:
|
||||
root.mkdir(mode=0o700)
|
||||
root_created = True
|
||||
except FileExistsError:
|
||||
pass
|
||||
except OSError as exc:
|
||||
raise ProxyError("could not establish attended login private storage") from exc
|
||||
_assert_owned_mode(root, mode=0o700, directory=True)
|
||||
|
||||
try:
|
||||
session = Path(tempfile.mkdtemp(prefix="session-", dir=root))
|
||||
session.chmod(0o700)
|
||||
_assert_owned_mode(session, mode=0o700, directory=True)
|
||||
helper = session / _TOKEN_HELPER_NAME
|
||||
flags = os.O_WRONLY | os.O_CREAT | os.O_EXCL
|
||||
if hasattr(os, "O_NOFOLLOW"):
|
||||
flags |= os.O_NOFOLLOW
|
||||
fd = os.open(helper, flags, 0o600)
|
||||
try:
|
||||
# Exercise persistence before auth, then leave the helper empty for bao.
|
||||
os.write(fd, b"preflight")
|
||||
os.fsync(fd)
|
||||
os.ftruncate(fd, 0)
|
||||
finally:
|
||||
os.close(fd)
|
||||
_assert_owned_mode(helper, mode=0o600, directory=False)
|
||||
except (OSError, ProxyError) as exc:
|
||||
if "session" in locals():
|
||||
shutil.rmtree(session, ignore_errors=True)
|
||||
if root_created:
|
||||
try:
|
||||
root.rmdir()
|
||||
except OSError:
|
||||
pass
|
||||
if isinstance(exc, ProxyError):
|
||||
raise
|
||||
raise ProxyError("could not establish attended login private storage") from exc
|
||||
return root, session, root_created
|
||||
|
||||
|
||||
def _contained_run(argv: List[str], *, env: dict) -> subprocess.CompletedProcess:
|
||||
"""Run with both output streams captured and never forwarded."""
|
||||
return subprocess.run( # noqa: S603
|
||||
argv,
|
||||
stdout=subprocess.PIPE,
|
||||
stderr=subprocess.PIPE,
|
||||
stdin=None,
|
||||
env=env,
|
||||
check=False,
|
||||
)
|
||||
|
||||
|
||||
def _output_bytes(completed: subprocess.CompletedProcess) -> bytes:
|
||||
stdout = completed.stdout
|
||||
stderr = completed.stderr
|
||||
if isinstance(stdout, str):
|
||||
stdout = stdout.encode("utf-8", errors="replace")
|
||||
if isinstance(stderr, str):
|
||||
stderr = stderr.encode("utf-8", errors="replace")
|
||||
stdout = stdout if isinstance(stdout, bytes) else b""
|
||||
stderr = stderr if isinstance(stderr, bytes) else b""
|
||||
return stdout + b"\n" + stderr
|
||||
|
||||
|
||||
def _revoke_contained(
|
||||
bao_binary: str,
|
||||
*,
|
||||
env: dict,
|
||||
possible_output: bytes,
|
||||
) -> bool:
|
||||
"""Attempt self-revocation without exposing helper or captured output."""
|
||||
revoke_env = dict(env)
|
||||
try:
|
||||
first = _contained_run(
|
||||
[bao_binary, "token", "revoke", "-self"], env=revoke_env
|
||||
)
|
||||
except OSError:
|
||||
return False
|
||||
if first.returncode == 0:
|
||||
return True
|
||||
|
||||
# A helper-persistence failure can leave the issued token only in contained
|
||||
# client output. Use it solely for immediate self-revocation, never for a log,
|
||||
# return value, hash, fingerprint, file, or argv.
|
||||
match = _OPENBAO_TOKEN.search(possible_output)
|
||||
if match is None:
|
||||
return False
|
||||
token = match.group(0).decode("ascii")
|
||||
revoke_env["BAO_TOKEN"] = token
|
||||
revoke_env.pop("VAULT_TOKEN", None)
|
||||
try:
|
||||
try:
|
||||
second = _contained_run(
|
||||
[bao_binary, "token", "revoke", "-self"], env=revoke_env
|
||||
)
|
||||
except OSError:
|
||||
return False
|
||||
return second.returncode == 0
|
||||
finally:
|
||||
revoke_env.pop("BAO_TOKEN", None)
|
||||
token = "" # noqa: F841 - best-effort release of the credential reference
|
||||
|
||||
|
||||
def proxy_attended_login_exec(
|
||||
resolved: ResolvedFetch,
|
||||
*,
|
||||
child_argv: List[str],
|
||||
) -> int:
|
||||
"""Run an attended login and one silent child inside a private helper home.
|
||||
|
||||
The default home is proven writable before the OIDC client starts. Login,
|
||||
child, and revocation output are captured and discarded. Any non-empty output,
|
||||
persistence defect, or non-zero result fails closed; any possibly issued token
|
||||
is revoked before the isolated helper directory is removed.
|
||||
"""
|
||||
if not child_argv:
|
||||
raise ProxyError(
|
||||
"attended login requires --exec -- <reviewed-command>; a persistent "
|
||||
"login-only handoff is not permitted"
|
||||
)
|
||||
if (
|
||||
resolved.argv is None
|
||||
or len(resolved.argv) < 2
|
||||
or Path(resolved.argv[0]).name != "bao"
|
||||
or resolved.argv[1] != "login"
|
||||
):
|
||||
raise ProxyError("attended login requires a direct bao login argv")
|
||||
|
||||
root, session, root_created = _prepare_attended_login_home()
|
||||
helper = session / _TOKEN_HELPER_NAME
|
||||
env = _caller_env()
|
||||
env["HOME"] = str(session)
|
||||
env.pop("BAO_TOKEN", None)
|
||||
env.pop("VAULT_TOKEN", None)
|
||||
for key in (
|
||||
"BAO_LOG_LEVEL",
|
||||
"BAO_LOG_FORMAT",
|
||||
"VAULT_LOG_LEVEL",
|
||||
"VAULT_LOG_FORMAT",
|
||||
):
|
||||
env.pop(key, None)
|
||||
|
||||
login_argv = list(resolved.argv)
|
||||
if not any(arg == "-format" or arg.startswith("-format=") for arg in login_argv):
|
||||
login_argv.append("-format=json")
|
||||
|
||||
try:
|
||||
try:
|
||||
login = _contained_run(login_argv, env=env)
|
||||
except OSError as exc:
|
||||
raise ProxyError("attended login client could not start before OIDC") from exc
|
||||
login_output = _output_bytes(login)
|
||||
helper_valid = False
|
||||
try:
|
||||
_assert_owned_mode(helper, mode=0o600, directory=False)
|
||||
helper_valid = helper.stat().st_size > 0
|
||||
except (OSError, ProxyError):
|
||||
helper_valid = False
|
||||
|
||||
if login.returncode != 0 or login_output.strip() or not helper_valid:
|
||||
revoked = _revoke_contained(
|
||||
resolved.argv[0], env=env, possible_output=login_output
|
||||
)
|
||||
status = "revoked" if revoked else "revocation could not be confirmed"
|
||||
raise ProxyError(
|
||||
"attended login failed closed before command handoff; any possible "
|
||||
f"issued session was contained and {status}"
|
||||
)
|
||||
|
||||
try:
|
||||
child = _contained_run(child_argv, env=env)
|
||||
except OSError as exc:
|
||||
revoked = _revoke_contained(
|
||||
resolved.argv[0], env=env, possible_output=b""
|
||||
)
|
||||
status = "revoked" if revoked else "revocation could not be confirmed"
|
||||
raise ProxyError(
|
||||
"attended command could not start; the login session was " + status
|
||||
) from exc
|
||||
|
||||
child_output = _output_bytes(child)
|
||||
revoked = _revoke_contained(
|
||||
resolved.argv[0], env=env, possible_output=child_output
|
||||
)
|
||||
if child.returncode != 0 or child_output.strip():
|
||||
status = "revoked" if revoked else "revocation could not be confirmed"
|
||||
raise ProxyError(
|
||||
"attended command failed closed because it returned a failure or "
|
||||
f"unexpected output; the login session was {status}"
|
||||
)
|
||||
if not revoked:
|
||||
raise ProxyError(
|
||||
"attended command completed but session revocation could not be confirmed"
|
||||
)
|
||||
return 0
|
||||
finally:
|
||||
try:
|
||||
shutil.rmtree(session)
|
||||
if root_created:
|
||||
root.rmdir()
|
||||
except OSError as exc:
|
||||
raise ProxyError("attended login private storage cleanup failed") from exc
|
||||
|
||||
|
||||
def _capture_value(resolved: ResolvedFetch) -> str:
|
||||
"""Run the fetch and return its stdout (the value) minus one trailing newline.
|
||||
|
||||
|
|
|
|||
|
|
@ -588,7 +588,13 @@ def draft_route_answer(query: str) -> str:
|
|||
elif e.has_native_exec:
|
||||
parts.append(f"Primary: {e.exec_command}.")
|
||||
elif e.exec_capable:
|
||||
parts.append(f"Proxy: warden access {e.id} --fetch (as the caller).")
|
||||
if e.lane == "login":
|
||||
parts.append(
|
||||
f"Contained login: warden access {e.id} --exec -- "
|
||||
"<reviewed-command>."
|
||||
)
|
||||
else:
|
||||
parts.append(f"Proxy: warden access {e.id} --fetch (as the caller).")
|
||||
parts.append(f"See {e.wiki_ref}.")
|
||||
return " ".join(parts)
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue