Practice

How to sandbox an AI coding agent (and what a sandbox cannot prove)

Traceseal · 4 August 2026 · 8 min read

The question usually arrives after the run rather than before it. An agent has spent forty minutes in a repository, the diff is larger than anyone expected, and somebody asks whether it touched anything outside the working directory. The instinct is to search the transcript. The trouble with that instinct is the one this blog keeps returning to: the transcript is written by the process you are asking about, so a clean transcript is exactly what a misbehaving run produces.

The durable answer is to make the action impossible rather than to watch for it. This post is a working profile for doing that on Linux, an honest account of where it leaked when we tested it, and the point at which a sandbox stops helping.

What a sandbox is, underneath the word

"Sandbox" is used loosely enough to mean anything from a Docker container to a permissions prompt. On Linux it has a specific meaning, assembled from three kernel facilities:

Bubblewrap is a small setuid-or-userns tool that composes the first two into a single command. It is what Flatpak uses underneath, it needs no daemon, and it starts in milliseconds, which matters when the thing you are wrapping is invoked hundreds of times a day. Everything below was run against bubblewrap 0.11.0 on Debian.

A profile that actually runs

The shape you want for a coding agent is narrow: it may read the system, it may write to one directory, and it may not reach the network.

$ bwrap \
    --ro-bind / / \
    --bind "$PWD" "$PWD" \
    --dev /dev --proc /proc \
    --unshare-net \
    --unshare-pid \
    --die-with-parent \
    --chdir "$PWD" \
    -- your-agent-cli run

Line by line: --ro-bind / / mounts the entire host filesystem read-only, so the agent keeps the toolchain, libraries and language runtimes it needs and can modify none of them. --bind "$PWD" "$PWD" punches one writable hole at the working directory. --unshare-net puts the process in a fresh network namespace with no interface but loopback. --unshare-pid stops it seeing or signalling other processes on the box, and --die-with-parent means an orphaned agent is killed rather than left running.

Two classes of action stop being things you monitor and become things that cannot happen. A write outside the working directory fails at the kernel:

$ touch /home/tim/SHOULD_NOT_EXIST
touch: cannot touch '/home/tim/SHOULD_NOT_EXIST': Read-only file system

And there is no route off the machine, so a connection to a raw address fails to establish. That is a better guarantee than any amount of transcript review, because it does not depend on the agent's cooperation, its honesty, or your diligence in reading the output.

Where it leaked

Testing the profile above produced a result we did not expect. Egress was properly gone: ip route was empty inside the namespace and curl to a bare IP address failed to connect. But name resolution still worked.

$ getent hosts example.com
2606:4700:10::6814:179a  example.com
2606:4700:10::ac42:93f3  example.com

The explanation is in /etc/nsswitch.conf. Debian's hosts line includes the resolve module, which does not send a DNS packet at all: it talks to systemd-resolved over a Unix socket at /run/systemd/resolve/io.systemd.Resolve. That socket arrived inside the sandbox as an ordinary file, carried in by the read-only bind of /. Masking the directory confirms it, and the resolution stops:

$ bwrap ... --unshare-net --tmpfs /run/systemd/resolve -- getent hosts example.com
$   # no output

The general lesson is worth more than the specific fix. A network namespace governs interfaces, routes and sockets in the network stack. It has nothing to say about a Unix socket to a host daemon that still has full network access, and a filesystem bind will hand you one of those without mentioning it. Anyone auditing an agent sandbox should assume the same is true of the container runtime socket, the SSH agent socket, and the D-Bus session bus.

Worth stating plainly: we wrote the confident version of the paragraph above first, then ran it, and the run disagreed. A sandbox profile that has not been driven end to end is a hypothesis about isolation, not a control.

Three further limits are structural rather than fixable. The working directory is writable by design, and for most repositories that is where the interesting secrets live: .env files, and .git/config remotes with an access token embedded in the URL. Environment variables cross the boundary untouched unless you add --clearenv, so an API key exported in the parent shell is available inside. And the kernel is shared, which is the standing caveat on all container-style isolation and the reason NIST SP 800-190 treats it as weaker than a virtual machine boundary. Docker's none network driver is the same idea with the same caveats, if a container is already in your stack.

The part nobody budgets for

Isolation changes what the agent does, not only what it is able to do, and this is a genuine operational cost rather than a footnote. An agent that cannot reach the network will still try to install a package. When that fails, it improvises: it writes a stub, or vendors something from a cache, or quietly adjusts a test so the missing dependency stops mattering. The run does not stop; it goes sideways.

The second effect is on diagnosis. A run that failed because the sandbox blocked it and a run that failed because the task was wrong produce output that looks much the same, and the agent's own explanation of which one happened is not evidence either. Teams adopting sandboxing tend to lose a week to this before they start logging the policy denials separately from the task failures. It is worth doing on day one. This is also why grading agents against ground truth on disk beats grading them on what they said they did.

From control to evidence

Here is the limit of everything above. A sandbox constrains; it does not communicate. You now know the agent could not write outside the repository. A customer, an auditor or a regulator asking the same question next quarter still has nothing but your description of a profile you say you applied.

Closing that gap is what an execution receipt is for. The receipt specification carries a sandbox_profile_hash field: a SHA-256 hash of the sandbox configuration, meaning the bubblewrap argument list plus the environment. A verifier holding a known-good profile can hash it and check whether the run used that profile or a different one, offline, with one command and no access to your systems. Combined with the signature over the whole record, an altered profile cannot be presented as the original. For what the signature covers and what an [OK] means, see the walkthrough and the primer on why logs are not evidence.

What that does not do is more interesting, and the specification says so itself rather than leaving a reader to discover it. Among the things a valid receipt does not prove:

That the sandbox actually enforced the declared profile (the receipt records what the operator claims the sandbox was; a compromised operator could lie).

Section 6.1 of the same document puts it more bluntly still: the receipt is "an attestation, not a proof of execution in the zero-knowledge sense". An operator who never ran bubblewrap at all can sign a receipt naming a profile hash they took from the documentation.

What is still open

That last gap does not close by thinking harder about receipt design, and we are not going to pretend a signature solves it. It closes only by moving the seal outside the party being trusted, and each route out has a real cost. A second party can run or observe the sandbox and cosign the record, which works and requires someone willing to be that party. Hardware-rooted attestation can bind the measurement to a chip rather than an assertion, at the price of a much heavier deployment. A third-party execution environment removes the operator's discretion entirely, along with the operator's control of their own infrastructure.

Which of those becomes normal is going to be settled by procurement departments and insurers rather than by cryptographers, and probably not this year. In the meantime the honest position is that a signed profile hash moves an unverifiable claim to a checkable one and stops short of proof, which is still a considerable distance from a log file nobody can check at all.

The regulatory direction is at least consistent with the effort. Article 15 of Regulation (EU) 2024/1689 requires high-risk systems to be designed for an appropriate level of accuracy, robustness and cybersecurity across their lifecycle. A demonstrable containment boundary is easier to argue than a policy document describing one.

Questions to ask of your own setup

The receipt format, the verifier and the transparency log are open. You can adopt the format without adopting us.

Give your agents receipts.

Open spec, open verifier, one command to check.

How Traceseal works →