All articles

// Knowledge.log — 技術記事

Sandboxing coding-agent tool calls: filesystem and network isolation before the PR

Isolate coding-agent tool calls at the filesystem and network layer with bubblewrap: a reproducible escape test, kernel evidence, and a pre-PR checklist.

Your coding agent runs Bash, writes files, calls MCP — all of it under the same user and the same network session as your terminal. If a tool call decides (on its own, or because a malicious README.md in the repo injected an instruction) to read ~/.aws/credentials or open a connection to the outside world, nothing in the process stops it. The permission you configured is just a command-string check before it runs; once the process starts, it has the same access you do.

The result this article aims for is a test you run once and trust: a process isolated by Linux kernel namespaces, where writing outside the allowed area returns EROFS, reading a secret returns ENOENT, and opening a network connection returns ENETUNREACH — not because the model "behaved," but because the kernel refused.

Threat model: why this isn't your everyday CI sandbox

A CI pipeline runs a fixed, known-in-advance script inside a disposable image. The process that executes is the pipeline itself — predictable, audited in code review.

A coding-agent tool call is dynamic: the command that will run comes from the model's output, which may itself have been influenced by untrusted content (a file from the repo, a web page, the result of another tool). Command-string permission validation — "does this Bash call match the allowed pattern?" — is a decision gate, not a sandbox: it runs before the process starts and has no idea what the binary will actually do once it's running. This holds even for stricter architectures: the article on execute-verify-stop in an MCP server describes a policy layer that refuses tools/call before the side effect happens — and that layer is still valuable, but it operates before the process is born. This article covers the layer that still holds afterward: if the policy fails, gets bypassed, or the model gets tricked, the process is still trapped by the kernel namespace it was born into. That's not redundancy — it's defense in depth at two different points in the chain.

One thing worth stating upfront: bubblewrap and containers share the host kernel. It's not a VM. A kernel bug is, in theory, an escape route — if your threat model includes kernel exploitation, the right answer is gVisor or an actual VM, not namespace isolation. The guarantee claimed here is narrower and more verifiable: this process, with these mounts and namespaces, got EROFS/ENETUNREACH from the kernel — not "the model can't escape."

Prerequisites

  • Linux with unprivileged user namespaces enabled (kernel.unprivileged_userns_clone=1).
  • bubblewrap 0.11.1 (bwrap --version). This is what ran to generate the evidence in this article, on kernel 7.0.0-27-generic.
  • python3 available inside the sandbox, just for the network test (uses socket.create_connection).

Docker shows up here as documented reference — the flags (--read-only, --tmpfs, --network none) come from the official documentation, but they were not run on this host because Docker isn't installed here. Treat the Docker section as "the same concepts, a different tool," not as a tested result.

Filesystem: a real allowlist, not --ro-bind / / and a prayer

bubblewrap starts with an empty mount namespace — you mount every piece of the tree you want the process to see. That's good because it forces you to declare what exists, but there's a common trap: mounting --ro-bind / / to "have the whole system available" and thinking that's a sandbox. It isn't. It's the entire host, just read-only — and read access still includes ~/.ssh, ~/.aws/credentials, anything the user can read. A real allowlist also covers what can't be read, not just what can't be written.

The structure that separates read, write, and secret:

DEMO=/tmp/agent-sandbox-demo
rm -rf "$DEMO"
mkdir -p "$DEMO/repo" "$DEMO/scratch" "$DEMO/secret"
echo "public-code" > "$DEMO/repo/README.md"
echo "super-secret" > "$DEMO/secret/.env"
chmod 600 "$DEMO/secret/.env"

And the command that mounts the sandbox and opens a shell inside it (the probes in the next section replace that final /bin/sh with specific commands):

DEMO=/tmp/agent-sandbox-demo
bwrap \
  --ro-bind / / \
  --dev /dev --proc /proc \
  --ro-bind "$DEMO/repo" "$DEMO/repo" \
  --bind "$DEMO/scratch" "$DEMO/scratch" \
  --tmpfs "$DEMO/secret" \
  --unshare-net --unshare-pid \
  --new-session --die-with-parent \
  --chdir "$DEMO/repo" \
  /bin/sh

Notice the order: --ro-bind / / gives the base (everything read-only), and each subsequent bind overrides a piece of it — repo stays RO, scratch becomes RW, secret becomes an empty tmpfs that covers the real directory, hiding the .env entirely instead of just blocking writes to it. A gotcha worth knowing before you burn ten minutes on it: --bind DEST requires that DEST already exist on the left side (here, inside the --ro-bind / /); if you try to mount onto a path that doesn't exist, the mkdir from inside bwrap fails with "Read-only file system," because the target directory simply isn't there.

And always --new-session --die-with-parent: the first avoids terminal hijacking via TIOCSTI (CVE-2017-5226), the second ensures the child process doesn't survive orphaned if the parent dies.

The conceptual Docker equivalent (documented, not run on this host):

docker run --rm \
  --read-only \
  --tmpfs /scratch \
  --network none \
  --cap-drop ALL \
  --security-opt no-new-privileges \
  -v "$DEMO/repo:/workspace:ro" \
  -v "$DEMO/scratch:/scratch:rw" \
  --user 1000:1000 \
  <imagem> ...

--cap-drop ALL removes, among others, NET_ADMIN; Docker already drops CAP_SYS_ADMIN by default, so mount from inside the container fails even without that explicit flag.

Network: denied by default, allowlisting is a separate piece

--unshare-net creates a new netns with only loopback — no interface, no route to the outside. There's no "port left open by default" to escape through: the kernel simply has nowhere to route the packet.

The part that tends to confuse people: a domain allowlist (allow api.github.com but nothing else) is not a bwrap flag. --unshare-net only knows how to deny everything or (if you skip the flag) pass everything through the host's interface — there's no middle ground of "allow these hosts" inside the network namespace itself. That fine-grained control lives in a second piece, typically a proxy outside the sandbox that traffic gets redirected to (Unix socket + domain allowlist on the proxy is the pattern Anthropic's documentation describes for agent sandboxes). And it's worth stating the honest limitation of that piece: if the proxy doesn't terminate TLS, it decides by SNI/hostname — which can be fooled by domain fronting. Network allowlisting and network denial are two different guarantees, with two different mechanisms.

Reproducible escape test

Each probe below is a separate bwrap invocation — not a single script dumping combined output — reusing the same mounts from the previous section via a shell array:

DEMO=/tmp/agent-sandbox-demo
MOUNTS=(
  --ro-bind / /
  --dev /dev --proc /proc
  --ro-bind "$DEMO/repo" "$DEMO/repo"
  --bind "$DEMO/scratch" "$DEMO/scratch"
  --tmpfs "$DEMO/secret"
  --unshare-net --unshare-pid
  --new-session --die-with-parent
  --chdir "$DEMO/repo"
)

1. Write to README.md (expected: EROFS)

bwrap "${MOUNTS[@]}" /bin/sh -c 'echo bad > README.md'; echo "exit=$?"
/bin/sh: 1: cannot create README.md: Read-only file system
exit=2

2. Write to scratch (expected: ok)

bwrap "${MOUNTS[@]}" /bin/sh -c 'echo ok > /tmp/agent-sandbox-demo/scratch/out.txt'; echo "exit=$?"
exit=0

3. Read the secret under tmpfs (expected: ENOENT)

bwrap "${MOUNTS[@]}" cat /tmp/agent-sandbox-demo/secret/.env; echo "exit=$?"
cat: /tmp/agent-sandbox-demo/secret/.env: No such file or directory
exit=1

4. Write to /root/PWNED (expected: EROFS)

bwrap "${MOUNTS[@]}" /bin/sh -c 'echo pwned > /root/PWNED'; echo "exit=$?"
/bin/sh: 1: cannot create /root/PWNED: Read-only file system
exit=2

5. Connect to 1.1.1.1:443 from inside the isolated netns (expected: ENETUNREACH)

bwrap "${MOUNTS[@]}" python3 -c "import socket; socket.create_connection(('1.1.1.1',443),2); print('CONTROL_CONNECTED')"
OSError: [Errno 101] Network is unreachable

(full traceback omitted — what matters is errno 101, not the socket.py call stack.)

And, from outside the sandbox, the host confirms what didn't change:

cat "$DEMO/repo/README.md"    # public-code — unchanged
cat "$DEMO/scratch/out.txt"   # ok — the write went to the allowed area
ls /root/PWNED                # No such file or directory

Running the same probe 5 without --unshare-net (control, same payload, different namespace):

bwrap --ro-bind / / --dev /dev --proc /proc --unshare-pid \
  --new-session --die-with-parent \
  python3 -c "import socket; socket.create_connection(('1.1.1.1',443),2); print('CONTROL_CONNECTED')"
CONTROL_CONNECTED

That's the proof that probe 5's block came from the isolated netns, not from an external firewall or the machine lacking connectivity.

Now the negative control — the reason this article keeps insisting that "--ro-bind / / is not an allowlist." Run the same mount command, but drop the --tmpfs "$DEMO/secret" line:

bwrap \
  --ro-bind / / \
  --dev /dev --proc /proc \
  --ro-bind /tmp/agent-sandbox-demo/repo /tmp/agent-sandbox-demo/repo \
  --bind /tmp/agent-sandbox-demo/scratch /tmp/agent-sandbox-demo/scratch \
  --unshare-net --unshare-pid \
  --new-session --die-with-parent \
  --chdir /tmp/agent-sandbox-demo/repo \
  /bin/sh -c 'cat /tmp/agent-sandbox-demo/secret/.env; echo "NEGATIVE_LEAK=$?"'

Output:

super-secret
NEGATIVE_LEAK=0

The .env was only covered by --ro-bind / /, with no tmpfs on top. It wasn't written to — but it was read, successfully, exit=0. That's exactly the kind of configuration bug that passes a smoke test ("the sandbox didn't let anything get written!") and fails in production the moment someone asks the agent to exfiltrate a secret instead of deleting it.

Verifying isolation at the right level

The success criterion isn't "the child process exited with code zero" — a shell keeps running commands even after one of them fails, so "exit 0 at the end" proves nothing about what happened along the way. The criterion is host state + errno: is README.md still public-code? Did the file show up in scratch and only there? Did the TCP connection return 101 and not a slow-network timeout?

That's also what separates "the sandbox blocked it" from "the model didn't try": running the same payload twice, once inside the isolation and once outside (the controls above), is the test that actually attributes the block to the kernel. Without the control, an ENETUNREACH might just be the CI network being down that minute.

Concrete remediation: treat --ro-bind / / as the dangerous starting point it is — every path that might hold a secret (~/.aws, ~/.ssh, .env, CI tokens) needs an explicit --tmpfs on top of it, not just the absence of a write --bind. And every extra --bind/--tmpfs destination needs to exist before you mount it, or bwrap fails trying to create it.

Production-safe observability: don't log the isolated process's output (it may contain the very secrets you're protecting). Capture bwrap --info-fd — it emits JSON with the child's PID and the namespaces created — as a per-run artifact, and run the host-state-plus-errno check above as an automated assertion in the pipeline, not something a human checks manually now and then.

Pitfalls

  • --ro-bind / / alone hides no secrets — it's a write allowlist, not a read allowlist. Cover it with --tmpfs or don't bind all of /.
  • --bind/--tmpfs stacked on --ro-bind / / requires the destination to already exist; otherwise the error message ("Read-only file system") gets confused with an intentional block.
  • Docker's socket (/var/run/docker.sock) mounted inside the sandbox is full host access, no isolation at all — Anthropic's own documentation lists this as a known bypass.
  • A domain-allowlist proxy without TLS termination decides by the announced hostname, not the real destination; domain fronting fools this kind of filter.
  • No performance numbers were measured here — there's no latency or throughput claim in this article, and there shouldn't be one without real load.

Next step

Run the escape test (with the negative control) in the environment where the agent will actually execute Bash — not just on this demo host — before you trust it in CI. If any probe returns a different result than it did here, the sandbox still isn't ready to sit between the agent and your repository.

ai-agentslinuxdevex

// Continue.training — 次のステップ

Knowledge only counts when it becomes practice.

Go back to the article, run the examples, and share what you learned.

Explore more articles