wren-linux / extras / claude-vm.sh
#!/bin/bash
# Claude Code inside a disposable virtual machine. Instead of running the
# agent as another user on this computer, this runs it in its own throwaway
# Debian guest (QEMU/KVM), so a kernel bug that would escape a user-level
# sandbox only reaches a VM you can delete. It is self-contained: it needs
# nothing else installed. (It can also run side by side with the lighter
# the retired extras/claude-code.sh, which it leaves untouched.)
#
# How it is put together:
# - A locked user, claude-vm (no password, no login), runs the one
# qemu-system-x86_64 process. The guest's disk is its own; there is no
# shared folder with you on purpose. The agent's only way to hand work
# back is its branch claude, which claude-pull fetches over the VM's
# ssh login as a git bundle, no git host in between (this script
# installs that command too, so the VM stands on its own).
# - QEMU's user-mode networking turns the guest's traffic into ordinary
# host sockets owned by claude-vm, so a firewall rule on that user
# (data/claude-vm-firewall.nft) governs the guest too: internet yes; your
# router, printers and local services no. The one hole is loopback to the
# guest's own SSH console (127.0.0.1:2242), which the host needs to log in.
# - The guest is built unattended with the bundled mockvps-kvm (QEMU, no
# libvirt), its installer kernel checked against Debian's signed lists,
# then provisioned to install Claude Code from Anthropic's apt repository
# (the same key fingerprint pinned in pins.sh). The guest gets its own git
# identity and ssh key, never yours.
# - Your user may run exactly one thing as claude-vm: the claude-vm-session
# script (a rule in /etc/sudoers.d). The claude-vm command calls it.
# Needs KVM (VT-x/AMD-V and /dev/kvm).
# See THREAT-MODEL.md.
# Another account (a second Claude login, apart from the first): --account
# NAME makes the user claude-vm-NAME with a VM of its own (its own disk, keys
# and Claude login), its own console port (2250-2299, in
# /etc/wren/claude-vm-ports) that only it may reach, a firewall line and a
# sudo rule; claude-vm --account NAME runs it. --remove-account NAME takes it
# out again.
# How big the VM is (lib/vm-size.sh): the first time, it shows this computer's
# memory and free disk, suggests a size and asks (Enter takes the suggestion),
# and never lets the VM have so much that the computer runs short. --memory MB
# and --disk MB choose it, then or later (memory from the VM's next start, the
# disk from its next build, claude-vm rebuild), and --cpus N how many processors
# it may use.
# Usage: bash claude-vm.sh [--remove | --manual | --account NAME | --remove-account NAME]
# [--memory MB] [--disk MB] [--cpus N]
# summary: Claude Code in a disposable VM (no shared folder)
# security: most
# category: dev
# files: bin/no-abstract-sockets lib/claude-accounts.sh bin/claude-vm-net-check bin/sandbox-net-check
# status: bin:claude-vm file:/usr/local/libexec/claude-vm-session
. "$(dirname -- "${BASH_SOURCE[0]}")/../lib.sh"
. "$WREN_DIR/lib/claude-accounts.sh"
. "$WREN_DIR/lib/vm-size.sh"
if [ "${1:-}" = --manual ]; then print_manual; exit; fi
need_user
# --memory MB and --disk MB, wherever they are; the rest stays in order
SIZE_MEM='' SIZE_DISK='' SIZE_CPUS='' rest=()
while [ $# -gt 0 ]; do
case $1 in
--memory) [ $# -ge 2 ] || die "--memory needs a number of MB"; SIZE_MEM=$2; shift 2 ;;
--disk) [ $# -ge 2 ] || die "--disk needs a number of MB"; SIZE_DISK=$2; shift 2 ;;
--cpus) [ $# -ge 2 ] || die "--cpus needs a number"; SIZE_CPUS=$2; shift 2 ;;
*) rest+=("$1"); shift ;;
esac
done
set -- "${rest[@]}"
AGENT=claude-vm
AGENT_HOME=/home/$AGENT
SESSION=/usr/local/libexec/claude-vm-session
MOCKVPS=/usr/local/libexec/claude-vm-mockvps-kvm
SUDOERS=/etc/sudoers.d/wren-claude-vm
FIREWALL=/etc/wren/claude-vm.nft
UNIT=/etc/systemd/system/wren-claude-vm-firewall.service
PROVISION_DST=$AGENT_HOME/.config/claude-vm/provision.sh
VM_USER=agent
# setup_vm_user: the locked user $AGENT, in the kvm group only
setup_vm_user() {
say "The $AGENT user"
if ! id "$AGENT" >/dev/null 2>&1; then
sudo useradd --create-home --home-dir "$AGENT_HOME" --shell /bin/bash --user-group \
--comment "Wren Linux Claude Code VM" "$AGENT"
fi
sudo usermod --lock "$AGENT"
sudo chmod 700 "$AGENT_HOME"
# It needs /dev/kvm; the kvm group is the only group it gets
if ! id -nG "$AGENT" | grep -qw kvm; then
sudo usermod -aG kvm "$AGENT"
fi
note "$AGENT: no password, no login, in the kvm group only."
}
# write_firewall [no]: the rule file, with each account's lines, checked by nft
# before it replaces the old one, then loaded (no: only written)
write_firewall() {
local rules
rules=$(mktemp)
claude_vm_firewall_text "$WREN_DIR/data/claude-vm-firewall.nft" >"$rules"
if ! sudo nft -c -f "$rules" >/dev/null; then
rm -f "$rules"
die "nft didn't accept the new firewall rule, so the old one stays."
fi
sudo install -d -o root -g root -m 0755 "${FIREWALL%/*}"
sudo install -o root -g root -m 0644 "$rules" "$FIREWALL"
rm -f "$rules"
[ "${1:-}" = no ] && return 0
sudo systemctl restart wren-claude-vm-firewall.service ||
warn "Couldn't load the firewall rule; check: systemctl status wren-claude-vm-firewall"
}
# install_provision: the provisioning script, with Anthropic's pinned key
# filled in, where only $AGENT can read it
install_provision() {
local prov
sudo install -d -o "$AGENT" -g "$AGENT" -m 0700 "$AGENT_HOME/.config" "$AGENT_HOME/.config/claude-vm"
prov=$(mktemp)
sed -e "s|@ANTHROPIC_FPR@|$ANTHROPIC_FPR|g" \
-e "s|@ANTHROPIC_KEY_URL@|$ANTHROPIC_KEY_URL|g" \
-e "s|@VM_USER@|$VM_USER|g" \
"$WREN_DIR/data/claude-vm-provision.sh" >"$prov"
sudo install -o "$AGENT" -g "$AGENT" -m 0600 "$prov" "$PROVISION_DST"
rm -f "$prov"
}
# sudoers_rule FILE: your user may run the session script, and nothing else,
# as $AGENT. A broken sudoers file locks you out of sudo, so visudo checks it first
sudoers_rule() {
local rule
rule=$(mktemp)
printf '# Written by Wren Linux extras/claude-vm.sh. %s may run the claude-vm-session script,\n# and nothing else, as the user %s.\n%s ALL=(%s) NOPASSWD: %s\n' \
"$(id -un)" "$AGENT" "$(id -un)" "$AGENT" "$SESSION" >"$rule"
if ! sudo visudo -cf "$rule" >/dev/null; then
rm -f "$rule"
die "The sudoers rule didn't pass visudo's check, so it wasn't installed."
fi
sudo install -o root -g root -m 0440 "$rule" "$1"
rm -f "$rule"
note "Wrote $1"
}
# build_vm: builds $AGENT's VM unless it has one (that is kept, with what is
# not yet brought in with claude-pull)
build_vm() {
say "Building the VM"
if sudo test -s "$AGENT_HOME/vm/$AGENT.qcow2"; then
note "The VM is already built; kept as it is. For a fresh one: claude-vm${1:+ --account $1} rebuild"
else
note "This installs Debian and Claude Code inside the guest; it takes 5-15 minutes"
note "and downloads a few hundred MB."
if sudo -u "$AGENT" "$SESSION" install; then
say "The VM is ready"
note "The agent's work reaches you with claude-pull, from the VM itself: it needs no key on your git host."
else
warn "The build didn't finish. Try again with: claude-vm${1:+ --account $1} rebuild"
fi
fi
}
# set_port NAME PORT | set_port NAME: NAME's console port written (or taken
# out) in the file only root can write
set_port() {
local tmp
tmp=$(mktemp)
{ awk -v n="$1" '$1 != n' "$CLAUDE_VM_PORTS" 2>/dev/null || true; [ -z "${2:-}" ] || echo "$1 $2"; } >"$tmp"
sudo install -d -o root -g root -m 0755 "${CLAUDE_VM_PORTS%/*}"
sudo install -o root -g root -m 0644 "$tmp" "$CLAUDE_VM_PORTS"
rm -f "$tmp"
}
# account NAME: sets AGENT, AGENT_HOME, PROVISION_DST and the sudoers file for it
account() {
claude_account_valid "$1" || die "An account name is small letters and digits, a letter first, at most 15 (e.g. work)."
AGENT=$CLAUDE_VM_PREFIX$1
AGENT_HOME=/home/$AGENT
PROVISION_DST=$AGENT_HOME/.config/claude-vm/provision.sh
ACCOUNT_SUDOERS=/etc/sudoers.d/wren-claude-vm-$1
}
# remove_account NAME: its sudo rule and port, its VM powered off, and (if you
# say so) the user and its home, with the VM disk and what claude-pull hasn't brought in
remove_account() {
account "$1"
sudo rm -f "$ACCOUNT_SUDOERS"
sudo pkill -KILL -u "$AGENT" 2>/dev/null || true
set_port "$1"
vm_size_remove "$AGENT"
if id "$AGENT" >/dev/null 2>&1; then
if ask "Delete the user $AGENT and its home folder $AGENT_HOME (its VM disk, with anything not pushed, its ssh key and Claude login)?" n; then
sudo userdel -r "$AGENT" 2>/dev/null || sudo userdel "$AGENT"
note "Removed the user $AGENT and its VM."
else
note "Kept the user $AGENT and $AGENT_HOME (it can't be started: its sudo rule is gone)."
fi
fi
}
if [ "${1:-}" = --account ] || [ "${1:-}" = --remove-account ]; then
[ -n "${2:-}" ] || die "Usage: bash claude-vm.sh $1 NAME"
account "$2"
if [ "$1" = --remove-account ]; then
remove_account "$2"
# (after the user and its port are gone, so the rule doesn't name them)
if [ -f "$FIREWALL" ]; then write_firewall; fi
note "The account $2 is gone."
exit
fi
{ [ -x "$SESSION" ] && id claude-vm >/dev/null 2>&1; } ||
die "Set up the claude-vm sandbox first: bash $WREN_DIR/extras/claude-vm.sh"
[ -e /dev/kvm ] || die "No /dev/kvm: turn on virtualization (VT-x / AMD-V) in the firmware settings."
port=$(claude_vm_port "$2")
if [ -z "$port" ]; then
port=$(claude_vm_free_port) || die "No free console port left in 2250-2299."
fi
setup_vm_user
say "Its console port and firewall line"
set_port "$2" "$port"
write_firewall
note "$AGENT can reach the internet and its own guest's console (127.0.0.1:$port), nothing else."
install_provision
say "Its sudo rule"
sudoers_rule "$ACCOUNT_SUDOERS"
vm_size_choose "$AGENT" "$SIZE_MEM" "$SIZE_DISK"
[ -z "$SIZE_CPUS" ] || vm_cpus_choose "$AGENT" "$SIZE_CPUS"
build_vm "$2"
cat <<EOF
claude-vm --account $2 Claude Code in this account's own VM
claude-vm --account $2 shell (and shell, status, stop, pubkey, rebuild)
The first time, Claude shows a login link: log in with the other account.
Its VM, disk, keys and login are apart from the first VM's; neither can
reach the other's console.
EOF
exit
fi
if [ "${1:-}" = --remove ]; then
# the other accounts first, each with its own question
mapfile -t accounts < <(claude_account_users "$CLAUDE_VM_PREFIX" | cut -d: -f1)
for u in "${accounts[@]}"; do remove_account "${u#"$CLAUDE_VM_PREFIX"}"; done
AGENT=claude-vm AGENT_HOME=/home/claude-vm
sudo rm -f "$CLAUDE_VM_PORTS"
vm_size_remove claude-vm
remove_bin claude-vm
remove_claude_chooser
# claude-pull and claude-share were shared with the retired extras/claude-code.sh; keep them if that
# sandbox is still set up (its session wrapper is the tell)
[ -e /usr/local/libexec/claude-session ] || { remove_bin claude-pull; remove_bin claude-share; }
sudo systemctl disable --now wren-claude-vm-firewall.service >/dev/null 2>&1 || true
sudo nft delete table inet wren_claude_vm 2>/dev/null || true
sudo rm -f "$SUDOERS" "$SESSION" "$MOCKVPS" "$FIREWALL" "$UNIT"
sudo rm -f /usr/local/libexec/claude-vm-net-check
remove_no_abstract
remove_net_check
sudo systemctl daemon-reload >/dev/null 2>&1 || true
sudo rmdir /usr/local/libexec /etc/wren 2>/dev/null || true
if id "$AGENT" >/dev/null 2>&1; then
if ask "Delete the user $AGENT and its home folder $AGENT_HOME (the VM disk, its ssh key and settings)?" n; then
sudo pkill -KILL -u "$AGENT" 2>/dev/null || true
sudo userdel -r "$AGENT" 2>/dev/null || sudo userdel "$AGENT"
note "Removed the user $AGENT and its VM."
else
note "Kept the user $AGENT and $AGENT_HOME (the VM is still there)."
fi
fi
# QEMU and friends go unless another script's marker still needs them
apt_purge qemu-system-x86 qemu-utils socat nftables
note "Removed the claude-vm sandbox. The retired extras/claude-code.sh, if installed, is untouched."
exit
fi
[ -e /dev/kvm ] || die "No /dev/kvm: turn on virtualization (VT-x / AMD-V) in the firmware settings."
say "Installing what the VM sandbox needs"
# qemu + qemu-utils: the hypervisor. socat: clean power-off. nftables: the
# rule that keeps the guest off your network. openssh-client: logging into the
# guest. cpio/gzip/wget: the unattended installer. gpgv + keyring: verify
# Debian's signature on the installer. The agent itself is installed INSIDE
# the guest, from Anthropic's signed repo, by the provisioning step.
apt_install qemu-system-x86 qemu-utils socat nftables openssh-client \
cpio gzip wget gpgv debian-archive-keyring
setup_vm_user
say "Keeping $AGENT (and so the guest) off your network"
# (with each other account's lines)
write_firewall no
printf '%s\n' '# Written by Wren Linux extras/claude-vm.sh' '[Unit]' \
'Description=Keep the claude-vm user (and its guest) off the local network (Wren Linux)' \
'Before=network-pre.target' 'Wants=network-pre.target' '' '[Service]' \
'Type=oneshot' 'RemainAfterExit=yes' \
"ExecStart=/usr/sbin/nft -f $FIREWALL" \
'ExecStop=/usr/sbin/nft delete table inet wren_claude_vm' '' '[Install]' \
'WantedBy=multi-user.target' | sudo tee "$UNIT" >/dev/null
sudo systemctl daemon-reload
# Restart, not just enable --now: the unit stays "active" after it first ran,
# so a rule file changed by an update would otherwise not be loaded
sudo systemctl enable wren-claude-vm-firewall.service >/dev/null &&
sudo systemctl restart wren-claude-vm-firewall.service ||
warn "Couldn't load the firewall rule; check: systemctl status wren-claude-vm-firewall"
note "$AGENT can reach the internet and the guest's console, nothing else."
say "The VM engine and the claude-vm command"
sudo install -d -o root -g root -m 0755 /usr/local/libexec
# The bundled mockvps-kvm, where claude-vm (which has no access to your home)
# can run it. It looks at its own location, so the wren-only reminder in it
# stays quiet here.
sudo install -o root -g root -m 0755 "$WREN_DIR/vendor/mockvps-kvm/mockvps-kvm.sh" "$MOCKVPS"
sudo install -o root -g root -m 0755 "$WREN_DIR/bin/claude-vm-net-check" /usr/local/libexec/claude-vm-net-check
sudo install -o root -g root -m 0755 "$WREN_DIR/bin/claude-vm-session" "$SESSION"
install_no_abstract
install_net_check
install_provision
sudoers_rule "$SUDOERS"
install_bin claude-vm
install_bin claude
# How you bring the agent's pushed work into your own copy, after reviewing
# it. (The retired extras/claude-code.sh installed the same command.)
install_bin claude-pull
# (claude-share gave the agent files on a public branch; claude-vm give does it privately)
remove_bin claude-share
vm_size_choose "$AGENT" "$SIZE_MEM" "$SIZE_DISK"
[ -z "$SIZE_CPUS" ] || vm_cpus_choose "$AGENT" "$SIZE_CPUS"
build_vm
cat <<EOF
claude Claude Code, asking which if you have set up more than one
claude-vm Claude Code inside the disposable VM (boots it; powers it off when you exit)
claude-vm shell a plain shell in the VM
claude-vm status is the VM running?
claude-vm stop power the VM off
claude-vm pubkey the VM's ssh public key, if it is to push to a git host itself
claude-vm rebuild throw the VM away and build a fresh one
The agent works in ~/projects INSIDE the VM. There is no shared folder:
it hands work back only by pushing to a git host, which you bring into
your own copy with claude-pull (installed above; it shows what's new and
asks before merging). The VM reaches the internet but nothing on your
machine or local network.
EOF