diff --git a/agent/proxy_sources/__init__.py b/agent/proxy_sources/__init__.py new file mode 100644 index 00000000000..34af31ca6fb --- /dev/null +++ b/agent/proxy_sources/__init__.py @@ -0,0 +1,8 @@ +"""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`. +""" diff --git a/agent/proxy_sources/iron_proxy.py b/agent/proxy_sources/iron_proxy.py new file mode 100644 index 00000000000..e2d804c367b --- /dev/null +++ b/agent/proxy_sources/iron_proxy.py @@ -0,0 +1,2238 @@ +"""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. +""" + +from __future__ import annotations + +import hashlib +import ipaddress +import json +import logging +import os +import platform +import shutil +import signal +import stat +import subprocess +import tarfile +import tempfile +import threading +import time +import urllib.error +import urllib.request +from dataclasses import dataclass, field +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. +_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_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. +_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 + +# 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. +_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", +) + +# Provider env-var name -> upstream host (or list of hosts) on which the +# Authorization Bearer token should be swapped. Only includes providers +# whose API uses a plain "Authorization: Bearer " header — providers +# with custom auth (x-api-key, query params, signatures) get added as we +# write per-provider rules. +_BEARER_PROVIDERS: Dict[str, Tuple[str, ...]] = { + "OPENROUTER_API_KEY": ("openrouter.ai", "*.openrouter.ai"), + "OPENAI_API_KEY": ("api.openai.com",), + "GROQ_API_KEY": ("api.groq.com",), + "TOGETHER_API_KEY": ("api.together.xyz",), + "DEEPSEEK_API_KEY": ("api.deepseek.com",), + "MISTRAL_API_KEY": ("api.mistral.ai",), + "XAI_API_KEY": ("api.x.ai",), + "NOUS_API_KEY": ("inference.nousresearch.com",), +} + + +# Providers whose env-var names we recognize but whose API uses a non-bearer +# auth scheme (x-api-key, AAD/OAuth, SigV4, custom signatures). When any of +# these env vars are present at proxy-start time AND +# ``proxy.fail_on_uncovered_providers`` is true (which is OFF by default), +# ``start_proxy`` refuses to start. Without this list the sandbox would +# still hold real credentials for these providers and silently bypass the +# proxy. +# +# The default is False because many of these env vars (AWS_*, +# GOOGLE_APPLICATION_CREDENTIALS, GOOGLE_API_KEY) are present on most +# developer laptops for reasons unrelated to LLM API access — defaulting to +# refuse-start would force everyone using terraform / gcloud / aws-cli +# alongside Hermes to either unset their cloud auth or set the flag in +# config.yaml. The wizard surfaces uncovered providers at setup time and +# `hermes egress status` keeps them visible; operators who want hard +# enforcement opt in via ``proxy.fail_on_uncovered_providers: true``. +# +# Bare strings here are env-var names; the proxy doesn't try to wire them up, +# only flags their presence so the operator knows isolation is incomplete. +_NON_BEARER_PROVIDERS: Tuple[str, ...] = ( + # Anthropic native uses x-api-key, not Authorization: Bearer. + "ANTHROPIC_API_KEY", + # Azure OpenAI: api-key header + optional AAD bearer. + "AZURE_OPENAI_API_KEY", + # AWS Bedrock / SageMaker: SigV4-signed requests. + "AWS_ACCESS_KEY_ID", + "AWS_SECRET_ACCESS_KEY", + # GCP Vertex AI: OAuth bearer from gcloud SDK, not a static env key. + "GOOGLE_APPLICATION_CREDENTIALS", + # Google AI Studio (Gemini): x-goog-api-key OR query param. + "GEMINI_API_KEY", + "GOOGLE_API_KEY", +) + + +# Tier of `_NON_BEARER_PROVIDERS` that's LLM-specific enough that any +# accidental sandbox bypass is a real isolation failure. When +# ``fail_on_uncovered_providers`` is true, only env vars in this tier +# cause refuse-start; the rest are warn-only via `_NON_BEARER_PROVIDERS`. +# Splitting this avoids tripping every operator with `AWS_PROFILE` set +# for unrelated cloud work. +_LLM_SPECIFIC_NON_BEARER_PROVIDERS: Tuple[str, ...] = ( + "ANTHROPIC_API_KEY", + "AZURE_OPENAI_API_KEY", + "GEMINI_API_KEY", +) + + +# 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_UPSTREAM_DENY_CIDRS: Tuple[str, ...] = ( + "127.0.0.0/8", # IPv4 loopback + "::1/128", # IPv6 loopback + "169.254.0.0/16", # IPv4 link-local incl. AWS/GCP/Azure IMDS + "fe80::/10", # IPv6 link-local + "10.0.0.0/8", # RFC1918 + "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", +) + + +# Min env vars the iron-proxy subprocess actually needs. Everything else +# is stripped — see ``_build_proxy_subprocess_env`` for the rationale. +_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 +) + + +# 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. +_PROXY_SUBPROCESS_ENV_STRIP: Tuple[str, ...] = ( + "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. +_KILL_SIGNAL = getattr(signal, "SIGKILL", signal.SIGTERM) + + +# 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. +_VERSION_CACHE: Dict[str, str] = {} + + +# --------------------------------------------------------------------------- +# Public dataclasses +# --------------------------------------------------------------------------- + + +@dataclass +class ProxyStatus: + """Snapshot of the iron-proxy installation + runtime state.""" + + enabled: bool = False + binary_path: Optional[Path] = None + binary_version: Optional[str] = None + config_path: Optional[Path] = None + ca_cert_path: Optional[Path] = None + pid: Optional[int] = None + listening: bool = False + tunnel_port: int = _DEFAULT_TUNNEL_PORT + warnings: List[str] = field(default_factory=list) + + @property + def installed(self) -> bool: + return self.binary_path is not None and self.binary_path.exists() + + @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() + ) + + +@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``. + """ + + proxy_token: str + real_env_name: str + upstream_hosts: Tuple[str, ...] + + +# --------------------------------------------------------------------------- +# Paths +# --------------------------------------------------------------------------- + + +def _hermes_bin_dir() -> Path: + from hermes_constants import get_hermes_home + + return get_hermes_home() / "bin" + + +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. + """ + 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. + """ + 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 + return d + + +def _platform_binary_name() -> str: + return "iron-proxy.exe" if platform.system() == "Windows" else "iron-proxy" + + +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. + """ + + system = platform.system() + machine = platform.machine().lower() + + if system == "Linux": + 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" + 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." + ) + + raise RuntimeError( + f"Unsupported platform for iron-proxy auto-install: {system} {machine}" + ) + + +# --------------------------------------------------------------------------- +# Binary discovery + lazy install +# --------------------------------------------------------------------------- + + +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 = _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 + + +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. + """ + + 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. + _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}" + ) + + 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. + 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. + 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.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. + _VERSION_CACHE.pop(str(target), None) + + logger.info("Installed iron-proxy %s at %s", _IRON_PROXY_VERSION, target) + return target + + +def _http_download(url: str, dest: Path) -> None: + req = urllib.request.Request(url, headers={"User-Agent": "hermes-agent"}) + try: + with urllib.request.urlopen(req, timeout=_DOWNLOAD_TIMEOUT) as resp: # noqa: S310 + with open(dest, "wb") as f: + shutil.copyfileobj(resp, f) + except urllib.error.URLError as exc: + raise RuntimeError(f"Failed to download {url}: {exc}") from exc + + +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. + """ + gpg = shutil.which("gpg") + if not gpg: + logger.warning( + "gpg not found on PATH — skipping iron-proxy release-signature " + "verification (SHA-256 checksum check still enforced)." + ) + 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 + + try: + _http_download(sig_url, sig_path) + _http_download(pubkey_url, pubkey_path) + except RuntimeError as exc: + logger.warning( + "iron-proxy release signature assets unavailable (%s) — skipping " + "GPG verification (SHA-256 checksum check still enforced).", exc, + ) + 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, + ) + if imp.returncode != 0: + logger.warning( + "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, + ) + 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). " + f"gpg: {verify.stderr.decode('utf-8', 'replace')[:300]}" + ) + logger.info("Verified iron-proxy checksums.txt GPG signature.") + return True + + +def _expected_sha256(checksum_file: Path, asset_name: str) -> str: + """Parse the standard ``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}" + ) + + +def _sha256_file(path: Path) -> str: + h = hashlib.sha256() + with open(path, "rb") as f: + for chunk in iter(lambda: f.read(65536), b""): + h.update(chunk) + return h.hexdigest() + + +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) + 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] + + +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. + """ + + key = str(binary) + cached = _VERSION_CACHE.get(key) + if cached is not None: + return cached + + 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, + timeout=_RUN_TIMEOUT, + env=minimal_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: + _VERSION_CACHE[key] = out + return out + + +# --------------------------------------------------------------------------- +# CA cert generation +# --------------------------------------------------------------------------- + + +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. + """ + + state = _proxy_state_dir() + ca_crt = state / "ca.crt" + ca_key = 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" + + 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_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 + 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) + + 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. + """ + + return f"{prefix}-{hashlib.sha256(os.urandom(32)).hexdigest()[:32]}" + + +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. + """ + + if platform.system() == "Linux": + bridge_ip = _detect_docker_bridge_ip() + if bridge_ip and bridge_ip != "127.0.0.1": + return [f"{bridge_ip}:{tunnel_port}"] + logger.warning( + "No docker bridge (docker0) detected — binding iron-proxy to " + "loopback only. Docker sandboxes will NOT be able to reach " + "the proxy until it is restarted with docker running." + ) + return [f"127.0.0.1:{tunnel_port}"] + + +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 + try: + res = subprocess.run( # noqa: S603 — ip is a system binary + ["ip", "-4", "-o", "addr", "show", "docker0"], + capture_output=True, text=True, 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 + except (OSError, subprocess.TimeoutExpired): + return None + + 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 + ): + logger.warning( + "Refusing suspicious docker bridge IP %s reported by `ip`; " + "skipping bridge bind.", candidate, + ) + return None + + return str(addr) + + +def build_proxy_config( + *, + mappings: List[TokenMapping], + ca_cert: Path, + ca_key: Path, + tunnel_port: int = _DEFAULT_TUNNEL_PORT, + audit_log: Optional[Path] = None, + allowed_hosts: Optional[List[str]] = None, + 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. + """ + + 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) + + secrets_rules = [] + for m in mappings: + secrets_rules.append({ + "source": {"type": "env", "var": m.real_env_name}, + "replace": { + "proxy_value": m.proxy_token, + "match_headers": ["Authorization"], + # The token is also accepted as a bearer query param in case + # the sandbox passes it that way. Body matching is off — we + # don't want body inspection forced for every request. + "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], + }) + + # 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). + 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", + }, + "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. + "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. + "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", + }, + "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}, + }, + ], + "log": log_block, + } + + +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. + """ + + 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.close(fd) + except OSError as exc: + raise RuntimeError( + f"Refusing to start: could not pre-create audit log " + f"{audit_path} with restrictive permissions ({exc}). " + f"Move or chmod any existing file at that path and retry." + ) from exc + + +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. + """ + + 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) + os.replace(tmp_path, out) + os.chmod(out, stat.S_IRUSR | stat.S_IWUSR) + return out + + +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" + payload = { + "version": 1, + "tokens": [ + { + "proxy_token": m.proxy_token, + "env_name": m.real_env_name, + "upstream_hosts": list(m.upstream_hosts), + } + 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) + os.replace(tmp_path, out) + os.chmod(out, stat.S_IRUSR | stat.S_IWUSR) + return out + + +def load_mappings() -> List[TokenMapping]: + """Read mappings.json, if it exists. Empty list on any error.""" + + state = _proxy_state_dir() + f = state / "mappings.json" + if not f.exists(): + return [] + try: + payload = json.loads(f.read_text(encoding="utf-8")) + except (OSError, json.JSONDecodeError) as exc: + logger.warning("Failed to read iron-proxy mappings.json: %s", exc) + return [] + out: List[TokenMapping] = [] + for item in payload.get("tokens", []): + try: + out.append(TokenMapping( + proxy_token=item["proxy_token"], + real_env_name=item["env_name"], + upstream_hosts=tuple(item.get("upstream_hosts") or ()), + )) + except (KeyError, TypeError): + continue + 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). + """ + + if available_env_names is not None: + names = set(available_env_names) + else: + names = {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( + proxy_token=mint_proxy_token(prefix=env_name.lower().replace("_api_key", "")), + real_env_name=env_name, + upstream_hosts=hosts, + )) + return mappings + + +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. + + Anthropic native (x-api-key), AWS Bedrock (SigV4), Azure OpenAI + (api-key), etc. 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 uses this to print a warning at setup time; ``start_proxy`` + can be configured to refuse to start when ``fail_on_uncovered_providers`` + is true (see :func:`discover_blocked_providers` for the strict tier + that actually blocks). + """ + + 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 [n for n in _NON_BEARER_PROVIDERS if n in names] + + +def discover_blocked_providers( + *, + available_env_names: Optional[List[str]] = None, +) -> List[str]: + """Return env-var names for non-bearer providers that BLOCK start. + + Subset of :func:`discover_uncovered_providers` that's LLM-specific + enough to refuse-start when ``proxy.fail_on_uncovered_providers`` is + true. Excludes generic cloud creds (AWS_*, GCP application-default) + that are usually present for unrelated tooling. + """ + + 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 [n for n in _LLM_SPECIFIC_NON_BEARER_PROVIDERS if n in names] + + +def merge_mappings( + *, + 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 the host list in case we added + # new upstreams since last setup. + out.append(TokenMapping( + proxy_token=prior.proxy_token, + real_env_name=prior.real_env_name, + upstream_hosts=d.upstream_hosts, + )) + else: + out.append(d) + return out + + +# --------------------------------------------------------------------------- +# Subprocess lifecycle +# --------------------------------------------------------------------------- + + +def _pidfile() -> Path: + return _proxy_state_dir() / "iron-proxy.pid" + + +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()) + 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. + """ + 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. + 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] + + +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. + """ + 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() + 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) + 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 + 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. + """ + + 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. + 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: + 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) + 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 + 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 + except OSError: + pass + + # macOS / non-Linux fallback: ``ps`` command basename. + try: + res = subprocess.run( # noqa: S603 + ["ps", "-p", str(pid), "-o", "comm="], + capture_output=True, text=True, timeout=2, + ) + if res.returncode == 0: + comm = (res.stdout or "").strip() + return os.path.basename(comm).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. + return True + + +def start_proxy( + *, + binary: Optional[Path] = None, + config_path: Optional[Path] = None, + extra_env: Optional[Dict[str, str]] = None, + install_if_missing: bool = True, + 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. + """ + + 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`." + ) + + 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." + ) + + # 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. + env = _build_proxy_subprocess_env( + extra_env=extra_env, + refresh_from_bitwarden=refresh_secrets_from_bitwarden, + bitwarden_config=bitwarden_config, + ) + + # 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. + _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 + + 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 = _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 _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. + _kill_and_wait(proc, grace_seconds=2) + try: + pidfile.unlink() + except FileNotFoundError: + pass + raise KeyboardInterrupt() + + prev_sigint = None + prev_sigterm = None + install_handlers = ( + platform.system() != "Windows" + and threading.current_thread() is threading.main_thread() + ) + 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) + 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. + 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}" + ) + + # 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. + 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( + 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}" + ) + + logger.info("Started iron-proxy pid=%s config=%s", proc.pid, cfg) + return get_status() + + +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 + 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( + f"Another iron-proxy start appears to be in progress " + 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 + 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." + ) 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 + 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. + 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")) + 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: + """Best-effort SIGTERM → wait → SIGKILL for a child we own.""" + try: + proc.terminate() + except OSError: + return + try: + proc.wait(timeout=grace_seconds) + except subprocess.TimeoutExpired: + try: + proc.kill() + except OSError: + pass + try: + proc.wait(timeout=grace_seconds) + except subprocess.TimeoutExpired: + pass + + +def _build_proxy_subprocess_env( + *, + extra_env: Optional[Dict[str, str]] = None, + 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] = {} + 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. + needed = {m.real_env_name for m in load_mappings()} + for name in needed: + if name in parent: + env[name] = parent[name] + + # 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, + ) + + # Caller-supplied overrides win. This is intentionally last so the + # wizard can inject ad-hoc test secrets without recomputing the BW + # path. + 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) + + env.setdefault("NO_COLOR", "1") + return env + + +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 + 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. + starttime_before = _pid_proc_starttime(pid) + + try: + os.kill(pid, signal.SIGTERM) + except ProcessLookupError: + _cleanup_state_files() + _proxy_nonce = None + return False + + # Wait up to 5s for graceful exit, then SIGKILL. + 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 + ) or not _pid_alive(pid) + if recycled: + 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 + 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. + """ + + 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 + + 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 + + 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: + with socket.create_connection((host, port), timeout=0.5): + return True + except OSError: + return False + + +def _tail_log(path: Path, *, lines: int = 20) -> str: + if not path.exists(): + return "(no log file)" + try: + data = path.read_bytes()[-8192:] + return "\n".join(data.decode("utf-8", errors="replace").splitlines()[-lines:]) + except OSError as exc: + 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. + """ + + global _proxy_nonce + _VERSION_CACHE.clear() + _proxy_nonce = None + + +# Make a small set of symbols available without underscored access. +__all__ = [ + "ProxyStatus", + "TokenMapping", + "build_proxy_config", + "discover_provider_mappings", + "discover_blocked_providers", + "discover_uncovered_providers", + "ensure_audit_log", + "ensure_ca_cert", + "find_iron_proxy", + "get_status", + "install_iron_proxy", + "iron_proxy_version", + "load_mappings", + "merge_mappings", + "mint_proxy_token", + "start_proxy", + "stop_proxy", + "write_mappings", + "write_proxy_config", +] diff --git a/apps/desktop/src/app/command-palette/index.tsx b/apps/desktop/src/app/command-palette/index.tsx index bfda963204c..fe1b8aa6413 100644 --- a/apps/desktop/src/app/command-palette/index.tsx +++ b/apps/desktop/src/app/command-palette/index.tsx @@ -168,7 +168,7 @@ const NON_CONFIG_SETTINGS: ReadonlyArray<{ }, { icon: KeyRound, - keywords: ['providers', 'api key', 'keys', 'secrets', 'tokens'], + keywords: ['providers', 'api key', 'keys', 'secrets', 'tokens', 'egress', 'iron proxy', 'sandbox proxy'], labelKey: 'providerApiKeys', tab: 'providers&pview=keys' }, @@ -181,7 +181,7 @@ const NON_CONFIG_SETTINGS: ReadonlyArray<{ }, { icon: Settings2, - keywords: ['gateway', 'proxy', 'server', 'webhook', 'env'], + keywords: ['gateway', 'proxy', 'server', 'webhook', 'env', 'egress proxy', 'iron proxy'], labelKey: 'keysSettings', tab: 'keys&kview=settings' }, diff --git a/cli.py b/cli.py index 1a92ae93778..05be67f4be9 100644 --- a/cli.py +++ b/cli.py @@ -8249,6 +8249,10 @@ class HermesCLI(CLIAgentSetupMixin, CLICommandsMixin): self._show_gateway_status() elif canonical == "status": self._show_session_status() + elif canonical == "egress": + from hermes_cli.proxy_cli import format_status_text + + self._console_print(format_status_text(), highlight=False, markup=False) elif canonical == "statusbar": self._status_bar_visible = not self._status_bar_visible state = "visible" if self._status_bar_visible else "hidden" diff --git a/gateway/run.py b/gateway/run.py index 8e5416b9554..e68d0fd227f 100644 --- a/gateway/run.py +++ b/gateway/run.py @@ -8064,6 +8064,11 @@ class GatewayRunner(GatewayAuthorizationMixin, GatewayKanbanWatchersMixin, Gatew if _cmd_def_inner and _cmd_def_inner.name == "restart": return await self._handle_restart_command(event) + if _cmd_def_inner and _cmd_def_inner.name == "egress": + from hermes_cli.proxy_cli import format_status_text + + return format_status_text() + # /stop must hard-kill the session when an agent is running. # A soft interrupt (agent.interrupt()) doesn't help when the agent # is truly hung — the executor thread is blocked and never checks @@ -8526,6 +8531,11 @@ class GatewayRunner(GatewayAuthorizationMixin, GatewayKanbanWatchersMixin, Gatew if canonical == "status": return await self._handle_status_command(event) + if canonical == "egress": + from hermes_cli.proxy_cli import format_status_text + + return format_status_text() + if canonical == "agents": return await self._handle_agents_command(event) diff --git a/hermes_cli/commands.py b/hermes_cli/commands.py index 39cf526d2cc..44ff448d7c6 100644 --- a/hermes_cli/commands.py +++ b/hermes_cli/commands.py @@ -114,6 +114,8 @@ COMMAND_REGISTRY: list[CommandDef] = [ CommandDef("subgoal", "Add or manage extra criteria on the active goal", "Session", args_hint="[text | remove N | clear]"), CommandDef("status", "Show session, model, token, and context info", "Session"), + CommandDef("egress", "Show Docker egress proxy status", "Session", + args_hint="[status]", subcommands=("status",)), CommandDef("whoami", "Show your slash command access (admin / user)", "Info"), CommandDef("profile", "Show active profile name and home directory", "Info"), CommandDef("sethome", "Set this chat as the home channel", "Session", @@ -552,6 +554,7 @@ _TELEGRAM_MENU_PRIORITY = ( "new", "stop", "status", + "egress", "resume", "sessions", "model", @@ -1158,7 +1161,8 @@ _SLACK_PRIORITY_ALIASES = ("btw", "bg") # - moa: high-cost slash mode, available through /hermes moa to avoid # displacing existing native Slack slash commands at the 50-command cap. # - debug: the log/report upload surface; reached via /hermes debug on Slack. -_SLACK_VIA_HERMES_ONLY = frozenset({"credits", "billing", "moa", "debug"}) +# - egress: Docker-only proxy status; reachable as /hermes egress on Slack. +_SLACK_VIA_HERMES_ONLY = frozenset({"credits", "billing", "moa", "debug", "egress"}) def _sanitize_slack_name(raw: str) -> str: diff --git a/hermes_cli/config.py b/hermes_cli/config.py index a99b6d94a7d..5b4914ce374 100644 --- a/hermes_cli/config.py +++ b/hermes_cli/config.py @@ -2972,6 +2972,68 @@ DEFAULT_CONFIG = { "cua_telemetry": False, }, + # ========================================================================= + # Egress credential-injection proxy (iron-proxy) + # ========================================================================= + # When enabled, outbound traffic from remote terminal sandboxes (Docker + # today; Modal/SSH in follow-ups) is routed through a managed iron-proxy + # subprocess. The sandbox sees opaque proxy tokens; iron-proxy swaps in + # real API credentials at the egress boundary. Compromising the sandbox + # leaks tokens that only work behind the configured trusted proxy boundary + # (CA private key + proxy endpoint integrity are part of that boundary). + # + # Configure with `hermes egress setup`. Disabled by default — the rest of + # Hermes works exactly as before with `enabled: false`. + "proxy": { + # Master switch. When false, iron-proxy is never started, no docker + # mounts are added, no binaries are auto-installed — feature is a + # complete no-op. + "enabled": False, + # Tunnel listener port. Sandboxes get `HTTPS_PROXY=http://:`. + # 9090 is the default; collide-aware setup wizard can reassign. + "tunnel_port": 9090, + # Auto-download the pinned iron-proxy binary into ~/.hermes/bin/ on + # first use. When false, you must place `iron-proxy` on PATH yourself. + "auto_install": True, + # Where iron-proxy looks up the real upstream secrets at egress time. + # "env" — process env (default; what bitwarden integration + # already populates if you use it) + # "bitwarden" — refetch via `bws secret list` on each proxy restart; + # rotation in the Bitwarden web app propagates without + # touching .env (requires `secrets.bitwarden.enabled`). + "credential_source": "env", + # When true, the Docker backend refuses to start a sandbox if the + # proxy is enabled but not running. False = fall back to direct + # outbound with real credentials in the sandbox (the legacy posture). + "enforce_on_docker": True, + # When true, `hermes egress start` refuses to start if any provider + # env var is set that the proxy cannot strip (Anthropic native + # `x-api-key`, Azure OpenAI api-key, Gemini x-goog-api-key). + # These LLM-specific credentials would otherwise leak into the + # sandbox bypassing the proxy. Generic cloud creds (AWS_*, + # GOOGLE_APPLICATION_CREDENTIALS) are warned about but never + # block. Defaults to false because false positives (operator has + # the env set but doesn't actually use that provider) are common. + "fail_on_uncovered_providers": False, + # When credential_source is bitwarden but the BWS access token / + # project_id is missing OR the bws fetch returns no values for + # mapped providers, the daemon raises by default. Set this to + # True to opt back in to the legacy "silently fall back to host + # env" behaviour — useful for migrations where the operator wants + # to switch credential_source to bitwarden but hasn't fully wired + # BWS yet. Defaults to false (strict). + "allow_env_fallback": False, + # SSRF deny list applied to outbound traffic. Omit / leave empty + # to use the safe default: loopback, link-local (incl. cloud + # metadata IPs at 169.254.169.254), and RFC1918. Set to an + # explicit ``[]`` to opt out entirely (only sensible in hermetic + # tests that need to reach a loopback upstream). + "upstream_deny_cidrs": None, + # Extra allowed upstream hosts beyond the bundled defaults (which + # cover OpenRouter, OpenAI, Anthropic, Google, xAI, Mistral, Groq, + # Together, DeepSeek, Nous). Wildcards (`*.foo.com`) are supported. + "extra_allowed_hosts": [], + }, # Config schema version - bump this when adding new required fields "_config_version": 30, diff --git a/hermes_cli/main.py b/hermes_cli/main.py index 11451903d1e..5514535f14a 100644 --- a/hermes_cli/main.py +++ b/hermes_cli/main.py @@ -11586,7 +11586,7 @@ _BUILTIN_SUBCOMMANDS = frozenset( "acp", "auth", "backup", "bundles", "checkpoints", "claw", "completion", "computer-use", "config", "cron", "curator", "dashboard", "debug", "doctor", - "dump", "fallback", "gateway", "hooks", "import", "insights", + "dump", "egress", "fallback", "gateway", "hooks", "import", "insights", "gui", "desktop", "kanban", "login", "logout", "logs", "lsp", "mcp", "memory", "migrate", "moa", "model", "pairing", "pets", "plugins", "portal", "postinstall", "profile", "project", "proxy", @@ -12200,6 +12200,37 @@ def main(): secrets_parser.set_defaults(func=_dispatch_secrets) + # ========================================================================= + # egress command — iron-proxy outbound credential-injection firewall + # ========================================================================= + # NOTE: this is the OUTBOUND egress firewall (ironsh/iron-proxy). + # `hermes proxy` (defined elsewhere in this file) is a separate INBOUND + # OAuth-aggregator reverse proxy. Different direction, different purpose. + egress_parser = subparsers.add_parser( + "egress", + help="Manage the iron-proxy egress credential-injection firewall", + description=( + "Manage iron-proxy, the optional TLS-intercepting egress firewall " + "that swaps proxy tokens for real API credentials before outbound " + "requests leave a sandbox. Disabled by default. See: " + "https://hermes-agent.nousresearch.com/docs/user-guide/egress/iron-proxy" + ), + ) + + from hermes_cli import proxy_cli as _proxy_cli + _proxy_cli.register_cli(egress_parser) + + def _dispatch_egress(args): # noqa: ANN001 + # The egress subparser uses dest='egress_command' to stay disjoint + # from the inbound OAuth ``hermes proxy`` subparser (dest='proxy_command'). + sub = getattr(args, "egress_command", None) + if sub is not None and hasattr(args, "func") and args.func is not _dispatch_egress: + return args.func(args) + egress_parser.print_help() + return 0 + + egress_parser.set_defaults(func=_dispatch_egress) + # ========================================================================= # migrate command # ========================================================================= @@ -13284,9 +13315,15 @@ def main(): cmd_chat(args) return - # Execute the command + # Execute the command. Propagate the handler's return code as the + # process exit code so subcommands that signal failure (e.g. + # ``hermes egress start`` refusing because of fail_on_uncovered_ + # providers) actually exit non-zero. Handlers that return None + # are treated as success (exit 0). if hasattr(args, "func"): - args.func(args) + rc = args.func(args) + if isinstance(rc, int) and rc != 0: + sys.exit(rc) else: parser.print_help() diff --git a/hermes_cli/proxy_cli.py b/hermes_cli/proxy_cli.py new file mode 100644 index 00000000000..8f274fe9c72 --- /dev/null +++ b/hermes_cli/proxy_cli.py @@ -0,0 +1,747 @@ +"""CLI handlers for ``hermes egress ...``. + +Subcommands: + install — download the pinned iron-proxy binary + setup — interactive wizard: install binary, generate CA, mint tokens, write config + start — launch the proxy as a managed subprocess + stop — terminate the managed proxy + status — show binary version + config presence + listen state + mappings + disable — flip ``proxy.enabled`` to False (does not stop a running proxy) + config — print the generated proxy.yaml path (for debugging / external review) + +The top-level command is ``hermes egress``. Note that the inbound OAuth +reverse-proxy command (``hermes proxy``) lives elsewhere in +``hermes_cli/main.py`` — different direction, different purpose. +""" + +from __future__ import annotations + +import argparse +import os +from pathlib import Path +from typing import List + +from rich.console import Console +from rich.panel import Panel +from rich.table import Table + +from agent.proxy_sources import iron_proxy as ip +from hermes_cli.config import load_config, save_config + + +# --------------------------------------------------------------------------- +# Argparse wiring — called from hermes_cli.main +# --------------------------------------------------------------------------- + + +def register_cli(parent_parser: argparse.ArgumentParser) -> None: + """Attach the egress subcommand tree to a parent parser. + + Called from ``hermes_cli.main`` as part of building the top-level + ``hermes egress`` parser. + """ + + # dest='egress_command' — keeps this subparser tree disjoint from the + # inbound OAuth ``hermes proxy`` subparser (which uses dest='proxy_command'). + # No runtime collision today since they live in separate parser trees, + # but a future grep-and-refactor on ``proxy_command`` would otherwise + # hit both handlers. + sub = parent_parser.add_subparsers(dest="egress_command") + + install = sub.add_parser( + "install", + help=f"Download iron-proxy binary (v{ip._IRON_PROXY_VERSION})", + ) + install.add_argument( + "--force", action="store_true", + help="Re-download even if a managed copy already exists", + ) + install.set_defaults(func=cmd_install) + + setup = sub.add_parser( + "setup", + help="Interactive wizard: install + CA + mint tokens + write config", + ) + setup.add_argument( + "--tunnel-port", type=int, default=None, + help=f"Override the tunnel port (default {ip._DEFAULT_TUNNEL_PORT})", + ) + setup.add_argument( + "--from-bitwarden", action="store_true", + help="Treat secrets as managed by Bitwarden — discover provider keys " + "from secrets.bitwarden config instead of the current env. Fails " + "loudly if BW is unreachable rather than silently falling back.", + ) + setup.add_argument( + "--no-bitwarden", action="store_true", + help="Explicitly switch credential_source back to env on re-setup " + "(only meaningful when the previous setup used --from-bitwarden).", + ) + setup.add_argument( + "--rotate-tokens", action="store_true", + help="Mint fresh proxy tokens for every provider (default is to " + "preserve tokens for providers that already had one — avoids " + "401-ing already-running sandboxes on re-setup).", + ) + setup.set_defaults(func=cmd_setup) + + start = sub.add_parser("start", help="Start the managed iron-proxy") + start.set_defaults(func=cmd_start) + + stop = sub.add_parser("stop", help="Stop the managed iron-proxy") + stop.set_defaults(func=cmd_stop) + + status = sub.add_parser("status", help="Show proxy state and mappings") + status.add_argument( + "--show-tokens", action="store_true", + help="Print the proxy tokens (default: redacted prefix only). " + "Beware: tokens may persist in your shell history.", + ) + status.set_defaults(func=cmd_status) + + disable = sub.add_parser("disable", help="Turn off the proxy integration") + disable.set_defaults(func=cmd_disable) + + cfg = sub.add_parser("config", help="Print the generated proxy.yaml path") + cfg.set_defaults(func=cmd_config) + + +# --------------------------------------------------------------------------- +# Handlers +# --------------------------------------------------------------------------- + + +def cmd_install(args: argparse.Namespace) -> int: + console = Console() + try: + binary = ip.install_iron_proxy(force=bool(args.force)) + except Exception as exc: # noqa: BLE001 — top-level user-facing error funnel + console.print(f"[red]✗ install failed:[/red] {exc}") + console.print( + " Manual install: https://github.com/ironsh/iron-proxy/releases" + ) + return 1 + version = ip.iron_proxy_version(binary) or "(version unknown)" + console.print(f"[green]✓[/green] installed {binary} {version}") + return 0 + + +def cmd_setup(args: argparse.Namespace) -> int: + console = Console() + console.print(Panel.fit( + "[bold]iron-proxy setup[/bold]\n\n" + "Routes outbound sandbox traffic through a local TLS-intercepting\n" + "proxy so prompt-injected agents never see real provider API keys.\n\n" + "[dim]Project: https://github.com/ironsh/iron-proxy (Apache-2.0)[/dim]", + border_style="cyan", + )) + + # ------------------------------------------------------------------ binary + console.print() + console.print("[bold]Step 1[/bold] Install the iron-proxy binary") + try: + binary = ip.find_iron_proxy(install_if_missing=False) + if binary is None: + console.print(" No iron-proxy on PATH — downloading…") + binary = ip.install_iron_proxy() + version = ip.iron_proxy_version(binary) or "(version unknown)" + console.print(f" [green]✓[/green] {binary} {version}") + except Exception as exc: # noqa: BLE001 + console.print(f" [red]✗ install failed: {exc}[/red]") + return 1 + + # ------------------------------------------------------------------ CA + console.print() + console.print("[bold]Step 2[/bold] Generate a CA cert") + try: + ca_crt, ca_key = ip.ensure_ca_cert() + except Exception as exc: # noqa: BLE001 + console.print(f" [red]✗ CA generation failed: {exc}[/red]") + return 1 + console.print(f" [green]✓[/green] {ca_crt}") + + # ------------------------------------------------------------------ mint + console.print() + console.print("[bold]Step 3[/bold] Mint proxy tokens for known providers") + + available_env_names: List[str] = [] + if args.from_bitwarden: + cfg = load_config() + bw_cfg = (cfg.get("secrets") or {}).get("bitwarden") or {} + if not bw_cfg.get("enabled"): + console.print( + " [red]✗ --from-bitwarden requested but " + "secrets.bitwarden.enabled is false.[/red]" + ) + console.print( + " Run `hermes secrets bitwarden setup` first, or omit " + "--from-bitwarden." + ) + return 1 + try: + from agent.secret_sources import bitwarden as bw + access_token = os.environ.get( + bw_cfg.get("access_token_env", "BWS_ACCESS_TOKEN"), "" + ).strip() + if not access_token: + console.print( + f" [red]✗ --from-bitwarden requested but " + f"{bw_cfg.get('access_token_env', 'BWS_ACCESS_TOKEN')} " + "is not set in the environment.[/red]" + ) + return 1 + secrets, _ = bw.fetch_bitwarden_secrets( + access_token=access_token, + project_id=bw_cfg.get("project_id", ""), + cache_ttl_seconds=0, + use_cache=False, + ) + available_env_names = list(secrets.keys()) + if not available_env_names: + console.print( + " [red]✗ Bitwarden returned an empty secrets list.[/red]\n" + " Check the project_id in secrets.bitwarden and the " + "BWS access-token's project scope." + ) + return 1 + console.print( + f" Pulled {len(available_env_names)} env names from Bitwarden." + ) + except Exception as exc: # noqa: BLE001 — explicit user-facing error + console.print( + f" [red]✗ Could not enumerate Bitwarden secrets: {exc}[/red]" + ) + console.print( + " Either fix the Bitwarden config and retry, or rerun setup " + "without --from-bitwarden (the proxy will read secrets from " + "the host process env at start time)." + ) + return 1 + + discovered = ip.discover_provider_mappings( + available_env_names=available_env_names or None, + ) + + # Preserve tokens for providers we already had unless the operator + # explicitly requested rotation. This prevents re-running `hermes + # egress setup` from invalidating tokens baked into already-running + # sandboxes. + existing = ip.load_mappings() + rotate = bool(getattr(args, "rotate_tokens", False)) + + # P3 confirmation gate: --rotate-tokens invalidates every running + # sandbox's proxy tokens immediately. An accidental re-run (history + # scroll-back, tmux paste) is unrecoverable, so require explicit + # confirmation when there's something to actually rotate. Skipped + # when stdin isn't a tty (CI / non-interactive use), in which case + # the operator passed the flag deliberately. + if rotate and existing: + import sys as _sys + from datetime import datetime as _dt + if _sys.stdin.isatty(): + console.print( + "[yellow]⚠[/yellow] --rotate-tokens will invalidate proxy " + "tokens in every running Hermes sandbox. They will start " + "401-ing against upstreams until restarted." + ) + try: + ans = input("Type 'rotate' to confirm: ").strip().lower() + except EOFError: + ans = "" + if ans != "rotate": + console.print("[yellow]Cancelled.[/yellow]") + return 1 + # Backup the existing mappings before we overwrite. The + # resulting ``.rotated-`` sibling is plain JSON and lets + # the operator manually recover tokens if they realise the + # rotation was a mistake. + try: + import shutil as _shutil + state_dir = ip._proxy_state_dir() + mappings_src = state_dir / "mappings.json" + if mappings_src.exists(): + ts = _dt.now().strftime("%Y%m%dT%H%M%S") + backup = state_dir / f"mappings.json.rotated-{ts}" + _shutil.copy2(str(mappings_src), str(backup)) + console.print(f" [dim]backup: {backup}[/dim]") + except OSError as exc: + console.print( + f" [yellow]Could not back up mappings before rotation: " + f"{exc}[/yellow]" + ) + elif rotate and not existing: + console.print( + "[dim]Note: --rotate-tokens is a no-op on first-time setup " + "(no existing tokens to rotate).[/dim]" + ) + + mappings = ip.merge_mappings( + existing=existing, + discovered=discovered, + rotate=rotate, + ) + + if not mappings: + console.print( + " [yellow]No known provider API keys found in env/Bitwarden.[/yellow]" + ) + console.print( + " Set at least one of these and rerun setup:" + ) + for env_name in sorted(ip._BEARER_PROVIDERS): + console.print(f" - {env_name}") + return 1 + + # Warn the operator about providers we recognize but can't proxy + # (Anthropic native, AWS Bedrock, Azure OpenAI, etc). These still + # work — they just bypass the egress isolation. + uncovered = ip.discover_uncovered_providers( + available_env_names=available_env_names or None, + ) + if uncovered: + console.print() + console.print( + " [yellow]⚠[/yellow] Detected provider env vars that the " + "proxy does not yet cover:" + ) + for name in uncovered: + console.print(f" - {name}") + console.print( + " [dim]These providers use non-bearer auth (x-api-key, " + "SigV4, etc.) and will hold real credentials inside the " + "sandbox. Egress isolation is INCOMPLETE for these.[/dim]" + ) + + table = Table(show_header=True, header_style="bold") + table.add_column("Provider env", style="cyan") + table.add_column("Upstream hosts", style="dim") + table.add_column("Proxy token", style="green") + for m in mappings: + table.add_row( + m.real_env_name, + ", ".join(m.upstream_hosts), + _redact_token(m.proxy_token), + ) + console.print(table) + + # ------------------------------------------------------------------ write + console.print() + console.print("[bold]Step 4[/bold] Write config and persist mappings") + + cfg = load_config() + proxy_cfg = cfg.setdefault("proxy", {}) + # ``args.tunnel_port`` is None when the flag was not given; ``0`` is + # invalid for a TCP listener so we treat it as an explicit refusal + # and surface a clear error rather than silently substituting the + # default. + if args.tunnel_port is not None: + if args.tunnel_port < 1 or args.tunnel_port > 65534: + console.print( + " [red]✗ --tunnel-port must be between 1 and 65534 " + "(the plain-HTTP listener uses port+1).[/red]" + ) + return 1 + tunnel_port = int(args.tunnel_port) + else: + tunnel_port = int(proxy_cfg.get("tunnel_port", ip._DEFAULT_TUNNEL_PORT)) + proxy_cfg["tunnel_port"] = tunnel_port + + extra_hosts = list(proxy_cfg.get("extra_allowed_hosts") or []) + allowed = list(ip._DEFAULT_ALLOWED_HOSTS) + [ + h for h in extra_hosts if h not in ip._DEFAULT_ALLOWED_HOSTS + ] + + audit_log_path = ip._proxy_state_dir() / "audit.log" + # Pre-create the audit log with 0o600. On the pinned v0.39 the + # daemon does NOT write to this file (no ``log.audit_path`` field in + # its config schema) — it's reserved for the v0.40+ upgrade where + # per-request records start flowing. Because the file is + # non-load-bearing today, a pre-create failure (immutable parent, + # pre-existing foreign-owned file, full disk) is a WARNING, not a + # setup abort. + audit_log_ok = True + try: + ip.ensure_audit_log(audit_log_path) + except RuntimeError as exc: + audit_log_ok = False + console.print(f" [yellow]⚠ {exc}[/yellow]") + + # Allow operator override of the deny list via + # ``proxy.upstream_deny_cidrs`` — but the default (None) gives a safe + # default-deny list (loopback, IMDS, RFC1918) that matches the docs + # promise. + deny_cidrs = proxy_cfg.get("upstream_deny_cidrs") + iron_cfg = ip.build_proxy_config( + mappings=mappings, + ca_cert=ca_crt, + ca_key=ca_key, + tunnel_port=tunnel_port, + audit_log=audit_log_path, + allowed_hosts=allowed, + upstream_deny_cidrs=deny_cidrs, + ) + cfg_path = ip.write_proxy_config(iron_cfg) + mappings_path = ip.write_mappings(mappings) + console.print(f" [green]✓[/green] config: {cfg_path}") + console.print(f" [green]✓[/green] mappings: {mappings_path}") + if audit_log_ok: + console.print( + f" [green]✓[/green] audit log: {audit_log_path} " + f"[dim](reserved — not written by iron-proxy v0.39; " + f"per-request records land in iron-proxy.log)[/dim]" + ) + + # ------------------------------------------------------------------ enable + proxy_cfg["enabled"] = True + proxy_cfg.setdefault("auto_install", True) + proxy_cfg.setdefault("enforce_on_docker", True) + # CRITICAL: do NOT silently downgrade credential_source on re-run. + # If the operator previously configured `bitwarden` mode (e.g. for + # rotation), running `hermes egress setup` again WITHOUT + # --from-bitwarden must not rewrite credential_source to "env" — + # that silently breaks the Bitwarden rotation guarantee the docs + # make. Require an explicit --no-bitwarden to switch back. + existing_source = proxy_cfg.get("credential_source") + if args.from_bitwarden: + proxy_cfg["credential_source"] = "bitwarden" + elif getattr(args, "no_bitwarden", False): + proxy_cfg["credential_source"] = "env" + if existing_source == "bitwarden": + console.print( + "[yellow]Switched credential_source from bitwarden to env.[/yellow]" + ) + elif existing_source == "bitwarden": + # Preserve the existing bitwarden mode. Surface the decision so + # the operator knows we kept it. + console.print( + "[dim]Keeping credential_source=bitwarden from existing config. " + "Pass --no-bitwarden to switch to env-based credentials.[/dim]" + ) + else: + proxy_cfg["credential_source"] = "env" + proxy_cfg.setdefault("fail_on_uncovered_providers", False) + save_config(cfg) + + live_status = ip.get_status() + if live_status.pid is not None: + ip.stop_proxy() + console.print( + " [yellow]⚠ stopped the running iron-proxy; config or tokens changed, " + "so restart it with `hermes egress start` before launching new " + "Docker sandboxes.[/yellow]" + ) + + console.print() + console.print( + "[green]✓ iron-proxy is configured.[/green] " + "Sandboxes will route outbound traffic through it." + ) + console.print( + " Start: [cyan]hermes egress start[/cyan]\n" + " Status: [cyan]hermes egress status[/cyan]\n" + " Stop: [cyan]hermes egress stop[/cyan]\n" + " Disable: [cyan]hermes egress disable[/cyan]" + ) + return 0 + + +def cmd_start(args: argparse.Namespace) -> int: + console = Console() + cfg = load_config() + proxy_cfg = cfg.get("proxy") or {} + if not proxy_cfg.get("enabled"): + console.print( + "[yellow]proxy.enabled is false — run `hermes egress setup` " + "first.[/yellow]" + ) + return 1 + + # If the operator opted in to Bitwarden-rotation semantics, refresh + # upstream secrets from BSM at startup. This is what delivers the + # rotation guarantee that distinguishes ``credential_source: + # bitwarden`` from ``credential_source: env``. Without it, rotating + # a key in the Bitwarden web app doesn't reach the proxy. + credential_source = proxy_cfg.get("credential_source", "env") + bw_cfg = (cfg.get("secrets") or {}).get("bitwarden") + refresh_bw = ( + credential_source == "bitwarden" + and bw_cfg is not None + and bool(bw_cfg.get("enabled")) + ) + # Silent-degrade guard: the operator explicitly chose + # ``credential_source: bitwarden``, but secrets.bitwarden has since + # been disabled or removed. Proceeding would quietly start on host + # env — exactly the bug class the BW mode is meant to defeat. Refuse + # unless the documented escape hatch is set. + if credential_source == "bitwarden" and not refresh_bw: + if bool(proxy_cfg.get("allow_env_fallback", False)): + console.print( + "[yellow]⚠ credential_source=bitwarden but " + "secrets.bitwarden is disabled or missing — falling back " + "to host-env secrets (allow_env_fallback=true). Rotated " + "Bitwarden keys will NOT propagate.[/yellow]" + ) + else: + console.print( + "[red]✗ Refusing to start: proxy.credential_source is " + "'bitwarden' but secrets.bitwarden is disabled or " + "missing.[/red]" + ) + console.print( + " Re-enable it (`secrets.bitwarden.enabled: true`), switch " + "back to env credentials with `hermes egress setup " + "--no-bitwarden`, or set `proxy.allow_env_fallback: true` " + "to opt into the host-env fallback." + ) + return 1 + # Pass the proxy-side allow_env_fallback opt-in through to + # start_proxy. This is a deliberate, documented escape hatch: when + # set, the daemon silently falls back to host env if BWS is + # unreachable, instead of raising. Default is strict (raise). + if refresh_bw and bw_cfg is not None: + bw_cfg = dict(bw_cfg) + bw_cfg["allow_env_fallback"] = bool( + proxy_cfg.get("allow_env_fallback", False) + ) + + # fail_on_uncovered_providers: when true, refuse to start if any + # LLM-specific non-bearer providers (Anthropic native, Azure OpenAI, + # Gemini) have env vars set in the host process — those would + # otherwise leak real credentials into the sandbox while bypassing + # the proxy. Only the strict LLM-specific subset blocks; generic + # cloud creds (AWS_*, GOOGLE_APPLICATION_CREDENTIALS) still surface + # as warnings via `discover_uncovered_providers` but don't block, to + # avoid tripping every operator with terraform / gcloud set up. + if bool(proxy_cfg.get("fail_on_uncovered_providers", False)): + blocked = ip.discover_blocked_providers() + if blocked: + console.print( + "[red]✗ Refusing to start: provider env vars present " + "that bypass the proxy:[/red]" + ) + for name in blocked: + console.print(f" - {name}") + console.print( + " Set `proxy.fail_on_uncovered_providers: false` in " + "config.yaml to start anyway (sandbox will hold real " + "credentials for those providers)." + ) + return 1 + + # stephenschoettler #1: when `credential_source: bitwarden`, the + # operator picked BWS specifically to get the rotation guarantee — + # silently falling back to parent-env at start_proxy time reintroduces + # exactly the bug class the BW mode is supposed to defeat (host env + # is stale / mismatched). Pre-check at the wizard layer so we fail + # loud with actionable error messages BEFORE start_proxy degrades. + if refresh_bw: + bw_access_env = (bw_cfg or {}).get("access_token_env", "BWS_ACCESS_TOKEN") + if not os.environ.get(bw_access_env, "").strip(): + console.print( + f"[red]✗ Refusing to start: credential_source=bitwarden but " + f"{bw_access_env} is not set in the environment.[/red]" + ) + console.print( + " Either export the access token, or run " + "`hermes egress setup --no-bitwarden` to switch back to " + "env-based credentials." + ) + return 1 + if not (bw_cfg or {}).get("project_id"): + console.print( + "[red]✗ Refusing to start: credential_source=bitwarden but " + "secrets.bitwarden.project_id is empty.[/red]" + ) + console.print( + " Run `hermes secrets bitwarden setup` to configure the " + "project, or switch back via `hermes egress setup " + "--no-bitwarden`." + ) + return 1 + + try: + status = ip.start_proxy( + install_if_missing=bool(proxy_cfg.get("auto_install", True)), + refresh_secrets_from_bitwarden=refresh_bw, + bitwarden_config=bw_cfg, + ) + except Exception as exc: # noqa: BLE001 — top-level user-facing funnel + console.print(f"[red]✗ failed to start iron-proxy:[/red] {exc}") + return 1 + if status.pid: + listening = ( + "[green]listening[/green]" + if status.listening + else "[yellow]not yet listening[/yellow]" + ) + console.print( + f"[green]✓[/green] iron-proxy running pid={status.pid} " + f"port={status.tunnel_port} {listening}" + ) + else: + console.print("[red]✗ iron-proxy did not come up cleanly[/red]") + return 1 + return 0 + + +def cmd_stop(args: argparse.Namespace) -> int: + console = Console() + if ip.stop_proxy(): + console.print("[green]✓[/green] iron-proxy stopped") + else: + console.print("[dim]iron-proxy was not running[/dim]") + return 0 + + +def format_status_text(*, show_tokens: bool = False) -> str: + """Plain-text egress status for slash commands, Dashboard, and Desktop.""" + cfg = load_config() + proxy_cfg = cfg.get("proxy") or {} + status = ip.get_status() + + def yn(value: bool) -> str: + return "yes" if value else "no" + + lines = [ + "Egress proxy status", + "", + f"Enabled: {yn(bool(proxy_cfg.get('enabled')))}", + f"Binary: {status.binary_path or '(missing)'}", + f"Binary version: {status.binary_version or '(unknown)'}", + f"Config: {status.config_path or '(not generated)'}", + f"CA cert: {status.ca_cert_path or '(not generated)'}", + f"Tunnel port: {status.tunnel_port}", + f"Process: pid {status.pid}" if status.pid else "Process: (stopped)", + f"Listening: {yn(status.listening)}", + f"Credential src: {proxy_cfg.get('credential_source', 'env')}", + f"Docker enforce: {yn(bool(proxy_cfg.get('enforce_on_docker', True)))}", + "Scope: Docker backend only in this release", + ] + + mappings = ip.load_mappings() + if mappings: + lines.extend(["", "Token mappings:"]) + for m in mappings: + tok = m.proxy_token if show_tokens else _redact_token(m.proxy_token) + lines.append(f" - {m.real_env_name}: {tok} ({', '.join(m.upstream_hosts)})") + + uncovered = ip.discover_uncovered_providers() + if uncovered: + lines.extend([ + "", + "Uncovered providers (real credentials still visible inside the sandbox):", + ]) + for name in uncovered: + lines.append(f" - {name}") + + if bool(proxy_cfg.get("enabled")) and not status.configured: + lines.extend(["", "Next: run `hermes egress setup` to mint tokens and write proxy.yaml."]) + elif bool(proxy_cfg.get("enabled")) and not (status.pid and status.listening): + lines.extend(["", "Next: run `hermes egress start` before launching Docker sandboxes."]) + + return "\n".join(lines) + + +def cmd_status(args: argparse.Namespace) -> int: + console = Console() + cfg = load_config() + proxy_cfg = cfg.get("proxy") or {} + status = ip.get_status() + + table = Table(show_header=False, box=None, padding=(0, 2)) + table.add_column("", style="bold") + table.add_column("") + table.add_row("Enabled", _yn(bool(proxy_cfg.get("enabled")))) + table.add_row("Binary", str(status.binary_path or "[dim](missing)[/dim]")) + table.add_row("Binary version", status.binary_version or "[dim](unknown)[/dim]") + table.add_row("Config", str(status.config_path or "[dim](not generated)[/dim]")) + table.add_row("CA cert", str(status.ca_cert_path or "[dim](not generated)[/dim]")) + table.add_row("Tunnel port", str(status.tunnel_port)) + table.add_row("Process", f"pid {status.pid}" if status.pid else "[dim](stopped)[/dim]") + table.add_row("Listening", _yn(status.listening)) + table.add_row("Credential src", str(proxy_cfg.get("credential_source", "env"))) + table.add_row("Docker enforce", _yn(bool(proxy_cfg.get("enforce_on_docker", True)))) + console.print(table) + + mappings = ip.load_mappings() + if mappings: + console.print() + console.print("[bold]Token mappings[/bold]") + m_table = Table(show_header=True, header_style="bold") + m_table.add_column("Real env", style="cyan") + m_table.add_column("Upstream", style="dim") + m_table.add_column("Proxy token", style="green") + for m in mappings: + tok = m.proxy_token if args.show_tokens else _redact_token(m.proxy_token) + m_table.add_row(m.real_env_name, ", ".join(m.upstream_hosts), tok) + console.print(m_table) + if args.show_tokens: + console.print( + "[yellow]⚠[/yellow] proxy tokens just printed in full — " + "they may persist in your shell history. Consider clearing " + "it after this command." + ) + + # Surface uncovered providers so the operator knows the isolation + # boundary is incomplete for those upstreams. + uncovered = ip.discover_uncovered_providers() + if uncovered: + console.print() + console.print( + "[yellow]Uncovered providers[/yellow] " + "(real credentials still visible inside the sandbox):" + ) + for name in uncovered: + console.print(f" - {name}") + + return 0 + + +def cmd_disable(args: argparse.Namespace) -> int: + console = Console() + cfg = load_config() + proxy_cfg = cfg.setdefault("proxy", {}) + if not proxy_cfg.get("enabled"): + console.print("[dim]proxy.enabled was already false.[/dim]") + return 0 + proxy_cfg["enabled"] = False + save_config(cfg) + console.print("[green]✓[/green] proxy.enabled set to false") + # Use the public get_status() pid (which already incorporates the + # _pid_alive check) instead of reaching into ip._read_pid(). That + # private accessor only proves the pidfile is non-empty — a stale + # pidfile from a crashed previous run would fire the warning + # spuriously. + if ip.get_status().pid is not None: + console.print( + " iron-proxy is still running — stop it with " + "[cyan]hermes egress stop[/cyan] if you want it down too." + ) + return 0 + + +def cmd_config(args: argparse.Namespace) -> int: + console = Console() + status = ip.get_status() + if status.config_path is None: + console.print( + "[yellow](no config generated — run `hermes egress setup`)[/yellow]" + ) + return 1 + console.print(str(status.config_path)) + return 0 + + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + + +def _yn(value: bool) -> str: + return "[green]yes[/green]" if value else "[dim]no[/dim]" + + +def _redact_token(token: str) -> str: + if len(token) < 16: + return token + return f"{token[:12]}…{token[-4:]}" diff --git a/hermes_cli/setup.py b/hermes_cli/setup.py index 8eea7248d47..7157b333e53 100644 --- a/hermes_cli/setup.py +++ b/hermes_cli/setup.py @@ -1228,6 +1228,28 @@ def setup_terminal_backend(config: dict): config["terminal"].setdefault( "docker_image", "nikolaik/python-nodejs:python3.11-nodejs20" ) + print() + print_info("Docker sandboxes can be protected with the egress credential firewall.") + print_info( + "It routes sandbox traffic through iron-proxy so containers receive " + "proxy tokens instead of real API keys." + ) + print_info( + " Docker only for now; Modal, SSH, Daytona, and Singularity are not wired yet." + ) + if prompt_yes_no(" Enable egress firewall for Docker sandboxes?", False): + proxy_cfg = config.setdefault("proxy", {}) + proxy_cfg["enabled"] = True + proxy_cfg.setdefault("enforce_on_docker", True) + print_success("Egress firewall enabled in config") + print_info( + "Run `hermes egress setup` then `hermes egress start` to mint " + "tokens and launch the proxy." + ) + else: + print_info( + "Skipping egress firewall. You can enable it later with `hermes egress setup`." + ) elif selected_backend == "singularity": print_success("Terminal backend: Singularity/Apptainer") diff --git a/hermes_cli/web_server.py b/hermes_cli/web_server.py index 82cd8135bd0..d76b946606e 100644 --- a/hermes_cli/web_server.py +++ b/hermes_cli/web_server.py @@ -552,6 +552,25 @@ _SCHEMA_OVERRIDES: Dict[str, Dict[str, Any]] = { "description": "Modal sandbox mode", "options": ["sandbox", "function"], }, + "proxy.enabled": { + "type": "boolean", + "description": ( + "Docker-only egress credential firewall. Requires `hermes egress setup` " + "and `hermes egress start`; Modal/SSH/Daytona are not wired yet." + ), + "category": "security", + }, + "proxy.credential_source": { + "type": "select", + "description": "Where iron-proxy loads real upstream secrets at start time", + "options": ["env", "bitwarden"], + "category": "security", + }, + "proxy.enforce_on_docker": { + "type": "boolean", + "description": "Refuse Docker sandboxes when egress is enabled but not configured/running", + "category": "security", + }, "tts.provider": { "type": "select", "description": "Text-to-speech provider", @@ -3701,6 +3720,14 @@ async def get_schema(): return {"fields": CONFIG_SCHEMA, "category_order": _CATEGORY_ORDER} +@app.get("/api/egress/status") +async def get_egress_status(): + """Dashboard/Desktop-readable egress proxy status and remediation text.""" + from hermes_cli.proxy_cli import format_status_text + + return {"text": format_status_text()} + + _EMPTY_MODEL_INFO: dict = { "model": "", "provider": "", diff --git a/infographic/iron-proxy-egress/infographic.png b/infographic/iron-proxy-egress/infographic.png new file mode 100644 index 00000000000..9a7ea5a8b46 Binary files /dev/null and b/infographic/iron-proxy-egress/infographic.png differ diff --git a/scripts/release.py b/scripts/release.py index b31f9530068..905a75f648c 100755 --- a/scripts/release.py +++ b/scripts/release.py @@ -876,6 +876,7 @@ AUTHOR_MAP = { "252620095+briandevans@users.noreply.github.com": "briandevans", "incharge.automation@gmail.com": "inchargeautomation-lab", "danielrpike9@gmail.com": "Bartok9", + "kuangmi@deeparchi.com": "kuangmi-bit", "96944678+ymylive@users.noreply.github.com": "sweetcornna", "laflamme@illinoisalumni.org": "briancl2", "skozyuk@cruxexperts.com": "CruxExperts", diff --git a/tests/cli/test_cli_status_command.py b/tests/cli/test_cli_status_command.py index ed6fbd7d2b3..6172c6a7319 100644 --- a/tests/cli/test_cli_status_command.py +++ b/tests/cli/test_cli_status_command.py @@ -32,6 +32,13 @@ def test_status_command_is_available_in_cli_registry(): assert cmd.gateway_only is False +def test_egress_command_is_available_in_cli_registry(): + cmd = resolve_command("egress") + assert cmd is not None + assert cmd.gateway_only is False + assert "status" in cmd.subcommands + + def test_process_command_status_dispatches_without_toggling_status_bar(): cli_obj = _make_cli() @@ -42,6 +49,20 @@ def test_process_command_status_dispatches_without_toggling_status_bar(): assert cli_obj._status_bar_visible is True +def test_process_command_egress_prints_proxy_status(monkeypatch): + cli_obj = _make_cli() + monkeypatch.setattr( + "hermes_cli.proxy_cli.format_status_text", + lambda: "Egress proxy status\nEnabled: no", + ) + + assert cli_obj.process_command("/egress") is True + + cli_obj.console.print.assert_called() + printed = "\n".join(str(call.args[0]) for call in cli_obj.console.print.call_args_list) + assert "Egress proxy status" in printed + + def test_statusbar_still_toggles_visibility(): cli_obj = _make_cli() diff --git a/tests/gateway/test_unknown_command.py b/tests/gateway/test_unknown_command.py index 11413449638..c32f5da1bb1 100644 --- a/tests/gateway/test_unknown_command.py +++ b/tests/gateway/test_unknown_command.py @@ -146,6 +146,36 @@ async def test_known_slash_command_not_flagged_as_unknown(monkeypatch): assert "Unknown command" not in result +@pytest.mark.asyncio +async def test_egress_slash_command_reports_proxy_status(monkeypatch): + runner = _make_runner() + monkeypatch.setattr( + "hermes_cli.proxy_cli.format_status_text", + lambda: "Egress proxy status\nEnabled: no", + ) + + result = await runner._handle_message(_make_event("/egress")) + + assert result is not None + assert "Egress proxy status" in result + assert "Unknown command" not in result + + +@pytest.mark.asyncio +async def test_egress_slash_command_reports_proxy_status_while_agent_running(monkeypatch): + runner = _make_runner() + runner._running_agents[build_session_key(_make_source())] = MagicMock() + monkeypatch.setattr( + "hermes_cli.proxy_cli.format_status_text", + lambda: "Egress proxy status\nEnabled: yes", + ) + + result = await runner._handle_message(_make_event("/egress")) + + assert result is not None + assert "Egress proxy status" in result + + @pytest.mark.asyncio async def test_underscored_alias_for_hyphenated_builtin_not_flagged(monkeypatch): """Telegram autocomplete sends /reload_mcp for the /reload-mcp built-in. diff --git a/tests/hermes_cli/test_commands.py b/tests/hermes_cli/test_commands.py index d357a7c89a6..21e6b6d60d6 100644 --- a/tests/hermes_cli/test_commands.py +++ b/tests/hermes_cli/test_commands.py @@ -1143,6 +1143,7 @@ class TestTelegramMenuCommands: assert len(names) == 30 assert hidden > 0 for name in ( + "egress", "debug", "restart", "update", diff --git a/tests/hermes_cli/test_web_server.py b/tests/hermes_cli/test_web_server.py index 1afe572832f..40822aaaf4a 100644 --- a/tests/hermes_cli/test_web_server.py +++ b/tests/hermes_cli/test_web_server.py @@ -2756,6 +2756,18 @@ class TestBuildSchemaFromConfig: assert "options" in entry assert "local" in entry["options"] + def test_proxy_schema_warns_dashboard_users_about_lifecycle(self): + from hermes_cli.web_server import CONFIG_SCHEMA + + entry = CONFIG_SCHEMA["proxy.enabled"] + assert entry["category"] == "security" + assert "Docker-only" in entry["description"] + assert "hermes egress setup" in entry["description"] + + source_entry = CONFIG_SCHEMA["proxy.credential_source"] + assert source_entry["type"] == "select" + assert source_entry["options"] == ["env", "bitwarden"] + def test_empty_prefix_produces_correct_keys(self): from hermes_cli.web_server import _build_schema_from_config test_config = {"model": "test", "nested": {"key": "val"}} diff --git a/tests/test_iron_proxy.py b/tests/test_iron_proxy.py new file mode 100644 index 00000000000..34eded4a0ca --- /dev/null +++ b/tests/test_iron_proxy.py @@ -0,0 +1,1783 @@ +"""Hermetic tests for the iron-proxy egress integration. + +Covers the pure-function surface (token mint, mapping discovery, config build, +config + mappings I/O), the binary install path (HTTP downloads + tar +extraction + checksum verification fully mocked), the subprocess lifecycle +(spawn / PID / pid_alive / stop, with subprocess.Popen mocked), and the +docker backend's egress arg builder. + +Live network and the real ``iron-proxy`` binary are NEVER touched. See +``tests/test_iron_proxy_e2e.py`` (gated behind a marker) for the real-binary +smoke test. +""" + +from __future__ import annotations + +import io +import os +import sys +import tarfile +from pathlib import Path +from unittest.mock import MagicMock, patch + +import pytest + +from agent.proxy_sources import iron_proxy as ip + + +# --------------------------------------------------------------------------- +# Per-test isolation +# --------------------------------------------------------------------------- + + +@pytest.fixture +def hermes_home(tmp_path, monkeypatch): + """Point HERMES_HOME at a temp dir so install paths don't touch the real $HOME.""" + + home = tmp_path / "hermes" + home.mkdir() + monkeypatch.setenv("HERMES_HOME", str(home)) + # Make sure no stale provider keys influence discovery. + for key in list(os.environ): + if key.endswith("_API_KEY"): + monkeypatch.delenv(key, raising=False) + return home + + +# --------------------------------------------------------------------------- +# Token mint + mapping discovery +# --------------------------------------------------------------------------- + + +def test_mint_proxy_token_has_prefix_and_length(): + t = ip.mint_proxy_token("alpha") + assert t.startswith("alpha-") + assert len(t) >= len("alpha-") + 32 + + +def test_mint_proxy_token_is_random(): + a = ip.mint_proxy_token("x") + b = ip.mint_proxy_token("x") + assert a != b + + +def test_discover_provider_mappings_from_env(hermes_home, monkeypatch): + monkeypatch.setenv("OPENROUTER_API_KEY", "sk-or-real-1") + monkeypatch.setenv("OPENAI_API_KEY", "sk-openai-real-2") + monkeypatch.delenv("MISTRAL_API_KEY", raising=False) + ms = ip.discover_provider_mappings() + names = [m.real_env_name for m in ms] + assert "OPENROUTER_API_KEY" in names + assert "OPENAI_API_KEY" in names + assert "MISTRAL_API_KEY" not in names + + +def test_discover_provider_mappings_explicit_names(hermes_home): + ms = ip.discover_provider_mappings( + available_env_names=["OPENROUTER_API_KEY", "GROQ_API_KEY", "UNKNOWN_KEY"] + ) + names = {m.real_env_name for m in ms} + assert names == {"OPENROUTER_API_KEY", "GROQ_API_KEY"} + # Unknown providers (no entry in _BEARER_PROVIDERS) are skipped, not warned. + + +def test_discover_provider_mappings_empty(hermes_home): + ms = ip.discover_provider_mappings(available_env_names=[]) + assert ms == [] + + +# --------------------------------------------------------------------------- +# Config / mapping serialization +# --------------------------------------------------------------------------- + + +def _sample_mapping(env_name: str = "OPENROUTER_API_KEY") -> ip.TokenMapping: + return ip.TokenMapping( + proxy_token=ip.mint_proxy_token("test"), + real_env_name=env_name, + upstream_hosts=("openrouter.ai", "*.openrouter.ai"), + ) + + +def test_build_proxy_config_shape(tmp_path): + m = _sample_mapping() + ca_crt = tmp_path / "ca.crt" + ca_key = tmp_path / "ca.key" + cfg = ip.build_proxy_config( + mappings=[m], + ca_cert=ca_crt, + ca_key=ca_key, + ) + # Top-level sections — note `dns` is required by iron-proxy even when + # we only use the CONNECT tunnel. + assert set(cfg.keys()) >= {"dns", "proxy", "tls", "transforms", "log"} + # Transforms in expected order + assert [t["name"] for t in cfg["transforms"]] == ["allowlist", "secrets"] + # Allowlist uses `domains:` (iron-proxy schema), not `hosts:` + domains = cfg["transforms"][0]["config"]["domains"] + assert "openrouter.ai" in domains + # Secrets transform encodes our mapping + rules = cfg["transforms"][1]["config"]["secrets"] + assert len(rules) == 1 + rule = rules[0] + # Real secret value is sourced from env at egress time, NOT inlined. + assert rule["source"] == {"type": "env", "var": "OPENROUTER_API_KEY"} + # The proxy token is the replacement target. + assert rule["replace"]["proxy_value"] == m.proxy_token + assert "Authorization" in rule["replace"]["match_headers"] + # Fail-closed: a request to a mapped host without the proxy token must be + # rejected, not forwarded with whatever credential it carried + # (maxpetrusenko P1). iron-proxy's replaceConfig.Require enforces this. + assert rule["replace"]["require"] is True + # Rules list contains one entry per upstream host. + rule_hosts = {r["host"] for r in rule["rules"]} + assert rule_hosts == set(m.upstream_hosts) + # TLS section names the CA paths + assert cfg["tls"]["ca_cert"] == str(ca_crt) + + +def test_build_proxy_config_custom_allowed_hosts(tmp_path): + m = _sample_mapping("OPENAI_API_KEY") + cfg = ip.build_proxy_config( + mappings=[m], + ca_cert=tmp_path / "ca.crt", + ca_key=tmp_path / "ca.key", + allowed_hosts=["custom-host.test"], + ) + domains = cfg["transforms"][0]["config"]["domains"] + # Custom allowed_hosts wins as the base; mapping's hosts get appended. + assert "custom-host.test" in domains + assert "openrouter.ai" in domains # comes from the mapping + + +# --------------------------------------------------------------------------- +# Default SSRF deny list (regression: docs promise cloud metadata is denied) +# --------------------------------------------------------------------------- + + +def test_default_deny_cidrs_present_when_unspecified(tmp_path): + """build_proxy_config must emit the default deny list when the caller + passes nothing. The IMDS subnet (169.254.0.0/16) MUST be in the result + or the docs claim that ``upstream_deny_cidrs`` refuses cloud metadata + is a lie.""" + + cfg = ip.build_proxy_config( + mappings=[_sample_mapping()], + ca_cert=tmp_path / "ca.crt", + ca_key=tmp_path / "ca.key", + ) + deny = cfg["proxy"]["upstream_deny_cidrs"] + assert "169.254.0.0/16" in deny # IMDS + assert "127.0.0.0/8" in deny # loopback v4 + assert "::1/128" in deny # loopback v6 + assert "10.0.0.0/8" in deny # RFC1918 + assert "172.16.0.0/12" in deny # RFC1918 + assert "192.168.0.0/16" in deny # RFC1918 + + +def test_explicit_empty_deny_cidrs_disables_default(tmp_path): + """Explicit ``[]`` opts out of the default deny list — needed by + hermetic tests that want to talk to a loopback upstream.""" + + cfg = ip.build_proxy_config( + mappings=[_sample_mapping()], + ca_cert=tmp_path / "ca.crt", + ca_key=tmp_path / "ca.key", + upstream_deny_cidrs=[], + ) + assert cfg["proxy"]["upstream_deny_cidrs"] == [] + + +def test_wizard_rendered_yaml_contains_deny_list(hermes_home, tmp_path): + """End-to-end: cmd_setup writes proxy.yaml; the rendered file must + contain the deny list because the wizard now passes the operator's + config-level setting (None → default) through to build_proxy_config.""" + + # Simulate the wizard's call shape (matches proxy_cli.cmd_setup). + state = ip._proxy_state_dir() + (state / "ca.crt").write_text("fake-ca") + (state / "ca.key").write_text("fake-key") + proxy_yaml = ip.build_proxy_config( + mappings=[_sample_mapping()], + ca_cert=state / "ca.crt", + ca_key=state / "ca.key", + # The wizard passes ``upstream_deny_cidrs`` from the config; when + # the operator hasn't set anything, that's None and we get the + # safe default below. + upstream_deny_cidrs=None, + ) + out = ip.write_proxy_config(proxy_yaml) + text = out.read_text(encoding="utf-8") + assert "169.254.0.0/16" in text + + +# --------------------------------------------------------------------------- +# Bind policy (regression: must not bind 0.0.0.0) +# --------------------------------------------------------------------------- + + +def test_default_bind_is_loopback_not_zero_zero(tmp_path): + """The sandbox-facing listeners must NOT be ``0.0.0.0:PORT`` or + ``:PORT`` (latter is INADDR_ANY).""" + + cfg = ip.build_proxy_config( + mappings=[_sample_mapping()], + ca_cert=tmp_path / "ca.crt", + ca_key=tmp_path / "ca.key", + tunnel_port=12345, + http_listen=["127.0.0.1:12345"], # explicit so test is deterministic + ) + # tunnel_listen is the CONNECT/MITM listener sandboxes hit via + # HTTPS_PROXY; http_listen is the plain-HTTP forward on port+1. + assert cfg["proxy"]["tunnel_listen"] == "127.0.0.1:12345" + assert cfg["proxy"]["http_listen"] == "127.0.0.1:12346" + for key in ("tunnel_listen", "http_listen", "https_listen"): + val = cfg["proxy"][key] + # Sentinel: confirm we didn't accidentally serialize a bare-port + # form like ":12345" (that's INADDR_ANY). + assert not val.startswith(":") + assert "0.0.0.0" not in val + # iron-proxy v0.39 doesn't support http_listens (plural). We + # deliberately do NOT emit that key — re-emitting it would cause + # the daemon to fail YAML unmarshal at start time. + assert "http_listens" not in cfg["proxy"], ( + "iron-proxy v0.39 rejects http_listens (plural); only the " + "singular http_listen string is accepted by the binary" + ) + + +def test_default_bind_uses_docker_bridge_on_linux(tmp_path, monkeypatch): + """When http_listen isn't passed AND we're on Linux, the singular + http_listen field is the DOCKER BRIDGE bind — not loopback. + iron-proxy v0.39 only supports one bind per daemon process, and on + Linux ``host.docker.internal:host-gateway`` resolves to the bridge + gateway (172.17.0.1 by default), which a loopback-only daemon never + answers. Sandboxes must be able to reach the proxy from the + container's vantage point.""" + + monkeypatch.setattr(ip.platform, "system", lambda: "Linux") + monkeypatch.setattr(ip, "_detect_docker_bridge_ip", lambda: "172.17.0.1") + cfg = ip.build_proxy_config( + mappings=[_sample_mapping()], + ca_cert=tmp_path / "ca.crt", + ca_key=tmp_path / "ca.key", + tunnel_port=9090, + ) + # The sandbox-facing CONNECT/MITM listener binds the bridge gateway — + # reachable from containers via host.docker.internal. Plain-HTTP + # forward listener rides on port+1, same host. + assert cfg["proxy"]["tunnel_listen"] == "172.17.0.1:9090" + assert cfg["proxy"]["http_listen"] == "172.17.0.1:9091" + # No http_listens (plural) — v0.39 rejects that key. + assert "http_listens" not in cfg["proxy"] + + +def test_default_bind_falls_back_to_loopback_without_bridge(tmp_path, monkeypatch): + """On Linux without a detectable docker0 bridge (docker not + installed / not running), fall back to loopback rather than + refusing to bind.""" + + monkeypatch.setattr(ip.platform, "system", lambda: "Linux") + monkeypatch.setattr(ip, "_detect_docker_bridge_ip", lambda: None) + cfg = ip.build_proxy_config( + mappings=[_sample_mapping()], + ca_cert=tmp_path / "ca.crt", + ca_key=tmp_path / "ca.key", + tunnel_port=9090, + ) + assert cfg["proxy"]["tunnel_listen"] == "127.0.0.1:9090" + assert cfg["proxy"]["http_listen"] == "127.0.0.1:9091" + + +def test_default_bind_is_loopback_on_macos(tmp_path, monkeypatch): + """On Docker Desktop platforms host.docker.internal routes to the + host via VPNkit, so loopback is reachable from containers and is + the least-exposed bind.""" + + monkeypatch.setattr(ip.platform, "system", lambda: "Darwin") + cfg = ip.build_proxy_config( + mappings=[_sample_mapping()], + ca_cert=tmp_path / "ca.crt", + ca_key=tmp_path / "ca.key", + tunnel_port=9090, + ) + assert cfg["proxy"]["tunnel_listen"] == "127.0.0.1:9090" + assert cfg["proxy"]["http_listen"] == "127.0.0.1:9091" + + +def test_metrics_listener_pinned_to_loopback_ephemeral(tmp_path): + """iron-proxy v0.39's default metrics_listen is ``:9090``, which + collides with our default tunnel_port=9090. build_proxy_config MUST + explicitly pin metrics.listen to ``127.0.0.1:0`` so the bind + collision can never happen at start time.""" + + cfg = ip.build_proxy_config( + mappings=[_sample_mapping()], + ca_cert=tmp_path / "ca.crt", + ca_key=tmp_path / "ca.key", + tunnel_port=9090, + ) + assert cfg["metrics"]["listen"] == "127.0.0.1:0" + + +# --------------------------------------------------------------------------- +# audit_log file pre-creation (parameter still accepted; v0.39 doesn't +# wire it into the binary config but ensure_audit_log() still creates +# the file at 0o600 as a logrotate / monitoring sentinel) +# --------------------------------------------------------------------------- + + +def test_audit_log_kwarg_does_not_inject_audit_path_v039(tmp_path): + """v0.39 of iron-proxy rejects ``log.audit_path`` (not a struct + field). build_proxy_config still accepts the audit_log kwarg for + forward compatibility but MUST NOT emit it into the rendered yaml + until the upstream binary supports it. See the kwarg's docstring + for the upgrade path.""" + + cfg = ip.build_proxy_config( + mappings=[_sample_mapping()], + ca_cert=tmp_path / "ca.crt", + ca_key=tmp_path / "ca.key", + audit_log=tmp_path / "audit.log", + ) + assert "audit_path" not in cfg["log"], ( + "iron-proxy v0.39 has no log.audit_path field; emitting it " + "causes 'field audit_path not found in type config.Log' at " + "daemon start. ensure_audit_log() still creates the file as " + "an operator-facing logrotate target." + ) + + +def test_audit_log_omitted_when_caller_passes_none(tmp_path): + cfg = ip.build_proxy_config( + mappings=[_sample_mapping()], + ca_cert=tmp_path / "ca.crt", + ca_key=tmp_path / "ca.key", + audit_log=None, + ) + assert "audit_path" not in cfg["log"] + + +def test_write_and_load_mappings_roundtrip(hermes_home): + ms = [_sample_mapping("OPENROUTER_API_KEY"), _sample_mapping("OPENAI_API_KEY")] + path = ip.write_mappings(ms) + assert path.exists() + loaded = ip.load_mappings() + assert len(loaded) == 2 + assert {m.real_env_name for m in loaded} == {"OPENROUTER_API_KEY", "OPENAI_API_KEY"} + # Tokens preserved + assert loaded[0].proxy_token == ms[0].proxy_token + + +def test_load_mappings_handles_missing_file(hermes_home): + assert ip.load_mappings() == [] + + +def test_load_mappings_handles_corrupt_json(hermes_home): + state = ip._proxy_state_dir() + (state / "mappings.json").write_text("{not json", encoding="utf-8") + assert ip.load_mappings() == [] + + +def test_write_proxy_config_serializes_yaml(hermes_home, tmp_path): + ca_crt = tmp_path / "ca.crt" + ca_key = tmp_path / "ca.key" + cfg = ip.build_proxy_config( + mappings=[_sample_mapping()], + ca_cert=ca_crt, + ca_key=ca_key, + ) + out = ip.write_proxy_config(cfg) + assert out.exists() + text = out.read_text(encoding="utf-8") + assert "tunnel_listen" in text + assert f"ca_cert: {ca_crt}" in text + + +# --------------------------------------------------------------------------- +# Token-preservation on re-setup (regression: clobbered live sandboxes) +# --------------------------------------------------------------------------- + + +def test_merge_mappings_preserves_existing_tokens(): + """Re-running setup must not invalidate tokens baked into already- + running sandboxes. ``merge_mappings`` keeps the prior token for any + provider that's in both lists.""" + + existing = [ + ip.TokenMapping( + proxy_token="hermes-proxy-original-12345", + real_env_name="OPENROUTER_API_KEY", + upstream_hosts=("openrouter.ai",), + ), + ] + discovered = ip.discover_provider_mappings( + available_env_names=["OPENROUTER_API_KEY", "OPENAI_API_KEY"] + ) + merged = ip.merge_mappings(existing=existing, discovered=discovered) + by_name = {m.real_env_name: m for m in merged} + # Original token preserved. + assert by_name["OPENROUTER_API_KEY"].proxy_token == "hermes-proxy-original-12345" + # New provider got a fresh token. + assert by_name["OPENAI_API_KEY"].proxy_token != "hermes-proxy-original-12345" + # Both providers in the result. + assert set(by_name) == {"OPENROUTER_API_KEY", "OPENAI_API_KEY"} + + +def test_merge_mappings_drops_providers_removed_from_env(): + """When a provider is in `existing` but not in `discovered`, it must + be dropped from the result — the operator removed the env var.""" + + existing = [ + ip.TokenMapping( + proxy_token="stale", real_env_name="OPENROUTER_API_KEY", + upstream_hosts=("openrouter.ai",), + ), + ] + discovered = ip.discover_provider_mappings( + available_env_names=["OPENAI_API_KEY"] + ) + merged = ip.merge_mappings(existing=existing, discovered=discovered) + names = {m.real_env_name for m in merged} + assert names == {"OPENAI_API_KEY"} + + +def test_merge_mappings_rotate_mints_fresh_tokens(): + """``rotate=True`` rolls every token regardless of overlap. The + --rotate-tokens flag uses this.""" + + existing = [ + ip.TokenMapping( + proxy_token="hermes-proxy-original-12345", + real_env_name="OPENROUTER_API_KEY", + upstream_hosts=("openrouter.ai",), + ), + ] + discovered = ip.discover_provider_mappings( + available_env_names=["OPENROUTER_API_KEY"] + ) + merged = ip.merge_mappings(existing=existing, discovered=discovered, rotate=True) + assert merged[0].proxy_token != "hermes-proxy-original-12345" + + +# --------------------------------------------------------------------------- +# Uncovered provider detection (regression: non-bearer providers bypass) +# --------------------------------------------------------------------------- + + +def test_uncovered_providers_detects_anthropic_aws(hermes_home, monkeypatch): + monkeypatch.setenv("ANTHROPIC_API_KEY", "sk-ant-test") + monkeypatch.setenv("AWS_ACCESS_KEY_ID", "AKIAEXAMPLE") + uncovered = ip.discover_uncovered_providers() + assert "ANTHROPIC_API_KEY" in uncovered + assert "AWS_ACCESS_KEY_ID" in uncovered + + +def test_uncovered_providers_explicit_names_empty(): + assert ip.discover_uncovered_providers(available_env_names=[]) == [] + + +def test_uncovered_providers_skips_bearer_providers(hermes_home, monkeypatch): + """OPENROUTER_API_KEY etc. are bearer providers — they should NOT + appear in the uncovered list.""" + + monkeypatch.setenv("OPENROUTER_API_KEY", "sk-or-test") + uncovered = ip.discover_uncovered_providers() + assert "OPENROUTER_API_KEY" not in uncovered + + +# --------------------------------------------------------------------------- +# Binary discovery + lazy install +# --------------------------------------------------------------------------- + + +def test_find_iron_proxy_returns_none_when_missing(hermes_home, monkeypatch): + monkeypatch.setattr("shutil.which", lambda name: None) + assert ip.find_iron_proxy(install_if_missing=False) is None + + +def test_find_iron_proxy_returns_managed_first(hermes_home, monkeypatch): + managed = ip._hermes_bin_dir() / ip._platform_binary_name() + managed.parent.mkdir(parents=True, exist_ok=True) + managed.write_bytes(b"#!/bin/sh\necho ok\n") + managed.chmod(0o755) + # Even with a system one on PATH, the managed copy should win. + monkeypatch.setattr("shutil.which", lambda name: "/usr/bin/iron-proxy") + assert ip.find_iron_proxy() == managed + + +def _make_fake_tar(binary_name: str, payload: bytes = b"#!/bin/sh\necho ok\n") -> bytes: + """Build a tar.gz with one file at the root, named ``binary_name``.""" + + buf = io.BytesIO() + with tarfile.open(fileobj=buf, mode="w:gz") as tf: + info = tarfile.TarInfo(name=binary_name) + info.size = len(payload) + info.mode = 0o755 + tf.addfile(info, io.BytesIO(payload)) + return buf.getvalue() + + +def test_install_iron_proxy_verifies_checksum_and_extracts(hermes_home, monkeypatch): + fake_payload = _make_fake_tar(ip._platform_binary_name()) + import hashlib + + expected_sha = hashlib.sha256(fake_payload).hexdigest() + asset_name = ip._platform_asset_name() + checksum_text = f"{expected_sha} {asset_name}\nffff other-asset.tar.gz\n" + + def fake_download(url: str, dest: Path) -> None: + if url.endswith(ip._IRON_PROXY_CHECKSUM_NAME): + dest.write_text(checksum_text) + else: + dest.write_bytes(fake_payload) + + monkeypatch.setattr(ip, "_http_download", fake_download) + target = ip.install_iron_proxy() + assert target.exists() + assert target.read_bytes() == b"#!/bin/sh\necho ok\n" + # Executable bit is set + assert os.access(target, os.X_OK) + + +def test_install_iron_proxy_rejects_bad_checksum(hermes_home, monkeypatch): + fake_payload = _make_fake_tar(ip._platform_binary_name()) + asset_name = ip._platform_asset_name() + bad_text = f"deadbeef {asset_name}\n" + + def fake_download(url: str, dest: Path) -> None: + if url.endswith(ip._IRON_PROXY_CHECKSUM_NAME): + dest.write_text(bad_text) + else: + dest.write_bytes(fake_payload) + + monkeypatch.setattr(ip, "_http_download", fake_download) + with pytest.raises(RuntimeError, match="Checksum mismatch"): + ip.install_iron_proxy() + + +def test_install_iron_proxy_rejects_missing_checksum_entry(hermes_home, monkeypatch): + fake_payload = _make_fake_tar(ip._platform_binary_name()) + + def fake_download(url: str, dest: Path) -> None: + if url.endswith(ip._IRON_PROXY_CHECKSUM_NAME): + dest.write_text("aaaa some-other-file.tar.gz\n") + else: + dest.write_bytes(fake_payload) + + monkeypatch.setattr(ip, "_http_download", fake_download) + with pytest.raises(RuntimeError, match="No checksum entry"): + ip.install_iron_proxy() + + +# ── GPG release-signature verification (maxpetrusenko P1) ──────────────────── + +def test_verify_checksums_signature_skips_without_gpg(hermes_home, monkeypatch, tmp_path): + """No gpg on PATH → degrade gracefully (return False), do not raise.""" + monkeypatch.setattr(ip.shutil, "which", lambda name: None) + cks = tmp_path / "checksums.txt" + cks.write_text("abc iron-proxy.tar.gz\n") + assert ip._verify_checksums_signature(tmp_path, cks) is False + + +def test_verify_checksums_signature_skips_when_sig_assets_missing(hermes_home, monkeypatch, tmp_path): + """gpg present but the release ships no .asc assets → degrade, don't raise.""" + monkeypatch.setattr(ip.shutil, "which", lambda name: "/usr/bin/gpg" if name == "gpg" else None) + + def fail_download(url: str, dest: Path) -> None: + raise RuntimeError("404 not found") + monkeypatch.setattr(ip, "_http_download", fail_download) + cks = tmp_path / "checksums.txt" + cks.write_text("abc iron-proxy.tar.gz\n") + assert ip._verify_checksums_signature(tmp_path, cks) is False + + +def test_verify_checksums_signature_raises_on_bad_signature(hermes_home, monkeypatch, tmp_path): + """A present-but-INVALID signature is a tamper signal → must raise.""" + monkeypatch.setattr(ip.shutil, "which", lambda name: "/usr/bin/gpg" if name == "gpg" else None) + monkeypatch.setattr(ip, "_http_download", lambda url, dest: dest.write_bytes(b"asc")) + + class _R: + def __init__(self, rc): self.returncode = rc; self.stderr = b"BAD signature" + def fake_run(cmd, **kw): + # import succeeds (rc 0), verify fails (rc 1) + return _R(0) if "--import" in cmd else _R(1) + monkeypatch.setattr(ip.subprocess, "run", fake_run) + + cks = tmp_path / "checksums.txt" + cks.write_text("abc iron-proxy.tar.gz\n") + with pytest.raises(RuntimeError, match="failed GPG signature verification"): + ip._verify_checksums_signature(tmp_path, cks) + + +def test_verify_checksums_signature_passes_on_good_signature(hermes_home, monkeypatch, tmp_path): + """Valid signature → returns True.""" + monkeypatch.setattr(ip.shutil, "which", lambda name: "/usr/bin/gpg" if name == "gpg" else None) + monkeypatch.setattr(ip, "_http_download", lambda url, dest: dest.write_bytes(b"asc")) + + class _R: + def __init__(self, rc): self.returncode = rc; self.stderr = b"" + monkeypatch.setattr(ip.subprocess, "run", lambda cmd, **kw: _R(0)) + + cks = tmp_path / "checksums.txt" + cks.write_text("abc iron-proxy.tar.gz\n") + assert ip._verify_checksums_signature(tmp_path, cks) is True + + +def test_install_aborts_on_bad_release_signature(hermes_home, monkeypatch): + """End-to-end: a tampered (bad-signature) release must abort install.""" + fake_payload = _make_fake_tar(ip._platform_binary_name()) + import hashlib + sha = hashlib.sha256(fake_payload).hexdigest() + asset_name = ip._platform_asset_name() + + def fake_download(url: str, dest: Path) -> None: + if url.endswith(ip._IRON_PROXY_CHECKSUM_NAME): + dest.write_text(f"{sha} {asset_name}\n") + elif url.endswith(".asc"): + dest.write_bytes(b"-----BEGIN PGP-----\n") + else: + dest.write_bytes(fake_payload) + monkeypatch.setattr(ip, "_http_download", fake_download) + monkeypatch.setattr(ip.shutil, "which", lambda name: "/usr/bin/gpg" if name == "gpg" else None) + + class _R: + def __init__(self, rc): self.returncode = rc; self.stderr = b"BAD" + monkeypatch.setattr(ip.subprocess, "run", + lambda cmd, **kw: _R(0) if "--import" in cmd else _R(1)) + + with pytest.raises(RuntimeError, match="GPG signature verification"): + ip.install_iron_proxy() + + +def test_pick_tar_member_rejects_path_traversal(): + """A malicious tar that escapes via '..' must be refused.""" + + buf = io.BytesIO() + with tarfile.open(fileobj=buf, mode="w:gz") as tf: + info = tarfile.TarInfo(name="../iron-proxy") + info.size = 1 + info.mode = 0o755 + tf.addfile(info, io.BytesIO(b"x")) + buf.seek(0) + with tarfile.open(fileobj=buf, mode="r:gz") as tf: + with pytest.raises(RuntimeError, match="Could not find iron-proxy"): + ip._pick_tar_member(tf, "iron-proxy") + + +# --------------------------------------------------------------------------- +# Subprocess lifecycle +# --------------------------------------------------------------------------- + + +def test_get_status_when_nothing_configured(hermes_home): + status = ip.get_status() + assert status.binary_path is None + assert status.config_path is None + assert status.ca_cert_path is None + assert status.pid is None + assert status.listening is False + assert not status.installed + assert not status.configured + + +def test_get_status_with_config_present(hermes_home, monkeypatch): + # Materialize binary, config, and ca cert. + bin_path = ip._hermes_bin_dir() / ip._platform_binary_name() + bin_path.parent.mkdir(parents=True, exist_ok=True) + bin_path.write_bytes(b"") + bin_path.chmod(0o755) + state = ip._proxy_state_dir() + (state / "ca.crt").write_text("fake") + cfg = ip.build_proxy_config( + mappings=[_sample_mapping()], + ca_cert=state / "ca.crt", + ca_key=state / "ca.key", + tunnel_port=9999, + ) + ip.write_proxy_config(cfg) + monkeypatch.setattr(ip, "iron_proxy_version", lambda b: "iron-proxy v0.0.0-test") + + status = ip.get_status() + assert status.installed + assert status.configured + assert status.tunnel_port == 9999 + assert "test" in (status.binary_version or "") + + +def test_stop_proxy_handles_missing_pidfile(hermes_home): + # No pidfile → stop returns False, doesn't raise. + assert ip.stop_proxy() is False + + +def test_stop_proxy_cleans_stale_pidfile(hermes_home, monkeypatch): + pid_file = ip._proxy_state_dir() / "iron-proxy.pid" + pid_file.write_text("999999999") + monkeypatch.setattr(ip, "_pid_alive", lambda pid: False) + assert ip.stop_proxy() is False + assert not pid_file.exists() + + +def test_start_proxy_refuses_without_binary(hermes_home, monkeypatch): + # No binary, auto_install fails → RuntimeError surfaces. + monkeypatch.setattr(ip, "find_iron_proxy", lambda **kwargs: None) + state = ip._proxy_state_dir() + (state / "proxy.yaml").write_text("proxy: {}") + with pytest.raises(RuntimeError, match="binary not available"): + ip.start_proxy() + + +def test_start_proxy_refuses_without_config(hermes_home, monkeypatch): + binary = ip._hermes_bin_dir() / ip._platform_binary_name() + binary.parent.mkdir(parents=True, exist_ok=True) + binary.write_bytes(b"") + binary.chmod(0o755) + monkeypatch.setattr(ip, "find_iron_proxy", lambda **kwargs: binary) + with pytest.raises(RuntimeError, match="config not found"): + ip.start_proxy() + + +def test_start_proxy_writes_pidfile_when_alive(hermes_home, monkeypatch): + binary = ip._hermes_bin_dir() / ip._platform_binary_name() + binary.parent.mkdir(parents=True, exist_ok=True) + binary.write_bytes(b"") + binary.chmod(0o755) + state = ip._proxy_state_dir() + (state / "proxy.yaml").write_text("proxy: {}") + + monkeypatch.setattr(ip, "find_iron_proxy", lambda **kwargs: binary) + monkeypatch.setattr(ip, "_STARTUP_GRACE_SECONDS", 0) + + # Pre-stub everything start_proxy's get_status() call will touch — it + # runs INSIDE start_proxy, so by the time Popen is mocked these have + # to already be hermetic. + monkeypatch.setattr(ip, "_pid_alive", lambda pid: True) + # v3: start_proxy now REQUIRES _port_listening to return True before + # writing the pidfile. The previous version of this test mocked it + # to False and relied on the loop falling through; the new code + # treats fall-through as failure and kills the child. + monkeypatch.setattr(ip, "_port_listening", lambda h, p: True) + monkeypatch.setattr(ip, "iron_proxy_version", lambda b: "iron-proxy test") + + fake_proc = MagicMock() + fake_proc.pid = 4242 + fake_proc.poll.return_value = None # still alive + + with patch("subprocess.Popen", lambda *a, **k: fake_proc): + status = ip.start_proxy() + assert (state / "iron-proxy.pid").read_text() == "4242" + assert status.pid == 4242 + + +def test_start_proxy_raises_when_immediate_exit(hermes_home, monkeypatch): + binary = ip._hermes_bin_dir() / ip._platform_binary_name() + binary.parent.mkdir(parents=True, exist_ok=True) + binary.write_bytes(b"") + binary.chmod(0o755) + state = ip._proxy_state_dir() + (state / "proxy.yaml").write_text("proxy: {}") + (state / "iron-proxy.log").write_text("bind: address already in use\n") + + monkeypatch.setattr(ip, "find_iron_proxy", lambda **kwargs: binary) + monkeypatch.setattr(ip, "_STARTUP_GRACE_SECONDS", 0) + + fake_proc = MagicMock() + fake_proc.pid = 5151 + fake_proc.poll.return_value = 1 # exited immediately + fake_proc.returncode = 1 + with patch("subprocess.Popen", lambda *a, **k: fake_proc): + with pytest.raises(RuntimeError, match="exited immediately"): + ip.start_proxy() + + +def test_start_proxy_idempotent_when_already_running(hermes_home, monkeypatch): + state = ip._proxy_state_dir() + pid_file = state / "iron-proxy.pid" + pid_file.write_text("12345") + monkeypatch.setattr(ip, "_pid_alive", lambda pid: True) + monkeypatch.setattr(ip, "_port_listening", lambda h, p: True) + monkeypatch.setattr(ip, "iron_proxy_version", lambda b: "test") + # Materialize config so we get past that check (we shouldn't reach it, + # but if the idempotent path regresses we want a clean failure mode). + (state / "proxy.yaml").write_text("proxy: {}") + # Sentinel: subprocess.Popen must NOT be called. + with patch("subprocess.Popen", lambda *a, **k: pytest.fail("should not spawn")): + status = ip.start_proxy() + # Should return without spawning anything. + assert status is not None + + +# --------------------------------------------------------------------------- +# Docker integration +# --------------------------------------------------------------------------- + + +def test_docker_egress_args_empty_when_disabled(hermes_home, monkeypatch): + from tools.environments.docker import _egress_proxy_args_for_docker + + # Default config has proxy.enabled=False; helper should return all empties. + vol, env, host = _egress_proxy_args_for_docker() + assert vol == [] + assert env == {} + assert host == [] + + +def test_docker_egress_args_when_enabled_but_unconfigured_raises(hermes_home, monkeypatch): + from tools.environments.docker import _egress_proxy_args_for_docker + from hermes_cli.config import load_config, save_config + + cfg = load_config() + cfg.setdefault("proxy", {})["enabled"] = True + cfg["proxy"]["enforce_on_docker"] = True + save_config(cfg) + + # No proxy.yaml exists → enforce_on_docker should raise. + with pytest.raises(RuntimeError, match="not configured"): + _egress_proxy_args_for_docker() + + +def test_docker_egress_args_when_unconfigured_no_enforce(hermes_home, monkeypatch): + from tools.environments.docker import _egress_proxy_args_for_docker + from hermes_cli.config import load_config, save_config + + cfg = load_config() + cfg.setdefault("proxy", {})["enabled"] = True + cfg["proxy"]["enforce_on_docker"] = False + save_config(cfg) + + # Without enforcement, missing config returns empties (warning only). + vol, env, host = _egress_proxy_args_for_docker() + assert vol == [] + assert env == {} + assert host == [] + + +def test_docker_egress_args_full_path(hermes_home, monkeypatch): + """Wire up everything (config, CA, mappings, fake running proxy) and + verify the docker helper emits the right mounts and env.""" + + from tools.environments.docker import _egress_proxy_args_for_docker + from hermes_cli.config import load_config, save_config + + # Materialize config, CA, mappings. + state = ip._proxy_state_dir() + ca = state / "ca.crt" + ca.write_text("fake-ca") + (state / "ca.key").write_text("fake-key") + mapping = _sample_mapping("OPENROUTER_API_KEY") + proxy_cfg = ip.build_proxy_config( + mappings=[mapping], ca_cert=ca, ca_key=state / "ca.key", tunnel_port=9090, + ) + ip.write_proxy_config(proxy_cfg) + ip.write_mappings([mapping]) + + cfg = load_config() + cfg.setdefault("proxy", {})["enabled"] = True + cfg["proxy"]["enforce_on_docker"] = True + save_config(cfg) + + # Fake running proxy. + (state / "iron-proxy.pid").write_text("99999") + monkeypatch.setattr(ip, "_pid_alive", lambda pid: True) + monkeypatch.setattr(ip, "_port_listening", lambda h, p: True) + + vol, env, host = _egress_proxy_args_for_docker() + # CA mount present and in -v form + assert "-v" in vol + assert any("hermes-egress-ca.crt" in arg for arg in vol) + # Env contains both casings of HTTPS_PROXY and the CA env vars + assert env["HTTPS_PROXY"].endswith(":9090") + assert env["https_proxy"] == env["HTTPS_PROXY"] + assert env["REQUESTS_CA_BUNDLE"].endswith("hermes-egress-ca.crt") + assert env["NODE_EXTRA_CA_CERTS"] == env["REQUESTS_CA_BUNDLE"] + # NO_PROXY excludes loopback + assert "127.0.0.1" in env["NO_PROXY"] + # Per-mapping proxy token is surfaced under both the standard provider env + # name (so existing SDKs work without egress-specific code) and the + # introspection name. + assert env["OPENROUTER_API_KEY"] == mapping.proxy_token + assert env["HERMES_PROXY_TOKEN_OPENROUTER_API_KEY"] == mapping.proxy_token + # Linux host-gateway mapping + assert host == ["--add-host", "host.docker.internal:host-gateway"] + + +def test_docker_egress_fingerprint_changes_with_tokens(hermes_home, monkeypatch): + """Persistent Docker container reuse must not attach to a container that + was created before egress, before a token rotation, or with a different CA + mount. The label hash is what forces a fresh container in those cases.""" + + from tools.environments.docker import _egress_reuse_fingerprint + + first = _egress_reuse_fingerprint( + ["-v", "/tmp/ca:/etc/ssl/certs/hermes-egress-ca.crt:ro"], + {"OPENROUTER_API_KEY": "token-a", "HTTPS_PROXY": "http://h:9090"}, + ["--add-host", "host.docker.internal:host-gateway"], + ) + second = _egress_reuse_fingerprint( + ["-v", "/tmp/ca:/etc/ssl/certs/hermes-egress-ca.crt:ro"], + {"OPENROUTER_API_KEY": "token-b", "HTTPS_PROXY": "http://h:9090"}, + ["--add-host", "host.docker.internal:host-gateway"], + ) + + assert first + assert second + assert first != second + + +# --------------------------------------------------------------------------- +# Platform asset name resolution +# --------------------------------------------------------------------------- + + +@pytest.mark.parametrize( + "system,machine,expected_substring", + [ + ("Linux", "x86_64", "linux_amd64"), + ("Linux", "aarch64", "linux_arm64"), + ("Darwin", "arm64", "darwin_arm64"), + ("Darwin", "x86_64", "darwin_amd64"), + ], +) +def test_platform_asset_name(monkeypatch, system, machine, expected_substring): + monkeypatch.setattr("platform.system", lambda: system) + monkeypatch.setattr("platform.machine", lambda: machine) + assert expected_substring in ip._platform_asset_name() + + +def test_platform_asset_name_rejects_windows(monkeypatch): + monkeypatch.setattr("platform.system", lambda: "Windows") + monkeypatch.setattr("platform.machine", lambda: "AMD64") + with pytest.raises(RuntimeError, match="does not ship native Windows"): + ip._platform_asset_name() + + +# --------------------------------------------------------------------------- +# Subprocess env minimization (regression: host secrets leaked to proxy) +# --------------------------------------------------------------------------- + + +def test_subprocess_env_strips_unrelated_secrets(hermes_home, monkeypatch): + """``_build_proxy_subprocess_env`` must NOT carry every host secret + over to the proxy. /proc//environ on the proxy would otherwise + expose all of them to same-uid local processes.""" + + # Unrelated env vars that should NOT propagate. + monkeypatch.setenv("MY_PRIVATE_TOKEN", "should-not-leak") + monkeypatch.setenv("DATABASE_URL", "postgres://very-private") + monkeypatch.setenv("SLACK_BOT_TOKEN", "xoxb-very-secret") + # Provider keys that ARE in load_mappings should propagate. + monkeypatch.setenv("OPENROUTER_API_KEY", "sk-or-real") + ip.write_mappings([_sample_mapping("OPENROUTER_API_KEY")]) + + env = ip._build_proxy_subprocess_env() + assert "MY_PRIVATE_TOKEN" not in env + assert "DATABASE_URL" not in env + assert "SLACK_BOT_TOKEN" not in env + assert env.get("OPENROUTER_API_KEY") == "sk-or-real" + + +def test_subprocess_env_strips_proxy_recursion_vars(hermes_home, monkeypatch): + """HTTPS_PROXY etc. in the parent env would otherwise recurse iron-proxy + through itself (or send its traffic through a corporate proxy).""" + + monkeypatch.setenv("HTTPS_PROXY", "http://corporate:3128") + monkeypatch.setenv("HTTP_PROXY", "http://corporate:3128") + monkeypatch.setenv("ALL_PROXY", "socks5://corporate:1080") + env = ip._build_proxy_subprocess_env() + assert "HTTPS_PROXY" not in env + assert "https_proxy" not in env + assert "HTTP_PROXY" not in env + assert "ALL_PROXY" not in env + + +def test_subprocess_env_keeps_infrastructure_vars(hermes_home, monkeypatch): + """PATH / HOME / locale must propagate or the child can't even find + its libs.""" + + monkeypatch.setenv("PATH", "/usr/local/bin:/usr/bin") + monkeypatch.setenv("HOME", "/home/test") + monkeypatch.setenv("LANG", "C.UTF-8") + env = ip._build_proxy_subprocess_env() + assert env.get("PATH") == "/usr/local/bin:/usr/bin" + assert env.get("HOME") == "/home/test" + assert env.get("LANG") == "C.UTF-8" + + +# --------------------------------------------------------------------------- +# CA generation TOCTOU (regression: 0o600 only set AFTER copy) +# --------------------------------------------------------------------------- + + +def test_ca_key_created_with_0o600(hermes_home, monkeypatch): + """The CA private key must NEVER exist on disk with default umask + permissions, even transiently. Fix: open with explicit mode=0o600 + so the very first byte is written under tight perms.""" + + # ensure_ca_cert shells out to openssl; mock the subprocess.run calls + # so we don't need openssl on the test host AND don't depend on its + # output format. + def fake_run(args, **kwargs): + # First call: genrsa → -out is at args[-2] + if args[1] == "genrsa": + out = args[-2] + Path(out).write_bytes(b"-----BEGIN RSA PRIVATE KEY-----\nfake\n-----END RSA PRIVATE KEY-----\n") + elif args[1] == "req": + # Find -out path + i = args.index("-out") + Path(args[i + 1]).write_bytes(b"-----BEGIN CERTIFICATE-----\nfake\n-----END CERTIFICATE-----\n") + result = MagicMock() + result.returncode = 0 + return result + + monkeypatch.setattr(ip.shutil, "which", lambda name: "/usr/bin/openssl" if name == "openssl" else None) + monkeypatch.setattr(ip.subprocess, "run", fake_run) + + ca_crt, ca_key = ip.ensure_ca_cert() + assert ca_key.exists() + mode = ca_key.stat().st_mode & 0o777 + assert mode == 0o600, f"CA key has perms {oct(mode)}, expected 0o600" + + +# --------------------------------------------------------------------------- +# Audit log permissions (regression: depended on umask) +# --------------------------------------------------------------------------- + + +def test_ensure_audit_log_creates_with_0o600(hermes_home, tmp_path): + audit = tmp_path / "audit.log" + ip.ensure_audit_log(audit) + assert audit.exists() + mode = audit.stat().st_mode & 0o777 + assert mode == 0o600 + + +def test_ensure_audit_log_tightens_existing_perms(hermes_home, tmp_path): + audit = tmp_path / "audit.log" + audit.write_text("preexisting content\n") + os.chmod(audit, 0o644) + ip.ensure_audit_log(audit) + mode = audit.stat().st_mode & 0o777 + assert mode == 0o600 + + +# --------------------------------------------------------------------------- +# State dir hardening (regression: world-traversable on multi-user hosts) +# --------------------------------------------------------------------------- + + +def test_proxy_state_dir_is_0o700(hermes_home): + state = ip._proxy_state_dir() + mode = state.stat().st_mode & 0o777 + assert mode == 0o700 + + +def test_proxy_state_dir_ro_does_not_create(hermes_home): + """_proxy_state_dir_ro is for read-only callers — it must NOT + materialize the dir. Pure-status code paths shouldn't have the + side-effect of creating ~/.hermes/proxy/.""" + + # Sanity: rw path creates it. + rw = ip._proxy_state_dir() + assert rw.exists() + # Remove it and confirm the ro path doesn't recreate. + import shutil as _shutil + _shutil.rmtree(str(rw)) + assert not rw.exists() + ro = ip._proxy_state_dir_ro() + assert not ro.exists() + # The path string is the same as the rw one. + assert ro == rw + + +# --------------------------------------------------------------------------- +# Mappings clobber refused when corrupt (regression: silent 403s) +# --------------------------------------------------------------------------- + + +def test_docker_egress_args_raises_on_empty_mappings(hermes_home, monkeypatch): + """If mappings.json is missing / corrupt / empty AND + enforce_on_docker is true, refuse to start the sandbox rather than + silently mounting an unusable proxy config.""" + + from tools.environments.docker import _egress_proxy_args_for_docker + from hermes_cli.config import load_config, save_config + + state = ip._proxy_state_dir() + (state / "ca.crt").write_text("fake-ca") + (state / "ca.key").write_text("fake-key") + proxy_cfg = ip.build_proxy_config( + mappings=[_sample_mapping()], + ca_cert=state / "ca.crt", ca_key=state / "ca.key", tunnel_port=9090, + ) + ip.write_proxy_config(proxy_cfg) + # Note: we deliberately do NOT write mappings.json — that's the + # bug-class this test guards against. + + cfg = load_config() + cfg.setdefault("proxy", {})["enabled"] = True + cfg["proxy"]["enforce_on_docker"] = True + save_config(cfg) + + (state / "iron-proxy.pid").write_text("99999") + monkeypatch.setattr(ip, "_pid_alive", lambda pid: True) + monkeypatch.setattr(ip, "_port_listening", lambda h, p: True) + + with pytest.raises(RuntimeError, match="mappings.json is empty or"): + _egress_proxy_args_for_docker() + + +# --------------------------------------------------------------------------- +# CA missing → enforce_on_docker semantics (regression: silent fail-open) +# --------------------------------------------------------------------------- + + +def test_docker_egress_args_raises_when_ca_vanishes(hermes_home, monkeypatch): + """status.configured was True at check time but the CA file + disappeared between then and now (e.g. operator manually deleted + ~/.hermes/proxy/ca.crt). enforce_on_docker=True must refuse.""" + + from tools.environments.docker import _egress_proxy_args_for_docker + from hermes_cli.config import load_config, save_config + + state = ip._proxy_state_dir() + ca = state / "ca.crt" + ca.write_text("fake-ca") + (state / "ca.key").write_text("fake-key") + proxy_cfg = ip.build_proxy_config( + mappings=[_sample_mapping()], + ca_cert=ca, ca_key=state / "ca.key", tunnel_port=9090, + ) + ip.write_proxy_config(proxy_cfg) + ip.write_mappings([_sample_mapping()]) + + cfg = load_config() + cfg.setdefault("proxy", {})["enabled"] = True + cfg["proxy"]["enforce_on_docker"] = True + save_config(cfg) + + (state / "iron-proxy.pid").write_text("99999") + monkeypatch.setattr(ip, "_pid_alive", lambda pid: True) + monkeypatch.setattr(ip, "_port_listening", lambda h, p: True) + + # Build a fake status: configured=True (because both path fields are + # set), but ca_cert_path.exists() is False — simulating the race + # where the CA file vanished between get_status() and the + # exists() recheck inside _egress_proxy_args_for_docker. + fake_status = ip.ProxyStatus( + binary_path=state / "fake-bin", # truthy + config_path=state / "proxy.yaml", + ca_cert_path=state / "missing-ca.crt", # points at nonexistent path + pid=99999, + listening=True, + tunnel_port=9090, + ) + # ProxyStatus.configured returns True iff config_path AND ca_cert_path + # both exist. We need configured=True but the second exists() check + # in docker.py to return False — force that by writing a placeholder + # config_path that exists and pointing ca_cert_path at a missing file. + (state / "proxy.yaml").write_text("# fake") + # ProxyStatus.configured: config_path.exists() and ca_cert_path.exists(). + # Make ca_cert_path .exists() True for the configured check but the + # explicit .exists() recheck path in docker.py reads the same Path, + # which is missing — so we wrap. + class _CAStub: + """Path-like that toggles .exists() so configured=True but the + defensive recheck in docker.py returns False.""" + _calls = 0 + def __init__(self, real: Path): + self._real = real + def __str__(self): + return str(self._real) + @property + def parent(self): + return self._real.parent + def exists(self): + type(self)._calls += 1 + # First call: configured property check → say yes. + # Second call: docker.py defensive recheck → say no. + return type(self)._calls == 1 + fake_status.ca_cert_path = _CAStub(state / "missing-ca.crt") # type: ignore[assignment] + monkeypatch.setattr(ip, "get_status", lambda: fake_status) + + with pytest.raises(RuntimeError, match="CA cert vanished"): + _egress_proxy_args_for_docker() + + +# --------------------------------------------------------------------------- +# Docker env collision detection (regression: docker_env silently bypassed proxy) +# --------------------------------------------------------------------------- + + +def test_docker_env_collision_with_proxy_raises_when_enforce(hermes_home, monkeypatch): + """Setting docker_env: {HTTPS_PROXY: ''} in config.yaml with + enforce_on_docker=true must fail-loud rather than silently inverting + the egress isolation.""" + + from tools.environments.docker import DockerEnvironment + from hermes_cli.config import load_config, save_config + + # Set up a fully-running proxy. + state = ip._proxy_state_dir() + ca = state / "ca.crt" + ca.write_text("fake-ca") + (state / "ca.key").write_text("fake-key") + proxy_cfg = ip.build_proxy_config( + mappings=[_sample_mapping()], + ca_cert=ca, ca_key=state / "ca.key", tunnel_port=9090, + ) + ip.write_proxy_config(proxy_cfg) + ip.write_mappings([_sample_mapping()]) + cfg = load_config() + cfg.setdefault("proxy", {})["enabled"] = True + cfg["proxy"]["enforce_on_docker"] = True + save_config(cfg) + (state / "iron-proxy.pid").write_text("99999") + monkeypatch.setattr(ip, "_pid_alive", lambda pid: True) + monkeypatch.setattr(ip, "_port_listening", lambda h, p: True) + + # Mock the docker availability check so we never shell out. + monkeypatch.setattr( + "tools.environments.docker._ensure_docker_available", lambda: None, + ) + # Mock find_docker so the resolved docker exe isn't probed. + monkeypatch.setattr( + "tools.environments.docker.find_docker", lambda: "/bin/true", + ) + # Mock subprocess.run so we don't actually run `docker run`. We + # only need the constructor to get past the env merge logic. + monkeypatch.setattr( + "tools.environments.docker.subprocess.run", + lambda *a, **k: MagicMock(stdout="abc123\n", returncode=0), + ) + # init_session is the second outbound subprocess we don't care about. + monkeypatch.setattr( + "tools.environments.docker.DockerEnvironment.init_session", + lambda self: None, + ) + + # The collision: user sets HTTPS_PROXY to empty string in docker_env. + with pytest.raises(RuntimeError, match="overrides egress-proxy variables"): + DockerEnvironment( + image="busybox", + env={"HTTPS_PROXY": ""}, # the collision + ) + + +# --------------------------------------------------------------------------- +# v3 round: bridge-IP parser hardening (P1 #1) +# --------------------------------------------------------------------------- + + +@pytest.mark.parametrize( + "bogus_ip", + [ + "0.0.0.0", # INADDR_ANY — must NEVER bind here + "127.0.0.1", # loopback — reject (we bind loopback explicitly) + "224.0.0.1", # multicast + "240.0.0.0", # reserved + "169.254.0.1", # link-local / IMDS — never a real bridge + "8.8.8.8", # public — never a docker bridge + "999.999.999.999", # garbage that count(.)==3 used to accept + "aa.bb.cc.dd", # alpha garbage + ], +) +def test_detect_docker_bridge_ip_rejects_dangerous(monkeypatch, bogus_ip): + """The parser must reject anything that isn't plausibly a docker + bridge IP. Previously ``ip.count('.') == 3`` would accept all of + these and re-open the LAN exposure the bind-policy fix closed.""" + + fake_stdout = ( + f"3: docker0 inet {bogus_ip}/16 brd 172.17.255.255 scope global docker0\\\n" + " valid_lft forever preferred_lft forever" + ) + + def fake_run(cmd, **kw): + from unittest.mock import MagicMock + m = MagicMock() + m.returncode = 0 + m.stdout = fake_stdout + return m + + monkeypatch.setattr(ip.subprocess, "run", fake_run) + assert ip._detect_docker_bridge_ip() is None + + +def test_detect_docker_bridge_ip_accepts_typical(monkeypatch): + fake_stdout = ( + "3: docker0 inet 172.17.0.1/16 brd 172.17.255.255 scope global docker0\\\n" + " valid_lft forever preferred_lft forever" + ) + + def fake_run(cmd, **kw): + from unittest.mock import MagicMock + m = MagicMock() + m.returncode = 0 + m.stdout = fake_stdout + return m + + monkeypatch.setattr(ip.subprocess, "run", fake_run) + assert ip._detect_docker_bridge_ip() == "172.17.0.1" + + +def test_detect_docker_bridge_ip_handles_missing_ip_command(monkeypatch): + """No ``ip`` on PATH (or other OSError) returns None cleanly.""" + + def boom(*a, **k): + raise OSError("no such file") + + monkeypatch.setattr(ip.subprocess, "run", boom) + assert ip._detect_docker_bridge_ip() is None + + +# --------------------------------------------------------------------------- +# v3: default deny-list adjacency (P2 IPv4-mapped-v6 + CGNAT) +# --------------------------------------------------------------------------- + + +def test_default_deny_includes_ipv4_mapped_v6(tmp_path): + cfg = ip.build_proxy_config( + mappings=[_sample_mapping()], + ca_cert=tmp_path / "ca.crt", + ca_key=tmp_path / "ca.key", + ) + deny = cfg["proxy"]["upstream_deny_cidrs"] + assert "::ffff:0:0/96" in deny + assert "100.64.0.0/10" in deny # CGNAT + assert "198.18.0.0/15" in deny # RFC2544 benchmark + + +# --------------------------------------------------------------------------- +# v3: split LLM-specific blocked tier (P1 #3 + P2 non_bearer tiers) +# --------------------------------------------------------------------------- + + +def test_blocked_providers_subset_of_uncovered(hermes_home, monkeypatch): + """The strict tier that BLOCKS start must be a subset of the + uncovered-but-warn tier; the wizard surfaces ALL uncovered but only + blocks the LLM-specific ones.""" + + monkeypatch.setenv("ANTHROPIC_API_KEY", "sk-ant") + monkeypatch.setenv("AWS_ACCESS_KEY_ID", "AKIA-test") + monkeypatch.setenv("GOOGLE_APPLICATION_CREDENTIALS", "/etc/gcp.json") + monkeypatch.setenv("GEMINI_API_KEY", "g-test") + + uncovered = set(ip.discover_uncovered_providers()) + blocked = set(ip.discover_blocked_providers()) + + # Strict subset: every blocked is also uncovered, but the reverse + # doesn't hold. + assert blocked.issubset(uncovered) + # AWS / GCP appdefault present but NOT blocked (those are present on + # most dev laptops for unrelated cloud tooling). + assert "AWS_ACCESS_KEY_ID" in uncovered + assert "AWS_ACCESS_KEY_ID" not in blocked + assert "GOOGLE_APPLICATION_CREDENTIALS" in uncovered + assert "GOOGLE_APPLICATION_CREDENTIALS" not in blocked + # LLM-specific providers ARE blocked. + assert "ANTHROPIC_API_KEY" in blocked + assert "GEMINI_API_KEY" in blocked + + +# --------------------------------------------------------------------------- +# v3: _pid_proc_starttime parser (handles comm with parens, brackets) +# --------------------------------------------------------------------------- + + +def test_pid_proc_starttime_parses_comm_with_parens(tmp_path, monkeypatch): + """``/proc//stat`` 'comm' field can contain spaces and parens + (e.g. a process literally named ``weird) tail``, with the comm + wrapped in the outer parens producing ``(weird) tail)``). The + starttime parser must split from the LAST ')' to skip the comm + correctly, otherwise the field-index math drifts. + + Layout reminder: `` () ... ...`` + where starttime is field 22 (1-indexed). Past the LAST ')' we have + fields 3..N → tail index 0..N-3, so starttime is at tail index 19. + """ + + # Comm contains a ')' character inside the outer parens. The outer + # parens are stripped by the kernel's stat format; we test that + # rfind(')') correctly finds the OUTER closing one. + # Format: pid (comm) state ppid pgrp session tty_nr tpgid flags + # minflt cminflt majflt cmajflt utime stime cutime cstime + # priority nice num_threads itrealvalue starttime ... + fake_stat = ( + "12345 (weird) tail) " # pid + comm-with-paren-and-space + "S 1 1 1 0 -1 4194304 " # state ppid pgrp sess tty tpgid flags + "100 0 0 0 10 5 0 0 " # minflt cminflt majflt cmajflt utime stime cutime cstime + "20 0 1 0 99887766 " # priority nice nthreads itreal STARTTIME + "1234 5678 ...rest..." # vsize rss ... + ) + + real_read_text = Path.read_text + + def fake_read_text(self, *a, **k): + if str(self).startswith("/proc/12345/stat"): + return fake_stat + return real_read_text(self, *a, **k) + + monkeypatch.setattr(Path, "read_text", fake_read_text) + assert ip._pid_proc_starttime(12345) == "99887766" + + +def test_pid_proc_starttime_returns_none_on_missing_proc(monkeypatch): + """Non-Linux hosts or pid not running.""" + + def raise_oserror(self, *a, **k): + raise OSError("no such file") + + monkeypatch.setattr(Path, "read_text", raise_oserror) + assert ip._pid_proc_starttime(99999) is None + + +# --------------------------------------------------------------------------- +# v3: stop_proxy SIGKILL suppression on pid recycle (P3 #5 coverage gap) +# --------------------------------------------------------------------------- + + +def test_stop_proxy_suppresses_sigkill_on_pid_recycle(hermes_home, monkeypatch): + """When _pid_proc_starttime returns different values before and + after the SIGTERM grace window, stop_proxy must NOT issue SIGKILL — + the original pid was recycled to an unrelated process.""" + + state = ip._proxy_state_dir() + (state / "iron-proxy.pid").write_text("12345") + + # _pid_alive: always True (process at this pid keeps appearing alive) + monkeypatch.setattr(ip, "_pid_alive", lambda pid: True) + + # Starttime changes — first call returns "AAA", second returns "BBB" + # (simulating PID recycle to an unrelated process during the grace). + starttime_responses = iter(["111", "222"]) + monkeypatch.setattr( + ip, "_pid_proc_starttime", + lambda pid: next(starttime_responses, "222"), + ) + + # Sentinel: kill list — if SIGKILL is sent, this gets populated. + kills: list = [] + + def fake_os_kill(pid, sig): + kills.append((pid, sig)) + + monkeypatch.setattr(ip.os, "kill", fake_os_kill) + # Speed up the wait loop. + monkeypatch.setattr(ip.time, "sleep", lambda _: None) + # Make time advance fast. + orig_time = ip.time.time + counter = {"n": 0.0} + + def fake_time(): + counter["n"] += 1.0 # 1 second per call — past deadline immediately + return counter["n"] + + monkeypatch.setattr(ip.time, "time", fake_time) + + result = ip.stop_proxy() + assert result is True + # SIGTERM should fire, SIGKILL should NOT (recycled detection). + sigterm_count = sum(1 for _, s in kills if s == ip.signal.SIGTERM) + sigkill_count = sum(1 for _, s in kills if s == ip._KILL_SIGNAL) + assert sigterm_count == 1 + assert sigkill_count == 0, f"SIGKILL was issued despite pid recycle: {kills}" + + +# --------------------------------------------------------------------------- +# v3: _reset_for_tests actually clears module state (P3 #1) +# --------------------------------------------------------------------------- + + +def test_reset_for_tests_clears_version_cache_and_nonce(): + """_reset_for_tests must clear _VERSION_CACHE and _proxy_nonce so + in-process callers don't see leakage between tests.""" + + ip._VERSION_CACHE["dummy"] = "v0.0.0-fake" + ip._proxy_nonce = "fake-nonce-12345" + ip._reset_for_tests() + assert ip._VERSION_CACHE == {} + assert ip._proxy_nonce is None + + +# --------------------------------------------------------------------------- +# v3: version cache doesn't poison on empty stdout (P2 _VERSION_CACHE bug B) +# --------------------------------------------------------------------------- + + +def test_iron_proxy_version_does_not_cache_empty_output(monkeypatch, tmp_path): + """If --version returns 0 with empty stdout (corrupt binary, flag + rename upstream), don't poison the cache — re-probe next call.""" + + binary = tmp_path / "iron-proxy" + binary.write_text("") + + # First call: returns empty output. + def fake_run_empty(cmd, **kw): + from unittest.mock import MagicMock + m = MagicMock() + m.returncode = 0 + m.stdout = "" + m.stderr = "" + return m + + ip._VERSION_CACHE.clear() + monkeypatch.setattr(ip.subprocess, "run", fake_run_empty) + assert ip.iron_proxy_version(binary) == "" + # Should NOT have cached the empty string. + assert str(binary) not in ip._VERSION_CACHE + + +# --------------------------------------------------------------------------- +# v3: NODE_OPTIONS append-merge in docker env (arshkumarsingh #1) +# --------------------------------------------------------------------------- + + +def test_docker_egress_node_options_uses_sentinel(hermes_home, monkeypatch): + """``_egress_proxy_args_for_docker`` should NOT put NODE_OPTIONS in + env_overrides directly; it uses a sentinel key + ``_HERMES_EGRESS_NODE_OPTIONS_APPEND`` so DockerEnvironment can + append-merge with the operator's existing NODE_OPTIONS.""" + + from tools.environments.docker import _egress_proxy_args_for_docker + from hermes_cli.config import load_config, save_config + + state = ip._proxy_state_dir() + ca = state / "ca.crt" + ca.write_text("fake-ca") + (state / "ca.key").write_text("fake-key") + mapping = _sample_mapping("OPENROUTER_API_KEY") + proxy_cfg = ip.build_proxy_config( + mappings=[mapping], ca_cert=ca, ca_key=state / "ca.key", tunnel_port=9090, + ) + ip.write_proxy_config(proxy_cfg) + ip.write_mappings([mapping]) + + cfg = load_config() + cfg.setdefault("proxy", {})["enabled"] = True + cfg["proxy"]["enforce_on_docker"] = True + save_config(cfg) + + (state / "iron-proxy.pid").write_text("99999") + monkeypatch.setattr(ip, "_pid_alive", lambda pid: True) + monkeypatch.setattr(ip, "_port_listening", lambda h, p: True) + + _, env, _ = _egress_proxy_args_for_docker() + # The egress dict should contain the sentinel, NOT a raw NODE_OPTIONS. + assert env.get("_HERMES_EGRESS_NODE_OPTIONS_APPEND") == "--use-openssl-ca" + assert "NODE_OPTIONS" not in env, ( + "NODE_OPTIONS in egress env_overrides would clobber the operator's " + "docker_env NODE_OPTIONS — that's exactly the bug arshkumarsingh " + "flagged." + ) + + +# --------------------------------------------------------------------------- +# v3: ensure_audit_log fails loud on OSError (P2 promise mismatch) +# --------------------------------------------------------------------------- + + +def test_ensure_audit_log_raises_on_immutable_parent(tmp_path, monkeypatch): + """When the audit log can't be created with 0o600 (planted symlink, + immutable dir, disk full), raise — silently logging a warning would + let the daemon create the file under default umask, breaking the + privacy promise.""" + + # Aim at a path whose parent doesn't exist — open() will OSError. + audit = tmp_path / "definitely-does-not-exist" / "audit.log" + with pytest.raises(RuntimeError, match="audit log"): + ip.ensure_audit_log(audit) + + +# --------------------------------------------------------------------------- +# v3: persisted nonce roundtrip (stephenschoettler #3 cross-CLI defense) +# --------------------------------------------------------------------------- + + +def test_persisted_nonce_roundtrip(hermes_home, monkeypatch): + """Write the nonce next to the pidfile (simulating one CLI invocation + finishing start_proxy), then verify a fresh _read_persisted_nonce + can pick it up — that's what cross-process _pid_alive uses.""" + + nonce_path = ip._persisted_nonce_path() + nonce_path.parent.mkdir(parents=True, exist_ok=True) + nonce_path.write_text("test-nonce-abc123") + assert ip._read_persisted_nonce() == "test-nonce-abc123" + + +def test_persisted_nonce_returns_none_when_missing(hermes_home): + """No nonce file → None, callers fall back to argv0 basename.""" + assert ip._read_persisted_nonce() is None + + +# --------------------------------------------------------------------------- +# v4 round (GodsBoy follow-up): bind-host-aware liveness probes + +# allow_env_fallback on the partial-secret path +# --------------------------------------------------------------------------- + + +def test_read_http_listen_from_config_returns_host_and_port(hermes_home): + """_read_http_listen_from_config must surface the BIND HOST, not just + the port — on Linux the daemon binds the docker bridge gateway and a + hardcoded loopback probe would report a healthy daemon as dead.""" + + state = ip._proxy_state_dir() + (state / "proxy.yaml").write_text( + "proxy:\n http_listen: 172.17.0.1:9090\n", encoding="utf-8" + ) + assert ip._read_http_listen_from_config() == ("172.17.0.1", 9090) + assert ip._read_tunnel_port_from_config() == 9090 + + +def test_read_http_listen_from_config_missing_file(hermes_home): + assert ip._read_http_listen_from_config() is None + + +def test_get_status_probes_configured_bind_host(hermes_home, monkeypatch): + """get_status must probe the configured bind host (e.g. the docker + bridge IP), not loopback unconditionally.""" + + state = ip._proxy_state_dir() + (state / "proxy.yaml").write_text( + "proxy:\n http_listen: 172.17.0.1:9123\n", encoding="utf-8" + ) + (state / "ca.crt").write_text("cert") + ip._write_pidfile_safely(ip._pidfile(), 99999) + monkeypatch.setattr(ip, "_pid_alive", lambda pid: True) + monkeypatch.setattr(ip, "find_iron_proxy", lambda **kw: None) + + probed = {} + + def fake_probe(host, port): + probed["host"] = host + probed["port"] = port + return True + + monkeypatch.setattr(ip, "_port_listening", fake_probe) + status = ip.get_status() + assert probed == {"host": "172.17.0.1", "port": 9123} + assert status.listening is True + assert status.tunnel_port == 9123 + + +def test_partial_bitwarden_secrets_honor_allow_env_fallback( + hermes_home, monkeypatch, +): + """The missing-secret branch's own error message tells operators to + set proxy.allow_env_fallback — so the flag must actually work there + (previously only the empty-token branch honored it).""" + + ip.write_mappings([_sample_mapping("OPENROUTER_API_KEY")]) + monkeypatch.setenv("OPENROUTER_API_KEY", "sk-host-fallback") + + import agent.secret_sources.bitwarden as bw + monkeypatch.setattr( + bw, "fetch_bitwarden_secrets", lambda **kw: ({}, []), + ) + monkeypatch.setenv("BWS_ACCESS_TOKEN", "tok") + bw_cfg = { + "project_id": "proj", + "access_token_env": "BWS_ACCESS_TOKEN", + "allow_env_fallback": True, + } + + env = ip._build_proxy_subprocess_env( + refresh_from_bitwarden=True, bitwarden_config=bw_cfg, + ) + # Falls back to the host env value instead of raising. + assert env.get("OPENROUTER_API_KEY") == "sk-host-fallback" + + +def test_partial_bitwarden_secrets_raise_without_fallback( + hermes_home, monkeypatch, +): + """Strict default: missing BWS secrets raise.""" + + ip.write_mappings([_sample_mapping("OPENROUTER_API_KEY")]) + monkeypatch.setenv("OPENROUTER_API_KEY", "sk-host") + + import agent.secret_sources.bitwarden as bw + monkeypatch.setattr( + bw, "fetch_bitwarden_secrets", lambda **kw: ({}, []), + ) + monkeypatch.setenv("BWS_ACCESS_TOKEN", "tok") + bw_cfg = {"project_id": "proj", "access_token_env": "BWS_ACCESS_TOKEN"} + + with pytest.raises(RuntimeError, match="did not return secrets"): + ip._build_proxy_subprocess_env( + refresh_from_bitwarden=True, bitwarden_config=bw_cfg, + ) + + +def test_bitwarden_importerror_raise_without_fallback( + hermes_home, monkeypatch, +): + """Strict default: ImportError on BWS module raises when + allow_env_fallback is unset, matching the sibling branches.""" + + ip.write_mappings([_sample_mapping("OPENROUTER_API_KEY")]) + monkeypatch.setenv("OPENROUTER_API_KEY", "sk-host") + + # Simulate the BWS SDK not being installed. The lazy import + # ``from agent.secret_sources import bitwarden`` inside + # _build_proxy_subprocess_env resolves through the parent package's + # cached attribute; deleting both the sys.modules entry AND the + # parent-package attribute forces a real import that we intercept. + # + # In addition, block importlib.reload in case the test infra used it. + import agent.secret_sources as ss + monkeypatch.delitem(sys.modules, "agent.secret_sources.bitwarden", raising=False) + monkeypatch.delitem(sys.modules, "agent.secret_sources.bitwarden.bws", raising=False) + monkeypatch.delattr(ss, "bitwarden", raising=False) + + # Now block the re-import. ``from agent.secret_sources import + # bitwarden`` resolves to a submodule attribute; setting it to a + # sentinel that raises on attribute access is more reliable than + # trying to intercept __import__ at the C level. + class _MissingBWS: + """Sentinel: accessing any attribute raises ImportError.""" + def __getattr__(self, _name): + raise ImportError("bws SDK not installed") + def __call__(self, *a, **kw): + raise ImportError("bws SDK not installed") + monkeypatch.setattr(ss, "bitwarden", _MissingBWS(), raising=False) + + monkeypatch.setenv("BWS_ACCESS_TOKEN", "tok") + bw_cfg = {"project_id": "proj", "access_token_env": "BWS_ACCESS_TOKEN"} + + with pytest.raises(RuntimeError, match="Bitwarden refresh module unavailable"): + ip._build_proxy_subprocess_env( + refresh_from_bitwarden=True, bitwarden_config=bw_cfg, + ) + + +def test_bitwarden_importerror_honor_allow_env_fallback( + hermes_home, monkeypatch, +): + """With allow_env_fallback, an ImportError falls through to host env + instead of raising.""" + + ip.write_mappings([_sample_mapping("OPENROUTER_API_KEY")]) + monkeypatch.setenv("OPENROUTER_API_KEY", "sk-host-fallback") + + import agent.secret_sources as ss + monkeypatch.delitem(sys.modules, "agent.secret_sources.bitwarden", raising=False) + monkeypatch.delitem(sys.modules, "agent.secret_sources.bitwarden.bws", raising=False) + monkeypatch.delattr(ss, "bitwarden", raising=False) + + class _MissingBWS: + def __getattr__(self, _name): + raise ImportError("bws SDK not installed") + def __call__(self, *a, **kw): + raise ImportError("bws SDK not installed") + monkeypatch.setattr(ss, "bitwarden", _MissingBWS(), raising=False) + + monkeypatch.setenv("BWS_ACCESS_TOKEN", "tok") + bw_cfg = { + "project_id": "proj", + "access_token_env": "BWS_ACCESS_TOKEN", + "allow_env_fallback": True, + } + + env = ip._build_proxy_subprocess_env( + refresh_from_bitwarden=True, bitwarden_config=bw_cfg, + ) + assert env.get("OPENROUTER_API_KEY") == "sk-host-fallback" diff --git a/tests/test_iron_proxy_cli.py b/tests/test_iron_proxy_cli.py new file mode 100644 index 00000000000..3567db612bf --- /dev/null +++ b/tests/test_iron_proxy_cli.py @@ -0,0 +1,554 @@ +"""Unit tests for ``hermes_cli.proxy_cli`` command handlers. + +These tests cover the user-facing CLI surface that was previously +uncovered. We mock the iron_proxy module's side-effect functions +(install / start / stop / discover) and exercise the dispatch + +return-code logic plus the small amount of presentation logic in +each handler (e.g. --from-bitwarden's fail-loud path). +""" + +from __future__ import annotations + +import argparse +import os +from pathlib import Path +from unittest.mock import MagicMock, patch + +import pytest + +from agent.proxy_sources import iron_proxy as ip +from hermes_cli import proxy_cli + + +@pytest.fixture +def hermes_home(tmp_path, monkeypatch): + """Point HERMES_HOME at a temp dir so the wizard doesn't touch the + operator's real config. Also blanks any provider env vars so we + don't accidentally read a real key.""" + + home = tmp_path / "hermes" + home.mkdir() + monkeypatch.setenv("HERMES_HOME", str(home)) + for key in list(os.environ): + if key.endswith("_API_KEY") or key in ( + "BWS_ACCESS_TOKEN", "ANTHROPIC_API_KEY", + "AWS_ACCESS_KEY_ID", "AWS_SECRET_ACCESS_KEY", + ): + monkeypatch.delenv(key, raising=False) + return home + + +def _args(**overrides): + ns = argparse.Namespace( + force=False, + tunnel_port=None, + from_bitwarden=False, + rotate_tokens=False, + show_tokens=False, + ) + for k, v in overrides.items(): + setattr(ns, k, v) + return ns + + +# --------------------------------------------------------------------------- +# cmd_install +# --------------------------------------------------------------------------- + + +def test_cmd_install_success_returns_0(hermes_home, monkeypatch): + monkeypatch.setattr(ip, "install_iron_proxy", lambda **kw: hermes_home / "iron-proxy") + monkeypatch.setattr(ip, "iron_proxy_version", lambda b: "v0.39.0-test") + rc = proxy_cli.cmd_install(_args()) + assert rc == 0 + + +def test_cmd_install_failure_returns_1(hermes_home, monkeypatch): + def boom(**kw): + raise RuntimeError("download failed") + monkeypatch.setattr(ip, "install_iron_proxy", boom) + rc = proxy_cli.cmd_install(_args()) + assert rc == 1 + + +# --------------------------------------------------------------------------- +# cmd_setup — --from-bitwarden fail-loud paths +# --------------------------------------------------------------------------- + + +def test_cmd_setup_from_bitwarden_refuses_when_bw_disabled(hermes_home, monkeypatch): + """When --from-bitwarden is passed but secrets.bitwarden.enabled=false, + the wizard must FAIL rather than silently rewriting credential_source + to bitwarden.""" + + from hermes_cli.config import load_config, save_config + + cfg = load_config() + cfg.setdefault("secrets", {})["bitwarden"] = {"enabled": False} + save_config(cfg) + + # Pre-stub install + CA so we get to step 3. + monkeypatch.setattr(ip, "find_iron_proxy", lambda **kw: hermes_home / "iron-proxy") + monkeypatch.setattr(ip, "iron_proxy_version", lambda b: "test") + monkeypatch.setattr( + ip, "ensure_ca_cert", + lambda **kw: (hermes_home / "ca.crt", hermes_home / "ca.key"), + ) + + rc = proxy_cli.cmd_setup(_args(from_bitwarden=True)) + assert rc == 1 + # Verify we did NOT write credential_source: bitwarden to config. + cfg2 = load_config() + proxy_cfg = cfg2.get("proxy") or {} + assert proxy_cfg.get("credential_source", "env") != "bitwarden" + + +def test_cmd_setup_from_bitwarden_refuses_when_token_missing(hermes_home, monkeypatch): + """--from-bitwarden with secrets.bitwarden.enabled=true but BWS access + token unset → fail loud, not silent env-fallback.""" + + from hermes_cli.config import load_config, save_config + + cfg = load_config() + cfg.setdefault("secrets", {})["bitwarden"] = { + "enabled": True, + "project_id": "test-proj", + "access_token_env": "BWS_ACCESS_TOKEN", + } + save_config(cfg) + monkeypatch.delenv("BWS_ACCESS_TOKEN", raising=False) + + monkeypatch.setattr(ip, "find_iron_proxy", lambda **kw: hermes_home / "iron-proxy") + monkeypatch.setattr(ip, "iron_proxy_version", lambda b: "test") + monkeypatch.setattr( + ip, "ensure_ca_cert", + lambda **kw: (hermes_home / "ca.crt", hermes_home / "ca.key"), + ) + + rc = proxy_cli.cmd_setup(_args(from_bitwarden=True)) + assert rc == 1 + + +def test_cmd_setup_from_bitwarden_refuses_on_empty_vault(hermes_home, monkeypatch): + """If BW returns {} (empty vault / scoped wrong / unreachable), fail + loud rather than silently writing credential_source: bitwarden.""" + + from hermes_cli.config import load_config, save_config + + cfg = load_config() + cfg.setdefault("secrets", {})["bitwarden"] = { + "enabled": True, + "project_id": "test-proj", + "access_token_env": "BWS_ACCESS_TOKEN", + } + save_config(cfg) + monkeypatch.setenv("BWS_ACCESS_TOKEN", "bwsk-test-token") + + monkeypatch.setattr(ip, "find_iron_proxy", lambda **kw: hermes_home / "iron-proxy") + monkeypatch.setattr(ip, "iron_proxy_version", lambda b: "test") + monkeypatch.setattr( + ip, "ensure_ca_cert", + lambda **kw: (hermes_home / "ca.crt", hermes_home / "ca.key"), + ) + + # Mock fetch_bitwarden_secrets to return an empty dict (empty vault). + fake_bw = MagicMock() + fake_bw.fetch_bitwarden_secrets = lambda **kw: ({}, []) + monkeypatch.setattr("agent.secret_sources.bitwarden", fake_bw, raising=False) + import sys + sys.modules["agent.secret_sources.bitwarden"] = fake_bw + + rc = proxy_cli.cmd_setup(_args(from_bitwarden=True)) + assert rc == 1 + + +def test_cmd_setup_rejects_tunnel_port_zero(hermes_home, monkeypatch): + """--tunnel-port=0 is rejected explicitly (was silently substituting + the default before the fix).""" + + monkeypatch.setenv("OPENROUTER_API_KEY", "sk-or-test") + monkeypatch.setattr(ip, "find_iron_proxy", lambda **kw: hermes_home / "iron-proxy") + monkeypatch.setattr(ip, "iron_proxy_version", lambda b: "test") + monkeypatch.setattr( + ip, "ensure_ca_cert", + lambda **kw: (hermes_home / "ca.crt", hermes_home / "ca.key"), + ) + rc = proxy_cli.cmd_setup(_args(tunnel_port=0)) + assert rc == 1 + + +@pytest.mark.parametrize("bad_port", [-1, 65535, 65536]) +def test_cmd_setup_rejects_invalid_tunnel_port_range(hermes_home, monkeypatch, bad_port): + """The egress wizard owns the derived HTTP listener at tunnel_port+1, + so both listener ports must fit in the TCP range.""" + + monkeypatch.setenv("OPENROUTER_API_KEY", "sk-or-test") + monkeypatch.setattr(ip, "find_iron_proxy", lambda **kw: hermes_home / "iron-proxy") + monkeypatch.setattr(ip, "iron_proxy_version", lambda b: "test") + monkeypatch.setattr( + ip, + "ensure_ca_cert", + lambda **kw: (hermes_home / "ca.crt", hermes_home / "ca.key"), + ) + + rc = proxy_cli.cmd_setup(_args(tunnel_port=bad_port)) + assert rc == 1 + + +# --------------------------------------------------------------------------- +# cmd_start — fail_on_uncovered_providers + Bitwarden rotation wire-up +# --------------------------------------------------------------------------- + + +def test_cmd_start_refuses_when_proxy_disabled(hermes_home, monkeypatch): + from hermes_cli.config import load_config, save_config + cfg = load_config() + cfg.setdefault("proxy", {})["enabled"] = False + save_config(cfg) + + rc = proxy_cli.cmd_start(_args()) + assert rc == 1 + + +def test_cmd_start_refuses_on_uncovered_provider_when_strict(hermes_home, monkeypatch): + """fail_on_uncovered_providers=true + ANTHROPIC_API_KEY in env = + refuse to start (real credential would otherwise leak into sandbox).""" + + from hermes_cli.config import load_config, save_config + cfg = load_config() + cfg.setdefault("proxy", {})["enabled"] = True + cfg["proxy"]["fail_on_uncovered_providers"] = True + save_config(cfg) + monkeypatch.setenv("ANTHROPIC_API_KEY", "sk-ant-test") + + rc = proxy_cli.cmd_start(_args()) + assert rc == 1 + + +def test_cmd_start_honors_auto_install_false(hermes_home, monkeypatch): + from hermes_cli.config import load_config, save_config + + cfg = load_config() + cfg.setdefault("proxy", {})["enabled"] = True + cfg["proxy"]["auto_install"] = False + save_config(cfg) + + captured: dict = {} + + def fake_start_proxy(**kw): + captured.update(kw) + s = ip.ProxyStatus(pid=4242, listening=True, tunnel_port=9090) + return s + + monkeypatch.setattr(ip, "start_proxy", fake_start_proxy) + monkeypatch.setattr(ip, "discover_uncovered_providers", lambda **kw: []) + monkeypatch.setattr(ip, "discover_blocked_providers", lambda **kw: []) + + rc = proxy_cli.cmd_start(_args()) + assert rc == 0 + assert captured.get("install_if_missing") is False + + +def test_cmd_start_passes_bitwarden_refresh_flag_when_credential_source_is_bitwarden( + hermes_home, monkeypatch, +): + """When credential_source=bitwarden, cmd_start must wire + refresh_secrets_from_bitwarden=True into start_proxy. That's what + delivers the rotation promise the docs make.""" + + from hermes_cli.config import load_config, save_config + cfg = load_config() + cfg.setdefault("proxy", {})["enabled"] = True + cfg["proxy"]["credential_source"] = "bitwarden" + cfg["proxy"]["fail_on_uncovered_providers"] = False + cfg.setdefault("secrets", {})["bitwarden"] = { + "enabled": True, + "project_id": "test-proj-id", + "access_token_env": "BWS_ACCESS_TOKEN", + } + save_config(cfg) + # v3: cmd_start now pre-checks BWS access token + project_id before + # calling start_proxy. Provide both so we get to the rotation + # wire-up code path. + monkeypatch.setenv("BWS_ACCESS_TOKEN", "bwsk-test-access-token") + + captured: dict = {} + def fake_start_proxy(**kw): + captured.update(kw) + s = ip.ProxyStatus() + s.pid = 4242 + s.listening = True + s.tunnel_port = 9090 + return s + monkeypatch.setattr(ip, "start_proxy", fake_start_proxy) + monkeypatch.setattr(ip, "discover_uncovered_providers", lambda **kw: []) + monkeypatch.setattr(ip, "discover_blocked_providers", lambda **kw: []) + + rc = proxy_cli.cmd_start(_args()) + assert rc == 0 + assert captured.get("refresh_secrets_from_bitwarden") is True + assert captured.get("bitwarden_config") is not None + + +def test_cmd_start_refuses_when_bitwarden_token_missing(hermes_home, monkeypatch): + """stephenschoettler #1: when credential_source=bitwarden but the + access-token env var is empty, cmd_start must fail-loud BEFORE + start_proxy can silently fall back to parent env.""" + + from hermes_cli.config import load_config, save_config + cfg = load_config() + cfg.setdefault("proxy", {})["enabled"] = True + cfg["proxy"]["credential_source"] = "bitwarden" + cfg["proxy"]["fail_on_uncovered_providers"] = False + cfg.setdefault("secrets", {})["bitwarden"] = { + "enabled": True, + "project_id": "test-proj-id", + "access_token_env": "BWS_ACCESS_TOKEN", + } + save_config(cfg) + monkeypatch.delenv("BWS_ACCESS_TOKEN", raising=False) + + # Sentinel: start_proxy must NOT be called. + def must_not_call(**kw): + pytest.fail("start_proxy should not be invoked when BWS token missing") + monkeypatch.setattr(ip, "start_proxy", must_not_call) + monkeypatch.setattr(ip, "discover_uncovered_providers", lambda **kw: []) + monkeypatch.setattr(ip, "discover_blocked_providers", lambda **kw: []) + + rc = proxy_cli.cmd_start(_args()) + assert rc == 1 + + +def test_cmd_start_does_not_pass_bitwarden_refresh_when_credential_source_is_env( + hermes_home, monkeypatch, +): + from hermes_cli.config import load_config, save_config + cfg = load_config() + cfg.setdefault("proxy", {})["enabled"] = True + cfg["proxy"]["credential_source"] = "env" + cfg["proxy"]["fail_on_uncovered_providers"] = False + save_config(cfg) + + captured: dict = {} + def fake_start_proxy(**kw): + captured.update(kw) + s = ip.ProxyStatus() + s.pid = 4242 + s.listening = True + return s + monkeypatch.setattr(ip, "start_proxy", fake_start_proxy) + monkeypatch.setattr(ip, "discover_uncovered_providers", lambda **kw: []) + + rc = proxy_cli.cmd_start(_args()) + assert rc == 0 + assert captured.get("refresh_secrets_from_bitwarden") is False + + +# --------------------------------------------------------------------------- +# cmd_stop, cmd_status, cmd_disable, cmd_config +# --------------------------------------------------------------------------- + + +def test_cmd_stop_returns_0_when_running(hermes_home, monkeypatch): + monkeypatch.setattr(ip, "stop_proxy", lambda: True) + rc = proxy_cli.cmd_stop(_args()) + assert rc == 0 + + +def test_cmd_stop_returns_0_when_already_stopped(hermes_home, monkeypatch): + monkeypatch.setattr(ip, "stop_proxy", lambda: False) + rc = proxy_cli.cmd_stop(_args()) + assert rc == 0 + + +def test_cmd_status_returns_0(hermes_home, monkeypatch): + monkeypatch.setattr(ip, "get_status", lambda: ip.ProxyStatus()) + monkeypatch.setattr(ip, "load_mappings", lambda: []) + monkeypatch.setattr(ip, "discover_uncovered_providers", lambda **kw: []) + rc = proxy_cli.cmd_status(_args()) + assert rc == 0 + + +def test_cmd_disable_uses_public_status_pid_not_private_read_pid( + hermes_home, monkeypatch, +): + """cmd_disable must read status.pid (which incorporates the _pid_alive + check) — NOT ip._read_pid() directly (which would fire a spurious + 'still running' warning for a stale pidfile from a crashed run).""" + + from hermes_cli.config import load_config, save_config + + cfg = load_config() + cfg.setdefault("proxy", {})["enabled"] = True + save_config(cfg) + + # Pidfile exists but the process is dead. Old code would have warned + # "still running"; the new code reads status.pid which returns None + # because _pid_alive is False, so no spurious warning. + state = ip._proxy_state_dir() + (state / "iron-proxy.pid").write_text("99999") + # _pid_alive returns False → status.pid is None. + monkeypatch.setattr(ip, "_pid_alive", lambda pid: False) + + # If cmd_disable reads _read_pid() directly (old path), this test + # would still pass — but reading status.pid is the correct + # API. Sentinel: confirm _read_pid is NOT called from cmd_disable. + read_pid_calls = [] + real_read_pid = ip._read_pid + def tracked_read_pid(*a, **kw): + read_pid_calls.append((a, kw)) + return real_read_pid(*a, **kw) + monkeypatch.setattr(ip, "_read_pid", tracked_read_pid) + + rc = proxy_cli.cmd_disable(_args()) + assert rc == 0 + # cmd_disable should call get_status() (which may internally call + # _read_pid), but should NOT call _read_pid from its own body. + # Hard to assert directly without source-introspection — the meatier + # assertion is that no "still running" message fired with a stale + # pidfile. That's covered by inspecting return code + config + # mutation only. + from hermes_cli.config import load_config as _lc + cfg2 = _lc() + assert cfg2["proxy"]["enabled"] is False + + +def test_cmd_config_returns_0_when_present(hermes_home, monkeypatch): + fake = ip.ProxyStatus() + fake.config_path = hermes_home / "proxy.yaml" + monkeypatch.setattr(ip, "get_status", lambda: fake) + rc = proxy_cli.cmd_config(_args()) + assert rc == 0 + + +def test_cmd_config_returns_1_when_missing(hermes_home, monkeypatch): + monkeypatch.setattr(ip, "get_status", lambda: ip.ProxyStatus()) + rc = proxy_cli.cmd_config(_args()) + assert rc == 1 + + +# --------------------------------------------------------------------------- +# Argparse wiring — dest='egress_command' regression +# --------------------------------------------------------------------------- + + +def test_register_cli_uses_egress_command_dest(): + """The subparser dest must be 'egress_command' to stay disjoint from + the inbound OAuth 'hermes proxy' subparser (dest='proxy_command'). + A future grep-and-refactor on proxy_command should not hit this + subparser by accident.""" + + parser = argparse.ArgumentParser(prog="hermes egress") + proxy_cli.register_cli(parser) + # Parse a no-op invocation and confirm the attribute name. + args = parser.parse_args(["install"]) + assert hasattr(args, "egress_command") + assert not hasattr(args, "proxy_command") + + +def test_egress_subcommands_registered(): + """Smoke test: every documented subcommand parses without error.""" + + parser = argparse.ArgumentParser(prog="hermes egress") + proxy_cli.register_cli(parser) + for sub in ("install", "setup", "start", "stop", "status", "disable", "config"): + args = parser.parse_args([sub]) + assert args.egress_command == sub + + +def test_setup_has_rotate_tokens_flag(): + """--rotate-tokens is the documented escape hatch for re-rolling + every proxy token (used after a suspected token leak). Default is + preserve-existing.""" + + parser = argparse.ArgumentParser(prog="hermes egress") + proxy_cli.register_cli(parser) + args = parser.parse_args(["setup"]) + assert args.rotate_tokens is False + args = parser.parse_args(["setup", "--rotate-tokens"]) + assert args.rotate_tokens is True + + +# --------------------------------------------------------------------------- +# v4 round: credential_source=bitwarden with secrets.bitwarden disabled +# must NOT silently degrade to host-env secrets +# --------------------------------------------------------------------------- + + +def test_cmd_start_refuses_when_bitwarden_mode_but_disabled(hermes_home, monkeypatch): + """config keeps credential_source: bitwarden but secrets.bitwarden.enabled + later flips to false — cmd_start must refuse, not silently start on + host env (the silent-degrade class strict mode is meant to close).""" + + from hermes_cli.config import load_config, save_config + cfg = load_config() + cfg.setdefault("proxy", {})["enabled"] = True + cfg["proxy"]["credential_source"] = "bitwarden" + cfg["proxy"]["fail_on_uncovered_providers"] = False + cfg.setdefault("secrets", {})["bitwarden"] = {"enabled": False} + save_config(cfg) + + def must_not_call(**kw): + pytest.fail("start_proxy must not run when bitwarden mode is broken") + monkeypatch.setattr(ip, "start_proxy", must_not_call) + monkeypatch.setattr(ip, "discover_uncovered_providers", lambda **kw: []) + monkeypatch.setattr(ip, "discover_blocked_providers", lambda **kw: []) + + rc = proxy_cli.cmd_start(_args()) + assert rc == 1 + + +def test_cmd_start_bitwarden_disabled_proceeds_with_env_fallback( + hermes_home, monkeypatch, +): + """Same scenario but proxy.allow_env_fallback=true is the documented + escape hatch — start proceeds (with a warning).""" + + from hermes_cli.config import load_config, save_config + cfg = load_config() + cfg.setdefault("proxy", {})["enabled"] = True + cfg["proxy"]["credential_source"] = "bitwarden" + cfg["proxy"]["allow_env_fallback"] = True + cfg["proxy"]["fail_on_uncovered_providers"] = False + cfg.setdefault("secrets", {})["bitwarden"] = {"enabled": False} + save_config(cfg) + + captured: dict = {} + + def fake_start_proxy(**kw): + captured.update(kw) + s = ip.ProxyStatus() + s.pid = 4242 + s.listening = True + return s + + monkeypatch.setattr(ip, "start_proxy", fake_start_proxy) + monkeypatch.setattr(ip, "discover_uncovered_providers", lambda **kw: []) + monkeypatch.setattr(ip, "discover_blocked_providers", lambda **kw: []) + + rc = proxy_cli.cmd_start(_args()) + assert rc == 0 + # BW refresh is off (bitwarden disabled), running on env secrets. + assert captured.get("refresh_secrets_from_bitwarden") is False + + +def test_cmd_setup_audit_log_failure_is_warning_not_abort(hermes_home, monkeypatch): + """On the pinned v0.39 the daemon never writes audit.log, so a + pre-create failure must not abort the wizard.""" + + monkeypatch.setattr(ip, "find_iron_proxy", lambda **kw: hermes_home / "iron-proxy") + monkeypatch.setattr(ip, "discover_provider_mappings", lambda **kw: [ + ip.TokenMapping( + proxy_token="hermes-proxy-deadbeef", + real_env_name="OPENROUTER_API_KEY", + upstream_hosts=("openrouter.ai",), + ), + ]) + monkeypatch.setattr(ip, "discover_uncovered_providers", lambda **kw: []) + + def boom(path): + raise RuntimeError("could not pre-create audit log (synthetic)") + monkeypatch.setattr(ip, "ensure_audit_log", boom) + + rc = proxy_cli.cmd_setup(_args()) + assert rc == 0 diff --git a/tests/test_iron_proxy_e2e.py b/tests/test_iron_proxy_e2e.py new file mode 100644 index 00000000000..6be3ac7697f --- /dev/null +++ b/tests/test_iron_proxy_e2e.py @@ -0,0 +1,168 @@ +"""End-to-end smoke test for the iron-proxy egress integration. + +Spins up the REAL iron-proxy binary (auto-installed if not present), routes +a curl request through it against a local fake upstream, and verifies that +the Authorization header was swapped from a proxy token to a real secret. + +Gated on the network. Skipped by default in CI unless the user explicitly +opts in with --run-e2e or HERMES_RUN_E2E=1. This is intentional — the test +downloads ~16MB and requires both `openssl` and `curl` to be present. +""" + +from __future__ import annotations + +import os +import socket +import subprocess +import threading +import time +from http.server import BaseHTTPRequestHandler, HTTPServer +from pathlib import Path +from typing import Optional + +import pytest + +from agent.proxy_sources import iron_proxy as ip + + +pytestmark = pytest.mark.skipif( + os.environ.get("HERMES_RUN_E2E", "0") != "1", + reason="E2E proxy test — set HERMES_RUN_E2E=1 to run (requires network + curl + openssl)", +) + + +@pytest.fixture +def hermes_home(tmp_path, monkeypatch): + home = tmp_path / "hermes" + home.mkdir() + monkeypatch.setenv("HERMES_HOME", str(home)) + return home + + +def _free_port() -> int: + with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s: + s.bind(("127.0.0.1", 0)) + return s.getsockname()[1] + + +class _CaptureHandler(BaseHTTPRequestHandler): + """Records the Authorization header of every incoming request.""" + + captured_auth: Optional[str] = None # class-level so tests can read it + + def do_GET(self): + type(self).captured_auth = self.headers.get("Authorization") + body = b'{"ok": true}' + self.send_response(200) + self.send_header("Content-Type", "application/json") + self.send_header("Content-Length", str(len(body))) + self.end_headers() + self.wfile.write(body) + + def log_message(self, *args, **kwargs): + return # silence access log + + +def test_iron_proxy_swaps_authorization_header_end_to_end(hermes_home, monkeypatch): + """Real binary, real CA, real curl. Verify the proxy swaps a proxy-token + Authorization header for the real bearer value before forwarding.""" + + if not __import__("shutil").which("curl"): + pytest.skip("curl not available") + if not __import__("shutil").which("openssl"): + pytest.skip("openssl not available") + + # ----- fake upstream ---------------------------------------------------- + upstream_port = _free_port() + server = HTTPServer(("127.0.0.1", upstream_port), _CaptureHandler) + server_thread = threading.Thread(target=server.serve_forever, daemon=True) + server_thread.start() + + try: + # ----- iron-proxy install + CA + config --------------------------- + binary = ip.install_iron_proxy() + assert binary.exists() + ca_crt, ca_key = ip.ensure_ca_cert() + assert ca_crt.exists() + + real_secret = "sk-real-upstream-value-deadbeef" + monkeypatch.setenv("TEST_UPSTREAM_KEY", real_secret) + proxy_token = ip.mint_proxy_token("test") + + mapping = ip.TokenMapping( + proxy_token=proxy_token, + real_env_name="TEST_UPSTREAM_KEY", + upstream_hosts=("127.0.0.1",), + ) + + tunnel_port = _free_port() + cfg = ip.build_proxy_config( + mappings=[mapping], + ca_cert=ca_crt, + ca_key=ca_key, + tunnel_port=tunnel_port, + allowed_hosts=["127.0.0.1"], + # Test target is on loopback — clear the default IMDS+loopback + # deny list so iron-proxy will dial 127.0.0.1. + upstream_deny_cidrs=[], + # Hermetic: pin the bind to loopback. Without this, Linux + # hosts with docker0 present would bind the bridge gateway + # (the production default) and the loopback curl below would + # never reach the proxy. + http_listen=[f"127.0.0.1:{tunnel_port}"], + ) + ip.write_proxy_config(cfg) + ip.write_mappings([mapping]) + + # ----- start the proxy -------------------------------------------- + try: + status = ip.start_proxy() + except RuntimeError as exc: + pytest.skip(f"iron-proxy could not start in this environment: {exc}") + assert status.pid is not None + + # Wait up to 10s for the listener to come up. + for _ in range(50): + if ip._port_listening("127.0.0.1", tunnel_port): + break + time.sleep(0.2) + else: + pytest.fail("iron-proxy never started listening on the tunnel port") + + # ----- request through the proxy ---------------------------------- + # The fake upstream listens on plain HTTP (not HTTPS). Plain-HTTP + # absolute-form forwards are served by the http_listen listener on + # tunnel_port + 1 (tunnel_port itself is the CONNECT/MITM listener + # that HTTPS_PROXY traffic hits). The secrets transform fires on + # the plain forward too, swapping the Authorization header. + result = subprocess.run( + [ + "curl", + "--silent", + "--max-time", "10", + "-x", f"http://127.0.0.1:{tunnel_port + 1}", + "-H", f"Authorization: Bearer {proxy_token}", + f"http://127.0.0.1:{upstream_port}/", + ], + capture_output=True, + text=True, + ) + assert result.returncode == 0, f"curl failed: {result.stderr}" + # Some iron-proxy versions return 200 with no body; only the swap matters. + captured = _CaptureHandler.captured_auth + assert captured is not None, "upstream never received the request" + assert real_secret in captured, ( + f"Authorization header was not swapped — upstream saw: {captured!r}" + ) + assert proxy_token not in captured, ( + f"Proxy token leaked through to upstream: {captured!r}" + ) + + finally: + # ----- cleanup ------------------------------------------------------ + try: + ip.stop_proxy() + except Exception: + pass + server.shutdown() + server.server_close() diff --git a/tests/tools/test_docker_environment.py b/tests/tools/test_docker_environment.py index cea9ae4e4ff..6316edbb1b0 100644 --- a/tests/tools/test_docker_environment.py +++ b/tests/tools/test_docker_environment.py @@ -39,11 +39,13 @@ def _make_dummy_env(**kwargs): persistent_filesystem=kwargs.get("persistent_filesystem", False), task_id=kwargs.get("task_id", "test-task"), volumes=kwargs.get("volumes", []), + forward_env=kwargs.get("forward_env"), network=kwargs.get("network", True), host_cwd=kwargs.get("host_cwd"), auto_mount_cwd=kwargs.get("auto_mount_cwd", False), env=kwargs.get("env"), run_as_host_user=kwargs.get("run_as_host_user", False), + extra_args=kwargs.get("extra_args", []), persist_across_processes=kwargs.get("persist_across_processes", True), ) @@ -356,6 +358,52 @@ def test_docker_env_appears_in_run_command(monkeypatch): assert "GNUPGHOME=/root/.gnupg" in run_args_str +def _node_options_from_run(calls): + run_calls = [c for c in calls if isinstance(c[0], list) and len(c[0]) >= 2 and c[0][1] == "run"] + assert run_calls, "docker run should have been called" + args = run_calls[0][0] + for i, a in enumerate(args): + if a == "-e" and i + 1 < len(args) and args[i + 1].startswith("NODE_OPTIONS="): + return args[i + 1].split("=", 1)[1] + return None + + +def test_egress_node_options_overrides_conflicting_ca_flag(monkeypatch): + """maxpetrusenko P1: a conflicting docker_env NODE_OPTIONS CA-mode flag + (--use-bundled-ca) must be replaced by the egress-required --use-openssl-ca, + not left to survive alongside it (final Node trust would depend on order).""" + monkeypatch.setattr(docker_env, "find_docker", lambda: "/usr/bin/docker") + monkeypatch.setattr( + docker_env, "_egress_proxy_args_for_docker", + lambda: ([], {"_HERMES_EGRESS_NODE_OPTIONS_APPEND": "--use-openssl-ca"}, []), + ) + calls = _mock_subprocess_run(monkeypatch) + + _make_dummy_env(env={"NODE_OPTIONS": "--max-old-space-size=8192 --use-bundled-ca"}) + + node_opts = (_node_options_from_run(calls) or "").split() + assert "--use-openssl-ca" in node_opts, "egress CA flag must be present" + assert "--use-bundled-ca" not in node_opts, "conflicting CA flag must be stripped" + # Operator's unrelated tuning must be preserved. + assert "--max-old-space-size=8192" in node_opts + + +def test_egress_node_options_preserves_operator_tuning(monkeypatch): + """Non-conflicting operator NODE_OPTIONS survive the egress append-merge.""" + monkeypatch.setattr(docker_env, "find_docker", lambda: "/usr/bin/docker") + monkeypatch.setattr( + docker_env, "_egress_proxy_args_for_docker", + lambda: ([], {"_HERMES_EGRESS_NODE_OPTIONS_APPEND": "--use-openssl-ca"}, []), + ) + calls = _mock_subprocess_run(monkeypatch) + + _make_dummy_env(env={"NODE_OPTIONS": "--max-old-space-size=4096"}) + + node_opts = (_node_options_from_run(calls) or "").split() + assert "--use-openssl-ca" in node_opts + assert "--max-old-space-size=4096" in node_opts + + def test_docker_env_appears_in_init_env_args(monkeypatch): """Explicit docker_env values should appear in _build_init_env_args.""" env = _make_execute_only_env() @@ -673,6 +721,7 @@ def test_labels_attribute_populated_after_init(monkeypatch): "hermes-agent": "1", "hermes-task-id": "abc", "hermes-profile": "default", + "hermes-egress": "off", } @@ -704,8 +753,13 @@ def _mock_subprocess_run_with_reuse(monkeypatch, ps_state: str | None, if sub == "ps": if ps_state is None: return subprocess.CompletedProcess(cmd, 0, stdout="", stderr="") + # 3-field format: ID, State, EgressLabel. When egress_label + # is "off" the code parses all three fields; means + # the container has no egress label, which is acceptable. return subprocess.CompletedProcess( - cmd, 0, stdout=f"reused-cid\t{ps_state}\n", stderr="", + cmd, 0, + stdout=f"reused-cid\t{ps_state}\t\n", + stderr="", ) if sub == "start": if not start_succeeds: @@ -748,6 +802,95 @@ def test_reuse_attaches_to_running_container_without_docker_run(monkeypatch): ) +def test_egress_enabled_does_not_reuse_pre_egress_container(monkeypatch): + """A container created before egress was enabled lacks the proxy env vars + and CA mount. Reusing it would silently bypass the credential firewall.""" + + monkeypatch.setattr(docker_env, "find_docker", lambda: "/usr/bin/docker") + monkeypatch.setattr(docker_env, "_get_active_profile_name", lambda: "default") + monkeypatch.setattr( + docker_env, + "_egress_proxy_args_for_docker", + lambda: ( + ["-v", "/tmp/ca:/etc/ssl/certs/hermes-egress-ca.crt:ro"], + {"HTTPS_PROXY": "http://host.docker.internal:9090"}, + ["--add-host", "host.docker.internal:host-gateway"], + ), + ) + calls = [] + + def _run(cmd, **kwargs): + calls.append((list(cmd) if isinstance(cmd, list) else cmd, kwargs)) + if isinstance(cmd, list) and len(cmd) >= 2: + sub = cmd[1] + if sub == "version": + return subprocess.CompletedProcess(cmd, 0, stdout="Docker version", stderr="") + if sub == "ps": + # Simulate an old pre-egress container: without the egress label + # filter it would match; with the filter Docker returns no match. + assert any(str(part).startswith("label=hermes-egress=") for part in cmd) + return subprocess.CompletedProcess(cmd, 0, stdout="", stderr="") + if sub == "run": + return subprocess.CompletedProcess(cmd, 0, stdout="fresh-cid\n", stderr="") + return subprocess.CompletedProcess(cmd, 0, stdout="", stderr="") + + monkeypatch.setattr(docker_env.subprocess, "run", _run) + + env = _make_dummy_env(task_id="reuse-egress") + + assert env._container_id == "fresh-cid" + run_invocations = [ + c for c in calls + if isinstance(c[0], list) and len(c[0]) >= 2 and c[0][1] == "run" + ] + assert run_invocations, "egress-enabled containers require a fresh docker run" + + +def test_forward_env_provider_key_collision_refuses_under_egress(monkeypatch): + """docker_forward_env is explicit, but it still must not smuggle real + provider keys into an enforced egress sandbox.""" + + monkeypatch.setattr(docker_env, "find_docker", lambda: "/usr/bin/docker") + monkeypatch.setenv("OPENROUTER_API_KEY", "sk-real") + monkeypatch.setattr( + docker_env, + "_egress_proxy_args_for_docker", + lambda: ( + [], + { + "HTTPS_PROXY": "http://host.docker.internal:9090", + "OPENROUTER_API_KEY": "hermes-proxy-openrouter-token", + "HERMES_PROXY_TOKEN_OPENROUTER_API_KEY": "hermes-proxy-openrouter-token", + }, + [], + ), + ) + _mock_subprocess_run(monkeypatch) + + with pytest.raises(RuntimeError, match="docker_forward_env.*OPENROUTER_API_KEY"): + _make_dummy_env(forward_env=["OPENROUTER_API_KEY"]) + + +def test_extra_args_proxy_override_refuses_under_egress(monkeypatch): + """docker_extra_args are appended after Hermes args, so egress enforcement + must reject critical overrides before Docker sees them.""" + + monkeypatch.setattr(docker_env, "find_docker", lambda: "/usr/bin/docker") + monkeypatch.setattr( + docker_env, + "_egress_proxy_args_for_docker", + lambda: ( + [], + {"HTTPS_PROXY": "http://host.docker.internal:9090"}, + [], + ), + ) + _mock_subprocess_run(monkeypatch) + + with pytest.raises(RuntimeError, match="docker_extra_args.*HTTPS_PROXY"): + _make_dummy_env(extra_args=["-e", "HTTPS_PROXY="]) + + def test_reuse_starts_stopped_container_before_attaching(monkeypatch): """A labeled container in ``exited`` state must be restarted via ``docker start`` before the new Hermes process uses it. Without this @@ -902,10 +1045,11 @@ def test_find_reusable_container_prefers_running_over_stopped(monkeypatch): if cmd[1] == "version": return subprocess.CompletedProcess(cmd, 0, stdout="ok", stderr="") if cmd[1] == "ps": - # Two matches: stopped first, running second. + # Two matches: stopped first, running second. 3-field format + # with absent egress label for the "off" path. return subprocess.CompletedProcess( cmd, 0, - stdout="stopped-cid\texited\nrunning-cid\trunning\n", + stdout="stopped-cid\texited\t\nrunning-cid\trunning\t\n", stderr="", ) return subprocess.CompletedProcess(cmd, 0, stdout="fresh-cid\n", stderr="") @@ -918,6 +1062,71 @@ def test_find_reusable_container_prefers_running_over_stopped(monkeypatch): ) +def test_find_reusable_handles_empty_label_string(monkeypatch): + """Docker CLI v29.5.3 returns an empty string (NOT ````) + for absent labels. The trailing tab produces ``cid\\trunning\\t\\n``; + we must not strip the trailing tab or the three-field parser drops the + container. Regression test for the egilewski review on #48073.""" + monkeypatch.setattr(docker_env, "find_docker", lambda: "/usr/bin/docker") + monkeypatch.setattr(docker_env, "_get_active_profile_name", lambda: "default") + + def _run(cmd, **kwargs): + if isinstance(cmd, list) and len(cmd) >= 2: + if cmd[1] == "version": + return subprocess.CompletedProcess(cmd, 0, stdout="ok", stderr="") + if cmd[1] == "ps": + # Docker v29.5.3: absent label → empty string, trailing tab + return subprocess.CompletedProcess( + cmd, 0, + stdout="safe-cid\trunning\t\n", + stderr="", + ) + return subprocess.CompletedProcess(cmd, 0, stdout="fresh-cid\n", stderr="") + + monkeypatch.setattr(docker_env.subprocess, "run", _run) + + env = _make_dummy_env(task_id="empty-label") + assert env._container_id == "safe-cid", ( + f"container with empty-string label should be reused, got {env._container_id!r}" + ) + + +def test_reuse_off_rejects_non_off_egress_container(monkeypatch): + """When egress is off, a container that still has hermes-egress=on + (e.g. from before ``hermes egress disable``) must be rejected and a + fresh container created. The post-filter protects against silently + reusing a container with baked-in proxy env and CA mounts.""" + + monkeypatch.setattr(docker_env, "find_docker", lambda: "/usr/bin/docker") + monkeypatch.setattr(docker_env, "_get_active_profile_name", lambda: "default") + + def _run(cmd, **kwargs): + if isinstance(cmd, list) and len(cmd) >= 2: + if cmd[1] == "version": + return subprocess.CompletedProcess(cmd, 0, stdout="ok", stderr="") + if cmd[1] == "ps": + # Return a container with hermes-egress=on. With egress=off + # the three-field format includes the label; the post-filter + # must skip this entry. + return subprocess.CompletedProcess( + cmd, 0, + stdout="stale-cid\trunning\ton\n", + stderr="", + ) + if cmd[1] == "run": + return subprocess.CompletedProcess(cmd, 0, stdout="fresh-cid\n", stderr="") + return subprocess.CompletedProcess(cmd, 0, stdout="", stderr="") + + monkeypatch.setattr(docker_env.subprocess, "run", _run) + + env = _make_dummy_env(task_id="egress-off-reject") + # Should fall through to fresh container because the stale one has + # hermes-egress=on. + assert env._container_id == "fresh-cid", ( + f"expected fresh container, got {env._container_id!r}" + ) + + # ── Cleanup correctness (issue #20561) ──────────────────────────── diff --git a/tools/environments/docker.py b/tools/environments/docker.py index 8245d6879bf..59640921e1c 100644 --- a/tools/environments/docker.py +++ b/tools/environments/docker.py @@ -5,6 +5,7 @@ configurable resource limits (CPU, memory, disk), and optional filesystem persistence via bind mounts. """ +import hashlib import json import logging import os @@ -33,6 +34,7 @@ _DOCKER_SEARCH_PATHS = [ _docker_executable: Optional[str] = None # resolved once, cached _ENV_VAR_NAME_RE = re.compile(r"^[A-Za-z_][A-Za-z0-9_]*$") +_EGRESS_LABEL_KEY = "hermes-egress" def _normalize_forward_env_names(forward_env: list[str] | None) -> list[str]: @@ -352,6 +354,242 @@ _PRIVDROP_CAP_ARGS = [ ] +def _egress_proxy_args_for_docker() -> tuple[list[str], dict[str, str], list[str]]: + """Build the docker mount/env/host args needed to route a sandbox through + the iron-proxy egress firewall. + + Returns ``(volume_args, env_overrides, host_args)``: + + * ``volume_args`` — read-only bind mount of the CA cert into the container + (extends docker's ``-v`` argv list) + * ``env_overrides`` — env vars to set on container creation: ``HTTPS_PROXY``, + ``HTTP_PROXY``, ``NO_PROXY`` (loopback only), Python/Node/curl CA-bundle + paths, and one ``HERMES_PROXY_TOKEN_`` per minted mapping + * ``host_args`` — extra ``--add-host`` flags so the container can reach the + host-side proxy (Linux needs ``host.docker.internal:host-gateway``; + Docker Desktop populates this automatically on macOS/Windows) + + Returns three empty containers when the proxy is disabled, not yet set up, + or not currently running. If ``proxy.enforce_on_docker`` is true and the + proxy is enabled-but-not-running, raises ``RuntimeError`` so the docker + backend refuses to start the sandbox. + """ + + # Narrow except: ImportError is the only legitimate failure here. + # Bare ``except Exception`` would hide AttributeError, SyntaxError in + # the config module, etc. and silently start the sandbox without + # proxy enforcement. We let unexpected exceptions propagate so the + # docker backend visibly fails rather than degrading silently. + try: + from hermes_cli.config import load_config + from agent.proxy_sources import iron_proxy as ip + except ImportError as exc: + logger.debug("Egress proxy plumbing unavailable: %s", exc) + return ([], {}, []) + + cfg = load_config() + proxy_cfg = cfg.get("proxy") or {} + if not proxy_cfg.get("enabled"): + return ([], {}, []) + + status = ip.get_status() + enforce = bool(proxy_cfg.get("enforce_on_docker", True)) + + if not status.configured: + msg = ( + "proxy.enabled is true but iron-proxy is not configured. " + "Run `hermes egress setup` to mint tokens and write proxy.yaml." + ) + if enforce: + raise RuntimeError(msg) + logger.warning("%s — continuing without proxy (enforce_on_docker=false).", msg) + return ([], {}, []) + + if not (status.pid and status.listening): + msg = ( + f"iron-proxy is enabled but not running on port {status.tunnel_port}. " + "Start it with `hermes egress start`." + ) + if enforce: + raise RuntimeError(msg) + logger.warning("%s — continuing without proxy (enforce_on_docker=false).", msg) + return ([], {}, []) + + if status.ca_cert_path is None or not status.ca_cert_path.exists(): + # status.configured was True a moment ago but the CA file has + # disappeared. Treat this with the same enforce semantics as the + # other failure branches — silently dropping the CA mount would + # leave the sandbox with proxy env vars pointing at iron-proxy + # but no trust anchor, so every TLS handshake would 5xx; or + # worse, with enforce_on_docker=false we'd drop both the proxy + # vars AND any other isolation, opening the sandbox. + msg = ( + f"iron-proxy CA cert vanished from {status.ca_cert_path}. " + "Re-run `hermes egress setup` to regenerate it." + ) + if enforce: + raise RuntimeError(msg) + logger.warning("%s — continuing without proxy (enforce_on_docker=false).", msg) + return ([], {}, []) + + # Corrupt or empty mappings.json is a silent failure mode that's + # indistinguishable from an upstream outage from inside the sandbox + # (every request returns 403). Refuse to mount with empty mappings + # rather than ship a broken sandbox. + mappings = ip.load_mappings() + if not mappings: + msg = ( + "iron-proxy is configured but mappings.json is empty or " + "corrupt. Re-run `hermes egress setup` to mint provider " + "tokens before starting a sandbox." + ) + if enforce: + raise RuntimeError(msg) + logger.warning("%s — continuing without proxy (enforce_on_docker=false).", msg) + return ([], {}, []) + + container_ca = "/etc/ssl/certs/hermes-egress-ca.crt" + volume_args = ["-v", f"{status.ca_cert_path}:{container_ca}:ro"] + + # tunnel_port serves CONNECT (HTTPS); the plain-HTTP forward listener + # is on tunnel_port + 1 (see build_proxy_config's listener-role notes). + proxy_url = f"http://host.docker.internal:{status.tunnel_port}" + plain_http_url = f"http://host.docker.internal:{status.tunnel_port + 1}" + env_overrides: dict[str, str] = { + # HTTPS_PROXY / HTTP_PROXY are respected by curl, requests, urllib, + # httpx, node fetch, go default transport, etc. Lowercase variants + # are also set because some tools only look at one casing. + "HTTPS_PROXY": proxy_url, + "https_proxy": proxy_url, + "HTTP_PROXY": plain_http_url, + "http_proxy": plain_http_url, + # Loopback-only NO_PROXY so localhost dev servers inside the sandbox + # (test fixtures, local LLMs) don't get sent through the proxy. + "NO_PROXY": "127.0.0.1,localhost,::1", + "no_proxy": "127.0.0.1,localhost,::1", + # CA bundle locations for the major language runtimes. iron-proxy + # presents a leaf cert signed by our CA on every MITM'd connection. + # + # CRITICAL ASYMMETRY: Python (REQUESTS_CA_BUNDLE / SSL_CERT_FILE) + # and curl (CURL_CA_BUNDLE) REPLACE the system CA store. + # NODE_EXTRA_CA_CERTS ADDS to it. A Node.js process that + # bypasses HTTPS_PROXY by using a raw socket would still see the + # system CA store and succeed where Python/curl fail validation. + # We additionally set NODE_OPTIONS=--use-openssl-ca to force Node + # through the OpenSSL store that SSL_CERT_FILE controls, narrowing + # the asymmetry. Not a complete fix — see the docs caveat — but + # closes the easy case. + "REQUESTS_CA_BUNDLE": container_ca, # Python `requests` + "SSL_CERT_FILE": container_ca, # Python ssl module / OpenSSL + "CURL_CA_BUNDLE": container_ca, # curl + "NODE_EXTRA_CA_CERTS": container_ca, # Node.js: adds to system store + # NOTE: NODE_OPTIONS is intentionally NOT placed in env_overrides + # here as a flat assignment. We need to APPEND --use-openssl-ca + # to whatever the user already has in NODE_OPTIONS (e.g. + # --max-old-space-size=4096), not clobber it. The append-merge + # happens in DockerEnvironment._merge_node_options below. + # For the agent inside the sandbox to identify itself as proxy-aware. + "HERMES_EGRESS_PROXY": "1", + # Sentinel that DockerEnvironment uses to do the NODE_OPTIONS + # append-merge. Stripped from the final env before docker run. + "_HERMES_EGRESS_NODE_OPTIONS_APPEND": "--use-openssl-ca", + } + + # Surface the per-provider proxy tokens under the standard provider env + # names so existing SDKs and provider clients work unchanged inside the + # sandbox. Keep the HERMES_PROXY_TOKEN_* aliases for diagnostics. + for m in mappings: + env_overrides[m.real_env_name] = m.proxy_token + env_overrides[f"HERMES_PROXY_TOKEN_{m.real_env_name}"] = m.proxy_token + + # On Linux, host.docker.internal isn't populated by default — Docker Desktop + # adds it on macOS/Windows; on Linux we need an explicit --add-host with + # host-gateway. On Desktop this is a no-op (harmless duplicate). + host_args: list[str] = ["--add-host", "host.docker.internal:host-gateway"] + + return (volume_args, env_overrides, host_args) + + +def _egress_reuse_fingerprint( + volume_args: list[str], + env_overrides: dict[str, str], + host_args: list[str], +) -> str: + """Stable Docker-label value for the egress posture of a container.""" + if not (volume_args or env_overrides or host_args): + return "off" + payload = json.dumps( + { + "volume_args": volume_args, + "env_overrides": env_overrides, + "host_args": host_args, + }, + sort_keys=True, + separators=(",", ":"), + ) + return hashlib.sha256(payload.encode("utf-8")).hexdigest()[:24] + + +def _egress_enforce_on_docker(default: bool = True) -> bool: + """Read proxy.enforce_on_docker with fail-safe defaulting.""" + try: + from hermes_cli.config import load_config as _load_cfg + + return bool((_load_cfg().get("proxy") or {}).get("enforce_on_docker", default)) + except (ImportError, OSError): + return default + except Exception: + return default + + +def _critical_egress_env_names(env_overrides: dict[str, str]) -> set[str]: + """Env names that would weaken or bypass enforced egress if overridden.""" + critical = { + "HTTPS_PROXY", "https_proxy", "HTTP_PROXY", "http_proxy", + "NO_PROXY", "no_proxy", + "REQUESTS_CA_BUNDLE", "SSL_CERT_FILE", "CURL_CA_BUNDLE", + "NODE_EXTRA_CA_CERTS", "NODE_OPTIONS", + } + critical.update( + key for key in env_overrides + if key.endswith("_API_KEY") or key.endswith("_TOKEN") + ) + return critical + + +def _extra_args_egress_collisions( + extra_args: list[str], critical_names: set[str], +) -> list[str]: + """Return docker_extra_args entries that can override egress controls.""" + collisions: list[str] = [] + env_flags = {"-e", "--env", "--env-file"} + network_flags = {"--network", "--net"} + i = 0 + while i < len(extra_args): + arg = extra_args[i] + nxt = extra_args[i + 1] if i + 1 < len(extra_args) else "" + if arg in env_flags: + if arg == "--env-file": + collisions.append(arg) + else: + name = nxt.split("=", 1)[0] + if name in critical_names: + collisions.append(name) + i += 2 + continue + if any(arg.startswith(f"{flag}=") for flag in env_flags): + if arg.startswith("--env-file="): + collisions.append("--env-file") + else: + name = arg.split("=", 1)[1].split("=", 1)[0] + if name in critical_names: + collisions.append(name) + elif arg in network_flags or any(arg.startswith(f"{flag}=") for flag in network_flags): + collisions.append(arg) + i += 1 + return sorted(set(collisions)) + + def _build_security_args(run_as_host_user: bool, run_exec: bool = False) -> list[str]: """Return the security/cap/tmpfs args tailored to the privilege mode. @@ -716,11 +954,195 @@ class DockerEnvironment(BaseEnvironment): except Exception as e: logger.debug("Docker: could not load credential file mounts: %s", e) + # Egress credential-injection proxy (iron-proxy) — when configured, + # mount the CA cert into the sandbox and set HTTPS_PROXY + CA-bundle + # env vars so outbound traffic routes through the host-side proxy. + # The sandbox receives PROXY tokens instead of real API keys. + egress_volume_args, egress_env_overrides, egress_host_args = ( + _egress_proxy_args_for_docker() + ) + egress_label = _egress_reuse_fingerprint( + egress_volume_args, egress_env_overrides, egress_host_args, + ) + _enforce_egress = _egress_enforce_on_docker() + _critical_egress_names = _critical_egress_env_names(egress_env_overrides) + if egress_env_overrides: + _forward_collisions = sorted( + key for key in self._forward_env if key in _critical_egress_names + ) + if _forward_collisions: + _msg = ( + f"docker_forward_env would inject real egress-protected " + f"variables {_forward_collisions}; enforce_on_docker is " + f"{'enabled' if _enforce_egress else 'disabled'}." + ) + if _enforce_egress: + raise RuntimeError( + f"{_msg} Remove these names from docker_forward_env " + "or disable enforce_on_docker to opt out of egress isolation." + ) + logger.warning( + "%s Explicit docker_forward_env values will override egress tokens.", + _msg, + ) + volume_args.extend(egress_volume_args) + # egress env overrides are merged in further below alongside the + # other env_args computation. + # Explicit environment variables (docker_env config) — set at container # creation so they're available to all processes (including entrypoint). + # Egress proxy env vars (HTTPS_PROXY, CA-bundle paths, proxy tokens) + # are merged below. Precedence policy: + # + # - When egress enforcement is on AND the user's docker_env tries + # to override one of the proxy-control vars (HTTPS_PROXY, + # SSL_CERT_FILE, etc.), fail-loud rather than silently inverting + # the isolation. The CA mount + tokens would still ship while + # traffic leaves the sandbox direct with real credentials — + # exactly what enforce_on_docker is meant to prevent. + # - When enforcement is off, the user's docker_env wins (current + # behavior) but we log a warning naming both config sources. + # - When the user override is identical to the egress value, no-op. + if egress_env_overrides: + try: + from hermes_cli.config import load_config as _load_cfg_for_collision + _proxy_cfg = (_load_cfg_for_collision().get("proxy") or {}) + except (ImportError, OSError): + _proxy_cfg = {} + except Exception as _e: # noqa: BLE001 — narrowed below via yaml import + # yaml.YAMLError from a malformed config.yaml. We import + # lazily because PyYAML is a soft dep in some test envs. + try: + import yaml # noqa: F401 + except ImportError: + raise + logger.warning( + "Could not read proxy config for egress collision check: %s", + _e, + ) + _proxy_cfg = {} + _enforce_egress = bool(_proxy_cfg.get("enforce_on_docker", True)) + # Egress-controlling env vars that affect the proxy posture. + _critical_proxy_control = { + "HTTPS_PROXY", "https_proxy", "HTTP_PROXY", "http_proxy", + "NO_PROXY", "no_proxy", + "REQUESTS_CA_BUNDLE", "SSL_CERT_FILE", "CURL_CA_BUNDLE", + "NODE_EXTRA_CA_CERTS", + } + # stephenschoettler #2: also block docker_env from injecting + # real provider keys. `docker_env: {OPENROUTER_API_KEY: sk-real}` + # in config.yaml puts the live secret into the sandbox while + # egress is nominally enforced — defeats the entire feature. + # Pull the mapped real_env_name from each token mapping at + # call time so this stays in sync with whatever the operator + # has configured. + _critical_provider_keys: set[str] = set() + try: + from agent.proxy_sources import iron_proxy as _ip_for_mappings + _critical_provider_keys = { + m.real_env_name for m in _ip_for_mappings.load_mappings() + } + except Exception: # noqa: BLE001 — best-effort collision check + pass + _critical = _critical_proxy_control | _critical_provider_keys + _collisions = sorted( + k for k in _critical + if k in self._env + and ( + k not in egress_env_overrides + or self._env[k] != egress_env_overrides[k] + ) + # For provider keys, ANY override is a collision (the egress + # path mints proxy tokens; a real key in docker_env bypasses + # the swap regardless of whether the egress dict happens to + # carry it). + and ( + k in _critical_provider_keys + or (k in egress_env_overrides + and self._env[k] != egress_env_overrides[k]) + ) + ) + if _collisions: + _msg = ( + f"docker_env in config.yaml overrides egress-proxy " + f"variables {_collisions}; enforce_on_docker is " + f"{'enabled' if _enforce_egress else 'disabled'}." + ) + if _enforce_egress: + raise RuntimeError( + f"{_msg} Remove these keys from docker_env or " + "disable enforce_on_docker to opt out of egress " + "isolation." + ) + logger.warning( + "%s Falling back to docker_env values; sandbox traffic " + "will NOT route through the proxy.", _msg, + ) + + # When enforce_on_docker is true, egress overrides win. When + # false, docker_env wins (back-compat for users who deliberately + # opt out). In both cases the collision check above has already + # surfaced any disagreement. + try: + from hermes_cli.config import load_config as _load_cfg_for_precedence + _enforce_egress_merge = bool( + (_load_cfg_for_precedence().get("proxy") or {}) + .get("enforce_on_docker", True) + ) + except (ImportError, OSError): + _enforce_egress_merge = True + except Exception: # noqa: BLE001 — yaml.YAMLError or similar + # Malformed config.yaml; fail-safe to enforced. + _enforce_egress_merge = True + + if _enforce_egress_merge and egress_env_overrides: + merged_env = dict(self._env) + merged_env.update(egress_env_overrides) + else: + merged_env = dict(egress_env_overrides) + merged_env.update(self._env) + + # arshkumarsingh #1: NODE_OPTIONS append-merge. The egress path + # wants ``--use-openssl-ca`` so Node routes through the OpenSSL + # CA store ``SSL_CERT_FILE`` controls. But the operator's + # ``docker_env: {NODE_OPTIONS: "--max-old-space-size=8192"}`` + # MUST be preserved — replacing it would silently drop their + # tuning. We carry the egress flag in a sentinel key + # ``_HERMES_EGRESS_NODE_OPTIONS_APPEND`` and merge here. + _egress_node_append = merged_env.pop( + "_HERMES_EGRESS_NODE_OPTIONS_APPEND", None, + ) + if _egress_node_append: + existing_node = merged_env.get("NODE_OPTIONS", "") + existing_tokens = existing_node.split() + # maxpetrusenko P1: dedupe is not enough — the operator may have set + # a CONFLICTING CA-mode flag (e.g. --use-bundled-ca) that would + # otherwise survive alongside our --use-openssl-ca, leaving Node's + # final trust behavior dependent on option order / Node parsing. + # Egress isolation requires our flag to win deterministically, so + # strip any known-conflicting CA-mode flags before appending. + _CA_MODE_FLAGS = {"--use-openssl-ca", "--use-bundled-ca"} + append_token = _egress_node_append.strip() + if append_token in _CA_MODE_FLAGS: + dropped = [t for t in existing_tokens if t in _CA_MODE_FLAGS and t != append_token] + if dropped: + logger.warning( + "Overriding conflicting NODE_OPTIONS CA-mode flag(s) %s " + "with egress-required %s to keep Node routed through the " + "egress CA store.", dropped, append_token, + ) + existing_tokens = [t for t in existing_tokens if t not in _CA_MODE_FLAGS or t == append_token] + # De-dup: only add if not already present (the operator may + # have set the same flag themselves). + if append_token not in existing_tokens: + existing_tokens.append(append_token) + merged_env["NODE_OPTIONS"] = " ".join(existing_tokens).strip() + if not merged_env["NODE_OPTIONS"]: + merged_env.pop("NODE_OPTIONS", None) + env_args = [] - for key in sorted(self._env): - env_args.extend(["-e", f"{key}={self._env[key]}"]) + for key in sorted(merged_env): + env_args.extend(["-e", f"{key}={merged_env[key]}"]) # Optional: run the container as the host user so files written into # bind-mounted dirs (/workspace, /root, docker_volumes entries) are @@ -772,12 +1194,31 @@ class DockerEnvironment(BaseEnvironment): logger.warning("Ignoring non-string docker_extra_args entry: %r", arg) continue validated_extra.append(arg) + if egress_env_overrides: + _extra_collisions = _extra_args_egress_collisions( + validated_extra, _critical_egress_names, + ) + if _extra_collisions: + _msg = ( + f"docker_extra_args would override egress-proxy controls " + f"{_extra_collisions}; enforce_on_docker is " + f"{'enabled' if _enforce_egress else 'disabled'}." + ) + if _enforce_egress: + raise RuntimeError( + f"{_msg} Remove these args or disable enforce_on_docker " + "to opt out of egress isolation." + ) + logger.warning( + "%s Extra Docker args may bypass egress isolation.", _msg, + ) all_run_args = ( security_args + user_args + writable_args + resource_args + + egress_host_args + volume_args + env_args + validated_extra @@ -799,6 +1240,7 @@ class DockerEnvironment(BaseEnvironment): "--label", "hermes-agent=1", "--label", f"hermes-task-id={task_label}", "--label", f"hermes-profile={profile_name}", + "--label", f"{_EGRESS_LABEL_KEY}={egress_label}", ] # Save args for container recreation on "No such container" recovery. self._image = image @@ -810,6 +1252,7 @@ class DockerEnvironment(BaseEnvironment): "hermes-agent": "1", "hermes-task-id": task_label, "hermes-profile": profile_name, + _EGRESS_LABEL_KEY: egress_label, } # Cross-process container reuse (issue #20561 — docs claim "ONE long-lived @@ -819,14 +1262,15 @@ class DockerEnvironment(BaseEnvironment): # restores the documented contract; opt out via # ``terminal.docker_persist_across_processes: false``. # - # Reuse matches on labels only — we deliberately do NOT compare image - # / mounts / resources. Operators who need a fresh container after - # changing those settings should set ``docker_persist_across_processes: - # false`` (or run ``docker rm -f`` against the labeled container) to - # force a clean start. + # Reuse matches on labels only. The egress posture gets its own label + # because env vars, CA mounts, and host mappings are immutable after + # container creation — reusing a pre-egress or pre-rotation container + # would silently bypass the credential firewall. reused = False if persist_across_processes: - existing = self._find_reusable_container(task_label, profile_name) + existing = self._find_reusable_container( + task_label, profile_name, egress_label, + ) if existing is not None: container_id, state = existing self._container_id = container_id @@ -994,7 +1438,9 @@ class DockerEnvironment(BaseEnvironment): # 1. Try label-based reuse (another process may have recreated it). task_label = self._labels.get("hermes-task-id", "") profile_label = self._labels.get("hermes-profile", "") - existing = self._find_reusable_container(task_label, profile_label) + existing = self._find_reusable_container( + task_label, profile_label, self._labels.get(_EGRESS_LABEL_KEY, "off"), + ) if existing is not None: cid, state = existing if state == "running": @@ -1119,7 +1565,12 @@ class DockerEnvironment(BaseEnvironment): logger.debug("Docker --storage-opt support: %s", _storage_opt_ok) return _storage_opt_ok - def _find_reusable_container(self, task_label: str, profile_label: str) -> Optional[tuple[str, str]]: + def _find_reusable_container( + self, + task_label: str, + profile_label: str, + egress_label: str, + ) -> Optional[tuple[str, str]]: """Look for an existing container labeled for this (task, profile). Returns ``(container_id, state)`` on hit, ``None`` on miss / on any @@ -1133,13 +1584,28 @@ class DockerEnvironment(BaseEnvironment): started by some other tool. """ try: + filters = [ + "--filter", "label=hermes-agent=1", + "--filter", f"label=hermes-task-id={task_label}", + "--filter", f"label=hermes-profile={profile_label}", + ] + if egress_label != "off": + filters.extend(["--filter", f"label={_EGRESS_LABEL_KEY}={egress_label}"]) + fmt = "{{.ID}}\t{{.State}}" + else: + # When egress is off, we widen the probe to find any + # task+profile container (regardless of egress label), then + # post-filter in Python: reject containers whose + # hermes-egress label is present and not "off". Without + # this, a container created with egress=on can be silently + # reused after the operator runs "hermes egress disable", + # preserving baked-in proxy env and CA mounts. + fmt = '{{.ID}}\t{{.State}}\t{{.Label "' + _EGRESS_LABEL_KEY + '"}}' result = subprocess.run( [ self._docker_exe, "ps", "-a", - "--filter", "label=hermes-agent=1", - "--filter", f"label=hermes-task-id={task_label}", - "--filter", f"label=hermes-profile={profile_label}", - "--format", "{{.ID}}\t{{.State}}", + *filters, + "--format", fmt, ], capture_output=True, text=True, @@ -1156,7 +1622,7 @@ class DockerEnvironment(BaseEnvironment): result.returncode, result.stderr.strip(), ) return None - lines = [ln.strip() for ln in result.stdout.splitlines() if ln.strip()] + lines = [ln for ln in result.stdout.splitlines() if ln.strip()] if not lines: return None # Multiple matches are unusual (one (task, profile) should produce one @@ -1167,10 +1633,24 @@ class DockerEnvironment(BaseEnvironment): running = None first = None for ln in lines: - parts = ln.split("\t", 1) - if len(parts) != 2: - continue - cid, state = parts[0], parts[1].lower() + if egress_label == "off": + # Format: ID\tState\tEgressLabel — parse all three fields + # and reject containers with a non-off egress label. + parts = ln.split("\t", 2) + if len(parts) < 3: + continue + cid, state, egress_val = parts[0], parts[1].lower(), parts[2] + if egress_val not in ("", "", "off"): + logger.debug( + "skipping container %s for egress=off reuse: " + "label %s=%r", cid, _EGRESS_LABEL_KEY, egress_val, + ) + continue + else: + parts = ln.split("\t", 1) + if len(parts) != 2: + continue + cid, state = parts[0], parts[1].lower() if first is None: first = (cid, state) if state == "running" and running is None: diff --git a/website/docs/developer-guide/egress-internals.md b/website/docs/developer-guide/egress-internals.md new file mode 100644 index 00000000000..e78b3da5b11 --- /dev/null +++ b/website/docs/developer-guide/egress-internals.md @@ -0,0 +1,307 @@ +--- +sidebar_position: 14 +title: "Egress proxy internals" +description: "How the iron-proxy egress firewall integrates with Hermes — module layout, lifecycle, security invariants, and extension points" +--- + +# Egress proxy internals + +This page covers the architecture of the egress credential-injection firewall (`hermes egress` / iron-proxy) from a contributor / plugin author's perspective. End-user setup + usage docs live at [Egress proxy](../user-guide/egress/iron-proxy.md). + +The threat model and high-level design are summarised on the user page; this page is about *how* it's wired, where the security-relevant code lives, and what invariants you have to preserve if you touch it. + +## Module layout + +```text +agent/proxy_sources/iron_proxy.py Core: binary install, CA gen, config build, + subprocess lifecycle, mappings I/O, PID/nonce + defense. Pure-function surface where possible. + +hermes_cli/proxy_cli.py Wizard + slash command handlers. + `hermes egress {install,setup,start,stop, + status,disable,config}`. Wires the + core module into argparse. + +hermes_cli/main.py:_dispatch_egress Top-level subparser dispatcher. + dest='egress_command' (intentionally + disjoint from the inbound OAuth + `hermes proxy` subparser, which uses + dest='proxy_command'). + +hermes_cli/config.py: proxy schema The `proxy:` block in DEFAULT_CONFIG. + Adding a knob means: add it here, add a + wizard prompt or `setdefault` in + proxy_cli.cmd_setup, and document it + in the user-guide page. + +tools/environments/docker.py + _egress_proxy_args_for_docker() Builds the volume_args / env_overrides / + host_args triple that the Docker backend + injects when `proxy.enabled: true`. + + DockerEnvironment.__init__ Docker-side merge logic: collision + detection against critical egress vars, + NODE_OPTIONS append-merge via the + _HERMES_EGRESS_NODE_OPTIONS_APPEND + sentinel, enforce_on_docker precedence. + +tests/test_iron_proxy.py Hermetic tests (~70). Binary install + path, config build, mappings I/O, + subprocess lifecycle, docker arg builder, + deny CIDR defaults, bind policy, CA + TOCTOU, ensure_audit_log behaviour, etc. + +tests/test_iron_proxy_cli.py CLI handler unit tests (~20). Argparse + wiring, fail-loud paths, BWS refresh + wire-up, dest='egress_command' + regression guard. + +tests/test_iron_proxy_e2e.py Live E2E (gated on HERMES_RUN_E2E=1). + Real iron-proxy binary, real curl, + end-to-end token swap verified. +``` + +## Lifecycle + +```text +hermes egress install + -> agent.proxy_sources.iron_proxy.install_iron_proxy(force=...) + Downloads pinned tarball + checksums.txt from GitHub Releases. + SHA-256 verification before extraction. + tarfile.extract(..., filter="data") on Python 3.12+ (PEP 706); + falls back to plain extract on older Python with member-name + sanitisation via _pick_tar_member. + Stage into ~/.hermes/bin/.iron-proxy_XXXX, chmod 755, os.replace + to ~/.hermes/bin/iron-proxy (atomic). + _VERSION_CACHE.pop(target) so a forced reinstall re-probes + --version on next call. + +hermes egress setup [--from-bitwarden | --no-bitwarden] [--rotate-tokens] + -> proxy_cli.cmd_setup + Step 1. find_iron_proxy(install_if_missing=False) -> install if absent. + Step 2. ensure_ca_cert() + Run openssl genrsa + req via subprocess. + Write CA key via os.open(O_WRONLY|O_CREAT|O_TRUNC|O_NOFOLLOW, 0o600) + + os.replace. Never exists on disk under default umask. + Write CA cert with 0o644 (public). + Step 3. discover_provider_mappings() or pull names from BWS via + fetch_bitwarden_secrets() when --from-bitwarden. + merge_mappings(existing=load_mappings(), discovered, + rotate=args.rotate_tokens) preserves prior + tokens unless --rotate-tokens is passed. + discover_uncovered_providers() and surface warnings. + Step 4. ensure_audit_log(audit_log_path) # raises on OSError + build_proxy_config(...) with defaults applied at the call site + (deny CIDRs default, bind policy from _default_http_listen). + write_proxy_config(cfg) # atomic via .tmp + os.replace, 0o600 + write_mappings(mappings) # atomic, 0o600 + Step 5. proxy_cfg["enabled"] = True; credential_source preservation logic + (do NOT silently downgrade bitwarden -> env on re-run); + save_config(cfg). + +hermes egress start + -> proxy_cli.cmd_start + Pre-checks (refuse-start path): + - proxy.fail_on_uncovered_providers? -> discover_blocked_providers() + - credential_source=bitwarden? -> pre-validate access_token_env + project_id + -> iron_proxy.start_proxy( + refresh_secrets_from_bitwarden=..., + bitwarden_config=..., + ) + existing=_read_pid(); if alive, idempotent return. + _build_proxy_subprocess_env(...): ALLOWLIST + mapped real_env_names, + strip HTTPS_PROXY/etc. to avoid recursion, optional BWS refresh + (raises on missing values unless allow_env_fallback=true). + Plant nonce: _proxy_nonce = sha256(urandom(16)); env[NONCE_ENV] = ... + Open log_path via O_NOFOLLOW + 0o600 + st_uid check. + Popen with stdin=DEVNULL, stdout=log_fd, stderr=STDOUT, + start_new_session=True (POSIX). + Close parent's log_fd in finally. + _write_pidfile_safely(pidfile, proc.pid) + O_EXCL + O_NOFOLLOW + uid check + persisted nonce sidecar. + FileExistsError -> discriminate live vs stale, retry once if stale. + Install SIGINT/SIGTERM handlers (main-thread only). + Poll loop (do-while shape): + while True: + if proc.poll() is not None: tail log + unlink pidfile + raise + if _port_listening(probe_host, tunnel_port): break # probe_host = configured bind host + if time.time() >= deadline: break (do-while: checked AFTER first probe) + time.sleep(0.1) + If not listening at exit: _kill_and_wait(proc) + unlink pidfile + raise. + +hermes egress stop + -> iron_proxy.stop_proxy + _read_pid + _pid_alive guard. + starttime_before = _pid_proc_starttime(pid) # Linux only; None elsewhere + os.kill(pid, SIGTERM) + Wait up to 5s for graceful exit. + After grace: re-check starttime + _pid_alive. + If recycled (starttime drift OR _pid_alive False), DO NOT SIGKILL. + Otherwise os.kill(pid, _KILL_SIGNAL). + _cleanup_state_files: unlink pidfile + nonce sibling. +``` + +## Security invariants + +These are the load-bearing properties. If you touch the module, you must preserve them. Where there's a regression test, it's named. + +### Filesystem perms + +| Path | Mode | Test | +|---|---|---| +| `~/.hermes/proxy/` (dir) | `0o700` | `test_proxy_state_dir_is_0o700` | +| `ca.key` | `0o600` | `test_ca_key_created_with_0o600` | +| `ca.crt` | `0o644` | (implicit; chmod call in `ensure_ca_cert`) | +| `proxy.yaml` | `0o600` | (chmod after atomic rename in `write_proxy_config`) | +| `mappings.json` | `0o600` | (chmod after atomic rename in `write_mappings`) | +| `iron-proxy.pid` | `0o600` | (`os.open(..., 0o600)` mode in `_write_pidfile_safely`) | +| `iron-proxy.nonce` | `0o600` | (`os.open(..., 0o600)` mode in `_write_pidfile_safely`) | +| `audit.log` | `0o600` | `test_ensure_audit_log_creates_with_0o600` | +| `iron-proxy.log` | `0o600` | (`os.open(..., 0o600)` + `fchmod`) | + +All write paths use `os.open(O_WRONLY | O_CREAT | O_NOFOLLOW, 0o600)` + `os.fstat().st_uid` check. `shutil.copy2` + `os.chmod` is forbidden because it leaks a default-umask window. + +### Subprocess env minimisation + +`_build_proxy_subprocess_env` MUST NOT use `os.environ.copy()`. The allowlist is `_PROXY_SUBPROCESS_ENV_ALLOWLIST` (PATH, HOME, locale, etc.) plus the env names referenced by `load_mappings()`. Everything else stays on the host. + +Regression: `test_subprocess_env_strips_unrelated_secrets`, `test_subprocess_env_strips_proxy_recursion_vars`, `test_subprocess_env_keeps_infrastructure_vars`. + +### Bind policy + +`_default_http_listen` returns a single-element list: on Linux the docker bridge gateway IP (containers reach the proxy via `host.docker.internal:host-gateway`, which resolves to the bridge gateway — a loopback bind is unreachable from inside containers there); on macOS/Windows Docker Desktop, loopback (VPNkit routes `host.docker.internal` to the host). Linux without a detectable docker0 bridge falls back to loopback with a warning. Never `0.0.0.0`, never `:PORT` (INADDR_ANY). + +`_detect_docker_bridge_ip` validates via `ipaddress.IPv4Address` and rejects `is_unspecified` / `is_loopback` / `is_multicast` / `is_reserved` / `is_link_local` / `is_global`. A hostile `ip` shim on PATH cannot inject `0.0.0.0`. + +**v0.39 schema constraint and listener roles (verified live against the binary):** the binary's `config.Proxy` struct has only singular listener fields — there is no `http_listens` (plural) list. `tunnel_listen` is the CONNECT + MITM listener (what `HTTPS_PROXY` traffic hits); `http_listen` only handles absolute-form plain-HTTP forwards (a CONNECT sent to it is relayed upstream as a regular request and 400s). `build_proxy_config` therefore binds `tunnel_listen` on `tunnel_port` and `http_listen` on `tunnel_port + 1`, both on the platform bind host. The Docker backend sets `HTTPS_PROXY` to `tunnel_port` and `HTTP_PROXY` to `tunnel_port + 1`. + +The liveness probes (`start_proxy` poll loop, `get_status`) read the configured bind host via `_read_http_listen_from_config()` and probe THAT host — a hardcoded loopback probe would report a healthy bridge-bound daemon as dead. + +Regression: `test_default_bind_is_loopback_not_zero_zero` (asserts no INADDR_ANY AND that `http_listens` is NOT in the rendered yaml), `test_default_bind_uses_docker_bridge_on_linux`, `test_default_bind_falls_back_to_loopback_without_bridge`, `test_default_bind_is_loopback_on_macos`, `test_detect_docker_bridge_ip_rejects_dangerous` (parametrized over 8 attack inputs). + +### Metrics port collision + +`metrics.listen` defaults to `:9090` in iron-proxy v0.39 — the SAME port as Hermes's default `tunnel_port: 9090`. `build_proxy_config` MUST explicitly pin `metrics.listen: 127.0.0.1:0` so the metrics binding gets an ephemeral loopback port that can never collide with the proxy listener regardless of operator-chosen `tunnel_port`. + +Regression: `test_metrics_listener_pinned_to_loopback_ephemeral`. + +### Default deny CIDRs + +`_DEFAULT_UPSTREAM_DENY_CIDRS` covers loopback (v4 + v6), link-local (incl. IMDS at 169.254.169.254 and the IPv4-mapped-v6 form), RFC1918, IPv6 ULA, CGNAT, and the RFC2544 benchmark range. `build_proxy_config(..., upstream_deny_cidrs=None)` MUST emit the default; only an explicit empty list opts out. + +Regression: `test_default_deny_cidrs_present_when_unspecified`, `test_default_deny_includes_ipv4_mapped_v6`. + +### Audit log fail-loud + +`ensure_audit_log` raises `RuntimeError` on any `OSError`. On the pinned v0.39 the daemon never writes this file (no `log.audit_path` field), so `cmd_setup` treats the failure as a WARNING (the file is non-load-bearing until the version bump) and qualifies the success line as "reserved". When the pin moves to a version with `log.audit_path`, revisit: the pre-create becomes load-bearing for the 0o600-from-first-byte guarantee and the wizard should fail loud again. + +**v0.39 schema constraint:** `log.audit_path` is NOT a field in iron-proxy v0.39's `config.Log` struct, so `build_proxy_config` accepts the `audit_log` kwarg but does NOT emit it into the rendered yaml. Per-request records on v0.39 land in `iron-proxy.log` alongside daemon-level events. The `audit.log` file is still pre-created at `0o600` with `O_NOFOLLOW` so the privacy contract holds when the pinned version is bumped to one that supports the separate stream. + +Regression: `test_ensure_audit_log_raises_on_immutable_parent`, `test_audit_log_kwarg_does_not_inject_audit_path_v039`. + +### Bitwarden mode fail-loud + +When `credential_source: bitwarden` AND `proxy.allow_env_fallback: false` (default): +- Missing access token env var -> `cmd_start` refuses. +- Missing `project_id` -> `cmd_start` refuses. +- `bws secret list` returns no values for one or more mapped providers -> `_build_proxy_subprocess_env` raises. + +Falling back to host env in BW mode reintroduces exactly the staleness bug the BW path is meant to defeat. + +Regression: `test_cmd_start_refuses_when_bitwarden_token_missing` (CLI layer); strict-mode assertions in `_build_proxy_subprocess_env` (daemon layer). + +### docker_env collision detection + +When `enforce_on_docker: true`, `docker_env` overrides on any of the egress-controlling vars (HTTPS_PROXY, SSL_CERT_FILE, NODE_EXTRA_CA_CERTS, etc.) OR any mapped `real_env_name` (OPENROUTER_API_KEY, etc.) raises `RuntimeError` BEFORE the container starts. + +Regression: `test_docker_env_collision_with_proxy_raises_when_enforce`. + +### PID recycling defense + +`_pid_alive` MUST consult either the in-process `_proxy_nonce` (same-process case) OR the on-disk `iron-proxy.nonce` (cross-CLI case) before trusting an `argv[0]` basename match. `stop_proxy` MUST re-check `/proc//stat` starttime before SIGKILL and suppress the signal on starttime drift. + +Regression: `test_stop_proxy_suppresses_sigkill_on_pid_recycle`, `test_pid_proc_starttime_parses_comm_with_parens`, `test_persisted_nonce_roundtrip`. + +### Token preservation on re-setup + +`merge_mappings(existing, discovered, rotate=False)` MUST return prior tokens for providers that overlap. Re-running `hermes egress setup` cannot silently 401 running sandboxes. `--rotate-tokens` is the explicit opt-in. + +Regression: `test_merge_mappings_preserves_existing_tokens`, `test_merge_mappings_rotate_mints_fresh_tokens`. + +### `credential_source` preservation + +`cmd_setup` MUST NOT downgrade `credential_source: bitwarden` to `env` on re-run without an explicit `--no-bitwarden` flag. Running `hermes egress setup` (no flag) preserves whatever was previously configured. + +Tested via the `cmd_setup` flow in CLI tests (the bitwarden-preservation path is exercised when `--from-bitwarden` is followed by a plain `setup` re-run). + +## Extension points + +### Adding a new bearer-token provider + +`_BEARER_PROVIDERS` in `iron_proxy.py` maps env var name -> tuple of upstream hosts. Adding an entry makes it discoverable by `discover_provider_mappings()`; the wizard mints a token for it automatically when the env var is present. + +```python +_BEARER_PROVIDERS: Dict[str, Tuple[str, ...]] = { + ..., + "MY_PROVIDER_API_KEY": ("api.myprovider.com",), +} +``` + +Also update `_DEFAULT_ALLOWED_HOSTS` so the proxy allows the upstream by default. Run `test_discover_provider_mappings_*` to confirm. + +### Adding a new non-bearer provider + +If the provider uses `x-api-key` / SigV4 / OAuth-from-SDK / etc., iron-proxy's `secrets` transform cannot swap it. Add the env var to `_NON_BEARER_PROVIDERS` so the wizard warns about it. If the provider is LLM-specific enough that you want `fail_on_uncovered_providers: true` to actually block it, also add to `_LLM_SPECIFIC_NON_BEARER_PROVIDERS`. + +```python +_NON_BEARER_PROVIDERS: Tuple[str, ...] = ( + ..., + "MY_X_API_KEY_PROVIDER", +) + +_LLM_SPECIFIC_NON_BEARER_PROVIDERS: Tuple[str, ...] = ( + ..., + "MY_X_API_KEY_PROVIDER", +) +``` + +### Wiring iron-proxy into a non-Docker backend + +`_egress_proxy_args_for_docker` is Docker-specific. Backends that want similar wiring need their own analogue that: + +1. Reads `load_config().get("proxy", {})`; returns empty args if `enabled` is false. +2. Calls `iron_proxy.get_status()`; surfaces `enforce` semantics on `configured` / `pid` / `listening` / `ca_cert_path` failure paths. +3. Calls `iron_proxy.load_mappings()`; refuses to mount if empty AND `enforce_on_docker: true`. +4. Sets the seven env vars (HTTPS_PROXY, NO_PROXY, REQUESTS_CA_BUNDLE, SSL_CERT_FILE, CURL_CA_BUNDLE, NODE_EXTRA_CA_CERTS, HERMES_EGRESS_PROXY) and the per-mapping `HERMES_PROXY_TOKEN_` vars. +5. Distributes the CA cert into the sandbox at a path the runtime will trust (typically `/etc/ssl/certs/hermes-egress-ca.crt`). +6. Implements collision detection against the user's backend-specific env config. + +The Docker implementation is ~150 lines; expect similar volume for Modal / Daytona / SSH. + +### Subscribing to per-request audit events + +iron-proxy writes line-delimited JSON to `~/.hermes/proxy/iron-proxy.log` on the currently pinned v0.39 (daemon + per-request records combined; see "Logging on iron-proxy v0.39" in the user guide). A plugin / external watcher can tail that file and react to allowlist denials, secret swaps, or upstream errors. When the pinned version is bumped to one that supports `log.audit_path`, the per-request stream moves to `audit.log` and watchers wired to that path go live without operator action. The schema is documented at [docs.iron.sh/audit](https://docs.iron.sh/audit) (link). + +## Testing + +```bash +# Hermetic suite (no network, no real binary) +scripts/run_tests.sh tests/test_iron_proxy.py tests/test_iron_proxy_cli.py + +# Live E2E (real binary, real curl, real CONNECT tunnel) +HERMES_RUN_E2E=1 scripts/run_tests.sh tests/test_iron_proxy_e2e.py + +# Live PTY smoke against `hermes egress` +HERMES_HOME=/tmp/hermes-egress-test python3 -m hermes_cli.main egress --help +HERMES_HOME=/tmp/hermes-egress-test python3 -m hermes_cli.main egress setup --help +``` + +The CLI uses argparse, so `--help` is a good first probe for "did my new flag register correctly". + +## See also + +- User-facing setup + troubleshooting: [Egress proxy](https://hermes-agent.nousresearch.com/docs/user-guide/egress/iron-proxy) +- Docker backend internals: [Docker](https://hermes-agent.nousresearch.com/docs/user-guide/docker) +- Bitwarden Secrets Manager integration: [`hermes secrets bitwarden`](https://hermes-agent.nousresearch.com/docs/user-guide/secrets/bitwarden) +- CLI command reference: [`hermes egress`](https://hermes-agent.nousresearch.com/docs/reference/cli-commands#hermes-egress) +- Sandbox-injected environment variables: [Egress proxy (sandbox-injected)](https://hermes-agent.nousresearch.com/docs/reference/environment-variables#egress-proxy-sandbox-injected) diff --git a/website/docs/getting-started/quickstart.md b/website/docs/getting-started/quickstart.md index 907af9c2402..5e2999f7242 100644 --- a/website/docs/getting-started/quickstart.md +++ b/website/docs/getting-started/quickstart.md @@ -273,6 +273,8 @@ hermes config set terminal.backend docker # Docker isolation hermes config set terminal.backend ssh # Remote server ``` +For Docker sandboxes, you can also enable the **egress credential-injection proxy** so the sandbox never sees your real API keys — only opaque proxy tokens that work exclusively from behind a local TLS-intercepting daemon. See [Egress proxy](../user-guide/egress/iron-proxy.md). Setup is `hermes egress setup && hermes egress start`; `hermes setup terminal` also points Docker users at it. Modal, SSH, Daytona, and Singularity are not wired yet. + ### Voice mode ```bash diff --git a/website/docs/reference/cli-commands.md b/website/docs/reference/cli-commands.md index 908f077e20c..55335e103cb 100644 --- a/website/docs/reference/cli-commands.md +++ b/website/docs/reference/cli-commands.md @@ -43,6 +43,7 @@ hermes [global-options] [subcommand/options] | `hermes fallback` | Manage fallback providers tried when the primary model errors. | | `hermes gateway` | Run or manage the messaging gateway service. | | `hermes proxy` | Local OpenAI-compatible proxy that attaches OAuth provider credentials. See [Subscription Proxy](../user-guide/features/subscription-proxy.md). | +| `hermes egress` | Outbound credential-injection firewall for remote terminal sandboxes (iron-proxy). Disabled by default. See [Egress proxy](../user-guide/egress/iron-proxy.md). | | `hermes lsp` | Manage Language Server Protocol integration (semantic diagnostics for write_file/patch). | | `hermes setup` | Interactive setup wizard for all or part of the configuration. | | `hermes whatsapp` | Configure and pair the WhatsApp bridge. | @@ -613,6 +614,65 @@ All actions are also available as a slash command in the gateway (`/kanban …`) For the full design — comparison with Cline Kanban / Paperclip / NanoClaw / Gemini Enterprise, eight collaboration patterns, four user stories, concurrency correctness proof — see `docs/hermes-kanban-v1-spec.pdf` in the repository or the [Kanban user guide](/user-guide/features/kanban). +## `hermes egress` + +Outbound credential-injection firewall for remote terminal sandboxes. Wraps the [iron-proxy](https://github.com/ironsh/iron-proxy) daemon — a TLS-intercepting proxy that swaps opaque proxy tokens for real upstream API credentials at the network boundary, so sandboxes never hold real keys. Disabled by default; see the full [Egress proxy](../user-guide/egress/iron-proxy.md) page for setup + architecture. + +```bash +hermes egress install # download the pinned iron-proxy binary +hermes egress install --force # re-download even if already installed + +hermes egress setup # interactive wizard: CA, mappings, config +hermes egress setup --tunnel-port N # override the tunnel listener port (default 9090) +hermes egress setup --from-bitwarden # use Bitwarden Secrets Manager as credential source +hermes egress setup --no-bitwarden # explicitly switch back to env-based credentials +hermes egress setup --rotate-tokens # mint fresh proxy tokens (default preserves existing) + +hermes egress start # spawn the managed proxy daemon +hermes egress stop # SIGTERM (then SIGKILL after 5s grace) + +hermes egress status # binary + config + pid + listening + mappings +hermes egress status --show-tokens # print proxy tokens in full (default: redacted) + +hermes egress disable # flip proxy.enabled = false (does not stop a running proxy) +hermes egress config # print the path to proxy.yaml for inspection +``` + +### Common flows + +```bash +# First-time setup +export OPENROUTER_API_KEY=… +hermes egress setup && hermes egress start +hermes config set terminal.backend docker # if not already + +# Switching credential source after the fact +hermes egress setup --from-bitwarden # env → bitwarden +hermes egress setup --no-bitwarden # bitwarden → env +# (just `setup` without either flag preserves the existing mode) + +# Rotating all tokens (e.g. after a suspected token leak) +hermes egress setup --rotate-tokens +hermes egress start # setup stops a stale daemon; start it again +# (running sandboxes still hold old tokens; restart them too) + +# Adding a new upstream +# Edit ~/.hermes/config.yaml proxy.extra_allowed_hosts: [api.example.com] +hermes egress setup +hermes egress start +``` + +### Diagnostic shortcuts + +```bash +hermes egress status # current state in one view +cat ~/.hermes/proxy/proxy.yaml # the rendered iron-proxy config +tail -20 ~/.hermes/proxy/iron-proxy.log # daemon-level diagnostics +tail -f ~/.hermes/proxy/iron-proxy.log | jq # daemon + per-request log (line-delimited JSON; v0.39 combines both streams) +``` + +Common failure modes + recovery are covered in [Egress proxy → Troubleshooting](../user-guide/egress/iron-proxy.md#troubleshooting). + ## `hermes webhook` ```bash diff --git a/website/docs/reference/environment-variables.md b/website/docs/reference/environment-variables.md index 3387c80c70d..42def0163bb 100644 --- a/website/docs/reference/environment-variables.md +++ b/website/docs/reference/environment-variables.md @@ -234,6 +234,23 @@ For cloud sandbox backends, persistence is filesystem-oriented. `TERMINAL_LIFETI | `TERMINAL_LOCAL_PERSISTENT` | Enable persistent shell for local backend (default: `false`) | | `TERMINAL_SSH_PERSISTENT` | Override persistent shell for SSH backend (default: follows `TERMINAL_PERSISTENT_SHELL`) | +## Egress proxy (sandbox-injected) + +These env vars are NOT set on the host — they're injected into Docker sandboxes by the [Egress proxy](../user-guide/egress/iron-proxy.md) integration when `proxy.enabled: true`. Docker is the only wired backend in this release. + +| Variable | Description | +|----------|-------------| +| `HERMES_EGRESS_PROXY` | Set to `1` inside a sandbox when the egress proxy is active. Agent code can check this to know it's running behind a TLS-intercepting proxy. | +| Provider env vars (`OPENROUTER_API_KEY`, `OPENAI_API_KEY`, …) | Set to opaque proxy tokens, not real upstream secrets, so existing SDKs keep reading the standard env names. iron-proxy swaps those tokens for the real upstream secret at the network boundary. | +| `HERMES_PROXY_TOKEN_` | Diagnostic alias for each minted provider mapping. E.g. `HERMES_PROXY_TOKEN_OPENROUTER_API_KEY=hermes-proxy-openrouter-…`. Same token value as the standard provider env var. | +| `HTTPS_PROXY` / `HTTP_PROXY` | `HTTPS_PROXY` points at `http://host.docker.internal:` for CONNECT/MITM. `HTTP_PROXY` points at `` for plain-HTTP forwarding. | +| `NO_PROXY` | `127.0.0.1,localhost,::1` so loopback dev servers inside the sandbox bypass the proxy. | +| `REQUESTS_CA_BUNDLE` / `SSL_CERT_FILE` / `CURL_CA_BUNDLE` / `NODE_EXTRA_CA_CERTS` | Path to the mounted Hermes egress CA cert inside the sandbox (`/etc/ssl/certs/hermes-egress-ca.crt`). Lets the language runtimes trust iron-proxy's MITM-minted leaf certs. | +| `NODE_OPTIONS` | Appended with `--use-openssl-ca` (your existing flags are preserved) so Node.js routes through the OpenSSL store the other CA-bundle vars control. Narrows the [Node.js asymmetric CA caveat](../user-guide/egress/iron-proxy.md#nodejs-asymmetric-ca-caveat). | +| `HERMES_IRON_PROXY_NONCE` | Set on the iron-proxy daemon process itself (NOT inside the sandbox). Used by `_pid_alive` to confirm a candidate PID still refers to *our* managed binary across PID recycling. | + +These are set automatically by the Docker terminal backend when `proxy.enabled: true` AND the daemon is running. You don't set them yourself; the relevant operator-facing knobs are in `~/.hermes/config.yaml` under the `proxy:` section — see [Egress proxy → Configuration](../user-guide/egress/iron-proxy.md#configuration). + ## Messaging | Variable | Description | diff --git a/website/docs/reference/slash-commands.md b/website/docs/reference/slash-commands.md index 6eca760d434..1c626c589b6 100644 --- a/website/docs/reference/slash-commands.md +++ b/website/docs/reference/slash-commands.md @@ -53,6 +53,7 @@ Type `/` in the CLI to open the autocomplete menu. Built-in commands are case-in | `/subgoal ` | Append a user-supplied criterion to the active goal mid-loop. The continuation prompt surfaces all subgoals to the agent verbatim, and the judge factors them into its DONE/CONTINUE verdict — so the goal isn't marked done until the original goal **and** every subgoal are met. Subcommands: `/subgoal` (list), `/subgoal remove `, `/subgoal clear`. Requires an active `/goal`. | | `/resume [name]` | Resume a previously-named session | | `/sessions` (TUI alias: `/switch`) | Classic CLI: browse and resume previous sessions in an interactive picker. TUI: open the live session switcher for currently open TUI sessions. Use `/sessions new` in the TUI to start another live session immediately. | +| `/egress [status]` | Show Docker egress proxy status — enabled/configured/running state, credential source, token mappings, uncovered providers, and next remediation step. Works in CLI, TUI, Desktop chat, and messaging gateway. | | `/redraw` | Force a full UI repaint (recovers from terminal drift after tmux resize, mouse selection artifacts, etc.) | | `/status` | Show session info — model, provider, profile, session ID, working directory, title, created/updated timestamps, token totals, agent-running state — followed by a local **Session recap** block (recent user/assistant turn counts, tool result count, top tools used, last few files touched, the latest user prompt, and the latest assistant reply). The recap is computed locally from the in-memory conversation; no LLM call, no prompt-cache impact. | | `/agents` (alias: `/tasks`) | Show active agents and running tasks across the current session. | @@ -250,7 +251,7 @@ The messaging gateway supports the following built-in commands inside Telegram, - `/skills` is **CLI-only for search/browse/install**; its write-approval review subcommands (`pending`, `approve`, `reject`, `diff`, `approval`) also work on messaging platforms when `skills.write_approval` is on. `/memory` works on **both** surfaces. - `/verbose` is **CLI-only by default**, but can be enabled for messaging platforms by setting `display.tool_progress_command: true` in `config.yaml`. When enabled, it cycles the `display.tool_progress` mode and saves to config. - `/sethome`, `/update`, `/restart`, `/approve`, `/deny`, `/topic`, `/platform`, and `/commands` are **messaging-only** commands. -- `/status`, `/version`, `/background`, `/queue`, `/steer`, `/voice`, `/reload-mcp`, `/reload-skills`, `/rollback`, `/debug`, `/fast`, `/footer`, `/curator`, `/kanban`, `/credits`, `/suggestions`, `/blueprint`, `/learn`, `/sessions`, and `/yolo` work in **both** the CLI and the messaging gateway. +- `/status`, `/egress`, `/version`, `/background`, `/queue`, `/steer`, `/voice`, `/reload-mcp`, `/reload-skills`, `/rollback`, `/debug`, `/fast`, `/footer`, `/curator`, `/kanban`, `/credits`, `/suggestions`, `/blueprint`, `/learn`, `/sessions`, and `/yolo` work in **both** the CLI and the messaging gateway. - `/voice join`, `/voice channel`, and `/voice leave` are only meaningful on Discord. - In the TUI, `/sessions` shows live sessions in the current TUI process. Use `/resume [name]` or `hermes --tui --resume ` for saved or closed transcripts. diff --git a/website/docs/user-guide/egress/index.md b/website/docs/user-guide/egress/index.md new file mode 100644 index 00000000000..90ccbb8a05e --- /dev/null +++ b/website/docs/user-guide/egress/index.md @@ -0,0 +1,10 @@ +--- +title: Egress proxy +sidebar_position: 1 +--- + +# Egress proxy + +Optional outbound credential-injection firewall for remote terminal sandboxes. The sandbox only ever holds opaque proxy tokens; real API keys never leave the host. + +- [iron-proxy](./iron-proxy) — single-binary TLS-intercepting proxy from [ironsh/iron-proxy](https://github.com/ironsh/iron-proxy), lazy-installed and managed by `hermes egress`. diff --git a/website/docs/user-guide/egress/iron-proxy.md b/website/docs/user-guide/egress/iron-proxy.md new file mode 100644 index 00000000000..6bc23eb2d36 --- /dev/null +++ b/website/docs/user-guide/egress/iron-proxy.md @@ -0,0 +1,597 @@ +# Egress credential-injection proxy (iron-proxy) + +When Hermes runs your agent inside a Docker terminal sandbox, that sandbox normally holds your real upstream API keys (`OPENROUTER_API_KEY`, `OPENAI_API_KEY`, etc.). A prompt-injected agent in that sandbox can `cat ~/.config/openrouter/auth.json` or `printenv | grep -i key` and exfiltrate them. + +The egress proxy fixes this: the sandbox holds opaque **proxy tokens**, never the real keys. All outbound traffic from the sandbox routes through a local [iron-proxy](https://github.com/ironsh/iron-proxy) daemon (Apache-2.0, Go) on the host, which terminates TLS and swaps the proxy token for the real credential before forwarding the request upstream. Compromise the sandbox and the attacker walks away with tokens that only work behind the **configured trusted proxy boundary** — the CA private key and the proxy endpoint integrity are part of that boundary. If traffic can be redirected to attacker-controlled proxy infrastructure (e.g. a stolen CA private key or a hijacked proxy endpoint), the token guarantee no longer holds. + +This release wires the egress proxy into the Docker backend only. Modal, Daytona, SSH, and Singularity do **not** receive proxy env vars or CA mounts yet. + +## What it is + +- A managed `iron-proxy` subprocess on the host, lazy-installed into `~/.hermes/bin/iron-proxy` +- A local CA at `~/.hermes/proxy/ca.crt` that the sandbox trusts so iron-proxy can MITM TLS and rewrite headers +- A `proxy.yaml` config at `~/.hermes/proxy/proxy.yaml` listing the upstream hosts you allow and the secrets-transform mapping +- A `mappings.json` recording which proxy token corresponds to which real env var + +The sandbox gets `HTTPS_PROXY=http://host.docker.internal:9090`, `HTTP_PROXY=http://host.docker.internal:9091`, and standard provider env vars such as `OPENROUTER_API_KEY` set to opaque proxy tokens. Matching `HERMES_PROXY_TOKEN_` aliases are also exported for diagnostics. Existing provider SDKs read the usual env names, send the proxy token in `Authorization`, and iron-proxy's `secrets` transform substitutes the real value sourced from the host-side daemon environment. + +## What it is not + +- It is **not** the inbound `hermes proxy` command, which is an OAuth aggregator reverse proxy. Different command (`hermes egress`), different direction. +- It does **not** sit between your local terminal and providers — only between the sandbox and providers. +- It does **not** rewrite credentials for in-process LLM calls the host process makes. Those continue to use your `.env` keys directly. The threat model is the *sandbox*, not the host. + +## Quick start + +```bash +# 1. Install the iron-proxy binary (pinned version, SHA-256 verified) +hermes egress install + +# 2. Run the wizard: generates CA, mints proxy tokens for every provider key +# in your env, writes proxy.yaml. +hermes egress setup + +# 3. Start the proxy daemon +hermes egress start + +# 4. Check status +hermes egress status +``` + +Once running, the Docker terminal backend automatically: + +- Mounts `~/.hermes/proxy/ca.crt` into the sandbox at `/etc/ssl/certs/hermes-egress-ca.crt` +- Sets `HTTPS_PROXY`, `HTTP_PROXY`, `REQUESTS_CA_BUNDLE`, `SSL_CERT_FILE`, `CURL_CA_BUNDLE`, `NODE_EXTRA_CA_CERTS` to make every common HTTP runtime route through the proxy and trust the CA +- Sets `NODE_OPTIONS=--use-openssl-ca` (appended to whatever you already have in `docker_env.NODE_OPTIONS`) so Node.js routes through the OpenSSL store the other CA-bundle vars control — see [Node.js asymmetric CA caveat](#nodejs-asymmetric-ca-caveat) below for the residual gap +- Adds `--add-host=host.docker.internal:host-gateway` so the sandbox can reach the host-side proxy on Linux (Docker Desktop handles this automatically on macOS/Windows) +- Exports the proxy token under the standard provider env name (for example `OPENROUTER_API_KEY`) plus one `HERMES_PROXY_TOKEN_` diagnostic alias per minted mapping + +## Configuration + +The full config lives in `~/.hermes/config.yaml` under the `proxy:` section. Defaults are documented inline; everything is optional. + +```yaml +proxy: + # Master switch. When false the feature is a complete no-op — no + # binaries downloaded, no docker mounts added, no subprocess started. + enabled: false + + # Tunnel listener port. Sandboxes hit http://host.docker.internal:. + tunnel_port: 9090 + + # Auto-download the pinned iron-proxy binary on first use. + auto_install: true + + # Where iron-proxy looks up the real upstream secrets at egress time. + # env — process env (default). Whatever is in your ~/.hermes/.env + # at proxy-start time is the source of truth. + # bitwarden — refetch from Bitwarden Secrets Manager on each proxy + # restart. Rotation in the BW web app propagates without + # touching .env. Requires `secrets.bitwarden.enabled: true`. + credential_source: env + + # When true (default), the Docker backend refuses to start a sandbox if + # the proxy is enabled but not running. Set to false to fall back to the + # legacy "real credentials inside the sandbox" posture when the proxy + # is unavailable. + enforce_on_docker: true + + # When true, `hermes egress start` refuses to start if LLM-specific + # non-bearer provider env vars are set (Anthropic native, Azure OpenAI, + # Gemini) — those bypass the proxy's secrets transform and would leak + # real credentials into the sandbox. Defaults to false because the + # false-positive cost (operator has the env set but doesn't actually + # use that provider) is higher than the security cost of a warning. + # See "Uncovered providers" below for the strict tier vs warn tier + # distinction. + fail_on_uncovered_providers: false + + # When `credential_source: bitwarden` but the BWS access token / + # project_id is missing OR the bws fetch returns no values for mapped + # providers, the daemon raises by default (matches the spirit of "I + # asked for rotation — don't silently use stale env values"). Set + # to true to opt back into the legacy host-env fallback — useful for + # migrations where you want to start switching to BW mode but haven't + # wired every secret yet. + allow_env_fallback: false + + # SSRF deny list applied to outbound traffic. Omit / leave null to + # use the safe default: loopback (v4 + v6), link-local (incl. cloud + # metadata IPs at 169.254.169.254), RFC1918, IPv6 ULA, IPv4-mapped-v6, + # CGNAT, and the RFC2544 benchmark range. Set to an explicit `[]` + # to opt out entirely (only sensible in hermetic tests). + upstream_deny_cidrs: null + + # Extra allowed upstream hosts beyond the bundled defaults. + # Wildcards (`*.foo.com`) are supported. The defaults cover OpenRouter, + # OpenAI, Anthropic, Google, xAI, Mistral, Groq, Together, DeepSeek, + # and Nous Research. + extra_allowed_hosts: [] +``` + +### Default allowed upstream hosts + +``` +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 +``` + +If your agent needs an upstream that isn't on the list — a self-hosted inference endpoint, an extra cloud LLM, an MCP server — add it to `proxy.extra_allowed_hosts`. Wildcards are matched against the full hostname (`*.example.com` matches `api.example.com` and `staging.example.com` but not `example.com` itself). + +### Default SSRF deny CIDRs + +Applied regardless of allowlist. These ranges are refused by iron-proxy at the network boundary, so a DNS rebinding attack via an allowlisted hostname can't reach IMDS or your internal network: + +| CIDR | Purpose | +|---|---| +| `127.0.0.0/8`, `::1/128` | Loopback (v4 + v6) | +| `169.254.0.0/16`, `fe80::/10` | Link-local — **incl. AWS / GCP / Azure IMDS at `169.254.169.254`** | +| `10.0.0.0/8`, `172.16.0.0/12`, `192.168.0.0/16` | RFC1918 | +| `fc00::/7` | IPv6 ULA | +| `::ffff:0:0/96` | IPv4-mapped IPv6 — closes the dual-stack IMDS bypass | +| `100.64.0.0/10` | RFC6598 CGNAT (used by AWS VPC, K8s pod networks) | +| `198.18.0.0/15` | RFC2544 benchmark range | + +To override: set `proxy.upstream_deny_cidrs` to your own list. To opt out entirely (e.g. for a hermetic test that needs to reach a loopback upstream): set it to an empty list `[]`. + +### Bind policy + +The proxy never binds `0.0.0.0`. The default bind is platform-specific because iron-proxy v0.39 supports only a **single bind per daemon process**: + +- **Linux:** the docker bridge gateway (`172.17.0.1:` by default). Containers reach the proxy via `host.docker.internal`, which `--add-host=host.docker.internal:host-gateway` resolves to exactly this bridge gateway IP — a loopback-only bind would be unreachable from inside sandboxes. The bridge IP is an address on the host's `docker0` interface, so it is not exposed to the LAN; it IS reachable by other containers on the default bridge network, but requests still require a minted proxy token and an allowlisted upstream. If no docker bridge is detected (docker not installed/running), the bind falls back to loopback with a warning. +- **macOS / Windows Docker Desktop:** loopback (`127.0.0.1:`). Desktop's VPNkit routes `host.docker.internal` to the host, so loopback is reachable from containers and is the least-exposed choice. + +A LAN peer with a leaked proxy token cannot use the proxy — neither bind is reachable from the external network. + +We also pin `metrics.listen: 127.0.0.1:0` so the daemon's built-in metrics server gets an ephemeral loopback port instead of its default `:9090` — otherwise it would fight `tunnel_port: 9090` for the same socket and the daemon would refuse to start with "address already in use". Note the `:0` ephemeral port is random per start and not surfaced anywhere, so metrics are effectively disabled at this pin. + +If a hostile `ip` shim earlier on PATH had been able to inject a non-private IPv4 as the bridge address (`0.0.0.0`, a public address, multicast, link-local, etc.) the loopback fallback still applies — we never bind anything we couldn't validate via `ipaddress.IPv4Address` + `is_*` checks. + +## Uncovered providers + +iron-proxy's `secrets` transform only handles `Authorization: Bearer` headers. Providers using `x-api-key`, SigV4, AAD tokens, or custom signatures cannot be proxied — if their env vars are present, the sandbox holds **real credentials** for those providers and the egress isolation guarantee is incomplete for them. + +The wizard and `hermes egress status` always surface uncovered providers in your env. There are two tiers: + +### Strict tier — refuses start when `fail_on_uncovered_providers: true` + +| Env var | Provider | Reason | +|---|---|---| +| `ANTHROPIC_API_KEY` | Anthropic native | x-api-key header, not Bearer | +| `AZURE_OPENAI_API_KEY` | Azure OpenAI | api-key header + optional AAD | +| `GEMINI_API_KEY` | Google AI Studio (Gemini) | x-goog-api-key | + +These are LLM-specific names. An operator who has them set is using those providers; a bypass is a real isolation failure. + +### Warn-only tier — surfaced but never blocks + +| Env var | Provider | Reason | +|---|---|---| +| `AWS_ACCESS_KEY_ID`, `AWS_SECRET_ACCESS_KEY` | AWS Bedrock / SageMaker | SigV4-signed | +| `GOOGLE_APPLICATION_CREDENTIALS` | GCP Vertex AI | gcloud OAuth | +| `GOOGLE_API_KEY` | Google AI Studio | x-goog-api-key OR query param | + +These env vars are present on most developer laptops for unrelated tooling (terraform, gcloud, aws CLI, ECR push). They surface as warnings in the wizard + `status` output but don't refuse-start. + +### Operator playbook + +If `hermes egress start` refuses because of a strict-tier env var you don't actually use: + +```bash +unset ANTHROPIC_API_KEY # or whichever one is flagged +hermes egress start +``` + +If you DO use that provider but accept the isolation gap: + +```yaml +# config.yaml +proxy: + fail_on_uncovered_providers: false # default +``` + +Either way, the warning persists in `hermes egress status` until you remove the env var. + +## Bitwarden integration + +If you already use Bitwarden Secrets Manager via [`hermes secrets bitwarden setup`](../secrets/bitwarden), the egress proxy can pull real credentials from there instead of `os.environ`: + +```bash +hermes egress setup --from-bitwarden +``` + +This sets `proxy.credential_source: bitwarden` and discovers provider env names from your BW project. + +### Rotation semantics + +When `credential_source: bitwarden`, the iron-proxy daemon refetches secrets from BWS via `bws secret list ` **every time it starts**. So the rotation flow is: + +1. Rotate a key in the Bitwarden web app. +2. `hermes egress stop && hermes egress start` on the host. +3. Sandboxes started after that point swap proxy tokens for the new value. + +No `.env` edits. No Hermes restart on the host. The proxy daemon is the only thing that touches the new value — your host process and `os.environ` are untouched. + +### Fail-loud at start + +When `credential_source: bitwarden`, `hermes egress start` pre-checks at the wizard layer AND `_build_proxy_subprocess_env` re-checks at the daemon layer: + +- BWS access token env var is unset → refuse to start with a hint to `unset` and re-run, or `hermes egress setup --no-bitwarden` to switch back to env mode +- `secrets.bitwarden.project_id` is empty → refuse to start with a hint to run `hermes secrets bitwarden setup` +- `bws secret list` returns no values for one or more mapped providers → refuse to start, listing the missing names + +This is intentional. Falling back to host env in BW mode reintroduces exactly the staleness bug the BW path is meant to defeat (operator picked BW for the rotation guarantee; silent fallback breaks that guarantee). + +The `proxy.allow_env_fallback: true` config flag opts back in to the legacy "silently fall back to host env if BWS is unreachable" behavior for migration scenarios. Use it when you're moving secrets into BW one at a time and want the daemon to start with whichever values are available. + +### Switching credential source + +| From | To | Command | +|---|---|---| +| env | bitwarden | `hermes egress setup --from-bitwarden` | +| bitwarden | env | `hermes egress setup --no-bitwarden` | + +**Re-running `hermes egress setup` WITHOUT either flag preserves the existing `credential_source`** — the wizard refuses to silently downgrade you back to env. This matters because once you've configured bitwarden mode, the rotation guarantee is what you signed up for; you have to explicitly say "I want env again" to change it. + +## Slash commands + +The CLI subcommand tree: + +``` +hermes egress install # download the pinned iron-proxy binary +hermes egress install --force # re-download even if a managed copy exists + +hermes egress setup # interactive wizard +hermes egress setup --tunnel-port N # override the tunnel listener port +hermes egress setup --from-bitwarden # use BWS as credential source (fail-loud) +hermes egress setup --no-bitwarden # explicitly switch back to env mode +hermes egress setup --rotate-tokens # mint fresh tokens for every provider + # (default preserves existing) + +hermes egress start # spawn the managed proxy daemon +hermes egress stop # SIGTERM (then SIGKILL after 5s grace) + +hermes egress status # binary + config + pid + listening state + mappings +hermes egress status --show-tokens # print proxy tokens in full + # (default: redacted prefix + suffix only) + +hermes egress disable # flip proxy.enabled = false + # (does not stop a running proxy) + +hermes egress config # print the path to proxy.yaml for debugging +``` + +### Token rotation + +By default, `hermes egress setup` **preserves** proxy tokens for providers that already have them. Adding a new provider mints a fresh token only for the new one; existing tokens are unchanged. This avoids 401-ing running sandboxes when you re-run the wizard. + +`--rotate-tokens` rolls every token: + +```bash +hermes egress setup --rotate-tokens +``` + +When there are existing tokens AND stdin is a tty, the wizard prompts for confirmation: + +``` +⚠ --rotate-tokens will invalidate proxy tokens in every running + Hermes sandbox. They will start 401-ing against upstreams until restarted. +Type 'rotate' to confirm: +``` + +Non-tty invocations (CI, scripts) skip the prompt — the flag is treated as deliberate. Before any overwrite the current `mappings.json` is copied to a timestamped sibling so manual recovery is possible: + +``` +backup: ~/.hermes/proxy/mappings.json.rotated-20260524T143012 +``` + +`hermes egress setup` stops a running daemon when it rewrites config or token mappings, because the daemon keeps the old YAML in memory. After `--rotate-tokens`: + +```bash +hermes egress start +``` + +Containers already running hold the old tokens and will need to be restarted to pick up the new ones. New persistent Docker containers include an egress-posture label, so Hermes will not reuse a pre-egress or pre-rotation container for new sessions. + +## State directory layout + +Everything iron-proxy maintains lives in `~/.hermes/proxy/`: + +| Path | Mode | Purpose | +|---|---|---| +| `~/.hermes/proxy/` (dir) | `0o700` | Owned + traversable by you only | +| `ca.crt` | `0o644` | Public CA cert distributed into sandboxes | +| `ca.key` | `0o600` | CA signing key — never leaves the host | +| `proxy.yaml` | `0o600` | iron-proxy config; rewritten every `setup` | +| `mappings.json` | `0o600` | Sandbox proxy token → upstream env var | +| `mappings.json.rotated-*` | `0o600` | Backups created by `--rotate-tokens` | +| `iron-proxy.pid` | `0o600` | PID of the running daemon | +| `iron-proxy.nonce` | `0o600` | Per-start nonce for PID-recycle defense | +| `iron-proxy.log` | `0o600` | Daemon stdout/stderr — **includes per-request records on v0.39** | +| `audit.log` | `0o600` | Reserved for the dedicated per-request audit stream on future binary versions; pre-created so the privacy contract holds when upstream wires it in | + +The CA private key is the most sensitive file. It's created with `0o600` from the first byte (no umask-window TOCTOU) and `O_NOFOLLOW` so a same-uid attacker can't redirect it via a planted symlink. The pidfile, nonce file, daemon log, and audit log get the same treatment. + +### Logging on iron-proxy v0.39 + +On the currently pinned binary version (**v0.39.0**) iron-proxy writes ALL output — daemon-level diagnostics AND per-request records — to **`~/.hermes/proxy/iron-proxy.log`**. v0.39's `config.Log` struct doesn't have a separate `audit_path` field, so we can't route per-request records to a dedicated stream there. + +We still pre-create `~/.hermes/proxy/audit.log` at `0o600` with `O_NOFOLLOW` because: + +1. It reserves the path for the future version bump: when the pinned version moves to one that supports `log.audit_path`, per-request records will start flowing there without operator-side reconfiguration. **Until then the file stays at 0 bytes — do not point monitoring, alerting, or forensics tooling at it yet.** Use `iron-proxy.log` for everything today. +2. The 0o600-from-first-byte guarantee defends against the upstream-fix-day where v0.40+ creates the file under its default umask if it doesn't already exist. + +Until that version bump lands, treat `iron-proxy.log` as the source of truth for both audiences: + +- Daemon-level events (startup banner, bind errors, shutdown reason, transform errors). Operations + troubleshooting. +- Per-request records (CONNECT to allowlisted upstream, secret swap fired, allowlist denial). Forensics + compliance. + +Both files are appended to across restarts. Rotate them with logrotate if you care about disk usage on long-lived hosts. + +## How it works + +``` +┌──────────────┐ ┌──────────────┐ ┌─────────────┐ +│ Docker │ CONNECT / │ iron-proxy │ HTTPS w/ │ OpenRouter │ +│ sandbox ├──────────────▶│ (host:9090) ├───────────────▶│ / OpenAI / │ +│ │ HTTP forward │ │ real API key │ Anthropic … │ +│ has: │ w/ proxy tok │ mints leaf │ │ │ +│ - proxy tok │ in Auth hdr │ cert from CA │ │ │ +│ - CA cert │ │ matches token │ │ │ +│ - HTTPS_PROXY│ │ swaps secret │ │ │ +└──────────────┘ └──────────────┘ └─────────────┘ + │ + │ daemon + per-request log (combined on v0.39) + ▼ + ~/.hermes/proxy/iron-proxy.log + (~/.hermes/proxy/audit.log reserved for v0.40+ split stream) +``` + +1. Sandbox makes an HTTPS request, e.g. `POST https://openrouter.ai/v1/chat/completions` with `Authorization: Bearer hermes-proxy-openrouter-…` (the proxy token, not the real key). +2. Because `HTTPS_PROXY` is set, the request goes to iron-proxy as a CONNECT tunnel. +3. iron-proxy checks the allowlist. `openrouter.ai` is allowed. +4. iron-proxy mints a leaf cert signed by our CA for `openrouter.ai`, terminates the TLS connection, inspects the request. +5. The `secrets` transform matches the proxy-token string in the `Authorization` header and substitutes the real `OPENROUTER_API_KEY` value, sourced from iron-proxy's own environment. +6. Request is re-encrypted and forwarded to OpenRouter. +7. The request is logged to `~/.hermes/proxy/iron-proxy.log` on v0.39. When the pinned binary version supports the split stream (v0.40+), per-request records will flow to `~/.hermes/proxy/audit.log` and daemon-level diagnostics will stay in `iron-proxy.log`. See [Logging on iron-proxy v0.39](#logging-on-iron-proxy-v039). + +A request to a non-allowlisted host (e.g. `https://attacker.example.com/leak?key=...`) is rejected with HTTP 403 before any bytes leave the host. The denial is recorded in `iron-proxy.log` with the upstream host and the source sandbox. + +### CA distribution into the sandbox + +When the Docker backend starts a container with `proxy.enabled: true` and the daemon is listening, it adds these arguments to `docker run`: + +| Arg | Purpose | +|---|---| +| `-v ~/.hermes/proxy/ca.crt:/etc/ssl/certs/hermes-egress-ca.crt:ro` | Read-only mount of the CA | +| `-e HTTPS_PROXY=http://host.docker.internal:9090` | Python httpx / curl / go default transport / Node fetch | +| `-e HTTP_PROXY=http://host.docker.internal:9091` | curl + wget for plain HTTP — the plain-HTTP forward listener lives on `tunnel_port + 1` | +| `-e NO_PROXY=127.0.0.1,localhost,::1` | Loopback dev servers inside the sandbox bypass the proxy | +| `-e REQUESTS_CA_BUNDLE=…ca.crt` | Python `requests` | +| `-e SSL_CERT_FILE=…ca.crt` | Python `ssl` module / OpenSSL — **replaces** the system store | +| `-e CURL_CA_BUNDLE=…ca.crt` | curl — **replaces** the system store | +| `-e NODE_EXTRA_CA_CERTS=…ca.crt` | Node.js — **adds** to the system store | +| `-e NODE_OPTIONS=" --use-openssl-ca"` | Node.js — route through OpenSSL store (appended; your `--max-old-space-size` etc. are preserved) | +| `-e HERMES_EGRESS_PROXY=1` | Sentinel the agent can read to know it's proxy-aware | +| `-e OPENROUTER_API_KEY=` | Standard provider env names receive proxy tokens so existing SDKs keep working | +| `-e HERMES_PROXY_TOKEN_=…` | Diagnostic alias for each mapping; same value as the standard provider env var | +| `--add-host=host.docker.internal:host-gateway` | Linux-only; Docker Desktop maps it automatically | + +#### Node.js asymmetric CA caveat + +`REQUESTS_CA_BUNDLE` / `SSL_CERT_FILE` / `CURL_CA_BUNDLE` **replace** the system CA store inside the sandbox. `NODE_EXTRA_CA_CERTS` **adds** to it. A Node.js process inside the sandbox could in principle bypass the proxy by opening a raw `net.Socket` and starting its own TLS handshake — the system CA store would still trust real upstream certs, so the request would succeed where Python / curl would fail validation. + +`NODE_OPTIONS=--use-openssl-ca` is appended to whatever you already have in `docker_env.NODE_OPTIONS`. This forces Node through the OpenSSL store that `SSL_CERT_FILE` controls, narrowing the asymmetry. It does NOT cover code that explicitly passes its own `ca` option to `tls.connect()` or `https.request()`, but it closes the easy case. + +This is a known v1 limitation. Track [github.com/ironsh/iron-proxy/issues](https://github.com/ironsh/iron-proxy/issues) for an upstream resolution; in the meantime, do not run untrusted Node code that opens raw sockets in a sandbox you're depending on egress isolation for. + +### docker\_env collisions + +If you set proxy-controlling env vars in your `docker_env:` config block (rare but possible), Hermes refuses to start the sandbox when `enforce_on_docker: true` is set. This includes both: + +- Egress-control vars: `HTTPS_PROXY`, `HTTP_PROXY`, `NO_PROXY`, `REQUESTS_CA_BUNDLE`, `SSL_CERT_FILE`, `CURL_CA_BUNDLE`, `NODE_EXTRA_CA_CERTS` +- Real provider env vars: every name in `mappings.json` (e.g. `OPENROUTER_API_KEY`, `OPENAI_API_KEY`) + +Example error: + +``` +docker_env in config.yaml overrides egress-proxy variables +['HTTPS_PROXY', 'OPENROUTER_API_KEY']; enforce_on_docker is enabled. +Remove these keys from docker_env or disable enforce_on_docker to +opt out of egress isolation. +``` + +With `enforce_on_docker: false` the same situation surfaces as a warning and your `docker_env` values win — useful for migrations or testing, but you're explicitly opting OUT of the isolation guarantee. + +## PID and nonce defense + +The daemon's pidfile is written with `O_EXCL` + `O_NOFOLLOW` + ownership check. Concurrent `hermes egress start` calls produce one of two outcomes: + +- The existing pidfile points at a live iron-proxy → second start refuses with "another start in progress" + a hint to run `hermes egress stop` +- The existing pidfile is stale (crashed daemon) → second start unlinks it and retries once + +Beyond that, every `start_proxy` plants a fresh random nonce in two places: + +- `HERMES_IRON_PROXY_NONCE=` in the daemon's env +- `~/.hermes/proxy/iron-proxy.nonce` (0o600 sibling of the pidfile) + +When `hermes egress stop` (or any other `_pid_alive` check) wants to confirm a PID still refers to *our* daemon — not an unrelated process that was assigned the same PID after iron-proxy crashed — it reads `/proc//environ` and looks for the nonce. The on-disk copy is what makes this work across CLI invocations (the in-memory `_proxy_nonce` is per-process and resets on every `hermes` invocation). + +If the nonce check fails, the code falls back to matching `argv[0]` basename against `iron-proxy`. `stop_proxy` additionally captures `/proc//stat` starttime before SIGTERM and re-verifies after the 5s grace window — if starttime drifted, the PID was recycled mid-wait and SIGKILL is suppressed with a warning. + +## Security model + +**What this protects against:** + +- Prompt-injected agent in a Docker sandbox reading `printenv` / credential files and exfiltrating real keys. +- Compromised dependency in the sandbox phoning home to an arbitrary host — default-deny allowlist blocks unknown destinations. +- Agent dialing cloud metadata endpoints (`169.254.169.254`) — iron-proxy denies these by default via `upstream_deny_cidrs`, including the IPv4-mapped-v6 form `::ffff:169.254.169.254`. +- DNS rebinding through an allowlisted hostname to a private IP — the deny CIDRs are checked at connect time, not at allowlist time. +- Same-uid local processes reading the iron-proxy daemon's env to scrape secrets — only the env var names referenced by mappings are forwarded, not the full host env. +- A LAN peer with a leaked sandbox proxy token spending your API quota — the proxy binds the docker bridge gateway (Linux) or loopback (Docker Desktop), never `0.0.0.0`, so it is unreachable from the external network. + +**What it does NOT protect against:** + +- A compromised host process. If the agent process itself is compromised, real keys in the host's `~/.hermes/.env` are exposed regardless. This is a defense-in-depth feature for *sandbox* compromise, not host compromise. +- **Loss of the trusted-proxy boundary itself.** The token-swap guarantee assumes the sandbox trusts the mounted CA cert (`/etc/ssl/certs/hermes-egress-ca.crt`) and that traffic actually reaches *our* iron-proxy. If the CA private key is stolen, or sandbox egress is redirected to attacker-controlled proxy infrastructure, an adversary-in-the-middle can present a valid leaf cert and the proxy tokens are no longer a meaningful boundary (cf. [MITRE ATT&CK T1588.004](https://attack.mitre.org/techniques/T1588/004/) — obtained TLS certificate material enabling AiTM). Protect the CA key (it's `0600`, host-only) and the proxy endpoint accordingly. +- Sandbox processes that bypass `HTTPS_PROXY` by using a raw socket. The proxy can't intercept what doesn't route to it. Node.js is partially mitigated via `NODE_OPTIONS=--use-openssl-ca` (see caveat above). +- Credential files explicitly mounted into Docker (`terminal.credential_files` or skill-registered mounts). Egress protects provider env vars; it does not inspect arbitrary mounted files. Do not mount real provider credentials into an enforced egress sandbox. +- Allowlisted-host data exfiltration. If `api.openai.com` is allowed, an agent could embed exfil data in a request body to that host. The daemon log captures the request happened but doesn't prevent it. +- Uncovered providers (Anthropic native, AWS Bedrock, Azure OpenAI, Gemini). Their env vars stay in the sandbox; if you enable them, those credentials bypass the proxy entirely. See [Uncovered providers](#uncovered-providers). +- iron-proxy in-memory secret zeroisation. The Go binary holds swapped-in real credentials in process memory; a core-dump or `/proc//mem` read from a same-uid attacker would expose them. Out of scope for this layer. + +## Failure modes + +- **Binary not installed, `auto_install: true`** — first `hermes egress setup` or `hermes egress start` downloads it. SHA-256 verified against the upstream `checksums.txt`. +- **Binary not installed, `auto_install: false`** — `start` fails with a clear message pointing to manual install. +- **`enabled: true` but proxy not running** — with `enforce_on_docker: true` (default), Docker sandbox creation refuses to start with an explanatory error. With `enforce_on_docker: false`, it falls back to direct outbound with real creds and logs a warning. +- **Port collision** — iron-proxy exits immediately; `hermes egress start` reports the last 20 log lines and fails with non-zero exit. +- **Upstream-host denied** — sandbox gets HTTP 403 from the proxy with a body explaining which host wasn't allowed. The agent sees the error and reports it. +- **Cloud metadata IP (169.254.169.254) requested** — refused by `upstream_deny_cidrs` regardless of allowlist. +- **Strict-tier uncovered provider env var set** — `hermes egress start` refuses with a list of the offending env vars and the `proxy.fail_on_uncovered_providers: false` escape hatch. +- **`docker_env` collides with a proxy-controlling var (enforce on)** — sandbox creation refuses with the names of the colliding keys. +- **`docker_forward_env` tries to forward a protected provider key (enforce on)** — sandbox creation refuses; remove the key from `docker_forward_env` or opt out with `proxy.enforce_on_docker: false`. +- **`docker_extra_args` overrides proxy env/network controls (enforce on)** — sandbox creation refuses; user-supplied `-e HTTPS_PROXY=...`, `--env-file`, or `--network` args run after Hermes' generated args and can bypass egress. +- **BWS access token missing in `credential_source: bitwarden`** — `hermes egress start` refuses with `--no-bitwarden` as the recovery hint. +- **iron-proxy doesn't bind within 5 seconds** — process is killed, pidfile unlinked, error names the port + tail of `iron-proxy.log`. +- **Concurrent `hermes egress start` calls** — second call refuses with "another start in progress" if the first's daemon is up; otherwise the second unlinks the stale pidfile and proceeds. + +## Troubleshooting + +### "Refusing to start: BWS_ACCESS_TOKEN is not set" + +You enabled `credential_source: bitwarden` but the access-token env var isn't in your shell. Either: + +```bash +export BWS_ACCESS_TOKEN=… # one-shot +hermes egress start +``` + +Or move it into `~/.hermes/.env`. Or switch back to env mode: + +```bash +hermes egress setup --no-bitwarden +``` + +### "Refusing to start: provider env vars present that bypass the proxy" + +You have `fail_on_uncovered_providers: true` AND one of `ANTHROPIC_API_KEY` / `AZURE_OPENAI_API_KEY` / `GEMINI_API_KEY` is set in your env. Either unset the offending var, or flip the config flag back to `false` (default) if you accept the isolation gap. + +### "iron-proxy exited immediately" + +Look at the last 20 lines of `~/.hermes/proxy/iron-proxy.log`. Common causes: + +- Port already in use → change `proxy.tunnel_port` or kill whatever else owns 9090 +- Invalid `proxy.yaml` → run `hermes egress setup` to regenerate +- CA cert / key permissions wrong → `chmod 0o600 ~/.hermes/proxy/ca.key` + +### "iron-proxy did not bind \:9090 within 5s" + +The daemon started but never bound the listener. Usually means the binary is wedged or doing something expensive at startup. Check `~/.hermes/proxy/iron-proxy.log`. The orphan process is killed automatically and the pidfile cleaned up so you can just retry `hermes egress start`. + +### Sandbox times out connecting to the proxy (Linux) + +The container resolves `host.docker.internal` to the docker bridge gateway and the proxy is bound there, but a host firewall (commonly `ufw` with default-deny INPUT) drops container→host traffic on `docker0`. Verify from a container: + +```bash +docker run --rm --add-host host.docker.internal:host-gateway busybox \ + nc -zv -w 3 host.docker.internal 9090 +``` + +If that times out while `hermes egress status` shows `listening`, allow the bridge subnet in your firewall, e.g. for ufw: + +```bash +sudo ufw allow in on docker0 to any port 9090 proto tcp +sudo ufw allow in on docker0 to any port 9091 proto tcp +``` + +(9091 = the plain-HTTP forward listener on `tunnel_port + 1`.) + +### Sandbox sees `HTTP 403` from the proxy + +The agent inside the sandbox tried to hit a host that isn't in `proxy.extra_allowed_hosts`. The 403 body explains which host. If you want to allow it, add to your config: + +```yaml +proxy: + extra_allowed_hosts: + - api.example.com + - "*.staging.example.com" +``` + +Then `hermes egress setup` (to regenerate `proxy.yaml`) and `hermes egress stop && hermes egress start`. + +### Sandbox sees SSL verification errors + +Either the CA isn't mounted in the sandbox (rare; the docker backend does this automatically when `proxy.enabled: true`), or your image's HTTP client is reading from a non-standard env var. + +```bash +# Inside the sandbox: +cat /etc/ssl/certs/hermes-egress-ca.crt | head -1 +# Should print: -----BEGIN CERTIFICATE----- +env | grep -E "^(REQUESTS|CURL|SSL|NODE).*CA" +# Should list all four CA-bundle env vars pointing at /etc/ssl/certs/hermes-egress-ca.crt +``` + +If the cert isn't there, check that `proxy.enabled: true` AND `hermes egress status` shows `Listening yes`. If the env vars are missing, the sandbox image might be running an entrypoint that strips them — check your `docker_env` config. + +### Sandbox sees `HTTP 401` from upstreams + +Two common causes: + +1. **Token-clobber on re-setup.** You ran `hermes egress setup --rotate-tokens` (or rotated tokens some other way) and the running sandboxes still hold the old tokens. Restart the sandboxes. +2. **Bitwarden refresh failed silently.** Should not happen with the new fail-loud behavior, but if you have `proxy.allow_env_fallback: true` set, the daemon may have started with stale env values. Check the daemon's environment (`/proc//environ`) for the expected `OPENROUTER_API_KEY` etc. + +### "Address in use" after the parent process died + +The parent Hermes process died during `hermes egress start` (Ctrl-C during the listening probe, OOM, panic). The new fix-up logic writes the pidfile immediately after `Popen` so the orphan is recoverable: + +```bash +hermes egress stop # finds the orphan via the pidfile, kills it +hermes egress start +``` + +If `hermes egress stop` says "iron-proxy was not running" but you can still see the daemon in `ps`, the pidfile got out of sync. Manual recovery: + +```bash +pkill -TERM iron-proxy +rm -f ~/.hermes/proxy/iron-proxy.pid ~/.hermes/proxy/iron-proxy.nonce +hermes egress start +``` + +### Inspecting per-request behavior + +On the pinned binary version (**v0.39**) both daemon-level events and per-request records land in `~/.hermes/proxy/iron-proxy.log`. The format is line-delimited JSON. Grep for a specific upstream: + +```bash +grep '"upstream":"openrouter.ai"' ~/.hermes/proxy/iron-proxy.log | tail -20 +``` + +Or watch in real-time: + +```bash +tail -f ~/.hermes/proxy/iron-proxy.log | jq +``` + +When the pinned version moves to v0.40+ (which adds `log.audit_path`), per-request records will move to `~/.hermes/proxy/audit.log` and `iron-proxy.log` will hold only daemon-level events. Until that bump, `audit.log` is an empty placeholder (pre-created at `0o600` so the future daemon inherits tight permissions) — wire your logrotate / monitoring tooling to `iron-proxy.log` today and plan to add `audit.log` after the version bump. + +## Limitations (v1) + +- Docker backend only. Modal, Daytona, and SSH wiring will follow in separate PRs. +- Only bearer-token providers (OpenRouter, OpenAI, Anthropic-via-OR, etc.) are wired through the `secrets` transform out of the box. Providers with custom auth (x-api-key, query params, signatures) bypass the proxy entirely — see [Uncovered providers](#uncovered-providers). +- No native Windows binary upstream. Run on Linux / macOS / WSL. +- The CA is a 10-year self-signed cert on first generation. Rotation requires `openssl genrsa ...` by hand (or wait for a follow-up that adds `hermes egress rotate-ca`). +- Re-running setup stops a running daemon after rewriting config or mappings; run `hermes egress start` again, and restart already-running sandboxes after token rotation. +- iron-proxy in-memory secret zeroisation is upstream-controlled. Same-uid attackers with `/proc//mem` read access can read swapped-in secrets from the daemon's memory. +- iron-proxy v0.39 only supports a **single bind per daemon** (we bind the docker bridge gateway on Linux, loopback on Docker Desktop) and combines daemon + per-request records into a single log stream. When upstream adds `proxy.http_listens` (plural) and `log.audit_path`, a version bump can wire in multi-bind and the dedicated audit stream. + +## See also + +- Upstream project: [github.com/ironsh/iron-proxy](https://github.com/ironsh/iron-proxy) +- Upstream docs: [docs.iron.sh](https://docs.iron.sh/) +- Bitwarden integration: [`hermes secrets bitwarden`](../secrets/bitwarden) +- Hermes Docker terminal backend: [Docker](../docker) +- Developer / contributor reference: [Egress proxy internals](../../developer-guide/egress-internals) diff --git a/website/sidebars.ts b/website/sidebars.ts index 2dbf96e2ed3..dabb27206c8 100644 --- a/website/sidebars.ts +++ b/website/sidebars.ts @@ -38,6 +38,15 @@ const sidebars: SidebarsConfig = { 'user-guide/secrets/bitwarden', ], }, + { + type: 'category', + label: 'Egress proxy', + collapsed: true, + items: [ + 'user-guide/egress/index', + 'user-guide/egress/iron-proxy', + ], + }, 'user-guide/sessions', 'user-guide/profiles', 'user-guide/profile-distributions', @@ -752,6 +761,7 @@ const sidebars: SidebarsConfig = { 'developer-guide/browser-supervisor', 'developer-guide/acp-internals', 'developer-guide/cron-internals', + 'developer-guide/egress-internals', 'developer-guide/trajectory-format', ], },