"""The one place the platform shells out to a host binary. Nothing else in the codebase may call ``subprocess`` or ``os.system``. Every adapter that needs an external binary (docker/podman, git, caddy reload) goes through :func:`run_process`, which exists to make four failure modes impossible rather than merely unlikely: **Shell injection.** The command is an ``argv`` sequence handed straight to ``asyncio.create_subprocess_exec``. There is no ``shell=True`` path and no string interpolation into a shell, so a task id or a branch name can never become a second command. **Orphaned children.** The child starts in its own session (``start_new_session=True``), so a timeout kills the whole *process group*. A ``docker build`` that spawns helpers, or a ``git`` that runs a credential helper, dies with its parent instead of lingering as a stray process holding a lock. **Pipe deadlock.** stdout and stderr are drained concurrently by two tasks. Reading one to completion before touching the other is the classic 64 KB deadlock: the child blocks writing stderr, we block reading stdout, and both wait forever. ``communicate()`` is not used because we want a byte cap and a streaming callback. **Secret inheritance.** The child gets an explicit, minimal environment built from :func:`base_env`, never ``os.environ`` wholesale. The platform process holds the JWT secret, the database DSN and every provider key; a subprocess that inherits them can leak them through a stack trace, a build log or a core dump. Output is capped in bytes while streaming, so a runaway build cannot exhaust memory. The cap is applied *after* the callback sees the chunk, so live logs still show everything that arrived. """ from __future__ import annotations import asyncio import contextlib import os import signal import time from collections.abc import Awaitable, Callable, Mapping, Sequence from dataclasses import dataclass, field #: Environment variables a child may inherit from the platform process. Anything #: not listed is dropped. Deliberately tiny. _BASE_ENV_KEYS: tuple[str, ...] = ("PATH", "LANG", "LC_ALL", "LC_CTYPE", "TZ", "TMPDIR") #: Fallback PATH when the parent has none (systemd units often do not). _FALLBACK_PATH = "/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin" #: Read size per pipe chunk. 64 KiB is the pipe buffer size, so this drains #: promptly without spinning. _CHUNK_BYTES = 65_536 #: How long a child gets to die politely after SIGTERM before SIGKILL. _TERM_GRACE_SECONDS = 5.0 #: An argv element longer than this is almost certainly a mistake (a base64 blob #: pasted into a command line). Refused rather than silently truncated. MAX_ARGV_ELEMENT_CHARS = 100_000 class ProcessError(RuntimeError): """A host command could not be started at all (binary missing, not executable).""" @dataclass(slots=True) class ProcessResult: """Outcome of one host command. ``argv`` is retained so a test can assert on the *exact* argument vector — which is how the hardening flags and the "no credential in argv" rule are verified. """ argv: tuple[str, ...] exit_code: int stdout: str = "" stderr: str = "" duration_ms: int = 0 timed_out: bool = False truncated: bool = False #: True when the binary itself could not be executed (ENOENT/EACCES). spawn_failed: bool = False spawn_error: str = "" @property def ok(self) -> bool: return self.exit_code == 0 and not self.timed_out and not self.spawn_failed @dataclass(slots=True) class _DrainResult: data: bytes = b"" truncated: bool = False lines: list[str] = field(default_factory=list) def base_env(extra: Mapping[str, str] | None = None) -> dict[str, str]: """Minimal child environment: a few safe keys plus explicit overrides. Callers add what they need (``HOME`` for git, ``GIT_CONFIG_NOSYSTEM=1``) and nothing else is inherited. """ env = {key: os.environ[key] for key in _BASE_ENV_KEYS if key in os.environ} env.setdefault("PATH", _FALLBACK_PATH) if extra: env.update({str(k): str(v) for k, v in extra.items()}) return env def _validate_argv(argv: Sequence[str]) -> tuple[str, ...]: if not argv: raise ValueError("argv must not be empty") cleaned: list[str] = [] for index, element in enumerate(argv): if not isinstance(element, str): raise TypeError(f"argv[{index}] must be str, got {type(element).__name__}") if "\x00" in element: raise ValueError(f"argv[{index}] contains a NUL byte") if len(element) > MAX_ARGV_ELEMENT_CHARS: raise ValueError( f"argv[{index}] is {len(element)} chars; " f"the limit is {MAX_ARGV_ELEMENT_CHARS}" ) cleaned.append(element) return tuple(cleaned) async def _drain( stream: asyncio.StreamReader | None, *, cap_bytes: int, on_output: Callable[[str], Awaitable[None]] | None, ) -> _DrainResult: """Read a pipe to EOF, keeping at most ``cap_bytes`` and streaming every chunk.""" result = _DrainResult() if stream is None: return result buffer = bytearray() truncated = False while True: chunk = await stream.read(_CHUNK_BYTES) if not chunk: break if len(buffer) < cap_bytes: remaining = cap_bytes - len(buffer) buffer.extend(chunk[:remaining]) if len(chunk) > remaining: truncated = True else: truncated = True if on_output is not None: # The callback sees the *raw* chunk even past the cap: a live log # should not stop scrolling just because we stopped storing. with contextlib.suppress(Exception): await on_output(chunk.decode("utf-8", "replace")) result.data = bytes(buffer) result.truncated = truncated return result async def _terminate(proc: asyncio.subprocess.Process) -> None: """SIGTERM the child's process group, then SIGKILL if it will not go.""" if proc.returncode is not None: return def _signal_group(sig: int) -> None: # The child is a session leader (``start_new_session=True``), so its pid # *is* its process-group id. Fall back to the pid on platforms without # process groups. with contextlib.suppress(ProcessLookupError, PermissionError, OSError): if hasattr(os, "killpg"): os.killpg(proc.pid, sig) else: # pragma: no cover - POSIX is the only supported target proc.send_signal(sig) _signal_group(signal.SIGTERM) try: await asyncio.wait_for(proc.wait(), timeout=_TERM_GRACE_SECONDS) return except TimeoutError: pass _signal_group(signal.SIGKILL) with contextlib.suppress(Exception): await proc.wait() async def run_process( argv: Sequence[str], *, cwd: str | None = None, env: Mapping[str, str] | None = None, timeout_seconds: float = 120.0, max_output_bytes: int = 200_000, on_output: Callable[[str], Awaitable[None]] | None = None, stdin_data: bytes | None = None, ) -> ProcessResult: """Run ``argv`` and return its bounded, timed result. Never raises for a non-zero exit or a timeout — those are ordinary outcomes the caller inspects. It *does* raise :class:`ProcessError` when the binary cannot be started, because that is a deployment fault, not a command result. ``stdin_data`` is written and closed immediately; the platform never hands a child an interactive terminal. """ cleaned = _validate_argv(argv) started = time.perf_counter() try: proc = await asyncio.create_subprocess_exec( *cleaned, cwd=cwd, env=base_env(env), stdin=asyncio.subprocess.PIPE if stdin_data is not None else asyncio.subprocess.DEVNULL, stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.PIPE, start_new_session=True, ) except (FileNotFoundError, NotADirectoryError, PermissionError, OSError) as exc: return ProcessResult( argv=cleaned, exit_code=127, stderr=str(exc), duration_ms=int((time.perf_counter() - started) * 1000), spawn_failed=True, spawn_error=type(exc).__name__, ) if stdin_data is not None and proc.stdin is not None: with contextlib.suppress(BrokenPipeError, ConnectionResetError): proc.stdin.write(stdin_data) await proc.stdin.drain() with contextlib.suppress(Exception): proc.stdin.close() stdout_task = asyncio.create_task( _drain(proc.stdout, cap_bytes=max_output_bytes, on_output=on_output) ) stderr_task = asyncio.create_task( _drain(proc.stderr, cap_bytes=max_output_bytes, on_output=None) ) timed_out = False try: await asyncio.wait_for(proc.wait(), timeout=max(1.0, float(timeout_seconds))) except TimeoutError: timed_out = True await _terminate(proc) stdout_result, stderr_result = await asyncio.gather(stdout_task, stderr_task) return ProcessResult( argv=cleaned, exit_code=proc.returncode if proc.returncode is not None else -1, stdout=stdout_result.data.decode("utf-8", "replace"), stderr=stderr_result.data.decode("utf-8", "replace"), duration_ms=int((time.perf_counter() - started) * 1000), timed_out=timed_out, truncated=stdout_result.truncated or stderr_result.truncated, ) __all__ = [ "MAX_ARGV_ELEMENT_CHARS", "ProcessError", "ProcessResult", "base_env", "run_process", ]