69ffb9cfd4
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)
166 lines
5.9 KiB
Python
166 lines
5.9 KiB
Python
"""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()
|