Run Claude Code in a hardened VM with real Docker.
code-vm is the VM-based sibling of the container sandbox. The container
version emulates a container runtime with rootless Podman, which breaks
Docker/Podman compatibility, bridge DNS, and anything needing privileges.
A VM has its own kernel, so it runs a real Docker daemon instead.
- Real Docker. Rootless
dockerd, the real Docker CLI and API.docker composeservice discovery, buildx, and testcontainers behave as they do on a developer machine. - Egress allowlist. Squid domain allowlist plus iptables default-deny, enforced inside the guest where the agent has no sudo.
- Non-root agent. The agent runs as
devuser, whose UID and GID mirror yours so workspace files stay host-owned.limaadminholds sudo and is used only bycode-vmfor privileged setup. - Locked permissions.
~/.claude/settings.jsonis root-owned and read-only in the guest;settings.local.jsonis pre-claimed. - No host credentials. Only the directories you configure are shared. Host
$HOME,~/.sshand~/.awsare not visible in the guest.
- Linux x86_64 with KVM (
/dev/kvmreadable and writable by your user) - Lima 2.2.0 or newer, and
virtiofsd - mise for the build toolchain
Run code-vm doctor to check all of the above.
macOS is expected to work (Lima supports vz) but is not tested.
mise run build
sudo install -m 0755 dist/code-vm /usr/local/bin/code-vm
# Configure which directories are shared
mkdir -p ~/.config/code-vm
cat > ~/.config/code-vm/config.yaml <<'YAML'
projectsRoot: ~/projects
cpus: 4
memory: 12GiB
disk: 100GiB
YAML
code-vm doctor
code-vm start # first boot provisions the VM; expect several minutes
cd ~/projects/my-repo
code-vm -- claude login # once; persists on the guest disk
code-vm -- claude -p "fix the failing test" --max-turns 20
code-vm # interactive shell| Command | Purpose |
|---|---|
code-vm |
Interactive shell in the guest, at the current directory |
code-vm -- <cmd> |
Run a command as the agent, at the current directory |
code-vm start / stop |
Bring the VM up (idempotent) or shut it down |
code-vm status |
Instance state, shared paths, firewall verification |
code-vm mount <dir> |
Share another host directory (restarts the VM) |
code-vm recreate |
Delete and rebuild the guest from scratch |
code-vm proxy-log [all|denied|allowed|follow] |
Read the Squid access log |
code-vm allow [domain...] |
Add domains to the allowlist and apply them live |
code-vm doctor |
Check host prerequisites |
~/.config/code-vm/config.yaml:
instance: code-sandbox # the Lima instance this config drives
projectsRoot: ~/projects # the one directory always shared
extraMounts: # added by `code-vm mount`
- ~/work/other-repo
cpus: 4
memory: 12GiB
disk: 100GiB
extraDomains: # added to the Squid allowlist
- registry.mycompany.com
containerProxy: false # see belowNothing is read from the project directory. code-vm deliberately trusts no
file inside a workspace: the workspace is mounted writable and is exactly what
the agent edits, so anything there is agent-authored input. The host config is
the whole knob surface.
There is no credential injection mechanism. If a build needs a private registry, write the credential file into the guest home once — it persists across restarts, because the guest disk is the sandbox's durable state:
code-vm # shell into the guest
$ install -d -m 0700 ~/.gradle
$ cat > ~/.gradle/gradle.properties # paste, or pipe it inAssume the agent can read anything you put there, and use credentials created for the sandbox rather than your personal ones, so revoking them is cheap.
The previous .sandbox-secrets.yaml mechanism was removed rather than fixed. It
resolved each secret by running its source: command on the host — from a
file inside the workspace, which the agent can write. That is host command
execution reachable from inside the sandbox, which defeats the boundary the whole
design exists to draw. Its stated protection did not hold either: rendered files
were group-readable by the agent, and the generated deny rules only matched
commands where the path appeared as a separate argument, which python -c (an
allowed command) sidesteps.
code-vm allow # offer everything Squid recently denied
code-vm allow registry.example.com # add specific domains
code-vm allow --yes ghcr.example # no confirmation promptcode-vm allow writes accepted domains to extraDomains in the host config and
pushes them to Squid immediately — no VM restart, squid -k reconfigure takes a
few milliseconds and does not drop connections. With no arguments it reads the
denied entries from the proxy log, which is the quickest way to find what a
build actually needs.
The host config is the only source for the allowlist, deliberately. It lives
outside every mount, so the agent cannot reach it; code-vm refuses to start if
a mount would expose it. There is no per-project domain file: the agent can
write anything inside the workspace, so a domain file there would let it widen
its own egress — the exfiltration channel the firewall exists to prevent.
Domains a project needs belong in its README, or in each developer's config.
Removing a domain from the config revokes it on the next invocation; the guest fragment is rewritten to match, rather than keeping stale entries alive for the VM's lifetime.
Off by default. When on, docker run and docker build containers get
http_proxy pointed at the guest's Squid. That is useful when image builds need
to fetch packages, but it also injects the proxy into docker compose service
containers — where a bare service name like db matches no noProxy entry and
would be routed to Squid, breaking service-to-service traffic. Enable it per
project only when you need it.
code-vm firewall # show the current mode
code-vm firewall audit # allow all domains, keep the proxy and the log
code-vm firewall open --yes # unfiltered, unlogged agent egress
code-vm firewall allowlist # back to the defaultThe mode is runtime-only and lives in tmpfs, so restarting the VM always
reverts to allowlist. There is deliberately no config key: a loosened
firewall must not become the durable default.
Reach for audit first — it solves "the domain I need isn't allowlisted" while
keeping the access log. open exists for tooling that ignores http_proxy, and
gives up the audit trail as well as the filtering.
Two things to keep in mind before loosening it. This VM is shared by every
workspace you have mounted, under a single agent user, so a loosened firewall
applies to all of them at once — including credentials injected for other
projects. And the shipped permission profile allows python *, which means the
firewall is the primary defense against exfiltration; with it open, the
realistic risk is not you but prompt injection from content the agent reads
turning into an exfiltration channel.
In every mode the agent still cannot reach host services, and DNS tunneling to external resolvers stays blocked.
The perimeter is the VM boundary. Inside it, the agent is separated from guest
root: devuser has no sudo and the rootful Docker daemon is masked, so the
agent's own rootless dockerd cannot be used to become guest root.
These are consequences of the design, not oversights:
--privilegedgrants nothing outside the user namespace. Rootless dockerd accepts the flag, but the capabilities it grants are confined to the daemon's user namespace: a privileged container still cannot write host kernel state, load modules, or reach guest root. Workloads that need real privileges (arbitrarysysctls, host networking) do not work — that is the cost of rootless Docker, accepted in exchange for real separation between the agent and guest root.- No cross-project isolation. One agent user with all workspaces mounted means one project's agent can read another's tree and injected credentials.
- One allowlist for every workspace. There is a single agent user and a single Squid, so a domain allowed for one project is allowed for all of them.
- Mounts need a VM restart, because Lima declares them in the instance config.
- Guest root is reachable from the host by anyone who can run
limactl— you, never the agent.
mise run test:unit # Go tests: config, template rendering, argv construction
mise run lint # golangci-lint + shellcheck
mise run test:vm # full VM suite; requires KVMmise run test:vm builds its own throwaway VM (code-sandbox-test, minimal
resources) from a scratch config and deletes it afterwards, so it never touches
the instance you work in — it asserts that, comparing the default instance's
state and machine-id before and after. CODE_VM_KEEP=1 leaves the test VM up for
debugging a failure.
Because instance is a config key, the same mechanism runs two VMs deliberately:
point --config at another file naming another instance.
CI runs fmt-check, lint, test:unit and build on every push. The VM suite
needs nested KVM and a Lima install, so it lives in a separate vm-suite job
that is triggered manually from the Actions tab (workflow_dispatch) while we
confirm the stack comes up on a GitHub runner at all. Run it locally with
mise run test:vm — and do, before anything that touches a security control,
because CI showing green does not yet mean the suite passed.
The VM suite asserts the primitives testcontainers depends on — API socket, socket bind-mounting, Ryuk, published ports — rather than driving a JVM testcontainers run, which belongs with the projects that use it.