Sandboxing Claude Code: a container, Docker sandboxes, then a VM
Three attempts at giving a coding agent room to work without giving it my home directory: a pinned Docker image, Docker's sbx sandboxes, and corral, a per-project Lima VM with a host-side egress filter.
Claude Code runs as my user, so it can read whatever I can read — ~/.aws, ~/.ssh, every .env — and push with my credentials. Permission prompts help, but they are a policy the model is asked to respect rather than a boundary, and between March and May 2026 I tried three ways of putting a real one around it.
A pinned container
The first was claude-docker, a Debian image with every tool pinned and my Claude configuration mounted read-only. It solved reproducibility well enough, but a container shares the host kernel, and the workspace and configuration mounts meant that what it contained was mostly the toolchain.
Docker sandboxes
The second used Docker’s sandboxes (docker sandbox, later the standalone sbx CLI). I rebuilt the image as a template for them (docker-sandbox-claude) and wrote a small wrapper, corral, so that cd project && corral would create, resume or attach to the project’s sandbox. Two problems kept coming back.
The first was memory. I was using claude-mem at the time, which keeps its index in SQLite, and sharing one host directory between sandboxes over virtiofs corrupted it: the sandbox worker reported itself healthy but never ready, the host worker returned disk I/O error, and searches hung. One directory per sandbox with a lock file reduced the damage without removing the cause, since SQLite’s locking does not hold across virtiofs. The second was that sbx ls --json changed shape between minor versions, so the wrapper broke on upgrades. The stock template also runs Claude with --dangerously-skip-permissions behind a network allowlist broad enough for real work, which is why my image kept the permission prompts on inside the sandbox.
A VM per project
On 19 May I rewrote corral on Lima. Each project gets an Ubuntu VM that mounts only the project directory, with no home directory, no SSH agent forwarding and a guest user that is not mine; git pushes go through per-VM gh and glab tokens. Containers run inside the VM with rootless containerd, so the agent can break those without touching anything on the host.
The memory problem went away with the rewrite. Each project has its own Lima disk, formatted ext4 inside the guest and mounted where claude-mem keeps its data, and the hypervisor lets only one VM attach a given disk, so there is never a second writer and SQLite’s locking works as designed.
The part I had not planned for was the network. An agent that needs to reach the Anthropic API can reach anything else too, so corral has an optional egress filter on the host: pf drops everything from the VM subnet except connections to a local HAProxy, which allows HTTPS only to hostnames on an allowlist, checked by SNI before the TLS handshake completes. Root inside the VM cannot change any of this. The allowlist started at about eighty common development hosts, and I learned its real contents by watching provisioning fail on deb.nodesource.com, get.helm.sh and starship.rs in turn; each of those is now an integration test. The filter does not inspect payloads, so a token for an allowlisted host such as GitHub is still a way out, and the security notes in the repo list that alongside the other known gaps (IPv4 only, source-address spoofing not audited).
The host sandbox
Claude Code also has its own sandbox on macOS, which wraps each shell command in Seatbelt with a filesystem and network policy, and I use it for sessions on the host. It is much lighter than a VM, and it has two failure modes I now check for before believing an error.
The read-deny rules match on file names, so **/*secret* also blocks Python from importing pydantic_settings/sources/providers/secrets.py, and a perfectly healthy basic-memory --help fails with a ModuleNotFoundError deep inside a dependency. A sweep for dangling symlinks reported a link as broken for the same reason — its target existed, and the stat was denied. And name resolution on macOS goes through a system service that the sandbox blocks, so ssh reports that a host cannot be resolved while dig in a normal terminal resolves it at once. That one produced a confident and wrong note in a task file, saying an SSH alias for my router was broken, which I disproved with a single dig.
Was it worth it?
Yes. corral gives each project a disposable machine with a real kernel boundary, a single command to reach it, and nothing from my home directory inside it except the project, so an agent can be given far more freedom than I would allow on the host, and the worst it can do is wreck a VM I can rebuild in a few minutes.
Compared with what I tried before, the difference is in what stays outside. A dev container shares the host kernel and usually gets more of the host mounted than it needs; Docker’s sandboxes isolate better, but the stock setup skips permission prompts, and I could not keep a database on shared storage or rely on the CLI’s output across versions. Claude Code’s own sandbox is the lightest of all, but it runs as my user on the host and only applies to shell commands. corral also filters the network on the host, where the guest cannot reach it, and it needs no Docker Desktop licence.
It was also a good way to learn things I had only used from a distance: how Lima and Apple’s virtualization framework fit together, why SQLite locking stops working over virtiofs, and how far pf and an SNI check in HAProxy can go before payload inspection becomes the only option left.
dmorel69/corral— Python, Lima, pf, HAProxy