Part of my notes on running coding agents with least privilege.

Earlier posts in this series promised a sandbox for my coding agents. Part 4 designed one around gVisor and Firecracker, and neither was ever built. What exists is smaller: a Lima virtual machine with a short list of mounted host directories. I also wrote a NixOS image for that VM with a locked-down user, and I need to be direct that the VM I have run does not use it. This post covers what exists, with the evidence I could collect today.

For the threat model, read Part 3 and Part 4. In short, an agent runs commands with my permissions, so the question is what those permissions reach. The VM is meant to shrink that reach to a few directories. Credentials for the model API are covered in the Bedrock post, and command screening in the guards post. The VM first appears in Part 15 as a template for a second, disposable VM.

Two VM definitions

The first definition is nix/vms/opencode-lima/default.nix, a NixOS configuration. Its header says it is a minimal NixOS for running OpenCode in isolation, built with nix build ~/dotfiles/nix#opencode-lima-image and copied to lima/opencode-nixos.qcow2. It claims a separate user with no sudo, no host SSH keys, GPG or AWS credentials, and a firewall that blocks everything inbound except SSH. The user block is short:

users.users.opencode = {
  isNormalUser = true;
  shell        = pkgs.fish;

It sets no extraGroups, so it never joins wheel. The flake exposes the image as opencode-lima-image and as the nixosConfigurations.opencode-lima output.

The second definition is lima/opencode.yaml, and it is the one Lima boots. It sets vmType: vz, so the VM uses Apple's Virtualization framework, with 4 CPUs, 4GiB of memory and a 20GiB disk. Its image is the official Ubuntu 24.04 server cloud image, pinned to a release dated 2026-05-18 with a SHA-256 digest. No qcow2 appears anywhere in it. Part 10 records that I replaced the NixOS image with Ubuntu within hours of writing it, because the image needed a Linux builder and a copy step for every update. Ubuntu boots, the provision script installs the multi-user Nix daemon, and I get the same Nix configuration inside the VM that I use on the host.

I did not run nix build on the image for this post. There is no opencode-nixos.qcow2 in lima/ today, and the directory listing shows only YAML files. So the NixOS jail is a design that evaluates in the flake, not something I have ever run as my agent VM.

What I could check on this machine

I tried to prove the no-sudo claim by running id and sudo -n true inside the VM. limactl list shows only two instances here, docker (running) and odysseus (stopped). There is no opencode instance:

$ limactl shell opencode -- sudo -n true
level=fatal msg="instance `opencode` does not exist, run `limactl create opencode` to create a new instance"

I did not create or start one, because that mutates state. So I cannot show a sudo denial, an /etc/os-release or a live mount table, and I make no claim about a running system. The rest of this post quotes configuration.

The configuration does cut against the no-sudo idea for the Ubuntu VM. Its provision script runs sudo tee -a /etc/shells and sudo chsh as the login user, which only works if that user has sudo. The opencode user with no sudo exists in the NixOS design alone. In the Ubuntu VM, the boundary is the hypervisor and the mount list, and I should not describe the agent as unprivileged inside it.

The mounts are the boundary

lima/opencode.yaml uses mountType: "virtiofs" and no whole-home mount. A comment in the file lists ~/.ssh, ~/.gnupg, ~/.aws and ~/.restic as things the VM cannot see, which holds because none of them appears in the list. Here are the six mounts as configured:

Host path Writable Purpose in the file
~/dotfiles no the VM only reads fish config and the tools file
~/IdeaProjects yes code to review and edit
~/Dev/notes yes agents update notes
~/Dev/<site repo> yes content updates to this site
~/.config/opencode yes OpenCode config and plugins
~/.agents no agent definitions

Four are read-write, and ~/dotfiles and ~/.agents are read-only. This is not how the file looked when I first wrote this post. ~/dotfiles was mounted writable, which let a compromised agent edit the Nix configuration, scripts and launchd definitions that later run on my host. I found that flaw while writing, and the commit message for the fix says the VM only reads config/fish/* and config/hermes/tools.yaml from that mount. The read-only mount changed in my config on 2026-10-15. I have not yet verified it on the running VM, and the VM needs a restart before the new mount applies.

The plugin directory that holds my command guard still sits inside the writable ~/.config/opencode mount, so an agent can in principle edit its own guard. I have not tested that write path, and the claim follows from the mount list alone. The NixOS file also disagrees with the YAML on notes, listing them as read-only in one place and read-write in another.

Host directories mounted into the agent VMSix host directories are mounted into a Lima VM. Four are read-write, and the dotfiles and agents directories are read-only. A credentials file on the host reaches the VM by a dashed path, piped over stdin into a systemd drop-in when the VM starts.Host, macOSLima VM, Ubuntu 24.04~/dotfilesro~/dotfiles~/IdeaProjectsrw~/IdeaProjects~/Dev/notesrw~/Dev/notes~/Dev/site reporw~/Dev/site repo~/.config/opencoderw~/.config/opencode~/.agentsro~/.agentsCredentials file~/.bedrock-creds, mode 600stdin, then drop-inService env overridesystemd drop-in, at VM start
Four mounts are writable and two are read-only. The dashed line is the credential path, which is not a mount.

What crosses the boundary

Directories cross by mount, and everything I put in them is visible to the agent. Credentials cross by a separate route. The opencode-vm-autostart launchd agent starts the VM, then collects secrets and pipes them over the stdin of a limactl shell command. Earlier it wrote them to an env file at ~/dotfiles/.opencode-vm-env, inside the writable dotfiles mount, so any process in the VM could read them. I found that while writing and changed it on 2026-10-15. The env file and its .gitignore entry are gone, and no secret sits on host disk or on any mount. I have not yet verified this on the running VM, because it needs darwin-rebuild switch and a VM restart to apply.

What still crosses is the Bedrock triple from ~/.bedrock-creds, a DeepSeek key, an OpenRouter key and the dev database username and password, which sops-nix decrypts on the host. The credentials for my live database no longer cross. The script sends the literal disabled-in-vm for both values, so the variable expansion in the tools file still works while authentication against that database fails. The Bedrock keys expire in an hour. I have not checked whether the DeepSeek, OpenRouter and dev database secrets expire at all.

Inside the VM, a script reads stdin and writes two systemd drop-in files under ~/.config/systemd/user/, with umask 077. They are headroom-proxy.service.d/creds.conf and opencode-serve.service.d/api-keys.conf. Each holds every secret as Environment= lines, then the script restarts both services. Those files are readable by the VM user, which is the user the agent runs as. So a compromised agent can still read every secret that crosses, including the dev database credentials. The change removed a route to the host's files and the live-database credentials. It did not hide the rest from the agent.

The Bedrock post describes the staleness problem: the host file refreshes every 50 minutes, but the copy in the VM changes only when this start job runs. Git remotes cross too. Any repository under a writable mount carries its own remote configuration, and I did not inspect what credentials those use.

What it does not isolate

Network egress is open. The YAML gives the VM Lima's user-v2 network, which is NAT with internet access for model APIs, and I found no outbound filter in it. An agent can send anything it reads to any host. The firewall rule in the NixOS file only blocks inbound traffic, and it applies to the image I never ran.

Anything in a read-write mount is exposed to modification, including the project code and the notes. No snapshot protects those, and the VM adds no protection to a repository the agent is allowed to write. The one forward-published port, 8443 to the host, is an opencode serve endpoint, and I did not audit its authentication.

Where this leaves the tradeoffs

I can compare VMs and containers only from what I ran. A VM gives a separate kernel and a clear mount list, and Lima on Apple Silicon starts one without a dedicated host. I did not benchmark start time or memory against a container, and I never ran gVisor or Firecracker, so I have no measurement to prefer either over what I have. My choice came down to effort: Lima and a YAML file worked in an afternoon, and the sandbox designs did not get built. What the VM does not do is limit what its mounts hold, and it does not limit the network.

What I could not verify

  • Whether a running opencode VM boots Ubuntu. No such instance exists on this machine, so I quoted the YAML.
  • Any sudo behavior, id output or live mount table, for the same reason.
  • That the NixOS image builds today. I did not run nix build.
  • Whether the read-only dotfiles mount and the stdin pipe are live. Both are committed, and I have not applied them or restarted the VM.
  • Whether the plugin directory can be modified from inside the VM.
  • The authentication on port 8443.
  • The expiry of the non-Bedrock keys and dev database credentials sent at start.