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

281 lines
15 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# 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`)
```rust
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:1244``1307`) 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)
```rust
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.
### 4.3 Consent UX
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?
```