DVM: small per-project VMs on macOS

· Tech

I wanted my projects off the host. Every install script, every dev tool, every AI CLI that decides to curl | bash something, all of it inside a VM. Not on the laptop where I also have a browser logged into email, GitHub, and my bank.

The thing I keep picturing: I npm install something, it has a postinstall hook, and the hook does what it wants for the next few seconds. On the host that hook can read every project I have ever cloned, every SSH key on my keyring, every token in my shell. I want it to see one project, one VM, one VM-local SSH key that only works for that project. Nothing else.

I looked around first. Nothing fit.

So I wrote DVM. The README puts it as “keep your friends close, your supply chain in a VM.”

What it is

A Bash wrapper around Lima. Each project gets its own Fedora VM. Code, dev tools, AI CLIs, and credentials live inside the VM. The host stays small: Lima, Bash, and a config directory under ~/.config/dvm.

How it works

dvm init app writes ~/.config/dvm/vms/app.sh. dvm sync app renders one Lima YAML, starts the VM, runs the baseline, then runs the recipes you picked. dvm sh app opens a shell in the project directory inside the VM. dvm rm app --yes deletes the VM, but only after a dirty-work check on nested Git repos and loose files in the code directory.

Host project directories are not mounted by default. Code is cloned from Git or created inside the VM. If I need it on the host, I commit it or dvm cp it out. The ~ in DVM config always means the guest home, never the host home, so a typo cannot accidentally point a recipe at host files.

What a compromised package sees

This is the part I cared about. Inside a VM, a malicious dependency, postinstall hook, or AI tool can reach:

What it does not reach:

The AI layer

AI CLIs (Codex, Claude, OpenCode, Mistral) get a second wall. The wrappers always run them as a separate user, dvm-agent, inside Bubblewrap. The sandbox mounts the project at /workspace, but it does not mount the main VM user’s home. So even inside the VM, the agent cannot read my dotfiles, my shell history, or anything outside the project tree.

By default the agents run unattended, so I do not have to babysit prompts. DVM_CLAUDE_BYPASS=0 and DVM_CODEX_YOLO=0 turn approval back on when I want it.

Bubblewrap is the inner ring; the VM is the outer ring. Guest root or a bad sudo policy can bypass Bubblewrap. That is why the VM, not the sandbox, is the real boundary.

What it ships with

App VMs with configurable CPU, memory, disk, ports, and recipes. Bundled recipes for the baseline, zsh, git, helix, lazygit, starship, fzf, bat, git-delta, just, tmux, zellij, yazi, Node, Python, and chezmoi. AI recipes for Codex, Claude, OpenCode, and Mistral. Dedicated service VMs for llama (local models), cloudflared (tunnels), and tailscale (private hostnames between VMs, optional Funnel for public URLs). VMs reach each other over lima-dvm-<name>.internal. Local recipes in ~/.config/dvm/recipes override bundled ones, so I can pin a fork without forking DVM itself.

What I gave up

VMs cost disk and memory. Code in a guest has to leave through Git or dvm cp if I want it on the host. DVM does not review what AI tools write, does not back up VM work, and is not stronger than Lima. Guest root or weak sudo inside a VM can bypass the in-guest sandbox.

I am fine with that. The blast radius of “I let an agent run for two hours” used to be my whole laptop. Now it is one VM I can delete.