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.
118 lines
3.7 KiB
Python
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
|