Skip to content

Repository files navigation

code-vm

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.

What you get

  • Real Docker. Rootless dockerd, the real Docker CLI and API. docker compose service 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. limaadmin holds sudo and is used only by code-vm for privileged setup.
  • Locked permissions. ~/.claude/settings.json is root-owned and read-only in the guest; settings.local.json is pre-claimed.
  • No host credentials. Only the directories you configure are shared. Host $HOME, ~/.ssh and ~/.aws are not visible in the guest.

Prerequisites

  • Linux x86_64 with KVM (/dev/kvm readable 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.

Quick start

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

Commands

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

Configuration

~/.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 below

Nothing 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.

Credentials

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 in

Assume 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.

Extending the allowlist

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 prompt

code-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.

containerProxy

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.

Firewall modes

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 default

The 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.

Security model

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.

Known limitations

These are consequences of the design, not oversights:

  • --privileged grants 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 (arbitrary sysctls, 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.

Testing

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 KVM

mise 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.

About

No description, website, or topics provided.

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Used by

Contributors

Languages