Run AI coding agents in a disposable Linux VM that can reach your code and nothing else on your Mac.
Agents like Claude Code and opencode are useful in proportion to how much they are allowed to do: run builds, install packages, execute tests, drive containers. On your own machine that means handing a language model an unrestricted shell. This repo puts that shell inside a Lima VM instead, sharing exactly the folders you name and nothing more.
Requires macOS on Apple Silicon.
| Template | prime-radiant.yaml |
| Guest | Ubuntu 26.04 LTS, arm64 |
| Hypervisor | vz, Apple Virtualization.framework |
| Default resources | 2 vCPU, 12 GiB RAM |
| Agents | Claude Code, opencode |
| Containers | rootless containerd with nerdctl, no Docker daemon |
# Readable
brew install lima gettext
git clone <this-repo> && cd agentic-coding-isolator
cp .env-example .env # then edit it, see Configuration below
mkdir -p .ssh && cp ~/.ssh/id_ed25519.pub .ssh/key.pub
mkdir -p agent-data/{claude,aider,opencode,opencode-data,skills}
ln -s ../skills agent-data/claude/skills
./start.sh prime-radiant.yaml# Copy-friendly
brew install lima gettext && cp .env-example .env && mkdir -p .ssh agent-data/{claude,aider,opencode,opencode-data,skills} && cp ~/.ssh/id_ed25519.pub .ssh/key.pub && ln -s ../skills agent-data/claude/skills && ./start.sh prime-radiant.yamlFirst boot takes several minutes while packages install. Later boots take seconds. Then:
ssh -p 8222 <VM_USER>@localhostCommands in this guide use
< >for values you substitute:<VM_NAME>,<VM_SSH_PORT>and<VM_USER>come from your.env.
The VM boundary does the heavy lifting, but two choices harden the inside of it as well.
The 90-lima-user sudoers drop-in grants PASSWD: ALL, so an agent that decides to sudo hits a prompt it cannot answer. Two narrow NOPASSWD exceptions cover only the commands Lima itself runs during boot. The password is VM_INIT_PASS from your .env.
docker.io is deliberately not installed. Its daemon requires membership in the docker group, which is root-equivalent and would make the sudo gate meaningless. Lima's rootless containerd runs as your unprivileged VM user instead, so a container escape lands on that account rather than on root.
/usr/local/bin/docker is a symlink to nerdctl, so docker commands and docker compose work as normal. A symlink rather than a shell alias, because MCP servers are spawned with execve and never see interactive aliases: a docker run ... line in an MCP config has to resolve on PATH.
You can verify the mapping yourself. A container writing as root produces a file owned by your unprivileged user on the host:
# Copy-friendly
docker run --rm alpine cat /proc/self/uid_mapThe first line reads 0 <your-uid> 1: container root is your user.
Four mounts, and nothing else:
.env variable |
Mount point | Mode | Contents |
|---|---|---|---|
PATH_LIMA_SSH |
/mnt/lima-ssh |
read-only | a key.pub used to authorise SSH |
PATH_REPOSITORY |
/mnt/repository |
read-write | the code you want agents to work on |
PATH_AGENT_DATA |
/mnt/agent-data |
read-write | agent config, credentials and history |
PATH_SKILLS |
/mnt/agent-data/skills |
read-write | Claude Code skills |
The template sets loadDotSSHPubKeys: false, so the key in PATH_LIMA_SSH is the only one the VM trusts.
System packages: build-essential, git, curl, Node.js 24, OpenJDK 21, Go, Helm 3, Chromium for headless browser tests, poppler-utils, python3-pip.
Agents, installed into ~/.local/bin: Claude Code and opencode. Aider is present in the template but commented out.
Both agent installers hardcode paths that have to be rewritten, and each substitution is asserted so that an upstream change fails loudly instead of silently reverting to the slow path:
- Claude Code stages its download in
$HOME/.claude/downloads, which resolves onto a host mount. Downloading,chmod +xing and executing a ~230 MB binary across that boundary is slow and unreliable, so it is redirected to a local temp directory. - opencode installs to
~/.opencode/bin, which is not onPATH. It is redirected to~/.local/bin.
Lima recreates the VM on essentially every start. Treat the guest disk as disposable and keep anything durable on a mount.
The guest disk holds the home directory, installed packages and system config. It survives reboots and dies with the instance.
Provision scripts run on every boot, not just the first, so the expensive ones are guarded by marker files:
| Marker | Guards |
|---|---|
/etc/.lima_system_init_done |
apt-get update |
/etc/.lima_devtools_init_done |
system packages and toolchain |
~/.lima_user_init_done |
agent installers and ~/.profile |
Two blocks run unguarded on every boot: SSH key injection, and the block that wires the home directory into /mnt/agent-data. Both are idempotent and must survive anything that recreates those paths.
Everything the agents care about lives in agent-data, which is on your Mac. Rebuilding the VM costs time, not state: credentials, session history and settings are all still there afterwards.
Nothing is mounted inside the guest home. Instead each tool's directory is a symlink into the single agent-data mount, created on every boot:
~/.claude -> /mnt/agent-data/claude
~/.claude.json -> /mnt/agent-data/claude.json
~/.aider -> /mnt/agent-data/aider
~/.config/opencode -> /mnt/agent-data/opencode
~/.local/share/opencode -> /mnt/agent-data/opencode-data
So agent-data on your Mac looks like this:
agent-data/
claude/ skills -> ../skills
claude.json
aider/
opencode/
opencode-data/
skills/ mount point for PATH_SKILLS
profile.local optional, see Customising
Adding a tool is one link_to_agent_data line in the template and one directory here. No new mount, no new variable.
Mounting each tool's directory into the home directory works, but it scales badly and has two sharp edges.
Lima creates every mount point and all of its parents as root, before provisioning runs. Mounting ~/.local/share/opencode therefore leaves ~/.local root-owned, which breaks the user-mode installers that write to ~/.local/bin, and needs a separate privileged block to repair.
A mount nested inside another mount also shadows whatever is underneath it on the host, silently. Mounting a shared skills folder over ~/.claude/skills hides any real content at that path, and nothing warns you.
Neither problem exists when the home directory contains only symlinks.
Symlinks are created guest-side, never inside the mount. A symlink written into /mnt/agent-data is stored on your Mac and outlives the VM. An absolute one bakes in /home/<VM_USER>.guest, which means nothing to macOS and dangles the moment VM_USER changes.
The skills link is relative. agent-data/claude/skills points at ../skills. Path resolution is physical, so opening ~/.claude/skills resolves ~/.claude to /mnt/agent-data/claude, then reads ../skills against that real parent, arriving at /mnt/agent-data/skills. Being relative, the same link is also valid on macOS. A link that escapes the mount root, such as ../../elsewhere, resolves to a different place in each namespace and breaks.
ln -sfn is not used bare. If the link path already exists as a real directory, ln creates the link inside it and exits 0; if it is a real file, ln removes it. Both are silent, and the tool would then write to guest-local storage that the next rebuild discards. The wiring block removes empty directories, moves anything non-empty aside, and asserts the result with readlink. Ubuntu 26.04 ships uutils coreutils rather than GNU; both behave this way.
Failures are recorded on the mount. A failing provision script does not fail limactl start, and the boot log needs sudo to read. If the wiring block has to move something aside it appends to agent-data/.wiring-warnings.log, where you can see it from macOS.
~/.claude.json holds Claude Code's account record, MCP server list and folder-trust state. Lima can only mount directories, so it is stored as agent-data/claude.json and symlinked. It must sit next to agent-data/claude/, not inside it.
Copy .env-example to .env and fill it in with absolute paths. .env is gitignored; keep it that way.
| Variable | Required | Meaning |
|---|---|---|
VM_NAME |
default | Lima instance name, used when no name argument is given |
VM_USER |
yes | Username in the VM. Home becomes /home/<VM_USER>.guest |
VM_INIT_PASS |
yes | Login and sudo password. Change it with passwd after first login |
VM_SSH_PORT |
yes | Local port forwarded to the VM's SSH server |
PATH_LIMA_SSH |
yes | Folder containing your key.pub |
PATH_REPOSITORY |
yes | Code the agents may read and write |
PATH_AGENT_DATA |
yes | Agent config and state |
PATH_SKILLS |
yes | Claude Code skills folder |
CLAUDE_CODE_OAUTH_TOKEN |
no | Exported into the VM |
SONARQUBE_URL, SONARQUBE_TOKEN |
no | Exported into the VM |
FORGEJOMCP_SERVER, FORGEJOMCP_TOKEN |
no | Exported into the VM |
OMLX_API_KEY |
no | Exported into the VM |
start.sh derives its "is it set" and "does the directory exist" checks from one list, so the two cannot drift apart. Every path must exist before the VM is created.
envsubst is deliberately restricted to the variable names found in .env. A bare envsubst would substitute every exported variable, including HOME, PATH and SHELL; it is a plain text filter with no shell parsing, so quoting does not protect anything, and the guest's own export PATH="$HOME/.local/bin:$PATH" would be rewritten with your macOS values before Lima ever saw the template.
The consequence is worth knowing: any ${VAR} in the template that is not a name in .env reaches the guest as literal text, and under set -eu that aborts the provisioning block.
# Readable
./start.sh prime-radiant.yaml # create or start
./start.sh prime-radiant.yaml <VM_NAME> # override the name from .env
limactl stop <VM_NAME>
./delete.sh <VM_NAME> # remove the VM, its disk and its SSH fingerprint
# Copy-friendly
./start.sh prime-radiant.yamlstart.sh loads .env, refuses to run on a missing or empty variable, checks every path exists, renders the template, and purges the stale SSH fingerprint before starting an existing instance.
# Readable
ssh -p <VM_SSH_PORT> \
-o IdentitiesOnly=yes \
-i <path-to-private-key> \
<VM_USER>@localhost
# Copy-friendly
ssh -p <VM_SSH_PORT> -o IdentitiesOnly=yes -i <path-to-private-key> <VM_USER>@localhostlimactl shell <VM_NAME> also works, but it does not start a login shell, so ~/.profile is not sourced and PATH will not include ~/.local/bin. Use SSH for a full environment.
Editing prime-radiant.yaml has no effect on an existing instance. Lima copies the rendered template to ~/.lima/<VM_NAME>/lima.yaml when the instance is created and reuses that copy on every later start. To pick up changes, recreate:
# Readable
limactl stop <VM_NAME>
limactl remove <VM_NAME>
./start.sh prime-radiant.yaml
# Copy-friendly
limactl stop <VM_NAME> && limactl remove <VM_NAME> && ./start.sh prime-radiant.yamlNothing is lost, because config and code live on mounts rather than the guest disk.
Recreating or editing a VM changes its SSH host key, so the next connection fails with
REMOTE HOST IDENTIFICATION HAS CHANGED.start.shanddelete.shpurge the fingerprint automatically;delete-tls-fingerprint.shdoes it on demand.
The instance name is an argument, so a scratch VM costs nothing:
# Copy-friendly
limactl stop <VM_NAME> && cp prime-radiant.yaml prime-radiant-test.yaml && ./start.sh prime-radiant-test.yaml testRun them one at a time. Lima forwards every listening guest port to the same port on the host, so two running instances compete for ports, and both would mount the same agent-data. To run a test VM with isolated state, point it at its own directory:
# Copy-friendly
mkdir -p agent-data-test && PATH_AGENT_DATA=$PWD/agent-data-test ./start.sh prime-radiant-test.yaml testPersonal aliases and exports go in agent-data/profile.local, which ~/.profile sources if present. It persists across rebuilds and is gitignored, so nothing local ends up in a commit:
# Copy-friendly
printf "alias cdproj='cd /mnt/repository/some/project'\n" >> agent-data/profile.localExtra packages install normally. This prompts for VM_INIT_PASS:
# Copy-friendly
sudo apt-get update && sudo apt-get install -y <package>Updating the agents:
claude update
opencode upgradeAnother tool needs a directory under agent-data/ and one line in the wiring block of the template:
link_to_agent_data ~/.yourtool "$AGENT_DATA/yourtool"Reclaiming disk from cached Lima base images, which are re-downloaded on next creation:
limactl pruneCheck the VM is up: limactl list
Boot problems. Hypervisor log on the Mac at ~/.lima/<VM_NAME>/ha.stderr.log, guest console at ~/.lima/<VM_NAME>/serialv.log.
Tools missing or config wrong. Read the provisioning log in the VM with sudo tail -n 100 /var/log/cloud-init-output.log. A block that fails leaves its marker unwritten and retries next boot, so check which markers exist:
# Copy-friendly
ls -la /etc/.lima_system_init_done /etc/.lima_devtools_init_done ~/.lima_user_init_doneMounts look wrong:
# Copy-friendly
findmnt -t virtiofs -o TARGET,SOURCE,OPTIONSExpect exactly four entries and none under /home. A path showing your Mac's root filesystem means the matching PATH_* in .env is wrong.
A tool lost its settings, or asks you to log in again. Its directory is probably a real directory on the guest disk rather than a symlink:
# Copy-friendly
ls -la ~/.claude ~/.claude.json ~/.aider ~/.config/opencode ~/.local/share/opencodeEvery one must point into /mnt/agent-data. If the wiring block moved something aside it said so in agent-data/.wiring-warnings.log.
claude or opencode not found. They live in ~/.local/bin, added to PATH by ~/.profile. If it is missing you are probably in a limactl shell session rather than SSH:
# Copy-friendly
echo "$PATH" | tr ':' '\n' | grep local/binContainers not working. containerd runs as a user service, not a system one:
# Copy-friendly
systemctl --user is-active containerdLima recreates the VM on start rather than resuming it, so the SSH host key changes and your client reports a fingerprint mismatch. The scripts purge it for you.
Provisioning as code is reproducible but slow to iterate on, and it runs on every start rather than only at creation. The marker files exist to keep repeat boots cheap.
If you want a VM you evolve by hand, installing things interactively and expecting them to persist, this is the wrong shape. It suits treating the VM as a disposable container whose durable state lives on mounts.
File watchers do not see host-side changes across the virtiofs mounts, so tooling that relies on inotify will not notice edits made on the Mac.
MIT. See LICENSE.