AI DevelopmentTutorial

Running Agent-Written Code Behind a Sandbox: Timeouts, Limits and No Egress

October 14, 2026
13 min read
Clear glass cube holding a small metal sphere on a pale surface
Share:

Your agent just wrote a Python script and wants to run it. If your answer is exec() or subprocess inside the same process that holds your database URL and API keys, one poisoned web page or README is enough to turn model output into code execution on your server.

In this tutorial I build a code-interpreter tool the right way: generated code runs in a disposable sandbox with a hard timeout, capped resources and egress denied by default. You get quickstart code for E2B, Vercel Sandbox, Modal and Daytona taken from their current docs, a facts-only comparison table, and a list of what isolation does not cover. The snippets are adapted from each provider’s documentation, not benchmarks of mine.

1. The Problem: Generated Code Is Untrusted Input

A code-interpreter tool lets the model compute instead of guess: parse a CSV, draw a chart, run a unit test. The catch is that the code is written by a system that also reads untrusted text — web pages, issues, PDFs, output from other tools. Prompt injection turns ‘summarize this page’ into ‘run this snippet’, and you have remote code execution with one extra step.

The Most Common Mistake

Running generated code with exec(), eval(), child_process or subprocess inside your own app container — or in a ‘sandbox’ that shares your environment variables, filesystem and network. A container is a packaging boundary, not a promise about hostile code, and anything inside the process can read the secrets you put there.

The fix is not one product. It is a short stack: run the code somewhere disposable, give it a time budget and a resource budget, decide what the network can reach before it starts, and keep credentials out of the box. The providers below sell the disposable box; the rest is policy you write.

2. Minimum Concepts Before Touching Code

Six ideas cover almost everything. Keep them in mind and the provider snippets below read as variations on one theme.

🧱

Isolation boundary

what separates code from your host

A container shares the host kernel and relies on namespaces and cgroups. A microVM boots its own guest kernel under a hypervisor (Firecracker is the common one). gVisor sits in between: a user-space kernel that intercepts syscalls. Each is a different thing an attacker has to break.

🌐

Egress policy

the leak you forget

Isolation says nothing about where bytes may go. These sandboxes can usually reach the whole internet by default, so injected code can POST your data anywhere. Deny by default and allow-list what the task needs.

⏱️

Timeouts

two clocks, not one

A per-command timeout stops a hung script; the sandbox lifetime stops everything, including the sandbox you forgot about. Set both, and call stop or kill in a finally block.

📏

Resource limits

CPU, memory, disk

Cap vCPUs and memory per sandbox so a fork bomb or a giant allocation exhausts its own box, not your bill or your neighbours.

💾

Persistent state

feature or foot-gun

Some providers snapshot and resume by default. Good for a coding agent’s workspace, bad for one-shot execution: leftover files, installed packages and planted scripts follow you into the next run.

🔑

Secrets

keep them outside

Anything inside the sandbox is readable by the code running there. Prefer injecting credentials at the network edge, or calling your own API from the host, over passing keys as environment variables.

The mental model to keep

A sandbox protects your host from the code. It does not protect the data you hand to the code, the services your network policy lets it reach, or the model from whatever the code prints back. Draw the threat model around those three things, then pick the isolation layer.

3. Four Providers, Facts From Their Own Docs

I limited the table to what each provider states in its own documentation or pricing page as of 14 October 2026. Where a page gave no number, the cell says so instead of guessing. Prices and limits change, so treat this as a map to the pages in Sources, not as a quote.

E2BVercel SandboxModalDaytona
Isolation (their words)Firecracker microVM with its own guest kernel per session (enterprise page)Firecracker microVM with a dedicated kernel per sandboxgVisor, or a VM with its own Linux kernel; set runtime or let Modal pickDocs: dedicated kernel, filesystem and network stack; resource tables list Container and Linux VM classes
StartupNo figure on the pages I read‘Milliseconds’ (no number given)No figure on the pages I read‘Under 90 ms’ (provider claim)
SDK languagesJavaScript/TypeScript, PythonJavaScript/TypeScript, Python, CLIPython, JavaScript, GoPython, TypeScript, Ruby, Go, Java; CLI and REST
LifetimeUp to 1 h on Hobby, 24 h on Pro; timeout set at creationDefault 5 min; session max 45 min on Hobby, 24 h on ProDefault 5 min; up to 24 hAuto-stop after 15 min of inactivity by default (SDK reference)
Egress controlsallowInternetAccess, allowOut / denyOut (IP, CIDR, domain)allow-all, deny-all or user-defined domain and CIDR policy; updatable at runtimeblock_network, CIDR allowlist, domain allowlist (beta, TLS on 443 only)networkBlockAll, networkAllowList (max 10 IPv4 CIDRs), domainAllowList
ResourcesDefault 2 vCPU / 4 GiB; max 8 vCPU / 8 GiBDefault 2 vCPU with 2 GB each; max 4 (Hobby), 8 (Pro), 32 (Enterprise) vCPUscpu in fractional cores, memory in MiB, per sandboxDefault 1 vCPU / 1 GB / 3 GiB disk; org max 4 vCPU / 8 GB / 10 GB
Pricing modelPer second: $0.000014 per vCPU-s, $0.0000045 per GiB-s; Hobby $0 + $100 one-time credit, Pro $150/mo + usageActive CPU $0.128/h (I/O waits not billed) + memory $0.0212/GB-h; Hobby has free quotasSandbox rates $0.00003942 per core-s and $0.00000667 per GiB-s (about 3x functions); Starter $30/mo creditPer second: $0.0504 per vCPU-h, $0.0162 per GiB-h; $200 free credit

Versions checked on 14 October 2026

@vercel/sandbox 3.8.0, @e2b/code-interpreter 2.9.0 (npm) and e2b-code-interpreter 2.11.0 (PyPI), modal 1.6.1, daytona 0.224.1 (PyPI) and @daytona/sdk 0.224.1 (npm), read from the npm and PyPI registries.

How to read the isolation row

It is what each provider claims, not what I measured. Firecracker and gVisor are real isolation technologies with different trade-offs, but neither is a guarantee: a hypervisor or kernel bug breaks any of them, and a permissive network policy bypasses all of them.

Daytona: two statements that do not line up

Daytona’s docs say each sandbox has a dedicated kernel, and the same docs label the default snapshot daytona-small as ‘Container’ class next to separate daytona-vm-* ‘Linux VM’ snapshots. Third-party comparisons I found describe default Daytona sandboxes as plain OCI/Docker containers; I could not confirm that from Daytona’s own pages. Before running hostile code there, ask which class your sandbox gets and prefer the VM class if you need a kernel boundary.

4. Step 1 — The Tool Contract and Vercel Sandbox (TypeScript)

Start with the contract, because it is the same for every provider: the model sends code and a time budget; the tool returns stdout, stderr and an exit code, truncated; the host never executes anything itself. Below it runs on Vercel Sandbox (@vercel/sandbox 3.8.0 at publish time): create a sandbox with egress denied, write the file, run it with a per-command abort signal and always stop the VM.

TypeScript — runCode on Vercel Sandbox

import { Sandbox } from "@vercel/sandbox";

const MAX_OUT = 20_000; // characters handed back to the model

export async function runCode(code: string, budgetMs = 15_000) {
  const sandbox = await Sandbox.create({
    timeout: 60_000,           // hard session cap in ms (default 5 min)
    resources: { vcpus: 1 },   // default is 2
    networkPolicy: "deny-all", // blocks egress, DNS included
    persistent: false,         // one-shot: discard the filesystem on stop
  });
  try {
    await sandbox.writeFiles([
      { path: "agent-code.mjs", content: Buffer.from(code) },
    ]);
    const run = await sandbox.runCommand(
      "node",
      ["/vercel/sandbox/agent-code.mjs"],
      { signal: AbortSignal.timeout(budgetMs) }, // aborted call rejects
    );
    return {
      exitCode: run.exitCode,
      stdout: (await run.stdout()).slice(0, MAX_OUT),
      stderr: (await run.stderr()).slice(0, MAX_OUT),
    };
  } finally {
    await sandbox.stop(); // always, even when the call above throws
  }
}

With deny-all from the start there is no npm or pip install. If the task needs packages, use the live-update pattern from the docs: install with network in a trusted setup step, drop to deny-all, and only then run the untrusted code. For partial access the policy takes a domain list, and for credentials there is brokering: the firewall injects the header and the secret never enters the sandbox.

TypeScript — install, lock down, or broker a credential

// 1) Trusted setup with network, then lock before untrusted code
const sandbox = await Sandbox.create({ persistent: false }); // allow-all
await sandbox.runCommand("npm", ["install", "mathjs"]);
await sandbox.update({ networkPolicy: "deny-all" });

// 2) Or allow-list only what the task needs
const scoped = await Sandbox.create({
  networkPolicy: { allow: ["ai-gateway.vercel.sh"] },
});

// 3) Or let the firewall inject the credential (secret stays outside)
const brokered = await Sandbox.create({
  networkPolicy: {
    allow: {
      "ai-gateway.vercel.sh": [{
        transform: [{ headers: { Authorization: "Bearer " + process.env.AI_GATEWAY_TOKEN } }],
      }],
    },
  },
});

Why persistent: false, and why stop()

Vercel Sandboxes are persistent by default: the filesystem is snapshotted on stop and restored on resume, and snapshots count toward storage. That suits a long-lived agent workspace and is wrong for one-shot execution. The docs also give a 5-minute default session timeout and a 45-minute cap on Hobby, and a sandbox you forget to stop keeps accruing provisioned-memory charges until it expires. The AbortSignal cancels the SDK call; I did not find a statement in the docs that it kills the process inside the VM, so treat the sandbox timeout as the hard wall.

5. Step 2 — E2B Code Interpreter (TypeScript and Python)

E2B’s @e2b/code-interpreter gives you runCode with a Jupyter-style result, so the agent can send multi-line Python and read structured errors. Set the lifetime in the create call and mind the units: milliseconds in JavaScript (timeoutMs), seconds in Python (timeout). Switching internet off is a single flag, and network rules cover the allow-list case.

TypeScript — no internet, 60 s lifetime

import { Sandbox } from "@e2b/code-interpreter"; // needs E2B_API_KEY

const sbx = await Sandbox.create({
  timeoutMs: 60_000,          // JavaScript: milliseconds
  allowInternetAccess: false, // same as denyOut: ["0.0.0.0/0"]
});
try {
  const execution = await sbx.runCode(aiGeneratedCode);
  if (execution.error) {
    console.error(execution.error.name, execution.error.value);
    console.error(execution.error.traceback);
  } else {
    console.log(execution.logs);
  }
} finally {
  await sbx.kill();
}

In Python the same idea is spelled with snake_case, and the network option takes explicit allow and deny lists. Denying all traffic first and then allowing specific addresses is the shape the docs show.

Python — deny all, allow two ranges

from e2b_code_interpreter import Sandbox

sbx = Sandbox.create(
    timeout=60,  # Python: seconds
    network={
        "deny_out": lambda ctx: [ctx.all_traffic],
        "allow_out": ["1.1.1.1", "8.8.8.0/24"],
    },
)
try:
    execution = sbx.run_code(ai_generated_code)
    if execution.error:
        print(execution.error.traceback)
    else:
        print(execution.logs)
finally:
    sbx.kill()

Domain filtering on E2B has sharp edges

Per the docs: when you filter by domain you must deny all other traffic in denyOut, domains are not supported in deny lists, domain filtering covers HTTP on port 80 (Host header) and TLS on 443 (SNI), QUIC/HTTP3 is not supported, and the default nameserver 8.8.8.8 is auto-allowed once any domain is used. Allow rules win over deny rules, and a blocked TCP connection can still report the socket as open, so test with an application-level request, not a port check.

6. Steps 3 and 4 — Modal and Daytona (Python)

Modal Sandboxes run commands in a container you define at runtime. You can pick the runtime — gVisor, or a VM with its own kernel — or leave it unset and Modal chooses. The default maximum lifetime is 5 minutes (configurable up to 24 hours), CPU and memory are explicit parameters, and the network is dropped with one flag.

Python — Modal Sandbox with block_network

import modal

app = modal.App.lookup("code-interpreter", create_if_missing=True)

sb = modal.Sandbox.create(
    image=modal.Image.debian_slim().pip_install("pandas"),
    app=app,
    timeout=120,         # seconds of max lifetime (default 300)
    cpu=1.0,             # fractional cores
    memory=1024,         # MiB
    block_network=True,  # drops all outbound traffic
)
try:
    p = sb.exec("python", "-c", ai_generated_code, timeout=15)
    print(p.stdout.read())
    print(p.stderr.read())
    print("exit code:", p.wait())
finally:
    sb.terminate()

Daytona (daytona and @daytona/sdk 0.224.1) creates a sandbox, runs code with process.code_run and takes a timeout in seconds. Documented defaults are 1 vCPU, 1 GB RAM, 3 GiB disk and auto-stop after 15 minutes of inactivity. Egress is set at creation with network_block_all, network_allow_list or domain_allow_list; they are mutually exclusive and a conflicting mix returns a 400.

Python — Daytona ephemeral sandbox, network blocked

from daytona import Daytona, DaytonaConfig, CreateSandboxFromSnapshotParams

daytona = Daytona(DaytonaConfig(api_key="YOUR_API_KEY"))  # read it from env

sandbox = daytona.create(
    CreateSandboxFromSnapshotParams(
        network_block_all=True,  # or domain_allow_list="example.com,*.daytona.io"
        ephemeral=True,          # deleted when stopped
        auto_stop_interval=5,    # minutes of inactivity (default 15)
    )
)
try:
    response = sandbox.process.code_run(ai_generated_code, timeout=10)
    if response.exit_code != 0:
        print("failed:", response.result)
    else:
        print(response.result)
finally:
    sandbox.delete()

Daytona tiers can override you

On Tier 1 and Tier 2 organizations network access is restricted at the org level and cannot be overridden per sandbox; Tier 3 and 4 default to full internet access, which sandbox settings can replace. The CIDR allow list accepts IPv4 only, with at most 10 entries. On Modal, block_network cannot be combined with the allowlist parameters, and its domain allowlist is beta and TLS-on-443 only.

7. A Lockdown Routine Around the Sandbox

The provider gives you the box. This is the routine I wrap around it so the box actually earns its keep:

🚦 Before every run (design time)

  1. Scope: one sandbox per untrusted session or tenant; never share a sandbox between users
  2. Egress: deny-all, or a domain allow-list for the one API the task needs; install dependencies in a trusted setup step, then lock
  3. Data in: pass inputs as files, not credentials; keep tokens out of env vars and out of the filesystem
  4. Budgets: set sandbox lifetime and per-command timeout, and cap vCPUs and memory

🔍 After every run

  1. Truncate: cap stdout and stderr length before they go back to the model
  2. Distrust: treat output as untrusted data; a script can print a prompt injection straight into your agent’s context
  3. Clean up: stop, kill or delete in a finally block, and log exit code, duration and the network policy used

🧪 Every release (CI)

  1. Egress test: run a script that tries to reach a canary endpoint you control and assert it fails, and that only the intended host works on allow-lists
  2. Limits test: an infinite loop and a memory hog; assert your timeout and cap fire and no billable sandbox is left running
  3. Provider drift: re-read the pages in Sources; limits, defaults and pricing change

8. Common Errors (and What Each Layer Does Not Cover)

The left column is what working integrations do; the right column is what shows up in incident reviews.

✅ Do this

  • • Deny egress first, then open the smallest hole the task needs
  • • Set a sandbox lifetime and a per-command timeout, and stop the VM in finally
  • • Use a fresh, non-persistent sandbox for one-shot code; persist only deliberate workspaces
  • • Keep secrets outside: broker credentials at the edge or call your API from the host
  • • Truncate output before it re-enters the model context
  • • Pin the SDK version and re-read limits before changing plans

❌ Not this

  • • exec() or subprocess in your own app container ‘because it is in Docker’
  • • Trusting a domain allow-list as a content filter: SNI matching can be fronted, and some modes leave DNS reachable
  • • Passing API keys as sandbox env vars ‘just for this run’
  • • Reusing a persistent sandbox across users or sessions
  • • Assuming timeout units match across SDKs (E2B: ms in JS, seconds in Python)
  • • Treating ‘isolated’ as ‘secure’ and skipping the threat model

Golden rule

The sandbox answers one question: what can this code do to my host? You still have to answer the other three — what data did I give it, where can it send bytes, and what will I do with what it says back.

Conclusion

You now have the whole loop: a tool contract (code and budget in, stdout, stderr and exit code out), a disposable sandbox on E2B, Vercel Sandbox, Modal or Daytona, egress denied before the first line runs, two clocks, and output treated as untrusted. The providers differ in isolation technology, lifetime limits and pricing model, but the policy you write around them is the same.

This week, pick one provider, wrap it in the runCode contract above and add two CI tests: a canary egress attempt and an infinite loop. If both pass, ‘the agent can run code’ stops being a liability and becomes a feature with a measured blast radius. If your agent also installs skills or clones repos, read the related guides below: the sandbox is one layer, not the whole defence.

Tutorial Cheat Sheet

Contract

  • • code + time budget in
  • • stdout, stderr, exit code out
  • • truncate, then treat as data

Sandbox

  • • one per session, non-persistent
  • • egress deny-all by default
  • • lifetime + per-command timeout

Outside it

  • • secrets stay on the host
  • • CI: egress and limits tests
  • • re-read provider pages often

Sources

Diego Rodriguez

Diego Rodriguez

Senior Full-Stack & AI Engineer

Diego has 10+ years of experience building production-grade AI-powered applications, from LLM orchestration and RAG pipelines to ML-driven risk detection and algorithmic trading systems.

Learn more about Diego →