← All postsSource.md

Setting Up an Arch VM for Graphic-less Work + Docker

28 minutes to read

Provisioning a headless Arch Linux VM under libvirt/QEMU, and sandboxing a YOLO-mode coding agent inside it with Docker and a filtering egress proxy while watching its files over a FUSE mount.

Introduction

Currently, irresponsible engineers (and careless users) have been getting into unfortunate situations with Large Language Models. I give in this guide one possible solution for individuals, regarding how to use these models in a properly contained manner.

One recent example of the consequences of bad security practices coupled with these tools is the hacking incident between OpenAI and Hugging Face; these links lead to their respective timelines of events.

Note that there is mention of what happened as "Driven, end to end, by an autonomous AI agent system" in HF's Security incident disclosure, while human folk sat at each one of both ends and could have prevented both the whole credential theft consequence, as well as the initial agent escaping its training grounds in the first place.

OpenAI also calls the incident "Unprecedented," and even though there were numerous zero-day CVEs discovered, the only thing out of the ordinary is agents running on a loop, while as similar events have been happening over time at a smaller magnitude of scale, including the agents on a loop part as well.

What should reasonably concern responsible users then, is that LLMs can be (and usually are) given what we call "tools," this being access to run commands on a shell; the ability to run other programs as a program. This is remarkable in the sense that lots of things can be automated and ran faster than manually, and it is what we call an "agent." At the same time, there are obvious risks involved because one is trading speed for reliability and supervision. It is not only that an LLM types a command, but in some cases it might run those outright depending on its harness and harness mode.

Main Commands

Here is a quick reference for day-to-day VM lifecycle management. One might have an idea of what could be done with this guide by just looking at the commands:

bash
virsh --connect qemu:///system list --all
virsh --connect qemu:///system start arch-sandbox

virsh --connect qemu:///system shutdown arch-sandbox
virsh --connect qemu:///system undefine arch-sandbox --remove-all-storage

virt-viewer --connect qemu:///system arch-sandbox
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 --delete arch-sandbox:~/agent-sandbox/projects/ /home/user/Documents/virtual-machines/arch/vm-projects-copy/

Step-by-Step Setup Guide

We are going to explore how to run an Arch Linux Virtual Machine on a base Arch system, connect through SSH to it, create a containerized development environment; and lastly, watch it live from the host (main) machine.

1. Install Virt-Manager, Libvirt and some dependencies on the host, then manage ufw

1.1 Install and Set Up the QEMU and Virtualization Packages:

bash
sudo pacman -S qemu-desktop libvirt virt-install virt-viewer dnsmasq iptables-nft openbsd-netcat sshfs fusermount3

Enable and start the libvirtd service:

bash
sudo systemctl enable --now libvirtd

Ensure libvirt's default NAT network is running so the VM gets network access:

bash
sudo virsh net-start default
sudo virsh net-autostart default

To run virtualization commands without sudo, add your user to the libvirt group:

bash
sudo usermod -aG libvirt $USER

Log out and back in (or run newgrp libvirt) for group membership to take effect.

1.2 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's dnsmasq (serving DHCP and DNS to the VM) listens directly on the host, so DHCP requests and DNS-over-dnsmasq lookups need to reach the host itself. DHCP times out.
  • FORWARD: the VM's actual internet-bound traffic (HTTP, HTTPS, everything pacman needs) is routed, not delivered to the host. Block this and ping can look deceptively fine (simple ICMP-through-NAT sometimes still finds a path) while every TCP connection; including pacman -Sy during archinstall, silently times out with no useful error.

Let's assume your host uses ufw. 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.

bash
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 reload

Confirm with sudo ufw status numbered: you should see 67/udp on virbr0 and 53 on virbr0 under ALLOW IN, and Anywhere on virbr0 under both ALLOW FWD entries.


2. Spin Up the Isolated VM

2.1 Download the ISO and Create the VM

Grab the ISO 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.

bash
curl -O https://geo.mirror.pkgbuild.com/iso/latest/archlinux-x86_64.iso
sudo mv archlinux-x86_64.iso /var/lib/libvirt/images/

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.

bash
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.iso

Then open a graphical console into it (this will open a window which captures your mouse and focus):

bash
virt-viewer --connect qemu:///system arch-sandbox

2.2 Set the Keyboard Layout and Console Font

We are already on a fresh Arch ISO at this point!

bash
loadkeys {KEYBOARD_LAYOUT}
setfont sun12x22

2.3 Verify Network Connectivity

This will most probably fail so far:

bash
ping -c 3 8.8.8.8
ip addr show

2.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.

bash
dhcpcd -1 enp1s0

The network should work now, launch the installer script:

bash
archinstall
Suggested 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
  • 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 a user named arch 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

After the installation completes, choose the "chroot into installation for post-installation configurations" option.

Then, we start and configure ufw. We also exit from chroot and shutdown the system (there's no point in rebooting per se, as we use a VM and it won't boot up that way).

bash
ufw enable
ufw allow ssh
exit

shutdown -h now

2.5 Boot the VM Again

On the host:

bash
virsh --connect qemu:///system start arch-sandbox

This takes 5-10 seconds to boot.


3. Set Up SSH Access from the Host

With the VM reachable, 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:

bash
virsh --connect qemu:///system domifaddr arch-sandbox

If that comes back empty or looks stale, fall back to the DHCP lease table directly:

bash
virsh --connect qemu:///system net-dhcp-leases default

3.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 with this template:

bash
ssh-keygen -t ed25519 -C "$USER@$(hostname)"
  • Add the ~/.ssh/VMs_ed25519 path which we use below.

3.3 Create an SSH Config Alias for the VM

On the host, edit ~/.ssh/config to create a quick alias for the sandbox:

text
Host arch-sandbox
    HostName 192.168.122.50
    User arch
    IdentityFile ~/.ssh/VMs_ed25519

3.4 Copy Your Key to the VM

Copy your SSH public key to the VM so you can log in without typing passwords:

bash
ssh-copy-id -i ~/.ssh/VMs_ed25519.pub arch-sandbox

3.5 Connect

Connect using the host name

bash
ssh arch-sandbox

Or, this works whether or not you have set up the alias above:

bash
ssh arch@192.168.122.50

4. Provision the VM

With SSH access running, we can now use our regular terminal and copy our initial setup lines to be ran.

4.1 Install Dependencies and Development Tooling

bash
sudo pacman -Syu rsync base-devel docker-compose docker-buildx

# And, in case you want some extra things to make the VM more usable
sudo pacman -Sy man-db nvim tree-sitter-cli unzip gvfs ripgrep fzf tmux github-cli mise starship eza zoxide git bat btop viu lazygit lazydocker fd yazi ueberzugpp ffmpeg poppler imagemagick chafa 7zip resvg jq # choose pipewire-jack for ffmpeg
# Mise: one needs to initialize mise on .bashrc for the shims to be available (things will install fine beforehand)
mise use --global node@latest
# Lazyvim
mv ~/.config/nvim{,.bak}; 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/.git
# ...

4.2 (Optional) Copy Dotfiles and Config from the Host

From the host, copy over one's local files, use your own paths to leave the VM's state as you'd like to use it:

bash
scp ~/Documents/virtual-machines/arch/.bashrc arch-sandbox:~/
scp -r ~/.config/nvim/ arch-sandbox:~/.config/
# ...

# Here are the files for docker too, we go through them along step 5
ssh arch-sandbox 'mkdir ~/agent-sandbox/'
scp ~/sources/Arch-VM/agent-sandbox/{compose.yaml,allowlist.txt,tinyproxy.conf,Dockerfile.proxy,Dockerfile.agent,agent-wrapper} arch-sandbox:~/agent-sandbox/

I have made a repository with this last group of files to clone and revise more easily.

4.3 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:

bash
sudo systemctl enable --now serial-getty@ttyS0.service

Configure 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.4 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:

bash
sudoedit /etc/default/grub

With:

ini
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:

bash
sudo grub-mkconfig -o /boot/grub/grub.cfg

Once that service is running inside the guest, do test from your host terminal:

bash
virsh --connect qemu:///system console arch-sandbox

Hit Enter once or twice, and you'll see the login prompt. Close with Ctrl + ].

4.4 Go Headless: Remove the Graphical Console

Once SSH and the serial console above are working, the VNC <graphics> device and the emulated <video> GPU might not as well be needed day-to-day, unless you plan differently.

The GRUB serial settings from step 4.3 are what makes removing <video> 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.

We do this from the host:

bash
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-sandbox

virt-viewer 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 an LLM running in YOLO mode (auto-approving its own actions) can't wander outside the one projects directory it is meant to touch, or phone home to arbitrary destinations, even if it tries for a myriad of causes.

5.1 Why Bother, If the VM Is Already Isolated?

The VM's job is protecting the host; this being your other projects and credentials. It does nothing to contain the agent within the VM: without a container, a YOLO-mode agent could 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 unsupervised agents from ever reaching that limit (within reason and depending on how containers are made).


Docker is process isolation rather than 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, and pids_limit in the compose.yaml map 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: true removes 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: ALL strips 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, which if it goes wrong (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.

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.

The two layers we set up are no thing like equal ones stacked for redundancy.

5.2 Directory Layout and the Agent Image

Our Docker setup will lie on this directory, alongside the projects directory the agent will be given:

bash
mkdir -p ~/agent-sandbox/projects && cd ~/agent-sandbox

Creating projects now truly matters, as it is bind-mounted into the container in step 5.4, and if it doesn't exist by the time it first starts, the Docker daemon creates it for you as root:root.

Write agent-wrapper: each harness command (claude, codex, opencode, pi) is a symlink to this script:

bash
#!/bin/bash

# Updates the tool to the latest version, then runs it
tool=$(basename "$0")

# `cd /` so the update doesn't discover (and prompt to trust) a project's mise.toml in /workspace,
# `--yes` for any other confirmation, `</dev/null` so a prompt can never block invisibly
(cd / && mise use -g --yes "$tool" >/dev/null </dev/null) ||
  echo "$tool: update check failed (proxy / offline ?), running the installed version" >&2

exec mise x "$tool" -- "$tool" "$@"

To add another harness, check that its command name exists in mise's registry (mise registry <name>) and add a symlink for it in the Dockerfile below.

Write Dockerfile.agent: a minimal image with just Node.js, git, mise and the wrapper, running as a non-root user:

dockerfile
FROM node:22-slim

RUN apt-get update && apt-get install -y --no-install-recommends \
  git gh ca-certificates curl build-essential pkg-config python3 \
  && rm -rf /var/lib/apt/lists/*

# mise: installs and updates the harnesses, and provisions project runtimes (step 5.9)
RUN curl -fsSL https://mise.run | MISE_INSTALL_PATH=/usr/local/bin/mise sh

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

# Create every directory a bind mount in compose.yaml targets, as agent
# Docker creates a missing bind-mount parent as root:root, which would leave ~/.config unwritable and break `mise use -g`
RUN mkdir -p /home/agent/.config/opencode

# Harness launchers, like Omarchy's ~/.local/bin wrappers
# mkdir as agent first, so ~/.local stays agent-owned (mise writes to ~/.local/share)
RUN mkdir -p /home/agent/.local/bin
COPY --chown=agent:agent --chmod=755 agent-wrapper /home/agent/.local/bin/agent-wrapper
RUN cd /home/agent/.local/bin && for t in claude codex opencode pi; do ln -s agent-wrapper "$t"; done

# ~/.local/bin (the wrappers) must come before mise's shims
ENV PATH="/home/agent/.local/bin:/home/agent/.local/share/mise/shims:$PATH"

The first time you launch each harness, it gets installed into ~/.local/share/mise. Every later launch checks for a newer release.

Every harness added is another set of hostnames the proxy in step 5.3 has to allow: its model API, whatever it uses for sign-in, and often a separate model-catalogue host.

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 question expanded on in step 5.9.

5.3 Restrict Egress to an Allowlist

The agent needs to reach APIs, and (usually) git and package registries too!

With a filtering forward proxy, the agent container has no direct route to the internet, only to a small proxy container, which allows connections solely to an explicit list of hostnames and drops everything else.

Since it is 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:

dockerfile
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:

text
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"
FilterType ere

FilterType ere (POSIX extended regular expressions)

Matching is case-insensitive unless FilterCaseSensitive Yes is set, which is the behaviour one wants for hostnames.

Below is a broad starting point which should cover most common network usage. Everything in it comes from the vendors' own published requirements.

Frontier LLMs are able to fetch web information from all sorts of places with their own tools, regardless of this list.

Write allowlist.txt: regex patterns matched against the requested hostname, one rule per line, with # comment lines allowed. Start narrow and add entries as legitimate requests turn up blocked in the proxy's logs.

text
# Agent harnesses --.

# Claude Code: api.anthropic.com, mcp-proxy.anthropic.com, claude.ai and downloads.claude.ai, platform.claude.com (OAuth token exchange/refresh), code.claude.com (docs lookups)
^(.*\.)?anthropic\.com$
^(.*\.)?claude\.ai$
^(.*\.)?claude\.com$

# Plugin metadata and install counts shown in /plugin (doubles as the Go module mirror bucket)
^storage\.googleapis\.com$

# opencode: auth and the web UI proxied from app.opencode.ai
^(.*\.)?opencode\.ai$

# pi: install.sh and docs
^(.*\.)?pi\.dev$

# Shared open model/provider catalogue; opencode builds its model list from it, and pi packages fetch https://models.dev/api.json at startup
^(.*\.)?models\.dev$

# Model APIs --.

^api\.openai\.com$
^auth\.openai\.com$
^(.*\.)?chatgpt\.com$
^generativelanguage\.googleapis\.com$
^aiplatform\.googleapis\.com$
^cloudcode-pa\.googleapis\.com$
^accounts\.google\.com$
^oauth2\.googleapis\.com$
^openrouter\.ai$
^api\.deepseek\.com$
^api\.mistral\.ai$
^api\.groq\.com$
^api\.x\.ai$
^(.*\.)?githubcopilot\.com$

# Source control --.

# githubusercontent covers raw., codeload. and release-assets. (release binaries moved to release-assets.githubusercontent.com during 2025)
^(.*\.)?github\.com$
^(.*\.)?githubusercontent\.com$

# Package registries --.

# Node
^(.*\.)?npmjs\.org$
^(.*\.)?npmjs\.com$
^(.*\.)?nodejs\.org$
^(.*\.)?yarnpkg\.com$
^get\.pnpm\.io$
^bun\.sh$

# Python
^pypi\.org$
^(.*\.)?pythonhosted\.org$
^bootstrap\.pypa\.io$
^(.*\.)?astral\.sh$

# Go: the default GOPROXY/GOSUMDB pair, plus go.dev/dl -> dl.google.com tarballs
^proxy\.golang\.org$
^sum\.golang\.org$
^index\.golang\.org$
^go\.dev$
^dl\.google\.com$
^gopkg\.in$

# Rust
^(.*\.)?crates\.io$
^static\.rust-lang\.org$
^sh\.rustup\.rs$
# Others, as needed
^(.*\.)?rubygems\.org$
^repo1\.maven\.org$
^(.*\.)?gradle\.org$
^(.*\.)?nuget\.org$
^ftp\.postgresql\.org$

# Toolchains (mise, step 5.9) --.
^mise\.run$
# Sigstore: mise's build-provenance verification for some language installs, like Ruby
^(.*\.)?sigstore\.dev$
^(.*\.)?jdx\.dev$

# Browser automation: Playwright, Chromium won't start without libglib, so use something like Sparticuz/chromium --.
# Playwright's own CDN for browser binaries (its actual Chromium/Firefox/WebKit archives)
^(.*\.)?playwright\.dev$

# MCP servers --.
^mcp\.context7\.com$
^mcp\.grep\.app$
Notes on the list above
  • Anthropic publishes the exact table. Its enterprise network configuration page lists every host Claude Code needs and what each is for, which is the thing to re-check rather than trusting any list copied off a blog: these move (the old console.anthropic.com or docs.anthropic.com pair became platform.claude.com and code.claude.com).
  • GitHub's Copilot allowlist reference is the most complete public list of package-ecosystem hostnames per language, and it is maintained; most of the registry block above is a trimmed version of it.
  • A hostname filter cannot express paths. Go's module mirror is served out of a Google Cloud Storage bucket, so ^storage\.googleapis\.com$ is all of GCS, not one bucket. It is the price of filtering CONNECT by hostname without breaking TLS; narrow it by pointing GOPROXY and GOSUMDB at an internal mirror if that trade is unacceptable.
  • Wildcards subsume the single-host entries. The list above is already collapsed that way, but it's worth knowing when merging in someone else's: ^(.*\.)?npmjs\.org$ covers registry.npmjs.org, ^(.*\.)?pythonhosted\.org$ covers files.pythonhosted.org, ^(.*\.)?crates\.io$ covers both index. and static.crates.io, and ^(.*\.)?githubusercontent\.com$ covers raw., objects. and release-assets. (while codeload.github.com, the tarball host git and go get actually hit, belongs to the github.com entry instead). Keeping the narrower duplicates is harmless, just longer to read; deleting a wildcard whose subdomains one didn't realize were in use is the failure worth avoiding.
  • This only constrains runtime. docker compose build runs on the VM's ordinary network, so the apt-get and mise.run lines in Dockerfile.agent never touch the proxy. The allowlist governs the agent once it is running, not how the image got built. The harnesses themselves are the exception: agent-wrapper installs and updates them at runtime, so every harness download does go through this allowlist.

5.4 Wire It Together and Lock Down Runtime Behavior

Write compose.yaml: 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:

yaml
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 for
  • ./projects:/workspace: the place an agent can write to besides scratch space; point this at the project(s) you want it touching. 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-home volume: covers the whole container's home directory, since any CLI tool living in this image (claude, opencode and 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 it 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 :exec is needed because Docker's default tmpfs mount is noexec and some tools like opencode's native render library extract a .so into /tmp and need to actually run it, which fails with failed to map segment from shared object otherwise. Note that /workspace (a normal bind mount) was never noexec in the first place; an agent that can write files there could already have written 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.

5.5 Verify the Allowlist Before Trusting It

Before pointing a real agent at this setup, confirm the container proxy actually discriminates:

bash
docker compose build
docker compose run --rm agent bash

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 proxy

Check docker compose logs proxy to find out what to add to allowlist.txt when something legitimate gets blocked.

5.6 Run (exempli gratia) Claude Code in YOLO Mode

Once the allowlist checks out:

bash
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 web browser to get its resulting code
claude --dangerously-skip-permissions

The container can be torn down and rebuilt at any time without losing the bind-mounted projects directory.

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:

bash
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 keeping in mind:

  • Use the absolute remote path (/home/arch/... and not ~/...).
  • Edits made through the mount reach the VM immediately (it's a synchronous SFTP write, not a copy), and in the reverse direction too of course.

Syncthing (bidirectional, paired directly between VM and host, no SSH tunneling needed) is the heavier tool, worth if the sshfs setup above stops being enough.

Notes on security

The mount doesn't touch the container's own isolation (it only sees ~/agent-sandbox/projects on the VM's real disk, same as the bind mount does), but it does bridge agent-written content straight to host-side tooling, live. A symlink the agent leaves behind pointing at an absolute path (ln -s /home/user/.ssh/id_rsa leftover.txt) is inert on the VM/container side, but the moment a host file manager, editor or thumbnailer opens that path through the mount, it follows the link using the host's own filesystem root, outside both the VM and container boundary entirely.

rsync -avz copies carry the same latent risk (-a preserves symlinks as symlinks), just not live. Mount -o ro from the host if you don't need to write back, and avoid opening the mirror with tools that auto-preview or auto-execute directory-local configuration (.envrc, editor workspace settings, etcetera).

5.7.2 A One-off Copy

If one just wants a single file or a snapshot rather than an ongoing view:

bash
scp -r arch-sandbox:~/agent-sandbox/projects/ /home/user/Documents/virtual-machines/vm-projects-copy/

Or, for a whole tree:

bash
rsync -avz --delete 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, but note that there is no bidirectionality with this approach.

It needs inotify-tools on the VM.

This inotifywait feed can drive a continuously up-to-date one-way copy rather than just printing events:

bash
# 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/
done

Use 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.

5.8 (Optional) Bring In Shell Aliases from the VM

To bring in specific aliases (or other files too) to the container, one could bind-mount select files, read-only, in compose.yaml instead of copying them:

yaml
volumes:
  - ./projects:/workspace
  - agent-home:/home/agent
  - ~/agent-sandbox/extra/.bash_aliases:/home/agent/.bash_aliases:ro

Notice the path which is on the VM (since that's where docker compose runs). Whether this actually gets sourced depends on the base image's default .bashrc; node:22-slim's stock one sources .bash_aliases by default.

For instance, mine has these:

bash
alias c='opencode --auto'
alias ca='DISABLE_AUTOUPDATER=1 claude --dangerously-skip-permissions'
alias cx='codex --dangerously-bypass-approvals-and-sandbox'
alias mup='MISE_MINIMUM_RELEASE_AGE=0 mise up'
alias x='exit'

This is, basically, an open step to broaden the container's configuration.

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, Go binary, Rust crate, or 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: the simplest option, but it fixes what is 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 mise provision it at runtime instead: (already installed in step 5.2). Drop a project's normal .tool-versions or mise.toml in /workspace, then from inside the container:

The second option 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 compose.yaml for this to work.

Installing individual language runtimes may need further allowlist.txt entries depending on which one. The registry and toolchain blocks in step 5.3 already cover the usual suspects (nodejs.org, astral.sh for the standalone Python builds, dl.google.com for Go tarballs, static.rust-lang.org), and mise fetches a good number of the rest straight from GitHub release assets, which ^(.*\.)?githubusercontent\.com$ already allows. Anything past that follows the same "check docker compose logs proxy, then add what is legitimately blocked" loop from step 5.5, the first time each new language gets installed.


Either way, one thing mise does not cover is system-level packages (a C compiler, headers or 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.

The Image Layers Freeze At the First Successful Build

Docker reuses a cached layer whenever its instruction and everything above it is unchanged. Dockerfile.agent never changes after step 5.2.

Every install is a cache hit from then on. The base image, the apt packages and mise stay at whatever they were when the image was first built, and later docker compose build runs do nothing.

To genuinely refresh them:

bash
docker compose build --pull --no-cache

--no-cache re-runs every instruction; --pull additionally re-fetches node:22-slim itself, which otherwise stays pinned to the digest you first pulled, security updates included.

This does not log you out. A build only replaces the image's read-only layers, and every harness keeps its credentials under /home/agent (~/.claude/.credentials.json, ~/.local/share/opencode/auth.json, ~/.codex/auth.json), which is the agent-home volume; the next docker compose run mounts it straight back over the new image. The command that clears stored auth is docker compose down -v, with emphasis on the -v flag which removes the volume.

A plain docker compose down removes only the containers and networks. Either way ./projects being a bind mount is untouched.