Sandboxing Claude: Building a reproducible Claude Code environment with Tart and Packer
This article explains the process I use to build a custom VM for macOS using Tart and Packer to keep Claude Code from accessing sensitive information like credentials or running destructive commands on my workstation or infrastructure. It builds the VM image, starting from an official Ubuntu cloud image, and then uses a Packer Template to boot and provision the image with my dev environment dependencies.
- Source: https://github.com/wu/claude-vm
Contents
This article was not written by AI.
Introduction
I use Claude Code regularly, and I try to always use it safely. I've configured allow and deny permissions for commands, and I try to read all the tool requests carefully, and I never use yolo mode. But sometimes the commands claude wants to execute are complicated, and sometimes that leads to an excessive number of approval requests. And sometimes claude comes up with creative ideas that could be destructive or cause additional security risks. The chance of missing one of these is just too high:
I wanted to build a shield between my workstation and claude to provide extra layers of security.
I reviewed the current virtualization solutions for Mac. I use Docker extensively on Linux, and I've used it on my workstation in the past, but I prefer not to run Docker on my macOS workstation. Docker Desktop on MacOS runs in a linux VM, so it wasn't really a question of VM or no VM. I ended up choosing Tart, which uses Apple’s native Virtualization.Framework, is impressively fast, can be installed through homebrew, and is easily scriptable.
But I didn't want to just create a base VM and then manually customize it--I wanted to keep all my customization in code, version controlled, and I wanted to be able to nuke and pave the VM. That's where Packer comes in--it's a tool from HashiCorp that automates building golden machine images from a source template.
Implementation
Dependencies
For starters, I installed Tart and the dependencies needed to build the image using homebrew:
# install tart
brew trust cirruslabs/cli
brew install cirruslabs/cli/tart
# install other dependencies for build
brew install hashicorp/tap/packer qemu jq
Base Ubuntu VM Image
The Tart quick start docs provide these instructions to download an Ubuntu VM image created by cirruslabs (the creators of tart):
tart clone ghcr.io/cirruslabs/ubuntu:latest ubuntu
I wondered what was in that custom image. It had a few features I didn't love, e.g. an 'admin' user and sshd enabled by default. I knew pretty quickly I wanted to create my own custom image. So I browsed over to the source:
Much of this process came directly from there.
There are some very interesting customizations in the cirruslabs template, including some for the GPU and the Tart guest agent. I didn't need the GPU tweaks for my claude VM, but I did need the Tart guest agent to be able to use tart exec.
I cloned the repo and then pulled the parts I wanted to keep into a custom build script starting with the official Ubuntu cloud image, with no dependency on the CirrusLabs image.
Shared Filesystems
I keep my projects in ~/projects on my workstation. I decided to make the entire projects directory accessible to the claude VM. I don't have any particularly sensitive information in that folder, and I back it up regularly, so I didn't mind giving claude access to it all.
I found that by mounting a filesystem into the VM at the same path it was mounted on the host, I could 'cd' into any project directory on my host and then start claude using $PWD:
# set into a path on my workstation host
cd ~/projects/foo
# start the claude cli in ~/projects/foo inside the claude-vm
tart exec -it claude-vm zsh -l -c "cd $PWD && claude"
Caution
don't store files outside ~/projects and ~/.claude inside the VM; they will be lost when you nuke and pave! The default filesystem is intentionally small (20GB) because the expectation is that you will only store files on the shared filesystem.
Mounting Filesystems
The filesystems are automatically mounted when the VM boots, thanks to scripts/01-base.sh, which adds them to fstab using the virtiofs tags "projects" and "claude". These strings must exactly match what you use in the 'tart run' command--if not, then the VM will still boot, but nothing will be mounted.
I had a few false starts with getting the filesystems mounted. The quick start documentation shows this format:
tart run --dir=project:~/src/project vm
But that didn't work for me--I realized I needed to do it this way:
tart run --dir="/path/to/foo:tag=foo" vm
Copying Files Between Host and VM
Since I don't have ssh enabled on the claude-vm, if I want to copy files between my workstation and the VM, I just copy/move them under ~/projects so they are immediately accessible on the other side.
Claude Config
Claude Code looks for its configuration in ~/.claude. So, I mounted the ~/.claude directory in the vm back to ~/.claude-vm on my workstation host. That way the history is preserved when I nuke and pave the VM image.
I chose to keep the claude configuration in the VM separate from any claude configuration on the host, as another layer of defense to prevent any sensitive information from ending up inside the VM.
The first time you run claude in the VM, you will get prompted to log in. But once you've successfully logged in, it will persist in ~/.claude-vm/claude.json, so you won't get prompted to log in again after a nuke and pave.
The one this this doesn't cover is the ~/.claude.json file. To handle that, I just moved the file to ~/.claude-vm/claude.json, and then in the packer provisioning scripts, I symlink the file from the subdirectory back out to the home directory:
rm -f "$HOME/.claude.json"; ln -sfn .claude/claude.json "$HOME/.claude.json"
This way, I can nuke and pave the VM, and when it restarts, the claude configuration from the previous sessions is still available, and I can still pick up right where I left off.
Dependencies
Inside the VM, I have all the dependencies I need when doing development in my VM:
- Claude Code
- go, with golangci-lint and govulncheck/gosec built from the VM's own Go toolchain
- Node.js
- Ansible from pipx
- Terraform and Packer from the official HashiCorp release archive
- Renovate
- LaTeX support
- SQLite3
- cli tools: ripgrep, fd, jq, tmux
Claude doesn't have credentials for any of my external systems, but it can still make use of tools like Terraform and Ansible to validate configurations.
Most dependencies are pinned to a specific version, so they don't break due to a VM rebuild unless I explicitly changed the version. I use renovate to help keep the versions updated.
User
The user is defined in cloud-init/user-data. The Tart build process hardcodes the user as "admin".
My user-data file creates 'wu' as the user account. The Packer template I created reads the username from user-data and passes it into every provisioning script as $VM_USER. So you just need to rename the account in that one file.
Dotfiles
Another dependency for my user was my dot files (e.g. .zshrc). I keep my dotfiles in ~/projects/dot so they are automatically mounted in the VM under ~/projects. Then in the Packer template, I created 10-dotfiles.sh to symlink the dotfiles I needed inside the VM back to the root of my home directory.
Tart Guest Agent
The Tart Guest Agent is what makes "tart exec" work. It's installed in scripts/00-system.sh
When I switched out the user from 'admin', I realized that the packaged systemd unit for the tart guest agent hardcodes "User=admin". So, there is logic in scripts/00-system.sh to modify the systemd config to use $VM_USER. Without this, the agent crashes on every boot, and "tart exec" fails with a GRPC control-socket error.
Build Pipeline
I created a Makefile to run the build. Just run make with no arguments. This will perform the following steps:
- Download the official Ubuntu cloud image
- Convert it from qcow2 to a raw disk with qemu-img
- Assemble an empty
tart create --linuxVM and clone the raw disk asdisk.img - Generate a cloud-init NoCloud ISO from
cloud-init/
- creates the user (
wuby default) - sets the password to the username - only used by Packer during the install
- applies the DHCP fix that makes
tart ipwork on macOS
- Provision with Packer, which boots the VM with the ISO attached and runs the scripts
- Gracefully swap in the finished build as
claude-vm
The downloaded/converted cloud image and the ISO are kept between runs, so iterating on the Packer config or provisioning scripts doesn't re-download anything.
Run make help to get more info.
Graceful Shutdown
When you run 'make', it will create a new claude-vm image, but if you're already running the claude-vm image, it will wait until you shut down the existing VM before removing it and installing the new one.
This is accomplished by:
- creating the new vm as claude-vm-build
- on successful build, check if the VM is already running
- if the VM is running, poll every 5 seconds until it is shut down
- delete the existing claude-vm
- rename claude-vm-build to claude-vm
See also: nuke and pave
Sizing
You can resize the VM or change the CPU/Memory defaults like so:
# grows the root partition on the next boot
# default size is 20GB
tart set claude-vm --disk-size 60
# change cpu/memory size
# defaults to 2 CPUs and 4GB
tart set claude-vm --cpu 4 --memory 8192
Usage
Here's how I start the VM:
tart run --dir="/path/to/projects:tag=projects" --dir="/path/to/.claude-vm:tag=claude" claude-vm
And here's how I start claude inside the project directory where I want it to work:
# set into a path on my workstation host
cd ~/projects/foo
# start the claude cli in ~/projects/foo inside the claude-vm
tart exec -it claude-vm zsh -l -c "cd $PWD && claude"
Note that tart exec with zsh needs -l, because that's what sources the PATH additions for node, go, and the dotfiles.
Nuke and Pave
When you want to recreate the VM, set into the directory where claude-vm lives and run make with no arguments. This will build the new VM image.
If you don't already have the claude-vm running, it will automatically swap in the new build, so you can just start it up.
If you already have the VM running, the build will create the new image and then display the following message and pause until you shut the existing VM down.
==> claude-vm is still running - stop it whenever you're ready; waiting to swap in the new build...
First, make sure all your claude sessions are shut down. If you kill the VM while they are still running, you'll see this error: ❯ Error: unavailable (14): Transport became inactive. You won't see the normal claude exit Resume this session with: claude --resume <uuid>, which might make it a little trickier to resume. But you can still find your old session with claude -resume.
Kill off the current vm image by hitting "ctrl-c" on the 'tar run' command, or alternately kill the process. There's no need to shut the VM down gracefully because you are just about to throw it away. Wait until you see this output from the 'make' command, which just takes a few seconds:
==> claude-vm stopped, swapping in the new build.
tart delete claude-vm || true
tart rename claude-vm-build claude-vm
Once this completes, you can start the new VM image using the 'tart run' command above.
Warning
The old image will be gone now, and there's no getting back. This is why you should never store anything you want to keep outside of ~/projects.
Maintenance
Renovate
I use renovate to keep all the dependencies updated.
If you look through some of the files, you'll notice that most dependencies are pinned to specific versions. Pinning your dependencies is a best practice when using the nuke-and-pave approach; it ensures that your dependencies don't change unexpectedly when you build a new version of your VM, which might lead to failures that are difficult to diagnose.
Above the pinned versions, you'll see renovate comments, e.g.:
# renovate: datasource=golang-version depName=go
GO_VERSION=1.26.5
These comments tie back to regular expressions defined in the renovate.json. They tell renovate how to find new releases.
To run renovate, just use the Makefile from within the VM to see any dependency versions that need to be updated:
# start a shell inside the VM
tart exec -it claude-vm zsh -l
# set into the directory where your claude-vm project is checked out
cd ~/projects/claude-vm
# run renovate using the makefile
make renovate
Some lookups get skipped without a github token. The token needs no scopes at all, it just bypasses the unauthenticated rate limit on public release data. If you use a token, you can pass it in like so:
make renovate GITHUB_COM_TOKEN=<token>
Note there is a cooldown period set on the releases, to give some time after a new version of a package gets released, so that any major bugs might be found before they affect the build. It's configured to 3 days for a minor release (e.g. moving from 1.1 to 1.2) and 14 days for major releases (e.g. moving from 1.1 to 2.0).
Issues
If the VM gets killed, then any running claude CLI sessions inside the VM will immediately get killed. You won't get the normal notification with the session ID to make it easy to restart your session when you bring the VM back up. So, be careful about killing the VM process if you have claude sessions active.
If you are a user of Little Snitch, note that Little Snitch rules don't apply to the Tart VM. I plan to configure a firewall in the VM so I can control what it can access over the network.
zsh and chase_links
Most people probably won't experience this, but one minor complication I ran into is that my projects directory actually lives on a separate volume, /Volumes/tank/projects. On my workstation, I symlink ~/projects into my home directory, so to get to a project directory, I always just 'cd ~/projects'.
This wouldn't be an issue, except that I also use zsh option chase_links, which means that when I run cd ~/projects, my prompt (and pwd) show me in the actual path, /Volumes/tank/projects. So when I run tart exec with $PWD, it expands to /Volumes/tank/projects, which doesn't exist inside the VM. To work around this complication, I added a line to the Packer provisioning scripts that makes the directory /Volumes/tank and then symlinks ~/projects into that directory.
# link /Volumes/tank/projects
sudo mkdir -p /Volumes/tank
sudo chown "$VM_USER:$VM_USER" /Volumes/tank
sudo -u "$VM_USER" ln -sfn "$VM_HOME/projects" /Volumes/tank/projects