How-to › Use AI to do the integration

How to sandbox an agent's shell and code execution#

Run what an agent executes inside a boundary with no credentials, a scratch filesystem and a closed network, in Docker, a sandbox service or an OS-level sandbox.

Audience
Platform team
Level
intermediate
Topic
Guard agents and add approvals
Languages
Python and TypeScript
Verified

The agent runs pip install for a package it picked off a web page, and the package’s setup script reads ~/.aws/credentials and posts it somewhere. Nothing asked for permission. The shell tool had every credential the developer’s shell had and was on the same network, which is how the setup script could read the file and post it. The task needed a scratch directory and a Python interpreter but got the whole machine.

What you get

You will end up with a runner that executes an agent’s command with no credentials, no network, and a scratch directory, and hands back the results as text. This is for you if an agent executes code in production and the boundary is a prompt.

Short answer

Run everything the agent executes inside a boundary that starts with nothing: no inherited environment, a scratch directory as the only writable path, and no route to the network. Copy in only the inputs, and copy the results out as text. Pick the boundary by what it must resist. A Docker container with --network none shares the host kernel, a sandbox service gives a fresh VM or gVisor container per run, and Codex’s OS-level sandbox runs on the developer’s machine.

You will need

Node 22 or later for the runner, Python 3 for the task it runs, and a task you can express as a command plus its inputs. The three real boundaries need their own things: a Docker host for docker run, an account with E2B or Modal, or a developer’s machine for Codex. This page is the isolation boundary for an agent that runs in production. Containing a coding agent while it writes an integration against a mock API is a different task with a different shape.

Approaches compared

ApproachWhen it fitsWhat it costs youWhen to pick something else
Codex CLI OS-level sandboxA coding agent on a developer’s machine, where the workspace is the pointSeatbelt or bubblewrap around one process tree, on a machine that still holds every credentialThe agent runs unattended in production, or the host must survive a kernel bug
Docker with --network noneA server you run, familiar tooling, and a task that needs a full Linux user spaceThe host kernel is shared, so a kernel bug is an escape, and one wrong -v or -e reopens the hostThe code cannot be trusted with a shared kernel, or nobody patches the Docker host
E2B or Modal SandboxesPer-run isolation from a service, with a fresh VM or gVisor container each timeA bill for the time it runs, a network hop for every call, and an account with keys of its ownData may not leave your network, or the loop needs sub-second turnaround

The three rows trade isolation for setup and latency in a straight line. Codex’s sandbox is already installed and wraps one process tree with Seatbelt on macOS or bubblewrap on Linux; the host keeps every credential the developer has. A container is a kernel namespace, which a kernel bug crosses, and a wrong mount crosses without one. A sandbox service starts a VM or a gVisor container per run and charges for it,1 and every byte in or out crosses the network.

Start from nothing and add the inputs

A child process inherits its parent’s environment unless told otherwise. The runner tells it otherwise. The environment is built from scratch, HOME is the scratch directory so ~/.aws/credentials resolves to a path that does not exist, and every proxy variable points at a closed loopback port.

export function policyEnv(scratch) {
  return {
    PATH: process.env.PATH,
    HOME: scratch,
    TMPDIR: scratch,
    LANG: 'C.UTF-8',
    HTTP_PROXY: CLOSED, HTTPS_PROXY: CLOSED, ALL_PROXY: CLOSED, NO_PROXY: '',
    http_proxy: CLOSED, https_proxy: CLOSED, all_proxy: CLOSED, no_proxy: '',
  }
}

The proxy variables are the runner’s network cut, and it is a cut by convention. Python’s urllib honors them, as do curl, pip and most HTTP clients, and a task that opens a raw socket does not. That is the difference between a policy and a boundary, and it is the reason the three rows exist. The runner shows what the policy is and tests the mistakes; a namespace, a VM, or Seatbelt enforces it for code that was never asked to cooperate.

export async function runInSandbox({ command, args = [], inputs = {}, env = {}, timeoutMs = 10_000, maxOutput = 8_000 }) {
  const scratch = await mkdtemp(join(tmpdir(), 'agent-run-'))
  try {
    for (const [name, content] of Object.entries(inputs)) {
      // A plain file name only, so an input cannot land outside the scratch directory.
      if (basename(name) !== name) throw new Error(`input name ${JSON.stringify(name)} is not a plain file name`)
      await writeFile(join(scratch, name), content)
    }
    // Its own process group, so the kill at the timeout reaches a background child too.
    const child = spawn(command, args, { cwd: scratch, env: { ...policyEnv(scratch), ...env }, stdio: ['ignore', 'pipe', 'pipe'], detached: true })

Inputs are copied in by plain filename, and a name with a path separator in it is refused, so nothing the caller passes lands outside the scratch directory. That is the whole of the runner’s filesystem boundary. The runner moves only HOME, TMPDIR and the working directory, which leaves the process running as your user on your disk. A task that opens the real home by absolute path, or asks the password database where it is, finds the credentials file. Results come back the same way: whatever regular file the task wrote that it was not handed is read as text, capped, and returned, and a directory it made is skipped. The scratch directory is deleted whether the task finished or was killed at the timeout. The signal at the timeout goes to the task’s whole process group, so a background child dies with it. The env parameter exists so a caller can forward a variable on purpose.

Map the policy onto a real boundary

The same policy as a docker run command, built by a function so a review reads the flags and not a shell history.

export function dockerArgs({ image, scratch, memory = '512m', pids = 256 }) {
  return [
    'run', '--rm',
    '--network', 'none',
    '--read-only', '--tmpfs', '/tmp',
    '--cap-drop', 'ALL', '--security-opt', 'no-new-privileges',
    '--user', '65534:65534',
    '--pids-limit', String(pids), '--memory', memory,
    '-v', `${scratch}:/work`, '-w', '/work',
    image,
  ]
}

Every flag is one line of the policy. --network none leaves the container a loopback device and nothing else.2 --read-only with a --tmpfs for /tmp makes the scratch mount the only place a write lands. --cap-drop ALL, no-new-privileges and a --user that is not root close the paths a process takes to widen its own access, and --pids-limit with --memory stop a fork bomb from being the host’s problem. What the list does not contain matters as much: no -e, no --env-file, and no mount of the project directory.

Codex expresses the same three decisions in its configuration. sandbox_mode is one of read-only, workspace-write and danger-full-access; approval_policy is on-request or never; and network access inside workspace-write is off unless switched on.3 The read-only interactive setting the approvals page recommends is two lines:

sandbox_mode = "read-only"
approval_policy = "on-request"

Modal’s version is a keyword argument. A Sandbox can reach any public address by default, and block_network=True drops all outbound traffic, with outbound_cidr_allowlist as the middle setting when the task needs one host. Claude Code makes the same shape available to a coding agent, with Seatbelt on macOS, bubblewrap on Linux, and a domain allow list in front of the network.

Have the agent try the two things

The probe is the task an agent would run if it meant harm, or if a dependency did. It reads ~/.aws/credentials, looks for the secret in its environment, reaches for a URL, and writes a result file.

try:
    with open(os.path.expanduser("~/.aws/credentials")) as f:
        report["credentials"] = "readable, %d bytes" % len(f.read())
except FileNotFoundError:
    report["credentials"] = "no such file"

report["secret_in_env"] = "AWS_SECRET_ACCESS_KEY" in os.environ

try:
    with urllib.request.urlopen(target, timeout=3) as res:
        report["network"] = "reached, status %d" % res.status
except urllib.error.URLError as e:
    report["network"] = "refused" if isinstance(e.reason, ConnectionRefusedError) else "failed: %s" % e.reason

The demo builds a host home with a credentials file in it and puts a secret in the host environment. It starts a local server to stand in for the internet, and runs the probe three ways.

node demo.mjs
on the host, as the agent would run without a boundary
  credentials: "readable, 81 bytes"
  secret_in_env: true
  network: "reached, status 200"
  cwd_entries: ["probe.py"]

inside the policy runner
  credentials: "no such file"
  secret_in_env: false
  network: "refused"
  cwd_entries: ["probe.py"]
  files returned as text: {"result.json":"{\"answer\": 42}"}

inside the runner, but handed the host environment
  credentials: "readable, 81 bytes"
  secret_in_env: true
  network: "reached, status 200"
  cwd_entries: ["probe.py"]
  files returned as text: {"result.json":"{\"answer\": 42}"}

the same policy as a docker command
  docker run --rm --network none --read-only --tmpfs /tmp --cap-drop ALL --security-opt no-new-privileges --user 65534:65534 --pids-limit 256 --memory 512m -v /tmp/agent-run:/work -w /work python:3.12-slim

what the audit says about the usual shortcut
  forwards host environment variables
  network is not none
  mounts /home/dev/project read-write
  runs as root inside the container
  root filesystem is writable

The third block is the pitfall, measured. The runner is unchanged; the caller passed the host’s environment through the env parameter, and all three protections are gone at once. HOME came back, so the credentials file is readable. The secret came back with it. The proxy variables were overwritten, so the network is open. One argument undid the sandbox.

The last block is the same mistake spelled for Docker. -v /home/dev/project:/work mounts the project read-write, and -e AWS_SECRET_ACCESS_KEY forwards the one variable that should never cross. The audit function reads a docker run argument list and names each way the host leaks in, which is a check you can run in CI over the command your job template builds.

Check it worked

Six tests, and the two that matter assert the literal strings no such file and refused from inside the runner while the same probe reports reached, status 200 outside it.

node --test sandbox.test.mjs
1..6
# tests 6
# suites 0
# pass 6
# fail 0
# cancelled 0
# skipped 0
# todo 0
# duration_ms 544.78992

The last test pins the audit to the five findings shown, so a change to dockerArgs that drops a flag fails here before it reaches a job template.

When it goes wrong

The task reaches the network from inside the runner. It ignored the proxy variables, which is its right; the runner’s cut is a convention. Move the task to one of the three rows, where the kernel or the hypervisor holds the line, and keep the runner for the tests.

Operation not permitted inside a container, or the sandbox refuses to start on Ubuntu. Both Codex and Claude Code use bubblewrap on Linux, and bubblewrap needs user namespaces, which recent Ubuntu releases restrict through AppArmor by default. Each vendor’s page carries the profile to load; the Docker row does not have the problem because the daemon creates the namespace.

Codex will not start after a configuration change. approval_policy = "untrusted" is retired and the approvals page says it can prevent the client from starting. Remove it and use on-request.

The container has --network none, but the agent still copied a file out. Look at the mounts and the environment, not the network. A project directory mounted read-write is a channel out that needs no socket, and --env-file .env hands over the same secrets -e would.

When not to do this

Do not use the runner on this page as the boundary for code you do not trust. It scrubs the environment, it moves the home directory without hiding the rest of the disk, and it cuts the network only for code that honors a proxy variable. Anything that runs a package it chose itself goes inside a container, a VM, or an OS sandbox.

Do not mount the project directory read-write into a sandbox because the task needs to read a file. Copy the file in. A read-write mount of the workspace makes the boundary a formality, because the agent’s output is your source tree, and a forwarded environment does the same for your keys. Pass only the inputs the task needs, and take the results back as text.

Do not pick a sandbox service for data that may not leave your network. Do not pick Docker for a workload whose threat model includes a kernel bug, because a shared kernel is what a container is. Do not apply this page to a coding agent working on your codebase either. That agent needs the workspace, which is what Codex’s workspace-write mode and Claude Code’s sandbox are built for, and a mock API rather than a closed network.

Last verified

Verified 2026-09-24 against Node 22.22.2 and Python 3.11.15. Every output block is what the command before it printed. No Docker daemon, sandbox service, or Codex install was available offline, so the boundary demonstrated is the policy runner. The docker run command it prints was checked against the flag reference and not executed. The Codex, Modal, and Claude Code settings are quoted from their documentation.

Footnotes

  1. The gVisor documentation has a heading asking how it differs. The answer is a list of what it is not: not a system call filter, not a wrapper over the Linux isolation primitives, and not a VM in the everyday sense. It calls its approach a distinct third one, which is a modest way to describe answering a container’s system calls yourself, in Go, rather than letting the host kernel hear them. ↩︎ Back to text

  2. A container started with --network none is not without a network. The none driver page shows ip link show inside one: a single loopback device that is up and has no IPv6 loopback address configured for it. A process inside can talk to itself over IPv4 and to nobody else, which is the limitation or the design depending on who is asking. ↩︎ Back to text

  3. The approvals page lists two named approval policies, on-request and never, with a granular form between them. Its migration section covers a third, untrusted, which is retired and can keep the client from starting at all. The policy named for distrust is the one that no longer runs. ↩︎ Back to text

Read this page as markdown · All how-to guides

Generate the client instead of writing it#

Retries, timeouts, pagination and auth are the same problems in every client. Voxgig generates them from your OpenAPI description, in 23 languages, from one model.

Get the Voxgig dispatch

Short notes on building SDKs, CLIs, REPLs, and MCPs for API-first teams, plus the occasional Fireside episode pick.

By signing up you agree to our Terms and Conditions.