"""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