← Back to Blog
Engineering by

What a Coding Agent's Sandbox Actually Stops: Codex, Claude Code, odev and Bigger Boxes

What a Coding Agent's Sandbox Actually Stops: Codex, Claude Code, odev and Bigger Boxes

Summary

I ran the same probe script inside six sandboxes for coding agents (Codex, Claude Code, odev, Anthropic's devcontainer, Drop and a Multipass VM) to see what the agent can write, read and reach. Each one blocks something different, and two of them let the agent plant a git hook that later runs on the host.

Two earlier posts on our other sites touched this topic from different ends. Protecting Your Local Machine Against Supply Chain Attacks14 suggested a throwaway Multipass VM for running code you do not trust. Introducing odev and its AI plugins15 showed odev running the agent inside bwrap (Linux) or Seatbelt (macOS), next to an empty PostgreSQL, and listed that sandbox as the first idea worth borrowing.

A coding agent is the same problem with a twist: the untrusted code is the commands the agent decides to run, and it runs them in your project, with your user account, many times an hour. So this post asks one practical question of each tool: when the agent runs a shell command, what can that command actually touch? Every result below comes from a run on my machine; where I did not test something, I say so.

The probe

Same script everywhere, run from a fresh git repository:

#!/bin/bash
echo "cwd write:   $(touch ./ok.txt 2>&1 && echo ok)"
echo "home write:  $(touch ~/sandbox-probe.txt 2>&1 && echo ok; rm -f ~/sandbox-probe.txt)"
echo ".git write:  $(touch .git/probe 2>&1 && echo ok; rm -f .git/probe)"
echo "git hooks:   $(touch .git/hooks/pre-commit 2>&1 && echo ok; rm -f .git/hooks/pre-commit)"
echo "ssh key:     $(test -r ~/.ssh/id_ed25519 && echo readable || echo hidden)"
echo "network:     $(curl -s -o /dev/null -m 5 -w '%{http_code}' https://pypi.org/simple/ 2>&1 || echo blocked)"
echo "localhost pg:$(timeout 3 bash -c '</dev/tcp/127.0.0.1/5432' 2>&1 && echo open || echo closed)"

The lines map to what goes wrong in practice. Writing outside the project is how a prompt-injected agent plants something in ~/.bashrc. A git hook is code that runs later, outside any sandbox, the next time you commit. The SSH key matters even with the network off: whatever a command prints goes back to the model, so a key that the agent cats ends up in the conversation sent to the model provider. Localhost port 5432 is the local PostgreSQL holding customer database copies, which is the one that matters most for Odoo work.

On the host, with no sandbox, everything is ok, readable, 200 and open. In the outputs below I removed the duplicate bash: line 1: error lines. “home write: ok” inside a container, Drop or a VM means the sandbox’s own home directory, not mine.

Versions: Ubuntu 24.04 (kernel 7.0), bubblewrap 0.9.0, codex-cli 0.163.0, Claude Code 2.1.306, odev 4.34.0, Docker 29.6.0, Drop 0.3.1, Multipass 1.17.0.

Codex

Codex has three sandbox modes: read-only, workspace-write and danger-full-access. According to its Linux sandbox README2, it uses the first bwrap on PATH (with a bundled one as fallback), mounts the filesystem read-only with --ro-bind / /, binds the writable roots on top, and adds PR_SET_NO_NEW_PRIVS and a seccomp filter that blocks network syscalls. codex sandbox runs any command the way the agent would, which makes it easy to test:

$ codex sandbox -c sandbox_mode='"workspace-write"' -- ./probe.sh
cwd write:   ok
home write:  touch: cannot touch '/home/xavie/sandbox-probe.txt': Read-only file system
.git write:  touch: cannot touch '.git/probe': Read-only file system
git hooks:   touch: cannot touch '.git/hooks/pre-commit': Read-only file system
ssh key:     readable
network:     000blocked
localhost pg:bash: socket: Operation not permitted
closed

With no -c, codex sandbox uses read-only and even ./ok.txt fails.

Three things stand out:

  • .git is read-only even in workspace-write. The source protects .git, .agents and .codex inside the project. That blocks hooks, and it also blocks commits. Through the agent itself:

    $ codex exec -s workspace-write "Run exactly: git commit --allow-empty -m codex-commit"
    approval: never
    sandbox: workspace-write [workdir, /tmp, $TMPDIR]
    fatal: Unable to create '.../sandbox-probe-demo/.git/index.lock': Read-only file system
    

    codex exec runs with approval: never, so the commit simply fails. In the interactive CLI the documented on-request policy lets the agent ask to go past the boundary; I did not test that path.

  • No network is no network. The block is at the socket level, so localhost is blocked too, not just the internet. Turning the network on (-c sandbox_workspace_write.network_access=true) opens both at once: the same probe then prints network: 200 and localhost pg:open.

  • ~/.ssh is readable. Everything outside the writable roots is visible, read-only.

  • Your environment goes in. I started codex exec with a dummy FAKE_API_KEY exported, and the command the agent ran printed it.

Newer Codex versions can hide paths with a permission profile in ~/.codex/config.toml:

default_permissions = "agent"

[permissions.agent.filesystem]
":root" = "read"
":project_roots" = "write"
"~/.ssh" = "deny"
$ codex sandbox -- ./probe.sh
cwd write:   ok
...
ssh key:     hidden
network:     000blocked

What happened to CODEX_UNSAFE_ALLOW_NO_SANDBOX

You still find CODEX_UNSAFE_ALLOW_NO_SANDBOX=1 in Dockerfiles and old blog posts. It belonged to the first, TypeScript version of the CLI, where it let you run Codex inside a container that was already locked down (openai/codex#11253), and it had platform bugs (#12134). In the current Rust source the variable does not appear at all. The flags that replace it are:

codex --sandbox danger-full-access                # no sandbox
codex --dangerously-bypass-approvals-and-sandbox  # no sandbox, no approval prompts

The help text for the second one says it is “intended solely for running in environments that are externally sandboxed”. Which brings us to the bigger boxes further down.

Claude Code

Claude Code’s Bash sandbox5 is off by default. /sandbox turns it on, or "sandbox": {"enabled": true} in settings.json. On Linux it needs bubblewrap and socat; on macOS it uses Seatbelt. It is built on @anthropic-ai/sandbox-runtime6. The difference with Codex is the network: the documentation describes a proxy on your machine that checks each hostname against an allowlist, which starts empty.

I ran the probe through the agent itself, with allowUnsandboxedCommands: false so that it could not retry outside the sandbox:

$ claude -p --settings '{"sandbox":{"enabled":true,"allowUnsandboxedCommands":false}}' \
    "Run ./probe.sh once and paste its raw stdout"
cwd write:   ok
home write:  touch: cannot touch '/home/xavie/sandbox-probe.txt': Read-only file system
.git write:  ok
git hooks:   touch: cannot touch '.git/hooks/pre-commit': Read-only file system
ssh key:     readable
network:     000blocked
localhost pg:bash: connect: Connection refused
closed
<sandbox_violations>
deny network-outbound pypi.org:443 (user denied)
</sandbox_violations>

.git is writable but the hooks directory is protected, and a git commit --allow-empty from inside the sandbox did create the commit. The SSH key is readable, same as Codex; the documentation says reads are allowed on “most of the machine, including credential files such as ~/.ssh and ~/.aws/credentials”.

Two settings change that:

{
  "sandbox": {
    "enabled": true,
    "allowUnsandboxedCommands": false,
    "filesystem": { "denyRead": ["~/.ssh", "~/.aws"] },
    "network": { "allowedDomains": ["pypi.org"] }
  },
  "permissions": {
    "deny": ["Read(~/.ssh/**)", "Read(~/.aws/**)"]
  }
}
ssh key:     hidden
network:     200
localhost pg:bash: connect: Connection refused

Now pypi.org answers and localhost still refuses: unlike Codex, the allowlist is per host, so allowing the package index does not also open your database.

The permissions.deny lines are not redundant. The sandbox covers Bash, not Claude’s own file tools. I put a dummy token in ~/fake-secrets/token.txt, added that directory to denyRead, allowed the Read tool, and asked for the file both ways:

(1) Bash: cat: /home/xavie/fake-secrets/token.txt: No such file or directory
(2) Read: 1	FAKE-TOKEN-4242

With "deny": ["Read(~/fake-secrets/**)"] added, the Read tool answered “File is in a directory that is denied by your permission settings.” You need both.

Like Codex, it passes your shell’s environment through: a dummy FAKE_API_KEY exported before claude -p was visible to the sandboxed command. API keys exported in ~/.bashrc are one env away.

Other limits I checked: docker ps failed on the Docker socket inside the sandbox (it works on the host), and ping could not open its socket. One more from the documentation that I did not test: when a command fails because of the sandbox, Claude may retry it with dangerouslyDisableSandbox, which allowUnsandboxedCommands: false disables.

The documentation also says the sandbox fails open when a dependency is missing. I checked: same settings, "sandbox": {"enabled": true}, but with socat removed from PATH:

cwd write:   ok
home write:  ok
.git write:  ok
git hooks:   ok
ssh key:     readable
network:     200
localhost pg:open

That is the host baseline, with the sandbox “enabled” and no warning in the claude -p output. Run /sandbox on every new machine and look at what it reports, instead of trusting the setting.

odev

The earlier post15 checked odev’s sandbox with ls ~ and psql -l. This time I ran the full probe through odev ai (with Claude as the agent), and it tells a more mixed story:

$ odev ai -H -y --cli claude "Run ./probe.sh and paste its raw stdout"
home write:  ok
.git write:  ok
git hooks:   ok
ssh key:     hidden
network:     200
localhost pg:open

The home directory is replaced: ls -A ~ shows odev’s own .claude, .config, .nvm, odev and the project, and ~/.ssh does not exist. psql -l lists only the ephemeral cluster’s odev, postgres and template databases, not the four on the host. That part holds.

The rest does not match what I wrote last week:

  • The network namespace is the host’s. readlink /proc/self/ns/net printed net:[4026531833] inside the sandbox and on the host. Internet, LAN and localhost are all reachable.
  • localhost:5432 is the host’s PostgreSQL. The empty cluster replaces the Unix socket that psql uses by default. Over TCP, psql -h 127.0.0.1 -l reached the host server and stopped only at fe_sendauth: no password supplied. What protects the customer databases here is the host server’s authentication, not the sandbox.
  • .git/hooks is writable, so the hook problem described under the devcontainer below applies here too. A commit from inside went through.
  • The environment is passed through, 82 variables inside.

So odev’s sandbox protects the home directory and gives the agent a database to work with, which is what Odoo work needs. It does not isolate the network.

Side by side

All values from the runs in this post, including the three bigger boxes from the next section:

Codex workspace-write Claude Code /sandbox odev ai Devcontainer Drop Multipass
On by default Yes No Yes — — —
Write in project Yes Yes Yes Yes Yes Own copy
Write in my $HOME No No No (own home) No (own home) No (own home) No (own home)
Write .git/hooks of the host repo No No Yes Yes No No mount
git commit from inside No Yes Yes Yes No Own copy
Read ~/.ssh Yes, unless denied Yes, unless denyRead No No No No
Internet Off, or on Per-host allowlist On GitHub + allowlist On On
Host 127.0.0.1:5432 Follows network switch Refused Reached, password asked Refused Refused Refused
Host service on 0.0.0.0 Follows network switch Blocked Reached Reached Blocked Reached
My shell’s environment variables Passed through Passed through Passed through Not passed Allowlist Not passed
Kernel Host Host Host Host Host Own

Two notes on reading it. “Host service on 0.0.0.0” is a test HTTP server I started on port 8765, bound to all interfaces, reached through the Docker or Multipass gateway and through my LAN address. PostgreSQL listens on localhost only by default (listen_addresses = 'localhost', unchanged on my machine), which is why it was refused from the containers and the VM; one bound to 0.0.0.0 would have answered. And everything but Multipass shares the host kernel: namespaces are a good boundary against an agent that does the wrong thing, not against code written to break out of them.

Bigger boxes

When you want the agent to run with no approval prompts at all (--dangerously-skip-permissions, --dangerously-bypass-approvals-and-sandbox), the sandbox has to be something the agent cannot switch off. Claude Code v2.1.311 goes one step further: --dangerously-skip-permissions still prompts for rm on critical paths such as ~, and launching with CLAUDE_CODE_EVEN_MORE_DANGEROUSLY_SKIP_PERMISSIONS=1 drops that prompt too. The docs say to set it only in an isolated environment whose contents you can afford to lose. I did not run it: my machine is on v2.1.306.

A container with a firewall: Claude Code’s devcontainer

Anthropic publishes a reference devcontainer8 for exactly this. It is a node:20 image, the project bind-mounted at /workspace, ~/.claude in a named volume, and an init-firewall.sh run at start-up that sets the default policy to DROP and allows:

  • DNS and outbound SSH,
  • GitHub’s published IP ranges (from api.github.com/meta),
  • registry.npmjs.org, api.anthropic.com, sentry.io, statsig.com and the VS Code marketplace,
  • the host’s /24 network.

The script then checks itself: it fails if example.com answers or if api.github.com does not. The agent runs as node, whose only sudo right is that one script; inside the container, iptables -F failed with “you must be root” and sudo iptables -F asked for a password.

I built the image and ran the probe the way the devcontainer does, with the project mounted at /workspace:

$ docker run --rm --cap-add=NET_ADMIN --cap-add=NET_RAW -v ~/sandbox-probe-demo:/workspace \
    -w /workspace cc-devcontainer bash -c 'sudo /usr/local/bin/init-firewall.sh; ./probe.sh'
...
Firewall verification passed - unable to reach https://example.com as expected
Firewall verification passed - able to reach https://api.github.com as expected
cwd write:   ok
home write:  ok
.git write:  ok
git hooks:   ok
ssh key:     hidden
network:     000blocked
localhost pg:bash: connect: Connection refused
closed

~/.ssh is not there at all, and curl https://github.com returns 200.

The line that deserves attention is git hooks: ok. The project, .git included, is a bind mount of the host directory. To check what that means, I wrote a harmless pre-commit hook from inside the container, then committed on the host:

# inside the container
hook written from container 986c3db638db
# on the host
$ git commit --allow-empty -m test && cat HOOK_RAN
pre-commit ran on xavie

Code written by the agent inside the box ran on my machine, outside any sandbox, the moment I committed. Codex, Claude Code and Drop protect that path; the devcontainer and odev do not. If you use either, commit from inside, or look at .git/hooks before you commit on the host.

The firewall is worth reading before you copy it, too. All of GitHub is reachable, which is also a way out for data. The host’s /24 on the Docker bridge is allowed, and my test server on 0.0.0.0:8765 answered 200 from inside. Domains are resolved to IPs once at start-up, so a CDN that moves breaks things until you restart. For Odoo work you would add pypi.org and files.pythonhosted.org, and decide deliberately whether the agent gets a database container of its own.

A sandbox for any program: Drop

Drop10 (droprun.sh) is a newer, Linux-only tool. It gives the program its own home directory and hides yours, uses user namespaces like bwrap and pasta for networking, and, according to its documentation, can optionally run the program on gVisor so it does not talk to the host kernel directly. I did not try the gVisor mode.

On Ubuntu 24.04 it needs one AppArmor profile first. Without it, drop run fails with “not enough capabilities … Is Drop allowed to use user namespaces via AppArmor profile?”, because Ubuntu restricts unprivileged user namespaces to programs that have one:

sudo apt-get install passt
DROP_BIN=$(which drop) && sudo tee /etc/apparmor.d/drop << EOF
abi <abi/4.0>,
include <tunables/global>
profile drop $DROP_BIN flags=(unconfined) {
  userns,
}
EOF
sudo systemctl reload apparmor.service

Then, from the project:

$ drop init
Drop environment created with config at ~/.config/drop/home-xavie-sandbox-probe-demo.toml
$ drop run ./probe.sh
cwd write:   ok
home write:  ok
.git write:  touch: cannot touch '.git/probe': Read-only file system
git hooks:   touch: cannot touch '.git/hooks/pre-commit': Read-only file system
ssh key:     hidden
network:     200
localhost pg:bash: connect: Connection refused
closed

With no configuration written by hand, it was the only one of the six that kept my home directory, my keys, the host’s .git/hooks, my environment and my local services all out of reach. It leaves the internet open:

  • ls -A ~ inside shows only what base.toml mounts: .bashrc, .profile, .gitconfig, .nvm, go, .local and the project. ~/code and /media do not exist.
  • drop init mounted the project read-write and its .git read-only, with the comment “block changing git config or adding hooks”. A commit fails with the same index.lock error as Codex.
  • The internet is open, but my test server was blocked on 127.0.0.1 and on my LAN address, so the agent can install packages and cannot reach local services.
  • Environment variables go through an allowlist: 78 on the host, 24 inside. I exported FAKE_API_KEY and ANTHROPIC_API_KEY before drop run; both were unset inside.

The trade-offs: the agent cannot commit, and the AppArmor profile trusts whatever file sits at ~/.local/bin/drop.

A real VM: Multipass

For a repository you know nothing about, the security post14 already has the answer: a VM, with its own kernel.

multipass launch 24.04 --name agent-vm --cpus 2 --memory 2G --disk 10G
multipass shell agent-vm
# ... clone, install the agent, let it run ...
multipass delete agent-vm && multipass purge
$ multipass exec agent-vm -- ./probe.sh
cwd write:   ok
home write:  ok
.git write:  ok
git hooks:   ok
ssh key:     hidden
network:     200
localhost pg:bash: connect: Connection refused
closed
kernel:      6.8.0-147-generic (host: 7.0.0-38-generic)
mounts from host: 0

Here .git and its hooks are the VM’s own copy, with nothing mounted from the host, so a hook only ever runs inside the VM. The one thing that crossed the boundary was the network back to the host: my test server on 0.0.0.0:8765 answered 200 through the VM’s gateway (10.97.0.1) and through my LAN address. A VM isolates the filesystem and the kernel, not the services you leave listening on all interfaces.

What the isolation costs

Measured on the same laptop, running true in a sandbox that already exists:

Time
codex sandbox -- true 0.04 s
drop run true 0.03 s
docker run --rm cc-devcontainer true 0.54 s
multipass exec agent-vm -- true 0.50 s

The per-command cost is small everywhere. The real cost of the VM is setup: the first multipass launch downloaded the image and took several minutes, a second launch with the image cached took 84.5 s, and delete plus purge took 1.4 s. Then you install the agent and its credentials inside, every time.

What I take from it

  • Turn the Claude Code sandbox on, check that it really is, and deny credentials in both places. It is off by default, it silently runs unsandboxed when socat or bwrap is missing, the default read access includes ~/.ssh, and denyRead alone did not stop the Read tool. The settings block above is what I now use.
  • Keep API keys out of the shell you start the agent from. Codex, Claude Code and odev all hand your environment to the commands the agent runs. Only Drop filters it.
  • In Codex, add the ~/.ssh deny rule. With the network off a key can still be read and printed into the conversation; with it on, localhost opens too.
  • Check .git/hooks whenever the project is bind-mounted read-write. The devcontainer and odev both let a hook written inside run later on the host. Codex, Claude Code and Drop block it.
  • Don’t count on any of these to protect a database on localhost. The containers and the VM were refused because PostgreSQL listens on localhost by default, not because of their own rules. odev, and Codex with its network on, share the host’s localhost, so there the password was the only thing in the way. Keep both defaults, and do not leave dev servers bound to 0.0.0.0.
  • Skipping approvals means a container or a VM, not a flag. The --dangerously-* flags belong inside the devcontainer or a Multipass VM, and even there, with the two caveats above.

The probe script takes a minute. Run it inside whatever sandbox you use before you trust it: I was wrong about odev’s network in my own post from last week, and the probe is what showed it.

  1. Codex sandboxing — OpenAI

  2. Codex Linux sandbox README — OpenAI ↩︎

  3. codex issue #1125 (CODEX_UNSAFE_ALLOW_NO_SANDBOX) — OpenAI ↩︎

  4. codex issue #1213 (CODEX_UNSAFE_ALLOW_NO_SANDBOX) — OpenAI ↩︎

  5. Claude Code sandboxing — Anthropic ↩︎

  6. sandbox-runtime — Anthropic ↩︎

  7. Claude Code permission modes (CLAUDE_CODE_EVEN_MORE_DANGEROUSLY_SKIP_PERMISSIONS) — Anthropic

  8. Claude Code reference devcontainer — Anthropic ↩︎

  9. Drop — Drop

  10. Drop source code — Drop ↩︎

  11. Installation notes — Drop

  12. Multipass — Canonical

  13. bubblewrap — containers

  14. Protecting Your Local Machine Against Supply Chain Attacks — Trobz Security ↩︎

  15. Introducing odev and its AI plugins — Trobz Internal ↩︎

Ready to put AI to work?

Let's explore how Trobz AI can automate your processes, enhance your ERP, and help your team make better decisions — faster.