How do I sandbox an AI coding agent?
To keep an AI coding agent such as Claude Code off your host, run its commands in a disposable environment. That environment holds a copy of the source, the project’s toolchain (Cargo, Maven, or Gradle), and its own test services, such as Postgres and Kafka. The agent must not be able to go around the environment, and the environment must not need the host’s Docker socket. Dev containers, microVMs such as Docker Sandboxes, and plain docker run each meet some of these requirements. Dagger meets all of them in one place. A small module defines the sandbox container and its services in code. The agent runs every command through dagger shell, and the same container runs your checks locally and in CI.
What a safe agent sandbox must do
A good sandbox for a coding agent meets six requirements. The first four come from the isolation docs of the agent and container vendors. The last two come from how agents work: they rebuild many times, and their results must hold in CI.
- Commands run outside the host filesystem. A
cargo clean, anrm -rf, or a test that writes files changes a copy, not your checkout. Claude Code’s docs say that when permission prompts are off, “the isolation boundary you choose is what protects your system”. - The agent cannot go around the sandbox. Claude Code’s sandboxed Bash tool covers shell commands, but “Built-in file tools, MCP servers, and hooks still run directly on your host”. The setup must say which tools the agent can use.
- The sandbox has the project’s toolchain, and build output stays out of the working tree.
target/,build/, and~/.m2belong to the sandbox. - Test services start without the host Docker socket. The Testcontainers docs say that when tests run in a container, “The docker socket must be available via a volume mount”. Docker’s own comparison lists a socket mount as “Shared host daemon”.
- Reruns are fast. Dependency downloads and compiled artifacts stay cached between runs.
- The same environment runs locally and in CI. A test that passes in the agent’s sandbox passes in CI for the same reason.
Meet the requirements with dev containers, Docker, or microVMs
Each common approach meets part of the list. Pick one by what you need to isolate.
- Claude Code’s sandboxed Bash tool uses operating system features to restrict shell commands, and needs no Docker. It does not cover file tools, MCP servers, or hooks (requirement 2), and it does not start test services (requirement 4). See Choose a sandbox environment.
- A dev container puts the whole agent in a container with your toolchain. Claude Code has an official Dev Container Feature. Commands run in the container, and edits to project files appear in your local repository. Test services need Docker Compose beside it, or a socket mount for Testcontainers.
docker runwith a bind mount gives the same isolation for one command. The Testcontainers pattern adds-v /var/run/docker.sock:/var/run/docker.sock, and that breaks requirement 4.- Docker Sandboxes run the agent in a microVM with “its own kernel, its own private Docker Engine, and no path back to the host” (product page).
sbx run claudestarts Claude Code in it, anddocker compose upruns inside it. Local use is free. A direct workspace mount “is read-write, so the agent edits your working tree in place”. With--clone, the agent works on a private clone.
To isolate the whole agent process and keep its credentials outside the boundary, use a microVM such as Docker Sandboxes.
Where the hand-built sandbox strains
The problems grow with the number of services and agents.
- The service setup lives in a second file. A Compose file next to the dev container must match the CI service definitions. When they differ, a test passes in the sandbox and fails in CI (requirement 6).
- Testcontainers needs a Docker daemon in the sandbox. Without a microVM, that means a socket mount or a Docker-in-Docker container, which Docker’s comparison lists as “Partial (privileged)” isolation (requirement 4).
- Caches are per sandbox. The Docker Sandboxes docs say that “Multiple sandboxes don’t share images or layers”. Each new sandbox downloads crates or Maven dependencies again (requirement 5).
- CI is a separate definition. The sandbox serves the agent, and the CI workflow defines its own containers and services (requirement 6).
Meet the requirements with Dagger
Dagger runs each step in a container on the Dagger Engine, the open-source runtime that runs and caches the work (Glossary). A Dagger module is a package of functions that build and test the project. You write it in Dang, Dagger’s native module language (Dang SDK). For Cargo and Gradle, wrap the commands you already run in a small project module, as below. To find a published module for your toolchain, run dagger module search.
Install the Dang SDK and create the project’s dev module. Run these commands from the root of your Git repository:
dagger init -y
dagger module install dagger.io/sdk/dang
dagger module init dang -y
dagger init writes dagger.toml, the workspace configuration. dagger module init with no name creates .dagger/modules/<project>-dev and installs it as the workspace main. Dagger also writes dagger.lock, which pins the images and modules that the workspace uses. Commit all three.
Replace .dagger/modules/orders-dev/main.dang with the sandbox. OrdersDev stands in for the type name that dagger module init generated from your project name:
"""
A disposable sandbox where a coding agent builds and tests the project.
"""
type OrdersDev {
let source: Directory!
new(ws: Workspace!) {
self.source = ws.directory("/", exclude: ["target", ".git"])
self
}
"""
A Postgres database for the integration tests.
"""
let postgres: Service! {
container
.from("postgres:17")
.withEnvVariable("POSTGRES_PASSWORD", "test")
.withExposedPort(5432)
.asService
}
"""
A single-node Kafka broker for the integration tests.
"""
let kafka: Service! {
container
.from("apache/kafka:4.1.0")
.withEnvVariable("KAFKA_NODE_ID", "1")
.withEnvVariable("KAFKA_PROCESS_ROLES", "broker,controller")
.withEnvVariable("KAFKA_LISTENERS", "PLAINTEXT://:9092,CONTROLLER://:9093")
.withEnvVariable("KAFKA_ADVERTISED_LISTENERS", "PLAINTEXT://kafka:9092")
.withEnvVariable("KAFKA_CONTROLLER_LISTENER_NAMES", "CONTROLLER")
.withEnvVariable("KAFKA_LISTENER_SECURITY_PROTOCOL_MAP", "CONTROLLER:PLAINTEXT,PLAINTEXT:PLAINTEXT")
.withEnvVariable("KAFKA_CONTROLLER_QUORUM_VOTERS", "1@localhost:9093")
.withEnvVariable("KAFKA_OFFSETS_TOPIC_REPLICATION_FACTOR", "1")
.withExposedPort(9092)
.asService
}
"""
The sandbox: the Rust toolchain, a copy of the source, and the test services.
"""
pub sandbox: Container! {
container
.from("rust:1.90")
.withMountedCache("/usr/local/cargo/registry", cacheVolume("cargo-registry"))
.withMountedCache("/src/target", cacheVolume("cargo-target"))
.withDirectory("/src", source)
.withWorkdir("/src")
.withServiceBinding("postgres", postgres)
.withServiceBinding("kafka", kafka)
.withEnvVariable("DATABASE_ADDR", "postgres:5432")
.withEnvVariable("KAFKA_BOOTSTRAP_SERVERS", "kafka:9092")
.withDefaultTerminalCmd(["/bin/bash"])
}
"""
Run cargo test in the sandbox.
"""
pub test: Void @check {
sandbox.withExec(["cargo", "test"]).sync
null
}
}
The next sections show how this module meets each requirement.
Commands run outside the host filesystem
dagger shell sandbox -c runs one command in the sandbox container, then stops and returns the command’s exit code (Dev shells). Each call starts a new container from the module’s definition. The container holds a copy of the working tree, with uncommitted edits, not a mount of your checkout. In this guide’s project, a destructive command removed the copy and left the checkout unchanged:
dagger shell sandbox -c 'rm -rf src tests Cargo.toml && ls'
dagger.toml
target
After the command, ls on the host still listed Cargo.toml, src, and tests.
The agent cannot go around the sandbox
In Claude Code, permission rules decide which tools the agent can use. The Claude Code docs say that “Permission rules are enforced by Claude Code, not by the model”. In dontAsk mode, Claude Code denies every call that would otherwise prompt. Pre-approved tools, file reads, and read-only Bash commands still run (permission modes).
Allow the sandbox commands and edits under the source directories in .claude/settings.json:
{
"permissions": {
"defaultMode": "dontAsk",
"allow": [
"Bash(dagger shell sandbox -c *)",
"Bash(dagger check *)",
"Edit(/src/**)",
"Edit(/tests/**)"
]
}
}
With these rules, the agent edits source files in the working tree with its Edit tool, and it runs every build or test command in the container. To keep the agent’s edits away from your checkout too, start the agent in a Git worktree. The agent process itself still runs on the host. To isolate the process and its credentials too, run the agent in a microVM or a dev container and let it call Dagger from there.
The toolchain is in the sandbox, and build output stays there
The sandbox starts from the toolchain image, rust:1.90 in this example. Cargo writes target/ to a cache volume, a directory that Dagger keeps between runs and mounts into the container (CacheVolume). target/ never appears in your checkout.
For other toolchains, change the image and the cache path:
- Maven: start from
maven:3.9-eclipse-temurin-21and mount a cache volume at/root/.m2. For the project’s own build and tests, the official Maven module does this for you (How do I run Maven builds the same way locally and in CI?). - Gradle: start from
gradle:8.14-jdk21, mount a cache volume at/root/.gradle, and excludebuildand.gradlefrom the source.
In this guide’s runs, mvn test and gradle test passed in those containers against the Postgres service. No target/ or build/ directory appeared on the host.
Test services start from code, with no Docker socket
A Dagger service is a long-running container, such as a database or a broker. A function binds it to the sandbox with withServiceBinding (Running services). The sandbox reaches Postgres at postgres:5432 and Kafka at kafka:9092. Dagger checks that a service is healthy before any client connects, and stops it when nothing uses it. Another service, such as Redis, is one more function of the same shape. The sandbox does not mount a Docker socket:
dagger shell sandbox -c 'cargo test --test services'
running 2 tests
test kafka_is_reachable ... ok
test postgres_is_reachable ... ok
test result: ok. 2 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s
Finished `test` profile [unoptimized + debuginfo] target(s) in 0.01s
Running tests/services.rs (target/debug/deps/services-20918be52632dceb)
This is the Compose model: you define the services once in the module, outside the tests. In the Testcontainers model, the test starts its own services. With Dagger, such a test calls Dagger through a client library, such as io.dagger:dagger-java-sdk for Java or dagger-sdk for Rust. You run the test command under dagger api with-session (Client libraries). The guide to integration tests with real databases and Kafka covers both models.
Reruns are fast
Dagger caches each step by its inputs, and the cache volumes keep Cargo’s registry and target/ between runs. This guide’s project depends on serde_json. Each command below was dagger shell sandbox -c 'cargo test' in a new sandbox container:
- The first run, with new inputs, downloaded and compiled the dependencies in 9.1 seconds.
- A rerun with no changes took 0.9 seconds.
- A rerun after a one-line change to
src/lib.rscompiled only the crate, in 0.9 seconds.
These are single runs on one machine, so treat them as an example. Agents that use the same engine use the same cache. To run many agents without the load on your laptop, --engine=cloud sends the same commands to Dagger’s Cloud Engines (Cloud Engines).
Run the same sandbox locally and in CI
The test function is a check, a function that passes or fails (Checking your code). dagger check runs it in the same container that the agent uses:
dagger check
CHECKS ✔ 2 passed
✔ dag://?check=dang-sdk/generate/stale 1.0s OK
✔ dag://?check=test 0.4s OK
In CI, Cloud Checks run the same checks on Dagger’s Cloud Engines for each push and pull request, with no CI workflow file. Connect the GitHub repository from the project root:
dagger cloud checks on
See Set up Cloud Checks for the account and GitHub steps.
Bottom line: Give the agent a disposable container that holds a copy of the source, the toolchain, and its own test services, and allow it to run commands only there. Dagger defines that container, Postgres, and Kafka in one Dang module, with no Docker socket. The agent runs commands with dagger shell sandbox -c. In this guide’s project, a cached rerun took 0.9 seconds, and dagger check ran the same container that Cloud Checks use in CI. A microVM such as Docker Sandboxes also isolates the agent process itself, and you can use the two together.
Related guides
- How do I run integration tests against real databases?: services from a module or from the test code.
- How do I cache Rust builds in CI?: keep Cargo’s registry and
target/cached between runs. - What is Dagger’s role in an agentic software factory?: how agents and CI use one engine.