diff --git a/agent/proxy_sources/__init__.py b/agent/proxy_sources/__init__.py new file mode 100644 index 0000000000000..34af31ca6fb2d --- /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/host_hardening.py b/agent/proxy_sources/host_hardening.py new file mode 100644 index 0000000000000..fbd347f2e3b2e --- /dev/null +++ b/agent/proxy_sources/host_hardening.py @@ -0,0 +1,542 @@ +"""Read-only host-hardening survey for ``hermes egress harden``. + +This module probes the *host* for perimeter-security signals (firewall, +SSH config, fail2ban, mesh-VPN, Docker seccomp) and reports how they +layer with iron-proxy's sandbox-egress hardening. It is intentionally +**complementary** to ``hermes egress doctor``: + + * ``doctor`` answers "is the egress proxy itself healthy?" (binary, + CA, config, daemon, token-swap, SSRF deny-list). + * ``harden`` answers "is the *machine the proxy runs on* locked + down?" (firewall, SSH, fail2ban, mesh-VPN) and folds the two + iron-proxy runtime signals in so an operator sees the whole + defense-in-depth stack in one table. + +Design constraints (mirrors iron_proxy.py): + * Stdlib only — no ``cryptography``, ``python-iptables``, or DNS libs. + * Every probe is best-effort and graceful: a missing binary or an + unreadable file yields a ``skip`` (not a ``fail``), so the survey + degrades cleanly on macOS / minimal containers / locked-down hosts. + * No side effects. Subprocess calls are all read-only status probes + with short timeouts; we never start, stop, or rewrite anything. + +Inspired by @catalinmpit's public Hetzner + Tailscale + UFW + Cloudflare ++ fail2ban deployment of a Hermes agent, which prompted Teknium's "can we +get a security review of the egress proxy?" question on X. The +``catalin`` baseline encodes that exact perimeter shape. +""" + +from __future__ import annotations + +import platform +import re +import shutil +import subprocess +from dataclasses import dataclass +from pathlib import Path +from typing import Callable, Dict, List, Optional, Tuple + +from agent.proxy_sources.iron_proxy import get_status + + +# --------------------------------------------------------------------------- +# Status vocabulary — bare strings (not an enum) so --json output is +# trivially serializable and stable across versions, matching the doctor +# convention in iron_proxy.py. +# --------------------------------------------------------------------------- + +PASS = "pass" +FAIL = "fail" +WARN = "warn" +SKIP = "skip" + +# Default sshd_config location. Probed read-only; missing file -> skip. +_SSHD_CONFIG = Path("/etc/ssh/sshd_config") + +# Short timeout for every read-only status probe. A hung firewall/VPN +# binary must never wedge the survey. +_PROBE_TIMEOUT = 4.0 + + +@dataclass +class HardeningSignal: + """Result of a single host-hardening probe. + + ``status`` is one of pass/fail/warn/skip. ``detail`` is a one-line + human state description. ``fix`` is a one-line remediation hint + (shell command or doc link), ``None`` when the signal passes or is + skipped with nothing to do. + """ + + name: str + status: str + detail: str + fix: Optional[str] = None + + def to_dict(self) -> Dict[str, Optional[str]]: + return { + "name": self.name, + "status": self.status, + "detail": self.detail, + "fix": self.fix, + } + + +# --------------------------------------------------------------------------- +# Subprocess helper — read-only, short-timeout, never raises. +# --------------------------------------------------------------------------- + + +def _run(cmd: List[str]) -> Tuple[Optional[int], str, str]: + """Run a read-only probe command. + + Returns ``(returncode, stdout, stderr)``. ``returncode`` is ``None`` + when the binary is missing, times out, or otherwise can't be spawned + — callers treat that as "skip", never "fail". + """ + if shutil.which(cmd[0]) is None: + return None, "", "" + try: + proc = subprocess.run( + cmd, + capture_output=True, + text=True, + timeout=_PROBE_TIMEOUT, + check=False, + ) + except (OSError, subprocess.SubprocessError): + return None, "", "" + return proc.returncode, proc.stdout or "", proc.stderr or "" + + +def _is_linux() -> bool: + return platform.system() == "Linux" + + +# --------------------------------------------------------------------------- +# Signal 1: Tailscale (mesh VPN) — cross-platform. +# --------------------------------------------------------------------------- + + +def _signal_tailscale() -> HardeningSignal: + name = "tailscale" + rc, out, _ = _run(["tailscale", "status", "--json"]) + if rc is None: + return HardeningSignal( + name, SKIP, "tailscale not installed", + "install Tailscale to put the host on a private mesh: " + "https://tailscale.com/download", + ) + import json + try: + data = json.loads(out) + except (ValueError, TypeError): + return HardeningSignal( + name, WARN, "tailscale status returned non-JSON output", + "check `tailscale status --json` manually", + ) + backend = (data or {}).get("BackendState") + if backend == "Running": + return HardeningSignal(name, PASS, "BackendState=Running") + return HardeningSignal( + name, FAIL, f"BackendState={backend or 'unknown'}", + "run `tailscale up` to connect the mesh", + ) + + +# --------------------------------------------------------------------------- +# Signal 2: UFW (Linux firewall front-end). +# --------------------------------------------------------------------------- + + +def _signal_ufw() -> HardeningSignal: + name = "ufw" + if not _is_linux(): + return HardeningSignal(name, SKIP, "ufw is Linux-only", None) + rc, out, _ = _run(["ufw", "status", "verbose"]) + if rc is None: + return HardeningSignal( + name, SKIP, "ufw not installed", + "apt install ufw && ufw default deny incoming && ufw enable", + ) + if "Status: active" not in out: + return HardeningSignal( + name, FAIL, "ufw installed but inactive", + "ufw default deny incoming && ufw enable", + ) + # Parse the default-policy line, e.g.: + # "Default: deny (incoming), allow (outgoing), disabled (routed)" + m = re.search(r"Default:\s*(.+)", out) + policy = m.group(1).strip() if m else "active" + if m and "deny (incoming)" not in policy and "reject (incoming)" not in policy: + return HardeningSignal( + name, WARN, f"active but default incoming not deny ({policy})", + "ufw default deny incoming", + ) + return HardeningSignal(name, PASS, f"active ({policy})") + + +# --------------------------------------------------------------------------- +# Signal 3: firewalld (Linux firewall daemon). +# --------------------------------------------------------------------------- + + +def _signal_firewalld() -> HardeningSignal: + name = "firewalld" + if not _is_linux(): + return HardeningSignal(name, SKIP, "firewalld is Linux-only", None) + rc, out, _ = _run(["firewall-cmd", "--state"]) + if rc is None: + return HardeningSignal( + name, SKIP, "firewalld not installed", + "install firewalld or use ufw/nftables instead", + ) + # Exact-match against `firewall-cmd --state` output: + # running → daemon is running + # not running → daemon is stopped (exit code 252) + # Substring `"running" in "not running".lower()` would falsely report + # PASS when the daemon is stopped — the worst possible failure mode for + # a hardening probe. Caught by Cursor Bugbot review (high severity). + state = out.strip().lower() + if state == "running": + return HardeningSignal(name, PASS, "running") + return HardeningSignal( + name, FAIL, f"not running ({state or 'unknown'})", + "systemctl enable --now firewalld", + ) + + +# --------------------------------------------------------------------------- +# Signal 4: nftables (Linux packet filter). +# --------------------------------------------------------------------------- + + +def _signal_nftables() -> HardeningSignal: + name = "nftables" + if not _is_linux(): + return HardeningSignal(name, SKIP, "nftables is Linux-only", None) + rc, out, _ = _run(["nft", "list", "ruleset"]) + if rc is None: + return HardeningSignal( + name, SKIP, "nft not installed", + "apt install nftables and load a ruleset", + ) + if rc != 0: + return HardeningSignal( + name, WARN, "nft present but ruleset query failed (need root?)", + "run `sudo nft list ruleset` to inspect", + ) + if out.strip(): + return HardeningSignal(name, PASS, "non-empty ruleset loaded") + return HardeningSignal( + name, FAIL, "empty ruleset", + "load an nftables ruleset (e.g. /etc/nftables.conf)", + ) + + +# --------------------------------------------------------------------------- +# Signal 5: fail2ban (SSH brute-force mitigation). +# --------------------------------------------------------------------------- + + +def _signal_fail2ban() -> HardeningSignal: + name = "fail2ban" + if not _is_linux(): + return HardeningSignal(name, SKIP, "fail2ban is Linux-only", None) + rc, out, _ = _run(["fail2ban-client", "status"]) + if rc is None: + return HardeningSignal( + name, SKIP, "fail2ban not installed", + "apt install fail2ban && systemctl enable --now fail2ban", + ) + if rc != 0: + return HardeningSignal( + name, FAIL, "fail2ban-client could not reach the server", + "systemctl enable --now fail2ban", + ) + # "Jail list:\tsshd, ..." — non-empty after the colon means at least + # one jail is configured. + m = re.search(r"Jail list:\s*(.*)", out) + jails = (m.group(1).strip() if m else "") + if jails: + return HardeningSignal(name, PASS, f"jails: {jails}") + return HardeningSignal( + name, FAIL, "running but no jails configured", + "enable the sshd jail in /etc/fail2ban/jail.local", + ) + + +# --------------------------------------------------------------------------- +# Signals 6 & 7: SSH config (password auth + root login). +# --------------------------------------------------------------------------- + + +def _read_sshd_config() -> Optional[str]: + """Return sshd_config text, or None when it's absent/unreadable. + + A missing file maps to ``skip`` (not ``fail``) in the callers: many + hosts (macOS dev boxes, minimal containers) simply don't run sshd. + """ + try: + if not _SSHD_CONFIG.exists(): + return None + return _SSHD_CONFIG.read_text(encoding="utf-8", errors="replace") + except OSError: + return None + + +def _signal_ssh_password_auth() -> HardeningSignal: + name = "ssh-password-auth" + text = _read_sshd_config() + if text is None: + return HardeningSignal( + name, SKIP, f"{_SSHD_CONFIG} not present/readable", None + ) + # Last effective directive wins in sshd_config; scan all matches. + matches = re.findall( + r"(?im)^\s*PasswordAuthentication\s+(\S+)", text + ) + if not matches: + return HardeningSignal( + name, WARN, + "PasswordAuthentication not set (sshd default is 'yes')", + "set `PasswordAuthentication no` in /etc/ssh/sshd_config", + ) + value = matches[-1].lower() + if value == "no": + return HardeningSignal(name, PASS, "PasswordAuthentication no") + return HardeningSignal( + name, FAIL, f"PasswordAuthentication {value}", + "set `PasswordAuthentication no` and use key-based auth", + ) + + +def _signal_ssh_root_login() -> HardeningSignal: + name = "ssh-root-login" + text = _read_sshd_config() + if text is None: + return HardeningSignal( + name, SKIP, f"{_SSHD_CONFIG} not present/readable", None + ) + matches = re.findall( + r"(?im)^\s*PermitRootLogin\s+(\S+)", text + ) + if not matches: + return HardeningSignal( + name, WARN, + "PermitRootLogin not set (sshd default is 'prohibit-password')", + "set `PermitRootLogin no` in /etc/ssh/sshd_config", + ) + value = matches[-1].lower() + if value in ("no", "prohibit-password"): + return HardeningSignal(name, PASS, f"PermitRootLogin {value}") + return HardeningSignal( + name, FAIL, f"PermitRootLogin {value}", + "set `PermitRootLogin no` (or prohibit-password)", + ) + + +# --------------------------------------------------------------------------- +# Signals 8 & 9: iron-proxy runtime (reuse get_status — the seam to the +# sandbox-egress layer this command complements). +# --------------------------------------------------------------------------- + + +def _signal_iron_proxy_enabled() -> HardeningSignal: + name = "iron-proxy-enabled" + try: + status = get_status() + except Exception: # noqa: BLE001 -- status probe must never crash the survey + return HardeningSignal( + name, SKIP, "could not read iron-proxy status", None + ) + if status.enabled or status.configured: + # get_status() leaves .enabled at its dataclass default (it doesn't + # read config.yaml); treat a generated config + CA as "enabled" so + # this signal works without a full config load. An explicit + # enabled=True (future callers) also passes. + return HardeningSignal( + name, PASS, "iron-proxy configured (CA + proxy.yaml present)" + ) + return HardeningSignal( + name, FAIL, "iron-proxy not configured", + "run `hermes egress setup` to enable sandbox-egress isolation", + ) + + +def _signal_iron_proxy_running() -> HardeningSignal: + name = "iron-proxy-running" + try: + status = get_status() + except Exception: # noqa: BLE001 + return HardeningSignal( + name, SKIP, "could not read iron-proxy status", None + ) + if status.pid and status.listening: + return HardeningSignal( + name, PASS, f"daemon pid={status.pid} listening on " + f"127.0.0.1:{status.tunnel_port}" + ) + if status.pid and not status.listening: + return HardeningSignal( + name, WARN, f"daemon pid={status.pid} alive but not listening", + "check `hermes egress doctor --check listening`", + ) + return HardeningSignal( + name, FAIL, "iron-proxy daemon not running", + "run `hermes egress start`", + ) + + +# --------------------------------------------------------------------------- +# Signal 10: Docker seccomp. +# --------------------------------------------------------------------------- + + +def _signal_docker_seccomp() -> HardeningSignal: + name = "docker-seccomp" + rc, out, _ = _run(["docker", "info", "--format", "{{.SecurityOptions}}"]) + if rc is None: + return HardeningSignal( + name, SKIP, "docker not installed", + "install Docker if you run sandboxes via the Docker backend", + ) + if rc != 0: + return HardeningSignal( + name, WARN, "docker present but `docker info` failed (daemon down?)", + "start the Docker daemon and re-run", + ) + if "seccomp" in out.lower(): + return HardeningSignal(name, PASS, "seccomp profile active") + return HardeningSignal( + name, FAIL, "seccomp not listed in SecurityOptions", + "ensure the Docker default seccomp profile is not disabled", + ) + + +# --------------------------------------------------------------------------- +# Probe registry — order is the report order. +# --------------------------------------------------------------------------- + +_PROBES: Tuple[Tuple[str, Callable[[], HardeningSignal]], ...] = ( + ("tailscale", _signal_tailscale), + ("ufw", _signal_ufw), + ("firewalld", _signal_firewalld), + ("nftables", _signal_nftables), + ("fail2ban", _signal_fail2ban), + ("ssh-password-auth", _signal_ssh_password_auth), + ("ssh-root-login", _signal_ssh_root_login), + ("iron-proxy-enabled", _signal_iron_proxy_enabled), + ("iron-proxy-running", _signal_iron_proxy_running), + ("docker-seccomp", _signal_docker_seccomp), +) + +SIGNAL_NAMES: Tuple[str, ...] = tuple(n for n, _ in _PROBES) + + +# --------------------------------------------------------------------------- +# Baselines — which signals must PASS for the baseline to be "satisfied". +# +# A baseline never *forces* a run to fail; it only colors the summary +# line. Each entry is a list of requirement groups: a group is satisfied +# when ANY of its member signals passes (OR), and the baseline is +# satisfied when EVERY group is satisfied (AND). This lets "any firewall" +# be one OR-group while "ssh password auth disabled" is a hard single. +# --------------------------------------------------------------------------- + +# Each requirement is a tuple of signal-names; the requirement is met when +# at least one of them is PASS. +_Requirement = Tuple[str, ...] + +BASELINES: Dict[str, List[_Requirement]] = { + # Solo dev / single machine: any firewall (or Tailscale as the + # perimeter) + SSH password auth off + egress proxy enabled. + "minimal": [ + ("ufw", "firewalld", "nftables", "tailscale"), + ("ssh-password-auth",), + ("iron-proxy-enabled",), + ], + # @catalinmpit's Hetzner shape: mesh VPN + UFW + fail2ban + SSH + # password auth off + egress proxy enabled. + "catalin": [ + ("tailscale",), + ("ufw",), + ("fail2ban",), + ("ssh-password-auth",), + ("iron-proxy-enabled",), + ], + # Prod / multi-tenant: every signal passes. + "paranoid": [(n,) for n in SIGNAL_NAMES], +} + +BASELINE_NAMES: Tuple[str, ...] = tuple(BASELINES.keys()) + + +def _missing_requirements( + signals: List[HardeningSignal], baseline: str +) -> List[str]: + """Return the signal-names that keep ``baseline`` from being satisfied. + + For an OR-group where none passed, every member name is reported (so + the operator sees the full set of acceptable fixes). For a single + requirement, that one name is reported. + """ + by_name = {s.name: s.status for s in signals} + missing: List[str] = [] + for group in BASELINES.get(baseline, []): + if any(by_name.get(n) == PASS for n in group): + continue + missing.extend(group) + return missing + + +# --------------------------------------------------------------------------- +# Public API +# --------------------------------------------------------------------------- + + +def survey_host(*, baseline: str = "minimal") -> List[HardeningSignal]: + """Probe the host for all hardening signals. + + ``baseline`` is accepted for API symmetry / forward-compat but does + not change which signals are probed — every signal is always + surveyed. Use :func:`baseline_status` (or the ``--baseline`` CLI + flag) to evaluate a probed signal list against a named baseline. + """ + if baseline not in BASELINES: + raise ValueError( + f"unknown baseline {baseline!r}; " + f"choose one of {', '.join(BASELINE_NAMES)}" + ) + return [probe() for _, probe in _PROBES] + + +def baseline_status( + signals: List[HardeningSignal], baseline: str +) -> Tuple[bool, List[str]]: + """Evaluate probed ``signals`` against a named ``baseline``. + + Returns ``(satisfied, missing)`` where ``missing`` is the list of + signal-names that would need to pass for the baseline to be met. + """ + if baseline not in BASELINES: + raise ValueError( + f"unknown baseline {baseline!r}; " + f"choose one of {', '.join(BASELINE_NAMES)}" + ) + missing = _missing_requirements(signals, baseline) + return (not missing), missing + + +__all__ = [ + "HardeningSignal", + "survey_host", + "baseline_status", + "BASELINES", + "BASELINE_NAMES", + "SIGNAL_NAMES", + "PASS", + "FAIL", + "WARN", + "SKIP", +] diff --git a/agent/proxy_sources/iron_proxy.py b/agent/proxy_sources/iron_proxy.py new file mode 100644 index 0000000000000..b9c54428e9978 --- /dev/null +++ b/agent/proxy_sources/iron_proxy.py @@ -0,0 +1,2019 @@ +"""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 from behind the proxy. + +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``, structured audit log at + ``/proxy/audit.log``. + +* 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" + +# 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) + + 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 _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: + res = subprocess.run( # noqa: S603 — binary path is trusted + [str(binary), "--version"], + capture_output=True, + text=True, + timeout=_RUN_TIMEOUT, + ) + 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 list of host:port pairs the proxy should bind on. + + Always binds loopback (``127.0.0.1``) so host-side test tooling can hit + the proxy directly. On Linux we also bind the docker bridge gateway + (``172.17.0.1`` by default) so containers can reach the proxy via + ``host.docker.internal:host-gateway``. We do NOT 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. + + On macOS / Windows Docker Desktop the bridge gateway is managed by + Desktop itself and ``host.docker.internal`` resolves via VPNkit, so + a single loopback bind is enough. + """ + + binds = [f"127.0.0.1:{tunnel_port}"] + if platform.system() == "Linux": + bridge_ip = _detect_docker_bridge_ip() + if bridge_ip and bridge_ip != "127.0.0.1": + binds.append(f"{bridge_ip}:{tunnel_port}") + return binds + + +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: by default we bind loopback (``127.0.0.1``) plus the + docker bridge gateway IP on Linux (``172.17.0.1`` or whatever + ``docker0`` resolves to). Sandboxes use ``host.docker.internal`` which + Linux Docker maps to the bridge gateway via ``--add-host``; macOS / + Windows Docker Desktop manage their own gateway. 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, + }, + "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 address. iron-proxy v0.39 takes a single string at + # ``proxy.http_listen`` — there is no plural ``http_listens`` field, + # 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. We bind loopback by default; the + # docker bridge listener is added as a second binary invocation if + # we ever need multi-bind, but a single bind is what v0.39 supports. + 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}" + + 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": { + # http_listen is the HTTP-proxy listener that handles both plain + # HTTP forwards AND CONNECT tunnels for HTTPS. Sandboxes set + # `HTTPS_PROXY=http://host:tunnel_port` and the same listener + # serves both protocols. We bind loopback by default — NOT + # 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. + "http_listen": primary_listen, + # The HTTPS-listener (direct TLS termination, no CONNECT) and + # the SOCKS5/CONNECT-only tunnel listener get loopback ephemeral + # ports — we don't expose them. + "https_listen": "127.0.0.1:0", + "tunnel_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. The daemon still emits metrics for any local + # scraper that knows where to look (via ``hermes egress + # status``); we just don't expose them to the public default. + "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``. Without this, + iron-proxy creates the file under the default umask the first time it + writes — meaning every host-side request history is potentially + world-readable. We pre-create the file empty with 0o600 so the daemon + inherits the tight permissions. + + Raises :class:`RuntimeError` on any OSError (planted symlink, + immutable parent dir, full disk). The previous version of this + function logged a warning and returned silently — that's the bad + option, because the docstring's promise of "iron-proxy inherits + tight permissions" depends on us actually creating the file. When + the create fails, the caller MUST know about it so the wizard can + surface a clear error instead of printing "✓ audit log: ..." for a + file the daemon will create under the default umask. + """ + + 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, + 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=True) + 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. + tunnel_port = _read_tunnel_port_from_config() or _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("127.0.0.1", 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 127.0.0.1:{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. + 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." + ) + # 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: + # Truly unrecoverable import failure: log + fall through to + # the env path so we don't completely block start. Callers + # that want strict mode can pre-check at the wizard layer. + logger.warning( + "Bitwarden refresh module unavailable at proxy start, " + "falling back to parent env: %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() + status.tunnel_port = _read_tunnel_port_from_config() or _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 + status.listening = _port_listening("127.0.0.1", status.tunnel_port) + + return status + + +def _read_tunnel_port_from_config() -> Optional[int]: + 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 + # The CLI/Docker side calls this "the tunnel port" because that's how + # sandboxes use it (HTTPS_PROXY), but on the iron-proxy side it's the + # http_listen — the HTTP-proxy listener handles both plain HTTP and the + # CONNECT method for HTTPS upstreams. + listen = ((data or {}).get("proxy") or {}).get("http_listen") or "" + if not isinstance(listen, str) or ":" not in listen: + return None + try: + return int(listen.rsplit(":", 1)[1]) + except ValueError: + return None + + +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/hermes_cli/config.py b/hermes_cli/config.py index c292ab9a05ec9..cf949ef0c15a6 100644 --- a/hermes_cli/config.py +++ b/hermes_cli/config.py @@ -1913,6 +1913,67 @@ def _ensure_hermes_home_managed(home: Path): "paste_collapse_threshold": 5, "paste_collapse_threshold_fallback": 0, + # ========================================================================= + # 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 from behind the proxy. + # + # 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": 24, diff --git a/hermes_cli/main.py b/hermes_cli/main.py index a4578b16d1ad9..b2dac239aed5c 100644 --- a/hermes_cli/main.py +++ b/hermes_cli/main.py @@ -10759,7 +10759,7 @@ def _build_provider_choices() -> list[str]: "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", "kanban", "login", "logout", "logs", "lsp", "mcp", "memory", "migrate", "model", "pairing", "plugins", "portal", "postinstall", "profile", "proxy", "send", "sessions", "setup", @@ -11186,6 +11186,37 @@ def _dispatch_secrets(args): # noqa: ANN001 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 # ========================================================================= @@ -13970,9 +14001,15 @@ def cmd_acp(args): 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 0000000000000..f850f74175bee --- /dev/null +++ b/hermes_cli/proxy_cli.py @@ -0,0 +1,817 @@ +"""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 agent.proxy_sources import host_hardening as hh +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) + + harden = sub.add_parser( + "harden", + help="Host-hardening survey: firewall, SSH, fail2ban, mesh-VPN, " + "Docker seccomp, + iron-proxy runtime (complements `doctor`).", + ) + harden.add_argument( + "--baseline", choices=hh.BASELINE_NAMES, default="minimal", + help="Baseline to evaluate against (default: minimal). Colors the " + "summary line only — never gates the exit code.", + ) + harden.add_argument( + "--json", action="store_true", dest="as_json", + help="Emit {signals[], baseline, satisfied, missing[]} as JSON.", + ) + harden.add_argument( + "--all", action="store_true", dest="show_all", + help="Show ALL signals including passing ones (default: hide " + "passing signals to focus on gaps).", + ) + harden.set_defaults(func=cmd_harden) + + 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 == 0: + console.print( + " [red]✗ --tunnel-port=0 is not a valid TCP port.[/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 so iron-proxy inherits private + # perms instead of letting the daemon create it under the default + # umask (potentially world-readable). Raises on failure (planted + # symlink, immutable parent, full disk) — the wizard must surface + # that rather than print "✓" for a file the daemon will create + # under a slacker umask. + try: + ip.ensure_audit_log(audit_log_path) + except RuntimeError as exc: + console.print(f" [red]✗ {exc}[/red]") + return 1 + + # 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}") + console.print(f" [green]✓[/green] audit log: {audit_log_path}") + + # ------------------------------------------------------------------ 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) + + 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")) + ) + # 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( + 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 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_harden(args: argparse.Namespace) -> int: + """Survey host-perimeter hardening and show how it layers with the + sandbox-egress proxy. Always exits 0 — informational, never gates.""" + console = Console() + baseline = getattr(args, "baseline", "minimal") + show_all = bool(getattr(args, "show_all", False)) + + signals = hh.survey_host(baseline=baseline) + satisfied, missing = hh.baseline_status(signals, baseline) + + if getattr(args, "as_json", False): + import json as _json + # Plain stdout (not rich) so the JSON is machine-parseable. + print(_json.dumps({ + "signals": [s.to_dict() for s in signals], + "baseline": baseline, + "satisfied": satisfied, + "missing": missing, + }, indent=2)) + # Informational command: always exit 0. + return 0 + + _glyph = { + hh.PASS: "[green]\u2713[/green]", + hh.WARN: "[yellow]\u26a0[/yellow]", + hh.FAIL: "[red]\u2717[/red]", + hh.SKIP: "[dim]\u2013[/dim]", + } + + # Default view hides passing signals to focus the operator on gaps. + shown = signals if show_all else [ + s for s in signals if s.status != hh.PASS + ] + + # Columns: glyph (pass/fail/warn/skip), signal name, human detail, fix. + # The detail column shows the actually-useful explanation ("ufw installed + # but inactive", "BackendState=Running") rather than re-stating the + # status text, which is already encoded in the glyph. Mirrors cmd_doctor. + # Caught by Cursor Bugbot review (medium severity). + table = Table(show_header=True, header_style="bold", box=None, + padding=(0, 1)) + table.add_column("", width=2) + table.add_column("Signal", style="cyan", no_wrap=True) + table.add_column("Detail") + table.add_column("Action") + for s in shown: + table.add_row( + _glyph.get(s.status, "?"), + s.name, + s.detail or "\u2014", + s.fix or "\u2014", + ) + if shown: + console.print(table) + elif not show_all: + console.print( + "[green]\u2713 all signals pass[/green] " + "[dim](pass --all to list them)[/dim]" + ) + + # Counts across the full set (not just the shown subset). + n_pass = sum(1 for s in signals if s.status == hh.PASS) + n_warn = sum(1 for s in signals if s.status == hh.WARN) + n_fail = sum(1 for s in signals if s.status == hh.FAIL) + n_skip = sum(1 for s in signals if s.status == hh.SKIP) + console.print() + console.print( + f"[bold]Signals[/bold] " + f"[green]{n_pass} pass[/green] " + f"[yellow]{n_warn} warn[/yellow] " + f"[red]{n_fail} fail[/red] " + f"[dim]{n_skip} skip[/dim]" + ) + + if satisfied: + console.print( + f"[green]\u2713 {baseline} baseline satisfied[/green]" + ) + else: + # De-dupe while preserving order for the summary line. + seen = [] + for n in missing: + if n not in seen: + seen.append(n) + console.print( + f"[red]\u2717 {baseline} baseline incomplete[/red] " + f"(missing: {', '.join(seen)})" + ) + + # Informational only — never gate the exit code on hardening gaps. + return 0 + + +# --------------------------------------------------------------------------- +# Audit log handlers +# --------------------------------------------------------------------------- + + +_AUDIT_TABLE_FIELDS = ("ts", "method", "upstream_host", "path", "status", + "sandbox_id") + + +def _resolve_since(console: Console, spec): + """Parse --since, printing a clear error and returning the sentinel + ``False`` on failure (None means 'no window').""" + if spec is None: + return None + try: + return ip.parse_since(spec) + except ValueError as exc: + console.print(f"[red]✗ invalid --since: {exc}[/red]") + return False + + +def _audit_row(ev: dict): + if ev.get("_unparsed"): + return None + return [str(ev.get(f, "")) for f in _AUDIT_TABLE_FIELDS] + + +def _print_audit_table(console: Console, events): + table = Table(show_header=True, header_style="bold", box=None, + padding=(0, 1)) + for f in _AUDIT_TABLE_FIELDS: + table.add_column(f, overflow="fold") + any_row = False + for ev in events: + row = _audit_row(ev) + if row is None: + # Show unparsed lines dimmed in the first column. + table.add_row(f"[dim]{ev.get('_raw','')[:80]}[/dim]", + *([""] * (len(_AUDIT_TABLE_FIELDS) - 1))) + else: + table.add_row(*row) + any_row = True + if any_row: + console.print(table) + else: + console.print("[dim](no audit events)[/dim]") + + +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/infographic/iron-proxy-egress/infographic.png b/infographic/iron-proxy-egress/infographic.png new file mode 100644 index 0000000000000..9a7ea5a8b467b Binary files /dev/null and b/infographic/iron-proxy-egress/infographic.png differ diff --git a/tests/test_iron_proxy.py b/tests/test_iron_proxy.py new file mode 100644 index 0000000000000..617f358325197 --- /dev/null +++ b/tests/test_iron_proxy.py @@ -0,0 +1,1457 @@ +"""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 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"] + # 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): + """``http_listen`` must NOT be ``0.0.0.0:PORT`` or ``:PORT`` (latter is + INADDR_ANY). Loopback only by default.""" + + 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 + ) + primary = cfg["proxy"]["http_listen"] + assert primary == "127.0.0.1:12345" + # Sentinel: confirm we didn't accidentally serialize a bare-port form + # like ":12345" (that's INADDR_ANY). + assert not primary.startswith(":") + assert "0.0.0.0" not in primary + # 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_loopback_on_linux(tmp_path, monkeypatch): + """When http_listen isn't passed AND we're on Linux, the singular + http_listen field is the loopback bind. iron-proxy v0.39 only + supports one bind per daemon process — earlier versions of this + code emitted a plural http_listens list with the docker bridge + appended, but v0.39 rejects that key so we keep loopback only. + Sandboxes still reach the daemon via host.docker.internal which + Docker maps to the host gateway, so loopback-only is functional.""" + + 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, + ) + # Primary http_listen is loopback. + assert cfg["proxy"]["http_listen"] == "127.0.0.1:9090" + # No http_listens (plural) — v0.39 rejects that key. + assert "http_listens" not in cfg["proxy"] + + +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() + + +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 surfaced + assert env["HERMES_PROXY_TOKEN_OPENROUTER_API_KEY"] == mapping.proxy_token + # Linux host-gateway mapping + assert host == ["--add-host", "host.docker.internal:host-gateway"] + + +# --------------------------------------------------------------------------- +# 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 diff --git a/tests/test_iron_proxy_cli.py b/tests/test_iron_proxy_cli.py new file mode 100644 index 0000000000000..e2a6411004891 --- /dev/null +++ b/tests/test_iron_proxy_cli.py @@ -0,0 +1,427 @@ +"""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 + + +# --------------------------------------------------------------------------- +# 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_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 diff --git a/tests/test_iron_proxy_e2e.py b/tests/test_iron_proxy_e2e.py new file mode 100644 index 0000000000000..9e0e47f44790a --- /dev/null +++ b/tests/test_iron_proxy_e2e.py @@ -0,0 +1,165 @@ +"""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=[], + ) + 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), so we use the + # proxy's tunnel for the CONNECT but talk plaintext to upstream via + # `--proxy-insecure` semantics: iron-proxy accepts HTTPS_PROXY-style + # CONNECT to any host on its allowlist. For a clean E2E we hit + # http://127.0.0.1:/ which goes through the proxy as a plain + # HTTP forward (no MITM needed) and the secrets transform still fires + # on the Authorization header. + result = subprocess.run( + [ + "curl", + "--silent", + "--max-time", "10", + "-x", f"http://127.0.0.1:{tunnel_port}", + "-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/test_iron_proxy_harden.py b/tests/test_iron_proxy_harden.py new file mode 100644 index 0000000000000..c7b9153651fee --- /dev/null +++ b/tests/test_iron_proxy_harden.py @@ -0,0 +1,387 @@ +"""Hermetic tests for ``hermes egress harden`` (host_hardening.survey_host). + +Each signal is exercised with monkeypatched subprocess / filesystem so we +never touch the network, a real firewall, or a real iron-proxy daemon. +Mirrors the style of ``tests/test_iron_proxy_doctor.py``. +""" + +from __future__ import annotations + +import json +from dataclasses import dataclass + +import pytest + +from agent.proxy_sources import host_hardening as hh +from agent.proxy_sources import iron_proxy as ip + + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + + +@dataclass +class _FakeProc: + returncode: int + stdout: str = "" + stderr: str = "" + + +def _patch_run(monkeypatch, table): + """Patch hh._run with a dispatch table keyed on the first arg (binary). + + ``table`` maps binary name -> (returncode, stdout, stderr). A binary + absent from the table behaves as "not installed" (returncode None). + """ + def fake_run(cmd): + entry = table.get(cmd[0]) + if entry is None: + return None, "", "" + return entry + monkeypatch.setattr(hh, "_run", fake_run) + + +def _force_linux(monkeypatch): + monkeypatch.setattr(hh, "_is_linux", lambda: True) + + +def _signal(signals, name): + return next(s for s in signals if s.name == name) + + +@pytest.fixture +def no_iron_proxy(monkeypatch): + """Default: iron-proxy not configured / not running so the proxy + signals don't accidentally pass from the host's real state.""" + monkeypatch.setattr( + hh, "get_status", + lambda: ip.ProxyStatus(enabled=False, pid=None, listening=False), + ) + + +# --------------------------------------------------------------------------- +# PASS path, one per signal (10 tests) +# --------------------------------------------------------------------------- + + +def test_tailscale_pass(monkeypatch, no_iron_proxy): + _patch_run(monkeypatch, { + "tailscale": (0, json.dumps({"BackendState": "Running"}), ""), + }) + s = _signal(hh.survey_host(), "tailscale") + assert s.status == hh.PASS + + +def test_ufw_pass(monkeypatch, no_iron_proxy): + _force_linux(monkeypatch) + _patch_run(monkeypatch, { + "ufw": (0, "Status: active\nDefault: deny (incoming), allow (outgoing)", ""), + }) + s = _signal(hh.survey_host(), "ufw") + assert s.status == hh.PASS + assert "deny (incoming)" in s.detail + + +def test_firewalld_pass(monkeypatch, no_iron_proxy): + _force_linux(monkeypatch) + _patch_run(monkeypatch, {"firewall-cmd": (0, "running\n", "")}) + s = _signal(hh.survey_host(), "firewalld") + assert s.status == hh.PASS + + +def test_firewalld_stopped_is_fail_not_pass(monkeypatch, no_iron_proxy): + """`firewall-cmd --state` outputs 'not running' (exit 252) when stopped. + + Regression: a substring match `'running' in 'not running'.lower()` would + falsely report PASS — the worst-possible failure mode for a hardening + probe (firewall reported up when it's actually down). Caught by Cursor + Bugbot on the retargeted PR. We now require exact equality on stripped + output. + """ + _force_linux(monkeypatch) + # firewalld returns exit 252 + 'not running' on stdout when stopped. + _patch_run(monkeypatch, {"firewall-cmd": (252, "not running\n", "")}) + s = _signal(hh.survey_host(), "firewalld") + assert s.status == hh.FAIL, ( + f"firewalld stopped must report FAIL, got {s.status} (detail={s.detail!r})" + ) + assert "not running" in s.detail + + +def test_nftables_pass(monkeypatch, no_iron_proxy): + _force_linux(monkeypatch) + _patch_run(monkeypatch, { + "nft": (0, "table inet filter {\n chain input { type filter }\n}", ""), + }) + s = _signal(hh.survey_host(), "nftables") + assert s.status == hh.PASS + + +def test_fail2ban_pass(monkeypatch, no_iron_proxy): + _force_linux(monkeypatch) + _patch_run(monkeypatch, { + "fail2ban-client": (0, "Status\n|- Number of jail:\t1\n`- Jail list:\tsshd", ""), + }) + s = _signal(hh.survey_host(), "fail2ban") + assert s.status == hh.PASS + assert "sshd" in s.detail + + +def test_ssh_password_auth_pass(monkeypatch, no_iron_proxy): + monkeypatch.setattr(hh, "_read_sshd_config", + lambda: "PasswordAuthentication no\n") + s = _signal(hh.survey_host(), "ssh-password-auth") + assert s.status == hh.PASS + + +def test_ssh_root_login_pass(monkeypatch, no_iron_proxy): + monkeypatch.setattr(hh, "_read_sshd_config", + lambda: "PermitRootLogin prohibit-password\n") + s = _signal(hh.survey_host(), "ssh-root-login") + assert s.status == hh.PASS + + +def test_iron_proxy_enabled_pass(monkeypatch): + monkeypatch.setattr( + hh, "get_status", + lambda: ip.ProxyStatus(enabled=True, pid=4242, listening=True), + ) + s = _signal(hh.survey_host(), "iron-proxy-enabled") + assert s.status == hh.PASS + + +def test_iron_proxy_running_pass(monkeypatch): + monkeypatch.setattr( + hh, "get_status", + lambda: ip.ProxyStatus(enabled=True, pid=4242, listening=True, + tunnel_port=9090), + ) + s = _signal(hh.survey_host(), "iron-proxy-running") + assert s.status == hh.PASS + assert "4242" in s.detail + + +def test_docker_seccomp_pass(monkeypatch, no_iron_proxy): + _patch_run(monkeypatch, { + "docker": (0, "[name=seccomp,profile=builtin name=cgroupns]", ""), + }) + s = _signal(hh.survey_host(), "docker-seccomp") + assert s.status == hh.PASS + + +# --------------------------------------------------------------------------- +# SKIP path: binary missing (parametrized over binary-dependent signals) +# --------------------------------------------------------------------------- + + +@pytest.mark.parametrize("signal_name", [ + "tailscale", "ufw", "firewalld", "nftables", "fail2ban", "docker-seccomp", +]) +def test_binary_missing_skips(monkeypatch, no_iron_proxy, signal_name): + _force_linux(monkeypatch) + # Empty dispatch table -> every binary reads as "not installed". + _patch_run(monkeypatch, {}) + s = _signal(hh.survey_host(), signal_name) + assert s.status == hh.SKIP + + +# --------------------------------------------------------------------------- +# Baseline evaluation +# --------------------------------------------------------------------------- + + +def _wire_minimal_pass(monkeypatch): + """Host with ufw + ssh-no-password + iron-proxy enabled.""" + _force_linux(monkeypatch) + _patch_run(monkeypatch, { + "ufw": (0, "Status: active\nDefault: deny (incoming), allow (outgoing)", ""), + }) + monkeypatch.setattr(hh, "_read_sshd_config", + lambda: "PasswordAuthentication no\n") + monkeypatch.setattr( + hh, "get_status", + lambda: ip.ProxyStatus(enabled=True, pid=4242, listening=True), + ) + + +def test_minimal_baseline_satisfied(monkeypatch): + _wire_minimal_pass(monkeypatch) + signals = hh.survey_host(baseline="minimal") + satisfied, missing = hh.baseline_status(signals, "minimal") + assert satisfied is True + assert missing == [] + + +def test_catalin_baseline_missing_fail2ban(monkeypatch): + # Tailscale + ufw + ssh-no-password + iron-proxy, but NO fail2ban. + _force_linux(monkeypatch) + _patch_run(monkeypatch, { + "tailscale": (0, json.dumps({"BackendState": "Running"}), ""), + "ufw": (0, "Status: active\nDefault: deny (incoming)", ""), + # fail2ban-client absent -> skip (not pass) + }) + monkeypatch.setattr(hh, "_read_sshd_config", + lambda: "PasswordAuthentication no\n") + monkeypatch.setattr( + hh, "get_status", + lambda: ip.ProxyStatus(enabled=True, pid=4242, listening=True), + ) + signals = hh.survey_host(baseline="catalin") + satisfied, missing = hh.baseline_status(signals, "catalin") + assert satisfied is False + assert "fail2ban" in missing + + +def test_paranoid_baseline_requires_all(monkeypatch): + # Wire everything to pass. + _force_linux(monkeypatch) + _patch_run(monkeypatch, { + "tailscale": (0, json.dumps({"BackendState": "Running"}), ""), + "ufw": (0, "Status: active\nDefault: deny (incoming)", ""), + "firewall-cmd": (0, "running\n", ""), + "nft": (0, "table inet filter { chain c {} }", ""), + "fail2ban-client": (0, "Jail list:\tsshd", ""), + "docker": (0, "[name=seccomp]", ""), + }) + monkeypatch.setattr( + hh, "_read_sshd_config", + lambda: "PasswordAuthentication no\nPermitRootLogin no\n", + ) + monkeypatch.setattr( + hh, "get_status", + lambda: ip.ProxyStatus(enabled=True, pid=4242, listening=True), + ) + signals = hh.survey_host(baseline="paranoid") + satisfied, missing = hh.baseline_status(signals, "paranoid") + assert satisfied is True + assert missing == [] + # Sanity: every signal name is in the paranoid requirement set. + assert {n for n, in (g for g in hh.BASELINES["paranoid"])} == set(hh.SIGNAL_NAMES) + + +def test_paranoid_baseline_incomplete_when_one_skips(monkeypatch): + # Same as minimal-pass but missing most binaries -> paranoid fails. + _wire_minimal_pass(monkeypatch) + signals = hh.survey_host(baseline="paranoid") + satisfied, missing = hh.baseline_status(signals, "paranoid") + assert satisfied is False + # firewalld / nftables / fail2ban / docker not wired -> in missing. + assert "fail2ban" in missing + + +# --------------------------------------------------------------------------- +# JSON schema + --all behavior (via the CLI handler) +# --------------------------------------------------------------------------- + + +def test_json_schema(monkeypatch, capsys): + import argparse + from hermes_cli import proxy_cli + + _wire_minimal_pass(monkeypatch) + ns = argparse.Namespace(baseline="minimal", as_json=True, show_all=False) + rc = proxy_cli.cmd_harden(ns) + assert rc == 0 + out = json.loads(capsys.readouterr().out) + assert set(out.keys()) == {"signals", "baseline", "satisfied", "missing"} + assert out["baseline"] == "minimal" + assert isinstance(out["satisfied"], bool) + assert isinstance(out["missing"], list) + for sig in out["signals"]: + assert set(sig.keys()) == {"name", "status", "detail", "fix"} + + +def test_all_shows_passing_signals(monkeypatch, capsys): + import argparse + from hermes_cli import proxy_cli + + _wire_minimal_pass(monkeypatch) + # default view: passing signals hidden + proxy_cli.cmd_harden(argparse.Namespace( + baseline="minimal", as_json=False, show_all=False)) + default_out = capsys.readouterr().out + # --all view: passing signals shown + proxy_cli.cmd_harden(argparse.Namespace( + baseline="minimal", as_json=False, show_all=True)) + all_out = capsys.readouterr().out + # "ssh-password-auth" passes and its name appears in no other signal's + # fix text, so it's a clean marker: present under --all, absent in the + # default (gaps-only) view. + assert "ssh-password-auth" in all_out + assert "ssh-password-auth" not in default_out + + +def test_cmd_harden_table_shows_detail_not_status(capsys, monkeypatch): + """Regression: the table's third column must render `s.detail`, not `s.status`. + + The status text ("fail"/"warn"/"skip") is already encoded in the glyph + column; repeating it as text wastes the most-informative column. The + useful content is `detail` (e.g. "ufw installed but inactive"). Caught + by Cursor Bugbot on the retargeted PR. Mirrors `cmd_doctor`. + """ + import argparse + from hermes_cli import proxy_cli + + # Build a deterministic survey: one fail, one warn, one pass. + fake_signals = [ + hh.HardeningSignal("ufw", hh.FAIL, "ufw installed but inactive", + "ufw enable"), + hh.HardeningSignal("fail2ban", hh.WARN, "installed, no jails configured", + "fail2ban-client add jail sshd"), + hh.HardeningSignal("tailscale", hh.PASS, "BackendState=Running", None), + ] + monkeypatch.setattr(hh, "survey_host", lambda *a, **kw: fake_signals) + + rc = proxy_cli.cmd_harden(argparse.Namespace( + baseline="minimal", as_json=False, show_all=True)) + assert rc == 0 + + out = capsys.readouterr().out + # The DETAIL strings must appear in the table body. + assert "ufw installed but inactive" in out, out + assert "installed, no jails configured" in out, out + assert "BackendState=Running" in out, out + # The third column header is "Detail" — the glyph already shows status. + assert "Detail" in out, out + + +# --------------------------------------------------------------------------- +# Platform + missing-file graceful degradation +# --------------------------------------------------------------------------- + + +def test_non_linux_skips_linux_signals(monkeypatch, no_iron_proxy): + monkeypatch.setattr(hh, "_is_linux", lambda: False) + monkeypatch.setattr(hh.platform, "system", lambda: "Darwin") + # Tailscale + docker still probe-able on macOS; wire tailscale to pass. + _patch_run(monkeypatch, { + "tailscale": (0, json.dumps({"BackendState": "Running"}), ""), + }) + # sshd config absent on this mock box. + monkeypatch.setattr(hh, "_read_sshd_config", lambda: None) + signals = hh.survey_host() + by_name = {s.name: s.status for s in signals} + # Linux-specific firewall + fail2ban signals skip cleanly. + for n in ("ufw", "firewalld", "nftables", "fail2ban"): + assert by_name[n] == hh.SKIP + # macOS-friendly signals still run. + assert by_name["tailscale"] == hh.PASS + assert by_name["iron-proxy-enabled"] in (hh.PASS, hh.FAIL) + + +def test_missing_sshd_config_skips_both_ssh_signals(monkeypatch, no_iron_proxy): + monkeypatch.setattr(hh, "_read_sshd_config", lambda: None) + signals = hh.survey_host() + assert _signal(signals, "ssh-password-auth").status == hh.SKIP + assert _signal(signals, "ssh-root-login").status == hh.SKIP + + +# --------------------------------------------------------------------------- +# survey_host rejects unknown baselines +# --------------------------------------------------------------------------- + + +def test_survey_host_rejects_unknown_baseline(no_iron_proxy): + with pytest.raises(ValueError): + hh.survey_host(baseline="bogus") diff --git a/tools/environments/docker.py b/tools/environments/docker.py index ed53cd07c4187..67a18943b9550 100644 --- a/tools/environments/docker.py +++ b/tools/environments/docker.py @@ -180,6 +180,158 @@ def find_docker() -> Optional[str]: ] +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"] + + proxy_url = f"http://host.docker.internal:{status.tunnel_port}" + 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": proxy_url, + "http_proxy": proxy_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. The sandbox can swap these into + # its provider config (or its env, if it reads the standard names) and the + # proxy translates them to the real secrets on egress. + for m in mappings: + 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 _build_security_args(run_as_host_user: bool) -> list[str]: """Return the security/cap/tmpfs args tailored to the privilege mode.""" if run_as_host_user: @@ -453,11 +605,155 @@ def __init__( 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() + ) + 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", "") + # De-dup: only add if not already present (the operator may + # have set the same flag themselves). + if _egress_node_append.strip() not in existing_node.split(): + if existing_node.strip(): + merged_env["NODE_OPTIONS"] = ( + f"{existing_node} {_egress_node_append}".strip() + ) + else: + merged_env["NODE_OPTIONS"] = _egress_node_append + 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 @@ -494,6 +790,7 @@ def __init__( + user_args + writable_args + resource_args + + egress_host_args + volume_args + env_args + validated_extra diff --git a/website/docs/developer-guide/egress-internals.md b/website/docs/developer-guide/egress-internals.md new file mode 100644 index 0000000000000..6e274af2823e9 --- /dev/null +++ b/website/docs/developer-guide/egress-internals.md @@ -0,0 +1,305 @@ +--- +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("127.0.0.1", tunnel_port): break + 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 loopback (and, on Linux, the docker bridge IP as a *second list entry the rendered yaml currently discards* — see below). 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:** the binary's `config.Proxy` struct has only a singular `http_listen` string field — there is no `http_listens` (plural) list, despite earlier comments in this module claiming otherwise. `build_proxy_config` emits only the first entry of `_default_http_listen`'s result; the second-bind path is dead code today. When the pinned `_IRON_PROXY_VERSION` is bumped to one that supports the plural form, re-enable the list-emit in `build_proxy_config` and the docker-bridge bind becomes live without further changes. + +Regression: `test_default_bind_is_loopback_not_zero_zero` (asserts loopback bind AND that `http_listens` is NOT in the rendered yaml), `test_default_bind_uses_loopback_on_linux`, `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`. Swallowing the failure would let the daemon create the file under the default umask, defeating the privacy promise. `cmd_setup` catches the RuntimeError and surfaces a clear error to the operator. + +**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](../user-guide/egress/iron-proxy.md) +- Docker backend internals: [Docker](../user-guide/docker.md) +- Bitwarden Secrets Manager integration: [`hermes secrets bitwarden`](../user-guide/secrets/bitwarden.md) +- CLI command reference: [`hermes egress`](../reference/cli-commands.md#hermes-egress) +- Sandbox-injected environment variables: [Egress proxy (sandbox-injected)](../reference/environment-variables.md#egress-proxy-sandbox-injected) diff --git a/website/docs/getting-started/quickstart.md b/website/docs/getting-started/quickstart.md index 2a1cf2dd1ae3b..a1fb3c2684e58 100644 --- a/website/docs/getting-started/quickstart.md +++ b/website/docs/getting-started/quickstart.md @@ -256,6 +256,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`; the Docker backend wires everything up automatically once `proxy.enabled` flips on. + ### Voice mode ```bash diff --git a/website/docs/reference/cli-commands.md b/website/docs/reference/cli-commands.md index eb5bda2f64952..51f083e9936e0 100644 --- a/website/docs/reference/cli-commands.md +++ b/website/docs/reference/cli-commands.md @@ -41,6 +41,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. | @@ -458,6 +459,72 @@ 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 harden # host-hardening survey: firewall/SSH/fail2ban/mesh-VPN/seccomp + iron-proxy runtime +hermes egress harden --baseline catalin # evaluate against minimal|catalin|paranoid (default minimal) +hermes egress harden --all # show passing signals too (default: gaps only) +hermes egress harden --json # {signals[], baseline, satisfied, missing[]}; always exits 0 + +hermes egress disable # flip proxy.enabled = false (does not stop a running proxy) +hermes egress config # print the path to proxy.yaml for inspection +``` + +`harden` is informational and always exits `0` (it never gates); its `--baseline` values are `minimal`, `catalin`, `paranoid`, and it surveys these signals: `tailscale`, `ufw`, `firewalld`, `nftables`, `fail2ban`, `ssh-password-auth`, `ssh-root-login`, `iron-proxy-enabled`, `iron-proxy-running`, `docker-seccomp`. See [Host hardening baselines](../user-guide/egress/hardening-baselines.md). + +### 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 stop && hermes egress start # restart daemon to pick up new mappings +# (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 stop && 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 0ce872d0334cd..043ad67f6a1e1 100644 --- a/website/docs/reference/environment-variables.md +++ b/website/docs/reference/environment-variables.md @@ -237,6 +237,22 @@ 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`. The agent code reads them instead of real API keys. + +| 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. | +| `HERMES_PROXY_TOKEN_` | One per minted provider mapping. E.g. `HERMES_PROXY_TOKEN_OPENROUTER_API_KEY=hermes-proxy-openrouter-…`. The sandbox uses these in the `Authorization: Bearer` header; iron-proxy swaps them for the real upstream secret at the network boundary. | +| `HTTPS_PROXY` / `HTTP_PROXY` | Set to `http://host.docker.internal:` so every standard HTTP client routes through iron-proxy. | +| `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/user-guide/egress/hardening-baselines.md b/website/docs/user-guide/egress/hardening-baselines.md new file mode 100644 index 0000000000000..d96ccae128203 --- /dev/null +++ b/website/docs/user-guide/egress/hardening-baselines.md @@ -0,0 +1,163 @@ +# Host hardening baselines + +`hermes egress harden` is a **read-only survey** of the host the egress +proxy runs on. Where [`hermes egress doctor`](./iron-proxy.md) answers *"is +the egress proxy itself healthy?"*, `harden` answers *"is the machine it +runs on locked down?"* — and folds the two iron-proxy runtime signals in +so you see the whole defense-in-depth stack in one table. + +```bash +hermes egress harden # gaps only, minimal baseline +hermes egress harden --all # show passing signals too +hermes egress harden --baseline catalin +hermes egress harden --json # {signals[], baseline, satisfied, missing[]} +``` + +The command is **informational** — it always exits `0`. It never gates a +deploy or blocks a start. The summary line tells the operator what to fix; +acting on it is a human decision. + +## Why baselines + +Sandbox-egress isolation and host-perimeter hardening solve *different* +threats, and you want both: + +- **Perimeter** (Tailscale / UFW / firewalld / nftables / Cloudflare / + fail2ban / SSH config) keeps attackers *off* the box: closed ports, no + SSH password brute-force, no public exposure of the control plane. +- **Sandbox-egress** ([iron-proxy](./iron-proxy.md)) keeps a + *compromised-from-the-inside* agent from exfiltrating real API + credentials or reaching cloud metadata (IMDS) — even after a successful + prompt injection. + +A firewall does nothing against a prompt-injected agent that already runs +inside your sandbox and POSTs your `OPENAI_API_KEY` to an attacker. The +egress proxy does nothing against an open SSH port with password auth. +Defense-in-depth means layering them. The three baselines below name three +common layering targets. + +## The ten signals + +| # | Signal | Probe | Platform | +|---|---|---|---| +| 1 | `tailscale` | `tailscale status --json` → `BackendState=Running` | any | +| 2 | `ufw` | `ufw status verbose` → `Status: active` + default-deny incoming | Linux | +| 3 | `firewalld` | `firewall-cmd --state` → `running` | Linux | +| 4 | `nftables` | `nft list ruleset` → non-empty | Linux | +| 5 | `fail2ban` | `fail2ban-client status` → ≥1 jail | Linux | +| 6 | `ssh-password-auth` | `^PasswordAuthentication no` in `/etc/ssh/sshd_config` | any (needs sshd) | +| 7 | `ssh-root-login` | `^PermitRootLogin (no\|prohibit-password)` in sshd_config | any (needs sshd) | +| 8 | `iron-proxy-enabled` | reuses `get_status()` — CA + `proxy.yaml` present | any | +| 9 | `iron-proxy-running` | reuses `get_status()` — daemon pid alive + listening | any | +| 10 | `docker-seccomp` | `docker info` SecurityOptions includes `seccomp` | any (needs Docker) | + +Every probe is **best-effort and graceful**: a missing binary, an +unreadable file, or a non-Linux host yields `skip` (never `fail`). On a +macOS dev box the Linux-only firewall and fail2ban signals skip cleanly +while Tailscale, SSH-config, iron-proxy, and Docker signals still run. + +## Minimal baseline + +For a **solo developer on a single machine**. Three requirements: + +1. **Any one firewall present** — `ufw` *or* `firewalld` *or* `nftables` + *or* `tailscale` as the network perimeter. +2. **SSH password auth disabled** (`ssh-password-auth` passes). +3. **iron-proxy enabled** (`iron-proxy-enabled` passes). + +Why these three: they're the highest-leverage, lowest-friction controls. +One firewall closes the box; key-only SSH defeats the single most common +internet attack (password spray); and the egress proxy is the whole point +of running Hermes with credential isolation. If you do nothing else, do +these. + +```bash +hermes egress harden --baseline minimal +# ✓ minimal baseline satisfied +``` + +## Catalin baseline + +Credited to **[@catalinmpit](https://x.com/catalinmpit)**, who publicly +deployed a Hermes agent on a Hetzner VPS behind Tailscale + UFW + Cloudflare ++ fail2ban — a clean, opinionated perimeter that prompted Teknium's request +for a security review of the egress proxy. Catalin described the shape: + +> *"I've deployed my Hermes agent on a Hetzner VPS — locked behind +> Tailscale, UFW default-deny, Cloudflare in front, and fail2ban watching +> SSH. The only thing exposed is what I explicitly allow."* + +The `catalin` baseline encodes the host-detectable subset of that shape — +five hard requirements: + +1. `tailscale` — host is on the private mesh. +2. `ufw` — active, default-deny incoming. +3. `fail2ban` — at least one jail (SSH brute-force mitigation). +4. `ssh-password-auth` — disabled. +5. `iron-proxy-enabled` — sandbox-egress isolation on. + +```bash +hermes egress harden --baseline catalin +# ✗ catalin baseline incomplete (missing: fail2ban) +``` + +Cloudflare-edge protection is part of Catalin's real deployment but isn't +host-detectable from inside the box, so it's tracked as a future +enhancement (see below) rather than a `harden` signal. + +## Paranoid baseline + +For **production / multi-tenant** hosts: **all ten signals must pass**. +This is the strictest target — every firewall layer present, both SSH +directives locked, fail2ban active, iron-proxy enabled *and* running, and +Docker seccomp confirmed. Use it as a pre-flight gate in your deploy +runbook (read the summary, then decide), not as an automated blocker — +`harden` never gates on its own. + +```bash +hermes egress harden --baseline paranoid --all +``` + +## Composing with iron-proxy + +Each layer mitigates a distinct threat. The survey shows you the whole +column at once: + +| Threat | Mitigated by | +|---|---| +| Host port scan / unexpected listener | `ufw` / `firewalld` / `nftables` default-deny | +| Public exposure of the control plane | `tailscale` (mesh-only reachability) | +| SSH password brute-force | `ssh-password-auth` off + `fail2ban` | +| Root takeover via SSH | `ssh-root-login` off | +| Sandbox credential exfiltration | `iron-proxy-enabled` (token-swap at the boundary) | +| MITM on upstream API traffic | iron-proxy CA-pinned TLS interception | +| Cloud metadata (IMDS) access from a sandbox | iron-proxy SSRF deny-list (`169.254.0.0/16`) | +| Container syscall abuse | `docker-seccomp` | + +The first four rows are **perimeter** (this survey's host signals); the +middle three are **sandbox-egress** (see [iron-proxy](./iron-proxy.md) and +its [`doctor`](./iron-proxy.md) checks); the last is the container runtime. +Neither layer substitutes for the other. + +## Future enhancements + +Deliberately **out of scope** for the first version of `harden`, tracked as +follow-ups: + +- **Hetzner-specific detection** — cloud-provider firewall (Hetzner Cloud + Firewall) state via metadata API. +- **Cloudflare edge** — verifying the host only accepts traffic from + Cloudflare CIDR ranges (requires DNS + CIDR caching). +- **iptables baseline** — legacy `iptables -L` parsing distinct from + nftables. +- **nftables-vs-iptables differentiation** — which backend is authoritative. +- **Docker user-namespace remapping** — `userns-remap` in `docker info`. + +These were cut to keep the first cut focused, stdlib-only, and fast. + +## See also + +- [Egress proxy (iron-proxy)](./iron-proxy.md) — setup, architecture, and + the complementary `hermes egress doctor` health check. +- [CLI commands reference](../../reference/cli-commands.md) — full + `hermes egress harden` flag list. diff --git a/website/docs/user-guide/egress/index.md b/website/docs/user-guide/egress/index.md new file mode 100644 index 0000000000000..90ccbb8a05e66 --- /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 0000000000000..29e2b94f1fca0 --- /dev/null +++ b/website/docs/user-guide/egress/iron-proxy.md @@ -0,0 +1,590 @@ +# Egress credential-injection proxy (iron-proxy) + +When Hermes runs your agent inside a remote terminal sandbox — Docker, Modal, SSH — 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 from behind the proxy. + +This page covers the Docker backend, which is what v1 ships. Modal, Daytona, and SSH wiring will follow in later releases. + +## 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` plus a set of `HERMES_PROXY_TOKEN_` env vars. The agent code reads those tokens instead of the real API keys. iron-proxy's `secrets` transform matches the token in the `Authorization` header and substitutes the real value sourced from its own 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 one `HERMES_PROXY_TOKEN_` 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 binds **loopback only** (`127.0.0.1:`). It does NOT bind `0.0.0.0`. This means: + +- A LAN peer with a leaked proxy token cannot use it — the proxy is unreachable from the network. +- Containers reach the proxy via `host.docker.internal:9090`, which Docker maps to the host gateway via `--add-host=host.docker.internal:host-gateway` on Linux. On macOS / Windows Docker Desktop, Desktop manages the gateway itself. + +iron-proxy v0.39 only supports a single bind per daemon process — earlier drafts of this integration emitted a plural `http_listens` list with the docker bridge IP appended for direct sandbox-to-bridge connectivity, but v0.39's YAML parser rejects that field. The `host.docker.internal -> host-gateway` mapping that Docker provides is sufficient: containers resolve the hostname to the bridge IP, then connect TO the host's loopback bind through it. + +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". + +If a hostile `ip` shim earlier on PATH had been able to inject a non-private IPv4 here (`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 +``` + +**Caveat:** rotating tokens DOES NOT automatically restart iron-proxy. The running daemon still has the old mappings in memory (and the old YAML). After `--rotate-tokens`: + +```bash +hermes egress stop && hermes egress start +``` + +Containers already running hold the old tokens and will need to be restarted to pick up the new ones. + +## 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 serves as a stable logrotate / fluent-bit / monitoring target — operators can wire downstream tooling to that path today, and when we bump the pinned version to one that supports `log.audit_path`, the records will start flowing without any operator-side reconfiguration. +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=…` | curl + wget for plain HTTP (rare in modern stacks) | +| `-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 HERMES_PROXY_TOKEN_=…` | One per mapping; the sandbox uses these instead of real keys | +| `--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 loopback only, never `0.0.0.0` (containers reach it via `host.docker.internal -> host-gateway`). + +**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. +- 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). +- 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: 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. +- **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 127.0.0.1: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 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. The file at `audit.log` is pre-created today at `0o600` so any logrotate / monitoring tooling you wire to that path keeps working through the version bump without operator-side reconfig. + +## 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`). +- Token rotation does not auto-restart the daemon; after `--rotate-tokens` you must `hermes egress stop && hermes egress start` and then restart running sandboxes. +- 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** and combines daemon + per-request records into a single log stream. The integration is designed to upgrade cleanly: the moment upstream adds `proxy.http_listens` (plural) and `log.audit_path`, both wire in automatically without changing operator configs. + +## Host hardening + +The egress proxy isolates sandbox *credentials*, but it can't lock down the +host it runs on — that's a separate, complementary layer. `hermes egress +harden` is a read-only survey that probes the host's perimeter controls +(Tailscale, UFW / firewalld / nftables, fail2ban, SSH config, Docker +seccomp) alongside the two iron-proxy runtime signals, so you can see the +whole defense-in-depth stack in one table. It's informational — always +exits `0` — and never gates a deploy. See [Host hardening +baselines](./hardening-baselines.md) for the `minimal` / `catalin` / +`paranoid` targets and which threat each layer mitigates. + +```bash +hermes egress harden --baseline catalin +``` + +## 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 a994e4e7fee8b..ebc8b25f4b172 100644 --- a/website/sidebars.ts +++ b/website/sidebars.ts @@ -36,6 +36,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', @@ -736,6 +745,7 @@ const sidebars: SidebarsConfig = { 'developer-guide/tools-runtime', 'developer-guide/acp-internals', 'developer-guide/cron-internals', + 'developer-guide/egress-internals', 'developer-guide/trajectory-format', ], },