geekfarm
← Back to Blog

Sandboxing Claude: Building a reproducible Claude Code environment with Tart and Packer

This article explains my process for building a custom VM on macOS using Tart and Packer in order to prevent Claude Code from accessing sensitive information like credentials and running destructive commands on my workstation or infrastructure. Along the way, I learned this had the added benefit of reducing the number of permission prompts that consume my attention and can lead to prompt fatigue. A Makefile orchestrates the build, starting from an official Ubuntu cloud image. Packer then boots and provisions the image with the Tart guest agent (to enable tart exec) and my dev environment dependencies. I preserve project files and Claude Code config in shared volumes so I can nuke and pave the VM without losing my session history or even needing to log in again. This article was not written by AI.

Contents

Introduction

I use Claude Code regularly, and I always try use it safely. I've configured allow and deny permissions for commands. I never use yolo mode, and I try to read all the tool requests carefully. But sometimes the commands claude wants to execute are rather complicated, and sometimes it asks for 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 due to prompt fatigue is just too high:

Claude Code already provides built-in security options including a sandbox. But not everything runs inside the sandbox, so there's still the danger of accepting a prompt that gives claude more access than intended. A single acceptance prompt may be all that sits between you and a production incident.

I wanted to build a better shield between my workstation and claude to provide extra layers of security.

By running claude in a virtual machine or container, I realized I could safely give it more open access--which would translate to running with fewer interruptions and saving my attention for decisions that actually matter. Then I discovered this article directly from Anthropic, which paints a very clear picture:

A VM makes a pretty good shield. With a VM, you can explicitly control what claude can access on your filesystem by sharing only directories that you want claude to see. By default, the VM would have full network access including access to my workstation, but it would have no more access than any other host on my network, and no credentials.

Docker is one way to solve this problem. I use Docker extensively on Linux, and I've used it on my workstation in the past, but I prefer not to install Docker on my macOS workstation. And since Docker Desktop on macOS runs in a linux VM, it isn'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 versioned template.

Implementation

Dependencies

For starters, I installed Tart and the other dependencies 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:

I used this as the starting point for my process.

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.

My editor, my git credentials, and my deployment commands are all executed on my workstation. The only thing I run in the VM is claude.

Tip

Mount filesystems into the VM using the same path where it was mounted on the host. This makes it easy to jump into claude in the same directory.

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 in the same directory:

# 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"

This is extremely convenient, because I am typically already working in my project directory at the command line, and I can easily jump into claude in the same project.

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

That didn't work for me, but I discovered this did:

tart run --dir="/path/to/foo:tag=foo" vm

Copying Files Between Host and VM

Since I don't have sshd 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. This preserves the history after a nuke and pave.

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, 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"

With all this in place, I can nuke and pave the VM, and when it restarts, I'm still logged in, and the claude configuration from the previous sessions is still available, so I can still pick up right where I left off.

Dev Environment Dependencies

Inside the VM, I have provisioning scripts to install the dependencies I need when doing development in my VM:

I don't give claude any credentials for any of my external systems (except of course the Claude OAuth token), 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 cirruslabs image 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 if you clone my project, 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 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:

  1. Download the official Ubuntu cloud image
  2. Convert it from qcow2 to a raw disk with qemu-img
  3. Assemble an empty tart create --linux VM and clone the raw disk as disk.img
  4. Generate a cloud-init NoCloud ISO from cloud-init/
  1. Provision with Packer, which boots the VM with the ISO attached and runs the scripts
  2. 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:

See also: nuke and pave

Sizing

You can resize the VM or change the CPU/Memory defaults using tart set:

# 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 tart 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

When tart delete claude-vm completes, the old image will be gone. There's no getting back. This is why you should never store anything you want to keep in the VM outside of your shared volumes.

Maintenance

Renovate

If you look through some of the files, you'll notice that most dependencies are pinned to specific versions.

Tip

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.

But pinning the dependencies comes with a cost--you need to manually track new versions. That's where renovate comes in.

Above the pinned versions in the project, 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:

I created the make renovate target specifically for this blog post. In my normal dev workflow, I run renovate from a Forgejo action, and it automatically submits PRs to bump the versions, similar to Dependabot.

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 and Limitations

Sensitive Information on Shared Volumes

Beware of saving any credentials or other sensitive information on volumes shared with the VM, e.g.:

Of course the VM will require access to your claude credentials.

Even if claude can't access your credentials, your project files may provide information about your hosts, services, and security configurations. Any of this information could be exfiltrated through a prompt injection attack.

Firewall

Currently there is no firewall configured on the VM, so network access is unrestricted. Even if claude can't access your credentials, it could potentially access self-hosted services if they trust your network segment.

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 claude's access to the network.

Killing the VM

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, e.g.

Resume this session with:
claude --resume <uuid>

But you should still be able to find the session by starting claude with claude --resume.

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