hack-house/docs/spec-virtualbox-sandbox.md
leetcrypt ca1666fbbb docs(sbx): VirtualBox backend spec, crypto pay-gate, save/load PoC
Add the VirtualBox sandbox design spec (headless 4th backend + share-an-
appliance GUI mode with detect-first install), the crypto pay-to-join gate
design, and the save/load PoC writeup with its demo/film driver scripts.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-06-03 10:10:44 -07:00

15 KiB
Raw Blame History

hack-house → VirtualBox Sandbox Backend — Spec

Status: Draft v1 · Date: 2026-06-03 Scope: Add VirtualBox as a sandbox backend, in two complementary modes: (A) a headless, owner-hosted VM driven through the existing shared PTY (drops into the current Backend abstraction), and (B) a portable VM appliance the room can hand out so each member boots the actual GUI locally on their own machine — including detecting and (with consent) installing VirtualBox if it's missing. Baseline reviewed: hh/src/sbx.rs, hh/src/app.rs @ feat/ai-context.


0. Decisions to lock

# Decision Proposal
A VirtualBox transport into the guest SSH (NAT port-forward) as primary; VBoxManage guestcontrol as a no-SSH fallback. SSH gives a clean PTY and reuses the multipass provisioning model verbatim.
B Single shared instance vs. per-user local copies Both, as two modes. Mode A = one owner-hosted headless VM, shared PTY (zero-knowledge preserved). Mode B = export the VM as an .ova, distribute over the existing /send channel, each member imports + launches the GUI locally.
C GUI sharing No live framebuffer relay. Sharing the desktop = sharing the appliance, not the pixels. Sidesteps the zero-knowledge problem entirely (the image rides the encrypted file transfer).
D Installation Detect-first, then opt-in install. ensure-vbox.sh mirrors ensure-docker.sh: never installs silently; prints what it would do and requires an explicit --yes (or the /sbx ... --install flag).

1. Why VirtualBox, and what's genuinely new

The existing backends (Backend::{Local,Docker,Multipass} in hh/src/sbx.rs:51) are all headless and text-only. The owner hosts the box and runs a local PTY into it (command_for, sbx.rs:278); the PTY bytes are encrypted with the room key and relayed as _sbx frames, so the server only ever sees ciphertext.

VirtualBox adds two things the others can't:

  1. Arbitrary guest OSes — Windows, BSD, old kernels, purpose-built malware-analysis or CTF images — with a mature snapshot tree.
  2. A real graphical desktop. This is the part that doesn't fit the PTY relay, and it's the part you explicitly want: people share a VM and each launches it locally with the GUI.

So the integration is deliberately split so each mode keeps the project's trust model intact:

  • Mode A (shared shell): one VM, owner-hosted, driven collaboratively through the shared PTY — identical trust story to multipass.
  • Mode B (shared appliance): the VM image is the shared artifact. It travels over the existing E2E /send transfer; each member runs their own local copy in the VirtualBox GUI. No pixels cross the wire — only the (encrypted) disk image.

2. Mode A — headless VirtualBox as a 4th backend

2.1 Enum + labels (hh/src/sbx.rs)

pub enum Backend { Local, Docker, Multipass, VirtualBox }   // new variant
  • Backend::parse: add "virtualbox" | "vbox" => Some(Backend::VirtualBox).
  • label(): "virtualbox".
  • default_image(): a named base appliance, e.g. "hh-base" (an Ubuntu image we pre-register), since VirtualBox has no "pull by release string" like multipass.

2.2 Mapping every existing fn to VBoxManage

Each function in sbx.rs gets one new match arm. The transport (how a command reaches inside the guest) is SSH over a NAT port-forward.

Fn (sbx.rs) Multipass today VirtualBox arm
prepare (:86) multipass launch import appliance if absent (VBoxManage import <ova> --vsys 0 --vmname <name>), set forward (modifyvm <name> --natpf1 "ssh,tcp,127.0.0.1,<port>,,22"), then startvm <name> --type headless; if it already exists just startvm. Idempotent like the multipass arm.
command_for (:278) multipass exec … bash ssh -tt -p <port> -o StrictHostKeyChecking=no <run_user>@127.0.0.1 (login shell). run_user empty ⇒ default account.
provision (:355) useradd via mp() identical useradd/sudoers scripts, run through an SSH helper vbx() (mirrors mp()/dk() at sbx.rs:319). Owner gets passwordless sudo via the same mp_grant_sudo script.
set_sudo (:387) sudoers drop-in same script over SSH; gate on backend == VirtualBox.
save_state (:208) multipass snapshot VBoxManage snapshot <name> take <label> --pause
list_snapshots (:241) multipass list --snapshots VBoxManage snapshot <name> list --machinereadable, parse SnapshotName*= lines
teardown (:172) multipass delete --purge VBoxManage controlvm <name> poweroff then unregistervm <name> --delete

Why SSH over guestcontrol: VBoxManage guestcontrol <vm> run --exe /bin/bash requires Guest Additions in the image and gives a rough PTY; interactive driving is clunky. SSH needs only an sshd in the base image (cheap to bake once) and the whole P4 permission stack (/grant, /sudo, drive ACL) works unchanged because it's all "run a command through a transport." guestcontrol stays as a documented fallback for images without sshd.

2.3 Port allocation

Each headless VM needs a unique host loopback port for its SSH forward. Reuse the free-port discovery already used by the save/load PoC (see docs/demo-save-load-poc.md / hh/demo-save-load.sh) so two sandboxes on one host don't collide. The owner is the only one who ever connects to it (127.0.0.1:<port>), so it never leaves the host.

2.4 Snapshots tie into existing /sbx save/load

/sbx save/load/snaps (app.rs:12441307) already branch on backend. VirtualBox snapshots map cleanly onto the same commands, so the existing UX ("save state → quit → load") works for VBox with no new commands — just the new match arms in save_state/list_snapshots, plus a VirtualBox arm in the load path (today load hardcodes Docker at app.rs:1282; generalize it to the broker's backend).


3. Mode B — share a VM, launch the GUI locally

This is the new product surface. The shared artifact is the appliance, not a live session. Flow:

owner:   /sbx export [name]        → freezes the VM to an .ova on the owner's disk
owner:   /send hh-box.ova          → existing E2E file transfer (chunked, SHA-256)
member:  /accept                   → lands in ./downloads/ (existing path)
member:  /sbx open ./downloads/hh-box.ova
            → ensure VirtualBox is installed (detect; offer install)
            → VBoxManage import … --vmname hh-box-<member>
            → VBoxManage startvm hh-box-<member> --type gui   ← real desktop window

Everyone ends up running an identical local VM — same disk, same tools, same state at export time — but each on their own machine, with a full GUI. Because the image moved over the encrypted /send channel, the server never saw it, and there is no live cross-machine display traffic to secure.

3.1 New commands

Command Who Action
/sbx export [name] owner of a VBox sandbox VBoxManage export <name> -o <out>.ova (VM should be powered off or snapshot-exported). Emits the path and hints /send it.
/sbx open <file.ova> [--install] any member Detect VirtualBox → (consent) install if missing → import under a per-member VM name → startvm --type gui.
/sbx gui [name] any member Launch the GUI for an already-imported VM (startvm --type gui), or attach a running headless one (VBoxManage startvm <name> --type separate).

/sbx open and /sbx export are deliberately local-only operations (like /pw): they never broadcast. The only thing that crosses the room is the .ova you choose to /send.

3.2 Relationship between the two modes

They compose: the owner can run a Mode A headless VM, /sbx save a snapshot, /sbx export it to an .ova, and /send it — at which point each member can /sbx open it and keep working locally in the GUI from the exact same state. "Collaborate live in one shared shell" and "everyone take a copy home and run the desktop" become two ends of one workflow.


4. Installation handling — ensure-vbox.sh (detect first)

Mirror hh/ensure-docker.sh exactly in spirit: a backend never installs anything silently.

4.1 Detection (always first, zero side effects)

pub fn vbox_installed() -> bool {            // sbx.rs, beside docker_daemon_up()
    Command::new("VBoxManage").arg("--version")
        .stdout(Stdio::null()).stderr(Stdio::null())
        .status().map(|s| s.success()).unwrap_or(false)
}

If present, every Mode A/B path proceeds normally. If absent, the command fails loud with the remedy, exactly like the Docker daemon message at app.rs:1206:

VirtualBox isn't installed — retry with /sbx open <file> --install to install it (needs sudo), or run ./ensure-vbox.sh in a terminal first

4.2 The installer script

hh/ensure-vbox.sh, invoked as bash ensure-vbox.sh --yes only when the user passed --install (matching how prepare shells ensure-docker.sh --yes at sbx.rs:31). It:

  1. Re-checks VBoxManage --version; if found, exits 0 immediately (idempotent).
  2. Detects the platform and prints the exact command it will run before running it:
    • Debian/Ubuntu: sudo apt-get install -y virtualbox (or add Oracle's repo for a current build).
    • Fedora: sudo dnf install -y VirtualBox.
    • Arch: sudo pacman -S --noconfirm virtualbox.
    • macOS: brew install --cask virtualbox (note: needs the kernel-extension approval in System Settings; the script surfaces that as a manual step).
    • Windows / unknown: do not attempt; point at the download page and the winget install Oracle.VirtualBox one-liner.
  3. On any failure, surfaces the last stderr line through the returned error (same pattern as start_docker_daemon at sbx.rs:31) so it lands in the TUI error popup, never bleeding raw onto the surface.

Honesty note for the spec: VirtualBox needs a host kernel module (vboxdrv) and, on Secure-Boot machines, a signed/enrolled MOK. The script detects Secure Boot (mokutil --sb-state) and, rather than fail opaquely, tells the user the one manual step required. We check; we don't pretend it's always one command.

No surprise installs, no surprise sudo. The flow is: try → detect missing → tell the user the remedy and the exact command → they re-issue with --install. This matches the project's existing posture (/sbx launch docker --start is opt-in daemon-start, not automatic).


5. Command surface (additions)

Extend the /sbx usage line (app.rs:1309):

/sbx launch [local|docker|multipass|virtualbox] [image]
/sbx stop | save [label] | load <label> | snaps
/sbx export [name]              # freeze host VM → .ova (then /send it)
/sbx open <file.ova> [--install]# import + launch the GUI locally
/sbx gui [name] [--install]     # launch GUI for an imported VM
Command Broadcasts? Notes
launch virtualbox yes (_sbx status) Mode A; owner-hosted headless, shared PTY
export no local; produces an artifact to /send
open / gui no local; each member's own GUI window

6. Code touchpoints (what actually changes)

Mode A is small — a new enum variant and ~7 match arms; the broker, drive ACL, sudo delegation, and save/load machinery are all backend-agnostic already.

File Change
hh/src/sbx.rs Backend::VirtualBox variant; arms in parse/label/default_image/prepare/command_for/provision/set_sudo/save_state/list_snapshots/teardown; vbox_installed(), vbx() SSH helper; export_ova() + open_local() (Mode B, local-only).
hh/src/app.rs accept virtualbox/vbox in /sbx launch; generalize /sbx load off hardcoded Docker (:1282) to the broker backend; new /sbx export, /sbx open, /sbx gui arms; extend usage string (:1309); install-missing error mirroring :1206.
hh/ensure-vbox.sh (new) detect-first installer, per §4.
hh/src/ui.rs add the three new commands to the clustered help menu.
README.MD backend table (:175) gains a virtualbox row; a short "share a VM, run it locally" subsection.
models.toml / docs none.

7. Security & trust notes

  • Mode A preserves zero-knowledge: the VM is owner-local, the SSH forward is 127.0.0.1-only, and only PTY ciphertext crosses the room — same as multipass.
  • Mode B preserves zero-knowledge by not streaming a display at all. The .ova is just a file through the existing SHA-256-verified, Fernet-encrypted /send path (hh/src/ft.rs). Note the existing 50 MB transfer cap (README :215) — real VM images blow past it, so Mode B needs either a raised cap for appliances or an out-of-band hand-off (documented honestly; see Open Questions).
  • No silent install, no silent sudo (§4.3).
  • Appliance provenance: a shared .ova is executable content. The spec should warn recipients exactly as /accept already implies trust in the sender — an imported VM runs code. Worth an explicit one-line caution in the open flow.

8. Phasing

Phase Deliverable Gate
V0 vbox_installed() + ensure-vbox.sh (detect-first, Linux apt path) manual: missing → guided install → present
V1 Backend::VirtualBox Mode A: launch/stop/PTY over SSH, provision, sudo shared shell into a headless VBox VM, two clients driving
V2 snapshots: save/load/snaps arms; generalize /sbx load backend save → stop → load round-trips on VBox
V3 Mode B: /sbx export/send/sbx open GUI launch a second machine boots the shared appliance's desktop
V4 macOS/other-distro install paths; transfer-cap handling for .ova cross-platform open works end-to-end

9. Open questions

  1. Appliance size vs. the 50 MB /send cap. Options: raise the cap for .ova only, compress (.ova + zstd), ship a thin base + a provisioning script instead of a fat image, or accept out-of-band transfer for large VMs and keep /send for small ones. Needs a product call.
  2. Base image sourcing. Do we bake and ship an hh-base.ova (sshd + sudo preinstalled) so Mode A "just works," or import whatever the user points at and require them to have sshd? Baking one image once is the smoother UX.
  3. Per-member VM naming / cleanup for Mode B locals — namespacing (hh-box-<member>) and a /sbx open --replace to re-import cleanly.
  4. guestcontrol fallback — ship it in V1 or document-only until someone needs a no-sshd image?