Skip to content

Latest commit

 

History

1 Commit

Folders and files

NameName
Last commit message
Last commit date
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

Agentic Coding Isolator

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

Quick start

# 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.yaml

First boot takes several minutes while packages install. Later boots take seconds. Then:

ssh -p 8222 <VM_USER>@localhost

Commands in this guide use < > for values you substitute: <VM_NAME>, <VM_SSH_PORT> and <VM_USER> come from your .env.


How the isolation works

The VM boundary does the heavy lifting, but two choices harden the inside of it as well.

Sudo needs a password

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.

No Docker daemon, and no docker group

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_map

The first line reads 0 <your-uid> 1: container root is your user.

What the VM can reach

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.


What is installed

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 on PATH. It is redirected to ~/.local/bin.

How state survives

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.


The symlink layout

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.

Why symlinks rather than a mount per tool

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.

Rules that keep it working

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.


Configuration

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.


Usage

# 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.yaml

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

Connecting

# 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>@localhost

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

Applying template changes

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

Nothing 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.sh and delete.sh purge the fingerprint automatically; delete-tls-fingerprint.sh does it on demand.

A second VM for testing template changes

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 test

Run 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 test

Customising

Personal 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.local

Extra 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 upgrade

Another 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 prune

Troubleshooting

Check 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_done

Mounts look wrong:

# Copy-friendly
findmnt -t virtiofs -o TARGET,SOURCE,OPTIONS

Expect 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/opencode

Every 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/bin

Containers not working. containerd runs as a user service, not a system one:

# Copy-friendly
systemctl --user is-active containerd

Known limitations

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


License

MIT. See LICENSE.

About

A reproducible, ephemeral Ubuntu VM managed by Lima, pre-configured with Claude Code, opencode, and rootless containers. Designed to keep AI coding agents and repository operations isolated from the host.

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Used by

Contributors

Languages