Zac Sheffer · 2026.10.15 · 11 min read
Written with Evan Youngblood, Founding AI Engineer at Introzy, who ran the evaluation and the spike described here.
Atlas is our internal coding agent. Give it a small, well-scoped Linear ticket and it returns a draft pull request against our monorepo. To do that it needs a disposable Linux machine running the whole introzy-core dev stack: a Next.js dev server, Postgres, Redis, and a headless browser.
In July we spent two days choosing where that machine would live. On paper, Cloudflare's Sandbox SDK fit our security requirements best, because it let us enforce network and credential policy in code that runs outside the sandbox. During the spike we couldn't get our container to start, and with a deadline close we shipped on exe.dev instead.
Since then Cloudflare has rebuilt Containers for agent sandboxes, and we're planning to test it again. Treat the scorecard below as a record of July 2026. Two of the three vendors have shipped real changes since, and where the docs have moved, we say so.
Pricing pages compare vCPUs and RAM. Our first job was finding out how much we needed. Evan ran our full verification tier (typecheck, unit tests, the dev server, and a Playwright pass) and sampled memory:
| Process | Peak memory |
|---|---|
next dev | 6.10 GB |
| Chromium (Playwright) | 1.2 GB |
| Postgres | 0.14 GB |
| Whole tier | 8.29 GB |
| Plus the agent process | about 8.8 GB |
The dev server is 74% of the total. Most of a coding agent's memory goes to the app it's working on. That one measurement settled a question early: an 8 GB ceiling was out, whatever else a provider offered.
The agent reads ticket text, repository files, dependency READMEs, and test output. All of that is untrusted input, and indirect prompt injection means any of it can carry instructions. Simon Willison's lethal trifecta names the failure: an agent with access to private data, exposure to untrusted content, and the ability to communicate externally can be tricked into handing that data to an attacker. A coding agent has all three out of the box, and the private data in our case is the source of a multi-tenant product that holds customer referral and finance data.
So we wrote the requirement for the worst case:
A fully compromised agent cannot reach anything except allowed hosts and cannot hold real credentials.
That sentence bundles two separate controls, and they're worth keeping apart:
A hostname allowlist can say the sandbox may talk to github.com. It can't say whether the sandbox may push to main. Only something outside the sandbox that holds the credential and understands the request can enforce that. Anthropic reached the same conclusion building Claude Managed Agents: "The structural fix was to make sure the tokens are never reachable from the sandbox where Claude's generated code runs."
We scored Cloudflare's Sandbox SDK (version 0.12), Daytona, and exe.dev against ten requirements. Four of them decided it:
| Requirement, as evaluated July 7 | Cloudflare | Daytona | exe.dev |
|---|---|---|---|
| Default-deny hostname egress | Yes, allowedHosts | Yes, domainAllowList (up to 20 entries) | Not documented |
| Credentials held outside the guest | Yes, outboundByHost (code) | Partly: placeholder-swap Secrets; git over HTTPS not documented | Secret held server-side; proxy endpoint reachable in-guest |
| Isolation class | VM per sandbox | Shared-kernel containers (Sysbox) | KVM VM |
| 4 vCPU, 12 GiB RAM, 20 GB disk | Yes, standard-4 | No, 8 GB / 10 GB max | Caps not documented |
| Score on these four | 4/4 | 2/4 | 1/4 |
Some notes on fairness to each vendor:
What put Cloudflare at the top of the scorecard was one primitive. An outbound handler is Worker code that sees every HTTP request leaving the container, and it runs outside the sandbox. Cloudflare's docs show the basic pattern:
export class MySandbox extends Sandbox {}
MySandbox.outboundByHost = {
'github.com': (request: Request, env: Env, ctx: OutboundHandlerContext) => {
const requestWithAuth = new Request(request);
requestWithAuth.headers.set('x-auth-token', env.SECRET);
return fetch(requestWithAuth);
},
};In their words: "No token is exposed to the sandbox. The secret lives in the Worker's environment and is never passed into the sandbox."
Because the handler is code and not a list, it can read what's being asked for. Our plan was to inspect each git push and refuse anything not aimed at an atlas/* branch. A smart-HTTP push opens with pkt-lines of the form <old-sha> <new-sha> <ref>, so the check is short:
// Sketch of the plan. The spike ended before we ran it.
'github.com': async (request, env) => {
if (new URL(request.url).pathname.endsWith('/git-receive-pack')) {
const body = new Uint8Array(await request.clone().arrayBuffer());
if (pushedRefs(body).some((ref) => !ref.startsWith('refs/heads/atlas/'))) {
return new Response('push refused', { status: 403 });
}
}
const authed = new Request(request);
authed.headers.set('authorization', `Basic ${btoa(`x-access-token:${env.GITHUB_TOKEN}`)}`);
return fetch(authed);
},
// pkt-line: 4 hex digits of length (including themselves), then the payload.
// "0000" ends the ref-update list; the packfile follows.
function pushedRefs(body: Uint8Array): string[] {
const text = new TextDecoder();
const refs: string[] = [];
for (let i = 0; i + 4 <= body.length; ) {
const len = parseInt(text.decode(body.subarray(i, i + 4)), 16);
if (!len) break;
const line = text.decode(body.subarray(i + 4, i + len)).split('\0')[0].trim();
refs.push(line.split(' ')[2]);
i += len;
}
return refs;
}A production version needs more: a cap on buffered body size, handling for compressed request bodies, and a test for every refused case. Even so, no other candidate let us put policy at that layer, and GitHub can't do it for us, because installation tokens are scoped to repositories and permissions, not branches.
Deployment went smoothly. The Worker answered /health, bearer auth rejected bad tokens, secrets were set, and the image was in the registry (4.17 GB uncompressed, about 1.2 GB compressed). Then the first real request:
POST /v1/sandbox/:id/session
502 pool error: Failed to start container (~1.4 s)It returned the same result on every retry. We checked five things:
| Check | Result | What it told us |
|---|---|---|
| Boot the same image locally | Up in 25 ms and stayed up | The entrypoint was fine; the registry copy was untested |
| Read the SDK's error | Logged as { remote: true } | The 0.x SDK didn't pass the platform's message through |
| Check the SDK's capacity path | Not taken. Max-instances maps to a 503; we got a 502 | Not an instance limit |
| Delete the app, deploy a fresh one | Same result, with zero instances provisioned | Not leftover application state |
wrangler cloudchamber list | Unauthorized for our token | We couldn't see placement details from our side |
The speed of the response suggested the start was rejected before scheduling, which points at something specific to our account or image rather than a container crashing. Two quick tests were left: switching the instance type from standard-4 to basic, and forcing a fresh push of the image. Each takes about ten minutes. We didn't run them, because by then exe.dev was working and we chose to ship.
We had a deadline and a working alternative. So Evan asked a different question: was the bar that picked Cloudflare the right bar for version one?
Atlas v1 is narrow on purpose. A human triggers every run. One repository, one run at a time, small tickets only, draft PRs only, and a person merges. The VM is destroyed after every run. Inside those limits, Evan proposed moving egress allowlisting from launch blocker to defense in depth, and wrote down exactly what that cost:
With open outbound egress, a fully compromised or prompt-injected agent can exfiltrate the introzy-core clone and any in-VM material, and can make arbitrary outbound calls for the lifetime of the VM. Mitigations bound the blast … they do not prevent it.
The next line in his note was "Not scary; honest." We think every accepted risk should be written down that plainly, with what it costs and what still holds.
Some things didn't move. No production or staging credentials in the VM. A model key with a hard spend cap. A GitHub credential the guest can't read. The VM destroyed after every run. A human review before merge. One control was demoted to "strongly prefer": in-guest iptables rules that drop private address ranges. Root inside the VM can remove them, but they turn "accidentally reachable" into "deliberately bypassed." We meant to block the cloud metadata address too, then learned exe.dev's integrations answer on it, so that one stays open.
Relaxing the bar also reopened the field. Evan's note says exe.dev "should win an audit, not a mood," and named Modal and E2B as the candidates to re-check under the new rules.
exe.dev passed a full dress rehearsal on July 8 and got the go on July 9. The numbers:
| Step | Time |
|---|---|
| VM created | 0.4–1.3 s |
| First SSH session | 1.9–3.1 s |
| Toolchain install (scripted, no custom image) | 85 s |
| Boot to ready (all services healthy) | 133.4 s |
| Whole rehearsal, wall clock | 311.5 s |
| Typecheck | 69.2 s, both runs |
Creating the VM takes about a second. Getting to useful takes more than two minutes, spent installing Postgres, Redis, Playwright, and dependencies. Startup benchmarks measure the first number, and your agent waits on the second. If you compare providers, compare time to the first useful tool call.
Evan tore down the Cloudflare deployment that evening, with notes on what to try next time.
On September 30, Cloudflare rebuilt Containers for agent sandboxes. Three of the changes line up with what we'd want from a second attempt.
You own the Durable Object now. Under the new durable_object scheduling policy (public beta), your own Durable Object picks the image and instance type when it calls ctx.container.start(). Cloudflare's explanation is that hiding the Durable Object behind the Container class "made it harder for you to see and combine the identity, state, and coordination it provides." For us, that puts our own code at the point where the container starts, which is exactly where we'd want visibility on a retry.
Startup is much faster. On ComputeSDK's burst benchmark, median time to interactive dropped from 4.049 s to 648 ms and p95 from 5.839 s to 910 ms. Cloudflare also reports starting 100,000 containers in 5.4 seconds across six locations.
Sandbox SDK 1.0 is a set of helpers inside your Durable Object, not a base class you extend. Outbound handlers are per hostname. S3Mount signs R2 requests from the Worker, so the bucket credential never enters the container. The 0.x line we tested gets fixes until December 31, 2026.
We're not the only ones who want this shape. Cursor now runs its cloud agents' tools on Cloudflare Containers: Cursor runs the agent loop, and the container does the work. It's the same split between brain and hands that Anthropic describes.
We don't know yet whether any of this changes our July result, and a benchmark median doesn't predict our time to ready. That's what the retest is for.
File the ticket anyway. We had the evidence written up, down to a paste-ready support ticket, and didn't send it once exe.dev worked. Cloudflare's team could likely have told us what was wrong in a day. Next time, the ticket goes out the day we park the problem.
Run the cheap tests before walking away. Stopping the investigation was right. But switching to basic and re-pushing the image were ten minutes each, and running them would have closed the record.
Make observability a requirement. Our ten requirements covered egress, credentials, isolation, size, sessions, and cost. None asked, "When it fails, does it tell you why?" We now ask every provider for an operation ID on every start, timestamps per stage (auth, scheduling, image pull, boot), and SDK errors that keep the platform's message.
Measure time to ready, not time to VM. The 0.4-second number was nice to see, but the agent waited on the 133-second one.
We're planning the retest on Sandbox SDK 1.0 with the durable_object scheduling policy. The plan:
standard-4 and on basic, from a fresh push.git-receive-pack handler above and test every refused case.If it passes, the question becomes whether programmable egress is worth a migration now that Atlas runs without it. We'll publish what we find either way.
Next week we'll cover the rest of Atlas's safety model: what it can touch, what it can't, and the dependency incident that changed it. The same idea, keeping consequential decisions out of the model's hands, runs through how Kai's deal agent writes to the database and how we check AI-generated code.
We'll be at Cloudflare Connect in San Francisco, October 19–21, including the sandbox egress session on Tuesday morning. If you work on Containers or the Sandbox SDK, we'd like to compare notes before the retest.
Introzy is free to start. No card required.