arizuko › components › egress
egress
egress is a forward proxy that lets one client reach one fixed list of host names, and runed starts one for each turn.
egress is a forward proxy for one client and one fixed list
egress is a forward HTTP and CONNECT proxy that allows one fixed list of hosts, for one client. It reads the destination from the request of the client, checks it against the allowlist, and either splices the connection or answers 403. The program does nothing more.
runed starts one proxy per turn, with the allowlist of that folder fixed at start, and stops it when the turn ends. No long-lived proxy, no registry of clients and no admin API exist. Those parts are necessary only when one proxy serves many tenants, and one proxy per turn needs none of them.
Only the path out of the guest knows both the folder and the host
An agent runs generated code with network access. You want it to reach github.com and api.anthropic.com. You do not want it to reach your internal services. You also do not want a crypto miner from a compromised npm package, or a host that a prompt injection names. A firewall on the network does not know which folder calls. Rules per process are hard to maintain. The proxy sits in the one place that knows both: the only path out of this turn.
One proxy per turn also prevents a leak. A shared proxy must identify tenants by source address and depends on each tenant unregistering when its process exits. If an address is reused at the wrong moment, a stale entry gives the next tenant the allowlist of the previous folder. With one proxy per turn, no registry exists, so no entry can go stale.
The tap rule set confines the guest, and the proxy filters hosts
runed brings up the tap of the turn and its rule set, and starts the proxy on the host side of the tap. It waits for the /health of the proxy, and only then starts the guest. Everything that the guest sends reaches the tap first:
┌──────────────────────────────┐
│ KVM guest │ a raw socket to any address,
│ no resolver, one tap, ├──────────────────────► dropped by the tap
│ HTTPS_PROXY = its own proxy │ DNS on UDP/53, IPv6
└──────────────┬───────────────┘
│ TCP to the proxy port: the one accepted path
▼
┌─────────────────────────────────────────────────────────────┐
│ tap rule set on the host side, iptables │
│ ACCEPT guest ip ► host ip on the proxy port, and the reply │
│ DROP every other packet on the tap: INPUT, OUTPUT, FORWARD │
│ disable_ipv6 on the tap before the link comes up │
└──────────────┬──────────────────────────────────────────────┘
▼
┌─────────────────────────────────────────────────────────────┐
│ egress proxy: one process per turn, healthy before boot ├──► 403 to
│ --client: the guest's address only │ any other
│ --allow: network_rules of the folder and its ancestors, │ peer, or a
│ resolved by routd at dispatch, plus the gateway host │ host off
│ --allow-all: egress grant, /root turn or a `*` row │ the list
│ CONNECT spliced, never decrypted; dials the checked address │
└──────────────┬──────────────────────────────────────────────┘
▼
allowed host
The tap rule set does the confinement
The guest has exactly one network interface, its tap. The static rule set of that tap accepts only the proxy port of the guest and drops every other packet in both directions, UDP/53 included. The proxy runs on the host side of that tap, inside the container of runed, so it reaches the network that runed is on. A process in the guest can ignore HTTPS_PROXY and open a raw socket to an IP address. It reaches nothing, because the tap drops the packet below the kernel of the agent. No shared network namespace exists to escape into, because nothing else is on that tap.
The proxy is healthy before the guest starts
The proxy answers GET /health with 200 only after its listener is bound and its allowlist is loaded. runed polls that endpoint for up to 15 seconds and starts the guest only on a 200. A TCP connect would not be enough, because it succeeds as soon as the port is bound. If the proxy does not start or never reports healthy, the turn fails. If the egress binary is missing, runed refuses to start at all, so no turn ever runs without the filter.
The proxy serves exactly one address
--client is the tap address of the guest, and every other peer gets a 403, including a concurrent turn in the same folder. Concurrent turns in one folder do not all hold the same list: an elevated /root turn has no limit. Without the pin, a plain turn could send its CONNECT to the proxy of a neighbour and use rules that it was never granted. The pin is a bind scope, not a lookup: nothing is registered, and the proxy refuses a second client.
- Inputs
- The resolved allowlist of the folder.
store.ResolveAllowlistreads thenetwork_rulesof the folder and its ancestors and removes duplicates. - Outputs
- One proxy process per turn, a
403for every host not on the list, and a502for an allowed host that does not resolve or does not answer.
The proxy matches host names only
The proxy matches host names only, without case and without a trailing dot. A name with a dot matches itself and its subdomains, so --allow github.com covers api.github.com, and *.example.com means the same as example.com. A name without a dot, such as localhost or a container service name, matches only itself and never as a suffix. Without this rule, an entry such as com would allow every host under .com. The proxy checks the NAME, because the client puts the name in the request line. No rules for paths or methods exist.
An empty allowlist denies everything. That is a valid rule for a folder with no network grants. So the proxy starts, logs a warning and does not refuse to run.
Allow-all is a separate mode, --allow-all, not a list entry. runed turns it on when the folder holds the egress grant, when the turn is an elevated /root turn, or when the operator wrote a * row with arizuko network. The default role:member does not carry that grant at any folder depth. A --deny entry wins over every allow, also in allow-all mode, and covers the subdomains of the name.
The proxy never decrypts traffic
- No TLS termination, no CA and no key material: the proxy tunnels CONNECT and never decrypts it.
- No access to request or response bodies, no content filter and no secret injection.
- No registry, no admin API and no client library.
- No transparent interception and no SNI reading: forward mode reads the destination from the request line, so neither is necessary.
- No DNS lookup of its own, and none is necessary. The proxy never sees a plain DNS query, because the guest has no resolver, and a name means something only inside a CONNECT line. See the “No DNS filter” section of security.
- No supervision: the lifetime of the turn is the lifetime of the proxy, and runed owns both.
The proxy does not intercept TLS, by design. Header injection into an opaque client would be the only use for interception. A CA inside every guest costs more than that use is worth. A proxy that never terminates TLS has nothing to leak if an attacker controls it, and that keeps it small enough to audit.
egress runs alone as one binary
egress runs alone. It is a plain Go binary with no dependency on any other part of arizuko.
# build the image (repo root)
make images # produces arizuko-egress
# or the bare binary
make -C egress build # produces ./egress/egress
# run it: allow two hosts, serve one client
./egress/egress --listen :3128 --allow api.anthropic.com,github.com --client 10.99.4.7
| flag | env | default | meaning |
|---|---|---|---|
--listen | EGRESS_LISTEN | :3128 | listen address |
--allow | EGRESS_ALLOW | empty | comma-separated allowlist of host names |
--deny | EGRESS_DENY | empty | comma-separated denylist. A deny wins over every allow |
--allow-all | EGRESS_ALLOW_ALL | off | allow every host name. --deny and the address checks still apply |
--client | EGRESS_CLIENT | empty | the only peer address served. Empty accepts any peer |
--deny-private | EGRESS_DENY_PRIVATE | off | also refuse destinations that resolve into a private range |
--allow-loopback | EGRESS_ALLOW_LOOPBACK | off | allow destinations on this host, for local development |
For each turn, runed passes --listen, --client, --allow and, with the grant, --allow-all. The allowlist is fixed for the life of the process. To change the rules, start a new proxy, which is what happens on every turn.
GET /health returns 200 {"status":"ok"} after the listener is bound and the allowlist is loaded, and 503 {"status":"starting"} before. The proxy answers it before the --client check, because the program that starts the sandbox is not the client. Proxy requests arrive in absolute form, GET http://host/path, so an origin-form GET /health is clearly a probe of this process and not traffic to forward.
You can run the same proxy on your own machine
Start the binary alone to try the same setup on your own machine. Production does the same: runed starts one egress process per turn and stops it with the turn.
egress --listen 127.0.0.1:3128 --client 127.0.0.1 --allow github.com,api.anthropic.com
HTTPS_PROXY=http://127.0.0.1:3128 curl https://github.com
Production puts the same proxy on the host side of the tap of the guest. So the only route out of the guest is through the proxy. The tap enforces this below the kernel that the agent runs on. No setting turns it off.
The allowlist is the only setting
egress isolation has no switch: every turn gets a proxy, and runed refuses to start if the egress binary is missing. The allowlist of each folder is what you configure:
arizuko network -i <instance> allow <folder> api.github.com
The tap subnet and the proxy port are settings of runed, not of the egress binary. See [runed/README.md].
Five sources cover the details
- security: where egress sits among the other controls on an agent run.
- components/runed: the daemon that starts the proxy and then the agent, in that order.
- reference/cli:
arizuko network allow,deny,listandresolve, which write the rules that the proxy starts with. - [egress/README.md]: the directory and its contents.
- [SECURITY.md § Network egress isolation]: the rule set, the IPv6 seal and the allowlist rules in one place.