ccvm
Run Claude Code in an ephemeral, RAM-only QEMU microVM — with native-terminal fidelity and zero host setup.
ccvm is a drop-in replacement for running claude. Launch it in any project directory and it
whips up a completely ephemeral NixOS microVM, drops you into the exact same Claude Code TUI you’d get from
native claude, and tears the whole VM down when you exit. Everything Claude does happens inside
the VM; the only things that cross back to your host are the edits it makes to the one project
directory you launched it in.
The whole project is 100% reproducible from this repository and built entirely with Nix — no Node, no Bun, no npm, no hidden lockfiles. The site you are reading is byte-reproducible from a commit too.
Why ccvm
- Containment by default. Claude can see the one directory you launched it in — not the rest of your machine, your SSH keys, or your cloud credentials — and everything it does disappears when you close it.
- Feels native. A real guest PTY over SSH means resize,
vim,less, full-screen TUIs, and vi-mode all behave exactly as they do outside the VM. - Your host login never auto-crosses. ccvm shares your
~/.claudesettings, commands, agents, and memory, but the OAuth credential is excluded by construction — you/logininside the VM (wiped on exit) or setANTHROPIC_API_KEY. - One knob to lock egress down. Open egress by default (like native
claude); setegressAllowlistto switch to a default-deny firewall enforced inside the guest.
Where to go next
- New here? Start with About for the what/why and the one-paragraph threat model, then Getting started to install and run it.
- Configuring it? The Options reference covers every
programs.ccvm.*setting and the per-runCCVM_*environment variables. - Care about the boundary? The Security section is the deep version of the threat model, the must-not-regress invariants, and the egress design.
- Hacking on ccvm? Developing has the repo map, the build/test loop, the deliberate defaults not to reverse, and the settled design decisions not to relitigate.
Heads up: avoid pressing Ctrl+Z inside ccvm — Claude Code treats it as suspend and stops itself, and the VM has no shell to bring it back, so the session freezes. This is upstream Claude Code behavior, not specific to ccvm. See Getting started.
About
ccvm runs Claude Code inside an ephemeral, RAM-only QEMU microVM. Running ccvm in a project
directory automatically boots a NixOS microVM and drops you into the same TUI that running claude
would — but everything Claude does happens inside a disposable sandbox that is destroyed when you
exit.
What it is
- A drop-in replacement for
claude: everything you type afterccvmis forwarded to the realclaudeverbatim, including flags like--dangerously-skip-permissions. - An ephemeral sandbox: the entire VM (root filesystem, packages, shell history, anything outside the shared project directory) lives in RAM and is destroyed on exit. There is no disk to recover state from afterwards.
- Zero host setup: it runs unprivileged, with QEMU’s built-in user-mode networking — no
bridges, TAP devices, or
sudorequired.
Who it’s for
- People who want to run Claude Code (especially with
--dangerously-skip-permissions) without giving an agent — possibly a prompt-injected one — free rein over their home directory and credentials. - Security-minded developers who want a real, auditable trust boundary (QEMU) rather than a best-effort one.
- Nix users who value a fully reproducible, lockfile-pinned toolchain.
The threat model in one paragraph
The trust boundary is QEMU: ccvm assumes QEMU’s device/Virtio isolation holds, and defends the
host filesystem and your credentials against a (possibly prompt-injected) agent inside the VM.
Out of the box you get containment — Claude can’t read or write anything on the host beyond the
single project directory you launched it in, and nothing it does persists past exit — plus the
guarantee that your host login never auto-crosses into the VM (the OAuth credential is excluded
from what’s shared, by construction). What the defaults do not give you is exfiltration
resistance: under the native-mirroring defaults (open egress), a misbehaving agent can read your
project tree and send it somewhere over the open network. Locking that down is one setting —
egressAllowlist, a default-deny firewall enforced inside the guest.
For the full version — scope of the boundary, the must-not-regress invariants, the egress design and its residual channels — read the Security section.
The native-mirroring default
ccvm’s defaults are deliberately tuned to feel exactly like native claude:
- Live host edits (
writableCwd = true) — the agent’s edits to the project directory land on your host immediately. - Shared
~/.claudeconfig — your settings,CLAUDE.md, commands, agents, skills, keybindings, and output styles are staged into the VM (but never your login credential). - Shared git identity (
share.gitConfig = true) — in-VMgitcommits as you, with your aliases and ignores (no credentials or signing keys cross). - Open egress — the VM can reach the internet freely, just like native
claude.
Isolation (read-only project, no shared config, locked-down egress) is the opt-in, not the default. This is a deliberate DevEx choice: the out-of-the-box win is containment plus the host login never crossing, not project-exfiltration resistance. See Deliberate defaults for the full rationale.
Getting started
Requirements
- Linux
- Nix (with flakes enabled)
No NixOS required — Nix on any Linux distribution works. If you don’t have Nix yet, install it with
the official installer, then enable flakes once by adding this to
~/.config/nix/nix.conf:
experimental-features = nix-command flakes
KVM is used automatically when available and falls back to software emulation (TCG) otherwise, so
ccvm runs even where /dev/kvm is unavailable — just more slowly. See
acceleration to force a mode.
Try it without installing
With Nix and flakes enabled, run ccvm straight from the repository in any project directory:
nix run github:openccvm/ccvm
The first run builds the VM image, so it takes a few minutes; after that it’s cached and starts quickly.
Run it once installed
Once installed (see below), run it in any project directory, exactly like claude:
ccvm
Everything after ccvm is forwarded to claude verbatim — including
--dangerously-skip-permissions, which is safe to opt into precisely because the VM is the trust
boundary.
First run: authenticating
ccvm brings your ~/.claude settings into the VM but not your login — the OAuth credential
is excluded by construction (see Security invariants). So on first run,
authenticate one of two ways:
/logininside the VM — the resulting token lives in the VM’s ephemeral tmpfs and is wiped on exit. It never touches your host’s stored credential.ANTHROPIC_API_KEY— set it in your host environment before launching. ccvm passes it to the VM only over the SSH channel (never on disk, argv, or the kernel command line). The host variable name is configurable viaapiKeyVariable.
Installing with home-manager
ccvm ships a home-manager module that exposes programs.ccvm.* and installs the ccvm command.
1. Install Nix
Use the official installer. You don’t need NixOS to use Nix or ccvm. If you’re not on NixOS and don’t plan to switch, use the install script on that page.
2. Flake configuration
If you don’t already have a dotfiles repo, make a directory for the flake:
mkdir -p ~/Projects/yourConfigRepo
Enable nix-command and flakes (per-user) by adding to ~/.config/nix/nix.conf:
experimental-features = nix-command flakes
In yourConfigRepo/flake.nix (replace every yourUsername):
{
inputs = {
nixpkgs.url = "nixpkgs/nixos-26.05";
ccvm = {
url = "github:openccvm/ccvm";
inputs = {
nixpkgs.follows = "nixpkgs";
claude-code.follows = "claude-code";
};
};
claude-code = {
url = "github:ryoppippi/nix-claude-code";
inputs.nixpkgs.follows = "nixpkgs";
};
home-manager = {
url = "github:nix-community/home-manager";
inputs.nixpkgs.follows = "nixpkgs";
};
};
outputs =
{
nixpkgs,
ccvm,
claude-code,
home-manager,
...
}:
let
system = "x86_64-linux";
pkgs = import nixpkgs {
inherit system;
overlays = [ claude-code.overlays.default ];
};
in
{
homeConfigurations.yourUsername = home-manager.lib.homeManagerConfiguration {
inherit pkgs;
modules = [
ccvm.homeModules.ccvm
./yourUsername/home.nix
];
};
};
}
Update the lock file:
cd ~/Projects/yourConfigRepo && nix flake update ccvm --flake path:.
3. home-manager configuration
Make a folder for your home-manager files if you don’t have one:
mkdir -p ~/Projects/yourConfigRepo/yourUsername
In yourConfigRepo/yourUsername/home.nix (replace every yourUsername again):
{
pkgs,
lib,
...
}:
{
home = {
stateVersion = "26.05";
username = "yourUsername";
homeDirectory = "/home/yourUsername";
};
programs.ccvm = {
enable = true;
cores = 8;
memory = 8192;
vmDiskSize = 32;
nix.enable = true;
egressAllowlist = [
"cache.nixos.org"
"storage.googleapis.com"
"github.com"
"api.github.com"
"raw.githubusercontent.com"
"codeload.github.com"
"npmjs.com"
"registry.npmjs.org"
];
extraPackages = with pkgs; [
bottom
delta
eza
yazi
];
};
nixpkgs.config.allowUnfreePredicate =
pkg:
builtins.elem (lib.getName pkg) [
"claude-code"
];
}
claude-codeis unfree, so a consuming config must allow it. TheallowUnfreePredicateabove is the form that works in a real home-manager activation — the name that matches isclaude-code(the bare nameclaudeis rejected; don’t simplify it).nix run github:openccvm/ccvmneeds none of this — ccvm’s own build allows unfree internally.
Switch to your new configuration:
cd ~/Projects/yourConfigRepo && nix run nixpkgs#nh -- home switch path:.
See the Options reference for everything programs.ccvm.* can set.
The Ctrl+Z caveat
Avoid pressing Ctrl+Z inside ccvm. Claude Code treats Ctrl+Z as suspend and stops itself, but the VM has no job-control shell to bring it back — so the session freezes. If it happens, disconnect and start again (the VM is ephemeral, so nothing is lost beyond the session). This is upstream Claude Code behavior, not specific to ccvm, and there is no guest-side fix — the signal involved is uncatchable. See Gotchas → Ctrl+Z for the gory technical detail.
Options
Every setting below is a programs.ccvm.* option from the home-manager module. Most have a per-run
CCVM_* environment-variable override and, for a few, a ccvm command-line flag — see
Per-run overrides at the bottom.
Egress is open by default (like native
claude), so a compromised agent could exfiltrate your project files (and anything you authenticate with inside the VM). Lock it down withegressAllowlist. Full threat model: Security.
The options are ordered roughly by how often you’ll reach for them — essentials first, escape hatches last.
Core
enable
Install the ccvm command. Default: false. Type: boolean.
writableCwd
Mount the host CWD (the project directory ccvm was launched in) read-write so the agent’s edits
land on the host live. false keeps the CWD read-only with edits in an ephemeral overlay discarded
on exit. Only this one directory ever crosses to the host. Default: true. Type: boolean.
Per-run: CCVM_WRITABLE_CWD, or the --writable-cwd / --read-only-cwd flags.
memory
How much RAM (in MiB) to allocate to the VM. Default: 4096. Type: positive integer.
This is a runtime QEMU argument — changing it does not rebuild the guest. Per-run: CCVM_MEMORY.
cores
How many vCPUs to allocate to the VM. Default: 4. Type: positive integer.
Runtime QEMU argument — no rebuild.
acceleration
Which acceleration mode to use. Default: "auto". Type: "auto", "kvm", or "tcg".
"auto"— use KVM when/dev/kvmis usable, else fall back to TCG software emulation. Uses-cpu maxso QEMU’s own-accel kvm:tcgruntime fallback stays valid."kvm"— require KVM. Hard-errors with an actionable reason (missing device / not in thekvmgroup / not writable) if it can’t. Uses-accel kvm(no fallback) and-cpu host."tcg"— force software emulation. Works anywhere, slowly.
Per-run: CCVM_ACCEL.
Config sharing (share.*)
By default ccvm stages items from your host ~/.claude into the VM so it behaves like native
claude — but never your login credential (.credentials.json is excluded by construction, not
by filter; see Security invariants).
share.settings, share.claudeMd, share.keybindings, share.commands, share.agents, share.skills, share.outputStyles
Stage the named item from host ~/.claude into the VM — your settings, context file (CLAUDE.md),
keyboard shortcuts, commands, agents, skills, and output styles respectively. Default: true for
all seven. Type: boolean.
share.settings also stages a sanitized copy of the home-root ~/.claude.json — its known
secret-bearing keys (mcpServers[].env, mcpServers[].headers, the legacy primaryApiKey) are
stripped via jq before staging. See Security invariants.
Per-run: CCVM_SHARE_SETTINGS, CCVM_SHARE_CLAUDEMD, CCVM_SHARE_KEYBINDINGS,
CCVM_SHARE_COMMANDS, CCVM_SHARE_AGENTS, CCVM_SHARE_SKILLS, CCVM_SHARE_OUTPUTSTYLES.
share.plugins, share.config
Opt-in sharing for ~/.claude/plugins and ~/.claude/config. Default: false for both. Type:
boolean. Per-run: CCVM_SHARE_PLUGINS, CCVM_SHARE_CONFIG.
share.gitConfig
Stage a sanitized copy of your global git config so in-VM git commits as you, with your
aliases and ignores. No credentials or signing keys cross: every value containing a /nix/store/
path and all credential.* entries are dropped, commit/tag signing is force-disabled, and
core.excludesfile is staged by content. Default: true. Type: boolean. Per-run:
CCVM_SHARE_GIT_CONFIG.
Disabling all config sharing at once
Set every share.* to false, or use the back-compat env var CCVM_SHARE_CLAUDE_CONFIG=0 to
toggle all claude items off at once (per-item vars win over it). CCVM_SHARE_CLAUDE_CONFIG=1
re-enables them.
Nix in the VM
nix.enable
Enable Nix inside the VM. Default: false (read-only /nix/store, no in-VM nix, lean closure).
Type: boolean. This is build-time — it rebuilds the store as a writable overlay in the initrd.
Combine with vmDiskSize > 0 to relocate the writable-store overlay onto the
encrypted disk so a large nix develop doesn’t exhaust guest RAM. See
Deliberate defaults.
nix.substituters, nix.trustedPublicKeys
Extra binary caches for in-VM Nix and the public keys that verify paths from them. Default: [] for
both. Type: list of strings. A public-read signed cache works with zero secrets; a cache behind
a token/netrc is not yet supported. require-sigs stays on. See
Design decisions → in-VM nix.
Persistence & disk
persistClaudeProjects
Mount ~/.claude/projects read-write so transcripts and memory persist back to the host
(cross-run --resume). Scoped to projects/ only — nothing else under ~/.claude is writable, so
the login credential (which lives at the ~/.claude root) is still never writable. Default:
false. Type: boolean. Per-run: CCVM_PERSIST_PROJECTS.
vmDiskSize
GiB of opt-in encrypted, ephemeral disk mounted at /scratch (and, under nix.enable, backing
the writable /nix/store overlay). 0 keeps the VM pure-RAM. The LUKS key is generated inside the
guest from /dev/urandom every boot and never leaves guest RAM, so the disk is inert ciphertext the
instant QEMU stops — wipe-on-exit is cryptographic. Default: 0. Type: non-negative integer.
Per-run: CCVM_VM_DISK_SIZE. Adds ~4–5s to boot (per-boot luksFormat). See
Encrypted disk.
Clipboard
clipboard.images
Make Ctrl+V image paste work inside the VM (like native claude) by bridging the host clipboard
image over the existing SSH connection. Image-only — host clipboard text never crosses — and
it opens no new network hole. Default: true. Type: boolean. Per-run: CCVM_CLIPBOARD_IMAGES
(only 0 is honored — disables image paste for the run). See
Image-paste bridge.
Egress control
egressAllowlist
FQDN / IP / CIDR egress allowlist. Empty = open egress (the default). Non-empty = a default-deny
firewall enforced inside the guest. api.anthropic.com is always allowed so Claude keeps
working. Default: []. Type: list of strings.
programs.ccvm.egressAllowlist = [ "github.com" "registry.npmjs.org" ];
Setting this auto-drops the agent’s sudo (agentSudo) and, under nix.enable,
removes the agent from Nix trusted-users — both load-bearing so a compromised agent can’t flush
the in-guest firewall. Allowlisted FQDNs are pinned at launch to the IPs they resolve to. See
Egress control for the full design and its residual channels.
Building ccvm itself (or anything whose Nix closure includes
claude-code) from inside an allowlisted VM also needsstorage.googleapis.comon the list — that’s where the unfreeclaude-codebinary is downloaded from. Just running ccvm doesn’t need it.
egressPorts
Destination ports the allowlist permits. Default: [ 443 ]. Type: list of ports.
agentSudo
Whether the in-VM agent gets passwordless root (sudo). null (default) = auto: on for DevEx
and --shell debugging, but automatically off when egressAllowlist is set (so a compromised
agent can’t flush the in-guest egress firewall). true/false force it — but forcing true
alongside an egressAllowlist re-opens the bypass, so only do that behind host-side egress control.
Default: null. Type: null or boolean. Build-time (rebuilds the guest closure).
Authentication & context
apiKeyVariable
The host environment variable carrying the Anthropic API key, passed to the VM only over SSH
(never on disk, argv, or kernel command line). Default: "ANTHROPIC_API_KEY". Type: string.
extraClaudeMd
Markdown staged as the guest’s ~/.claude/CLAUDE.md, telling the agent it’s running inside ccvm.
It is appended to any host-shared CLAUDE.md, never clobbering it. Default: a built-in blurb.
Type: lines ("" disables). Per-run: CCVM_CLAUDE_MD.
Escape hatches
extraPackages
Additional packages to install into the VM. Default: []. Type: list of strings (well, package
list). Build-time.
package
The claude-code package to run in the VM. Default: pkgs.claude-code (the community
nix-claude-code build). Type: package. Build-time.
extraGuestModules
Extra NixOS modules merged into the guest — a general escape hatch. Default: []. Type: list of
modules. Build-time.
lockGuestMemory
mlock guest RAM so in-VM secrets can’t be paged to host swap. Takes tinkering and isn’t
recommended for most people — QEMU refuses to start unless you raise the host’s RLIMIT_MEMLOCK
(ulimit -l, systemd LimitMEMLOCK, or limits.conf). Only worth it if (a) your host swap is
unencrypted (the one case it actually buys something) or (b) you’re willing to do that host
setup. Default: false. Type: boolean. Per-run: CCVM_MLOCK.
Per-run overrides
A CCVM_* environment variable overrides the baked-in default for a single run; an explicit ccvm
flag wins over the env var.
| Option | Env var | Flag |
|---|---|---|
writableCwd | CCVM_WRITABLE_CWD | --writable-cwd / --read-only-cwd |
acceleration | CCVM_ACCEL | — |
memory | CCVM_MEMORY | — |
share.settings | CCVM_SHARE_SETTINGS | — |
share.claudeMd | CCVM_SHARE_CLAUDEMD | — |
share.keybindings | CCVM_SHARE_KEYBINDINGS | — |
share.commands | CCVM_SHARE_COMMANDS | — |
share.agents | CCVM_SHARE_AGENTS | — |
share.skills | CCVM_SHARE_SKILLS | — |
share.outputStyles | CCVM_SHARE_OUTPUTSTYLES | — |
share.plugins | CCVM_SHARE_PLUGINS | — |
share.config | CCVM_SHARE_CONFIG | — |
all claude share.* at once | CCVM_SHARE_CLAUDE_CONFIG (0/1) | — |
share.gitConfig | CCVM_SHARE_GIT_CONFIG | — |
persistClaudeProjects | CCVM_PERSIST_PROJECTS | — |
clipboard.images | CCVM_CLIPBOARD_IMAGES (only 0 honored) | — |
extraClaudeMd | CCVM_CLAUDE_MD | — |
lockGuestMemory | CCVM_MLOCK | — |
vmDiskSize | CCVM_VM_DISK_SIZE | — |
ccvm-only flags (consumed by the wrapper, never forwarded to claude): --writable-cwd,
--read-only-cwd, --shell (debug shell), --ccvm-debug (stream console), --ccvm-help,
--ccvm-version. All other arguments pass through to claude unchanged — so bare --help and
--version still reach claude.
Threat model & scope
These are the whole point of the project. Treat any change that weakens one of the security invariants as a bug.
Scope of the boundary
The trust boundary is QEMU: we assume its device/Virtio isolation holds, and defend the host filesystem and the user’s credentials against a (possibly prompt-injected) agent.
Explicitly out of scope:
- Defending the host against a malicious guest kernel.
- Being a general-purpose VM manager — ccvm builds exactly one guest and boots it one way.
The VM being the boundary is what makes --dangerously-skip-permissions safe to opt into.
writableCwd = false adds a file-level safety net on top.
Default posture — what the defaults do and don’t stop
The invariants stop the host from being persisted to or having its credentials written to disk — they do not sandbox what a prompt-injected agent can read and send.
Under the native-mirroring defaults (open egress + share.* on), the in-VM agent can read the whole
project tree and exfiltrate it over open egress (with clipboard.images on, also the host
clipboard image — never text; see Image-paste bridge).
What it can no longer read is the host’s OAuth login: the share.* allowlist stages only
settings/commands/memory, and .credentials.json is excluded by construction — claude starts
unauthenticated and the user’s own in-VM /login or API key authenticates it. The host’s stored
credential never crosses; but once authenticated in-VM, the resulting token lives in the ephemeral
tmpfs ~/.claude and is readable by the in-VM agent (it has to be), so under open egress it is
exfiltratable — same class as the project tree.
The out-of-the-box win is containment (no host access beyond CWD, nothing persists) plus the host login never auto-crossing — not project-exfiltration resistance, a deliberate DevEx choice.
The primary hardening knob
The primary hardening knob is egressAllowlist (default-deny egress), enforced
inside the guest — so it only binds a non-root agent.
Setting egressAllowlist also auto-drops the agent’s sudo and, under nix.enable, removes the
agent from Nix trusted-users — both load-bearing: a Nix trusted-user is root-equivalent
(post-build-hook runs as root) and would otherwise regain root to nft flush the rules
(audit S-1; fixed).
To disable all claude config sharing: set all share.* to false, or CCVM_SHARE_CLAUDE_CONFIG=0.
Keep this distinction accurate — understating it turns a sandbox into a liability.
Reporting a vulnerability
Security reports go through GitHub’s private vulnerability reporting for this repository (see
SECURITY.md in the repo root). Please don’t open a public issue for anything that could be a
boundary break before it’s triaged.
Security invariants — MUST NOT regress
These are the whole point of the project. Treat any change that weakens one as a bug. They stop the host from being persisted to or having its credentials written to disk. (For what they deliberately do not stop, see Threat model & scope.)
API key never touches disk / argv / kernel-cmdline
The API key travels only over the SSH channel via SendEnv→AcceptEnv. Use SendEnv, never
SetEnv (SetEnv puts it on the remote command line).
Host key is pinned
Ephemeral ed25519 keys per run, StrictHostKeyChecking=yes. Never disable host-key checking to
“make it work.”
The share.* allowlist excludes the OAuth credential — airtight by construction
The share.* items are the ONLY things staged from ~/.claude into the seed. The credential
(.credentials.json) is not a share.* item and is therefore never copied into the seed —
exclusion is by omission, not by filter. Two defenses reinforce this:
- The per-item
cp -aLonly copies the listed paths, so the credential is never touched. - A defense-in-depth
find $SEED/claude-config -name .credentials.json -deletestrips any nested one that a directorycpmight drag in.
The guest lays staged items into a fresh tmpfs ~/.claude at boot. Claude starts
unauthenticated; the user’s /login or API key authenticates it ephemerally. This also avoids OAuth
refresh-token rotation: the rotated in-VM token dies with the tmpfs, leaving the host’s stored token
valid.
Verify: grep -rl '\.credentials\.json' "$SEED" → zero hits.
persistClaudeProjects (opt-in) does not change this: it mounts only ~/.claude/projects
read-write; the credential lives at the ~/.claude root. Never widen the writable mount to all of
~/.claude.
~/.claude.json is staged SANITIZED — its known secret-bearing keys are stripped
The home-root ~/.claude.json (distinct from ~/.claude/) can carry MCP server configs with inline
secrets. When share.settings is on, the wrapper stages it through jq, dropping
mcpServers[].env, mcpServers[].headers, and the legacy primaryApiKey; the non-secret structure
(including server definitions) is kept.
Secure-fail: if jq is missing or the file is invalid JSON, nothing is staged — hence jq is a
wrapper runtimeInput.
Verify: a fixture with an MCP env token + auth header + primaryApiKey → grep
seed/claude-json for the secrets → zero hits, with userID + server name still present
(tests/host.sh §1b). If a new secret-bearing key appears, add it to the jq del(...).
share.gitConfig stages only sanitized, non-secret git config
The wrapper resolves the global git config host-side and writes seed/gitconfig only after:
- dropping every value containing
/nix/store/, - dropping all
credential.*entries, - force-disabling commit/tag signing, and
- staging
core.excludesfileby content.
Keep all four guards. Verify by grepping the seed for any /nix/store path or credential key
— expect zero hits.
No persistent disk
Root is tmpfs; the store is a read-only image. Nothing the agent does survives exit except
host-project edits while writableCwd = true — and, when the opt-in persistClaudeProjects is on,
writes under ~/.claude/projects (session transcripts + memory). Both are deliberate,
narrowly-scoped write-throughs, not a general persistent disk.
The opt-in vmDiskSize disk pool is not an exception: it is an ephemeral disk, wiped on exit.
/home and root deliberately stay tmpfs, so secrets never go on the disk.
vmDiskSize pool: the LUKS key is guest-only, the disk is wiped on exit
The host attaches a sparse image but never the key — the guest generates it from /dev/urandom
in its own RAM and luksFormats fresh every boot, so the host only ever sees ciphertext (verify: no
key file in the seed; the wrapper writes only the vm-disk marker, never the key). Wipe-on-exit is
cryptographic (key dies with guest RAM), with the trap rm as belt-and-suspenders.
The host image MUST live in a disk-backed dir, never tmpfs/$TMP (that would put the “disk” back in
RAM) — the wrapper refuses a tmpfs target unless CCVM_SCRATCH_ALLOW_TMPFS=1. The pool backs only
bulk, non-secret data — /scratch and (with nix.enable) the writable /nix/store overlay
upper, mounted in the initrd by a fail-open LUKS oneshot (key still guest-only). Keep
/home/secrets in tmpfs. Never stage the key through the seed. See
Encrypted disk.
writableCwd = false means genuinely read-only
The host tree is the 9p lower; edits land in a tmpfs upper and must not reach the host.
Only the CWD is shared
No ~/.ssh, ~/.aws, or home dir crosses the boundary.
QEMU is sandboxed; ccvm never runs as host root; 9p shares are nosuid,nodev
QEMU launches with -sandbox on,obsolete=deny,elevateprivileges=deny,spawn=deny,resourcecontrol=deny
so a device-emulation / 9p / slirp escape hits a seccomp wall (CCVM_QEMU_SANDBOX=0 is the escape
hatch).
The wrapper refuses host uid 0 — 9p security_model=none passthrough as root would let the guest
create root-owned/setuid files on the host workspace (CCVM_ALLOW_ROOT=1 overrides).
Every 9p share is mounted nosuid,nodev (deliberately not noexec — the workspace needs to run
project binaries/build scripts). Don’t regress any of the three.
Guest kernel/userspace hardening
In-guest defense-in-depth against an agent probing the kernel (guest/default.nix):
security.protectKernelImage— no kexec/hibernation. (NotlockKernelModules, which would break the seed service’s runtimemodprobeofnf_tables/dm_crypt.)- sysctls —
kptr_restrict=2,dmesg_restrict=1,unprivileged_bpf_disabled=1,bpf_jit_harden=2,rp_filter=1. sudo-rs— memory-safe Rust sudo (execWheelOnly) in place of classic C sudo, still gated onagentSudo.- an explicit
root.hashedPassword = "!", and - a pinned
nix.settings.allowed-users = [ "root" "ccvm" ].
Not done: lockKernelModules (incompatible with runtime modprobe) and disabling unprivileged user
namespaces (the nix build sandbox needs them; namespaced-root can’t reach the init-netns firewall —
audit-verified, not a containment hole).
Egress control
egressAllowlist is ccvm’s primary hardening knob. Empty (the default) = open egress, just like
native claude. Non-empty = a default-deny firewall: only the FQDNs / IPs / CIDRs you list (plus
api.anthropic.com, always allowed) can be reached.
programs.ccvm.egressAllowlist = [ "github.com" "registry.npmjs.org" ];
An allowlist, not Tor
Tor solves anonymity — which is orthogonal here: the API authenticates you by credential regardless, and Tor adds latency and hits exit blocking. Users who want anonymity run Tor on the host and the guest rides it. Egress control belongs in ccvm; anonymization belongs on the host.
How it’s enforced
The firewall is installed inside the guest by a root systemd unit (not the agent), using
nftables. Allowlisted FQDNs are pre-resolved host-side at launch into a name→IP map
(egress-hosts), written into the guest’s /etc/hosts, so the agent resolves each FQDN to exactly
the IP the firewall allows. DNS is pinned to the slirp stub resolver.
The load-bearing caveat: enforcement lives in the guest
Because enforcement lives in the guest, it only binds a non-root agent — a root agent could
nft flush it. That’s why setting egressAllowlist:
- auto-drops
agentSudo, and - under
nix.enable, drops the agent from Nixtrusted-users.
A Nix trusted-user is root-equivalent (a post-build-hook runs as root); audit S-1 demonstrated the
bypass end-to-end, now closed. Together these raise the bar from one command to a guest-kernel
exploit. A non-trusted agent can still nix build / nix develop (builds run as the nixbld
users).
Forcing
agentSudo = truealongside anegressAllowlistre-opens thenft flushbypass (and re-grants trusted-user), so it’s only sensible behind host-side egress control.
The three residual channels
The IP-filter MVP has three residual channels — known and accepted, not bugs:
- FQDN staleness. The kernel sees IPs, not names. ccvm pins each allowlisted FQDN to the IPs it
resolved to at launch, in both the firewall and the guest resolver. Residual: a host that rotates
every pinned IP away mid-session breaks — restart, or pin a CIDR for round-robin hosts. (GitHub
publishes its ranges at
api.github.com/meta.) - DNS tunneling. DNS is pinned to the slirp stub resolver, blocking DNS-to-anywhere, but low-bandwidth tunneling through the recursive resolver remains.
- TCP-only. QUIC / UDP 443 is dropped; clients fall back to TCP.
Building ccvm from inside a hardened VM
Any build that re-realizes the guest closure must fetch the unfree claude-code, whose
fixed-output derivation downloads from storage.googleapis.com (deliberately never on a binary
cache). That host is not in a typical allowlist, so from inside a hardened ccvm such a build
hangs, then fails with cannot download claude from any mirror — the firewall doing its job, not a
bug. Add storage.googleapis.com to the allowlist when you need to rebuild ccvm in-VM.
Why not complete host-side enforcement (yet)
The complete fix is host-side egress enforcement: put the allowlist nft in a namespace the
guest can’t reach, with a filtered uplink via pasta / slirp4netns. The uplink + filtering half
is prototyped and works — but integrating it hit a hard uid/caps/9p trilemma. Three constraints
can’t all hold in a plain unprivileged user namespace:
- nft needs
CAP_NET_ADMINinside the namespace; - 9p
security_model=noneneeds QEMU’s effective host uid to be the real user, and the guest agent’s uid to match; - caps don’t survive
execvefor a non-root uid.
The consequences:
--map-current-user(uid preserved → 9p OK) losesCAP_NET_ADMINatexecve— verified:nftfails with “Operation not permitted.”--map-rootkeeps caps but maps to uid 0, and claude hard-refuses--dangerously-skip-permissionswhen euid == 0 — so that path is ruled out.--runascan’t bridge it.
The only way out is to use host /etc/subuid + newuidmap to map a uid range (holding both uid 0
for nft AND the real uid for QEMU/9p). Clean and correct, but it requires host setup (against
ccvm’s zero-setup principle) and a delicate boot-path rework needing a human --shell pass.
Net: agentSudo is the shipped interim — it raises exfil from one command to a guest-kernel
exploit; the host-side fix would raise it to a full QEMU escape, a marginal gain for real setup
cost, so it stays opt-in / future. Don’t re-attempt map-root.
Related: slirp host-loopback
An empty allowlist (open egress) also leaves the host’s loopback reachable from inside the VM via
slirp’s 10.0.2.2 gateway. An egressAllowlist closes that too (10.0.2.2 isn’t in the set). See
Slirp host-loopback reachability.
Encrypted disk & wipe-on-exit
The opt-in vmDiskSize (GiB) attaches a sparse disk image to back bulk, non-secret data:
/scratch, and — under nix.enable — the writable /nix/store overlay upper. vmDiskSize = 0
(the default) keeps the VM pure-RAM.
The LUKS key is guest-only
The host attaches the sparse image but never the key. The guest generates the key from
/dev/urandom in its own RAM and luksFormats the disk fresh every boot, so the host only ever
sees ciphertext. (Verify: no key file in the seed; the wrapper writes only the vm-disk marker,
never the key.)
The disk is mounted in the initrd by a fail-open LUKS oneshot — if it can’t set up, the system falls back to a tmpfs upper rather than failing to boot.
Wipe-on-exit is cryptographic
The key dies with guest RAM at power-off, so the image is inert ciphertext the instant QEMU stops —
trap or no trap. The cleanup trap’s rm is belt-and-suspenders; the guarantee rests on the key
being gone.
This is why an encrypted disk rather than a plain ephemeral one: wipe-on-exit must survive a crash that skips the cleanup trap, and on modern storage plain deletion ≠ erasure (async SSD TRIM, CoW snapshots retain freed blocks). With full-disk encryption, the image is unrecoverable the moment the key is gone, regardless of how the process ended.
Why one encrypted pool, not a second /nix/store disk
Once the disk is encrypted with a guest-RAM key, disk-vs-tmpfs makes no confidentiality difference
to an in-guest attacker (it can read tmpfs or decrypt the disk equally). The right split is bulk on
the encrypted disk, secrets in tmpfs — by placement, not a second disk. A second disk only
earns its keep for a different lifecycle (a persistent content-addressed store cache) — a separate
future feature, deliberately not folded into vmDiskSize.
Where the image must live
The host image MUST live in a disk-backed directory, never tmpfs / $TMP — that would put the
“disk” back in RAM and defeat the point. The wrapper refuses a tmpfs target unless
CCVM_SCRATCH_ALLOW_TMPFS=1.
/home and root deliberately stay tmpfs, so secrets never go on the disk. Never stage the LUKS key
through the seed.
Cost
vmDiskSize > 0 adds ~4–5s to boot — the encrypted disk’s device-settle plus the per-boot
luksFormat. This cost is inherent to the wipe-on-exit guarantee (a fresh luksFormat every
boot, by design), not a regression. The pure-RAM default boots faster.
Measured baselines under KVM (8 vCPU / 8 GiB): a full boot is ~7.3s (≈277ms kernel + 3.9s initrd +
3.1s userspace); systemd-analyze blame’s top units are the disk device settling at ~4.6s. A
running session sits around ~0.7–0.8 GiB RAM because the squashfs store and writable-store overlay
upper live on the encrypted disk.
Image-paste bridge
clipboard.images (default-on) makes Ctrl+V image paste work inside the VM, like native
claude.
The problem
Claude Code reads pasted images by shelling out to xclip / wl-paste. The guest has no
X/Wayland, so without a bridge Ctrl+V image paste silently no-ops.
How the bridge works
ccvm restores image paste over the existing SSH connection — no new network hole:
- Guest shims. Fake
xclip/wl-paste(guest/default.nix, gated oncfg.clipboard.images) connect tocfg.clipboard.port(9180) on guest loopback, send a one-word request (TARGETS/image/png/image/bmp), and stream the reply to stdout. - Reverse tunnel. The wrapper’s
ssh -ttadds-R 127.0.0.1:9180:127.0.0.1:<hostport>. sshd isAllowTcpForwarding = "remote"pinned byPermitListen 127.0.0.1:9180— exactly one reverse forward to one loopback port; no local/dynamic forwarding, and forwarding is client-requested so the agent can’t set up its own. - Host server. A
socatlistener (a wrapperruntimeInput) starts before connecting. Per request it runs the host’swl-paste/xclipfor image targets only. Thecasearms are literal MIME types — a guest request can’t widen totext/*or inject a command.
Why this doesn’t weaken the boundary
The bridge rides loopback + the established SSH connection (oifname lo accept +
ct state established), punching zero holes in the egress firewall — a prompt-injected agent
can’t repurpose the one pinned forward.
It is image-only, enforced host-side — the server never reads text/plain, and the shims never
write the host clipboard — so host clipboard text (where passwords/tokens live) never
crosses. This makes the bridge strictly less exposure than native claude.
Honest residual
Under open egress, a prompt-injected agent can pull any clipboard image at any time (pull-on-demand, not just on user paste) and exfiltrate it — same class as the project tree. Under hardened egress it can read the image but can’t send it off-box.
The bridge is inert when the host has no wl-paste / xclip. CCVM_CLIPBOARD_IMAGES=0 only
disables the wrapper-side wiring (it can’t conjure missing guest shims / the sshd rule).
The image-only guarantee is regression-tested against the real reader extracted from the wrapper
(tests/clipboard.sh, the clipboard flake check) — no VM needed. macOS host support is future.
Slirp host-loopback reachability
ccvm uses QEMU’s built-in slirp user-mode networking (see Design decisions → QEMU + slirp). A property of slirp worth knowing about:
The guest can reach the host’s loopback via 10.0.2.2
slirp maps its gateway 10.0.2.2 to the host’s 127.0.0.1. Verified: from the guest,
10.0.2.2:22 answers with the host’s own sshd.
So under open egress (the default), any host service bound to 127.0.0.1 is reachable from inside
the VM — local databases, unauthenticated dashboards, model servers (e.g. Ollama on 11434),
cloud metadata/credential proxies, a second ccvm. Many of these are unauthenticated precisely
because they assume only host-local processes reach them.
This is network reach only, not a host-write path — the filesystem boundary still holds. It matters most when ccvm runs on a host with sensitive loopback-bound services.
Closing it
An egressAllowlist closes it: 10.0.2.2 isn’t in the set, so the default-deny
firewall drops it along with everything else not allowlisted.
There is no slirp knob to keep internet access but drop only the host redirect (restrict=on
kills both). The only complete fix is the host-side namespace approach described under
Egress → host-side enforcement, which is
gated on host setup and remains future work.
Repo map
| Path | Role |
|---|---|
flake.nix | Outputs: packages.* (ccvm, guest artifacts, docs), checks.*, homeModules.ccvm. |
lib/mkccvm.nix | The builder. Evaluates the guest NixOS system, then bakes its boot artifacts + scalar config into the wrapper via builtins.replaceStrings @TOKENS@. |
lib/defaults.nix | Default values for the builder’s config (memory, cores, the package, etc.). |
lib/ccvm-context.md | The built-in extraClaudeMd blurb staged as the guest’s ~/.claude/CLAUDE.md. |
wrapper/ccvm.sh | Host wrapper template (the @TOKEN@ placeholders). Generates completely ephemeral SSH keys, writes the seed, boots QEMU headless, ssh -tts in, traps cleanup. |
guest/default.nix | The microVM NixOS guest (tmpfs root, ro squashfs /nix/store). |
guest/launcher.nix | Two units. ccvm-seed.service (root oneshot, Before=sshd) installs the pinned host key + authorized_keys and does every 9p/overlay mount. ccvm-guest-launch is the unprivileged sshd ForceCommand that just cds to the workspace and execs claude (or zsh). |
guest/sshd.nix | Hardened sshd: key-only, no root, single ForceCommand. |
modules/home-manager.nix | programs.ccvm.* options → installs the command. |
docs/ | This mdBook site (book.toml, src/). Built by packages.docs, gated by checks.docs. |
tests/ | host.sh (CI host-side guarantees via the CCVM_DRYRUN hook), boot.sh+stub-claude.sh+boot.nix (local full-boot smoke test), clipboard.sh (image-only bridge), egress.sh, default.nix (wires the checks into nix flake check). |
Build / test / debug
Building
- Build the wrapper:
nix build .#ccvm. - Iteration cost:
memory/coresare runtime QEMU args (cheap, no rebuild); changingpackage/extraPackages/nix.enable/ guest modules rebuilds the guest closure. - Buildable guest artifacts are honest packages:
nix build .#guest-store(ro squashfs store) or.#guest-toplevel(system closure). - Build the docs site:
nix build .#docs→ site in./result; openresult/index.htmlto eyeball it.
Iterate fast with a stub claude
The proven way to test file/config/arg behaviour without a real agent run: bake a shell script as
the package and assert on its stdout.
(import ./lib/mkccvm.nix { inherit pkgs; } {
package = pkgs.writeShellScriptBin "claude" ''
# print what the guest sees: mount type of $HOME/.claude, is settings.json
# readable, does it contain the expected model, is .credentials.json present, …
'';
}).wrapper
Boot it under tcg / q35, grep the output. This is exactly how share.* and writableCwd were
verified end-to-end — much faster than booting the real agent.
nix flake check
nix flake check should pass — and is warning-clean. It:
- builds the guest image (the ro squashfs store),
- shellchecks the wrapper (via
writeShellApplication), - builds the docs site (
checks.docs, which fails on dead internal links — see below), - runs
tests/host.sh(checks.<sys>.host) — host-side secret hygiene, config staging, verbatim argv, mode selection — against the real wrapper driven by itsCCVM_DRYRUNhook (no VM, no claude-code), - runs the
egressandclipboardchecks, and - enforces formatting (
checks.formatting: nixfmt + shfmt).
The home-manager module is exposed as
homeModules(the name stock Nix recognizes —homeManagerModuleswarns; don’t reintroduce it).
The docs check catches dead links
checks.docs builds the site with the mdbook-linkcheck2 backend
([output.linkcheck2] in docs/book.toml, warning-policy = "error"), so any broken internal
link breaks CI. mdBook’s plain mdbook build does not fail on bad internal links on its own — the
backend is what makes it strict. External links are not followed (the Nix sandbox has no network).
Introspecting guest config
The non-derivation bring-up handles (append, the evaluated guestSystem) are deliberately not
flake outputs; introspect them with a direct import — e.g. dump any guest config value:
nix eval --impure --expr 'let p = (builtins.getFlake (toString ./.)).inputs.nixpkgs.legacyPackages.x86_64-linux;
in (import ./lib/mkccvm.nix { pkgs = p; } { nix.enable = true; egressAllowlist = ["x"]; }).guestSystem.config.nix.settings.trusted-users'
Rebuilding the guest from inside a hardened-egress ccvm needs storage.googleapis.com
Any build that re-realizes the guest closure — nix flake check’s guest-image / wrapper checks,
tests/boot.nix, or nix build .#ccvm — must fetch the unfree claude-code, whose
fixed-output derivation downloads from storage.googleapis.com. That host is NOT in a typical
egressAllowlist, so from inside a hardened ccvm such a build hangs, then fails with
cannot download claude from any mirror — the egress firewall doing its job, not a bug. Add
storage.googleapis.com to the allowlist when you need to rebuild ccvm in-VM. The host-side checks
(checks.<sys>.{host,egress}) don’t pull claude-code and build fine under any egress posture.
Full-boot smoke test
bash tests/boot.sh (defaults to CCVM_ACCEL=tcg CCVM_MACHINE=q35) boots a stub-claude VM and
asserts argv-reaches-claude and overlay vs. rw file visibility.
Boot-testing without working KVM: force software emulation with CCVM_ACCEL=tcg CCVM_MACHINE=q35 ccvm (slow but correct).
Debug switches
CCVM_DEBUG=1/--ccvm-debugstreams the guest console and keeps the scratch dir.CCVM_SHELL=1/--shelldrops into a guest zsh instead of claude.
Terminal fidelity is human-verified, not automated (ccvm --shell, then resize / vim / less /
vi-mode). Don’t claim it works from code inspection alone.
Definition of done for a behaviour change
nix flake check green and a stub-package boot test asserting the new behaviour under tcg /
q35 — plus a human --shell pass if it touches the TTY.
aarch64-linux is best-effort
It evaluates and is wired up (qemu-system-aarch64, the virt machine, PL011 ttyAMA0 console),
but x86_64-linux is the primary, CI-built target.
Deliberate defaults — do not reverse
These defaults were chosen on purpose. Reversing one needs a new reason, not a rediscovery of the old trade-off.
Native mirroring is the default
writableCwd = true (live host edits), the share.* allowlist on (settings / claudeMd / commands /
agents / skills reuse host ~/.claude), and share.gitConfig = true (commit as you, with your
aliases/ignores) make ccvm behave like native claude. Isolation (read-only project, no config) is
the opt-in.
Do not re-propose “secure by default” — that was the original spec and was deliberately reversed. The out-of-the-box win is containment plus the host login never crossing, not project-exfiltration resistance, a deliberate DevEx choice.
RAM-only is the default; the disk pool and in-VM nix are opt-in
vmDiskSize = 0 (no disk, pure RAM) and nix.enable = false (read-only /nix/store, no in-VM nix,
lean closure) are the defaults.
The user-facing option is programs.ccvm.nix.enable; the internal config + guest module use the same
nested nix.enable name end to end. It is build-time — it flips nix.enable and rebuilds the
store as a writable overlay in the initrd. Its overlay upper is tmpfs by default; combine with
vmDiskSize > 0 and the initrd LUKS oneshot relocates that upper onto the encrypted disk (fail-open
to tmpfs), so a large nix develop doesn’t OOM guest RAM — one shared pool also backs /scratch.
The guest always boots off the self-contained squashfs store; the host store is never the guest’s boot store.
To give in-VM nix extra pre-built paths, point it at a binary cache via nix.substituters +
nix.trustedPublicKeys — pure guest-closure config, baked into the guest’s nix.conf (appended to
cache.nixos.org and nixpkgs’ keys), HTTP substitution at line rate, no host credentials.
require-sigs stays ON. A public-read signed cache works with zero secrets; a cache behind a
token/netrc is not yet supported.
Two predecessors were deliberately removed — do not re-add: mountHostNixStore (host store as
the guest’s boot store) and nix.useHostStoreAsCache (host /nix/store + DB over ro 9p as a local
substituter): 9p copy ran slower than downloading (<1 MiB/s vs. network), and it exposed the
entire host store to the agent. See Design decisions → in-VM nix.
agentSudo is auto, not a fixed default
The agent has passwordless root in the guest EXCEPT when egressAllowlist is set, where it
auto-drops so the in-guest egress firewall can’t be flushed (the firewall is installed by a root
systemd unit, not the agent).
Coupled to it (guest/default.nix): Nix trusted-users is gated on the SAME flag —
[ "root" ] ++ lib.optional cfg.agentSudo "ccvm" — so when sudo is off the agent is also
non-trusted. This is load-bearing under nix.enable: a Nix trusted-user is root-equivalent (a
post-build-hook runs as root), so leaving the agent trusted would hand straight back the root the
sudo-drop removes, letting it nft flush the firewall (audit S-1). A non-trusted agent can still
nix build / nix develop; builds run as the nixbld users.
null = auto; true / false force it — but forcing true alongside an egressAllowlist
re-opens the nft flush bypass (and re-grants trusted-user), so it’s only sensible behind host-side
egress control. Build-time (rebuilds the guest closure).
Guest kernel/userspace hardening is deliberate, cheap, and boot-safe
See Security invariants → guest hardening
for the full list: security.protectKernelImage, the sysctl set, sudo-rs, the explicit root
password lock, and the pinned allowed-users. Notably not done: lockKernelModules
(incompatible with the seed service’s runtime modprobe) and disabling unprivileged user namespaces
(the nix build sandbox needs them; namespaced-root can’t reach the init-netns firewall —
audit-verified, not a containment hole).
extraClaudeMd is default-on context, not a flag
A built-in blurb is staged as the guest’s ~/.claude/CLAUDE.md (via the seed, appended to any
host-shared one — never clobbering it) so the agent knows it’s in ccvm. It must stay seed-delivered,
never become --append-system-prompt (which would break transparent passthrough). The wrapper
prepends a runtime mode line (rw=live / overlay=discarded) that the build-time file can’t know.
Transparent passthrough
The wrapper injects no flags. Everything after ccvm is forwarded to claude verbatim,
including --dangerously-skip-permissions. The only args the wrapper consumes are its own:
--shell, --ccvm-debug, --writable-cwd, --read-only-cwd, --ccvm-help, --ccvm-version —
all deliberately ccvm-specific names (none is a claude flag), so bare --help / --version still
reach claude. Preserve that interception boundary.
Image paste — image-only reverse clipboard bridge (clipboard.images, default-on)
The full design and threat analysis live in Security → Image-paste bridge. In short: the bridge restores Ctrl+V image paste over the existing SSH connection, is image-only (host clipboard text never crosses), and opens no new network hole.
Design decisions
Settled decisions — don’t relitigate. These were considered and decided; reopening one needs a new reason, not a rediscovery of the old trade-off.
SSH transport, not the serial console — for PTY fidelity
A serial line is not a terminal: no SIGWINCH, no window size, no full termios, so resize breaks and
vim / less / full-screen TUIs corrupt. ssh -tt to a real sshd gives a genuine guest PTY that
propagates TERM, window size, SIGWINCH on every resize, and termios end-to-end — so the VM is
invisible. -tt forces PTY allocation even when the wrapper’s own stdin isn’t a tty. The wrapper
runs ssh in the foreground but never execs it, so it regains control to tear the VM down.
QEMU + slirp, not firecracker / cloud-hypervisor
We need outbound HTTPS with zero host setup — no bridges, TAP devices, or sudo. QEMU’s
built-in slirp gives unprivileged user-mode NAT (guest 10.0.2.x, synthesised DNS+DHCP) plus
hostfwd for the inbound SSH port. The lighter VMMs boot faster but require host networking setup,
breaking “works on a stock box as a normal user.” Boot speed matters; running unprivileged matters
more.
A consequence to know about: the guest can reach the host’s loopback via slirp’s 10.0.2.2 gateway.
See Slirp host-loopback reachability.
Egress: an allowlist, not Tor
Tor solves anonymity (orthogonal — the API authenticates you by credential regardless; Tor adds latency and hits exit blocking; users wanting anonymity run it on the host and the guest rides it). Egress control belongs in ccvm; anonymization on the host. The full allowlist design, the three residual channels, and the host-side-enforcement trilemma live in Egress control.
Encrypted disk, not a plain ephemeral one
Wipe-on-exit must survive a crash that skips the cleanup trap, and on modern storage plain deletion ≠
erasure (async SSD TRIM, CoW snapshots retain freed blocks). With FDE the key dies with guest RAM at
power-off, so the image is inert ciphertext the instant QEMU stops — trap or no trap. The trap rm
is belt-and-suspenders; the guarantee rests on the key being gone. See
Encrypted disk.
One encrypted pool, not a second /nix/store disk
Once the disk is encrypted with a guest-RAM key, disk-vs-tmpfs makes no confidentiality difference to
an in-guest attacker (it can read tmpfs or decrypt the disk equally). The right split is bulk on the
encrypted disk, secrets in tmpfs — by placement, not a second disk. A second disk only earns its
keep for a different lifecycle (persistent content-addressed store cache) — a separate future
feature, deliberately not folded into vmDiskSize.
9p for the shares, not virtiofs (and the large-tree edge case)
The workspace / seed / config / projects shares ride virtio-9p — zero host daemon, unprivileged,
zero setup. 9p with cache=none is latency-bound on metadata: each stat / open / readdir is
a host round-trip, so a cold whole-tree walk is sluggish while the agent’s normal localized loop
feels native.
Calibration: systemd-scale (~4k files) is a non-issue; the Linux kernel (~85k files, ~1.5 GB) is
the usable ceiling — past that (100k+ tiny files) it crawls, but that isn’t ccvm’s user. The
real-world worst case is a huge gitignored dir (node_modules / .venv / target): rg / fd
skip gitignored paths; bulk build output belongs on /scratch.
virtiofs is a deliberate non-goal pre-1.0: it needs a per-share virtiofsd daemon +
shared-memory guest backend (reworking core QEMU -m args, the cleanup trap, and the
uid-remap/security_model=none path) — a multi-day change that reopens every share’s security
verification, for a problem the audience rarely hits. Cheap lever: bump 9p msize and add a
mode-aware cache (cache=loose / mmap is fine for the ro overlay lower / config / seed, but risks
stale reads on the live rw workspace). Don’t reach for virtiofs without that benchmark first.
Nix in the VM
nix.enable is opt-in and build-time. When on, the store is rebuilt as a writable overlay in the
initrd; its upper is tmpfs by default and relocates to the encrypted disk when vmDiskSize > 0.
To give in-VM nix extra pre-built paths, point it at a binary cache via nix.substituters +
nix.trustedPublicKeys. A public-read signed cache works with zero secrets; a cache behind a
token/netrc is not yet supported. require-sigs stays on.
Two predecessors were deliberately removed — do not re-add: mountHostNixStore (host store as
the guest’s boot store) and nix.useHostStoreAsCache (host /nix/store + DB over ro 9p as a local
substituter). 9p copy ran slower than downloading (<1 MiB/s vs. network), and it exposed the
entire host store to the agent. The guest always boots off the self-contained squashfs store; the
host store is never the guest’s boot store.
claude-code comes from a community flake; nixpkgs is pinned stable
nixpkgs is pinned to the stable release (nixos-26.05), not nixos-unstable. The old reason for
unstable — tracking a fast-moving claude-code — no longer applies: claude-code now comes from the
community github:ryoppippi/nix-claude-code flake (input claude-code, follows our nixpkgs).
Its overlays.default sets pkgs.claude-code, so every existing pkgs.claude-code reference
(lib/defaults.nix, guest/default.nix) transparently picks up the community build, kept current
independent of the nixpkgs channel.
It only reaches a home-manager consumer because modules/home-manager.nix closes over the
claude-code input and applies the overlay to the consumer’s own pkgs (pkgs.extend) — a
downstream flake has no view of our inputs otherwise; keep that wiring.
The package is unfree, so a consuming config must allow it: the README home-manager example’s
allowUnfreePredicate does this, and the entry that works in a real build is claude-code
(verified on a real home-manager activation — claude alone is rejected; do not “simplify” it to
claude). ccvm’s own standalone pkgsFor sets a blanket allowUnfree = true, so nix run / the
flake needs nothing extra. Its FOD downloads the prebuilt binary from storage.googleapis.com (see
the hardened-egress note).
No published binary cache (first-run stays a local build)
Most of the guest closure substitutes from cache.nixos.org, so first-run is bounded — mostly
download + the ccvm-specific squashfs/toplevel build (~minutes). The unfree claude-code path is
not on cache.nixos.org, and re-serving it from a public cache is a redistribution problem. Net:
bounded win, licensing headache. Don’t re-propose a public cache without a new reason; if first-run
ever hurts, shrink the closure.
Documentation: mdBook, not a JS SSG
The docs site is built with mdBook (Rust, via pkgs.mdbook in the flake) — no Node / Bun / npm.
ccvm stays 100% Nix and the site is byte-reproducible from a commit (pinned via flake.lock). A JS
SSG would reintroduce a lockfile/runtime into a pure-Nix security repo. The docs build is a flake check (checks.docs) so the site can never silently break, and dead internal links fail the build
via the mdbook-linkcheck2 backend.
Gotchas
Things that are expensive to rediscover.
9p preserves symlinks verbatim
A host symlink pointing outside the exported tree (home-manager links
~/.claude/settings.json → /nix/store/…) dangles in the guest. Fix already in place:
dereference such links host-side into the seed, then lay the real files over the config overlay’s
tmpfs upper (shadowing the dead lower symlink).
Overlay copy-up hazard
Never chown -R an overlay root — it copies every lower file up into the tmpfs upper. Chown only
the specific files you staged.
microvm vs q35 use different virtio transports
virtio-mmio / BUS=device vs virtio-pci / BUS=pci. The wrapper derives BUS from the machine
type; keep new -device args going through it.
ssh -tt adds a PTY
So guest stdout gets \r and escape sequences. When grepping captured guest output, use grep -a
and tr -d '\r' or matches silently fail.
Ctrl+Z freezes the session — and there is NO guest-side fix
claude is exec’d as the sshd ForceCommand with NO job-control shell behind it, so a stopped
claude has nothing to fg it: a hard lockout (the VM has no second tty; the only escape, dropping
the SSH connection, tears down the whole VM).
The cause is upstream Claude Code: it reads Ctrl+Z in raw mode and raises a stop signal on
itself — specifically SIGSTOP (the Windows port crashes with “Unknown signal: SIGSTOP”).
SIGSTOP is uncatchable and unignorable, so guest-side mitigation does not work — stty susp undef (terminal SUSP) and trap "" TSTP (ignore SIGTSTP) were both tried and had zero effect
(removed; don’t re-add — they only made tests/boot.sh pass while the bug persisted).
Ctrl+Z is also not a rebindable keybinding (the keybindings doc lists it under “Terminal
conflicts”, not as an action), so ~/.claude/keybindings.json cannot disable it. Same brick as
claudecode.nvim#194; related claude-code#3586 / #12483.
The only ccvm-side fix would be an external supervisor that auto-SIGCONTs claude whenever it
stops (an outside process can continue a SIGSTOP’d child) — deliberately NOT pursued: a C
helper plus job-control/foreground-pgrp handling is too much machinery for an upstream bug Anthropic
may yet make configurable. Documented instead as a user caveat (see
Getting started → the Ctrl+Z caveat). Don’t disable
-ixon / Ctrl+S either — Ctrl+S is a claude keybinding (stash), not a freeze.
The guest interactive shell is zsh, which has no /dev/tcp
Any in-guest TCP-connect probe (egress checks against the allowlist, the clipboard-bridge
127.0.0.1:9180 reader) relies on bash’s /dev/tcp pseudo-device; under the guest’s zsh it fails
with no such file or directory and falsely reads as BLOCKED/dead. Wrap such probes in bash -c.
Test artifact only — the real clipboard shims are bash scripts (writeShellScriptBin), so they hit
/dev/tcp fine.
9p msize is negotiated DOWN
guest/launcher.nix requests msize=1048576 (1 MiB), but QEMU’s virtio-9p caps the effective
value (≈512000 in practice — grep msize /proc/mounts). Harmless, but don’t trust the requested
number when reasoning about 9p throughput.
vmDiskSize adds ~4–5s to boot
The encrypted disk’s device-settle plus the per-boot luksFormat. Measured baselines under KVM
(8 vCPU / 8 GiB): a full boot is ~7.3s (≈277ms kernel + 3.9s initrd + 3.1s userspace);
systemd-analyze blame’s top units are the vdb / virtio-ccvm-scratch device settling at ~4.6s.
The pure-RAM default boots faster; this cost is inherent to the wipe-on-exit guarantee, not a
regression. Other measured references: warm 9p is a non-issue (768-file repo walk ≈70ms,
git status ≈100ms); a running session sits around ~0.7–0.8 GiB RAM. See
Encrypted disk.
Nix '' string escaping
The wrapper + guest scripts are inside ''…'': a literal bash ${var} is written ''${var};
$(...) and bare $var pass through literally.
The parallel @TOKEN@ lists
Config flows through @TOKENS@ baked at build time in mkccvm.nix. Adding a new @TOKEN@ means
updating BOTH the bake in mkccvm.nix AND the stand-in token list in tests/default.nix (the host
test bakes the wrapper itself, with fixture values) — forget the latter and the token stays literal,
which tests/host.sh catches as a failure. See Conventions → @TOKENS@ flow.
Forwarded argv is NUL-separated
On the wire (claude-args in the seed, read with mapfile -d ""); spaces/quotes/globs survive
intact. Never rebuild the argv by string-splitting.
writeShellApplication shellchecks at build
wrapper/ccvm.sh is built via writeShellApplication, so shellcheck runs at build — keep it
clean (and the set -euo pipefail it injects in mind).
Conventions
Commit automatically once all checks pass when working through a task list
When working a multi-step task, run the relevant checks (bash -n, the host.sh dry-run recipe,
and — on a Nix+KVM box — nix flake check / bash tests/boot.sh / a --shell pass for TTY
changes); if they’re green, commit without stopping to ask per item. Still surface anything that can
only be verified on the Nix+KVM box so it gets checked there before being claimed done.
Don’t touch README.md without an explicit go-ahead
The user owns the README. Propose changes (even a one-word fix to a dangling reference) and wait for an explicit OK before editing it — unlike the rest of the tree, it is not auto-fixable under the commit-on-green rule.
Audience split — write to the right altitude
- The README is newcomer-facing: approachable, for people new to ccvm and non-technical evaluators.
- This docs site (Security / Developing) is for the technical reader — security nuts, contributors — and is where the deep detail, threat-model nuance, edge cases, and settled-decision rationale live.
- CLAUDE.md is terse operational rules + a routing directive that points here.
When something surfaces, put the friendly version (if any) in the README, the real depth in this site, and a one-line operational rule (if any) in CLAUDE.md. Don’t duplicate depth across all three.
Commit trailer (exact)
Co-authored-by: Claude <noreply@anthropic.com> — lowercase authored-by, bare Claude, no model
name. This intentionally differs from the Claude Code CLI default; use this form.
Config flows through @TOKENS@
Scalars are baked at build time in mkccvm.nix (@MODE@ = rw/overlay,
@SHARE_SETTINGS@ / @SHARE_CLAUDEMD@ / @SHARE_KEYBINDINGS@ / @SHARE_COMMANDS@ /
@SHARE_AGENTS@ / @SHARE_SKILLS@ / @SHARE_OUTPUTSTYLES@ / @SHARE_PLUGINS@ / @SHARE_CONFIG@ =
1/0, etc.).
Values only known at launch — the workspace 9p share and the SSH port — are not baked; the wrapper builds those QEMU args at runtime (the microvm.nix “runtime-share trap”).
Adding a new @TOKEN@ means updating BOTH the bake in mkccvm.nix AND the stand-in token list in
tests/default.nix (the host test bakes the wrapper itself, with fixture values) — forget the
latter and the token stays literal, which tests/host.sh catches as a failure.
Runtime override pattern
A CCVM_* env var overrides the baked default for one run (CCVM_WRITABLE_CWD,
CCVM_SHARE_SETTINGS, CCVM_SHARE_CLAUDEMD, CCVM_SHARE_KEYBINDINGS, CCVM_SHARE_COMMANDS,
CCVM_SHARE_AGENTS, CCVM_SHARE_SKILLS, CCVM_SHARE_OUTPUTSTYLES, CCVM_SHARE_PLUGINS,
CCVM_SHARE_CONFIG, CCVM_MLOCK, CCVM_ACCEL); an explicit ccvm flag wins over the env var.
Back-compat: CCVM_SHARE_CLAUDE_CONFIG=0|1 toggles all claude items at once; per-item vars win over
it.
acceleration is a declarative mode, baked as @ACCELERATION@
auto (default) uses KVM when /dev/kvm is usable else falls back to TCG, using -cpu max (not
host) so QEMU’s own -accel kvm:tcg runtime fallback stays valid. kvm requires KVM: hard-errors
with an actionable reason (missing device / not in kvm group / not writable) and uses -accel kvm
(no fallback) + -cpu host. tcg forces emulation. Per-run: CCVM_ACCEL. The boot-wait budget is
generous for anything that might run emulated. The KVM-usability probe only checks the device is
writable (can’t detect a present-but-broken KVM) — a real KVM_CREATE_VM failure surfaces as QEMU’s
error (kvm mode) or a silent TCG fallback (auto). Tests drive modes via CCVM_KVM_DEV to
simulate /dev/kvm states portably.
Formatting
nix fmt (treefmt — nixfmt for Nix, shfmt for the shell scripts). CI enforces it
(checks.formatting), so an unformatted tree fails nix flake check. Markdown is excluded from
treefmt (auto-reflow would wreck CLAUDE.md’s char budget and the README), and book.toml is TOML,
also unformatted. statix / deadnix stay green too.
Contributing
Thanks for helping out. ccvm is a small, security-focused project, so a few house rules keep it that way.
Read this first
This site’s Security and Developing sections are the authoritative engineering docs: the security invariants that must not regress, the rationale behind the settled design decisions (and which ones not to relitigate), and the gotchas that cost time to rediscover. Read the relevant pages before changing the guest, the wrapper, or the boot path.
CLAUDE.md in the repo root is the terse operational version — the must-not-regress checklist plus a
routing directive into this site. The README is
deliberately newcomer-facing — keep deep/technical detail here, not in the README.
Definition of done
A behaviour change is done when:
nix flake checkis green. It builds the guest image, shellchecks the wrapper, builds the docs (failing on dead internal links), and runs the host-side guarantee tests (secret hygiene, config staging, verbatim argv, mode selection). CI runs this on every PR.bash tests/boot.shpasses on a Nix+KVM box — if you touched the guest, wrapper, or boot path. It boots a stub-claudeVM and asserts the things that need a real guest (argv reaches claude, rw vs. overlay file visibility, egress, the encrypted disk). This is not in CI yet (it needs a KVM runner), so paste theN passed, M failedline in your PR. Defaults to TCG software emulation, so it runs anywhere — just slowly.- A human
ccvm --shellpass (resize,vim,less, vi-mode) — if it touches the terminal path. Terminal fidelity is verified by hand, not automated.
See Build / test / debug for the full loop, including the fast stub-claude
iteration pattern.
Security invariants
Treat any change that weakens a Security invariant as a bug: no secret to disk/argv/seed, the host key stays pinned, only the CWD is shared. The host-side tests grep the seed for the API key and the OAuth credential — keep them passing.
Conventions
- Commit trailer (exact):
Co-authored-by: Claude <noreply@anthropic.com>for AI-assisted commits (lowercaseauthored-by, bareClaude). - Format before committing:
nix fmt(treefmt —nixfmtfor Nix,shfmtfor the shell scripts). CI enforces it viachecks.formatting. See Conventions for the full set.
macOS host support — help wanted
macOS host support is on the roadmap and is community-driven: the maintainer has no Apple
hardware. Help from nix-darwin folks — or anyone willing to do the porting work — would be very
welcome. The hard parts are the host-side bits that assume Linux: KVM/TCG acceleration selection, the
clipboard bridge’s host reader (wl-paste / xclip → pbpaste), and QEMU’s accel/networking flags.
The guest itself is a NixOS system and is unaffected. If you’re interested, open an issue to
coordinate.
License
By contributing you agree your contributions are licensed under the MIT License (see the repo’s
LICENSE).