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:
-
.gitis read-only even inworkspace-write. The source protects.git,.agentsand.codexinside 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 systemcodex execruns withapproval: never, so the commit simply fails. In the interactive CLI the documentedon-requestpolicy 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 printsnetwork: 200andlocalhost pg:open. -
~/.sshis readable. Everything outside the writable roots is visible, read-only. -
Your environment goes in. I started
codex execwith a dummyFAKE_API_KEYexported, 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/netprintednet:[4026531833]inside the sandbox and on the host. Internet, LAN and localhost are all reachable. localhost:5432is the host’s PostgreSQL. The empty cluster replaces the Unix socket thatpsqluses by default. Over TCP,psql -h 127.0.0.1 -lreached the host server and stopped only atfe_sendauth: no password supplied. What protects the customer databases here is the host server’s authentication, not the sandbox..git/hooksis 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.comand the VS Code marketplace,- the host’s
/24network.
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 whatbase.tomlmounts:.bashrc,.profile,.gitconfig,.nvm,go,.localand the project.~/codeand/mediado not exist.drop initmounted the project read-write and its.gitread-only, with the comment “block changing git config or adding hooks”. A commit fails with the sameindex.lockerror as Codex.- The internet is open, but my test server was blocked on
127.0.0.1and 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_KEYandANTHROPIC_API_KEYbeforedrop 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
socatorbwrapis missing, the default read access includes~/.ssh, anddenyReadalone 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
~/.sshdeny rule. With the network off a key can still be read and printed into the conversation; with it on, localhost opens too. - Check
.git/hookswhenever 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
localhostby 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 to0.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.
-
Codex sandboxing — OpenAI
-
Codex Linux sandbox README — OpenAI ↩︎
-
codex issue #1125 (CODEX_UNSAFE_ALLOW_NO_SANDBOX) — OpenAI ↩︎
-
codex issue #1213 (CODEX_UNSAFE_ALLOW_NO_SANDBOX) — OpenAI ↩︎
-
Claude Code sandboxing — Anthropic ↩︎
-
sandbox-runtime — Anthropic ↩︎
-
Claude Code permission modes (CLAUDE_CODE_EVEN_MORE_DANGEROUSLY_SKIP_PERMISSIONS) — Anthropic
-
Claude Code reference devcontainer — Anthropic ↩︎
-
Drop — Drop
-
Drop source code — Drop ↩︎
-
Installation notes — Drop
-
Multipass — Canonical
-
bubblewrap — containers
-
Protecting Your Local Machine Against Supply Chain Attacks — Trobz Security ↩︎
-
Introducing odev and its AI plugins — Trobz Internal ↩︎