Files
me be87900794 agent-credential-broker: fork of vaultwarden-agent-broker + OpenBao backend
Mechanical rename (package agent_credential_broker; entry points
agent-broker{,-mcp,-plugin}; env AGENT_BROKER_*; paths agent-broker;
agent-browser credential provider 'broker'; MCP server name agent-broker).

New backend 'openbao': stdlib-only HTTP client for OpenBao/Vault KV v2
(approle login or token file/env; metadata-only ref index; lazy re-login
on 401/403; token renewal). bitwarden-cli backend kept as transition.
2026-10-14 13:27:33 -06:00

118 lines
3.7 KiB
Python

"""Shared helpers for adapters: bounded subprocesses and askpass scripts.
Secret-bearing helper files are created only under
$XDG_RUNTIME_DIR/agent-broker/tmp (tmpfs, 0700 dir, 0600
files) and deleted immediately after use. Secrets never appear on argv.
"""
from __future__ import annotations
import os
import secrets as _secrets
import subprocess
from contextlib import contextmanager
from pathlib import Path
from ..config import tmp_dir
from ..sanitize import sanitize_text, sanitize_bytes
def make_secret_dir() -> Path:
d = tmp_dir() / _secrets.token_hex(12)
d.mkdir(mode=0o700)
return d
def write_secret_file(path: Path, value: str):
fd = os.open(path, os.O_WRONLY | os.O_CREAT | os.O_EXCL, 0o600)
with os.fdopen(fd, "w") as fh:
fh.write(value)
def write_askpass(dirpath: Path, user_file: Path | None, pass_file: Path) -> Path:
"""askpass script reads values from sibling files -- the secret is
never inside the script itself or on any argv."""
script = dirpath / "askpass.sh"
lines = [
"#!/bin/sh",
"case \"$1\" in",
]
if user_file is not None:
lines += [
" *sername*|*ogin*) cat %s ;;",
f" *) cat {pass_file} ;;",
]
else:
lines.append(f" *) cat {pass_file} ;;")
lines.append("esac")
if user_file is not None:
lines[3] = lines[3] % user_file
script.write_text("\n".join(lines) + "\n")
os.chmod(script, 0o700)
return script
def run_subprocess(argv: list[str], *, env: dict | None = None, input_text: str | None = None,
timeout: int = 60, secrets: list[str] = ()) -> dict:
"""Run argv without a shell. Returns sanitized {exit_code, stdout,
stderr}. Never raises the child's output raw."""
try:
proc = subprocess.run(
argv,
env=env,
input=input_text,
capture_output=True,
text=True,
timeout=timeout,
)
rc, raw_out, raw_err = proc.returncode, proc.stdout, proc.stderr
timed_out = False
except subprocess.TimeoutExpired as e:
rc, timed_out = 124, True
raw_out = (e.stdout or b"").decode("utf-8", "replace") if isinstance(e.stdout, bytes) else (e.stdout or "")
raw_err = ((e.stderr or b"").decode("utf-8", "replace") if isinstance(e.stderr, bytes) else (e.stderr or "")) + \
f"\n[timeout after {timeout}s]"
out = sanitize_bytes(raw_out.encode(), [s.encode() for s in secrets if s])
err = sanitize_bytes(raw_err.encode(), [s.encode() for s in secrets if s])
result = {
"exit_code": rc,
"stdout": sanitize_text(out.decode("utf-8", "replace"), secrets),
"stderr": sanitize_text(err.decode("utf-8", "replace"), secrets),
}
if timed_out:
result["timed_out"] = True
return result
@contextmanager
def secret_dir():
d = make_secret_dir()
try:
yield d
finally:
for child in d.iterdir():
try:
child.unlink()
except OSError:
pass
try:
d.rmdir()
except OSError:
pass
def clean_env(extra: dict | None = None, prepend_paths: list[str] = ()) -> dict:
"""Minimal environment for secret-bearing children: no inherited
credential variables are added beyond what the adapter explicitly
injects. prepend_paths lists directories that must precede the base
PATH (e.g. the dir of a resolved executable whose shebang needs a
specific interpreter)."""
env = {
"PATH": ":".join([*prepend_paths, "/usr/local/bin", "/usr/bin", "/bin"]),
"HOME": os.environ.get("HOME", "/"),
"LANG": "C.UTF-8",
}
if extra:
env.update(extra)
return env