feat(egress): iron-proxy credential-injection firewall for sandboxes

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)
This commit is contained in:
Teknium 2026-05-21 19:31:42 -07:00
parent 7ce6b504a2
commit 69ffb9cfd4
No known key found for this signature in database
11 changed files with 2403 additions and 3 deletions

View file

@ -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`.
"""

View file

@ -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 ``<hermes_home>/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 ``<hermes_home>/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 ``<hermes_home>/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 ``<hermes_home>/proxy/iron-proxy.pid``, structured audit log at
``<hermes_home>/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 <key>" 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_<version>_<os>_<arch>.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. ``<hermes_home>/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: ``<hex> <filename>``."""
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 ``<hermes_home>/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",
]

View file

@ -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://<host>:<port>`.
# 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,
}

View file

@ -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
# =========================================================================

371
hermes_cli/proxy_cli.py Normal file
View file

@ -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:]}"

533
tests/test_iron_proxy.py Normal file
View file

@ -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()

View file

@ -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:<port>/ 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()

View file

@ -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_<NAME>`` 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

View file

@ -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`.

View file

@ -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_NAME>` 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_<ENV_NAME>` 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:<port>.
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: <https://github.com/ironsh/iron-proxy>
- Upstream docs: <https://docs.iron.sh/>
- Bitwarden integration: [`hermes secrets bitwarden`](../secrets/bitwarden)

View file

@ -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',