# Why your agent's sandbox shouldn't reach the whole internet Give an agent's sandbox an allow list of the few hosts its task needs, because every other reachable host is a place your data can go. **Runtime (withruntime.com) enforces each sandbox's allow list in a proxy on the host, outside the Firecracker microVM, so even root inside the sandbox cannot widen it, and a change applies at once, to connections already open.** This post explains what open internet access gives an attacker, why an allow list beats a block list, which hosts real agent tasks need, and why some allowed hosts are still a way out. It ends with a probe you can run against your own list. ## What can an agent do with open internet access? Anything a program on the internet can do, with your account's name on it. An agent with a shell runs commands a model chose, and some of those choices come from text an attacker wrote. With open outbound access, an injected command can: | What the command does | What it costs you | | ------------------------------------------- | --------------------------------------------------- | | Uploads the repository to a request catcher | Your source code, on someone else's server | | Sends `env` or `.env` files out | Every key the sandbox could read | | Downloads and runs a second-stage script | Whatever that script does, with the agent's access | | Joins a cryptocurrency mining pool | Your CPU bill, and an abuse report | | Scans or attacks other sites | Abuse reports traced to your account | | Installs a typo-squatted package | Malware inside your build, then inside your product | Isolation stops a bad command from reaching your machine. Network rules stop it from reaching everyone else, and from carrying your data with it. The two are separate defenses, and an agent needs both ([what egress control is](/glossary/egress-control)). ## Why is an allow list better than a block list? A block list names the places code may not go, and the internet has more places than you can name. An attacker who finds `pastebin.com` blocked uses `paste.ee`, a fresh domain registered this morning, or a bare IP address. Every block list is one step behind. An allow list names the places code may go, and the agent's task decides how short it is. A coding agent that installs Python packages and clones from GitHub needs four or five hosts. Anything else is refused, including the domain registered this morning. The trade-off is maintenance: a task that needs a new host fails until you add it. That failure is loud and immediate, which is what you want. A block list's failure is silent and arrives later. ## Which hosts does an agent task actually need? Fewer than you expect. These are the hosts the common package managers and code hosts use for downloads: | Task | Hosts to allow | | ----------------------------- | ---------------------------------------------------------------------------- | | `pip install` | `pypi.org`, `files.pythonhosted.org` | | `npm install` | `registry.npmjs.org` | | `cargo build` | `index.crates.io`, `static.crates.io` | | `go mod download` | `proxy.golang.org`, `sum.golang.org` | | `git clone` from GitHub | `github.com`, plus `*.githubusercontent.com` for raw files and releases | | `docker pull` from Docker Hub | `registry-1.docker.io`, `auth.docker.io`, `production.cloudflare.docker.com` | | Calling a model from inside | `api.anthropic.com` or `api.openai.com` | To find a task's list, start narrow and run it. A refused HTTPS connection fails at once: `curl` prints `CONNECT tunnel failed, response 403`, and a plain HTTP request comes back with an `X-Runtime-Egress: rule-not-allowed` header. Add the host if the task truly needs it, and run again. Most lists settle after two or three runs. On Runtime, `allow` takes domains, `*.domain` for every name under one, addresses and CIDR ranges, and `deny` always wins, so you can allow a wildcard and carve one name out of it ([allow only some hosts](/how-to/allow-only-some-hosts)). ## Why is an allowed host still a way out? Because some hosts accept uploads as well as downloads, and an injection can bring its own credentials. The text that tells your agent to run a command can also contain the attacker's token. Read your list with the attacker's eyes: | Allowed host | What your task does there | What an attacker could do there | | ------------------------------------ | ------------------------- | ----------------------------------------------------------------- | | `pypi.org`, `files.pythonhosted.org` | Download packages | Nothing useful: uploads go to `upload.pypi.org`, a different host | | `registry.npmjs.org` | Download packages | `npm publish` your code as a package, with their own token | | `github.com` | Clone, maybe push | Push your code to their repository, with their own token | | `*.githubusercontent.com` | Download raw files | Nothing useful: it serves files, it does not accept them | | `registry-1.docker.io` | Pull images | Push an image holding your code, with their own login | | `api.anthropic.com` | Call the model | Send your code as a prompt to their account, with their own key | Three ways to close those channels, from strongest to weakest: 1. **Open the network for setup, close it for the agent.** Install everything first, then cut the list down before the agent runs a single command. The next section shows it. 2. **Call the model from outside the sandbox.** If your agent loop runs on your server and only sends commands into the sandbox, the sandbox never needs the model's host at all. 3. **Use a read-only mirror.** A package mirror you run, which serves downloads and refuses uploads, replaces a public registry that does both. For the hosts you must keep, store your own credentials as secrets the sandbox never holds, scoped by method and path ([secrets sandboxes never see](/docs/security#secrets-sandboxes-never-see)). That stops your token from being used for the wrong thing; the steps above stop the attacker's. ## How do you open the network for setup and close it for the agent? Create the sandbox with the hosts setup needs, do the setup, then change the rules before the agent's first command. On Runtime a change applies at once, to connections already open too, and the files and processes setup left behind are untouched: ```ts check import { Sandbox } from "withruntime"; const SETUP = ["pypi.org", "files.pythonhosted.org", "github.com", "*.githubusercontent.com"]; await using sbx = await Sandbox.create({ network: { internet: true, allow: SETUP } }); await sbx.exec("git clone --depth 1 https://github.com/pallets/flask.git app", { check: true, timeoutMs: 300_000, }); await sbx.exec("pip install --quiet -e ./app pytest", { check: true, timeoutMs: 300_000 }); // Setup is over. From here on, the code the agent writes reaches nothing. await sbx.network.set({ internet: false }); const tests = await sbx.exec("python3 -m pytest -q -x", { cwd: "/workspace/app", timeoutMs: 600_000, }); console.log(tests.exitCode, await sbx.network.get()); ``` ```python check from withruntime import Sandbox SETUP = ["pypi.org", "files.pythonhosted.org", "github.com", "*.githubusercontent.com"] with Sandbox.create(network={"internet": True, "allow": SETUP}) as sbx: sbx.exec("git clone --depth 1 https://github.com/pallets/flask.git app", check=True, timeout_ms=300_000) sbx.exec("pip install --quiet -e ./app pytest", check=True, timeout_ms=300_000) # Setup is over. From here on, the code the agent writes reaches nothing. sbx.network.set(internet=False) tests = sbx.exec("python3 -m pytest -q -x", cwd="/workspace/app", timeout_ms=600_000) print(tests.exit_code, sbx.network.get()) ``` If the agent later needs one more package, open the list for that install and close it again, from your code, not from the agent's. The agent asks; your code decides. That keeps the decision outside the machine where the injected text lives. A sandbox can stay closed and still be useful for a long time. Tests, builds, linters and type checkers run offline once dependencies are installed, and those are most of what a coding agent runs. ## Where should the rule be enforced? Outside the machine the agent controls. A rule the agent's own machine enforces is a rule the agent's commands can remove: | Where the rule lives | What undoes it | | -------------------------------------- | ------------------------------------------------------ | | An `HTTP_PROXY` setting in the sandbox | `unset HTTP_PROXY`, or any program that ignores it | | Firewall rules inside the sandbox | Root inside the sandbox, which most agent images grant | | A filtering proxy container beside it | Any other route out that nobody closed | | A proxy on the host, outside the VM | Nothing inside the sandbox | A Runtime sandbox has no network card of its own. Every connection leaves through the host's proxy, which applies the sandbox's rules to programs that honor proxy settings and to programs that open raw sockets alike. Root inside the sandbox can change its own firewall all day; the rules live somewhere it cannot reach. Private and internal addresses, the cloud metadata address among them, are refused for every sandbox whatever its list says ([the network](/docs/sandbox-environment#the-network)). ## How do you test an allow list? Probe it from inside, with the hosts an attacker would reach for. Request catchers, paste sites and DNS-over-HTTPS resolvers are the usual choices, because each one accepts arbitrary data over ordinary HTTPS: ```ts import { Sandbox } from "withruntime"; const allow = ["pypi.org", "files.pythonhosted.org", "github.com"]; const probes = [ "pypi.org", // on the list "github.com", // on the list "example.com", // not on the list "webhook.site", // a request catcher "pastebin.com", // a paste site "dns.google", // DNS over HTTPS can carry anything "1.1.1.1", // a bare address, which slips past many name filters ]; await using sbx = await Sandbox.create({ network: { internet: true, allow } }); for (const host of probes) { const command = `curl -s -o /dev/null -m 5 https://${host}/ && echo reachable || echo refused`; const probe = await sbx.exec(command, { timeoutMs: 15_000 }); console.log(`${host.padEnd(14)} ${probe.stdout.trim()}`); } ``` ```python from withruntime import Sandbox allow = ["pypi.org", "files.pythonhosted.org", "github.com"] probes = [ "pypi.org", # on the list "github.com", # on the list "example.com", # not on the list "webhook.site", # a request catcher "pastebin.com", # a paste site "dns.google", # DNS over HTTPS can carry anything "1.1.1.1", # a bare address, which slips past many name filters ] with Sandbox.create(network={"internet": True, "allow": allow}) as sbx: for host in probes: command = f"curl -s -o /dev/null -m 5 https://{host}/ && echo reachable || echo refused" probe = sbx.exec(command, timeout_ms=15_000) print(f"{host:<14} {probe.stdout.strip()}") ``` On Runtime the two listed hosts print `reachable` and the other five print `refused`, the bare address included, since an address is matched against the list like a name. Run the same probe wherever your agent runs today. Any `reachable` you did not expect is a place your data can go, and it belongs in a test that fails the day someone widens the list. For a fuller drill that plants an injection and plays a model that obeys it, see [how prompt injection becomes code execution](/blog/prompt-injection-to-code-execution). ## In short - Open internet access lets an injected command send your data anywhere and act in your name. - An allow list beats a block list, because an attacker always has a host you did not block. - Some allowed hosts accept uploads with an attacker's own token: read the list with their eyes. - Open the network for setup, then close it before the agent runs, and change it from your code, not the agent's. - Enforce the rule outside the machine the agent controls, and probe it from inside. ## Run it on Runtime Runtime costs 42% to 88% less than fourteen other sandbox providers for an agent that mostly waits on a model ([compare costs](/how-to/compare-your-costs)). Every Runtime sandbox reaches the internet only through a proxy on its host, takes an allow list at create or at any time after, and refuses private addresses whatever the list says. A new sandbox runs its first command 221 ms after the create request on Runtime's servers, at $0.025 per vCPU-hour of CPU used. Start with 100 free hours, no card: [sign in](/sign-in), or read [turn off a sandbox's internet](/how-to/turn-off-sandbox-internet) and [get started](/docs/start).