A VM sandbox for Claude Code on Mac

Last updated: April 2026

The idea

Claude Code runs shell commands, edits files, and manages git repositories on your behalf. Running it inside a virtual machine gives you an isolation boundary: if something goes wrong, your host system is unaffected. You can also snapshot the VM and roll back at will.

UTM is a macOS application that provides a convenient frontend for QEMU virtual machines. The setup below creates a Linux VM with one or more shared folders, so you can edit files on your Mac (e.g., in Visual Studio Code) and have Claude Code work on them inside the VM. The examples use Ubuntu, but any Linux distribution will work with minor adjustments.

1. Setting up the VM

Install UTM and create the VM

Download and install UTM, then download an Ubuntu desktop image (ARM64 if you are on Apple Silicon). (A server image works too, and takes up less space, but forces pre-SSH bringup to be done without cut-and-paste.) Create a new VM following the UTM basics guide.

Once the VM is running, install the QEMU guest agent (see UTM Linux guest support for background). On Ubuntu:

sudo apt install qemu-guest-agent

Configure a shared folder in UTM

In UTM's VM settings, add a shared directory pointing to the folder on your Mac that contains the files you want Claude Code to work on. UTM exposes the shared directory to the guest via the VirtIO 9p protocol.

For concreteness, the rest of this guide uses /Users/username/src as the shared directory. (Replace username with your actual username throughout.)

2. Mounting the shared folder

Inside the guest VM, create a mount point:

sudo mkdir -p /mnt/utm1

Add the following line to /etc/fstab:

# UTM shared folder (VirtIO 9p)
share  /mnt/utm1  9p  trans=virtio,version=9p2000.L,rw,_netdev,nofail,auto  0 0

Then reload systemd and mount:

sudo systemctl daemon-reload
sudo mount /mnt/utm1

Verify that the mount works:

ls /mnt/utm1

You should see your Mac files listed.

On some Ubuntu versions (e.g., 25.04) you may see this error when reloading:

Failed to restart network-fs.target: Unit network-fs.target not found.

This is harmless — the _netdev and nofail options in the fstab entry mean that the mount will still succeed. If the mount did not happen automatically at boot, run:

sudo systemctl daemon-reexec
sudo mount /mnt/utm1

3. Fixing UID/GID mismatches with bindfs

If you inspect the mounted files, you will notice that they are owned by UID 501 and GID 20 — the default macOS user and staff group. Your Linux user is typically UID 1000 / GID 1000, so the files appear to belong to someone else:

$ ls -na /mnt/utm1
drwxr-xr-x  501  20  4096  ...

The fix is to use bindfs to create a second mount that remaps the ownership.

3.1. Install bindfs

sudo apt install bindfs

3.2. Verify your Linux user and group IDs

$ id -u   # expect 1000
$ id -g   # expect 1000

Also double-check the UIDs on the 9p mount:

$ ls -na /mnt/utm1

Look for the numbers (e.g., 501 and 20) that you will remap below.

3.3. Create the remapped mount

Create a directory where the remapped view will appear, e.g.:

mkdir ~/src

Add a second line to /etc/fstab. For example, assuming the ID values above:

# bindfs: remap macOS UID/GID to Linux UID/GID
/mnt/utm1  /home/username/src  fuse.bindfs  map=501/1000:@20/@1000,x-systemd.requires=/mnt/utm1,_netdev,nofail,auto  0 0

The key options:

Apply and mount:

sudo systemctl daemon-reload
sudo mount ~/src

Now ~/src should show your files with correct ownership:

$ ls -na ~/src
drwxr-xr-x  1000  1000  4096  ...

4. Using multiple shared directories

UTM's UI supports only one shared directory per VM. But it can be useful to have multiple shared directories, e.g., to have more fine-grained isolation rather than exposing an entire directory tree to the VM.

To share more than one directory, the simplest option is to export the second directory over NFS from the Mac. If NFS's higher latency becomes a bottleneck, you can add another 9p share via custom QEMU arguments instead, at the cost of some hassle caused by the macOS sandbox (see Option 2 below).

The examples below share a second host directory /Users/username/shared and mount it at ~/shared on the guest.

Option 1: NFS

NFS is served by nfsd on the macOS host. The guest kernel talks to it over the virtual network, so QEMU never opens the files and UTM's sandbox is not involved.

On the Mac

Add one line to /etc/exports for each directory you want to share:

/Users/username/shared  -network 192.168.64.0  -mask 255.255.255.0  -mapall=username

The -network/-mask clause restricts access to UTM's shared-network subnet. Confirm the actual subnet with ifconfig — UTM uses bridge10x interfaces that appear only while a VM is running. -mapall=username squashes every incoming request to your Mac user, so writes from the guest land on the host as your macOS user regardless of the guest's internal UID.

Enable nfsd (which also makes it start at every boot), start it, and re-read the exports on each change:

sudo nfsd enable
sudo nfsd start
sudo nfsd update

Note that macOS's nfsd serves only NFSv2 and NFSv3; NFSv4 is not supported on the server side. The guest side must therefore mount with vers=3 explicitly, since modern Linux defaults to v4.

On the guest

Install NFS client support and create the mount points:

sudo apt install nfs-common
sudo mkdir -p /mnt/nfs1
mkdir ~/shared

Add two /etc/fstab lines — one for the NFS mount and one for the bindfs remap on top of it (same UID/GID fix as for the 9p share):

192.168.64.1:/Users/username/shared  /mnt/nfs1  nfs  vers=3,proto=tcp,rsize=65536,wsize=65536,_netdev,nofail,auto  0 0
/mnt/nfs1  /home/username/shared  fuse.bindfs  map=501/1000:@20/@1000,x-systemd.requires=/mnt/nfs1,_netdev,nofail,auto  0 0

Replace 192.168.64.1 with the Mac's IP on the UTM vmnet (usually the .1 of the subnet). Then:

sudo systemctl daemon-reload
sudo mount /mnt/nfs1
sudo mount ~/shared

Optional: firewall belt-and-suspenders

The -network clause in /etc/exports is nfsd's own access control and is the primary defense against non-VM clients. The macOS Application Firewall (System Settings > Network > Firewall), even when enabled, does not restrict nfsd by subnet — it makes per-application decisions, and nfsd is an Apple-signed daemon that it allows by default. For a dev-machine threat model, the exports restriction alone is usually sufficient.

For defense in depth, or if you sometimes work on untrusted networks, you can add pf rules that block NFS (TCP/UDP 2049) and portmap (TCP/UDP 111) at the packet layer except from the UTM subnet. Create /etc/pf.anchors/com.nfs-restrict:

block in quick proto { tcp, udp } from any to any port { 2049, 111 }
pass  in quick proto { tcp, udp } from 192.168.64.0/24 to any port { 2049, 111 }

And load it from /etc/pf.conf.

Latency

NFS has noticeably higher per-operation latency than 9p, which shows up in metadata-heavy workloads (git on a large tree, build systems that stat() thousands of files). If this bottlenecks your workflow, either build into a VM-local directory (e.g., set CARGO_TARGET_DIR, GOCACHE, or a CMake out-of-tree build dir), or switch to Option 2.

Option 2: extra QEMU arguments (9p)

If NFS latency is a problem even with VM-local build artifacts, the alternative is a second 9p share added via raw QEMU arguments. Performance matches the original 9p share, but you run into a macOS sandbox issue discussed below.

Shares are passed to QEMU as pairs of -fsdev/-device arguments. You can add more of these yourself: in the VM settings, open the QEMU > Arguments page and click "New..." for each entry. For a second share pointing at /Users/username/shared, add the four arguments:

-fsdev
local,id=virtfs1,path=/Users/username/shared,security_model=mapped-xattr
-device
virtio-9p-pci,fsdev=virtfs1,mount_tag=share1

The id (virtfs1) and mount_tag (share1) must both differ from those used by the UI-configured share (typically virtfs0 and share). Screenshots of UTM's Arguments pane, before and after adding the extra share:

UTM QEMU Arguments pane, before adding extra share UTM QEMU Arguments pane, after adding extra share

In the guest, set up the new mount points:

sudo mkdir -p /mnt/utm2
mkdir ~/shared

And add two /etc/fstab lines, using the new mount_tag (share1) as the 9p source:

share1  /mnt/utm2  9p  trans=virtio,version=9p2000.L,rw,_netdev,nofail,auto  0 0
/mnt/utm2  /home/username/shared  fuse.bindfs  map=501/1000:@20/@1000,x-systemd.requires=/mnt/utm2,_netdev,nofail,auto  0 0

Caveat: macOS sandbox and security-scoped bookmarks

UTM is a sandboxed macOS application, and QEMU can only open host paths for which UTM holds a security-scoped bookmark. macOS issues these when you pick a folder with the "Browse..." button in the Shared Directory UI; paths that appear only in custom QEMU arguments have no bookmark. The sandbox then denies the open and the VM refuses to start, with an error like:

QEMU error: QEMU exited from an error:
... cannot initialize fsdev 'virtfs1':
failed to open '/Users/username/shared': Operation not permitted.

This can appear to work initially if macOS has a cached bookmark for the path from an earlier interaction, but reboots, UTM updates, or macOS upgrades may invalidate the cache. The standard workaround is to "prime" the bookmark:

  1. Shut down the VM.
  2. In UTM, point the UI-configured Shared Directory at the extra path using "Browse...", and save.
  3. Start the VM once, then shut it down.
  4. Change the UI-configured Shared Directory back to the original path and start the VM again — both shares now work.

This is fragile: bookmarks can expire between UTM sessions, so you may have to re-prime periodically. If that is unacceptable, use Option 1 (NFS).

5. Logging in from the host

UTM's VM display window is serviceable for initial bringup, but for day-to-day use it is much nicer to SSH into the VM from a Mac terminal — you get copy/paste, scrollback, your usual shell setup, and tabs. This is especially useful for running Claude Code.

5.1. Install and enable SSH in the guest

sudo apt install openssh-server
sudo systemctl enable --now ssh

Find the VM's IP on the shared-network interface:

ip addr show

Look for an address on the UTM subnet (typically 192.168.64.x).

5.2. Add a host entry on the Mac

Give the VM a convenient name by editing /etc/hosts on the Mac and adding a line like:

192.168.64.5  claude-vm

(Of course, replace the IP with the one from the step above, and the hostname with whatever you want.) UTM's shared-network DHCP assigns stable leases based on the VM's MAC, so the IP should remain valid unless you replace the virtual NIC.

5.3. Install your public key on the VM

If you do not already have an SSH key on the Mac, generate one:

ssh-keygen -t ed25519

Copy the public key to the VM:

ssh-copy-id username@claude-vm

You should now be able to log in without a password:

ssh claude-vm

6. Installing Claude Code

Claude Code requires Node.js 18 or later. Install it (and NPM, if necessary) in the VM:

sudo apt install nodejs npm

Then install Claude Code:

npm install -g @anthropic-ai/claude-code

You can now cd into ~/src (or any subdirectory of it) and run claude. It will operate on your shared files within the safety of the VM.