How to sandbox AI-generated code with default-deny networking
Kiwi's isolation model rests on one decision: the Architect and Implementer run in the daemon process; only your repository's own commands run in the sandbox.
That sandbox runs in two phases, because the two things a repository needs are in direct conflict. Installing dependencies requires a network. Running model-generated code must not have one. Trading one against the other is what most designs do; Kiwi separates them instead.
Two phases
| Phase A — install | Phase B — verify | |
|---|---|---|
| Network | on | off (--network none) |
| Credentials | none — an empty environment | the ones the test command needs |
| What runs | the repository's own lockfile install | your test command, over model-generated code |
| When | once, before the loop | every iteration of the loop |
Stated precisely, the security property is stronger than "the sandbox has no network":
Model-generated code never has network access, and the phase that does never holds a secret.
Phase A is handed a nil environment — not the org's git token, not a registry credential, and not a model key. A malicious postinstall hook in a dependency can therefore reach the network, but has nothing to send. Phase B has the credentials the test genuinely needs and no route off the machine.
The trade is explicit and deliberate: dependencies behind a private registry cannot be installed, because handing that credential to a networked container running third-party install hooks is precisely the exposure the split exists to avoid. Public dependencies only, for now.
Both phases run in the same image and the same runtime, so dependencies land where they will be used. Phase A's command is chosen by lockfile, not by guesswork — pnpm-lock.yaml, yarn.lock and package-lock.json all describe the same package.json, and running the wrong one fails outright. Go, Python, Rust, Ruby, PHP, Maven and Gradle are recognised the same way; a repository that declares no dependencies simply skips the phase.
Phase A is bounded by a timeout (5 minutes by default, KIWI_INSTALL_TIMEOUT), and a failed install ends the task with the reason attached rather than being left for the loop to discover. Letting the Implementer see Cannot find module 'react' invites it to earnestly edit code, burning the whole budget to learn it could not have worked.
The model keys are never in either phase
The Anthropic, OpenAI or Gemini key lives in the daemon process, which calls the provider directly. Every model key — ANTHROPIC_API_KEY, OPENAI_API_KEY, GEMINI_API_KEY — is withheld from the sandbox environment, while non-model credentials the test command genuinely needs are still passed through. See Models & provider keys.
The sandbox is persistent across a round's tool calls, so the Implementer can run several commands in the same environment. By default it receives no credentials at all beyond the two-phase split above, to protect secrets while it freely explores. If your tests require credentials, opt in explicitly by setting KIWI_SESSION_ALLOW_TEST_CREDS=true.
This is why Kiwi describes its differentiation as the layer above the sandbox — the Architect/Implementer session — rather than the sandbox itself. The sandbox is a well-scoped, minimal-trust execution surface.
Which image the sandbox uses
You never pass an image. The daemon works it out from what the repository already declares, in order:
- a devcontainer image, if there is one;
- the test command's executable — the strongest signal, because the command names the binary that has to exist. It is what settles a polyglot repo: a Go service with a frontend has both
go.modandpackage.json, and only the command says which is being tested; - marker files, with versions read from
go.mod,.nvmrc,engines.nodeand.python-version.
It always returns something, since there is nobody to fall back to. And it self-corrects: the first failing run is classified, and a missing runtime or a version mismatch swaps the image and re-runs once, before any of that output reaches the Implementer. Only the first failure is examined, so a genuine test failure costs nothing extra, and anything unrecognised is treated as an honest result — falsely blaming the environment would mask a real failure.
When a repository cannot be verified offline
Some projects need the network to build, not merely to install — a Next.js app importing next/font/google downloads the font on every build, so warming a cache in phase A does not help. The fetch is part of the build, and the build is exactly what must run without network.
No agent can fix that by editing code, so Kiwi says so instead. The first verification run is classified: at that point nothing model-generated has executed, which is what makes the call safe to make. The loop stops at step 0 and the task reports the cause by name. The classification is deliberately narrow — ordinary failures like assertion errors, type errors and unused variables are never blamed on the network, because telling a user to change their repository when the agent simply gave up is the more damaging mistake of the two.
The drivers
The sandbox driver is pluggable (pkg/sandbox), selected per daemon. Kiwi ships three:
| Driver | Selected by | Use |
|---|---|---|
| Docker | default | Development and BYOC — the test runs in a container. |
gVisor (runsc) | -sandbox-runtime runsc / KIWI_SANDBOX_RUNTIME=runsc | The shared Free tier. runsc is a user-space kernel that intercepts syscalls, giving stronger isolation between co-tenant workloads on a shared host. |
| Firecracker | KIWI_SANDBOX=firecracker | A microVM driver for hardened managed execution. |
:::note Status The Docker and gVisor drivers are on the live path — Docker for dev/BYOC, gVisor for the deployed Free tier. The Firecracker microVM driver for the managed-dedicated path is built but not yet deployed or hardware-validated. Check the status table for the current state. :::
Resource bounds
Beyond isolation, the test run is bounded so a single task cannot monopolize a host:
- A wall-clock timeout per task, from the org's
TaskTimeoutSecondslimit. - A sandbox disk ceiling (
MaxSandboxDiskMB). - The daemon-level session caps — max-rounds and session-budget — bound how many times the sandbox is invoked per task (see the Data Plane daemon).
Egress isolation on the shared host
On the Free tier, the free-fleet host runs each daemon's sandbox as a sibling container under runsc. Egress is handled in two layers, because they defend different things:
- The verification sandbox has no network at all —
--network none, set by the daemon and locked by a unit test so it cannot regress silently. This is the layer that contains model-generated code. - The host blocks the cloud metadata endpoint. The daemon container legitimately has network — it must reach the Control Plane and the LLM APIs — and on a cloud VM the metadata endpoint (
169.254.169.254) serves the machine's service-account token. Rules in Docker'sDOCKER-USERchain drop traffic to its token ports while leaving:53open, because on GCP that same address is also the DNS resolver and blocking the whole IP takes the fleet offline. Public egress is untouched; RFC1918 blocking is available but off by default.
Layer 2 ships as scripts in the repository's deploy/free-fleet/ (with a verifier that proves the metadata fetch fails while ordinary HTTPS still works) and is applied by the fleet operator, since iptables rules do not survive a reboot on their own. See the status table.