website/docs/user-guide/egress/iron-proxy.md
When Hermes runs your agent inside a Docker terminal sandbox, 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 behind the configured trusted proxy boundary — the CA private key and the proxy endpoint integrity are part of that boundary. If traffic can be redirected to attacker-controlled proxy infrastructure (e.g. a stolen CA private key or a hijacked proxy endpoint), the token guarantee no longer holds.
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.
iron-proxy subprocess on the host, lazy-installed into ~/.hermes/bin/iron-proxy~/.hermes/proxy/ca.crt that the sandbox trusts so iron-proxy can MITM TLS and rewrite headersproxy.yaml config at ~/.hermes/proxy/proxy.yaml listing the upstream hosts you allow and the secrets-transform mappingmappings.json recording which proxy token corresponds to which real env varThe sandbox gets HTTPS_PROXY=http://host.docker.internal:9090, HTTP_PROXY=http://host.docker.internal:9091, and standard provider env vars such as OPENROUTER_API_KEY set to opaque proxy tokens. Matching HERMES_PROXY_TOKEN_<ENV_NAME> aliases are also exported for diagnostics. Existing provider SDKs read the usual env names, send the proxy token in Authorization, and iron-proxy's secrets transform substitutes the real value sourced from the host-side daemon environment.
hermes proxy command, which is an OAuth aggregator reverse proxy. Different command (hermes egress), different direction..env keys directly. The threat model is the sandbox, not the host.# 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
hermes egress setup discovers provider keys from your environment. If your keys live only in ~/.hermes/.env (not exported into your shell), setup reads that file automatically — you don't have to export them first.
When you re-run setup later (new allowlist host, rotated tokens, switched credential source), it stops the running daemon because its config is held in memory, then offers to restart it for you so the change takes effect immediately. On a tty it asks; pass --restart to always restart or --no-restart to leave it down. To apply changes any other time, hermes egress restart is the one-command stop-then-start.
Once running, the Docker terminal backend automatically:
~/.hermes/proxy/ca.crt into the sandbox at /etc/ssl/certs/hermes-egress-ca.crtHTTPS_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 CANODE_OPTIONS=--use-openssl-ca (appended to whatever you already have in docker_env.NODE_OPTIONS) so Node.js routes through the OpenSSL store the other CA-bundle vars control — see Node.js asymmetric CA caveat below for the residual gap--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)OPENROUTER_API_KEY) plus one HERMES_PROXY_TOKEN_<ENV_NAME> diagnostic alias per minted mappingThe full config lives in ~/.hermes/config.yaml under the proxy: section. Defaults are documented inline; everything is optional.
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
# When `credential_source: bitwarden` but the BWS access token /
# project_id is missing OR the bws fetch returns no values for mapped
# providers, the daemon raises by default (matches the spirit of "I
# asked for rotation — don't silently use stale env values"). Set
# to true to opt back into the legacy host-env fallback — useful for
# migrations where you want to start switching to BW mode but haven't
# wired every secret yet.
allow_env_fallback: false
# SSRF deny list applied to outbound traffic. Omit / leave null to
# use the safe default: loopback (v4 + v6), link-local (incl. cloud
# metadata IPs at 169.254.169.254), RFC1918, IPv6 ULA, IPv4-mapped-v6,
# CGNAT, and the RFC2544 benchmark range. Set to an explicit `[]`
# to opt out entirely (only sensible in hermetic tests).
upstream_deny_cidrs: null
# 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: []
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
If your agent needs an upstream that isn't on the list — a self-hosted inference endpoint, an extra cloud LLM, an MCP server — add it to proxy.extra_allowed_hosts. Wildcards are matched against the full hostname (*.example.com matches api.example.com and staging.example.com but not example.com itself).
Applied regardless of allowlist. These ranges are refused by iron-proxy at the network boundary, so a DNS rebinding attack via an allowlisted hostname can't reach IMDS or 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 (used by AWS VPC, K8s pod networks) |
198.18.0.0/15 | RFC2544 benchmark range |
To override: set proxy.upstream_deny_cidrs to your own list. To opt out entirely (e.g. for a hermetic test that needs to reach a loopback upstream): set it to an empty list [].
The proxy never binds 0.0.0.0. The default bind is platform-specific because iron-proxy v0.39 supports only a single bind per daemon process:
172.17.0.1:<tunnel_port> by default). Containers reach the proxy via host.docker.internal, which --add-host=host.docker.internal:host-gateway resolves to exactly this bridge gateway IP — a loopback-only bind would be unreachable from inside sandboxes. The bridge IP is an address on the host's docker0 interface, so it is not exposed to the LAN; it IS reachable by other containers on the default bridge network, but requests still require a minted proxy token and an allowlisted upstream. If no docker bridge is detected (docker not installed/running), the bind falls back to loopback with a warning.127.0.0.1:<tunnel_port>). Desktop's VPNkit routes host.docker.internal to the host, so loopback is reachable from containers and is the least-exposed choice.A LAN peer with a leaked proxy token cannot use the proxy — neither bind is reachable from the external network.
We also pin metrics.listen: 127.0.0.1:0 so the daemon's built-in metrics server gets an ephemeral loopback port instead of its default :9090 — otherwise it would fight tunnel_port: 9090 for the same socket and the daemon would refuse to start with "address already in use". Note the :0 ephemeral port is random per start and not surfaced anywhere, so metrics are effectively disabled at this pin.
If a hostile ip shim earlier on PATH had been able to inject a non-private IPv4 as the bridge address (0.0.0.0, a public address, multicast, link-local, etc.) the loopback fallback still applies — we never bind anything we couldn't validate via ipaddress.IPv4Address + is_* checks.
The secrets transform swaps the proxy token wherever it appears in a matched location — and it matches more than Authorization: Bearer:
| 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 (*.openai.azure.com, *.cognitiveservices.azure.com, *.services.ai.azure.com) |
| 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 proxy token is minted and injected into the sandbox under both names, and either name in your host env satisfies discovery.
Auth schemes that involve request signing or SDK-minted OAuth cannot be swapped by a static header replacement — if their env vars are present, the sandbox holds real credentials for those providers and the egress isolation guarantee is incomplete for them:
| Env var | Provider | Reason |
|---|---|---|
AWS_ACCESS_KEY_ID, AWS_SECRET_ACCESS_KEY | AWS Bedrock / SageMaker | SigV4-signed requests |
GOOGLE_APPLICATION_CREDENTIALS | GCP Vertex AI | OAuth minted from a service-account file |
These env vars are present on most developer laptops for unrelated tooling (terraform, gcloud, aws CLI, ECR push). They surface as warnings in the wizard and hermes egress status but never block the proxy from starting. If you don't use those providers from sandboxes, unset the vars to clear the warning.
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.
When credential_source: bitwarden, the iron-proxy daemon refetches secrets from BWS via bws secret list <project_id> every time it starts. So the rotation flow is:
hermes egress stop && hermes egress start on the host.No .env edits. No Hermes restart on the host. The proxy daemon is the only thing that touches the new value — your host process and os.environ are untouched.
When credential_source: bitwarden, hermes egress start pre-checks at the wizard layer AND _build_proxy_subprocess_env re-checks at the daemon layer:
unset and re-run, or hermes egress setup --no-bitwarden to switch back to env modesecrets.bitwarden.project_id is empty → refuse to start with a hint to run hermes secrets bitwarden setupbws secret list returns no values for one or more mapped providers → refuse to start, listing the missing namesThis is intentional. Falling back to host env in BW mode reintroduces exactly the staleness bug the BW path is meant to defeat (operator picked BW for the rotation guarantee; silent fallback breaks that guarantee).
The proxy.allow_env_fallback: true config flag opts back in to the legacy "silently fall back to host env if BWS is unreachable" behavior for migration scenarios. Use it when you're moving secrets into BW one at a time and want the daemon to start with whichever values are available.
| From | To | Command |
|---|---|---|
| env | bitwarden | hermes egress setup --from-bitwarden |
| bitwarden | env | hermes egress setup --no-bitwarden |
Re-running hermes egress setup WITHOUT either flag preserves the existing credential_source — the wizard refuses to silently downgrade you back to env. This matters because once you've configured bitwarden mode, the rotation guarantee is what you signed up for; you have to explicitly say "I want env again" to change it.
The CLI subcommand tree:
hermes egress install # download the pinned iron-proxy binary
hermes egress install --force # re-download even if a managed copy exists
hermes egress setup # interactive wizard
hermes egress setup --tunnel-port N # override the tunnel listener port
hermes egress setup --from-bitwarden # use BWS as credential source (fail-loud)
hermes egress setup --no-bitwarden # explicitly switch back to env mode
hermes egress setup --rotate-tokens # mint fresh tokens for every provider
# (default preserves existing)
hermes egress start # spawn the managed proxy daemon
hermes egress stop # SIGTERM (then SIGKILL after 5s grace)
hermes egress restart # stop (if running) then start — needed when
# upstream SECRETS change (rotation, new provider)
hermes egress reload # hot-reload the ruleset from proxy.yaml via the
# management API — no restart, no dropped
# connections (allowlist / mapping edits)
hermes egress status # binary + config + pid + listening state + mappings
hermes egress status --show-tokens # print proxy tokens in full
# (default: redacted prefix + suffix only)
hermes egress disable # flip proxy.enabled = false
# (does not stop a running proxy)
hermes egress config # print the path to proxy.yaml for debugging
By default, hermes egress setup preserves proxy tokens for providers that already have them. Adding a new provider mints a fresh token only for the new one; existing tokens are unchanged. This avoids 401-ing running sandboxes when you re-run the wizard.
--rotate-tokens rolls every token:
hermes egress setup --rotate-tokens
When there are existing tokens AND stdin is a tty, the wizard prompts for confirmation:
⚠ --rotate-tokens will invalidate proxy tokens in every running
Hermes sandbox. They will start 401-ing against upstreams until restarted.
Type 'rotate' to confirm:
Non-tty invocations (CI, scripts) skip the prompt — the flag is treated as deliberate. Before any overwrite the current mappings.json is copied to a timestamped sibling so manual recovery is possible:
backup: ~/.hermes/proxy/mappings.json.rotated-20260524T143012
hermes egress setup stops a running daemon when it rewrites config or token mappings, because the daemon keeps the old YAML in memory. After --rotate-tokens:
hermes egress start
Containers already running hold the old tokens and will need to be restarted to pick up the new ones. New persistent Docker containers include an egress-posture label, so Hermes will not reuse a pre-egress or pre-rotation container for new sessions.
Everything iron-proxy maintains lives in ~/.hermes/proxy/:
| Path | Mode | Purpose |
|---|---|---|
~/.hermes/proxy/ (dir) | 0o700 | Owned + traversable by you only |
ca.crt | 0o644 | Public CA cert distributed into sandboxes |
ca.key | 0o600 | CA signing key — never leaves the host |
proxy.yaml | 0o600 | iron-proxy config; rewritten every setup |
mappings.json | 0o600 | Sandbox proxy token → upstream env var |
mappings.json.rotated-* | 0o600 | Backups created by --rotate-tokens |
iron-proxy.pid | 0o600 | PID of the running daemon |
iron-proxy.nonce | 0o600 | Per-start nonce for PID-recycle defense |
iron-proxy.log | 0o600 | Daemon stdout/stderr — includes per-request records on v0.39 |
audit.log | 0o600 | Reserved for the dedicated per-request audit stream on future binary versions; pre-created so the privacy contract holds when upstream wires it in |
The CA private key is the most sensitive file. It's created with 0o600 from the first byte (no umask-window TOCTOU) and O_NOFOLLOW so a same-uid attacker can't redirect it via a planted symlink. The pidfile, nonce file, daemon log, and audit log get the same treatment.
On the currently pinned binary version (v0.39.0) iron-proxy writes ALL output — daemon-level diagnostics AND per-request records — to ~/.hermes/proxy/iron-proxy.log. v0.39's config.Log struct doesn't have a separate audit_path field, so we can't route per-request records to a dedicated stream there.
We still pre-create ~/.hermes/proxy/audit.log at 0o600 with O_NOFOLLOW because:
log.audit_path, per-request records will start flowing there without operator-side reconfiguration. Until then the file stays at 0 bytes — do not point monitoring, alerting, or forensics tooling at it yet. Use iron-proxy.log for everything today.Until that version bump lands, treat iron-proxy.log as the source of truth for both audiences:
Both files are appended to across restarts. Rotate them with logrotate if you care about disk usage on long-lived hosts.
┌──────────────┐ ┌──────────────┐ ┌─────────────┐
│ 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 │ │ │
└──────────────┘ └──────────────┘ └─────────────┘
│
│ daemon + per-request log (combined on v0.39)
▼
~/.hermes/proxy/iron-proxy.log
(~/.hermes/proxy/audit.log reserved for v0.40+ split stream)
POST https://openrouter.ai/v1/chat/completions with Authorization: Bearer hermes-proxy-openrouter-… (the proxy token, not the real key).HTTPS_PROXY is set, the request goes to iron-proxy as a CONNECT tunnel.openrouter.ai is allowed.openrouter.ai, terminates the TLS connection, inspects the request.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.~/.hermes/proxy/iron-proxy.log on v0.39. When the pinned binary version supports the split stream (v0.40+), per-request records will flow to ~/.hermes/proxy/audit.log and daemon-level diagnostics will stay in iron-proxy.log. See Logging on iron-proxy v0.39.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. The denial is recorded in iron-proxy.log with the upstream host and the source sandbox.
When the Docker backend starts a container with proxy.enabled: true and the daemon is listening, it adds these arguments to docker run:
| Arg | Purpose |
|---|---|
-v ~/.hermes/proxy/ca.crt:/etc/ssl/certs/hermes-egress-ca.crt:ro | Read-only mount of the CA |
-e HTTPS_PROXY=http://host.docker.internal:9090 | Python httpx / curl / go default transport / Node fetch |
-e HTTP_PROXY=http://host.docker.internal:9091 | curl + wget for plain HTTP — the plain-HTTP forward listener lives on tunnel_port + 1 |
-e NO_PROXY=127.0.0.1,localhost,::1 | Loopback dev servers inside the sandbox bypass the proxy |
-e REQUESTS_CA_BUNDLE=…ca.crt | Python requests |
-e SSL_CERT_FILE=…ca.crt | Python ssl module / OpenSSL — replaces the system store |
-e CURL_CA_BUNDLE=…ca.crt | curl — replaces the system store |
-e NODE_EXTRA_CA_CERTS=…ca.crt | Node.js — adds to the system store |
-e NODE_OPTIONS="<your value> --use-openssl-ca" | Node.js — route through OpenSSL store (appended; your --max-old-space-size etc. are preserved) |
-e HERMES_EGRESS_PROXY=1 | Sentinel the agent can read to know it's proxy-aware |
-e OPENROUTER_API_KEY=<proxy-token> | Standard provider env names receive proxy tokens so existing SDKs keep working |
-e HERMES_PROXY_TOKEN_<NAME>=… | Diagnostic alias for each mapping; same value as the standard provider env var |
--add-host=host.docker.internal:host-gateway | Linux-only; Docker Desktop maps it automatically |
REQUESTS_CA_BUNDLE / SSL_CERT_FILE / CURL_CA_BUNDLE replace the system CA store inside the sandbox. NODE_EXTRA_CA_CERTS adds to it. A Node.js process inside the sandbox could in principle bypass the proxy by opening a raw net.Socket and starting its own TLS handshake — the system CA store would still trust real upstream certs, so the request would succeed where Python / curl would fail validation.
NODE_OPTIONS=--use-openssl-ca is appended to whatever you already have in docker_env.NODE_OPTIONS. This forces Node through the OpenSSL store that SSL_CERT_FILE controls, narrowing the asymmetry. It does NOT cover code that explicitly passes its own ca option to tls.connect() or https.request(), but it closes the easy case.
This is a known v1 limitation. Track github.com/ironsh/iron-proxy/issues for an upstream resolution; in the meantime, do not run untrusted Node code that opens raw sockets in a sandbox you're depending on egress isolation for.
If you set proxy-controlling env vars in your docker_env: config block (rare but possible), Hermes refuses to start the sandbox when enforce_on_docker: true is set. This includes both:
HTTPS_PROXY, HTTP_PROXY, NO_PROXY, REQUESTS_CA_BUNDLE, SSL_CERT_FILE, CURL_CA_BUNDLE, NODE_EXTRA_CA_CERTSmappings.json (e.g. OPENROUTER_API_KEY, OPENAI_API_KEY)Example error:
docker_env in config.yaml overrides egress-proxy variables
['HTTPS_PROXY', 'OPENROUTER_API_KEY']; enforce_on_docker is enabled.
Remove these keys from docker_env or disable enforce_on_docker to
opt out of egress isolation.
With enforce_on_docker: false the same situation surfaces as a warning and your docker_env values win — useful for migrations or testing, but you're explicitly opting OUT of the isolation guarantee.
The daemon's pidfile is written with O_EXCL + O_NOFOLLOW + ownership check. Concurrent hermes egress start calls produce one of two outcomes:
hermes egress stopBeyond that, every start_proxy plants a fresh random nonce in two places:
HERMES_IRON_PROXY_NONCE=<nonce> in the daemon's env~/.hermes/proxy/iron-proxy.nonce (0o600 sibling of the pidfile)When hermes egress stop (or any other _pid_alive check) wants to confirm a PID still refers to our daemon — not an unrelated process that was assigned the same PID after iron-proxy crashed — it reads /proc/<pid>/environ and looks for the nonce. The on-disk copy is what makes this work across CLI invocations (the in-memory _proxy_nonce is per-process and resets on every hermes invocation).
If the nonce check fails, the code falls back to matching argv[0] basename against iron-proxy. stop_proxy additionally captures /proc/<pid>/stat starttime before SIGTERM and re-verifies after the 5s grace window — if starttime drifted, the PID was recycled mid-wait and SIGKILL is suppressed with a warning.
What this protects against:
printenv / credential files and exfiltrating real keys.169.254.169.254) — iron-proxy denies these by default via upstream_deny_cidrs, including the IPv4-mapped-v6 form ::ffff:169.254.169.254.0.0.0.0, so it is unreachable from the external network.What it does NOT protect against:
~/.hermes/.env are exposed regardless. This is a defense-in-depth feature for sandbox compromise, not host compromise./etc/ssl/certs/hermes-egress-ca.crt) and that traffic actually reaches our iron-proxy. If the CA private key is stolen, or sandbox egress is redirected to attacker-controlled proxy infrastructure, an adversary-in-the-middle can present a valid leaf cert and the proxy tokens are no longer a meaningful boundary (cf. MITRE ATT&CK T1588.004 — obtained TLS certificate material enabling AiTM). Protect the CA key (it's 0600, host-only) and the proxy endpoint accordingly.HTTPS_PROXY by using a raw socket. The proxy can't intercept what doesn't route to it. Node.js is partially mitigated via NODE_OPTIONS=--use-openssl-ca (see caveat above).terminal.credential_files or skill-registered mounts). Egress protects provider env vars; it does not inspect arbitrary mounted files. Do not mount real provider credentials into an enforced egress sandbox.api.openai.com is allowed, an agent could embed exfil data in a request body to that host. The daemon log captures the request happened but doesn't prevent it./proc/<pid>/mem read from a same-uid attacker would expose them. Out of scope for this layer.auto_install: true — first hermes egress setup or hermes egress start downloads it. SHA-256 verified against the upstream checksums.txt.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_on_docker: false, it falls back to direct outbound with real creds and logs a warning.hermes egress start reports the last 20 log lines and fails with non-zero exit.upstream_deny_cidrs regardless of allowlist.docker_env collides with a proxy-controlling var (enforce on) — sandbox creation refuses with the names of the colliding keys.docker_forward_env tries to forward a protected provider key (enforce on) — sandbox creation refuses; remove the key from docker_forward_env or opt out with proxy.enforce_on_docker: false.docker_extra_args overrides proxy env/network controls (enforce on) — sandbox creation refuses; user-supplied -e HTTPS_PROXY=..., --env-file, or --network args run after Hermes' generated args and can bypass egress.credential_source: bitwarden — hermes egress start refuses with --no-bitwarden as the recovery hint.iron-proxy.log.hermes egress start calls — second call refuses with "another start in progress" if the first's daemon is up; otherwise the second unlinks the stale pidfile and proceeds.You enabled credential_source: bitwarden but the access-token env var isn't in your shell. Either:
export BWS_ACCESS_TOKEN=… # one-shot
hermes egress start
Or move it into ~/.hermes/.env. Or switch back to env mode:
hermes egress setup --no-bitwarden
Look at the last 20 lines of ~/.hermes/proxy/iron-proxy.log. Common causes:
proxy.tunnel_port or kill whatever else owns 9090proxy.yaml → run hermes egress setup to regeneratechmod 0o600 ~/.hermes/proxy/ca.keyThe daemon started but never bound the listener. Usually means the binary is wedged or doing something expensive at startup. Check ~/.hermes/proxy/iron-proxy.log. The orphan process is killed automatically and the pidfile cleaned up so you can just retry hermes egress start.
The container resolves host.docker.internal to the docker bridge gateway and the proxy is bound there, but a host firewall (commonly ufw with default-deny INPUT) drops container→host traffic on docker0. Verify from a container:
docker run --rm --add-host host.docker.internal:host-gateway busybox \
nc -zv -w 3 host.docker.internal 9090
If that times out while hermes egress status shows listening, allow the bridge subnet in your firewall, e.g. for ufw:
sudo ufw allow in on docker0 to any port 9090 proto tcp
sudo ufw allow in on docker0 to any port 9091 proto tcp
(9091 = the plain-HTTP forward listener on tunnel_port + 1.)
HTTP 403 from the proxyThe agent inside the sandbox tried to hit a host that isn't in proxy.extra_allowed_hosts. The 403 body explains which host. If you want to allow it, add to your config:
proxy:
extra_allowed_hosts:
- api.example.com
- "*.staging.example.com"
Then hermes egress setup (to regenerate proxy.yaml) and hermes egress stop && hermes egress start.
Either the CA isn't mounted in the sandbox (rare; the docker backend does this automatically when proxy.enabled: true), or your image's HTTP client is reading from a non-standard env var.
# Inside the sandbox:
cat /etc/ssl/certs/hermes-egress-ca.crt | head -1
# Should print: -----BEGIN CERTIFICATE-----
env | grep -E "^(REQUESTS|CURL|SSL|NODE).*CA"
# Should list all four CA-bundle env vars pointing at /etc/ssl/certs/hermes-egress-ca.crt
If the cert isn't there, check that proxy.enabled: true AND hermes egress status shows Listening yes. If the env vars are missing, the sandbox image might be running an entrypoint that strips them — check your docker_env config.
HTTP 401 from upstreamsTwo common causes:
hermes egress setup --rotate-tokens (or rotated tokens some other way) and the running sandboxes still hold the old tokens. Restart the sandboxes.proxy.allow_env_fallback: true set, the daemon may have started with stale env values. Check the daemon's environment (/proc/<iron-proxy-pid>/environ) for the expected OPENROUTER_API_KEY etc.The parent Hermes process died during hermes egress start (Ctrl-C during the listening probe, OOM, panic). The new fix-up logic writes the pidfile immediately after Popen so the orphan is recoverable:
hermes egress stop # finds the orphan via the pidfile, kills it
hermes egress start
If hermes egress stop says "iron-proxy was not running" but you can still see the daemon in ps, the pidfile got out of sync. Manual recovery:
pkill -TERM iron-proxy
rm -f ~/.hermes/proxy/iron-proxy.pid ~/.hermes/proxy/iron-proxy.nonce
hermes egress start
On the pinned binary version (v0.39) both daemon-level events and per-request records land in ~/.hermes/proxy/iron-proxy.log. The format is line-delimited JSON. Grep for a specific upstream:
grep '"upstream":"openrouter.ai"' ~/.hermes/proxy/iron-proxy.log | tail -20
Or watch in real-time:
tail -f ~/.hermes/proxy/iron-proxy.log | jq
When the pinned version moves to v0.40+ (which adds log.audit_path), per-request records will move to ~/.hermes/proxy/audit.log and iron-proxy.log will hold only daemon-level events. Until that bump, audit.log is an empty placeholder (pre-created at 0o600 so the future daemon inherits tight permissions) — wire your logrotate / monitoring tooling to iron-proxy.log today and plan to add audit.log after the version bump.
x-api-key, api-key, x-goog-api-key) are all covered.openssl genrsa ... by hand (or wait for a follow-up that adds hermes egress rotate-ca).hermes egress reload for ruleset-only changes) and restart already-running sandboxes after token rotation./proc/<pid>/mem read access can read swapped-in secrets from the daemon's memory.proxy.http_listens (plural) and log.audit_path, a version bump can wire in multi-bind and the dedicated audit stream.hermes secrets bitwarden