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