diff --git a/agent/proxy_sources/__init__.py b/agent/proxy_sources/__init__.py index 34af31ca6f..dd83ee3148 100644 --- a/agent/proxy_sources/__init__.py +++ b/agent/proxy_sources/__init__.py @@ -1,8 +1 @@ -"""Egress proxy integrations. - -Currently ships an iron-proxy (ironsh/iron-proxy) wrapper that intercepts -outbound traffic from remote terminal sandboxes and swaps proxy tokens -for real upstream credentials at the network edge. - -Design notes live in :mod:`agent.proxy_sources.iron_proxy`. -""" +"""Egress proxy integrations — see :mod:`agent.proxy_sources.iron_proxy` (ironsh/iron-proxy wrapper).""" diff --git a/agent/proxy_sources/iron_proxy.py b/agent/proxy_sources/iron_proxy.py index 277cd01865..59d52305f8 100644 --- a/agent/proxy_sources/iron_proxy.py +++ b/agent/proxy_sources/iron_proxy.py @@ -1,58 +1,10 @@ -"""iron-proxy (`ironsh/iron-proxy`) integration for credential-injecting egress control. +"""iron-proxy (``ironsh/iron-proxy``) integration for credential-injecting egress control. -Why ---- - -Remote terminal sandboxes (Docker, Modal, SSH) currently see real upstream -API credentials. A prompt-injected agent inside one of these sandboxes can -``cat ~/.config/openrouter/auth.json`` or ``printenv | grep -i key`` and -exfiltrate them. - -iron-proxy is a TLS-intercepting egress firewall (Apache-2.0, Go binary, by -ironsh). It sits between the sandbox and the internet, enforces a default-deny -allowlist on outbound hosts, and *swaps proxy tokens for real credentials* -on the way out. The sandbox only ever holds opaque proxy tokens — leaking -them is useless, since they only work behind the configured trusted proxy -boundary (the CA private key and proxy endpoint integrity are part of that -boundary: if traffic can be redirected to attacker-controlled proxy -infrastructure, the guarantee no longer holds). - -Design summary --------------- - -* The ``iron-proxy`` binary is auto-installed into ``/bin/iron-proxy`` - on first use. Hermes pins one upstream version (``_IRON_PROXY_VERSION``) - and downloads the matching tar.gz from the official GitHub Releases page, - verifying the SHA-256 against the release's ``checksums.txt``. - -* A long-lived CA at ``/proxy/ca.{crt,key}`` is generated on - first ``hermes egress setup``. Sandboxes trust this CA so iron-proxy can - terminate TLS and rewrite headers. - -* The proxy config lives at ``/proxy/proxy.yaml``. It enumerates - the per-provider allowlists and the ``secrets`` transform that does the - Authorization-header swap. - -* Token mappings (proxy token -> real credential lookup) live alongside the - config. The real credential is **never** written to the config — iron-proxy - reads it from its own environment via ``{type: env, var: NAME}``. When - Bitwarden Secrets Manager is configured, the real value is pulled there - at proxy startup instead. - -* The proxy runs as a managed subprocess (``hermes egress start``), pidfile - at ``/proxy/iron-proxy.pid``. Daemon output (including - per-request records on v0.39) goes to ``/proxy/iron-proxy.log``; - ``audit.log`` is pre-created but reserved for a future pin that supports - ``log.audit_path``. - -* Failures (binary missing, port collision, bad config) emit a one-line - warning and do *not* block agent startup. The Docker backend refuses to - start a sandbox with the proxy enabled-but-down, with a clear error. - -This module is intentionally subprocess-driven rather than depending on any -iron-proxy Python bindings — a single cross-platform binary is easier to -lazy-install than a wheels-with-extension dependency, and we keep maintenance -to a "bump the pinned version" loop. +Sandboxes (Docker/Modal/SSH) hold only opaque proxy tokens; iron-proxy — a TLS-intercepting, +default-deny egress firewall — swaps them for real credentials on the way out, so a leaked +token is useless outside the trusted proxy boundary. The pinned binary is auto-installed +into ``/bin``; CA, ``proxy.yaml``, ``mappings.json``, pidfile and logs live in +``/proxy``. Failures warn and never block agent startup. """ from __future__ import annotations @@ -65,7 +17,6 @@ import os import platform import shutil import signal -import stat import subprocess import tarfile import tempfile @@ -73,78 +24,46 @@ import threading import time import urllib.error import urllib.request -from dataclasses import dataclass, field +from dataclasses import dataclass, field, replace from pathlib import Path from typing import Dict, List, Optional, Tuple logger = logging.getLogger(__name__) - -# --------------------------------------------------------------------------- -# Configuration constants -# --------------------------------------------------------------------------- - -# Pinned upstream version. Bump in a follow-up PR — never auto-resolve "latest" -# because upstream YAML schema is allowed to change between releases and we -# want updates to be deliberate. +# Pinned upstream version. Never auto-resolve "latest": the YAML schema may change between +# releases and updates must be deliberate. _IRON_PROXY_VERSION = "0.39.0" -_IRON_PROXY_RELEASE_BASE = ( - f"https://github.com/ironsh/iron-proxy/releases/download/v{_IRON_PROXY_VERSION}" -) +_IRON_PROXY_RELEASE_BASE = f"https://github.com/ironsh/iron-proxy/releases/download/v{_IRON_PROXY_VERSION}" _IRON_PROXY_CHECKSUM_NAME = "checksums.txt" -# Detached signature for checksums.txt + the signing public key, both shipped on -# the release. Used for optional GPG verification of the release channel -# (maxpetrusenko P1): SHA-256 only protects the archive if checksums.txt itself -# came from an uncompromised channel; verifying its signature closes that gap. +# Detached signature + signing key for optional GPG verification of checksums.txt (SHA-256 +# alone only protects the archive if checksums.txt itself came from an untampered channel). _IRON_PROXY_CHECKSUM_SIG_NAME = "checksums.txt.asc" _IRON_PROXY_PUBKEY_NAME = "public-key.asc" -# How long to wait for HTTP downloads and subprocess interactions, in seconds. _DOWNLOAD_TIMEOUT = 120 # binary is ~16MB _RUN_TIMEOUT = 30 _STARTUP_GRACE_SECONDS = 5 -# Management (operator) API. iron-proxy v0.39 ships an authenticated -# loopback HTTP endpoint (``management.listen`` + ``management.api_key_env``) -# whose ``POST /v1/reload`` re-reads proxy.yaml and atomically swaps the -# transform pipeline in-place — no restart, no dropped connections. We -# always enable it on generated configs: it binds loopback only and every -# request needs the bearer key below. ``hermes egress reload`` is the -# client. -# -# The key is minted at setup time, stored at -# ``/proxy/management.token`` (0600), and injected into the -# daemon's env under this name at start. v0.39 validates at startup that -# the named env var is non-empty when management.listen is set. +# Management API (v0.39): authenticated loopback listener whose POST /v1/reload hot-swaps the +# ruleset. Bearer key minted at setup, stored 0600 at /management.token, injected into +# the daemon env under this name; v0.39 refuses to start if the named var is empty. _MGMT_API_KEY_ENV = "HERMES_IRON_PROXY_MGMT_KEY" -# The management listener binds loopback at tunnel_port + 2 (tunnel_port -# is CONNECT/MITM, +1 is the plain-HTTP forward listener). +# tunnel_port is CONNECT/MITM, +1 is plain-HTTP forward, +2 is management. _MGMT_PORT_OFFSET = 2 _MGMT_RELOAD_TIMEOUT = 15 -# Default listen ports. HTTPS_PROXY semantics use a single CONNECT tunnel, -# so we expose only the tunnel listener for v1 — no need to put the sandbox -# DNS at the iron-proxy IP. This greatly simplifies wiring. +# HTTPS_PROXY semantics use a single CONNECT tunnel, so only the tunnel listener is exposed. _DEFAULT_TUNNEL_PORT = 9090 # Hosts allowed by default for AI inference traffic. Anything else is 403'd. _DEFAULT_ALLOWED_HOSTS: Tuple[str, ...] = ( - "openrouter.ai", - "*.openrouter.ai", - "api.openai.com", - "api.anthropic.com", - "generativelanguage.googleapis.com", - "api.x.ai", - "api.mistral.ai", - "api.groq.com", - "api.together.xyz", - "api.deepseek.com", - "inference.nousresearch.com", + "openrouter.ai", "*.openrouter.ai", "api.openai.com", "api.anthropic.com", + "generativelanguage.googleapis.com", "api.x.ai", "api.mistral.ai", "api.groq.com", + "api.together.xyz", "api.deepseek.com", "inference.nousresearch.com", ) -# Provider env-var name -> upstream host (or list of hosts) on which the -# Authorization Bearer token should be swapped. +# Provider env-var name -> upstream hosts on which the Authorization Bearer token is swapped. _BEARER_PROVIDERS: Dict[str, Tuple[str, ...]] = { "OPENROUTER_API_KEY": ("openrouter.ai", "*.openrouter.ai"), "OPENAI_API_KEY": ("api.openai.com",), @@ -156,42 +75,23 @@ _BEARER_PROVIDERS: Dict[str, Tuple[str, ...]] = { "NOUS_API_KEY": ("inference.nousresearch.com",), } - -# Providers whose API authenticates with a NON-Authorization header. -# iron-proxy v0.39's ``secrets.replace.match_headers`` targets arbitrary -# header names (case-insensitive; confirmed by the iron-proxy author on -# PR #30179 and verified in the pinned v0.39.0 source — ``swapHeaders`` -# + ``parseHeaderMatchers``), so these are first-class swapped providers, -# not "uncovered". -# -# ``aliases`` are interchangeable env-var names for the SAME upstream -# credential (Hermes' auth.py keys Google on both GEMINI_API_KEY and -# GOOGLE_API_KEY). Aliased names MUST collapse into a single mapping: -# every rule carries ``require: true``, and two require-rules on the same -# host reject each other's requests (each rule whose own token isn't -# present returns ActionReject). The sandbox receives the minted token -# under the canonical name AND every alias so SDKs reading either work. +# Providers authenticating with a non-Authorization header (v0.39 ``match_headers`` is +# case-insensitive). ``aliases`` are interchangeable env names for the SAME credential and +# MUST collapse into one mapping: every rule is ``require: true`` and two require-rules on one +# host would reject each other's requests. The sandbox gets the token under every name. _HEADER_AUTH_PROVIDERS: Dict[str, Dict[str, Tuple[str, ...]]] = { - # Anthropic native: x-api-key. Authorization is also matched so an - # SDK sending the token as a Bearer (OAuth-style) still swaps. + # Authorization also matched so an SDK sending the token as Bearer still swaps. "ANTHROPIC_API_KEY": { "hosts": ("api.anthropic.com",), "match_headers": ("x-api-key", "Authorization"), "aliases": (), }, - # Azure OpenAI: api-key header (AAD bearer flows use Authorization). "AZURE_OPENAI_API_KEY": { - "hosts": ( - "*.openai.azure.com", - "*.cognitiveservices.azure.com", - "*.services.ai.azure.com", - ), + "hosts": ("*.openai.azure.com", "*.cognitiveservices.azure.com", "*.services.ai.azure.com"), "match_headers": ("api-key", "Authorization"), "aliases": (), }, - # Google AI Studio (Gemini): x-goog-api-key header; the SDKs that pass - # ``?key=`` as a query param are covered by match_query, which - # scans every query parameter for the token value. + # ``?key=`` SDK style is covered by match_query. "GEMINI_API_KEY": { "hosts": ("generativelanguage.googleapis.com",), "match_headers": ("x-goog-api-key",), @@ -199,34 +99,14 @@ _HEADER_AUTH_PROVIDERS: Dict[str, Dict[str, Tuple[str, ...]]] = { }, } - -# Providers whose env-var names we recognize but whose auth genuinely cannot -# be swapped by a static header/query replacement (SigV4 request signing, -# OAuth tokens minted by an SDK from a service-account file). Presence is -# surfaced as a warning at setup/status time — these are generic cloud creds -# that are usually present for unrelated tooling (terraform, gcloud, aws-cli), -# so they never block the proxy from starting. -# -# NOTE: this list used to include Anthropic / Azure OpenAI / Gemini, with an -# LLM-specific fail-closed tier (``proxy.fail_on_uncovered_providers``). -# Those providers moved to ``_HEADER_AUTH_PROVIDERS`` once we wired -# ``match_headers`` (upstream confirmed support on the pinned v0.39.0), which -# emptied the fail-closed tier — the flag and its refuse-start path were -# deleted rather than kept as a dead toggle. +# Recognized creds that cannot be swapped by static header replacement (SigV4 signing, +# SDK-minted OAuth). Surfaced as a warning only — they're generic cloud creds, never blocking. _NON_BEARER_PROVIDERS: Tuple[str, ...] = ( - # AWS Bedrock / SageMaker: SigV4-signed requests. - "AWS_ACCESS_KEY_ID", - "AWS_SECRET_ACCESS_KEY", - # GCP Vertex AI: OAuth bearer minted by the SDK from a service-account - # file, not a static env key. - "GOOGLE_APPLICATION_CREDENTIALS", + "AWS_ACCESS_KEY_ID", "AWS_SECRET_ACCESS_KEY", "GOOGLE_APPLICATION_CREDENTIALS", ) - -# Default SSRF-protection deny list applied to the proxy's outbound traffic. -# Mirrors the public docs promise ("cloud metadata IPs are refused by default -# regardless of allowlist"). Tests / dev setups that need loopback can pass -# an explicit override (e.g. [] to disable, or a smaller subset). +# Default SSRF deny list for outbound traffic (docs promise: cloud metadata IPs refused +# regardless of allowlist). Callers may pass [] to disable (hermetic tests only). _DEFAULT_UPSTREAM_DENY_CIDRS: Tuple[str, ...] = ( "127.0.0.0/8", # IPv4 loopback "::1/128", # IPv6 loopback @@ -236,70 +116,41 @@ _DEFAULT_UPSTREAM_DENY_CIDRS: Tuple[str, ...] = ( "172.16.0.0/12", # RFC1918 "192.168.0.0/16", # RFC1918 "fc00::/7", # IPv6 ULA - # IPv4-mapped IPv6 (``::ffff:0:0/96``) covers the dual-stack case - # where an upstream resolves to e.g. ``::ffff:169.254.169.254`` and - # the kernel hands the v4-mapped form to the socket — that would - # otherwise be a clean SSRF bypass to IMDS through the v6 path. - "::ffff:0:0/96", - # RFC6598 / CGNAT — used by AWS VPC for shared services, K8s pod - # networks, many cloud overlays. Not strictly RFC1918 but operators - # universally want it denied for the same reasons. - "100.64.0.0/10", - # RFC2544 benchmark range — rare in practice but occasionally used - # for internal services and never legitimate as an upstream. - "198.18.0.0/15", + "::ffff:0:0/96", # IPv4-mapped IPv6 — else ::ffff:169.254.169.254 bypasses IMDS deny + "100.64.0.0/10", # RFC6598 CGNAT (AWS VPC shared services, k8s pod nets) + "198.18.0.0/15", # RFC2544 benchmark range ) - -# Min env vars the iron-proxy subprocess actually needs. Everything else -# is stripped — see ``_build_proxy_subprocess_env`` for the rationale. +# Minimal env the iron-proxy subprocess needs; everything else is stripped so +# /proc//environ never exposes the operator's shell secrets. _PROXY_SUBPROCESS_ENV_ALLOWLIST: Tuple[str, ...] = ( - "PATH", - "HOME", - "TMPDIR", - "TZ", - "LANG", - "LC_ALL", - "LC_CTYPE", - "NO_COLOR", - "SSL_CERT_DIR", - "SSL_CERT_FILE", - "SYSTEMROOT", # Windows - "USERPROFILE", # Windows + "PATH", "HOME", "TMPDIR", "TZ", "LANG", "LC_ALL", "LC_CTYPE", "NO_COLOR", + "SSL_CERT_DIR", "SSL_CERT_FILE", + "SYSTEMROOT", "USERPROFILE", # Windows ) - -# Env vars that must be stripped from the subprocess env even if they're on -# the allowlist or named in mappings — these would either recurse the proxy -# back through itself or send its traffic through a corporate proxy. +# Always stripped — these would recurse the proxy through itself or a corporate proxy. _PROXY_SUBPROCESS_ENV_STRIP: Tuple[str, ...] = ( - "HTTPS_PROXY", "https_proxy", - "HTTP_PROXY", "http_proxy", - "ALL_PROXY", "all_proxy", - "NO_PROXY", "no_proxy", + "HTTPS_PROXY", "https_proxy", "HTTP_PROXY", "http_proxy", "ALL_PROXY", "all_proxy", "NO_PROXY", "no_proxy", ) - -# SIGKILL doesn't exist on Windows. We fall back to SIGTERM there, which the -# OS treats as a hard terminate via TerminateProcess() — equivalent semantics. +# SIGKILL doesn't exist on Windows; SIGTERM there is TerminateProcess() — same semantics. _KILL_SIGNAL = getattr(signal, "SIGKILL", signal.SIGTERM) +# O_NOFOLLOW is POSIX-only; 0 is a no-op flag elsewhere. +_O_NOFOLLOW = getattr(os, "O_NOFOLLOW", 0) - -# Cached ``iron-proxy --version`` output keyed by binary path. ``get_status`` -# is invoked per Docker-container-create; the version string is constant for -# a given binary so a one-shot subprocess call is plenty. +# ``iron-proxy --version`` output keyed by binary path (get_status runs per container create). _VERSION_CACHE: Dict[str, str] = {} - -# --------------------------------------------------------------------------- -# Public dataclasses -# --------------------------------------------------------------------------- +# Nonce planted in the daemon env at start so ``_pid_alive`` can prove a PID is still *our* +# binary across PID recycling (a fresh process can't inherit our arbitrary env value). +_HERMES_IRON_PROXY_NONCE_ENV = "HERMES_IRON_PROXY_NONCE" +_proxy_nonce: Optional[str] = None @dataclass class ProxyStatus: """Snapshot of the iron-proxy installation + runtime state.""" - enabled: bool = False binary_path: Optional[Path] = None binary_version: Optional[str] = None @@ -316,34 +167,14 @@ class ProxyStatus: @property def configured(self) -> bool: - return ( - self.config_path is not None - and self.config_path.exists() - and self.ca_cert_path is not None - and self.ca_cert_path.exists() - ) + return bool(self.config_path and self.config_path.exists() and self.ca_cert_path and self.ca_cert_path.exists()) @dataclass class TokenMapping: - """Map a sandbox-visible proxy token to a real upstream credential lookup. - - ``real_env_name`` is the env-var name iron-proxy reads at egress time. - When Bitwarden is configured as the credential source for the proxy, - iron-proxy's *own* environment is populated from bws on startup — the - sandbox still sees only ``proxy_token``. - - ``match_headers`` names the request headers iron-proxy scans for the - proxy token (default: ``Authorization`` for bearer providers; e.g. - ``("x-api-key", "Authorization")`` for Anthropic native). - - ``alias_env_names`` are additional env-var names the SANDBOX receives - the same proxy token under (e.g. ``GOOGLE_API_KEY`` for - ``GEMINI_API_KEY``). They do not appear in the iron-proxy config — - only one secrets rule is emitted per mapping, keyed on - ``real_env_name``. - """ - + """Map a sandbox-visible proxy token to a real upstream credential lookup. ``real_env_name`` + is read from iron-proxy's OWN env at egress time; ``alias_env_names`` are extra names the + SANDBOX receives the same token under (not emitted in the proxy config).""" proxy_token: str real_env_name: str upstream_hosts: Tuple[str, ...] @@ -351,11 +182,6 @@ class TokenMapping: alias_env_names: Tuple[str, ...] = () -# --------------------------------------------------------------------------- -# Paths -# --------------------------------------------------------------------------- - - def _hermes_bin_dir() -> Path: from hermes_constants import get_hermes_home @@ -363,35 +189,21 @@ def _hermes_bin_dir() -> Path: def _proxy_state_dir_ro() -> Path: - """Return the proxy state dir without creating it. - - Read-only callers (status probes, pidfile reads, version queries) use - this — there's no reason to materialize ``~/.hermes/proxy/`` just to - check whether a pidfile exists. - """ + """Proxy state dir without creating it (status probes, pidfile reads).""" from hermes_constants import get_hermes_home return get_hermes_home() / "proxy" def _proxy_state_dir() -> Path: - """Return the proxy state dir, creating it with 0o700 if absent. - - Writable callers (CA gen, config write, mappings write, start_proxy) - use this. We force 0o700 — the dir holds the CA signing key, audit - log, and pidfile, so traversal by other local users is undesirable. - The chmod is unconditional so a pre-existing dir with a slack umask - gets tightened on first access. - """ + """Proxy state dir, created 0o700 (holds the CA key, pidfile, logs); chmod is unconditional + so a pre-existing dir with a slack umask gets tightened.""" d = _proxy_state_dir_ro() d.mkdir(parents=True, exist_ok=True) try: d.chmod(0o700) except OSError: - # On Windows the chmod is a no-op for POSIX modes; on shared - # filesystems we may not own the dir. Don't fail here — the - # individual files still get explicit perms. - pass + pass # Windows no-op / shared fs we don't own; files still get explicit perms return d @@ -400,146 +212,79 @@ def _platform_binary_name() -> str: def _platform_asset_name() -> str: - """Map (uname, arch) → upstream release asset filename. - - iron-proxy ships ``iron-proxy___.tar.gz``. - Windows builds aren't published upstream as of v0.39.0; we raise a - clear error for callers on Windows. - """ - + """Map (uname, arch) -> ``iron-proxy___.tar.gz``; no Windows builds upstream.""" system = platform.system() machine = platform.machine().lower() - - if system == "Linux": + os_name = {"Linux": "linux", "Darwin": "darwin"}.get(system) + if os_name: arch = "arm64" if machine in ("arm64", "aarch64") else "amd64" - return f"iron-proxy_{_IRON_PROXY_VERSION}_linux_{arch}.tar.gz" - if system == "Darwin": - arch = "arm64" if machine in ("arm64", "aarch64") else "amd64" - return f"iron-proxy_{_IRON_PROXY_VERSION}_darwin_{arch}.tar.gz" + return f"iron-proxy_{_IRON_PROXY_VERSION}_{os_name}_{arch}.tar.gz" if system == "Windows": raise RuntimeError( - "iron-proxy does not ship native Windows binaries as of " - f"v{_IRON_PROXY_VERSION}. Run the proxy on a Linux/macOS host, " - "or inside WSL." + f"iron-proxy does not ship native Windows binaries as of v{_IRON_PROXY_VERSION}. " + "Run the proxy on a Linux/macOS host, or inside WSL." ) - - raise RuntimeError( - f"Unsupported platform for iron-proxy auto-install: {system} {machine}" - ) - - -# --------------------------------------------------------------------------- -# Binary discovery + lazy install -# --------------------------------------------------------------------------- + raise RuntimeError(f"Unsupported platform for iron-proxy auto-install: {system} {machine}") def find_iron_proxy(*, install_if_missing: bool = False) -> Optional[Path]: - """Return a path to a usable ``iron-proxy`` binary, or None. - - Resolution order: - 1. ``/bin/iron-proxy`` (our managed copy — preferred) - 2. ``shutil.which("iron-proxy")`` (system PATH) - - When ``install_if_missing`` is True and neither resolves, calls - :func:`install_iron_proxy` to download and verify the pinned version. - """ - + """Managed ``/bin`` copy first, then PATH; optionally auto-install.""" managed = _hermes_bin_dir() / _platform_binary_name() if managed.exists() and os.access(managed, os.X_OK): return managed - system = shutil.which("iron-proxy") if system: return Path(system) - - if install_if_missing: - try: - return install_iron_proxy() - except Exception as exc: # noqa: BLE001 — never block startup - logger.warning("iron-proxy auto-install failed: %s", exc) - return None - return None + if not install_if_missing: + return None + try: + return install_iron_proxy() + except Exception as exc: # noqa: BLE001 — never block startup + logger.warning("iron-proxy auto-install failed: %s", exc) + return None def install_iron_proxy(*, force: bool = False) -> Path: - """Download, verify, and install the pinned ``iron-proxy`` binary. - - Returns the path to the installed executable. Raises on any failure - (network, checksum, extraction). Callers in the auto-install path catch - these; the user-facing ``hermes proxy install`` surface lets them - propagate so the wizard can show a clear error. - """ - + """Download, verify, and install the pinned binary; raises on any failure.""" bin_dir = _hermes_bin_dir() bin_dir.mkdir(parents=True, exist_ok=True) target = bin_dir / _platform_binary_name() - if target.exists() and not force: return target asset_name = _platform_asset_name() - asset_url = f"{_IRON_PROXY_RELEASE_BASE}/{asset_name}" - checksum_url = f"{_IRON_PROXY_RELEASE_BASE}/{_IRON_PROXY_CHECKSUM_NAME}" - with tempfile.TemporaryDirectory(prefix="hermes-iron-proxy-") as tmpdir: tmp = Path(tmpdir) archive_path = tmp / asset_name checksum_path = tmp / _IRON_PROXY_CHECKSUM_NAME - - logger.info("Downloading %s", asset_url) - _http_download(asset_url, archive_path) - _http_download(checksum_url, checksum_path) - - # Defense-in-depth (maxpetrusenko P1): verify the GPG signature of - # checksums.txt before trusting it. The archive download honors ambient - # proxy env (urllib), so a compromised channel could serve a matching - # binary + checksums pair; the detached signature + pinned public key - # close that release-channel tamper gap. Best-effort: if gpg or the - # signature assets aren't available we log and fall back to the SHA-256 - # check alone rather than hard-failing offline installs. + logger.info("Downloading %s", f"{_IRON_PROXY_RELEASE_BASE}/{asset_name}") + _release_asset(asset_name, archive_path) + _release_asset(_IRON_PROXY_CHECKSUM_NAME, checksum_path) + # Best-effort GPG check of checksums.txt closes the release-channel tamper gap. _verify_checksums_signature(tmp, checksum_path) - expected = _expected_sha256(checksum_path, asset_name) actual = _sha256_file(archive_path) if expected.lower() != actual.lower(): - raise RuntimeError( - f"Checksum mismatch for {asset_name}: " - f"expected {expected}, got {actual}" - ) + raise RuntimeError(f"Checksum mismatch for {asset_name}: expected {expected}, got {actual}") with tarfile.open(archive_path, "r:gz") as tf: member = _pick_tar_member(tf, _platform_binary_name()) - # PEP 706 data filter — strips ownership/mode replay (we set - # chmod explicitly below) AND rejects symlink/hardlink members - # that escape the extraction dir. Required on 3.12+ to silence - # the deprecation warning and on 3.14+ to opt into the - # tarbomb-rejecting default. + # PEP 706 data filter rejects escaping links; Python < 3.12 lacks the kwarg and + # relies on _pick_tar_member's path sanitization. try: tf.extract(member, tmp, filter="data") # noqa: S202 except TypeError: - # Python < 3.12 — filter kw didn't exist yet; the - # _pick_tar_member sanitization already rejects path - # traversal so this is acceptable. tf.extract(member, tmp) # noqa: S202 extracted = tmp / member.name - # Stage into the final directory then atomically rename so the new - # binary is never visible half-written. + # Stage then atomically rename so the binary is never visible half-written. fd, staged = tempfile.mkstemp(dir=str(bin_dir), prefix=".iron-proxy_") os.close(fd) shutil.copy2(extracted, staged) - os.chmod( - staged, - stat.S_IRUSR | stat.S_IWUSR | stat.S_IXUSR - | stat.S_IRGRP | stat.S_IXGRP - | stat.S_IROTH | stat.S_IXOTH, - ) + os.chmod(staged, 0o755) os.replace(staged, target) - # Invalidate the version cache so a freshly-installed binary - # re-probes ``--version`` on the next ``get_status()`` call instead - # of returning the pre-upgrade string. Long-lived processes that - # bump the pinned version via ``force=True`` need this. + # A freshly-installed binary must re-probe --version on the next get_status(). _VERSION_CACHE.pop(str(target), None) logger.info("Installed iron-proxy %s at %s", _IRON_PROXY_VERSION, target) @@ -556,24 +301,14 @@ def _http_download(url: str, dest: Path) -> None: raise RuntimeError(f"Failed to download {url}: {exc}") from exc +def _release_asset(name: str, dest: Path) -> None: + _http_download(f"{_IRON_PROXY_RELEASE_BASE}/{name}", dest) + + def _verify_checksums_signature(tmp: Path, checksum_path: Path) -> bool: - """Best-effort GPG verification of ``checksums.txt`` (maxpetrusenko P1). - - Downloads the detached signature (``checksums.txt.asc``) and the release - signing key (``public-key.asc``), imports the key into an ephemeral - keyring, and verifies the signature over ``checksum_path``. - - Returns True when the signature is verified. Returns False (with a warning) - when verification is unavailable — ``gpg`` not installed, or the signature / - public-key assets are missing from the release. Raises RuntimeError ONLY - when verification actively FAILS (a present-but-bad signature), which is a - tamper signal we must not ignore. - - Rationale for graceful degradation on "unavailable": the SHA-256 check - against ``checksums.txt`` remains in force regardless, and many install - hosts (CI, minimal containers) won't have gpg. We harden when we can and - never make gpg a hard dependency for a working install. - """ + """Best-effort GPG verification of checksums.txt in an ephemeral keyring. Returns False (with + a warning) when gpg or the signature assets are unavailable — SHA-256 stays enforced and gpg + is never a hard dependency. Raises ONLY on a present-but-bad signature (tamper signal).""" gpg = shutil.which("gpg") if not gpg: logger.warning( @@ -582,14 +317,10 @@ def _verify_checksums_signature(tmp: Path, checksum_path: Path) -> bool: ) return False - sig_url = f"{_IRON_PROXY_RELEASE_BASE}/{_IRON_PROXY_CHECKSUM_SIG_NAME}" - pubkey_url = f"{_IRON_PROXY_RELEASE_BASE}/{_IRON_PROXY_PUBKEY_NAME}" - sig_path = tmp / _IRON_PROXY_CHECKSUM_SIG_NAME - pubkey_path = tmp / _IRON_PROXY_PUBKEY_NAME - + sig_path, pubkey_path = tmp / _IRON_PROXY_CHECKSUM_SIG_NAME, tmp / _IRON_PROXY_PUBKEY_NAME try: - _http_download(sig_url, sig_path) - _http_download(pubkey_url, pubkey_path) + _release_asset(_IRON_PROXY_CHECKSUM_SIG_NAME, sig_path) + _release_asset(_IRON_PROXY_PUBKEY_NAME, pubkey_path) except RuntimeError as exc: logger.warning( "iron-proxy release signature assets unavailable (%s) — skipping " @@ -597,29 +328,18 @@ def _verify_checksums_signature(tmp: Path, checksum_path: Path) -> bool: ) return False - # Ephemeral keyring so we never touch the user's real GPG home. gnupg_home = tmp / "gnupg" gnupg_home.mkdir(mode=0o700, exist_ok=True) - base_cmd = [gpg, "--homedir", str(gnupg_home), "--batch", "--no-tty"] - - imp = subprocess.run( # noqa: S603 — gpg path from trusted PATH lookup - [*base_cmd, "--import", str(pubkey_path)], - capture_output=True, timeout=60, - ) + gpg_base = [gpg, "--homedir", str(gnupg_home), "--batch", "--no-tty"] + imp = _run([*gpg_base, "--import", str(pubkey_path)], timeout=60) if imp.returncode != 0: logger.warning( - "Could not import iron-proxy signing key — skipping GPG " - "verification (SHA-256 still enforced): %s", + "Could not import iron-proxy signing key — skipping GPG verification (SHA-256 still enforced): %s", imp.stderr.decode("utf-8", "replace")[:200], ) return False - - verify = subprocess.run( # noqa: S603 - [*base_cmd, "--verify", str(sig_path), str(checksum_path)], - capture_output=True, timeout=60, - ) + verify = _run([*gpg_base, "--verify", str(sig_path), str(checksum_path)], timeout=60) if verify.returncode != 0: - # A present signature that does NOT verify is a tamper signal — fail hard. raise RuntimeError( "iron-proxy checksums.txt failed GPG signature verification — " "refusing to install (possible release-channel tampering). " @@ -630,16 +350,13 @@ def _verify_checksums_signature(tmp: Path, checksum_path: Path) -> bool: def _expected_sha256(checksum_file: Path, asset_name: str) -> str: - """Parse the standard ``sha256sum`` output: `` ``.""" - + """Parse ``sha256sum`` output (`` ``).""" text = checksum_file.read_text(encoding="utf-8", errors="replace") for line in text.splitlines(): parts = line.strip().split() if len(parts) >= 2 and parts[-1] == asset_name: return parts[0] - raise RuntimeError( - f"No checksum entry for {asset_name} in {checksum_file.name}" - ) + raise RuntimeError(f"No checksum entry for {asset_name} in {checksum_file.name}") def _sha256_file(path: Path) -> str: @@ -651,257 +368,151 @@ def _sha256_file(path: Path) -> str: def _pick_tar_member(tf: tarfile.TarFile, binary_name: str) -> tarfile.TarInfo: - """Find the binary inside the upstream tar. - - iron-proxy's archive is typically flat (binary at root) but we tolerate - a top-level directory. Members must be regular files with a leaf name - matching ``binary_name``, no absolute paths, and no ``..`` traversal. - """ - - candidates: List[tarfile.TarInfo] = [] - for member in tf.getmembers(): - if not member.isfile(): - continue - if member.name.startswith("/") or ".." in Path(member.name).parts: - continue - if Path(member.name).name == binary_name: - candidates.append(member) + """Find the binary in the archive (flat or one dir deep); reject abs paths and ``..``.""" + candidates = [ + m for m in tf.getmembers() + if m.isfile() and not m.name.startswith("/") and ".." not in Path(m.name).parts + and Path(m.name).name == binary_name + ] if not candidates: raise RuntimeError( f"Could not find {binary_name} inside downloaded archive " f"(members: {[m.name for m in tf.getmembers()[:5]]}...)" ) - candidates.sort(key=lambda m: len(m.name)) - return candidates[0] + return min(candidates, key=lambda m: len(m.name)) + + +def _allowlisted_env() -> Dict[str, str]: + """Infrastructure-only env (PATH, HOME, locale) — never the operator's secrets.""" + return {n: os.environ[n] for n in _PROXY_SUBPROCESS_ENV_ALLOWLIST if n in os.environ} + + +def _run(argv: List[str], *, timeout: int, text: bool = False, **kwargs) -> "subprocess.CompletedProcess": + """``subprocess.run`` with captured output; argv[0] is always a trusted PATH/system binary.""" + if text: + kwargs.update(text=True, encoding="utf-8", errors="replace") + return subprocess.run(argv, capture_output=True, timeout=timeout, **kwargs) # noqa: S603 def iron_proxy_version(binary: Path) -> str: - """Return ``iron-proxy --version`` output, stripped. Empty on failure. - - Cached by binary path: ``get_status`` is called per Docker container - create, but the version string is constant for a given binary. A - single subprocess invocation is plenty. - """ - + """``iron-proxy --version`` output, stripped and cached by path. Empty on failure.""" key = str(binary) - cached = _VERSION_CACHE.get(key) - if cached is not None: - return cached - + if key in _VERSION_CACHE: + return _VERSION_CACHE[key] try: - # Build a minimal env: only PATH, HOME, and locale vars. - # The version probe is a one-shot subprocess — forwarding - # the full host env (OPENAI_API_KEY, ANTHROPIC_API_KEY, etc.) - # to a PATH-resolved or unverified binary is an unnecessary - # credential leak. Reuse the same allowlist the daemon - # subprocess uses (see _build_proxy_subprocess_env). - minimal_env: Dict[str, str] = {} - parent = os.environ - for name in _PROXY_SUBPROCESS_ENV_ALLOWLIST: - if name in parent: - minimal_env[name] = parent[name] - # The S603 warning is legitimate for the PATH-fallback case - # (find_iron_proxy → shutil.which), but --version with a - # scrubbed env is safe regardless of binary provenance. - res = subprocess.run( # noqa: S603 - [str(binary), "--version"], - capture_output=True, - text=True, encoding="utf-8", errors="replace", - timeout=_RUN_TIMEOUT, - env=minimal_env, - ) + # Scrubbed env: a PATH-resolved binary must not see the host's API keys. + res = _run([str(binary), "--version"], timeout=_RUN_TIMEOUT, text=True, env=_allowlisted_env()) except (OSError, subprocess.TimeoutExpired): return "" out = (res.stdout or res.stderr or "").strip() - # Don't cache empty output — that would poison ``hermes egress - # status`` for the lifetime of the process if the first probe hit a - # corrupt binary or a flag-rename in a newer upstream. Re-probe on - # the next call instead. - if out: + if out: # never cache empty output — it would poison status for the process lifetime _VERSION_CACHE[key] = out return out -# --------------------------------------------------------------------------- -# CA cert generation -# --------------------------------------------------------------------------- +def _write_private_file(path: Path, data: bytes) -> None: + """Create/truncate ``path`` 0o600 from the first byte (no chmod-after TOCTOU); O_NOFOLLOW + defends against a planted symlink; fchmod tightens a pre-existing file.""" + fd = os.open(str(path), os.O_WRONLY | os.O_CREAT | os.O_TRUNC | _O_NOFOLLOW, 0o600) + try: + try: + os.fchmod(fd, 0o600) + except (OSError, AttributeError): + pass + os.write(fd, data) + finally: + os.close(fd) + + +def _fd_owned_by_us(fd: int) -> bool: + """False iff the open file is owned by another uid (same threat model as the pidfile).""" + try: + st = os.fstat(fd) + return not (hasattr(os, "getuid") and st.st_uid != os.getuid()) + except AttributeError: + return True # Windows def ensure_ca_cert(*, force: bool = False) -> Tuple[Path, Path]: - """Generate (or return existing) iron-proxy CA cert + key. - - Uses the host's ``openssl`` binary. We don't try to bind to a Python - crypto library — openssl is universally available on the platforms we - support, and it sidesteps cryptography-package licensing/distribution - surface. - """ - + """Generate (or return existing) 10-year CA cert + key via the host ``openssl``.""" state = _proxy_state_dir() - ca_crt = state / "ca.crt" - ca_key = state / "ca.key" - + ca_crt, ca_key = state / "ca.crt", state / "ca.key" if ca_crt.exists() and ca_key.exists() and not force: return ca_crt, ca_key - if shutil.which("openssl") is None: raise RuntimeError( "openssl not found on PATH. Install OpenSSL (apt: `openssl`, " "brew: `openssl`) to generate the iron-proxy CA cert." ) - # 10-year cert. iron-proxy mints short-lived leaf certs from this CA, - # so the CA itself only rotates when the user explicitly forces it. with tempfile.TemporaryDirectory(prefix="hermes-proxy-ca-") as tmpdir: - tmp = Path(tmpdir) - tmp_key = tmp / "ca.key" - tmp_crt = tmp / "ca.crt" + tmp_key, tmp_crt = Path(tmpdir) / "ca.key", Path(tmpdir) / "ca.crt" + _run(["openssl", "genrsa", "-out", str(tmp_key), "4096"], timeout=60, check=True) + _run([ + "openssl", "req", "-x509", "-new", "-nodes", + "-key", str(tmp_key), + "-sha256", "-days", "3650", + "-subj", "/CN=hermes iron-proxy CA", + "-addext", "basicConstraints=critical,CA:TRUE", + "-addext", "keyUsage=critical,keyCertSign", + "-out", str(tmp_crt), + ], timeout=60, check=True) - subprocess.run( # noqa: S603 — openssl path is trusted PATH lookup - ["openssl", "genrsa", "-out", str(tmp_key), "4096"], - check=True, - capture_output=True, - timeout=60, - ) - subprocess.run( # noqa: S603 - [ - "openssl", "req", "-x509", "-new", "-nodes", - "-key", str(tmp_key), - "-sha256", "-days", "3650", - "-subj", "/CN=hermes iron-proxy CA", - "-addext", "basicConstraints=critical,CA:TRUE", - "-addext", "keyUsage=critical,keyCertSign", - "-out", str(tmp_crt), - ], - check=True, - capture_output=True, - timeout=60, - ) - - # Move into place with private permissions. CRITICAL: the key - # has to be created with 0o600 from the very first byte — a - # ``shutil.copy2`` followed by ``os.chmod`` leaves a TOCTOU window - # where the private key is world-readable on multi-user hosts. - key_bytes = tmp_key.read_bytes() - crt_bytes = tmp_crt.read_bytes() - - # Stage with explicit 0o600, then atomically rename into place. - # O_NOFOLLOW guards against a symlink at ca_key (defence-in-depth - # — the state dir is 0o700-owned but a malicious local user with - # the same uid could pre-create one). + # Key: stage 0o600 against a fresh inode, then atomically rename into place. key_staged = ca_key.with_suffix(ca_key.suffix + ".staged") - open_flags = os.O_WRONLY | os.O_CREAT | os.O_TRUNC - # O_NOFOLLOW exists on POSIX; on Windows we just rely on the - # default semantics. - if hasattr(os, "O_NOFOLLOW"): - open_flags |= os.O_NOFOLLOW - # Best-effort: pre-unlink any existing staged file so the open - # with O_CREAT is always against a fresh inode. - try: - key_staged.unlink() - except FileNotFoundError: - pass - fd = os.open(str(key_staged), open_flags, 0o600) - try: - with os.fdopen(fd, "wb") as f: - f.write(key_bytes) - except Exception: - try: - os.close(fd) - except OSError: - pass - raise + key_staged.unlink(missing_ok=True) + _write_private_file(key_staged, tmp_key.read_bytes()) os.replace(key_staged, ca_key) - - # Cert is public — 0o644 is fine and matches typical PEM layout. - ca_crt.write_bytes(crt_bytes) - os.chmod(ca_crt, stat.S_IRUSR | stat.S_IWUSR | stat.S_IRGRP | stat.S_IROTH) + # Cert is public — 0o644 matches typical PEM layout. + ca_crt.write_bytes(tmp_crt.read_bytes()) + os.chmod(ca_crt, 0o644) logger.info("Generated iron-proxy CA at %s", ca_crt) return ca_crt, ca_key -# --------------------------------------------------------------------------- -# Proxy config + token mapping generation -# --------------------------------------------------------------------------- - - def mint_proxy_token(prefix: str = "hermes-proxy") -> str: - """Mint a fresh opaque token to hand to the sandbox. - - The token has no internal structure beyond a recognizable prefix — - iron-proxy matches on exact equality. We use a 128-bit random suffix - (32 hex chars from a SHA-256 of 32 bytes of os.urandom). At that - entropy the birthday-bound collision probability is below 2^-64 for - up to 2^32 tokens, which is plenty for a proxy-scoped namespace. - """ - + """Opaque token: recognizable prefix + 128-bit random hex suffix (iron-proxy matches exactly).""" return f"{prefix}-{hashlib.sha256(os.urandom(32)).hexdigest()[:32]}" -def _management_token_path() -> Path: - return _proxy_state_dir() / "management.token" +def _read_text_or_none(p: Path) -> Optional[str]: + """Stripped file contents, or None when missing/unreadable/empty.""" + try: + return p.read_text(encoding="utf-8").strip() or None + except OSError: + return None def ensure_management_token(*, force: bool = False) -> str: - """Return the management-API bearer key, minting it on first call. - - Stored at ``/proxy/management.token`` with 0600 perms. - The daemon receives it via the ``HERMES_IRON_PROXY_MGMT_KEY`` env var - (named in the generated config's ``management.api_key_env``); - ``hermes egress reload`` reads the same file to authenticate. - """ - - p = _management_token_path() - if not force and p.exists(): - try: - existing = p.read_text(encoding="utf-8").strip() - if existing: - return existing - except OSError: - pass + """Return the management-API bearer key (0600 at /management.token), minting on first call.""" + p = _proxy_state_dir() / "management.token" + existing = None if force else _read_text_or_none(p) + if existing: + return existing token = mint_proxy_token(prefix="hermes-mgmt") - fd = os.open( - str(p), - os.O_WRONLY | os.O_CREAT | os.O_TRUNC | getattr(os, "O_NOFOLLOW", 0), - 0o600, - ) - try: - os.fchmod(fd, 0o600) - except (OSError, AttributeError): - pass - try: - os.write(fd, token.encode("utf-8")) - finally: - os.close(fd) + _write_private_file(p, token.encode("utf-8")) return token def _read_management_token() -> Optional[str]: - p = _proxy_state_dir_ro() / "management.token" - try: - token = p.read_text(encoding="utf-8").strip() - except OSError: - return None - return token or None + return _read_text_or_none(_proxy_state_dir_ro() / "management.token") -def _read_management_listen_from_config( - config_path: Optional[Path] = None, -) -> Optional[Tuple[str, int]]: - """Return ``(host, port)`` of the management listener, if configured.""" - - cfg = config_path or (_proxy_state_dir_ro() / "proxy.yaml") - if not cfg.exists(): - return None +def _load_proxy_yaml(cfg: Path): + """Parsed proxy.yaml, or ``{}`` when missing/unreadable/PyYAML absent.""" try: import yaml except ImportError: - return None + return {} try: - data = yaml.safe_load(cfg.read_text(encoding="utf-8")) + return yaml.safe_load(cfg.read_text(encoding="utf-8")) or {} except (OSError, yaml.YAMLError): - return None - listen = ((data or {}).get("management") or {}).get("listen") or "" + return {} + + +def _parse_listen(listen) -> Optional[Tuple[str, int]]: + """``"host:port"`` -> ``(host, port)``; empty host means loopback.""" if not isinstance(listen, str) or ":" not in listen: return None host, _, port_s = listen.rpartition(":") @@ -912,25 +523,43 @@ def _read_management_listen_from_config( return (host or "127.0.0.1", port) +def _read_management_listen_from_config(config_path: Optional[Path] = None) -> Optional[Tuple[str, int]]: + """``(host, port)`` of the management listener, if configured.""" + data = _load_proxy_yaml(config_path or (_proxy_state_dir_ro() / "proxy.yaml")) + return _parse_listen((data.get("management") or {}).get("listen") or "") + + +def _read_http_listen_from_config() -> Optional[Tuple[str, int]]: + """``(host, port)`` of the sandbox-facing listener: ``tunnel_listen`` (CONNECT/MITM), falling + back to ``http_listen`` for configs written before the listener-role split.""" + proxy_block = _load_proxy_yaml(_proxy_state_dir_ro() / "proxy.yaml").get("proxy") or {} + return _parse_listen(proxy_block.get("tunnel_listen") or proxy_block.get("http_listen") or "") + + +def _probe_target() -> Tuple[str, int]: + """Configured bind host/port to probe — on Linux that's the docker bridge, where a loopback + connect would report a healthy daemon as down.""" + return _read_http_listen_from_config() or ("127.0.0.1", _DEFAULT_TUNNEL_PORT) + + +# Management-API error status -> operator message (422 = validation rejected, running ruleset +# unchanged; 401 = daemon started with a different management.token). +_RELOAD_HTTP_ERRORS = { + 422: "iron-proxy rejected the new config (validation failed; the running ruleset is unchanged): {body}", + 401: ( + "management API rejected our key (401). The running " + "daemon was started with a different management.token — " + "run `hermes egress restart`." + ), +} + + def reload_proxy() -> bool: - """Hot-reload the running daemon's ruleset via the management API. - - POSTs to ``/v1/reload`` on the loopback management listener; the daemon - re-reads proxy.yaml and atomically swaps the transform pipeline — - validation failures leave the running config untouched (HTTP 422). - - Returns True on a successful reload. Raises ``RuntimeError`` with an - actionable message when the daemon isn't running, the config predates - management-API support (no ``management`` block → restart required), - or the reload is rejected. - """ - + """Hot-reload the ruleset via ``POST /v1/reload`` (validation failures leave the running + config untouched). Raises RuntimeError with an actionable message on any failure.""" pid = _read_pid() if not pid or not _pid_alive(pid): - raise RuntimeError( - "iron-proxy is not running — nothing to reload. " - "Run `hermes egress start`." - ) + raise RuntimeError("iron-proxy is not running — nothing to reload. Run `hermes egress start`.") mgmt = _read_management_listen_from_config() if mgmt is None: raise RuntimeError( @@ -945,9 +574,6 @@ def reload_proxy() -> bool: "then `hermes egress restart`." ) - import urllib.error - import urllib.request - host, port = mgmt req = urllib.request.Request( f"http://{host}:{port}/v1/reload", @@ -959,32 +585,16 @@ def reload_proxy() -> bool: with urllib.request.urlopen(req, timeout=_MGMT_RELOAD_TIMEOUT) as resp: if resp.status == 200: return True - raise RuntimeError( - f"management API returned unexpected status {resp.status}" - ) + raise RuntimeError(f"management API returned unexpected status {resp.status}") except urllib.error.HTTPError as exc: - body = "" try: body = exc.read().decode("utf-8", errors="replace")[:500] except OSError: - pass - if exc.code == 422: - raise RuntimeError( - f"iron-proxy rejected the new config (validation failed; " - f"the running ruleset is unchanged): {body}" - ) from exc - if exc.code == 401: - raise RuntimeError( - "management API rejected our key (401). The running " - "daemon was started with a different management.token — " - "run `hermes egress restart`." - ) from exc - raise RuntimeError( - f"management reload failed (HTTP {exc.code}): {body}" - ) from exc + body = "" + message = _RELOAD_HTTP_ERRORS.get(exc.code, "management reload failed (HTTP {code}): {body}") + raise RuntimeError(message.format(code=exc.code, body=body)) from exc except (urllib.error.URLError, OSError) as exc: - # A daemon started from a pre-management config is alive but has - # no listener on the management port. + # A daemon started from a pre-management config is alive but has no listener. raise RuntimeError( f"could not reach the management API at {host}:{port} ({exc}). " "If the daemon was started before reload support, run " @@ -993,34 +603,10 @@ def reload_proxy() -> bool: def _default_http_listen(tunnel_port: int) -> List[str]: - """Build the single host:port bind the proxy should listen on. - - iron-proxy v0.39 supports exactly ONE ``proxy.http_listen`` bind per - daemon process, so this returns a one-element list and the choice of - host matters: - - * **Linux:** bind the docker bridge gateway (``172.17.0.1`` by - default). Sandboxes reach the proxy via - ``host.docker.internal:host-gateway``, which Docker resolves to - exactly this bridge gateway IP on Linux — a loopback-only bind is - unreachable from inside containers there. The bridge IP is still - host-local (it's an address on the host's ``docker0`` interface), - so host-side tooling and the status probe can reach it too. When - no docker bridge is detected (docker not installed / not started), - fall back to loopback — there are no sandboxes to serve in that - state, and the operator gets a warning. - * **macOS / Windows Docker Desktop:** ``host.docker.internal`` - resolves via VPNkit to the host, so a loopback bind is reachable - from containers and is the least-exposed choice. - - We never bind ``0.0.0.0`` — that would expose the proxy (and, with a - leaked sandbox token, the user's API quota) to anyone on the local - network. The bridge-gateway bind is reachable by other containers - on the default bridge network, which is unavoidable given v0.39's - single-bind limit; requests still require a minted proxy token and - an allowlisted upstream. - """ - + """Single bind (v0.39 allows one per daemon): docker bridge gateway on Linux — that's what + ``host.docker.internal`` resolves to there, loopback is unreachable from containers — and + loopback on macOS/Windows Docker Desktop (VPNkit). NEVER 0.0.0.0: a LAN peer with a leaked + sandbox token could spend the operator's API quota.""" if platform.system() == "Linux": bridge_ip = _detect_docker_bridge_ip() if bridge_ip and bridge_ip != "127.0.0.1": @@ -1034,69 +620,39 @@ def _default_http_listen(tunnel_port: int) -> List[str]: def _detect_docker_bridge_ip() -> Optional[str]: - """Return the docker0 bridge IPv4, if present, else None. - - Best-effort: we try ``ip -4 addr show docker0`` first. Anything that - fails, doesn't parse as a strict IPv4, or parses as an address we - must NOT bind to (unspecified, loopback, multicast, reserved, public) - returns None — callers handle that as "no bridge bind". - - SECURITY: a hostile ``ip`` shim earlier on the operator's PATH used - to be able to inject ``0.0.0.0`` here and re-open INADDR_ANY binding - that the rest of the bind-policy work explicitly closed. We - validate via :mod:`ipaddress` and reject anything that isn't - plausibly a docker bridge IP (private + non-special). - """ - - candidate: Optional[str] = None + """docker0 IPv4 via ``ip -4 addr show docker0``, or None. SECURITY: validated through + :mod:`ipaddress` so a hostile ``ip`` shim on PATH can't inject ``0.0.0.0`` (or + loopback/multicast/link-local/public) and reopen INADDR_ANY binding.""" try: - res = subprocess.run( # noqa: S603 — ip is a system binary - ["ip", "-4", "-o", "addr", "show", "docker0"], - capture_output=True, text=True, encoding="utf-8", errors="replace", timeout=2, - ) - if res.returncode == 0: - for line in res.stdout.splitlines(): - parts = line.split() - # Expected: ": docker0 inet 172.17.0.1/16 ..." - for i, tok in enumerate(parts): - if tok == "inet" and i + 1 < len(parts): - candidate = parts[i + 1].split("/")[0] - break - if candidate is not None: - break + res = _run(["ip", "-4", "-o", "addr", "show", "docker0"], timeout=2, text=True) except (OSError, subprocess.TimeoutExpired): return None - + if res.returncode != 0: + return None + # Expected: ": docker0 inet 172.17.0.1/16 ..." — first inet token wins. + candidate = None + for line in res.stdout.splitlines(): + parts = line.split() + hits = [parts[i + 1] for i, tok in enumerate(parts[:-1]) if tok == "inet"] + if hits: + candidate = hits[0].split("/")[0] + break if not candidate: return None - # Stdlib validation: rejects garbage strings AND special-purpose - # addresses that must not be used as a bind target. try: addr = ipaddress.IPv4Address(candidate) except (ipaddress.AddressValueError, ValueError): return None - # Reject: - # - 0.0.0.0 / INADDR_ANY (is_unspecified) - # - 127.0.0.0/8 (is_loopback — already in deny list) - # - 224.0.0.0/4 (is_multicast) - # - 240.0.0.0/4 (is_reserved) - # - 169.254.0.0/16 (is_link_local — IMDS range, never docker0) - # - global / public IPs (is_global — docker0 must be RFC1918) if ( - addr.is_unspecified - or addr.is_loopback - or addr.is_multicast - or addr.is_reserved - or addr.is_link_local - or addr.is_global + addr.is_unspecified or addr.is_loopback or addr.is_multicast + or addr.is_reserved or addr.is_link_local or addr.is_global ): logger.warning( "Refusing suspicious docker bridge IP %s reported by `ip`; " "skipping bridge bind.", candidate, ) return None - return str(addr) @@ -1111,243 +667,89 @@ def build_proxy_config( upstream_deny_cidrs: Optional[List[str]] = None, http_listen: Optional[List[str]] = None, ) -> Dict: - """Build the iron-proxy YAML config (as a dict) for a given mapping set. - - The dict is YAML-serializable via ``yaml.safe_dump``. iron-proxy reads - real secrets from its OWN environment via ``source: {type: env, var: ...}``; - the sandbox never sees them. - - Bind policy: the sandbox-facing listeners (``tunnel_listen`` on - ``tunnel_port``, plain-HTTP ``http_listen`` on ``tunnel_port + 1``) - bind the docker bridge gateway on Linux (``172.17.0.1`` or whatever - ``docker0`` resolves to — that's what ``host.docker.internal`` - resolves to inside containers there) and loopback on macOS / Windows - Docker Desktop. We do NOT bind ``0.0.0.0`` — a LAN peer with a - leaked sandbox token could otherwise spend the operator's API quota - against any allowlisted upstream. - - SSRF policy: ``upstream_deny_cidrs`` defaults to a conservative deny - list covering loopback, link-local (incl. AWS/GCP/Azure IMDS at - 169.254.169.254), and RFC1918. Pass an explicit ``[]`` to opt out of - the deny list entirely (only sensible in hermetic tests). - - Schema mirrors the official iron-proxy schema as of v0.39.0. Notable - points: - - * The ``dns`` section is required by the binary even when we only use the - CONNECT tunnel. We point it at loopback so it doesn't conflict with - anything else and disable the listener. - * The ``proxy.tunnel_listen`` is what sandboxes hit via ``HTTPS_PROXY``. - ``http_listen`` / ``https_listen`` are present (loopback only) so the - proxy boots; sandboxes never route directly to them. - * ``allowlist`` transform takes ``domains:`` and ``cidrs:``, not ``hosts:``. - * ``secrets`` transform takes ``secrets:`` (plural), each with a - ``source``, a ``replace.proxy_value`` (the sandbox-visible token), and - a list of ``rules`` saying which hosts the swap should fire on. - """ - + """Build the iron-proxy YAML config dict (schema as of v0.39.0). Real secrets are read from + iron-proxy's OWN env (``source: {type: env}``); the sandbox never sees them. + ``upstream_deny_cidrs=None`` means the default SSRF deny list; ``[]`` opts out. ``audit_log`` + is accepted for forward-compat but unused: v0.39's ``config.Log`` rejects ``audit_path``.""" hosts: List[str] = list(allowed_hosts or _DEFAULT_ALLOWED_HOSTS) for m in mappings: for h in m.upstream_hosts: if h not in hosts: hosts.append(h) + deny_cidrs = list(_DEFAULT_UPSTREAM_DENY_CIDRS if upstream_deny_cidrs is None else upstream_deny_cidrs) - secrets_rules = [] - for m in mappings: - match_headers = list(m.match_headers or ("Authorization",)) - secrets_rules.append({ + # Per-provider headers (case-insensitive in v0.39); query scan covers ``?key=`` SDKs; + # body inspection deliberately off. ``require`` fails closed: an allowlisted-host request + # WITHOUT the proxy token is rejected, so a real key sent directly can't cross the boundary. + secrets_rules = [ + { "source": {"type": "env", "var": m.real_env_name}, "replace": { "proxy_value": m.proxy_token, - # Per-provider header set: bearer providers match only - # Authorization; header-auth providers (Anthropic native - # x-api-key, Azure api-key, Gemini x-goog-api-key) match - # their native header (+ Authorization where the provider - # also accepts bearer flows). v0.39 matches header names - # case-insensitively — see parseHeaderMatchers upstream. - "match_headers": match_headers, - # The token is also accepted as a query param — v0.39 scans - # every query parameter for the token value, which covers - # SDKs that pass ``?key=`` (Gemini) as well as - # bearer-in-query styles. Body matching is off — we - # don't want body inspection forced for every request. + "match_headers": list(m.match_headers or ("Authorization",)), "match_query": True, "match_body": False, - # Fail closed (maxpetrusenko P1): when a request reaches an - # allowlisted upstream WITHOUT the proxy token present in a - # matched location, reject it instead of forwarding as-is. - # Without this, a real provider key that a sandbox process - # sent directly (not via the minted token) would still pass - # the proxy boundary to the allowed host. With require=true, - # iron-proxy returns ActionReject when no token swap fired - # (v0.39 secrets transform: replaceConfig.Require, enforced in - # TransformRequest — verified present in the pinned version). "require": True, }, "rules": [{"host": h} for h in m.upstream_hosts], - }) + } + for m in mappings + ] - # SSRF protection: default-deny cloud metadata + loopback + RFC1918. - # Callers can pass [] to opt out entirely (hermetic tests need this for - # talking to a loopback upstream). None means "use the default". - deny_cidrs: List[str] - if upstream_deny_cidrs is None: - deny_cidrs = list(_DEFAULT_UPSTREAM_DENY_CIDRS) - else: - deny_cidrs = list(upstream_deny_cidrs) - - # Listen addresses. iron-proxy v0.39 takes a single string per - # listener field — there is no plural ``http_listens`` form, despite - # earlier drafts of this module claiming v0.39 accepts both. An - # empirical strings(1) audit + a live "start the binary and observe - # the YAML unmarshal error" confirms the singular form is the only - # one the binary accepts. - # - # LISTENER ROLES (verified live against the v0.39 binary): - # * ``tunnel_listen`` is the CONNECT + MITM listener. HTTPS through - # ``HTTPS_PROXY`` issues CONNECT — this is the listener sandboxes - # must reach. A CONNECT sent to ``http_listen`` is NOT terminated: - # v0.39 forwards it upstream as a regular request and the upstream - # responds 400. - # * ``http_listen`` is the absolute-form plain-HTTP forward listener - # (``HTTP_PROXY`` for ``http://`` URLs). Transforms fire here too. - # Both get the sandbox-facing bind host: tunnel on ``tunnel_port``, - # plain HTTP on ``tunnel_port + 1``. - # - # The bind host comes from _default_http_listen: the docker bridge - # gateway on Linux (containers reach the proxy via - # host.docker.internal, which maps to the bridge gateway there — - # loopback would be unreachable from inside sandboxes) and loopback - # on macOS/Windows Docker Desktop (where host.docker.internal routes - # to the host via VPNkit). + # v0.39 takes ONE string per listener field (no plural form). tunnel_listen is the + # CONNECT+MITM listener sandboxes must reach via HTTPS_PROXY (a CONNECT to http_listen is + # forwarded upstream and 400s); http_listen is plain-HTTP forward on tunnel_port+1. listens = list(http_listen) if http_listen else _default_http_listen(tunnel_port) primary_listen = listens[0] if listens else f"127.0.0.1:{tunnel_port}" bind_host = primary_listen.rsplit(":", 1)[0] or "127.0.0.1" - plain_http_listen = f"{bind_host}:{tunnel_port + 1}" - - log_block: Dict = {"level": "info"} - # NOTE: ``log.audit_path`` is NOT a field in iron-proxy v0.39's - # ``config.Log`` struct — the binary rejects it with - # ``field audit_path not found in type config.Log``. Per-request - # audit records are written to the same log destination as - # everything else at this binary version; the operator-facing - # ``audit.log`` file we pre-create is still useful as a sentinel - # for monitoring (logrotate target, downstream tail watchers) but - # the daemon does not write to it directly. The kwarg is kept so - # we're forward-compatible with a future v0.40+ that adds the - # field; if you upgrade _IRON_PROXY_VERSION and the upstream gains - # ``log.audit_path``, re-enable the line below. - # if audit_log is not None: - # log_block["audit_path"] = str(audit_log) - _ = audit_log # consumed by ensure_audit_log() / docs only on v0.39 return { - # DNS section is required by the binary's config parser, but we run - # in tunnel-only mode so the DNS listener never binds an exposed port. - # Sandboxes reach the proxy via HTTPS_PROXY/CONNECT, not via DNS - # redirection. - "dns": { - "listen": "127.0.0.1:0", # ephemeral loopback — effectively disabled - "proxy_ip": "127.0.0.1", - }, + # Required by the parser; tunnel-only mode never binds an exposed DNS port. + "dns": {"listen": "127.0.0.1:0", "proxy_ip": "127.0.0.1"}, "proxy": { - # tunnel_listen is the CONNECT/MITM listener — what sandboxes - # hit via `HTTPS_PROXY=http://host:tunnel_port` for HTTPS - # upstreams (curl/requests/node issue CONNECT through it). - # http_listen handles absolute-form plain-HTTP forwards - # (`HTTP_PROXY` for http:// URLs) on tunnel_port+1. Both - # bind the docker bridge gateway on Linux / loopback on - # Docker Desktop — NEVER 0.0.0.0. LAN peers with a leaked - # sandbox token would otherwise be able to spend the - # operator's API quota against any allowlisted upstream. + # Both bind the docker bridge on Linux / loopback on Docker Desktop — NEVER 0.0.0.0. "tunnel_listen": primary_listen, - "http_listen": plain_http_listen, - # The HTTPS-listener (direct TLS termination, no CONNECT) - # gets a loopback ephemeral port — we don't expose it. + "http_listen": f"{bind_host}:{tunnel_port + 1}", + # Direct-TLS listener is not exposed. "https_listen": "127.0.0.1:0", "max_request_body_bytes": 16 * 1024 * 1024, "max_response_body_bytes": 0, "upstream_response_header_timeout": "120s", - # SSRF protection: deny outbound to cloud metadata + loopback by - # default. An empty list opts out entirely. "upstream_deny_cidrs": deny_cidrs, }, - # iron-proxy v0.39 starts a Prometheus-style metrics server by - # default on ``:9090`` — which is the SAME port as our default - # ``tunnel_port: 9090``, causing a guaranteed bind collision on - # startup. Pin the metrics listener to an ephemeral loopback - # port (``127.0.0.1:0``) so the metrics binding can't collide - # with the proxy listener regardless of what tunnel_port the - # operator chose. NOTE: ``:0`` means the kernel picks a fresh - # random port each start and nothing records it — metrics are - # effectively disabled/undiscoverable at this pin. If we want - # scrapable metrics later, allocate a fixed port and surface it - # in ``ProxyStatus`` / ``hermes egress status``. - "metrics": { - "listen": "127.0.0.1:0", - }, - # Operator-facing management API — loopback only, bearer-key - # authenticated (key read from the env var named below; injected - # by ``start_proxy`` from ``management.token``). ``POST /v1/reload`` - # re-reads THIS config file and atomically swaps the transform - # pipeline — `hermes egress reload` applies allowlist/token/mapping - # changes without a restart. Loopback deliberately: sandboxes must - # never reach the management surface, so it does NOT bind the - # docker bridge like the traffic listeners do. - "management": { - "listen": f"127.0.0.1:{tunnel_port + _MGMT_PORT_OFFSET}", - "api_key_env": _MGMT_API_KEY_ENV, - }, - "tls": { - "ca_cert": str(ca_cert), - "ca_key": str(ca_key), - "cert_cache_size": 1000, - "leaf_cert_expiry_hours": 168, - }, + # v0.39 defaults its metrics server to :9090 — the same as our default tunnel_port — + # so pin it to an ephemeral loopback port (metrics effectively undiscoverable). + "metrics": {"listen": "127.0.0.1:0"}, + # Loopback only: sandboxes must never reach the management surface. + "management": {"listen": f"127.0.0.1:{tunnel_port + _MGMT_PORT_OFFSET}", "api_key_env": _MGMT_API_KEY_ENV}, + "tls": {"ca_cert": str(ca_cert), "ca_key": str(ca_key), "cert_cache_size": 1000, "leaf_cert_expiry_hours": 168}, "transforms": [ - { - "name": "allowlist", - "config": {"domains": hosts}, - }, - { - "name": "secrets", - "config": {"secrets": secrets_rules}, - }, + {"name": "allowlist", "config": {"domains": hosts}}, + {"name": "secrets", "config": {"secrets": secrets_rules}}, ], - "log": log_block, + "log": {"level": "info"}, } -def ensure_audit_log(audit_path: Path) -> None: - """Create the audit log file with private permissions (0o600). - - Called from the wizard right before ``start_proxy``. On the pinned - v0.39 the daemon never writes this file (no ``log.audit_path`` - config field), so the pre-create is purely forward-compat: when the - pin moves to a version that supports a dedicated audit stream, the - file already exists with tight permissions and the daemon inherits - them instead of creating it under the default umask. - - Raises :class:`RuntimeError` on any OSError (planted symlink, - immutable parent dir, full disk) so the caller can decide how to - surface it. The wizard treats this as a WARNING on v0.39 — the - file is non-load-bearing until the version bump — but the qualified - message keeps operators from wiring monitoring to a path that can't - exist. - """ - +def _open_private_append(path: Path, *, strict_chmod: bool) -> int: + """Open ``path`` O_APPEND|O_CREAT 0o600 with O_NOFOLLOW (planted symlinks refused) and tighten + a pre-existing file via fchmod (its failure is fatal iff ``strict_chmod``). Raises OSError; + caller owns the fd.""" + fd = os.open(str(path), os.O_WRONLY | os.O_CREAT | os.O_APPEND | _O_NOFOLLOW, 0o600) try: - # Use os.open + O_CREAT to avoid races on the chmod. - open_flags = os.O_WRONLY | os.O_CREAT | os.O_APPEND - if hasattr(os, "O_NOFOLLOW"): - open_flags |= os.O_NOFOLLOW - fd = os.open(str(audit_path), open_flags, 0o600) - try: - # Tighten perms even if the file already existed under a - # slacker umask. - os.fchmod(fd, 0o600) - finally: + os.fchmod(fd, 0o600) + except OSError: + if strict_chmod: os.close(fd) + raise + return fd + + +def ensure_audit_log(audit_path: Path) -> None: + """Pre-create the audit log 0o600 (forward-compat: v0.39 never writes it, no ``log.audit_path``). + Raises RuntimeError on any OSError (planted symlink, immutable dir, full disk).""" + try: + os.close(_open_private_append(audit_path, strict_chmod=True)) except OSError as exc: raise RuntimeError( f"Refusing to start: could not pre-create audit log " @@ -1356,45 +758,31 @@ def ensure_audit_log(audit_path: Path) -> None: ) from exc +def _write_state_file_atomic(state: Path, name: str, dump) -> Path: + """Write ``/`` via a 0600 temp file + atomic replace: the file holds proxy + token values, and chmod-after-replace would leave a world-readable TOCTOU window.""" + tmp_path = state / f".{name}.tmp" + with open(tmp_path, "w", encoding="utf-8") as f: + dump(f) + os.chmod(tmp_path, 0o600) + os.replace(tmp_path, state / name) + return state / name + + def write_proxy_config(config: Dict) -> Path: - """Serialize the config dict to ``/proxy/proxy.yaml``. - - Uses ``yaml.safe_dump`` so we never emit Python tags. - """ - + """Serialize the config dict to ``/proxy/proxy.yaml`` (safe_dump, no Python tags).""" try: import yaml # PyYAML is already a Hermes dep except ImportError as exc: - raise RuntimeError( - "PyYAML is required to write the iron-proxy config but is not " - "installed." - ) from exc - - state = _proxy_state_dir() - out = state / "proxy.yaml" - tmp_path = state / ".proxy.yaml.tmp" - with open(tmp_path, "w", encoding="utf-8") as f: - yaml.safe_dump(config, f, default_flow_style=False, sort_keys=False) - # Tighten perms on the temp file BEFORE the atomic replace so the - # final path is never briefly world-readable under a slack umask - # (the config embeds proxy token values). chmod-after-replace would - # leave a TOCTOU window; the 0o700 state dir mitigates but same-uid - # processes could still race. - os.chmod(tmp_path, stat.S_IRUSR | stat.S_IWUSR) - os.replace(tmp_path, out) - return out + raise RuntimeError("PyYAML is required to write the iron-proxy config but is not installed.") from exc + return _write_state_file_atomic( + _proxy_state_dir(), "proxy.yaml", + lambda f: yaml.safe_dump(config, f, default_flow_style=False, sort_keys=False), + ) def write_mappings(mappings: List[TokenMapping]) -> Path: - """Persist the sandbox-visible proxy tokens to ``mappings.json``. - - The Docker backend reads this file to inject the right tokens as env - vars when starting a sandbox. The file is NOT read by iron-proxy - itself — the mapping is already baked into ``proxy.yaml``. - """ - - state = _proxy_state_dir() - out = state / "mappings.json" + """Persist sandbox-visible tokens to ``mappings.json`` (read by the Docker backend, not iron-proxy).""" payload = { "version": 1, "tokens": [ @@ -1408,22 +796,12 @@ def write_mappings(mappings: List[TokenMapping]) -> Path: for m in mappings ], } - tmp_path = state / ".mappings.json.tmp" - with open(tmp_path, "w", encoding="utf-8") as f: - json.dump(payload, f, indent=2) - # chmod before the atomic replace — see write_proxy_config. The - # mappings file holds proxy token values, so close the TOCTOU window - # rather than chmod-ing after the file is already at its final path. - os.chmod(tmp_path, stat.S_IRUSR | stat.S_IWUSR) - os.replace(tmp_path, out) - return out + return _write_state_file_atomic(_proxy_state_dir(), "mappings.json", lambda f: json.dump(payload, f, indent=2)) def load_mappings() -> List[TokenMapping]: """Read mappings.json, if it exists. Empty list on any error.""" - - state = _proxy_state_dir() - f = state / "mappings.json" + f = _proxy_state_dir() / "mappings.json" if not f.exists(): return [] try: @@ -1438,9 +816,7 @@ def load_mappings() -> List[TokenMapping]: proxy_token=item["proxy_token"], real_env_name=item["env_name"], upstream_hosts=tuple(item.get("upstream_hosts") or ()), - # Pre-header-auth mappings.json files (written before the - # match_headers/alias fields existed) load with the bearer - # defaults — identical to their behavior at write time. + # Pre-header-auth files load with the bearer defaults they were written under. match_headers=tuple(item.get("match_headers") or ("Authorization",)), alias_env_names=tuple(item.get("alias_env_names") or ()), )) @@ -1449,122 +825,52 @@ def load_mappings() -> List[TokenMapping]: return out -def discover_provider_mappings( - *, - available_env_names: Optional[List[str]] = None, -) -> List[TokenMapping]: - """Mint a TokenMapping for every known provider whose env var is set. - - Pass ``available_env_names`` to override the lookup source (used by the - Bitwarden adapter so we mint mappings for keys that *will* be in the - proxy's environment even if they aren't in the host process env right - now). - """ - +def _env_names(available_env_names: Optional[List[str]]) -> set: + """Explicit override (Bitwarden adapter) or the non-empty names in the host env.""" if available_env_names is not None: - names = set(available_env_names) - else: - names = {k for k, v in os.environ.items() if v} + return set(available_env_names) + return {k for k, v in os.environ.items() if v} - mappings: List[TokenMapping] = [] - for env_name, hosts in _BEARER_PROVIDERS.items(): - if env_name not in names: - continue - mappings.append(TokenMapping( + +def discover_provider_mappings(*, available_env_names: Optional[List[str]] = None) -> List[TokenMapping]: + """Mint a TokenMapping for every known provider whose env var is set (bearer providers first). + Canonical OR any alias present -> ONE mapping keyed on the canonical name (the subprocess-env + builder mirrors an alias value into it).""" + names = _env_names(available_env_names) + specs = [(n, h, ("Authorization",), ()) for n, h in _BEARER_PROVIDERS.items()] + [ + (n, tuple(s["hosts"]), tuple(s["match_headers"]), tuple(s.get("aliases") or ())) + for n, s in _HEADER_AUTH_PROVIDERS.items() + ] + return [ + TokenMapping( proxy_token=mint_proxy_token(prefix=env_name.lower().replace("_api_key", "")), real_env_name=env_name, upstream_hosts=hosts, - )) - for env_name, spec in _HEADER_AUTH_PROVIDERS.items(): - aliases = tuple(spec.get("aliases") or ()) - # A mapping is minted when the canonical name OR any alias is - # available. Aliases collapse into ONE mapping (single secrets - # rule) because two require-rules on the same host would reject - # each other's requests. The canonical env name is what - # iron-proxy reads — when only the alias is set in the host env, - # the subprocess-env builder mirrors it (see - # ``_build_proxy_subprocess_env``). - if env_name not in names and not any(a in names for a in aliases): - continue - mappings.append(TokenMapping( - proxy_token=mint_proxy_token(prefix=env_name.lower().replace("_api_key", "")), - real_env_name=env_name, - upstream_hosts=tuple(spec["hosts"]), - match_headers=tuple(spec["match_headers"]), + match_headers=headers, alias_env_names=aliases, - )) - return mappings + ) + for env_name, hosts, headers, aliases in specs + if env_name in names or any(a in names for a in aliases) + ] -def discover_uncovered_providers( - *, - available_env_names: Optional[List[str]] = None, -) -> List[str]: - """Return env-var names for providers we recognize but can't proxy. - - AWS Bedrock (SigV4) and GCP Vertex (SDK-minted OAuth) can't be swapped - by a static header replacement. When any of these are configured, the - sandbox is holding real credentials that the proxy can't strip — the - isolation guarantee is incomplete for those providers. - - The wizard and ``hermes egress status`` use this to print a warning. - (Anthropic / Azure OpenAI / Gemini used to be here; they're now - first-class swapped providers via ``_HEADER_AUTH_PROVIDERS``.) - """ - - if available_env_names is not None: - names = set(available_env_names) - else: - names = {k for k, v in os.environ.items() if v} - +def discover_uncovered_providers(*, available_env_names: Optional[List[str]] = None) -> List[str]: + """Env names of recognized providers the proxy can't swap (SigV4 / SDK-minted OAuth).""" + names = _env_names(available_env_names) return [n for n in _NON_BEARER_PROVIDERS if n in names] def merge_mappings( - *, - existing: List[TokenMapping], - discovered: List[TokenMapping], - rotate: bool = False, + *, existing: List[TokenMapping], discovered: List[TokenMapping], rotate: bool = False, ) -> List[TokenMapping]: - """Combine an existing mapping set with freshly discovered providers. - - By default this PRESERVES tokens for providers already in ``existing`` — - re-running ``hermes egress setup`` should not invalidate the tokens - baked into containers that are already running. Only newly added - providers get freshly minted tokens. - - When ``rotate=True``, every token in the result is freshly minted - regardless of overlap. The wizard exposes this via ``--rotate-tokens`` - for the rare case where the operator wants to roll all tokens - deliberately (e.g. after a suspected token leak). - - Providers that are in ``existing`` but no longer in ``discovered`` - (operator removed the env var since last setup) are dropped. - """ - - by_name = {m.real_env_name: m for m in existing} - out: List[TokenMapping] = [] - for d in discovered: - prior = by_name.get(d.real_env_name) - if prior is not None and not rotate: - # Preserve the token; refresh hosts/headers/aliases in case - # the provider spec changed since last setup (new upstreams, - # a provider moving from uncovered to header-auth, etc). - out.append(TokenMapping( - proxy_token=prior.proxy_token, - real_env_name=prior.real_env_name, - upstream_hosts=d.upstream_hosts, - match_headers=d.match_headers, - alias_env_names=d.alias_env_names, - )) - else: - out.append(d) - return out - - -# --------------------------------------------------------------------------- -# Subprocess lifecycle -# --------------------------------------------------------------------------- + """Combine existing and freshly discovered mappings. Tokens already in ``existing`` are + preserved (containers baked with them keep working) while hosts/headers/aliases are refreshed + from ``discovered``; ``rotate=True`` re-mints everything; undiscovered providers are dropped.""" + by_name = {} if rotate else {m.real_env_name: m for m in existing} + return [ + replace(d, proxy_token=by_name[d.real_env_name].proxy_token) if d.real_env_name in by_name else d + for d in discovered + ] def _pidfile() -> Path: @@ -1572,187 +878,91 @@ def _pidfile() -> Path: def _read_pid() -> Optional[int]: - # Use the read-only path: don't create the proxy dir just to read the - # pidfile. If neither pid file nor dir exists, the daemon is plainly - # not running. - pf = _proxy_state_dir_ro() / "iron-proxy.pid" - if not pf.exists(): - return None try: - pid = int(pf.read_text(encoding="utf-8").strip()) + pid = int((_proxy_state_dir_ro() / "iron-proxy.pid").read_text(encoding="utf-8").strip()) except (OSError, ValueError): return None return pid if pid > 0 else None -# Nonce env-var set in the iron-proxy subprocess at start_proxy time. Used -# by ``_pid_alive`` to confirm a candidate PID still refers to *our* managed -# binary even across PID recycling (a fresh process can't inherit our -# arbitrary env value). -_HERMES_IRON_PROXY_NONCE_ENV = "HERMES_IRON_PROXY_NONCE" -_proxy_nonce: Optional[str] = None - - def _pid_proc_starttime(pid: int) -> Optional[str]: - """Return /proc//stat[21] (starttime) on Linux, else None. - - Comparing starttime is the standard cheap way to detect PID recycling - without relying on cmdline scanning. When None, callers fall back to - the cmdline + nonce check. - """ + """/proc//stat starttime (field 22) on Linux, else None — cheap PID-recycling detector.""" try: text = Path(f"/proc/{pid}/stat").read_text(encoding="utf-8") except OSError: return None - # /proc//stat: pid (comm-with-parens) state ppid ... fields[21]=starttime - # The "comm" field can contain spaces and parens, so split from the - # right parenthesis instead of using shlex. + # comm may contain spaces/parens, so split after the LAST ")"; field 22 -> tail index 19. rparen = text.rfind(")") - if rparen < 0: - return None - fields = text[rparen + 1:].split() - # field index in the post-")" tail: original 3..n become fields[0..n-3] - # starttime is original field 22 (1-indexed) → tail index 22-3 = 19 - if len(fields) <= 19: - return None - return fields[19] + fields = text[rparen + 1:].split() if rparen >= 0 else [] + return fields[19] if len(fields) > 19 else None def _persisted_nonce_path() -> Path: - """Path to the on-disk sibling of the pidfile that stores the nonce. - - Written by ``_write_pidfile_safely`` after ``start_proxy`` plants - the nonce in the iron-proxy child env, read by ``_pid_alive`` in a - later CLI invocation (``stop`` / ``status``) so cross-process - PID-recycling defense holds. - """ + """On-disk nonce sibling of the pidfile, so stop/status in a later CLI process can still + defeat PID recycling.""" return _proxy_state_dir_ro() / "iron-proxy.nonce" def _read_persisted_nonce() -> Optional[str]: - """Read the on-disk nonce written next to the pidfile. - - Returns None when the file is missing, unreadable, or empty — - callers fall back to argv0 basename matching in that case. - """ - p = _persisted_nonce_path() + """Nonce from disk, or None if missing/unreadable/empty/not owned by us (callers fall back + to argv0 matching). O_NOFOLLOW: this read decides whether stop_proxy SIGKILLs a PID.""" try: - # O_NOFOLLOW: defence-in-depth against a planted symlink at the - # nonce path; same-uid required to plant one but worth defending - # since the nonce read here decides whether stop_proxy will - # SIGKILL a candidate PID. - flags = os.O_RDONLY - if hasattr(os, "O_NOFOLLOW"): - flags |= os.O_NOFOLLOW - fd = os.open(str(p), flags) + fd = os.open(str(_persisted_nonce_path()), os.O_RDONLY | _O_NOFOLLOW) except OSError: return None try: - # Ownership check — if the file isn't owned by us, ignore it. - # Same threat model as the pidfile uid check. - try: - st = os.fstat(fd) - if hasattr(os, "getuid") and st.st_uid != os.getuid(): - return None - except AttributeError: - pass - data = os.read(fd, 256).decode("utf-8", errors="ignore").strip() - return data or None + if not _fd_owned_by_us(fd): + return None + return os.read(fd, 256).decode("utf-8", errors="ignore").strip() or None finally: os.close(fd) def _pid_alive(pid: int) -> bool: - """Return True iff ``pid`` is alive AND is an iron-proxy process. - - Defends against PID reuse via three signals (in priority order): - 1. ``/proc//environ`` contains our nonce (most reliable, Linux) - 2. ``/proc//cmdline`` basename matches the managed binary - 3. ``ps -p `` command line contains the binary path - - The legacy ``"iron-proxy" in cmdline`` match was loose enough to match - ``tail iron-proxy.log`` or an editor with that file open. We tighten - on argv[0] basename plus an in-process nonce instead. - """ - + """True iff ``pid`` is alive AND is an iron-proxy process. PID-reuse defense, in priority + order: nonce in /proc//environ, argv[0] basename in /proc//cmdline, ``ps -o comm=`` + basename. A loose ``"iron-proxy" in cmdline`` match would hit ``tail iron-proxy.log``.""" if pid <= 0: return False try: - # Use psutil.pid_exists when available — it's a no-op on Windows - # whereas os.kill(pid, 0) on Windows is actually a hard kill - # (CTRL_C_EVENT to the target's console process group). See - # bpo-14484. windows-footgun: ok — we explicitly skip the - # os.kill probe on Windows below. + # psutil when available: os.kill(pid, 0) on Windows is a HARD kill (bpo-14484). import psutil # type: ignore if not psutil.pid_exists(pid): return False except ImportError: - if platform.system() == "Windows": - # On Windows without psutil we can't safely probe — assume - # the pidfile content is fresh and confirm via the cmdline - # path below. os.kill(pid, 0) is NOT safe here. - pass - else: + if platform.system() != "Windows": try: os.kill(pid, 0) # windows-footgun: ok — POSIX-only branch except (ProcessLookupError, PermissionError, OSError): return False - # Strong proof: nonce env var matches. /proc//environ is null- - # separated KEY=VALUE pairs; substring search is safe. - # - # The nonce can come from either: - # 1. the module-global ``_proxy_nonce`` set during this process's - # own ``start_proxy`` call (same-process case); - # 2. the on-disk ``iron-proxy.nonce`` file written by - # ``_write_pidfile_safely``, used when ``start`` and ``stop`` - # run in separate CLI invocations (cross-process case). - # Either source provides the same defeat-PID-recycling guarantee. - nonce_candidates: List[str] = [] - if _proxy_nonce: - nonce_candidates.append(_proxy_nonce) - on_disk = _read_persisted_nonce() - if on_disk and on_disk not in nonce_candidates: - nonce_candidates.append(on_disk) + # Strong proof: nonce from this process's start_proxy and/or the on-disk sibling file. + nonce_candidates = [n for n in dict.fromkeys((_proxy_nonce, _read_persisted_nonce())) if n] if nonce_candidates: try: env_bytes = Path(f"/proc/{pid}/environ").read_bytes() - for nonce in nonce_candidates: - needle = f"{_HERMES_IRON_PROXY_NONCE_ENV}={nonce}".encode() - if needle in env_bytes: - return True + if any(f"{_HERMES_IRON_PROXY_NONCE_ENV}={n}".encode() in env_bytes for n in nonce_candidates): + return True except OSError: pass - # Fallback: cmdline basename match. argv[0] is the first null- - # separated token in /proc//cmdline. try: cmdline_path = Path(f"/proc/{pid}/cmdline") if cmdline_path.exists(): - tokens = cmdline_path.read_bytes().split(b"\x00") - if tokens: - argv0 = tokens[0].decode("utf-8", errors="ignore") - argv0_base = os.path.basename(argv0) - if argv0_base.startswith("iron-proxy"): - return True - return False + argv0 = cmdline_path.read_bytes().split(b"\x00")[0].decode("utf-8", errors="ignore") + return os.path.basename(argv0).startswith("iron-proxy") except OSError: pass - # macOS / non-Linux fallback: ``ps`` command basename. + # macOS / non-Linux fallback. try: - res = subprocess.run( # noqa: S603 - ["ps", "-p", str(pid), "-o", "comm="], - capture_output=True, text=True, encoding="utf-8", errors="replace", timeout=2, - ) + res = _run(["ps", "-p", str(pid), "-o", "comm="], timeout=2, text=True) if res.returncode == 0: - comm = (res.stdout or "").strip() - return os.path.basename(comm).startswith("iron-proxy") + return os.path.basename((res.stdout or "").strip()).startswith("iron-proxy") except (OSError, subprocess.TimeoutExpired): pass - # Exotic platforms: be conservative — if the OS says alive we believe - # it. This restores the previous behaviour for non-Linux/non-macOS. + # Exotic platforms: if the OS says alive, believe it. return True @@ -1765,265 +975,145 @@ def start_proxy( refresh_secrets_from_bitwarden: bool = False, bitwarden_config: Optional[Dict] = None, ) -> ProxyStatus: - """Spawn iron-proxy as a managed background subprocess. - - Idempotent — if the proxy is already running with the expected PID, - just returns the live status. - - ``refresh_secrets_from_bitwarden=True`` re-fetches upstream secrets - via ``bws secret list`` at startup and injects them into the child - env. This delivers the rotation promise that distinguishes - ``credential_source: bitwarden`` from ``credential_source: env``. - Without this flag (or with ``bitwarden_config=None``) the proxy still - starts but uses whatever the host process env happens to contain. - """ - + """Spawn iron-proxy as a managed background subprocess (idempotent if already running). + ``refresh_secrets_from_bitwarden=True`` re-fetches upstream secrets from BWS at startup — + the rotation promise of ``credential_source: bitwarden``.""" global _proxy_nonce existing = _read_pid() if existing and _pid_alive(existing): return get_status() - bin_path = binary or find_iron_proxy(install_if_missing=install_if_missing) if bin_path is None: - raise RuntimeError( - "iron-proxy binary not available — run `hermes egress install`." - ) - + raise RuntimeError("iron-proxy binary not available — run `hermes egress install`.") cfg = config_path or (_proxy_state_dir() / "proxy.yaml") if not cfg.exists(): - raise RuntimeError( - f"iron-proxy config not found at {cfg}. " - "Run `hermes egress setup` first." - ) + raise RuntimeError(f"iron-proxy config not found at {cfg}. Run `hermes egress setup` first.") - # Build a minimal subprocess env. os.environ.copy() would ship every - # secret in the operator's shell to the proxy — /proc//environ - # would then expose OPENAI_API_KEY, AWS keys, etc. to any same-uid - # local process. Defeats the threat model the proxy exists to - # mitigate. + # Minimal env: os.environ.copy() would expose every operator secret via /proc//environ. env = _build_proxy_subprocess_env( extra_env=extra_env, refresh_from_bitwarden=refresh_secrets_from_bitwarden, bitwarden_config=bitwarden_config, ) - - # If the generated config enables the management API, the daemon - # validates at startup that the api_key_env is non-empty. Inject the - # persisted key (minting it if this is a config written by a newer - # setup but the token file was removed). + # v0.39 validates api_key_env is non-empty when management.listen is set. if _read_management_listen_from_config(cfg) is not None: env[_MGMT_API_KEY_ENV] = ensure_management_token() - - # Plant a per-start nonce in the child env so ``_pid_alive`` can - # confirm a candidate PID still refers to *our* binary across PID - # recycling. Module-global is fine — only one managed proxy per - # Hermes process. + # Per-start nonce for PID-recycling defense; module-global is fine (one proxy per process). _proxy_nonce = hashlib.sha256(os.urandom(16)).hexdigest() env[_HERMES_IRON_PROXY_NONCE_ENV] = _proxy_nonce log_path = _proxy_state_dir() / "iron-proxy.log" - # Keep ownership of the fd tight: open with explicit 0o600 so the - # log doesn't get world-readable under a slack umask, then close it - # immediately after Popen (the child has its own dup). Without the - # close-on-success path, every restart leaked one fd in the Hermes - # process. - # - # O_NOFOLLOW (defence-in-depth, same threat model as the pidfile - # path): a same-uid attacker who plants ``iron-proxy.log`` as a - # symlink to e.g. ``~/.ssh/authorized_keys`` would otherwise cause - # every restart to append daemon diagnostics to that file. - log_open_flags = os.O_WRONLY | os.O_CREAT | os.O_APPEND - if hasattr(os, "O_NOFOLLOW"): - log_open_flags |= os.O_NOFOLLOW - try: - log_fd = os.open(str(log_path), log_open_flags, 0o600) - except OSError as exc: - # ELOOP from a planted symlink — refuse with a clear error. - raise RuntimeError( - f"Refusing to write iron-proxy log {log_path}: {exc}. " - "Remove that path manually and retry." - ) from exc - try: - os.fchmod(log_fd, 0o600) # tighten if file pre-existed - except OSError: - pass - # Verify ownership — same st_uid check the pidfile uses. - try: - st = os.fstat(log_fd) - if hasattr(os, "getuid") and st.st_uid != os.getuid(): - os.close(log_fd) - raise RuntimeError( - f"iron-proxy log {log_path} has unexpected owner " - f"uid={st.st_uid}; refusing to write." - ) - except AttributeError: - pass # Windows + proc = _spawn_daemon(bin_path, cfg, env, log_path) - try: - # Use the fd directly via the dup mechanism; Popen will dup() it - # into the child so we can close ours unconditionally below. - # NOTE: on Windows ``start_new_session`` is invalid; we don't - # support Windows for the proxy (the binary itself doesn't ship) - # but the kwarg is POSIX-only and silently ignored on Win. - popen_kwargs: Dict = dict( - env=env, - stdin=subprocess.DEVNULL, - stdout=log_fd, - stderr=subprocess.STDOUT, - ) - if platform.system() != "Windows": - popen_kwargs["start_new_session"] = True - proc = subprocess.Popen( # noqa: S603 — binary path is trusted - [str(bin_path), "-config", str(cfg)], - **popen_kwargs, - ) - except OSError as exc: - os.close(log_fd) - raise RuntimeError(f"failed to spawn iron-proxy: {exc}") from exc - finally: - # Close our copy of the fd whether Popen raised or succeeded. - # The child has its own dup via Popen, so it's still writing. - try: - os.close(log_fd) - except OSError: - pass - - # Write the pidfile IMMEDIATELY after Popen, BEFORE the listening - # verification. If the parent dies during the poll loop (SIGINT, - # OOM, kernel pause), the pidfile is still on disk so the next - # ``hermes egress stop`` can clean up the orphan. Failure paths - # below unlink the pidfile when they kill the child. + # Pidfile BEFORE the listening poll: if the parent dies mid-poll, `hermes egress stop` + # can still clean up the orphan. pidfile = _pidfile() try: _write_pidfile_safely(pidfile, proc.pid) except RuntimeError: - # Kill the orphan so we don't leave a daemon nobody can stop. _kill_and_wait(proc, grace_seconds=2) raise - # Poll-with-timeout instead of an unconditional 5s sleep. The Go - # binary normally comes up in <200ms; falling through within 100ms - # of liveness keeps Docker container creation snappy. - # - # We scope a Ctrl-C handler around the poll loop so an operator who - # hits Ctrl-C while waiting for ``hermes egress start`` doesn't leak - # an orphan with the port bound. - # - # Probe the CONFIGURED bind host, not loopback unconditionally — on - # Linux the daemon binds the docker bridge gateway, where a loopback - # connect never succeeds and we'd kill a healthy daemon as "never - # came up". - listen_hp = _read_http_listen_from_config() - if listen_hp is not None: - probe_host, tunnel_port = listen_hp - else: - probe_host, tunnel_port = "127.0.0.1", _DEFAULT_TUNNEL_PORT - listening = False + def _abort(msg: str, *, kill: bool) -> RuntimeError: + tail = _tail_log(log_path, lines=20) + if kill: + _kill_and_wait(proc, grace_seconds=2) + pidfile.unlink(missing_ok=True) + return RuntimeError(f"{msg}Last log lines:\n{tail}") + + def _exited_error() -> RuntimeError: + return _abort(f"iron-proxy exited immediately (code {proc.returncode}). ", kill=False) def _interrupt_handler(_signum, _frame): # pragma: no cover - signal path - # Kill the child and unlink the pidfile, then re-raise so the - # caller sees the interrupt. + # Ctrl-C while waiting must not leak an orphan holding the port. _kill_and_wait(proc, grace_seconds=2) - try: - pidfile.unlink() - except FileNotFoundError: - pass + pidfile.unlink(missing_ok=True) raise KeyboardInterrupt() - prev_sigint = None - prev_sigterm = None - install_handlers = ( - platform.system() != "Windows" - and threading.current_thread() is threading.main_thread() - ) + # Poll with timeout (the Go binary is up in <200ms). Probe the CONFIGURED bind host — on + # Linux that's the docker bridge, where loopback never connects. + probe_host, tunnel_port = _probe_target() + install_handlers = platform.system() != "Windows" and threading.current_thread() is threading.main_thread() + prev_sigint = prev_sigterm = None if install_handlers: prev_sigint = signal.signal(signal.SIGINT, _interrupt_handler) prev_sigterm = signal.signal(signal.SIGTERM, _interrupt_handler) try: - deadline = time.time() + _STARTUP_GRACE_SECONDS - # Do-while shape: check listening at least once even when the - # grace window is 0 (test harness / synchronous fast-path). - while True: - if proc.poll() is not None: - tail = _tail_log(log_path, lines=20) - try: - pidfile.unlink() - except FileNotFoundError: - pass - raise RuntimeError( - f"iron-proxy exited immediately (code {proc.returncode}). " - f"Last log lines:\n{tail}" - ) - if _port_listening(probe_host, tunnel_port): - listening = True - break - if time.time() >= deadline: - break - time.sleep(0.1) + listening = _await_listening(proc, probe_host, tunnel_port, on_exit=_exited_error) finally: if install_handlers: signal.signal(signal.SIGINT, prev_sigint) signal.signal(signal.SIGTERM, prev_sigterm) - # Final exit check — process may have died right at deadline. + # Process may have died right at deadline. if proc.poll() is not None: - tail = _tail_log(log_path, lines=20) - try: - pidfile.unlink() - except FileNotFoundError: - pass - raise RuntimeError( - f"iron-proxy exited immediately (code {proc.returncode}). " - f"Last log lines:\n{tail}" - ) + raise _exited_error() - # The previous version of this code treated "process still alive at - # deadline" as success. That left iron-proxy running but - # non-listening on the port, with a pidfile pointing at it — - # subsequent restarts would fail with "address in use" because the - # orphan still held the port. Require port-listening for success. + # Alive-but-not-listening is a failure: an orphan holding the port breaks every restart. if not listening: - tail = _tail_log(log_path, lines=20) - _kill_and_wait(proc, grace_seconds=2) - try: - pidfile.unlink() - except FileNotFoundError: - pass - raise RuntimeError( + raise _abort( f"iron-proxy did not bind {probe_host}:{tunnel_port} within " - f"{_STARTUP_GRACE_SECONDS}s. Process was killed. " - f"Last log lines:\n{tail}" + f"{_STARTUP_GRACE_SECONDS}s. Process was killed. ", + kill=True, ) logger.info("Started iron-proxy pid=%s config=%s", proc.pid, cfg) return get_status() +def _spawn_daemon(bin_path: Path, cfg: Path, env: Dict[str, str], log_path: Path) -> "subprocess.Popen": + """Popen the daemon with stdout/stderr appended to ``log_path`` (0o600 from the first byte, + O_NOFOLLOW so a planted symlink e.g. to authorized_keys can't receive output, owner-checked). + Our log fd is closed right after Popen — the child has its dup.""" + try: + log_fd = _open_private_append(log_path, strict_chmod=False) + except OSError as exc: + raise RuntimeError( + f"Refusing to write iron-proxy log {log_path}: {exc}. Remove that path manually and retry." + ) from exc + if not _fd_owned_by_us(log_fd): + st = os.fstat(log_fd) + os.close(log_fd) + raise RuntimeError(f"iron-proxy log {log_path} has unexpected owner uid={st.st_uid}; refusing to write.") + + try: + # start_new_session is POSIX-only (Windows isn't supported anyway — no upstream binary). + popen_kwargs: Dict = dict(env=env, stdin=subprocess.DEVNULL, stdout=log_fd, stderr=subprocess.STDOUT) + if platform.system() != "Windows": + popen_kwargs["start_new_session"] = True + return subprocess.Popen([str(bin_path), "-config", str(cfg)], **popen_kwargs) # noqa: S603 + except OSError as exc: + raise RuntimeError(f"failed to spawn iron-proxy: {exc}") from exc + finally: + try: + os.close(log_fd) + except OSError: + pass + + +def _await_listening(proc: "subprocess.Popen", host: str, port: int, *, on_exit) -> bool: + """Poll until ``host:port`` accepts or the grace window lapses (do-while: checks at least + once even with a 0s window). Raises ``on_exit()`` if the child dies meanwhile.""" + deadline = time.time() + _STARTUP_GRACE_SECONDS + while True: + if proc.poll() is not None: + raise on_exit() + if _port_listening(host, port): + return True + if time.time() >= deadline: + return False + time.sleep(0.1) + + def _write_pidfile_safely(pidfile: Path, pid: int) -> None: - """Write ``pid`` to ``pidfile`` with O_EXCL + O_NOFOLLOW + ownership check. - - O_EXCL means "another start is in progress" if the file already - exists with a live owner — we cleanly fail rather than racing. When - the existing pidfile points at a dead pid (stale crash), we - explicitly unlink it before retrying once. - - Side effect: also persists the in-process nonce to disk so - cross-CLI-invocation ``_pid_alive`` checks (start in one process, - stop in another) can still defeat PID recycling. - """ - open_flags = os.O_WRONLY | os.O_CREAT | os.O_EXCL - if hasattr(os, "O_NOFOLLOW"): - open_flags |= os.O_NOFOLLOW + """Write the pidfile with O_EXCL + O_NOFOLLOW + ownership check, then persist the nonce. + O_EXCL on an existing pidfile means either a concurrent start (fail cleanly) or a stale + crash leftover (unlink and retry once).""" + open_flags = os.O_WRONLY | os.O_CREAT | os.O_EXCL | _O_NOFOLLOW try: fd = os.open(str(pidfile), open_flags, 0o600) except FileExistsError: - # Pidfile already exists. If it points at a live iron-proxy, - # caller's _read_pid + _pid_alive at the top of start_proxy - # should already have returned. Reaching here means EITHER - # the previous _pid_alive check raced (rare; another start in - # flight), OR a stale pidfile survived a crash. Discriminate - # and retry once with O_TRUNC if stale. existing_pid = _read_pid() if existing_pid and _pid_alive(existing_pid): raise RuntimeError( @@ -2031,56 +1121,27 @@ def _write_pidfile_safely(pidfile: Path, pid: int) -> None: f"(pidfile {pidfile} -> pid {existing_pid}). " f"Run `hermes egress stop` if that proxy is stuck." ) - # Stale — unlink and retry. - try: - pidfile.unlink() - except FileNotFoundError: - pass + pidfile.unlink(missing_ok=True) fd = os.open(str(pidfile), open_flags, 0o600) except OSError as exc: # ELOOP from a planted symlink at the pidfile path. raise RuntimeError( - f"Refusing to write pidfile {pidfile}: {exc}. " - "Remove that path manually and retry." + f"Refusing to write pidfile {pidfile}: {exc}. Remove that path manually and retry." ) from exc try: - # Ownership check — same st_uid pattern the log file uses. - try: - st = os.fstat(fd) - if hasattr(os, "getuid") and st.st_uid != os.getuid(): - raise RuntimeError( - f"pidfile {pidfile} has unexpected owner uid={st.st_uid}" - ) - except AttributeError: - pass # Windows + if not _fd_owned_by_us(fd): + raise RuntimeError(f"pidfile {pidfile} has unexpected owner uid={os.fstat(fd).st_uid}") os.write(fd, str(pid).encode("utf-8")) finally: os.close(fd) - # Persist the nonce next to the pidfile (sibling, 0o600). - # ``stop_proxy`` in a separate CLI invocation can read this and use - # it to confirm the pid still refers to our binary even though the - # module-global ``_proxy_nonce`` is fresh in the new process. + # Best-effort nonce sibling (0o600); without it stop falls back to argv0 matching. if _proxy_nonce: - noncefile = pidfile.with_suffix(".nonce") - nfd = -1 try: - nopen = os.O_WRONLY | os.O_CREAT | os.O_TRUNC - if hasattr(os, "O_NOFOLLOW"): - nopen |= os.O_NOFOLLOW - nfd = os.open(str(noncefile), nopen, 0o600) - os.write(nfd, _proxy_nonce.encode("utf-8")) + _write_private_file(pidfile.with_suffix(".nonce"), _proxy_nonce.encode("utf-8")) except OSError: - # Best-effort. Without the nonce file we fall back to - # argv0-basename matching, which is what we did before. pass - finally: - if nfd >= 0: - try: - os.close(nfd) - except OSError: - pass def _kill_and_wait(proc: "subprocess.Popen", *, grace_seconds: int = 2) -> None: @@ -2108,38 +1169,18 @@ def _build_proxy_subprocess_env( refresh_from_bitwarden: bool = False, bitwarden_config: Optional[Dict] = None, ) -> Dict[str, str]: - """Construct the minimal env for the iron-proxy subprocess. - - Allowlists infrastructure vars (PATH, HOME, locale) plus the env vars - named in ``load_mappings()`` (the real upstream secrets the proxy - needs to do the swap). Everything else is stripped — see - ``_PROXY_SUBPROCESS_ENV_STRIP`` for proxy chain protection. - - When ``refresh_from_bitwarden=True`` AND ``bitwarden_config`` is - populated, fetches upstream secrets via the BSM SDK at startup and - merges them in. This is what delivers the rotation guarantee - promised by ``credential_source: bitwarden`` — without it, rotating - a key in the Bitwarden web app doesn't reach the proxy. - """ - - env: Dict[str, str] = {} + """Minimal env for the daemon: allowlisted infra vars + the secrets named in mappings. With + ``refresh_from_bitwarden`` and a populated ``bitwarden_config``, secrets are fetched from BWS + at startup (the rotation guarantee); without ``allow_env_fallback`` any BWS shortfall fails + closed rather than silently keeping stale host-env values.""" + env = _allowlisted_env() parent = os.environ - for name in _PROXY_SUBPROCESS_ENV_ALLOWLIST: - if name in parent: - env[name] = parent[name] - # The proxy reads the real upstream secrets from its OWN env, indexed - # by ``m.real_env_name`` in the YAML config's ``secrets.source.var`` - # field. Forward those — but only those. For alias providers - # (GEMINI_API_KEY / GOOGLE_API_KEY), the rule is keyed on the canonical - # name; when only the alias is set in the host env, mirror its value - # into the canonical name so the swap still has a real secret. - alias_sources: Dict[str, Tuple[str, ...]] = {} - needed = set() - for m in load_mappings(): - needed.add(m.real_env_name) - if m.alias_env_names: - alias_sources[m.real_env_name] = tuple(m.alias_env_names) + # Forward ONLY the secrets named in mappings. For alias providers the rule is keyed on the + # canonical name; mirror the alias value into it when only the alias is set. + mappings = load_mappings() + needed = {m.real_env_name for m in mappings} + alias_sources = {m.real_env_name: m.alias_env_names for m in mappings if m.alias_env_names} for name in needed: if name in parent: env[name] = parent[name] @@ -2149,115 +1190,14 @@ def _build_proxy_subprocess_env( env[name] = parent[alias] break - # Optional Bitwarden refresh path. Pulled lazily so the proxy module - # doesn't hard-depend on the bitwarden module being importable in - # every install. if refresh_from_bitwarden and bitwarden_config: - try: - from agent.secret_sources import bitwarden as bw - access_token_name = bitwarden_config.get( - "access_token_env", "BWS_ACCESS_TOKEN" - ) - access_token = parent.get(access_token_name, "").strip() - project_id = bitwarden_config.get("project_id", "") - if access_token and project_id: - secrets, warnings = bw.fetch_bitwarden_secrets( - access_token=access_token, - project_id=project_id, - cache_ttl_seconds=0, - use_cache=False, - ) - # Only inject env names we have a mapping for — extra - # secrets in the BW project shouldn't leak into the proxy - # process unless they're going to be used by the swap. - missing = sorted(needed - set(secrets)) - for n in needed: - if n in secrets: - env[n] = secrets[n] - if missing: - # stephenschoettler #1: don't silently keep stale - # host-env values when BWS mode was explicitly - # selected. An operator on credential_source=bitwarden - # picked it specifically to get rotation; falling back - # to parent env reintroduces the bug class the mode - # is supposed to defeat. ``allow_env_fallback`` is the - # documented, deliberate opt-out — honor it here exactly - # as the empty-token branch below does (the error - # message tells operators to set it, so it must work). - if not (bitwarden_config or {}).get("allow_env_fallback"): - raise RuntimeError( - f"Bitwarden refresh did not return secrets for " - f"{missing}. Either add the secrets to your BWS " - f"project, switch to credential_source: env via " - f"`hermes egress setup --no-bitwarden`, or set " - f"`proxy.allow_env_fallback: true` in config.yaml " - f"to opt into the legacy host-env fallback." - ) - logger.warning( - "Bitwarden refresh did not return secrets for %s — " - "falling back to host env for those names " - "(allow_env_fallback=true).", - missing, - ) - # bws warnings are non-secret status messages (e.g. "no - # project found", "rate limited"), but the taint analyzer - # can't tell that — log the count and let the operator - # rerun under verbose if they need detail. - if warnings: - logger.warning( - "Bitwarden refresh produced %d warning(s); " - "run `hermes secrets bitwarden status` for detail.", - len(warnings), - ) - else: - # NOTE: deliberately do not interpolate access_token_name - # in the log message — CodeQL's taint analyzer treats - # bitwarden_config values as secret-tainted (it can't - # distinguish the env-var NAME from the env-var VALUE). - # The name is non-secret but logging it just trips the - # check for no real benefit. - if not (bitwarden_config or {}).get("allow_env_fallback"): - raise RuntimeError( - "credential_source=bitwarden but the access-token " - "env or project_id is empty. Either set both, " - "switch to credential_source: env, or set " - "`proxy.allow_env_fallback: true` to opt into " - "the legacy fallback behaviour." - ) - logger.warning( - "credential_source=bitwarden but access-token env or " - "project_id is empty — proxy will fall back to parent env " - "(allow_env_fallback=true).", - ) - except (ImportError,) as exc: - # The BWS module or one of its runtime deps isn't importable. - # Mirror the sibling branches: if allow_env_fallback isn't - # explicitly enabled, fail closed — credential_source=bitwarden - # with a unavailable module should not silently degrade to host - # env. A wizard-time check can't catch a dependency that goes - # missing between setup and a later restart. - if not (bitwarden_config or {}).get("allow_env_fallback"): - raise RuntimeError( - "Bitwarden refresh module unavailable at proxy start " - "(credential_source=bitwarden with " - "proxy.allow_env_fallback: false). Either fix the " - "import, switch to credential_source: env, or set " - "`proxy.allow_env_fallback: true` to opt into the " - "legacy fallback behaviour." - ) from exc - logger.warning( - "Bitwarden refresh module unavailable at proxy start, " - "falling back to parent env (allow_env_fallback=true): %s", - exc, - ) + _refresh_secrets_from_bitwarden( + env, needed, bitwarden_config, bool(bitwarden_config.get("allow_env_fallback")), + ) - # Caller-supplied overrides win. This is intentionally last so the - # wizard can inject ad-hoc test secrets without recomputing the BW - # path. + # Caller overrides win (wizard test secrets), then strip proxy-recursion vars regardless. if extra_env: env.update(extra_env) - - # Strip proxy-recursion-risk vars regardless of how they got in. for name in _PROXY_SUBPROCESS_ENV_STRIP: env.pop(name, None) @@ -2265,166 +1205,165 @@ def _build_proxy_subprocess_env( return env +def _bitwarden_shortfall(allow_env_fallback: bool, error: str, warning: str, *args) -> None: + """Fail closed with ``error`` unless the operator opted into the legacy host-env fallback, + in which case only ``warning`` (``%``-formatted with ``args``) is logged.""" + if not allow_env_fallback: + raise RuntimeError(error) + logger.warning(warning, *args) + + +def _refresh_secrets_from_bitwarden( + env: Dict[str, str], needed: set, bitwarden_config: Dict, allow_env_fallback: bool, +) -> None: + """Overwrite ``env[needed]`` with fresh BWS values (uncached fetch). Only mapped names are + injected so unrelated BWS secrets never leak into the daemon env.""" + try: + # Lazy: the bitwarden module isn't importable in every install. + from agent.secret_sources import bitwarden as bw + access_token = os.environ.get(bitwarden_config.get("access_token_env", "BWS_ACCESS_TOKEN"), "").strip() + project_id = bitwarden_config.get("project_id", "") + if not (access_token and project_id): + # Don't interpolate access_token_name — CodeQL treats config values as tainted. + _bitwarden_shortfall( + allow_env_fallback, + "credential_source=bitwarden but the access-token " + "env or project_id is empty. Either set both, " + "switch to credential_source: env, or set " + "`proxy.allow_env_fallback: true` to opt into " + "the legacy fallback behaviour.", + "credential_source=bitwarden but access-token env or " + "project_id is empty — proxy will fall back to parent env " + "(allow_env_fallback=true).", + ) + return + secrets, warnings = bw.fetch_bitwarden_secrets( + access_token=access_token, + project_id=project_id, + cache_ttl_seconds=0, + use_cache=False, + ) + except ImportError as exc: + # A dependency vanishing between setup and restart must not silently degrade. + if not allow_env_fallback: + raise RuntimeError( + "Bitwarden refresh module unavailable at proxy start " + "(credential_source=bitwarden with " + "proxy.allow_env_fallback: false). Either fix the " + "import, switch to credential_source: env, or set " + "`proxy.allow_env_fallback: true` to opt into the " + "legacy fallback behaviour." + ) from exc + logger.warning( + "Bitwarden refresh module unavailable at proxy start, " + "falling back to parent env (allow_env_fallback=true): %s", + exc, + ) + return + + missing = sorted(needed - set(secrets)) + for n in needed: + if n in secrets: + env[n] = secrets[n] + if missing: + _bitwarden_shortfall( + allow_env_fallback, + f"Bitwarden refresh did not return secrets for " + f"{missing}. Either add the secrets to your BWS " + f"project, switch to credential_source: env via " + f"`hermes egress setup --no-bitwarden`, or set " + f"`proxy.allow_env_fallback: true` in config.yaml " + f"to opt into the legacy host-env fallback.", + "Bitwarden refresh did not return secrets for %s — " + "falling back to host env for those names " + "(allow_env_fallback=true).", + missing, + ) + # Log only the count: the taint analyzer can't tell bws status text is non-secret. + if warnings: + logger.warning( + "Bitwarden refresh produced %d warning(s); " + "run `hermes secrets bitwarden status` for detail.", + len(warnings), + ) + + +def _forget_daemon() -> None: + """Best-effort removal of pidfile + persisted nonce, and drop the in-process nonce.""" + global _proxy_nonce + _pidfile().unlink(missing_ok=True) + try: + _persisted_nonce_path().unlink() + except OSError: + pass + _proxy_nonce = None + + def stop_proxy() -> bool: """Stop the managed iron-proxy. Returns True if it was running.""" - - global _proxy_nonce - - def _cleanup_state_files() -> None: - """Best-effort cleanup of pidfile + persisted nonce.""" - _pidfile().unlink(missing_ok=True) - try: - _persisted_nonce_path().unlink() - except FileNotFoundError: - pass - except OSError: - pass - pid = _read_pid() if not pid or not _pid_alive(pid): - _cleanup_state_files() - _proxy_nonce = None + _forget_daemon() return False - # Capture starttime BEFORE signalling so we can compare after the - # grace window — if the pid got recycled mid-wait, the starttime - # changes and we abort the SIGKILL. + # Capture starttime BEFORE signalling: if the pid is recycled mid-wait, abort the SIGKILL. starttime_before = _pid_proc_starttime(pid) try: os.kill(pid, signal.SIGTERM) except ProcessLookupError: - _cleanup_state_files() - _proxy_nonce = None + _forget_daemon() return False - # Wait up to 5s for graceful exit, then SIGKILL. + # Up to 5s for graceful exit, then SIGKILL — unless the pid was recycled meanwhile. deadline = time.time() + 5.0 while time.time() < deadline: if not _pid_alive(pid): break time.sleep(0.1) else: - # Verify the pid hasn't been recycled before delivering SIGKILL. - # Two checks: - # 1. /proc//stat starttime is unchanged (Linux) - # 2. _pid_alive() still says it's an iron-proxy process starttime_after = _pid_proc_starttime(pid) recycled = ( - starttime_before is not None - and starttime_after is not None - and starttime_before != starttime_after + starttime_before is not None and starttime_after is not None and starttime_before != starttime_after ) or not _pid_alive(pid) if recycled: - logger.warning( - "iron-proxy pid=%s appears recycled before SIGKILL; " - "not killing.", pid, - ) + logger.warning("iron-proxy pid=%s appears recycled before SIGKILL; not killing.", pid) else: try: os.kill(pid, _KILL_SIGNAL) except ProcessLookupError: pass - _cleanup_state_files() - _proxy_nonce = None + _forget_daemon() logger.info("Stopped iron-proxy pid=%s", pid) return True def get_status() -> ProxyStatus: - """Snapshot the current proxy state — does NOT start anything. - - Crucially, this is called per Docker-container-create when egress - enforcement is on. It must not have side-effects (no mkdir, no - binary version subprocess that takes 30s on a hung binary). The - state dir is read-only here. - """ - + """Snapshot the proxy state without side effects (called per Docker container create).""" status = ProxyStatus() - listen_hp = _read_http_listen_from_config() - if listen_hp is not None: - probe_host, status.tunnel_port = listen_hp - else: - probe_host = "127.0.0.1" - status.tunnel_port = _DEFAULT_TUNNEL_PORT + probe_host, status.tunnel_port = _probe_target() binary = find_iron_proxy(install_if_missing=False) if binary: status.binary_path = binary - # Cached — see iron_proxy_version(). First call still costs one - # subprocess; subsequent calls in the same process are dict - # lookups. status.binary_version = iron_proxy_version(binary) state = _proxy_state_dir_ro() - cfg = state / "proxy.yaml" - ca = state / "ca.crt" - if cfg.exists(): - status.config_path = cfg - if ca.exists(): - status.ca_cert_path = ca + cfg, ca = state / "proxy.yaml", state / "ca.crt" + status.config_path = cfg if cfg.exists() else None + status.ca_cert_path = ca if ca.exists() else None pid = _read_pid() if pid and _pid_alive(pid): status.pid = pid - # Probe the configured bind host — on Linux that's the docker - # bridge gateway, where a loopback connect would report a healthy - # daemon as "not listening". status.listening = _port_listening(probe_host, status.tunnel_port) return status -def _read_tunnel_port_from_config() -> Optional[int]: - listen = _read_http_listen_from_config() - if listen is None: - return None - return listen[1] - - -def _read_http_listen_from_config() -> Optional[Tuple[str, int]]: - """Return ``(host, port)`` of the configured sandbox-facing listener. - - Reads ``proxy.tunnel_listen`` — the CONNECT/MITM listener sandboxes - hit via ``HTTPS_PROXY`` — falling back to ``proxy.http_listen`` for - configs written before the tunnel/http listener-role split. - - The bind host matters for liveness probes: on Linux the daemon binds - the docker bridge gateway (e.g. ``172.17.0.1``), where a loopback - connect would report "not listening" for a perfectly healthy daemon. - """ - - cfg = _proxy_state_dir_ro() / "proxy.yaml" - if not cfg.exists(): - return None - try: - import yaml - except ImportError: - return None - try: - data = yaml.safe_load(cfg.read_text(encoding="utf-8")) - except (OSError, yaml.YAMLError): - return None - proxy_block = (data or {}).get("proxy") or {} - # The CLI/Docker side calls this "the tunnel port" because that's how - # sandboxes use it (HTTPS_PROXY) — on the iron-proxy side it's the - # tunnel_listen (CONNECT + MITM). http_listen is the plain-HTTP - # forward listener on tunnel_port+1. - listen = proxy_block.get("tunnel_listen") or proxy_block.get("http_listen") or "" - if not isinstance(listen, str) or ":" not in listen: - return None - host, _, port_s = listen.rpartition(":") - try: - port = int(port_s) - except ValueError: - return None - return (host or "127.0.0.1", port) - - def _port_listening(host: str, port: int) -> bool: """Cheap TCP connect probe — True iff something accepts on host:port.""" - import socket try: @@ -2444,32 +1383,13 @@ def _tail_log(path: Path, *, lines: int = 20) -> str: return f"(could not read log: {exc})" -# --------------------------------------------------------------------------- -# Test hook -# --------------------------------------------------------------------------- - - def _reset_for_tests() -> None: - """Clear module-level caches so tests get a fresh start. - - This module owns two mutable globals that need reset between tests: - - ``_VERSION_CACHE`` — subprocess output cache keyed by binary path. - - ``_proxy_nonce`` — the strong-proof token written by ``start_proxy`` - and read by ``_pid_alive`` to defeat PID recycling. - - Today the repo's tests run each file in its own subprocess (per - AGENTS.md) so leakage is bounded, but any in-process caller - (notebooks, ad-hoc scripts, ``pytest -p no:xdist``) would otherwise - see whichever values were probed first regardless of subsequent - ``install_iron_proxy(force=True)`` or ``start_proxy`` calls. - """ - + """Clear the module's mutable globals (``_VERSION_CACHE``, ``_proxy_nonce``).""" global _proxy_nonce _VERSION_CACHE.clear() _proxy_nonce = None -# Make a small set of symbols available without underscored access. __all__ = [ "ProxyStatus", "TokenMapping",