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
| Approach | When it fits | What it costs you | When to pick something else |
|---|---|---|---|
| Codex CLI OS-level sandbox | A coding agent on a developer’s machine, where the workspace is the point | Seatbelt or bubblewrap around one process tree, on a machine that still holds every credential | The agent runs unattended in production, or the host must survive a kernel bug |
Docker with --network none | A server you run, familiar tooling, and a task that needs a full Linux user space | The host kernel is shared, so a kernel bug is an escape, and one wrong -v or -e reopens the host | The code cannot be trusted with a shared kernel, or nobody patches the Docker host |
| E2B or Modal Sandboxes | Per-run isolation from a service, with a fresh VM or gVisor container each time | A bill for the time it runs, a network hop for every call, and an account with keys of its own | Data 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.
Related how-tos
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
-
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
-
A container started with
--network noneis not without a network. The none driver page showsip link showinside 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 -
The approvals page lists two named approval policies,
on-requestandnever, 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