Files
hermes-agent/tests/test_iron_proxy.py
T
Teknium 69ffb9cfd4 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)
2026-05-23 20:38:27 -07:00

534 lines
20 KiB
Python

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