Main Commands
Quick reference for day-to-day VM lifecycle management:
See the full setup guide below for first-time provisioning.
virsh --connect qemu:///system list --all
virsh --connect qemu:///system start arch-sandbox
virsh --connect qemu:///system domifaddr arch-sandbox # confirm the IP below still matches, as DHCP can hand out a different one
virsh --connect qemu:///system shutdown arch-sandbox
virsh --connect qemu:///system destroy arch-sandbox
virsh --connect qemu:///system undefine arch-sandbox --remove-all-storage
virt-viewer --connect qemu:///system arch-sandbox
sudo virsh console arch-sandbox
ssh arch@192.168.122.50
# In case SSH refuses to connect given old trust
ssh-keygen -R 192.168.122.50
# With keys set up already
ssh arch-sandbox
# Mount VM's container projects directory
sshfs arch-sandbox:/home/arch/agent-sandbox/projects /home/user/Documents/virtual-machines/arch/vm-projects-mirror/
# Unmount to stop watching / editing from the host's side
fusermount3 -u /home/user/Documents/virtual-machines/arch/vm-projects-mirror/
# Make an efficient copy of the current state of the containerized projects
rsync -avz arch-sandbox:~/agent-sandbox/projects/ /home/user/Documents/virtual-machines/arch/vm-projects-copy/Step-by-Step Setup Guide
1. Install Virt-Manager and Libvirt on Arch (host), then manage ufw
1. Install and Set Up the QEMU and Virtualization Packages:
sudo pacman -S qemu-desktop libvirt virt-install virt-viewer dnsmasq iptables-nft openbsd-netcat sshfs fusermount3Enable and start the libvirtd service:
sudo systemctl enable --now libvirtdTo run virtualization commands without sudo, add your user to the libvirt group:
sudo usermod -aG libvirt $USERLog out and log back in (or run
newgrp libvirt) for group membership to take effect.
Ensure libvirt's default NAT network is running so the VM gets network access:
sudo virsh net-start default
sudo virsh net-autostart default2. Allow VM Traffic Through the Host Firewall
If your host runs a firewall, the VM won't reach the network at all, even though libvirt's NAT is running. It'd fail in two different ways depending on which chain is blocking it:
INPUT: libvirt'sdnsmasq(serving DHCP and DNS to the VM) listens directly on the host, so DHCP requests and DNS-over-dnsmasqlookups need to reach the host itself. DHCP times out.FORWARD: the VM's actual internet-bound traffic (HTTP, HTTPS, everythingpacmanneeds) is routed, not delivered to the host. Block this andpingcan look deceptively fine (simple ICMP-through-NAT sometimes still finds a path) while every TCP connection; includingpacman -Syduringarchinstall, silently times out with no useful error.
If your host uses ufw (confirm with sudo ufw status), fix both from the host. The INPUT side only needs to be open enough for dnsmasq itself: DHCP (UDP/67) and DNS (53, tcp+udp). Anything wider also lets the VM reach whatever else happens to be listening on 0.0.0.0 on one's host, which works against the whole point of sandboxing it.
sudo ufw allow in on virbr0 to any port 67 proto udp
sudo ufw allow in on virbr0 to any port 53
sudo ufw route allow in on virbr0
sudo ufw route allow out on virbr0
sudo ufw reloadConfirm with
sudo ufw status numbered: you should see67/udp on virbr0and53 on virbr0underALLOW IN, andAnywhere on virbr0under bothALLOW FWDentries.
2. Spin Up the Isolated VM
2.1 Download the ISO and Create the VM
Arch doesn't offer a netinstall URL like Debian, so grab the ISO first and move it to /var/lib/libvirt/images/, because although virt-install runs under sudo, the QEMU process itself runs as the unprivileged libvirt-qemu user, which can't read files inside your home directory (typically mode 700), hence permission errors at boot.
The commands below call /usr/bin/python3 /usr/bin/virt-install explicitly rather than plain virt-install: if you use mise, its shimmed python3 shadows the system one on $PATH and lacks the gi (PyGObject) bindings these libvirt tools use, so virt-install / virt-xml would fail.
curl -O https://geo.mirror.pkgbuild.com/iso/latest/archlinux-x86_64.iso
sudo mv archlinux-x86_64.iso /var/lib/libvirt/images/sudo /usr/bin/python3 /usr/bin/virt-install \
--name arch-sandbox \
--memory 3072 \
--vcpus 4 \
--disk size=32,format=qcow2 \
--os-variant archlinux \
--network network=default \
--graphics vnc \
--cdrom /var/lib/libvirt/images/archlinux-x86_64.isoThen open a graphical console into it:
virt-viewer --connect qemu:///system arch-sandbox2.2 Set the Keyboard Layout and Console Font
loadkeys <keyboard-layout>
setfont sun12x222.3 Verify Network Connectivity
This will most probably fail so far:
ping -c 3 8.8.8.8
ip addr show2.4 Network Configuration and Archinstall
If DHCP doesn't get an address (confirm the interface name first with ip link, typically enp1s0), configure it manually; these commands need to be run again after every reboot until the OS install completes. Once networking is up (whether it came up via DHCP or you had to configure it here)
dhcpcd -1 enp1s0Once the network works, launch installer:
archinstallSuggested archinstall Options for This Sandbox
- Archinstall language: English
- Locales:
- Keyboard layout:
<keyboard-layout> - Locale language: en_US.UTF-8
- Locale encoding: UTF-8
- Console font: sun12x22
- Keyboard layout:
- Mirrors & repositories: Selected mirror regions:
<given-countries>
- Disk configuration:
best-effort default, ext4, no LVM. - Swap:
- Swap on zram: Enabled
- Compression algorithm: zstd
- Bootloader: GRUB
- Kernels: linux
- Authentication: set root password, and create
archuser with sudo privileges - Profile: Server (with Docker and SSH)
- Applications:
- Firewall: ufw
- Network configuration: Copy ISO network configuration to installation
- Pacman:
- Color: true
- Additional packages: (none)
- Timezone:
<given-timezone> - Automatic time sync (NTP): Enabled
Exit archinstall and do a shutdown -h now. Then start the VM again.
2.5 Open the Guest Firewall for SSH
The installed system already has ufw active. It now needs an SSH exception, since the default-deny ruleset otherwise blocks access. On the VM:
sudo ufw allow sshOne can confirm with something like sudo ufw status verbose (it should show Status: active and 22 ALLOW IN Anywhere).
3. Set Up SSH Access from the Host
With the VM reachable, the next step happens entirely on one's host machine: generate (or reuse) a key pair, make sure it's backed up, and configure passwordless access to the sandbox.
3.1 Confirm the VM's Current IP Address
DHCP doesn't guarantee the same address on every installation. The rest of this guide uses 192.168.122.50 as a running example, but check first:
virsh --connect qemu:///system domifaddr arch-sandboxIf that comes back empty or looks stale, fall back to the DHCP lease table directly:
virsh --connect qemu:///system net-dhcp-leases default3.2 Generate an SSH Key Pair (One-Time Setup)
If you don't have an SSH key pair on the host machine yet, create one:
ssh-keygen -t ed25519 -C "$USER@$(hostname)"- Add the
~/.ssh/VMs_ed25519path. - The passphrase is optional; leaving it empty enables fully non-interactive logins.
3.3 Back Up Your Keys
Your private key (id_ed25519, no .pub suffix) cannot be regenerated from the public key, so if it's lost you have to create a brand-new pair and re-distribute the new public key to every host that trusted the old one; therefore, treat it accordingly:
- Back it up somewhere durable and separate from this machine: your normal backup routine, an encrypted external drive, or a password manager are all reasonable choices. The point is that a single disk failure shouldn't take out both the live key and its only copy.
- Never store the private key unencrypted in a cloud-synced folder, email it to yourself, or commit it to a git repository.
- If you ever suspect the private key has leaked, treat it as compromised: generate a new pair and replace it in every
authorized_keysfile it was added to.
Verify the Key Pair & Lock Down Permissions
ls -l ~/.ssh/id_ed25519*
chmod 700 ~/.ssh
chmod 600 ~/.ssh/id_ed25519
chmod 644 ~/.ssh/id_ed25519.pub3.4 Create an SSH Config Alias for the VM
On your host workstation, edit ~/.ssh/config to create a quick alias for the sandbox:
Host arch-sandbox
HostName 192.168.122.69
User arch
IdentityFile ~/.ssh/VMs_ed255193.5 Copy Your Key to the VM
Copy your SSH public key to the VM so you can log in without typing passwords:
ssh-copy-id -i ~/.ssh/VMs_ed25519.pub arch-sandbox3.6 Connect
Connect using the host name
ssh arch-sandboxOr, this works whether or not you've set up the alias above:
ssh arch@192.168.122.504. Provision the VM
With SSH access running:
4.1 Install Dependencies and Development Tooling
sudo pacman -Syu rsync base-devel docker-compose nvim unzip ripgrep fzf tmux github-cli mise starship eza zoxide git bat btop lazygit lazydocker fd yazi # with font 47
# One needs to initialize mise on .bashrc and/or .bash_profile files for the shims to be available
mise use --global node@latest
mise use --global go@latest4.2 Set Up LazyVim
# required
mv ~/.config/nvim{,.bak}
# optional but recommended
mv ~/.local/share/nvim{,.bak}
mv ~/.local/state/nvim{,.bak}
mv ~/.cache/nvim{,.bak}
git clone https://github.com/LazyVim/starter ~/.config/nvim
rm -rf ~/.config/nvim/.git4.3 Copy Dotfiles and Config from the Host
From the host, copy over one's local files, change the paths to leave the VM's state as you'd like to use it:
scp -r ~/.config/nvim/ arch-sandbox:~/.config/
scp -r ~/Documents/virtual-machines/arch/.config/yazi arch-sandbox:~/.config/
scp ~/Documents/virtual-machines/arch/.config/starship.toml arch-sandbox:~/.config/
scp ~/Documents/Arch/arch/.inputrc arch-sandbox:~/
scp -r ~/Documents/Arch/arch/.bashrc.d arch-sandbox:~/
scp ~/Documents/Arch/arch/.bashrc arch-sandbox:~/4.4 Serial Console Access
Enable a serial getty on the VM so you can reach it via virsh console even without SSH, and use it without graphics and video devices:
sudo systemctl enable --now serial-getty@ttyS0.serviceConfigure GRUB to output to serial too. This covers the kernel's own console, but GRUB itself has a separate, earlier terminal setting that this doesn't touch (see step 4.5 below for why that distinction matters once there's no video device at all). The input/output ones already exist in Arch's default file, just commented or set to console.
GRUB_SERIAL_COMMAND is a new line, matching the 115200 baud used on the kernel side so both consoles agree on a speed.
Edit:
sudoedit /etc/default/grubWith:
GRUB_CMDLINE_LINUX_DEFAULT="loglevel=3 quiet console=tty0 console=ttyS0,115200"
# ...
GRUB_TERMINAL_INPUT="console serial"
# ...
GRUB_TERMINAL_OUTPUT=serial
GRUB_SERIAL_COMMAND="serial --speed=115200"Then update the GRUB config:
sudo grub-mkconfig -o /boot/grub/grub.cfgOnce that service is running inside the guest, test virsh console arch-sandbox from your host terminal. Hit Enter once or twice, and you'll see the login prompt. Close with Ctrl + ].
4.5 Go Headless: Remove the Graphical Console
Once SSH (and the serial console above) are working, the VNC <graphics> device and the emulated <video> GPU aren't needed day-to-day.
The GRUB serial settings from step 4.4 are what makes removing <video> specifically safe: without them, GRUB's own boot menu (before the kernel even loads) defaults to a graphical terminal that needs a virtual GPU, hanging the moment it's gone. Removing <graphics> alone never hits this, since that only drops the remote-display (VNC) transport, not the GPU itself.
virsh --connect qemu:///system shutdown arch-sandbox
sleep 10
sudo /usr/bin/python3 /usr/bin/virt-xml arch-sandbox --remove-device --graphics all
sudo /usr/bin/python3 /usr/bin/virt-xml arch-sandbox --edit --video model=none
virsh --connect qemu:///system start arch-sandboxvirt-viewer / VNC will no longer connect after this.
5. Sandbox Further with Docker
The VM already isolates the agent from the host; this adds a second layer inside the VM itself, so a coding agent running in YOLO mode (auto-approving its own actions, e.g. Claude Code's --dangerously-skip-permissions) can't wander outside the one project directory it's meant to touch, or phone home to arbitrary destinations, even if it tries.
5.1 Why Bother, If the VM Is Already Isolated?
The VM's job is protecting the host, aka. Your real machine, your other projects, your credentials. It does nothing to contain the agent within the VM: without a container, a YOLO-mode agent can read ~/.ssh, rewrite your shell config, install arbitrary packages system-wide, or fill the available disk.
Docker adds a second, tighter boundary around the agent process itself; the VM stays the blast-radius limit for anything that escapes the container, and the container is what keeps day-to-day agent mistakes from ever reaching that limit (within reason and depending on how containers are made).
Docker is process isolation, not virtualization. A container isn't a separate machine with its own kernel: it is a normal process on the host OS made to look isolated by combining a few Linux kernel features:
- Namespaces: give the process its own private view of things that are normally global: its own PID tree (so it can't see or signal other processes), its own network stack (interfaces, routing table, ports), its own mount table (so it sees a different filesystem root), its own hostname. This is what makes a container feel like a separate box even though it isn't one.
- cgroups: the accounting/limiting layer which caps how much CPU, memory, and PIDs a process tree can consume. This is what
mem_limit,cpus, andpids_limitin thedocker-compose.ymlmap to. - Union/overlay filesystem; a container's filesystem is the image's read-only layers plus one writable layer on top, per container.
read_only: trueremoves that writable layer entirely; tmpfs: /tmp adds back one memory-backed writable exception. - Capabilities: Linux root privilege is actually a bundle of dozens of separate powers (mount filesystems, load kernel modules, manage raw sockets, etc.).
cap_drop: ALLstrips all of them, so even a process with UID 0 inside the container won't do most of what "root" normally implies. - seccomp: Docker applies a default profile that blocks a chunk of the more dangerous syscalls regardless of capabilities.
There's no hypervisor, no separate kernel instance, no hardware-level memory isolation. The "wall" around a container is entirely made of kernel bookkeeping: namespaces hiding things, cgroups throttling things, capabilities / seccomp refusing certain calls. If there's an issue in how any of that bookkeeping is enforced (a kernel exploit, a capability left on that shouldn't have been, a bad bind-mount), the process is no longer meaningfully contained.
We do not want agents to read the VM's filesystem directly, hit the VM's real network stack instead of the internal, true Docker network, etc. The internal/egress network split and the proxy allowlist are themselves just Docker-managed network namespaces and iptables rules living inside the VM's kernel, so they're also void if an escape happens, since whatever broke out has the same kernel access needed to reconfigure them D:
The VM layer is enforced by KVM/QEMU hardware virtualization, a fundamentally different and much stronger boundary where the guest has its own kernel, its own memory space managed by the hypervisor, and no visibility into the host's real hardware except through the narrow, defensively-implemented virtio devices exposed. Breaking out of a VM to the host requires a hypervisor-level vulnerability, which is a rarer and harder bug class than a container escape. So the two layers aren't equal walls stacked for redundancy.
5.2 Directory Layout and the Agent Image
Create a working directory for the setup.
mkdir -p ~/agent-sandbox
cd ~/agent-sandboxWrite Dockerfile.agent: a minimal image with just Node.js and git, running as a non-root user:
FROM node:22-slim
RUN apt-get update && apt-get install -y --no-install-recommends \
git ca-certificates curl build-essential pkg-config \
&& rm -rf /var/lib/apt/lists/*
RUN npm install -g opencode-ai
RUN npm install -g @anthropic-ai/claude-code
# node:22-slim already ships a "node" user at UID/GID 1000
# Remove it and reuse that ID for "agent" instead of taking whatever's next free (1001)
# This lines up with the VM user (arch, UID 1000) that owns the bind-mounted ./projects directory
# A mismatched UID here is a silent write-permission failure, not an obvious one while it looks like the mount itself is read-only.
RUN userdel -r node \
&& groupadd -g 1000 agent \
&& useradd --create-home --uid 1000 --gid 1000 --shell /bin/bash agent
USER agent
WORKDIR /workspace
# A basic local commit identity so `git commit` works out of the box; deliberately not tied to any real account
RUN git config --global user.name "sandbox-agent" \
&& git config --global user.email "sandbox-agent@localhost" \
&& git config --global init.defaultBranch main
# mise, so the agent can provision whatever language/runtime a given project actually needs at runtime
RUN curl https://mise.run | sh
ENV PATH="/home/agent/.local/bin:/home/agent/.local/share/mise/shims:$PATH"Check
id -ufor the VM user that owns~/agent-sandboxfirst (archis UID 1000 on a fresharchinstall); if it is something other than 1000, use that value in bothuseradd/groupaddabove instead.
build-essential and pkg-config are there preemptively: the most common reason a project's own npm install / pip install fails inside a minimal image is a native extension trying to compile and finding no compiler. mise handles the "what does this project need to actually run" question, expanded on in step 5.9.
5.3 Restrict Egress to an Allowlist
The agent needs to reach APIs, and usually git/package registries too. With a filtering forward proxy, the agent container has no direct route to the internet, but only to a small proxy container, which allows connections solely to an explicit list of hostnames and drops everything else. Since it's just permitting or refusing the TLS CONNECT tunnel by hostname (not intercepting the traffic itself), it doesn't need to for instance break or inspect HTTPS.
Write Dockerfile.proxy:
FROM alpine:latest
RUN apk add --no-cache tinyproxy
COPY tinyproxy.conf /etc/tinyproxy/tinyproxy.conf
COPY allowlist.txt /etc/tinyproxy/filter
EXPOSE 8888
CMD ["tinyproxy", "-d"]Write tinyproxy.conf:
User tinyproxy
Group tinyproxy
Port 8888
Listen 0.0.0.0
Timeout 600
Allow 172.28.0.0/24
FilterDefaultDeny Yes
Filter "/etc/tinyproxy/filter"
FilterExtended YesWrite allowlist.txt: regex patterns matched against the requested hostname; start narrow and add entries as legitimate requests turn up blocked in the proxy's logs:
^(.*\.)?anthropic\.com$
^(.*\.)?claude\.ai$
^(.*\.)?claude\.com$
^(.*\.)?opencode\.ai$
^(.*\.)?github\.com$
^(.*\.)?githubusercontent\.com$
^(.*\.)?npmjs\.org$
^(.*\.)?mise\.run$
^files\.pythonhosted\.org$
^registry\.npmjs\.org$
^pypi\.org$5.4 Wire It Together and Lock Down Runtime Behavior
Write docker-compose.yml: the agent sits on an internal: true network with no route to the outside world except through proxy, which straddles that network and the normal internet-facing one:
services:
proxy:
build:
context: .
dockerfile: Dockerfile.proxy
networks:
- internal
- egress
restart: unless-stopped
agent:
build:
context: .
dockerfile: Dockerfile.agent
networks:
- internal
depends_on:
- proxy
environment:
- HTTP_PROXY=http://proxy:8888
- HTTPS_PROXY=http://proxy:8888
- NO_PROXY=localhost,127.0.0.1
volumes:
- ./projects:/workspace
- agent-home:/home/agent
working_dir: /workspace
read_only: true
tmpfs:
- /tmp:exec
cap_drop:
- ALL
security_opt:
- no-new-privileges:true
pids_limit: 256
mem_limit: 2g
cpus: "3"
stdin_open: true
tty: true
networks:
internal:
internal: true
ipam:
config:
- subnet: 172.28.0.0/24
egress:
driver: bridge
volumes:
agent-home:What each runtime restriction is actually for
-
./projects:/workspace: the only thing the agent can write to besides scratch space; point this at the project(s) you want it touching, never$HOMEor~/.ssh. As of this guide, we create a directory named "projects" on the directory we're already in. We are mounting this back on the host as well! -
agent-homevolume: covers the whole home directory, since any CLI tool living in this image (claude,opencodeand whatever gets added later) tends to want its own writable spot under the container's$HOME..claude,.local,.config,.cache,.npm, etc. Mounting the one directory means a new tool's config, cache and login-state just works without a new volume entry every time; it's still an isolated Docker-managed volume, not a host path, yet still persists across container restarts which lets us not authenticate and/or add API keys more than once. -
read_only+tmpfs: /tmp:exec: the container's root filesystem cannot be modified. Only/tmp(memory-backed, wiped on restart) and the two mounts above are writable. The:execis needed because Docker's defaulttmpfsmount isnoexec, some tools likeopencode's native render library extract a.sointo/tmpand need to actually run it, which fails withfailed to map segment from shared objectotherwise. Worth noting as a tradeoff rather than a free fix, as this doesn't meaningfully widen the sandbox's real boundary, since/workspace(a normal bind mount) was nevernoexecin the first place; an agent that can write files there could already write and run a script. -
cap_drop: ALL+no-new-privileges: strips every Linux capability (raw sockets, changing ownership, mount, etc.) and blocks any way of regaining privileges even via a setuid binary. -
pids_limit,mem_limit,cpus: cap a runaway or fork-bombing process from starving the VM.
Fixing write permissions in case things do not work
If a tool inside the container ever reports permission-denied writing under /home/agent, the fix has to come from outside the agent service, as cap_drop: ALL strips CAP_CHOWN (along with other things), so nothing inside this container can chown anything, not even a process running as root (docker compose run --rm --user root agent chown ...).
Use a plain, uncapped one-off container against the same volume instead:
docker volume ls # confirm the exact name, likely agent-sandbox_agent-home
docker run --rm -v agent-sandbox_agent-home:/data alpine chown -R 1000:1000 /data5.5 Verify the Allowlist Before Trusting It
Before pointing a real agent at this setup, confirm the proxy actually discriminates:
docker compose build
docker compose run --rm agent bash
# Inside the container:
curl -sS -o /dev/null -w "%{http_code}\n" https://api.anthropic.com # expect an HTTP status, not a proxy error
curl -sS -o /dev/null -w "%{http_code}\n" https://example.com # and this one to be refused
exit
# After which (to confirm)
docker compose logs proxyCheck docker compose logs proxy to find out what to add to allowlist.txt when something legitimate gets blocked.
5.6 Run Claude Code in YOLO Mode
Once the allowlist checks out:
docker compose run --rm agent bash
# The very first time, the auth flow will fail and give you a plain web URL, paste it in the host's machine browser to get its resulting code
claude --dangerously-skip-permissionsThe container can be torn down and rebuilt at any time (docker compose down && docker compose up -d --build) without losing the project (bind-mounted) or the agent's login session (named volume), although everything else it might have touched is gone.
5.7 Bridge Files Between the VM and the Host
The ./projects:/workspace mount already gets everything the agent touches out of the container and onto the VM's real filesystem, at ~/agent-sandbox/projects/. Getting it from the VM to the host is a second, separate hop.
I use two main approaches; one for live interactions, and another for long-term copy of the files. Also, there is a last option in case you need an efficient loop to have constant duplicates of the files available to the agents.
5.7.1 Mounting the projects Directory: a Two-way System
sshfs gives a normal-looking local folder that's actually a synchronous view of the VM's directory:
sshfs arch-sandbox:/home/arch/agent-sandbox/projects /home/user/Documents/virtual-machines/vm-projects-mirror/
# To then unmount
fusermount3 -u /home/user/Documents/virtual-machines/vm-projects-mirror/Two things worth knowing:
- Use the absolute remote path (
/home/arch/...), not~/.... Somesshfsversions fail to resolve a~over SFTP with a misleading "No such file or directory," even though the directory is right there. - Edits made through the mount reach the VM immediately (it's a synchronous SFTP write, not a copy), and in the reverse direction it works too of course.
5.7.2 A One-off Copy
If one just wants a single file or a snapshot rather than an ongoing view:
scp -r arch-sandbox:~/agent-sandbox/projects/ /home/user/Documents/virtual-machines/vm-projects-copy/Or, for a whole tree:
rsync -avz arch-sandbox:~/agent-sandbox/projects/ /home/user/Documents/virtual-machines/vm-projects-copy/5.7.3 Copying changes as they happen, without mounting
This is useful for writes to the host if you can spare the drive's space, be it to use more comfortably or to not deal with some of the inconveniences of a FUSE mount:
It needs inotify-tools on the VM.
This inotifywait feed can drive a continuously up-to-date one-way copy instead of only printing events:
# On the host
rsync -avz arch-sandbox:~/agent-sandbox/projects/ /home/user/Documents/virtual-machines/vm-projects-copy/ # initial copy
ssh arch-sandbox 'inotifywait -m -r -e modify,create,delete,move ~/agent-sandbox/projects' | \
while read -r _; do
rsync -avz --delete arch-sandbox:~/agent-sandbox/projects/ /home/user/Documents/virtual-machines/vm-projects-copy/
doneUse a directory other than the sshfs mountpoint above for this (~/vm-projects-copy instead of vm-projects-mirror): one is a live mount, the other is a plain copy kept in sync.
Syncthing (bidirectional, paired directly between VM and host, no SSH tunneling needed) is the heavier tool, worth if the
sshfssetup above stops being enough.
5.8 (Optional) Bring In Shell Aliases from the VM
To bring in specific aliases without dragging in a whole .bashrc that likely references tools/paths this minimal image doesn't have, one could bind-mount just select files, read-only, in docker-compose.yml:
volumes:
- ./projects:/workspace
- agent-home:/home/agent
- ~/agent-sandbox/extra/.bash_aliases:/home/agent/.bash_aliases:roPath is on the VM, since that's where docker compose runs. Whether this actually gets sourced depends on the base image's default .bashrc source-ing ~/.bash_aliases; node:22-slim's stock one does by default.
For instance, mine has these:
alias c='opencode --auto'
alias ca='claude --dangerously-skip-permissions'
alias x='exit'5.9 (Optional) Toolchains for Compiling & Testing Project Code
The image built in step 5.2 is deliberately minimal. If the project the agent is editing needs to actually build or run (a Python script, a Go binary, a Rust crate, anything with a compile step), that runtime has to come from somewhere too, and it is worth being deliberate about where:
-
Bake specific versions into
Dockerfile.agent: simplest, but fixes what's available up front; adding or bumping a language later means a rebuild, and running several unrelated projects out of the same container means whatever's baked in has to satisfy all of them at once. -
Let
miseprovision it at runtime instead: (already installed in step 5.2). Drop a project's normal.tool-versionsormise.tomlin/workspace, then from inside the container:bash mise trust # mise refuses to auto-run config files from a directory it hasn't seen before mise install # pulls whatever versions the project's config asks for
This works despite read_only: true on the root filesystem because everything mise writes, the actual downloaded toolchains, shims and cache live under ~/.local/share/mise and ~/.local/bin, both inside /home/agent, which is the one part of the container's filesystem the agent-home volume already makes writable. Nothing needs to change in docker-compose.yml for this to work.
Installing individual language runtimes may need further allowlist.txt entries depending on which one (Node/Python/Go/Ruby versions are typically fetched from their own upstream mirrors), so expect to repeat the "check docker compose logs proxy, add what's legitimately blocked" loop from step 5.3 the first time each new language gets installed.
Either way, one thing mise does not cover is system-level packages (a C compiler, headers, system libraries; build-essential, libssl-dev, etc.) which still come from apt, and those genuinely can't be installed at runtime as the root filesystem is read-only and every capability is dropped.
Do not reach for the Docker socket. A common shortcut to "give the agent somewhere to test things" is mounting
/var/run/docker.sockinto the container so it can spin up its own throwaway containers. Be reticent: anything with access to the host's Docker socket can trivially mount the host's real root filesystem into a new container and read/write it directly! It is a full escape from every restriction in this guide, not a sandboxed way to run more sandboxes.
If one modifies the image's /home/agent
Docker only populates a named volume from the image's content once, the very first time that volume is created. Every rebuild since then, the image's /home/agent (which has programs installed in it) gets completely shadowed the moment the old, already-existing volume mounts on top of it at container start.
Run docker compose down -v if need be: It will clear up any configs or auth tokens stored so far from previous container runs.