# QVM - Lightweight QEMU Development VM Wrapper

A standalone CLI tool for running commands in an isolated NixOS VM with persistent state and shared caches.

## Motivation

Running AI coding agents in isolation presents a security challenge. Containers provide some isolation, but kernel exploits remain a real attack surface. QVM uses full VM isolation for stronger security guarantees while maintaining developer ergonomics.

**Why QVM?**

- **VM isolation over container isolation** - Hypervisor boundary is fundamentally stronger than kernel namespaces
- **One master image, shared caches** - Single ~#GB base image instead of per-project images
- **Transparent workspace mounting** - Current directory automatically available in VM
- **Persistent state** - VM overlay preserves installed tools and configuration
- **Shared build caches** - Cargo, pnpm, and sccache caches shared across all projects

**Primary use case:** Running AI coding agents (opencode, aider, cursor) in isolation to prevent unintended host filesystem access while maintaining build cache performance.

## Installation

### System-wide installation (NixOS flake)

Add QVM to your NixOS configuration:

```nix
{
  inputs.qvm.url = "git+https://git.joshuabell.xyz/ringofstorms/qvm";
  
  environment.systemPackages = [
    inputs.qvm.packages.${system}.default
  ];
}
```

### Direct execution

Run without installation:

```bash
nix run git+https://git.joshuabell.xyz/ringofstorms/nvim -- start
```

### Development shell

For local development:

```bash
git clone https://git.joshuabell.xyz/ringofstorms/qvm.git
cd qvm
nix develop
```

## Quick Start

```bash
# Start the VM (auto-builds base image on first run)
qvm start

# Run commands in VM with current directory mounted
qvm run opencode
qvm run cargo build --release
qvm run npm install

# Interactive SSH session
qvm ssh

# Check VM status
qvm status

# Stop VM
qvm stop
```

The first `qvm start` will build the base image, which takes several minutes. Subsequent starts are fast (3-5 seconds).

## Commands

### qvm start

Start the VM daemon. Creates base image and overlay if they don't exist.

```bash
qvm start
```

**Behavior:**
- Checks if VM already running (no-op if yes)
- Builds base image if missing (via `qvm rebuild`)
- Creates overlay.qcow2 if missing (copy-on-write backed by base image)
- Launches QEMU with KVM acceleration
- Mounts all registered workspaces and cache directories via 9p
- Waits for SSH to become available
- Exits when SSH is ready (VM runs as background daemon)

### qvm stop

Gracefully stop the running VM.

```bash
qvm stop
```

**Behavior:**
- Sends ACPI shutdown signal to VM
- Waits up to 30 seconds for graceful shutdown
- Force kills QEMU if timeout exceeded
- Cleans up PID file
- No-op if VM not running

### qvm run

Execute a command in the VM with current directory mounted as workspace.

```bash
qvm run <command> [args...]
```

**Examples:**
```bash
qvm run cargo build
qvm run npm install
qvm run "ls -la"
qvm run bash              # Interactive shell in VM
```

**Behavior:**
1. Generates hash from current directory absolute path
2. Registers workspace in `~/.local/state/qvm/workspaces.json`
3. Auto-starts VM if not running
4. Checks if workspace is mounted (warns to restart VM if newly registered)
5. SSHs into VM and executes: `cd /workspace/{hash} && <command>`
6. Streams stdout/stderr to terminal
7. Exits with command's exit code
8. VM stays running for next command

**Note:** Workspaces are mounted at VM startup. If you run from a new directory, you'll need to restart the VM to mount it.

### qvm ssh

Open interactive SSH session or run command in VM.

```bash
qvm ssh                   # Interactive shell
qvm ssh -c "command"      # Run single command
```

**Examples:**
```bash
qvm ssh                           # Drop into root shell
qvm ssh -c "systemctl status"     # Check systemd status
qvm ssh -c "df -h"                # Check disk usage
```

Connects as root user. Password: `root` (if needed, though SSH key auth is configured).

### qvm status

Show VM state, SSH port, and mounted workspaces.

```bash
qvm status
```

**Example output:**
```
VM Status: Running
PID: 12345
SSH Port: 2222

Mounted Workspaces:
  abc12345 -> /home/josh/projects/qvm
  def67890 -> /home/josh/projects/myapp

Cache Directories:
  /cache/cargo     -> ~/.cache/qvm/cargo-home
  /cache/target    -> ~/.cache/qvm/cargo-target
  /cache/pnpm      -> ~/.cache/qvm/pnpm-store
  /cache/sccache   -> ~/.cache/qvm/sccache
```

### qvm rebuild

Rebuild the base VM image from NixOS flake.

```bash
qvm rebuild
```

**Behavior:**
- Runs `nix build` on `~/.config/qvm/flake`
- Copies result to `~/.local/share/qvm/base.qcow2`
- Warns if VM is running (restart required to use new image)

**Use when:**
- Customizing VM configuration (edited `~/.config/qvm/flake/flake.nix`)
- Updating base image with new packages or settings
- Pulling latest changes from upstream NixOS

### qvm reset

Delete overlay and start fresh. Keeps base image intact.

```bash
qvm reset
```

**Behavior:**
- Stops VM if running
- Deletes `overlay.qcow2` (all VM state changes)
- Deletes `workspaces.json` (registered workspaces)
- Next `qvm start` creates fresh overlay from base image

**Use when:**
- VM state is corrupted
- Want to return to clean base image state
- Testing fresh install scenarios

## Directory Layout

QVM uses XDG-compliant directories:

```
~/.config/qvm/
  └── flake/              # User's customizable NixOS flake
      ├── flake.nix       # VM system configuration
      └── flake.lock      # Pinned dependencies

~/.local/share/qvm/
  └── base.qcow2          # Base VM image (~7GB, read-only)

~/.local/state/qvm/
  ├── overlay.qcow2       # Persistent VM state (copy-on-write)
  ├── vm.pid              # QEMU process ID
  ├── ssh.port            # SSH forwarded port
  ├── serial.log          # VM console output
  └── workspaces.json     # Registered workspace mounts

~/.cache/qvm/
  ├── cargo-home/         # Shared cargo registry/cache
  ├── cargo-target/       # Shared cargo build artifacts
  ├── pnpm-store/         # Shared pnpm content-addressable store
  └── sccache/            # Shared compilation cache
```

### Inside the VM

```
/workspace/
  ├── abc12345/          # Mounted from /home/josh/projects/qvm
  └── def67890/          # Mounted from /home/josh/projects/myapp

/cache/
  ├── cargo/             # Mounted from ~/.cache/qvm/cargo-home
  ├── target/            # Mounted from ~/.cache/qvm/cargo-target
  ├── pnpm/              # Mounted from ~/.cache/qvm/pnpm-store
  └── sccache/           # Mounted from ~/.cache/qvm/sccache
```

Environment variables in VM:
- `CARGO_HOME=/cache/cargo`
- `CARGO_TARGET_DIR=/cache/target`
- `PNPM_HOME=/cache/pnpm`
- `SCCACHE_DIR=/cache/sccache`

## Workspace Management

When you run `qvm run` from different directories, each gets registered and mounted:

```bash
cd ~/projects/myapp
qvm run cargo build       # Workspace abc12345

cd ~/projects/other
qvm run npm install       # Workspace def67890
```

Both workspaces are mounted simultaneously in the VM. The hash is derived from the absolute path, so the same directory always maps to the same workspace ID.

**Important:** Workspaces are mounted at VM start time. If you run from a new directory:
1. Workspace gets registered in `workspaces.json`
2. `qvm run` will detect it's not mounted and warn you
3. Restart VM to mount: `qvm stop && qvm start`

## Cache Sharing

Build caches are shared between host and VM via 9p mounts:

**Cargo:**
- Registry and crate cache shared across all projects
- Each project still uses its own `Cargo.lock`
- Different versions coexist peacefully

**pnpm:**
- Content-addressable store shared
- Each project links to shared store
- Massive disk space savings

**sccache:**
- Compilation cache shared
- Speeds up repeated builds across projects

All caches persist across VM restarts and resets (they live in `~/.cache/qvm`, not in the overlay).

## Customization

### Editing VM Configuration

The VM is defined by a NixOS flake at `~/.config/qvm/flake/flake.nix`. Edit this file to customize the VM.

**Default packages included:**
- git, vim, tmux, htop
- curl, wget, jq, ripgrep, fd
- opencode (AI coding agent)
- Language toolchains can be added

**Example: Add Rust and Node.js:**

```nix
environment.systemPackages = with pkgs; [
  # ... existing packages ...
  
  # Add language toolchains
  rustc
  cargo
  nodejs_22
  python3
];
```

**Example: Add custom shell aliases:**

```nix
environment.shellAliases = {
  "ll" = "ls -lah";
  "gst" = "git status";
};
```

**Example: Include your dotfiles:**

```nix
environment.etc."vimrc".source = /path/to/your/vimrc;
```

**Apply changes:**

```bash
# Edit the flake
vim ~/.config/qvm/flake/flake.nix

# Rebuild base image
qvm rebuild

# Restart VM to use new image
qvm stop
qvm start
```

### Resource Allocation

To customize, set environment variables before `qvm start`:

```bash
export QVM_MEMORY="16G"
export QVM_CPUS="8"
qvm start
```

These will be configurable in a config file in future versions.

## Security Model

### VM Isolation Benefits

**Why VM over containers?**
- Container escapes via kernel exploits are well-documented
- VM escapes require hypervisor exploits (far rarer)
- For long-running unattended AI sessions, VM isolation provides stronger guarantees

### 9p Mount Security

Filesystem sharing uses `security_model=mapped-xattr`:
- No direct passthrough to host filesystem
- Only explicitly mounted directories are visible
- Host filesystem outside mounts is completely invisible to VM
- File ownership and permissions mapped via extended attributes

**What the VM can access:**
- Registered workspaces (directories you ran `qvm run` from)
- Shared cache directories
- Nothing else on the host

**What the VM cannot access:**
- Your home directory (except mounted workspaces)
- System directories
- Other users' files
- Any unmounted host paths

### Network Isolation

VM uses QEMU user-mode networking:
- VM can make outbound connections
- No inbound connections to VM except forwarded SSH port
- SSH port forwarded to random high port on localhost only

## Troubleshooting

### VM won't start

Check if QEMU/KVM is available:

```bash
qemu-system-x86_64 --version
lsmod | grep kvm
```

Check the serial log for errors:

```bash
cat ~/.local/state/qvm/serial.log
```

### SSH timeout

If SSH doesn't become ready within 60 seconds:

1. Check if VM process is running: `ps aux | grep qemu`
2. Check serial log: `cat ~/.local/state/qvm/serial.log`
3. Try increasing timeout (future feature)

### Workspace not mounted

If you see "Workspace not mounted in VM":

```bash
qvm stop
qvm start
qvm run <command>
```

Workspaces must be registered before VM start.

### Build cache not working

Verify cache directories exist and are mounted:

```bash
qvm ssh -c "ls -la /cache"
qvm ssh -c "echo \$CARGO_HOME"
```

Check that cache directories on host exist:

```bash
ls -la ~/.cache/qvm/
```

### VM is slow

Ensure KVM is enabled (not using emulation):

```bash
lsmod | grep kvm_intel  # or kvm_amd
```

Check resource allocation:

```bash
qvm status  # Shows allocated CPUs/memory (future feature)
```

## Limitations

**Explicit exclusions:**
- **Multi-VM:** Only one VM at a time
- **Per-project configs:** Single global VM (use other tools for project-specific VMs)
- **Cross-platform:** Linux + KVM only (no macOS/Windows)
- **GUI:** Headless only, no desktop environment
- **Snapshots:** Only overlay reset, no checkpoint/restore

## Dependencies

Required on host system:
- `qemu` (with KVM support)
- `nix` (for building VM images)
- `openssh` (SSH client)
- `jq` (JSON processing)
- `nc` (netcat, for port checking)

All dependencies are included automatically when installing via Nix flake.

## Architecture

```
HOST                                VM
────────────────────────────────    ──────────────────────────

~/.local/share/qvm/base.qcow2   →   (read-only base image)
         ↓
~/.local/state/qvm/overlay.qcow2 →  (persistent changes)


~/.cache/qvm/cargo-home/    ──9p──→  /cache/cargo/
~/.cache/qvm/cargo-target/  ──9p──→  /cache/target/
~/.cache/qvm/pnpm-store/    ──9p──→  /cache/pnpm/
~/.cache/qvm/sccache/       ──9p──→  /cache/sccache/

$(pwd)                      ──9p──→  /workspace/{hash}/
```

**Image layering:**
- Base image contains NixOS system from flake
- Overlay is copy-on-write layer for runtime changes
- `qvm reset` deletes overlay, preserves base
- `qvm rebuild` updates base, keeps overlay

**9p virtfs mounts:**
- Used for workspace and cache sharing
- `security_model=mapped-xattr` for security
- `msize=104857600` for performance (100MB transfer size)
- Mounts configured at VM start, no hotplug

## Contributing

Contributions welcome! This is a Go CLI tool designed to be readable and maintainable.

**Key files:**
- `cmd/qvm/` - CLI command implementations (Cobra)
- `internal/vm/` - VM lifecycle and QEMU management
- `internal/workspace/` - Workspace registration and mounting
- `internal/config/` - Configuration and XDG paths
- `flake/default-vm/flake.nix` - Default VM template

**Development:**

```bash
git clone https://github.com/yourusername/qvm
cd qvm
nix develop

# Build the Go binary
go build ./cmd/qvm

# Run locally
./qvm start
```

## License

MIT
