From 69ffb9cfd4f1029f4aaa4386cf6c7635d0d07ba0 Mon Sep 17 00:00:00 2001 From: Teknium <127238744+teknium1@users.noreply.github.com> Date: Thu, 21 May 2026 19:31:42 -0700 Subject: [PATCH] feat(egress): iron-proxy credential-injection firewall for sandboxes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds a TLS-intercepting egress proxy for remote terminal sandboxes (Docker v1; Modal/SSH to follow). When enabled, the sandbox holds opaque proxy tokens; iron-proxy swaps them for real provider API keys at the egress boundary. Compromising the sandbox leaks tokens that only work from behind the proxy. Wraps ironsh/iron-proxy (Apache-2.0, Go binary). Same lazy-install pattern as the recently merged Bitwarden Secrets Manager integration — pinned version, SHA-256 verified download into ~/.hermes/bin/iron-proxy, no apt or sudo required. Disabled by default. Run `hermes egress setup` to mint tokens and `hermes egress start` to launch. The Docker backend then automatically mounts the CA, sets HTTPS_PROXY + CA-bundle env vars, and adds the host-gateway hostmap. New surfaces: hermes egress install — download the pinned iron-proxy binary hermes egress setup — interactive wizard (supports --from-bitwarden) hermes egress start — spawn the managed proxy daemon hermes egress stop — SIGTERM (+SIGKILL after 5s grace) hermes egress status — binary + config + pid + listening + mappings hermes egress disable — flip proxy.enabled = false hermes egress config — print the path to the generated proxy.yaml Optional Bitwarden integration: `--from-bitwarden` sources the real upstream credentials from a BSM project at proxy startup, so rotating a key in the Bitwarden web app propagates to sandboxes on the next proxy start without touching .env. Hermes-side scope (v1): agent/proxy_sources/iron_proxy.py — install + CA + config + lifecycle hermes_cli/proxy_cli.py — `hermes egress` subcommand tree hermes_cli/config.py — "proxy:" section in DEFAULT_CONFIG hermes_cli/main.py — argparse wiring (uses 'egress' because 'proxy' is the existing inbound OAuth reverse proxy) tools/environments/docker.py — CA mount, HTTPS_PROXY, CA-bundle env vars, --add-host wiring Hermetic tests cover the full lifecycle: token mint, mapping discovery, config + mappings I/O, install pipeline (HTTP + tar + checksum all mocked), subprocess lifecycle (Popen mocked), Docker backend arg builder. A live E2E test (gated on HERMES_RUN_E2E=1) downloads the real iron-proxy binary, spawns it, routes a curl request through it against a local fake upstream, and verifies the Authorization header was swapped from the proxy token to the real secret value (and the proxy token did NOT leak through to upstream). Failures (binary missing, port collision, bad token) never block agent startup — they emit a warning and continue. The Docker backend refuses to start a sandbox when proxy.enabled=true but the daemon is dead, unless proxy.enforce_on_docker is explicitly set to false. Docs: website/docs/user-guide/egress/{index,iron-proxy}.md Tests: tests/test_iron_proxy.py (35), tests/test_iron_proxy_e2e.py (1) --- agent/proxy_sources/__init__.py | 8 + agent/proxy_sources/iron_proxy.py | 944 +++++++++++++++++++ hermes_cli/config.py | 39 + hermes_cli/main.py | 31 +- hermes_cli/proxy_cli.py | 371 ++++++++ tests/test_iron_proxy.py | 533 +++++++++++ tests/test_iron_proxy_e2e.py | 165 ++++ tools/environments/docker.py | 122 ++- website/docs/user-guide/egress/index.md | 10 + website/docs/user-guide/egress/iron-proxy.md | 174 ++++ website/sidebars.ts | 9 + 11 files changed, 2403 insertions(+), 3 deletions(-) create mode 100644 agent/proxy_sources/__init__.py create mode 100644 agent/proxy_sources/iron_proxy.py create mode 100644 hermes_cli/proxy_cli.py create mode 100644 tests/test_iron_proxy.py create mode 100644 tests/test_iron_proxy_e2e.py create mode 100644 website/docs/user-guide/egress/index.md create mode 100644 website/docs/user-guide/egress/iron-proxy.md diff --git a/agent/proxy_sources/__init__.py b/agent/proxy_sources/__init__.py new file mode 100644 index 0000000000..34af31ca6f --- /dev/null +++ b/agent/proxy_sources/__init__.py @@ -0,0 +1,8 @@ +"""Egress proxy integrations. + +Currently ships an iron-proxy (ironsh/iron-proxy) wrapper that intercepts +outbound traffic from remote terminal sandboxes and swaps proxy tokens +for real upstream credentials at the network edge. + +Design notes live in :mod:`agent.proxy_sources.iron_proxy`. +""" diff --git a/agent/proxy_sources/iron_proxy.py b/agent/proxy_sources/iron_proxy.py new file mode 100644 index 0000000000..d6f998ca48 --- /dev/null +++ b/agent/proxy_sources/iron_proxy.py @@ -0,0 +1,944 @@ +"""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 proxy 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 proxy 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 json +import logging +import os +import platform +import shutil +import signal +import stat +import subprocess +import sys +import tarfile +import tempfile +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",), +} + + +# --------------------------------------------------------------------------- +# 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() -> Path: + from hermes_constants import get_hermes_home + + d = get_hermes_home() / "proxy" + d.mkdir(parents=True, exist_ok=True) + 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()) + tf.extract(member, tmp) # noqa: S202 — member name is sanitized below + 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) + + 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.""" + + 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 "" + return (res.stdout or res.stderr or "").strip() + + +# --------------------------------------------------------------------------- +# 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. + shutil.copy2(tmp_key, ca_key) + shutil.copy2(tmp_crt, ca_crt) + os.chmod(ca_key, stat.S_IRUSR | stat.S_IWUSR) + 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 long random suffix + so collisions are infeasible. + """ + + return f"{prefix}-{hashlib.sha256(os.urandom(32)).hexdigest()[:32]}" + + +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, +) -> 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. + + 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], + }) + + 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. Bind on all interfaces so containers + # can reach it via host.docker.internal. + "http_listen": f":{tunnel_port}", + # 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. Tests / dev setups that need loopback can pass an + # explicit override (e.g. [] to disable, or just the IMDS subset). + **( + {"upstream_deny_cidrs": list(upstream_deny_cidrs)} + if upstream_deny_cidrs is not None + else {} + ), + }, + "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": {"level": "info"}, + } + + +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 + + +# --------------------------------------------------------------------------- +# Subprocess lifecycle +# --------------------------------------------------------------------------- + + +def _pidfile() -> Path: + return _proxy_state_dir() / "iron-proxy.pid" + + +def _read_pid() -> Optional[int]: + pf = _pidfile() + 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 + + +def _pid_alive(pid: int) -> bool: + """Return True iff ``pid`` is alive AND is an iron-proxy process. + + The cmdline check guards against PID reuse — without it, an unrelated + process that happens to have grabbed the same PID after iron-proxy + crashed would look "alive" and we'd refuse to restart. + """ + + if pid <= 0: + return False + try: + os.kill(pid, 0) + except (ProcessLookupError, PermissionError, OSError): + return False + + # Confirm via /proc when available (Linux + WSL). On macOS we fall + # back to ``ps``; on truly exotic platforms the os.kill(pid, 0) result + # is taken at face value. + try: + cmdline_path = Path(f"/proc/{pid}/cmdline") + if cmdline_path.exists(): + cmdline = cmdline_path.read_bytes().decode("utf-8", errors="ignore") + return "iron-proxy" in cmdline + except OSError: + pass + try: + res = subprocess.run( # noqa: S603 + ["ps", "-p", str(pid), "-o", "command="], + capture_output=True, text=True, timeout=2, + ) + if res.returncode == 0: + return "iron-proxy" in (res.stdout or "") + except (OSError, subprocess.TimeoutExpired): + pass + return True + + +def start_proxy( + *, + binary: Optional[Path] = None, + config_path: Optional[Path] = None, + extra_env: Optional[Dict[str, str]] = 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. + """ + + 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 proxy 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 proxy setup` first." + ) + + env = os.environ.copy() + if extra_env: + env.update(extra_env) + env.setdefault("NO_COLOR", "1") + + log_path = _proxy_state_dir() / "iron-proxy.log" + log_fp = open(log_path, "ab", buffering=0) + + try: + proc = subprocess.Popen( # noqa: S603 — binary path is trusted + [str(bin_path), "-config", str(cfg)], + env=env, + stdin=subprocess.DEVNULL, + stdout=log_fp, + stderr=subprocess.STDOUT, + start_new_session=True, + ) + except OSError as exc: + log_fp.close() + raise RuntimeError(f"failed to spawn iron-proxy: {exc}") from exc + + # Give it a moment to either come up or fail fast. We don't want to + # write a pidfile pointing at a dead process. + time.sleep(_STARTUP_GRACE_SECONDS) + if proc.poll() is not None: + log_fp.close() + tail = _tail_log(log_path, lines=20) + raise RuntimeError( + f"iron-proxy exited immediately (code {proc.returncode}). " + f"Last log lines:\n{tail}" + ) + + pidfile = _pidfile() + pidfile.write_text(str(proc.pid), encoding="utf-8") + logger.info("Started iron-proxy pid=%s config=%s", proc.pid, cfg) + return get_status() + + +def stop_proxy() -> bool: + """Stop the managed iron-proxy. Returns True if it was running.""" + + pid = _read_pid() + if not pid or not _pid_alive(pid): + _pidfile().unlink(missing_ok=True) + return False + + try: + os.kill(pid, signal.SIGTERM) + except ProcessLookupError: + _pidfile().unlink(missing_ok=True) + 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: + try: + os.kill(pid, signal.SIGKILL) + except ProcessLookupError: + pass + + _pidfile().unlink(missing_ok=True) + logger.info("Stopped iron-proxy pid=%s", pid) + return True + + +def get_status() -> ProxyStatus: + """Snapshot the current proxy state — does NOT start anything.""" + + 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 + status.binary_version = iron_proxy_version(binary) + + state = _proxy_state_dir() + 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() / "proxy.yaml" + if not cfg.exists(): + return None + try: + import yaml + data = yaml.safe_load(cfg.read_text(encoding="utf-8")) + except Exception: # noqa: BLE001 + 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: + """No-op today — kept symmetric with bitwarden._reset_cache_for_tests.""" + + return None + + +# Make a small set of symbols available without underscored access. +__all__ = [ + "ProxyStatus", + "TokenMapping", + "build_proxy_config", + "discover_provider_mappings", + "ensure_ca_cert", + "find_iron_proxy", + "get_status", + "install_iron_proxy", + "iron_proxy_version", + "load_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 715fd7eb76..2df3838a4a 100644 --- a/hermes_cli/config.py +++ b/hermes_cli/config.py @@ -1778,6 +1778,45 @@ DEFAULT_CONFIG = { }, }, + # ========================================================================= + # 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 proxy 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, + # 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": 23, } diff --git a/hermes_cli/main.py b/hermes_cli/main.py index bf75a51891..73bd44e407 100644 --- a/hermes_cli/main.py +++ b/hermes_cli/main.py @@ -10677,7 +10677,7 @@ _BUILTIN_SUBCOMMANDS = frozenset( "acp", "auth", "backup", "bundles", "checkpoints", "claw", "completion", "computer-use", "config", "cron", "curator", "dashboard", "debug", "doctor", - "dump", "fallback", "gateway", "hooks", "import", "insights", + "dump", "egress", "fallback", "gateway", "hooks", "import", "insights", "kanban", "login", "logout", "logs", "lsp", "mcp", "memory", "migrate", "model", "pairing", "plugins", "portal", "postinstall", "profile", "proxy", "send", "sessions", "setup", @@ -11104,6 +11104,35 @@ def main(): secrets_parser.set_defaults(func=_dispatch_secrets) + # ========================================================================= + # egress command — iron-proxy outbound credential-injection firewall + # ========================================================================= + # NOTE: this is the OUTBOUND egress firewall (ironsh/iron-proxy). + # `hermes proxy` (defined elsewhere in this file) is a separate INBOUND + # OAuth-aggregator reverse proxy. Different direction, different purpose. + egress_parser = subparsers.add_parser( + "egress", + help="Manage the iron-proxy egress credential-injection firewall", + description=( + "Manage iron-proxy, the optional TLS-intercepting egress firewall " + "that swaps proxy tokens for real API credentials before outbound " + "requests leave a sandbox. Disabled by default. See: " + "https://hermes-agent.nousresearch.com/docs/user-guide/egress/iron-proxy" + ), + ) + + from hermes_cli import proxy_cli as _proxy_cli + _proxy_cli.register_cli(egress_parser) + + def _dispatch_egress(args): # noqa: ANN001 + sub = getattr(args, "proxy_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 # ========================================================================= diff --git a/hermes_cli/proxy_cli.py b/hermes_cli/proxy_cli.py new file mode 100644 index 0000000000..7e774393a6 --- /dev/null +++ b/hermes_cli/proxy_cli.py @@ -0,0 +1,371 @@ +"""CLI handlers for ``hermes proxy ...``. + +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) +""" + +from __future__ import annotations + +import argparse +import os +from pathlib import Path +from typing import List + +from rich.console import Console +from rich.panel import Panel +from rich.table import Table + +from agent.proxy_sources import iron_proxy as ip +from hermes_cli.config import load_config, save_config + + +# --------------------------------------------------------------------------- +# Argparse wiring — called from hermes_cli.main +# --------------------------------------------------------------------------- + + +def register_cli(parent_parser: argparse.ArgumentParser) -> None: + """Attach the proxy subcommand tree to a parent parser. + + Called from ``hermes_cli.main`` as part of building the top-level + ``hermes proxy`` parser. + """ + + sub = parent_parser.add_subparsers(dest="proxy_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", + ) + 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)", + ) + status.set_defaults(func=cmd_status) + + disable = sub.add_parser("disable", help="Turn off the proxy integration") + disable.set_defaults(func=cmd_disable) + + cfg = sub.add_parser("config", help="Print the generated proxy.yaml path") + cfg.set_defaults(func=cmd_config) + + +# --------------------------------------------------------------------------- +# Handlers +# --------------------------------------------------------------------------- + + +def cmd_install(args: argparse.Namespace) -> int: + console = Console() + try: + binary = ip.install_iron_proxy(force=bool(args.force)) + except Exception as exc: # noqa: BLE001 + 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( + " [yellow]--from-bitwarden requested but secrets.bitwarden.enabled is false.[/yellow]" + ) + 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 access_token: + 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()) + console.print( + f" Pulled {len(available_env_names)} env names from Bitwarden." + ) + except Exception as exc: # noqa: BLE001 + console.print( + f" [yellow]Could not enumerate Bitwarden secrets: {exc}[/yellow]" + ) + console.print( + " Falling back to current process env for discovery." + ) + + mappings = ip.discover_provider_mappings( + available_env_names=available_env_names or None, + ) + + 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 + + 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", {}) + tunnel_port = ( + args.tunnel_port + if args.tunnel_port + else 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 + ] + + iron_cfg = ip.build_proxy_config( + mappings=mappings, + ca_cert=ca_crt, + ca_key=ca_key, + tunnel_port=tunnel_port, + audit_log=ip._proxy_state_dir() / "audit.log", + allowed_hosts=allowed, + ) + 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}") + + # ------------------------------------------------------------------ enable + proxy_cfg["enabled"] = True + proxy_cfg.setdefault("auto_install", True) + proxy_cfg.setdefault("enforce_on_docker", True) + proxy_cfg.setdefault("credential_source", "bitwarden" if args.from_bitwarden else "env") + 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 proxy start[/cyan]\n" + " Status: [cyan]hermes proxy status[/cyan]\n" + " Stop: [cyan]hermes proxy stop[/cyan]\n" + " Disable: [cyan]hermes proxy 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 proxy setup` first.[/yellow]" + ) + return 1 + try: + status = ip.start_proxy() + except Exception as exc: # noqa: BLE001 + 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) + return 0 + + +def cmd_disable(args: argparse.Namespace) -> int: + console = Console() + cfg = load_config() + proxy_cfg = cfg.setdefault("proxy", {}) + if not proxy_cfg.get("enabled"): + console.print("[dim]proxy.enabled was already false.[/dim]") + return 0 + proxy_cfg["enabled"] = False + save_config(cfg) + console.print("[green]✓[/green] proxy.enabled set to false") + if ip._read_pid() is not None: + console.print( + " iron-proxy is still running — stop it with " + "[cyan]hermes proxy 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 proxy 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/tests/test_iron_proxy.py b/tests/test_iron_proxy.py new file mode 100644 index 0000000000..8cdc063e02 --- /dev/null +++ b/tests/test_iron_proxy.py @@ -0,0 +1,533 @@ +"""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 typing import Dict +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(): + m = _sample_mapping() + cfg = ip.build_proxy_config( + mappings=[m], + ca_cert=Path("/tmp/ca.crt"), + ca_key=Path("/tmp/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"] == "/tmp/ca.crt" + + +def test_build_proxy_config_custom_allowed_hosts(): + m = _sample_mapping("OPENAI_API_KEY") + cfg = ip.build_proxy_config( + mappings=[m], + ca_cert=Path("/tmp/ca.crt"), + ca_key=Path("/tmp/ca.key"), + allowed_hosts=["only.example.com"], + ) + domains = cfg["transforms"][0]["config"]["domains"] + # Custom allowed_hosts wins as the base; mapping's hosts get appended. + assert "only.example.com" in domains + assert "openrouter.ai" in domains # comes from the mapping + + +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): + cfg = ip.build_proxy_config( + mappings=[_sample_mapping()], + ca_cert=Path("/tmp/ca.crt"), + ca_key=Path("/tmp/ca.key"), + ) + out = ip.write_proxy_config(cfg) + assert out.exists() + text = out.read_text(encoding="utf-8") + assert "tunnel_listen" in text + assert "ca_cert: /tmp/ca.crt" in text + + +# --------------------------------------------------------------------------- +# 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) + monkeypatch.setattr(ip, "_port_listening", lambda h, p: False) + 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() diff --git a/tests/test_iron_proxy_e2e.py b/tests/test_iron_proxy_e2e.py new file mode 100644 index 0000000000..9e0e47f447 --- /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/tools/environments/docker.py b/tools/environments/docker.py index 1cd72ce855..7bb00feaa2 100644 --- a/tools/environments/docker.py +++ b/tools/environments/docker.py @@ -177,6 +177,106 @@ _GOSU_CAP_ARGS = [ ] +def _egress_proxy_args_for_docker() -> tuple[list[str], dict[str, str], list[str]]: + """Build the docker mount/env/host args needed to route a sandbox through + the iron-proxy egress firewall. + + Returns ``(volume_args, env_overrides, host_args)``: + + * ``volume_args`` — read-only bind mount of the CA cert into the container + (extends docker's ``-v`` argv list) + * ``env_overrides`` — env vars to set on container creation: ``HTTPS_PROXY``, + ``HTTP_PROXY``, ``NO_PROXY`` (loopback only), Python/Node/curl CA-bundle + paths, and one ``HERMES_PROXY_TOKEN_`` per minted mapping + * ``host_args`` — extra ``--add-host`` flags so the container can reach the + host-side proxy (Linux needs ``host.docker.internal:host-gateway``; + Docker Desktop populates this automatically on macOS/Windows) + + Returns three empty containers when the proxy is disabled, not yet set up, + or not currently running. If ``proxy.enforce_on_docker`` is true and the + proxy is enabled-but-not-running, raises ``RuntimeError`` so the docker + backend refuses to start the sandbox. + """ + + try: + from hermes_cli.config import load_config + from agent.proxy_sources import iron_proxy as ip + except Exception as exc: # noqa: BLE001 + 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(): + # configured says ca exists; defensive double-check + 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. + "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 + # For the agent inside the sandbox to identify itself as proxy-aware. + "HERMES_EGRESS_PROXY": "1", + } + + # 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 ip.load_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: @@ -450,11 +550,28 @@ class DockerEnvironment(BaseEnvironment): except Exception as e: logger.debug("Docker: could not load credential file mounts: %s", e) + # Egress credential-injection proxy (iron-proxy) — when configured, + # mount the CA cert into the sandbox and set HTTPS_PROXY + CA-bundle + # env vars so outbound traffic routes through the host-side proxy. + # The sandbox receives PROXY tokens instead of real API keys. + egress_volume_args, egress_env_overrides, egress_host_args = ( + _egress_proxy_args_for_docker() + ) + 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 here so they're visible to the container's entrypoint as + # well as any subsequent execs. Existing self._env entries win, so + # users can still override the proxy explicitly via docker_env config. + merged_env = dict(egress_env_overrides) + merged_env.update(self._env) 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 @@ -491,6 +608,7 @@ class DockerEnvironment(BaseEnvironment): + user_args + writable_args + resource_args + + egress_host_args + volume_args + env_args + validated_extra diff --git a/website/docs/user-guide/egress/index.md b/website/docs/user-guide/egress/index.md new file mode 100644 index 0000000000..90ccbb8a05 --- /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 0000000000..d6b0226830 --- /dev/null +++ b/website/docs/user-guide/egress/iron-proxy.md @@ -0,0 +1,174 @@ +# 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 +- 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: + +```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 + + # 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: [] +``` + +## 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. Rotating a key in the Bitwarden web app then propagates to your sandboxes on the next `hermes egress start` — no `.env` edits, no Hermes restart on the host. + +## Slash commands + +The CLI subcommand tree: + +``` +hermes egress install # download the pinned iron-proxy binary +hermes egress setup # interactive wizard +hermes egress setup --tunnel-port N +hermes egress setup --from-bitwarden +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 +hermes egress disable # flip proxy.enabled = false +hermes egress config # print the path to proxy.yaml for debugging +``` + +## 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 │ │ │ +└──────────────┘ └──────────────┘ └─────────────┘ + │ + │ structured audit log + ▼ + ~/.hermes/proxy/iron-proxy.log +``` + +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. Every request is logged as a structured JSON entry to `~/.hermes/proxy/iron-proxy.log`. + +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. + +## 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`. + +**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 or a non-standard env var. The proxy can't intercept what doesn't route to it. +- Allowlisted-host data exfiltration. If `api.openai.com` is allowed, an agent could embed exfil data in a request body to that host. The audit log captures this but doesn't prevent it. + +## 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. + +## 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) need per-provider rules. +- No native Windows binary upstream. Run on Linux/macOS or inside 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`). + +## See also + +- Upstream project: +- Upstream docs: +- Bitwarden integration: [`hermes secrets bitwarden`](../secrets/bitwarden) diff --git a/website/sidebars.ts b/website/sidebars.ts index e690ae2afc..bf8dfe5839 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',