Run AI coding agents inside sandboxed Linux VMs. The agent gets full autonomy while your host system stays safe.
Uses Lima to create lightweight Debian VMs on macOS and Linux. Ships with dev tools, Docker, and a headless Chrome browser with Chrome DevTools MCP pre-configured.
Supports Claude Code, OpenCode, Codex CLI, and Mistral Vibe out of the box. Other agents can be run via agent-vm shell.
Never install attack vectors such as npm, claude or even Docker on your host machine again!
Feedback welcome!
- macOS or Linux
- Lima (installed automatically via Homebrew if available)
- A subscription or API key for your agent of choice
git clone https://github.com/sylvinus/agent-vm.git
cd agent-vm
./install.shinstall.sh puts agent-vm on your PATH as a symlink to agent-vm.sh in the
clone, then offers to also define it as a shell function. Being a symlink, a
git pull updates the command — there is nothing to reinstall.
Already installed by sourcing? Nothing breaks: sourcing is still fully
supported and is what defines the shell function. Run ./install.sh once if you
want the command on your PATH too — other tools need that, because a shell
function is not inherited by child processes. ./install.sh --uninstall removes
the link.
Sourcing by hand still works if you prefer it:
echo "source $(pwd)/agent-vm.sh" >> ~/.zshrc # zsh
echo "source $(pwd)/agent-vm.sh" >> ~/.bashrc # or bashSourcing puts agent-vm in your shell as a function. You can also invoke the script directly (./agent-vm.sh setup) without sourcing — useful for one-off runs.
agent-vm setupCreates a base VM template with dev tools, Docker, Chromium, and AI coding agents pre-installed. Run interactively to open the wizard; its first prompt offers a one-tap "default install" (everything except the opt-in languages Ruby, Rust, Go), answer n for per-component prompts. Pass --preinstall=... to skip the wizard. When stdin is not a terminal (CI), the wizard is auto-skipped and the default set is installed.
Options:
| Flag | Description | Default |
|---|---|---|
--disk GB |
VM disk size in GB | 10 |
--memory GB |
VM memory in GB | 3 |
--cpus N |
Number of CPUs | 1 |
--preinstall=LIST |
Preinstall only this comma-separated subset in the base image (skips the wizard) | — |
Names are lowercase: python, node, ruby, rust, golang, docker, chromium, gh, claude, opencode, codex, vibe, mcp-chrome, mcp-playwright. Use default for the default set (everything except Ruby/Rust/Go and mcp-playwright), all for everything, or none for nothing. Selecting codex, or chromium with any AI agent, also installs node because those paths require npm/npx.
The mcp-* names wire an MCP server into every installed agent's config. Both current ones drive the preinstalled Chromium, so both need node and chromium and are skipped with a notice without them. Omit them to leave the agents' MCP config untouched — useful when MCP servers are managed per project rather than baked into the base image.
agent-vm setup # Interactive wizard
agent-vm setup --preinstall=default # Default set, no prompts
agent-vm setup --preinstall=default,rust # Default set plus Rust
agent-vm setup --preinstall=default,mcp-playwright # Default set plus Playwright MCP
agent-vm setup --preinstall=python,docker,claude # Minimal Claude-only setup
agent-vm setup --preinstall=node,chromium,opencode # OpenCode, no MCP wired in
agent-vm setup --disk 50 --memory 16 --cpus 8 # Larger VM for heavy workloadscd your-project
agent-vm claude # Claude Code
agent-vm opencode # OpenCode
agent-vm codex # Codex CLI
agent-vm vibe # Mistral VibeCreates a persistent VM for the current directory (or reuses it if one already exists), mounts your working directory, and runs the agent with full permissions. The VM persists after the agent exits so you can reconnect later. Ports opened inside the VM (e.g. by Docker containers or dev servers) are automatically forwarded to your host by Lima.
Each agent runs with its respective auto-approve flag:
clauderuns with--dangerously-skip-permissions, and the VM also enforces bypass mode via managed settings (/etc/claude-code/managed-settings.json) so autonomy survives Claude Code's self-update/fullscreen relaunches — which otherwise drop the CLI flag (#72479)opencoderuns with--auto(auto-approves permission prompts that aren't explicitly denied)codexruns with--dangerously-bypass-approvals-and-sandboxviberuns with--agent auto-approve
Any extra arguments are forwarded to the agent command:
agent-vm claude -p "fix all lint errors" # Run with a prompt
agent-vm claude --resume # Resume previous session
agent-vm opencode -p "refactor auth module" # OpenCode with a prompt
agent-vm codex -q "explain this codebase" # Codex with a query
agent-vm vibe -p "fix all lint errors" # Mistral Vibe with a promptagent-vm shell # Open a zsh shell in the VM
agent-vm run npm install # Run a one-off command in the VM
agent-vm run docker compose up -d # Start servicesEach directory gets its own persistent VM. You can manage it with:
agent-vm status # Show status of all VMs (current dir marked with >)
agent-vm stop # Stop the VM (can be restarted later)
agent-vm rm # Stop and permanently delete the VM
agent-vm destroy-all # Stop and delete all agent-vm VMsWrapping agent-vm from another tool? Use these instead of parsing human-facing
output or reading ~/.agent-vm internals — VM naming, the template name and the
state files are implementation details.
agent-vm version # 0.1.0 — gate on this; a build without it predates the command
agent-vm name [dir] # VM name for a directory (default: cwd)
agent-vm info [dir] # machine-readable state, one key=value per line
agent-vm env set K V # store a secret for every VM (see below)
agent-vm env get K # read it back
agent-vm env has K # exit 0 if stored, 1 otherwise, no output
agent-vm env unset K
agent-vm env list # key NAMES only, never valuesUse agent-vm env rather than writing ~/.agent-vm/env yourself: that file is
sourced by a shell, so a single mis-escaped quote costs every secret in it, not
just the mis-quoted one. get/has answer about the file, never about the
ambient environment — which matters because callers often run inside a VM that
already exports those very variables.
info prints version, template, state_dir, dir, vm_name,
base_exists, vm_exists, vm_running and vm_stale. Booleans are 1/0;
anything that cannot be determined is unknown rather than a guess — including
vm_stale when no base version has been recorded to compare against.
version, name, info and env all work without Lima installed (the
Lima-dependent keys of info read unknown).
Call the command rather than sourcing the file: a shell function is not
inherited by child processes, so a tool that spawns a shell cannot see one.
agent-vm.sh does remain safe to source from a script running under set -u
and set -o pipefail; it is not written for the caller's set -e, since like
most shell libraries it uses test && action internally.
To automatically destroy a VM after the agent exits (like docker run --rm):
agent-vm --rm claude # Run Claude, then destroy the VM
agent-vm --rm run npm test # Run tests, then destroy the VMTo resize an existing VM's disk or memory, just pass --disk or --memory again — the VM will be stopped, reconfigured, and restarted automatically:
agent-vm --disk 50 claude # Grow disk to 50GB, then run Claude
agent-vm --memory 16 --cpus 8 shell # Increase memory and CPUs, then open shellNote: disk can only be grown, not shrunk.
Running agent-vm setup again updates the base template but does not update existing VMs. You'll see a warning when using a VM cloned from an older base. Use --reset to re-clone:
agent-vm --reset claude # Destroy and re-clone VM, then run Claudeagent-vm --offline claude # Block outbound internet access
agent-vm --readonly shell # Mount project directory as read-only
agent-vm --offline --readonly claude # Both--offline blocks outbound internet from the VM using iptables while preserving host/VM communication (mounts, port forwarding). Useful for ensuring agents don't phone home or download unexpected packages.
--readonly remounts the project directory as read-only. Useful for code review or audit tasks where the agent shouldn't modify files. Both flags are per-session and reset when the VM restarts.
Put environment variables (API tokens, secrets, etc.) in this file as plain KEY=value lines — no export prefix, # for comments. They're auto-loaded into every shell in every VM.
Edit it by hand, or let agent-vm handle the quoting for you:
agent-vm env set GH_TOKEN github_pat_xxxx
agent-vm env list # names only, never valuesScripts should always use agent-vm env — the file is sourced by a shell, so one mis-escaped quote breaks every secret in it, not just that one.
# ~/.agent-vm/env
GH_TOKEN=github_pat_xxxxxxxxxxxxxxxxxxxxxxxx
ANTHROPIC_API_KEY=sk-ant-xxxxxxxxxxxxxxxxxxxxxxxx
OPENAI_API_KEY=sk-xxxxxxxxxxxxxxxxxxxxxxxx
MISTRAL_API_KEY=xxxxxxxxxxxxxxxxxxxxxxxxThese are picked up automatically by the tools that look for them: gh reads GH_TOKEN, Claude Code uses ANTHROPIC_API_KEY when not signed in, Codex uses OPENAI_API_KEY, Vibe uses MISTRAL_API_KEY, etc. The file is pushed into the VM at ~/.agent-vm.env (mode 0600) on every agent-vm invocation, so edits propagate without --reset.
For subscription-based auth (where you've already run claude login / gh auth login on the host), share the host's credentials directory via ~/.agent-vm/volumes instead.
What the sandbox does and does not protect here. Anything in this file is
readable by everything running in the VM — the agent, its dependencies, any
code it fetches. The VM keeps those secrets away from your host, but it does not
keep them from the agent, and an agent that has been prompt-injected or a
dependency that has been tampered with can send them out over the network. So:
put a dedicated, revocable, narrowly-scoped token here rather than your main
one, and reach for --offline on sessions that don't need outbound internet.
--offline limits where a secret can go; it does not stop code in the VM from
reading it.
List host files or directories to mount inside every VM. One path per line, ~ is expanded, # starts a comment. Uses Docker Compose-style source[:destination][:mode] syntax, where mode is ro (default) or rw:
# ~/.agent-vm/volumes
# Mount at the same path in the VM (read-only)
~/.gitconfig
~/.gitignore
# Mount at a different path in the VM (read-only)
# Note: the destination is used verbatim (no shell expansion) — replace
# $USER with your actual username. Only the source (left) side expands a
# leading ~.
~/.claude:/home/youruser.linux/.claude
# Writable directory
~/.cache/shared:/home/youruser.linux/.cache/shared:rwWhen no destination is specified, the path is mounted at the same location inside the VM. Non-existent paths are skipped with a warning. Changes to this file take effect on new VMs (use --reset to re-apply to existing ones).
rw is only supported for directories. Files are always read-only: with the hardlink/staging strategy used below, writable file mounts would silently desync on cross-filesystem setups. If you need a writable single file, mount its parent directory as rw instead. A destination literally named ro or rw is treated as a mode keyword — append an explicit :ro/:rw to disambiguate.
Individual files are supported without exposing their parent directory: agent-vm hardlinks the source into a per-VM staging dir under ~/.agent-vm/file-mounts/<vm>/, then bind-mounts it at the final destination on each VM start. If the source sits on a different filesystem (hardlink impossible), it falls back to a copy and live host changes won't propagate until the next VM restart. The staged hardlink is refreshed on each agent-vm invocation, so atomic-rename edits (common in editors) are picked up at the next VM (re)start.
Create this file to install extra tools into the base VM template. It runs once during agent-vm setup, as the default VM user (with sudo available):
# ~/.agent-vm/setup.sh
sudo apt-get install -y postgresql-client
pip install pandas numpyCreate this file to run commands inside every VM on each start. It runs before the per-project .agent-vm.runtime.sh script.
Use it for anything that should be available in all your VMs: SSH keys, git config, GitHub CLI auth, Claude Code skills, MCP servers, etc.
Getting started:
cp runtime.example.sh ~/.agent-vm/runtime.sh
# Edit with your own valuesSee runtime.example.sh for a fully commented template covering:
- SSH key injection for GitHub (base64-encoded private key)
- Git identity and SSH-forced remotes (
url.insteadOf) - GitHub CLI authentication (
gh auth login --with-token) - Claude Code skills installation (global and per-project)
- MCP server registration (
claude mcp add --scope user) - Status line configuration in
~/.claude/settings.json
Encoding your SSH key:
cat ~/.ssh/id_ed25519 | base64Paste the output into your runtime.sh — the script decodes it at boot and sets up ~/.ssh with proper permissions.
Global vs per-project runtime:
| File | Scope | Runs when |
|---|---|---|
~/.agent-vm/runtime.sh |
All VMs | Every VM start, first |
.agent-vm.runtime.sh |
Current project only | Every VM start, after global |
Important: Always launch agent-vm from a path without spaces. macOS iCloud paths contain spaces (~/Library/Mobile Documents/...), which can break mounts. Create a symlink instead:
ln -s ~/Library/Mobile\ Documents/com~apple~CloudDocs/Dev ~/Dev
cd ~/Dev/your-project
agent-vm claudeCreate this file at the root of any project. It runs inside the VM each time a new VM is created for the project, just before you get access. Use it for project-specific setup like installing dependencies or starting services:
# your-project/.agent-vm.runtime.sh
npm install
docker compose up -dFor projects using a specific language version (Ruby, Python, etc.), install it via mise in the runtime script. mise automatically picks up .ruby-version, .python-version, .node-version, and .tool-versions files:
# your-project/.agent-vm.runtime.sh
mise install
bundle installThe base VM comes with Chrome DevTools MCP pre-configured for every installed agent, giving it headless browser access. Playwright MCP is available too via --preinstall=...,mcp-playwright; it is pointed at the same Chromium with --executable-path and launched through env PLAYWRIGHT_SKIP_BROWSER_DOWNLOAD=1 so it does not pull its own copies of Chromium, Firefox and WebKit into every VM. To drive another engine, edit its entry (drop --executable-path, add e.g. --browser firefox) and run npx playwright install firefox inside the VM.
Drop either one with --preinstall: the names are opt-in like any other component, so --preinstall=node,chromium,opencode installs OpenCode with no MCP server wired in. That is the switch to use when MCP servers are managed per project instead.
To add more MCP servers, add them to ~/.claude.json in your ~/.agent-vm/setup.sh, or edit the file directly inside a VM via agent-vm shell. Add entries to the mcpServers object:
{
"mcpServers": {
"chrome-devtools": {
"command": "npx",
"args": ["-y", "chrome-devtools-mcp@latest", "--headless=true", "--isolated=true"]
},
"postgres": {
"command": "npx",
"args": ["-y", "@modelcontextprotocol/server-postgres", "postgresql://localhost:5432/mydb"]
}
}
}agent-vm setupcreates a Debian 13 VM with Lima, runsagent-vm.setup.shinside it to install dev tools + Chrome + agents, and stops it as a reusable base templateagent-vm claude|opencode|codex [args]clones the base template into a persistent per-directory VM, mounts your working directory, runs optional runtime scripts (~/.agent-vm/runtime.shthen.agent-vm.runtime.sh), then launches the agent with full permissions- The VM persists after exit. Running any agent command or
agent-vm shellin the same directory reuses the same VM - Use
agent-vm stopto stop the VM oragent-vm rmto delete it. Use--rmto auto-delete after the command exits
Each VM is fully isolated — agents must authenticate independently inside their VM (e.g. claude login). Credentials persist within the VM across restarts but are not shared between VMs or with the host.
./test.shRuns against a stub limactl in a throwaway HOME: no VM is created, started or
deleted, your real ~/.agent-vm is untouched, and no network is needed. It covers
VM naming, resource comparison, staleness, the info/version/name surface,
the --preinstall parser and the MCP config writer.
Worth running under bash 3.2 as well — it is what macOS ships, and it is stricter
about empty array expansion under set -u, which modern bash forgives:
docker run --rm -v "$PWD:/w" -w /w bash:3.2 ./test.sh
zsh ./test.sh| File | Description |
|---|---|
agent-vm.sh |
Main script — source this in your shell config |
agent-vm.setup.sh |
Package installation script that runs inside the base VM during setup |
install.sh |
Installer — puts agent-vm on your PATH, --uninstall removes it |
test.sh |
Test suite — runs against a stub limactl, creates no VMs |
The wizard's "default install" and --preinstall=default produce the same set: everything in the table below except the opt-in languages (Ruby, Rust, Go). Pass a different --preinstall= to install a different subset.
| Category | Packages | Name | Installed by default? |
|---|---|---|---|
| Core | git, curl, wget, jq, zsh, ca-certificates, build-essential, unzip, zip, ripgrep, fd-find, htop, iptables | (always) | always |
| Build libs | libssl-dev, libreadline-dev, zlib1g-dev, libyaml-dev, libffi-dev | (always) | always |
| Version manager | mise | (always) | always |
| Python | python3, pip, venv | python |
yes |
| Node.js | Node.js 24 LTS (via NodeSource) | node |
yes |
| Ruby | ruby-full | ruby |
no |
| Rust | rustup (stable toolchain) | rust |
no |
| Go | golang-go | golang |
no |
| GitHub CLI | gh | gh |
yes |
| Browser | Chromium (headless), xvfb | chromium |
yes |
| Containers | Docker Engine, Docker Compose | docker |
yes |
| AI agents | Claude Code, OpenCode, Codex CLI, Mistral Vibe | claude, opencode, codex, vibe |
yes |
| MCP | Chrome DevTools MCP (Claude/OpenCode/Codex/Vibe) | mcp-chrome |
yes, when Node.js + Chromium + an agent are installed |
| MCP | Playwright MCP, reusing the Chromium above | mcp-playwright |
no |
AI coding agents need full permissions to be useful — they install dependencies, run builds, execute tests, start servers. But running npm install or pip install means executing arbitrary third-party code on your machine.
This is not a theoretical risk. The Shai-Hulud worm compromised thousands of npm packages in 2025 by injecting malicious code that runs during npm install. It harvested npm tokens, GitHub PATs, SSH keys, and cloud credentials from developers' machines, then used those credentials to spread to other packages the developer maintained. All of this happened silently, in the background, while the legitimate install appeared normal.
An AI agent running with --dangerously-skip-permissions on your host would give such an attack full access to everything: your SSH keys, your cloud credentials, your browser sessions, your entire filesystem.
agent-vm runs all code inside the VM. The VM only has access to your project directory (read-write mount, or read-only with --readonly). It has no access to your SSH keys, npm tokens, cloud credentials, git config, browser sessions, or anything else on your host. If a supply chain attack executes inside the VM, it finds nothing to steal (except your source code) and nowhere to spread. Use --offline to block internet access entirely.
Meanwhile, your host machine stays clean. You don't need Node.js, Docker, or any dev tooling installed locally. The only host dependency is Lima. Your SSH keys and signing credentials never enter the VM — we recommend running git commit on the host yourself.
| No sandbox | Docker | VM (agent-vm) | |
|---|---|---|---|
| Agent can run any command | Yes | Yes | Yes |
| File system isolation | None | Partial (shared kernel) | Full |
| Network isolation | None | Partial | Optional (--offline) |
| Can run Docker inside | Yes | Requires DinD or socket mount | Yes (native) |
| Kernel-level isolation | None | None (shares host kernel) | Full (separate kernel) |
| Protection from container escapes | None | None | Yes |
| Browser / GUI tools | Host only | Complex setup | Built-in (headless Chromium) |
Docker containers share the host kernel. A motivated attacker (or a compromised dependency running inside the container) could exploit kernel vulnerabilities to escape. A VM runs its own kernel — even root access inside the VM can't reach the host.
A VM also avoids the practical headaches of Docker sandboxing. Docker runs natively inside the VM without Docker-in-Docker hacks. Headless Chromium works out of the box. Lima automatically forwards ports to your host. The agent gets a normal Linux environment where everything just works.
This workflow also replaces Docker Desktop on the Mac, which has become more and more bloated over the years.
MIT