Skip to content

8 min read

Egress belongs behind a policy, not behind a switch

On this page

The review action item was one line — sandbox containers must not have network access — and it was implemented on the Friday as --network none. On Monday the harness completed 3 of 191 tasks. The three that succeeded were documentation edits. Everything else produced some version of this:

text
$ pnpm install --frozen-lockfile
 WARN  GET https://registry.npmjs.org/@acme%2Fsdk error (ERR_INVALID_THIS)
 ERR_PNPM_META_FETCH_FAIL  GET https://registry.npmjs.org/@acme%2Fsdk:
 request to https://registry.npmjs.org/@acme%2Fsdk failed,
 reason: getaddrinfo EAI_AGAIN registry.npmjs.org

By Monday afternoon there was a pull request titled "unblock the agent queue" that added --network bridge and a comment explaining that the sandbox still had no inbound access, so the risk was limited. Both the Friday change and the Monday change treat egress — the traffic a sandbox sends outward, to package registries, internal services and anywhere else on the internet — as a boolean, and the boolean has no setting that is correct: off means the sandbox cannot build the software it exists to modify, and on restores the path that carried 190 MB of compressed customer data to transfer.sh in chapter 5.

What the traffic actually is

The way out of a false binary is usually to measure. Before deciding anything, the team ran the proxy described below in report-only mode for two weeks — every connection allowed, every connection logged with the run identifier, the SNI hostname, the byte counts and the outcome. SNI is Server Name Indication, the field in which a TLS client announces the hostname it wants before the encrypted session begins, and it is the only part of an HTTPS connection a middlebox can read without decrypting anything. Across 2,680 tasks there were 411,904 outbound connections to 63 distinct hostnames, and nine hostnames accounted for 99.2% of them:

text
registry.npmjs.org              298,113
files.pythonhosted.org           41,882
git.internal.acme.com            29,540
ghcr.io / pkg-containers.ghcr.io 18,207
pypi.org                          9,116
broker.dev.acme.internal          4,455
artifacts.acme.internal           3,902
api.github.com                    2,338
sentry.io                           301

The remaining 54 hostnames and 3,150 connections are the interesting part of the report. Most were benign and unnecessary: telemetry endpoints baked into build tools, a font CDN pulled in by a docs build, three different analytics hosts belonging to package managers. Four were not: a run had fetched a shell script from a paste site because a README in a transitive dependency told it to, and three runs had resolved hostnames that appeared in issue bodies submitted through the external queue. None of those four caused damage, and none of them would have appeared anywhere at all under either the Friday policy or the Monday one, because a blocked connection with no logging is silent and an allowed connection with no logging is invisible.

Deny by default, at the layer the run cannot reach

The policy is a proxy that terminates every outbound connection, matches the requested hostname against an allowlist, and logs the decision either way. The part that makes it a control rather than a suggestion is where it is enforced.

Flowchart: Run opens a socket → DNS query; DNS query → NXDOMAIN, logged (resolver has no record); DNS query → CONNECT via egress proxy (allowed name); CONNECT via egress proxy → SNI on allowlist?; SNI on allowlist? → Upstream dial, log allow + bytes (yes); SNI on allowlist? → 403, log deny, alert (no); Run opens a socket → No route: dropped at netns, logged (direct, bypassing proxy).

Setting HTTPS_PROXY in the environment is not enforcement. It is a request to well-behaved HTTP libraries, honoured by curl and ignored by anything that opens a socket itself, and a run that has read a paragraph telling it to unset the variable will unset it. The proxy has to be the only route out of the network namespace, which means the sandbox gets no default gateway, a single route to the proxy address, and a resolver that answers only for names on the list. A run that dials an IP address directly gets no route, and the drop is logged with the destination, which turns an attempt into a signal.

yaml
# egress-policy.yaml — evaluated per connection
default: deny
rules:
  - hosts: ["registry.npmjs.org", "pypi.org", "files.pythonhosted.org"]
    methods: [GET, HEAD]            # publish is not a build operation
    max_response_bytes: 500_000_000
  - hosts: ["git.internal.acme.com"]
    methods: [GET, POST]
  - hosts: ["broker.dev.acme.internal", "artifacts.acme.internal"]
  - hosts: ["ghcr.io", "pkg-containers.ghcr.io"]
    methods: [GET, HEAD]
  - hosts: ["api.github.com"]
    methods: [GET]
    max_request_bytes: 65_536       # comments, not payloads
log: [allow, deny]
alert_on: [deny, "allow where request_bytes > 10_000_000"]

Two of those constraints are doing work that a hostname allowlist alone does not. Restricting the registries to GET and HEAD removes npm publish as a channel for the exfiltration of chapter 5 while leaving installation untouched, which matters because a package published from a sandbox is a public artefact containing whatever the run put in it. Capping request bytes on api.github.com distinguishes an agent leaving a review comment from an agent creating a gist with a database dump in it, and both are the same host.

The internal entries need more care than the public ones, because "no inbound access" says nothing about lateral reach. artifacts.acme.internal accepted anonymous uploads for years on the reasoning that only trusted hosts were on the network, and the sandbox is now a host on that network running text written by strangers. Every internal hostname on an allowlist is a service whose authentication model has to be re-examined under the assumption that the caller is not trustworthy, and that review is usually more work than the proxy itself. The link-local metadata address deserves an explicit drop rather than an implicit one: 169.254.169.254 needs no DNS, so a resolver-based control never sees it, and the route out of the namespace has to blackhole 169.254.0.0/16 outright.

Getting from report-only to enforcing

The two-week measurement is not just for building the list, it is what makes the switch to enforcement survivable. Turning the policy on with the nine hostnames above and the two-week log as evidence produced 14 denied connections on the first day, of which 11 were telemetry that nobody missed and 3 were a genuine gap — a build step fetching a Rust toolchain from static.rust-lang.org. The gap took an hour to close, because the run surfaces the denial as a structured error naming the host, and adding a host is a two-line pull request against the policy file with the run log attached as justification.

text
egress denied: static.rust-lang.org (rule: default)
run_id=run-d41a09 task=PLAT-2301
to request access: https://tooling.acme.internal/egress/request?host=static.rust-lang.org

The team that skips report-only ships the same policy from a whiteboard, discovers the gap as 40 failed tasks, and reaches for the Monday pull request again. The sequencing is the difference between an allowlist derived from what builds actually do and an allowlist derived from what somebody remembers builds doing.

Performance turned out to argue for the proxy rather than against it. Terminating and re-dialling added 6 ms at p50 to each connection, and because the same proxy was configured as a pull-through cache for the two registries, keeping its own copy of each package it fetched and serving that copy to the next run that asked, a cold pnpm install fell from 2 minutes 50 seconds to 1 minute 12 on the second and subsequent runs of the day. A control that makes the common path faster does not attract the flag that chapter 2 described, and building the cache at the same time as the policy is the cheapest way to buy the policy's political survival.

Reading the log

The log line is the artefact the whole design exists to produce, and it is worth being deliberate about its fields.

json
{"ts":"2026-03-16T11:02:41Z","run_id":"run-c02f11","task":"PLAT-2288",
 "sni":"registry.npmjs.org","method":"GET","decision":"allow",
 "req_bytes":412,"resp_bytes":1841203,"duration_ms":244}
{"ts":"2026-03-16T11:03:02Z","run_id":"run-c02f11","task":"PLAT-2288",
 "sni":"transfer.sh","method":"CONNECT","decision":"deny",
 "rule":"default","req_bytes":0,"resp_bytes":0}

Denies are an alarm and allows are an audit. The second line is worth paging someone about, because nothing in a legitimate build has any reason to reach a file-transfer host and the run identifier attaches it to a specific task and a specific prompt. The first line is worth keeping because "the boundary was in place" is a claim that has to be demonstrable months later, and only a log of permitted traffic can demonstrate it — a policy file shows what was configured, and a log shows what happened. That distinction is the entire subject of chapter 9.

What the allowlist cannot do

An allowlist entry for a host that accepts user-supplied content is an egress channel, and every list above contains several. api.github.com accepts issue comments. registry.npmjs.org serves package metadata, and a run can read a specific package's README on demand, which is a low-bandwidth inbound channel for instructions. git.internal.acme.com accepts pushes, and a branch is a file upload. The byte caps and method restrictions narrow these to a trickle rather than closing them, and a determined channel — encoding data into DNS labels for a permitted domain, or into the timing of permitted requests — survives all of it at a few hundred bytes a minute. The right way to hold that limitation is that the policy raises the cost of exfiltration from one curl to a deliberate covert channel, and moves the volume from megabytes per second to bytes per minute, which is a change in kind for the incident that follows even though it is not a closed door.

ApproachGranularitySees plaintextBreaks onCost
IP allowlistAddressNoCDN IP churn, anycastTrivial
SNI allowlistHostnameNoEncrypted ClientHelloLow
Full TLS interceptionHost + path + bodyYesCertificate pinningHigh
Application broker (ch 6)Named actionYes, by designUnenumerated workHighest

SNI matching is where most teams should land, because it needs no certificate authority in the sandbox's trust store and the proxy never holds plaintext, which keeps it out of scope for the disclosure problem in chapter 5. Full interception buys path-level rules and costs a private CA that every build tool has to trust, a proxy that now sees every token in every request, and a permanent argument with any tool that pins certificates, meaning it refuses any certificate other than the specific one it was built to expect. Encrypted ClientHello, the TLS extension that hides the requested hostname inside the handshake rather than sending it in the clear, will eventually make SNI matching unreliable on the public internet, and the fallback is a mirror of the registries inside the network so that the hostnames that matter are ones you operate.

This is also not a control that stands alone. Egress policy does nothing about what a run does with a credential it holds against a permitted host, which is why it sits alongside chapters 5 and 6 rather than replacing them, and it says nothing about the filesystem or the process table from chapter 3. What it changes is the assumption underneath both the Friday and the Monday position: that the network is one thing that is either available or not. The set of destinations a build genuinely needs is small, stable and measurable, and once it is written down the correct answer to "should the sandbox have network access" is a list rather than a boolean.

Every control in this topic so far — the workspace, the minted credentials, the proxy rules, the broker's per-run identity — creates state on a machine that has to be taken away again when the run finishes. The taking-away is the part nobody designs, because it is one line at the bottom of the function that starts everything else, and that line sits in the least reliable position in the whole system.

Sandboxed developer workspaces — read the current version at https://daviddominguez.dev/learn/en/sandboxed-developer-workspaces/egress-belongs-behind-a-policy-not-behind-a-switch

Back to top