diff --git a/docs/prds/0023-smolmachines-backend.md b/docs/prds/0023-smolmachines-backend.md new file mode 100644 index 0000000..502cc4f --- /dev/null +++ b/docs/prds/0023-smolmachines-backend.md @@ -0,0 +1,606 @@ +# PRD 0023: smolmachines bottle backend + +- **Status:** Draft +- **Author:** didericis +- **Created:** 2026-05-26 + +## Summary + +Ship a second concrete `BottleBackend` — +`SmolmachinesBottleBackend`, selected via +`CLAUDE_BOTTLE_BACKEND=smolmachines` — that runs each bottle inside +a per-agent microVM on macOS. The egress topology is enforced by +**gvproxy** (gvisor-tap-vsock), a userspace TCP/IP stack the guest's +virtio-net device is wired into via `VZFileHandleNetworkDeviceAttachment`. +gvproxy's only outbound configuration is an explicit per-bottle +port-forward set into a **single per-bottle sidecar container** that +bundles pipelock + egress + git-gate + supervise behind one supervised +init. Everything else — the host's LAN, the host's loopback +services, the public internet — is unreachable from the guest by +construction. + +The sidecar bundle is the same image PRD 0024 introduces for the +docker backend; this PRD consumes it. Inside the bundle, egress is +pipelock's internal upstream over localhost and is not exposed +externally. gvproxy port-forwards three external ports into the +bundle: pipelock (for `HTTPS_PROXY`), git-gate (for git push), and +supervise (for MCP). + +This explicitly rejects libkrun's TSI ("Transport Socket Interface") +allowlist as the network primitive. TSI's `--outbound-localhost-only` +is permissive on the entire `127.0.0.0/8` range with no +destination-port filter — the agent can dial any host-side service +bound to loopback (a local Postgres, an IDE plugin, a different +bottle's pipelock). That's the wrong default for a malicious-agent +threat model; see "Why gvproxy, not TSI" below. + +The Docker backend ships unchanged; this is opt-in via the existing +env-var selector. The acceptance gate is PRD 0022's +`tests/integration/test_sandbox_escape.py` running green against +`CLAUDE_BOTTLE_BACKEND=smolmachines`. + +## Problem + +`agent-vm-isolation.md` argues for hardware-isolated microVMs over +container-based bottles on macOS; `smolmachines-as-vm-backend.md` +evaluates smolmachines as the lifecycle wrapper. Today, the only +backend in the registry is Docker +(`claude_bottle/backend/__init__.py:_BACKENDS = {"docker": ...}`), +and four things motivate a second one now: + +- **Network reach beyond pipelock.** The threat model is a malicious + agent attempting to dial something on the operator's *local + network* (`192.168.x.x` services, the home router, a coworker's + laptop on the same Wi-Fi) or *host's loopback* (a local database, + an editor plugin, another bottle's sidecar) without traversing + pipelock. The Docker backend's `--internal` network blocks the + first; nothing in the current stack blocks the second cleanly. + This PRD's gvproxy-based design closes both gaps: the guest can + only reach the explicit port-forward list, period. +- **Isolation ceiling.** On macOS the Docker backend's agent + container shares Docker Desktop's host VM with every other + bottle. Container escape from claude-code lands the agent inside + that shared VM. A per-bottle microVM gets hardware page tables + via `Hypervisor.framework`; cross-bottle isolation becomes + enforced by the CPU's MMU instead of namespace bookkeeping. +- **PRD 0022 is backend-agnostic by design** but currently only + exercises the Docker backend. The suite was written with + `CLAUDE_BOTTLE_BACKEND` selection in mind precisely so the + smolmachines path could be validated against the same five + attacks. Until a second backend exists, the abstraction is + unproven. +- **CI carve-outs.** Most bottle-bringup integration tests skip + under `GITEA_ACTIONS=true` because act_runner shares the host + Docker socket but not the host filesystem. A microVM path + doesn't share that constraint shape (it has its own, but + different), so adding the backend forces the abstraction to be + clean in places where Docker-specific assumptions have been + tolerated. + +## Why gvproxy, not TSI + +libkrun's TSI hijacks guest socket syscalls inside the VMM and +opens the actual sockets from the host process, with a CIDR +allowlist gate. That works fine for blocking LAN reach (don't +allowlist `192.168.0.0/16`, agent can't dial it). But TSI's +`--outbound-localhost-only` permits the *entire* `127.0.0.0/8` +range across all ports — there is no destination-port filter at +the TSI layer (`smolmachines-as-vm-backend.md` flags this in the +"`--allow-host` semantics" caveat). For our threat model that +means any host-loopback service is reachable from the guest. + +gvproxy implements a full userspace TCP/IP stack on the host side +of a `VZFileHandleNetworkDeviceAttachment` unixgram socket. The +guest has a real virtio-net device; gvproxy is its gateway. The +guest can only reach what gvproxy is configured to forward — +typically a single port forward to the per-bottle pipelock — +and DNS resolves NXDOMAIN by default. There is no "permissive +loopback" mode to mis-configure; if it's not in `port_forwards`, +the guest cannot reach it. + +That property — *explicit allowlist by port forward, not CIDR* — +is the load-bearing reason this PRD chooses gvproxy. TSI shows up +once more in this doc, under Non-goals, where it is closed off. + +## Goals / Success Criteria + +The feature works when all of the following are observable on a +macOS host with smolmachines installed: + +- `CLAUDE_BOTTLE_BACKEND=smolmachines python3 cli.py start ` + brings up a microVM, runs claude-code inside it, and tears it + down on exit. Same y/N preflight UX as Docker — only the + resolved-runtime line differs. +- The sandbox-escape suite in `tests/integration/test_sandbox_escape.py` + runs green against the smolmachines backend (all five attack + categories blocked). +- Selecting the backend on a host without `smolvm` installed dies + at startup with an install pointer; no silent fall-through to + Docker. +- Active bottles show up under + `python3 cli.py list-bottles` regardless of backend. +- `python3 cli.py stop ` and orphan cleanup work for both + Docker bottles and smolmachines bottles via the same CLI surface. + +The feature is **done** when all of the following ship: + +- A new `claude_bottle/backend/smolmachines/` subpackage exists, + mirroring the layout of `claude_bottle/backend/docker/` + (`backend.py`, `bottle.py`, `bottle_plan.py`, + `bottle_cleanup_plan.py`, `prepare.py`, `launch.py`, + `cleanup.py`, `util.py`, and a `provision/` subpackage for the + five `provision_*` methods). +- `SmolmachinesBottleBackend` registered under the + `"smolmachines"` key in `claude_bottle/backend/__init__.py:_BACKENDS`. +- Per-bottle Smolfile generation: a runtime-rendered TOML written + to the bottle's stage dir, analogous to the compose file the + Docker backend writes today. The Smolfile pins `command`, + `env`, and a virtio-net device backed by a unixgram socket + pointed at the per-bottle gvproxy. There is no TSI + `--allow-cidr` / `--outbound-localhost-only` / `--allow-host` + in the Smolfile — TSI is not used. +- Per-bottle gvproxy: one `gvproxy` process per bottle, started + before the VM, listening on a unixgram socket the VM's + virtio-net device hooks into. The gvproxy config has up to + three `port_forwards` entries (pipelock / git-gate / supervise + — git-gate and supervise only when the bottle uses them) all + pointing at the per-bottle sidecar bundle's exposed ports, plus + a DNS section that resolves only `proxy.internal`. Every other + hostname returns NXDOMAIN; every other destination is + unreachable. +- Per-bottle sidecar bundle: one container per bottle running the + bundle image defined in PRD 0024. The bundle exposes up to + three host ports (pipelock for `HTTPS_PROXY`, git-gate for git + push, supervise for MCP), bound to `127.0.0.1` on dynamically + allocated ports. egress runs *inside* the bundle as pipelock's + upstream over localhost and is not exposed externally. The + agent's environment carries the resolved URLs (e.g. + `HTTPS_PROXY=http://proxy.internal:`). +- The agent guest image is produced from the existing `Dockerfile` + (or a thin variant), exported as an OCI archive, and consumed by + `smolvm machine create`. The image build step is part of `prepare`, + analogous to `docker_mod.build_image`. +- The PRD 0022 sandbox-escape suite, run with + `CLAUDE_BOTTLE_BACKEND=smolmachines`, passes locally on a + smolmachines-capable host. The suite is updated to skip cleanly + on hosts that can't reach smolmachines (same shape as the + existing `GITEA_ACTIONS == "true"` skip), not to fail. +- README + `CLAUDE.md` updated to document the env-var selection, + the macOS-only scope for v1, and the `smolvm` install + prerequisite. + +## Non-goals + +- **No Linux KVM support shipped in this PRD.** smolmachines works + on Linux via KVM, but the abstraction win is biggest on macOS + where Docker's shared-VM topology hurts most. Linux can come + later behind the same selector. +- **No removal of the Docker backend.** Both backends ship side by + side. Selection stays env-driven; the manifest does not gain a + `backend` field. +- **No default-backend change.** `docker` remains the default + value of `CLAUDE_BOTTLE_BACKEND`; smolmachines is strictly + opt-in until it has been load-bearing on at least one operator's + workflow for a release cycle. +- **No TSI for network policy.** libkrun's TSI mode is rejected + for this backend — it lacks per-port filtering on `127.0.0.0/8` + and would expose every host-loopback service to the guest. The + Smolfile must select libkrun's virtio-net mode and attach to + the per-bottle gvproxy unixgram socket; if that combination is + not supported by the pinned smolmachines version (see open + question 1), the implementation falls back to driving + Virtualization.framework directly via PyObjC and reuses the + same gvproxy attachment. +- **No host bind mounts.** The smolmachines research note flagged + that `-v HOST:GUEST` mounts via virtiofs would defeat the + isolation goal. The manifest already has no concept of host + mounts; this PRD does not introduce one. If a future PRD wants + agent-side access to host files, it must come through a + controlled channel (vsock relay, OCI overlay, supervise sidecar + endpoint). +- **No HTTP API mode.** `smolvm serve` is the long-term-clean + control plane, but v1 drives smolmachines via CLI subprocess + invocations — the lower-overhead first iteration the research + note already endorses. +- **No custom kernel / initrd.** smolmachines uses libkrunfw + only; the agent image is an OCI ref, not a kernel + rootfs pair. +- **No warm-pool or snapshot/restore.** Each bottle gets a fresh + microVM; cold-start cost is paid up front. +- **No supervise/agent-credential rewrites for the new backend.** + Provisioning logic ports as-is; only the *transport* (host-side + port URLs instead of in-network DNS names) changes. + +## Scope + +### In scope + +- New `claude_bottle/backend/smolmachines/` subpackage with the + full set of `BottleBackend` overrides. +- Smolfile generator (TOML), analogous to + `backend/docker/compose.py`'s `bottle_plan_to_compose`. +- A host-side sidecar-bundle lifecycle manager that brings up + one container per bottle (the bundle image defined in PRD 0024), + publishes its one to three host ports, waits for readiness, + and tears it down with the bottle. This backend depends on + PRD 0024's bundle image; it does not own the bundle's + Dockerfile or init. +- Per-bottle CA install path: the bundle's CA cert lands inside + the microVM via `smolvm machine exec` after start + (analogous to the existing `provision_ca` for Docker). +- gvproxy lifecycle: per-bottle `gvproxy` started by the backend + before VM bringup, torn down after VM teardown, configured with + up to three `port_forwards` entries (gateway port → host + bundle port for each of pipelock / git-gate / supervise) and a + DNS section that resolves only `proxy.internal`. Subnet and + gateway IP are derived from the bottle slug so two concurrent + bottles don't collide. +- DNS policy: the bottle's `egress.allowlist` does *not* go into + gvproxy's DNS — the agent resolves only `proxy.internal`, and + pipelock on the host enforces the egress allowlist against + the actual upstream connect target. This keeps the DNS-exfil + attack (PRD 0022 test 4) blocked because gvproxy answers + NXDOMAIN for every name except `proxy.internal`. +- Preflight `smolvm` check: if the user selects this backend and + `smolvm` isn't on `$PATH`, die with an install pointer (brew tap + + version pin TBD in implementation; see open question 3). +- Manifest validation: refuse any bottle field this backend can't + honor (today there are none, since the Docker backend already + rejects host mounts; this is a forward-compat check). +- Tests: + - Smoke unit-level test: Smolfile renderer produces the + expected TOML for a fixture bottle. + - Integration test: `prepare → launch → exec("echo hi") → + teardown` on a smolmachines-capable host (skips otherwise + via the same env/platform gate the Docker integration tests + use). + - PRD 0022 suite, re-run with the env var flipped, passes. + +### Out of scope + +- VM image caching across bottles (each prepare rebuilds from the + OCI archive; layer reuse is whatever smolmachines provides). +- Cross-host bottle relocation (the OCI archive is local-only). +- Operator-facing knobs for vCPU / memory / overlay size (use + sensible defaults; expose as manifest fields in a later PRD if + needed). +- Integration with the `supervise` plane's permission-prompt UX + beyond port plumbing — supervise already speaks HTTP and binds + to whatever loopback the backend hands it. + +## Proposed Design + +### Backend layout + +``` +claude_bottle/backend/smolmachines/ + __init__.py re-exports SmolmachinesBottleBackend + backend.py SmolmachinesBottleBackend façade + bottle.py SmolmachinesBottle (exec_claude / exec / cp_in / close) + bottle_plan.py SmolmachinesBottlePlan + .print() + bottle_cleanup_plan.py SmolmachinesBottleCleanupPlan + prepare.py resolve_plan(spec, stage_dir, ...) -> SmolmachinesBottlePlan + launch.py @contextmanager launch(plan) -> SmolmachinesBottle + cleanup.py prepare_cleanup / cleanup / list_active + smolfile.py bottle_plan_to_smolfile(...) -> dict + render + gvproxy.py per-bottle gvproxy config render + process lifecycle + sidecar_bundle.py host-side lifecycle for the PRD 0024 bundle container + smolvm.py thin subprocess wrapper: machine create/start/exec/stop + vfkit_attach.py VZFileHandleNetworkDeviceAttachment + VFKT handshake + util.py slugify, port allocation, OCI archive helpers + provision/ ca.py, prompt.py, skills.py, git.py, supervise.py +``` + +### Network + egress topology + +``` + ┌── macOS host ─────────────────────────────────────────────────────┐ + │ │ + │ ┌── per-bottle sidecar bundle (one container per microVM) ─┐ │ + │ │ init.py (Python supervisor) │ │ + │ │ ├─ pipelock (binds 0.0.0.0:8888 in container) │ │ + │ │ ├─ egress (mitmproxy) (binds 127.0.0.1:p_internal) │ │ + │ │ ├─ git-gate (binds 0.0.0.0:8889) │ │ + │ │ └─ supervise (MCP) (binds 0.0.0.0:8890) │ │ + │ │ pipelock's upstream is 127.0.0.1:p_internal (egress); │ │ + │ │ egress is not exposed outside the bundle. │ │ + │ └─────────────────────────────────────────────────────┬─────┘ │ + │ Host ports published (loopback, dynamic): │ │ + │ pipelock 127.0.0.1: │ │ + │ git-gate 127.0.0.1: (conditional) │ │ + │ supervise 127.0.0.1: (conditional) │ │ + │ ▲ host TCP, reached via gvproxy port-forward │ + │ │ │ + │ ┌── gvproxy (per bottle) ─────────────────────────────┐ │ + │ │ subnet: 192.168.127.X/24 (X derived from slug) │ │ + │ │ gateway: 192.168.127.X.1 │ │ + │ │ port_forwards: │ │ + │ │ - gateway 8888 → host 127.0.0.1: │ │ + │ │ - gateway 8889 → host 127.0.0.1: (cond) │ │ + │ │ - gateway 8890 → host 127.0.0.1: (cond) │ │ + │ │ # nothing else │ │ + │ │ DNS: proxy.internal → gateway IP; * → NXDOMAIN │ │ + │ └─────────────────────────────────────────────────────┘ │ + │ ▲ unixgram socket (VFKT handshake) │ + │ │ │ + │ ┌── microVM (per bottle) ─────────────────────────────┐ │ + │ │ virtio-net device backed by VZFileHandle... │ │ + │ │ env: HTTPS_PROXY=http://proxy.internal:8888 │ │ + │ │ GIT_GATE_URL=http://proxy.internal:8889 │ │ + │ │ MCP_SUPERVISE_URL=http://proxy.internal:8890 │ │ + │ │ no other host visible │ │ + │ └─────────────────────────────────────────────────────┘ │ + │ │ + └───────────────────────────────────────────────────────────────────┘ +``` + +What the guest can reach, exhaustively: **only `proxy.internal` +on the gateway-port set we configured.** Everything else — +host LAN, host loopback (Postgres, IDE plugins, other bottles' +sidecars), public internet directly — is gone, enforced at the +gvproxy userspace stack rather than relying on guest cooperation. + +Three changes vs. the Docker backend: + +1. **One sidecar container per bottle, not four.** The bundle + defined in PRD 0024 is the unit of sidecar lifecycle on both + backends. egress is internal to the bundle as pipelock's + upstream, never directly addressed. +2. **Sidecar container is on the host, not a sibling on a Docker + internal network.** Isolation primitive is gvproxy's explicit + port-forward list, not Docker's `--internal` flag. +3. **The agent's first hop is `proxy.internal`, not a sidecar's + container hostname.** Same scanning + DLP + auth-injection + chain, but the first hop crosses a userspace TCP/IP stack we + own, not a Docker-managed bridge. + +git-gate and supervise are conditional port forwards: only +emitted into gvproxy's config when the bottle actually uses +them, narrowing the attack surface for bottles that don't. + +### Lifecycle + +`SmolmachinesBottleBackend.prepare(spec, stage_dir)`: + +1. Cross-backend validation via `BottleBackend._validate` (skills, + git identity files). +2. Allocate one to three host loopback ports for the sidecar + bundle (pipelock always; git-gate and supervise conditional on + manifest — egress is internal to the bundle and gets no host + port). +3. Resolve the agent OCI archive path (build if missing, cache by + Dockerfile + agent-name hash). The sidecar-bundle image + (`claude-bottle-sidecars:`) is pulled or built per + PRD 0024; this backend does not own its build. +4. Pick a per-bottle gvproxy subnet (e.g. `192.168.127.X/24` where + `X` is derived from the slug) and render + `stage_dir/gvproxy.yaml`: one DNS entry for `proxy.internal` + and one `port_forwards` entry per active sidecar port + (gateway port → host loopback port on the bundle). +5. Render the per-bottle Smolfile to `stage_dir/smolfile.toml`, + pinning command / env / a virtio-net device backed by the + gvproxy unixgram socket path. No TSI flags. +6. Resolve the in-VM CA paths so launch knows where to copy + pipelock's CA after start. +7. Return a `SmolmachinesBottlePlan` carrying the slug, port map, + OCI archive path, Smolfile path, gvproxy config path, and + the bundle's container/run spec. + +`SmolmachinesBottleBackend.launch(plan)`: + +1. Start the sidecar bundle container with `docker run` (still + using the local Docker daemon for sidecars; the VM is what's + moving off Docker). Wait for its three readiness signals: + pipelock listening, git-gate listening (if enabled), supervise + listening (if enabled). Register the teardown callback. +2. Start the per-bottle `gvproxy` against the unixgram socket + path the Smolfile references, with `port_forwards` pointed at + the bundle's published host ports. Wait for the socket to + appear (the spike-style poll loop from `agent-vm-isolation.md`). +3. `smolvm machine create --smolfile ` and + `smolvm machine start `. The Smolfile's virtio-net + device handshakes (`VFKT` magic) with gvproxy on start. +4. Provisioning: CA install → prompt → skills → git → supervise + config, each via `smolvm machine exec` (analogous to + `docker exec`). +5. Yield a `SmolmachinesBottle` whose `exec_claude` / `exec` / + `cp_in` all funnel through `smolvm machine exec` / + `smolvm machine cp`. +6. Teardown: stop and remove the VM → stop gvproxy → stop + + remove the sidecar bundle container. + +### Data model + +No manifest schema change. `bottles[]` continues to carry +`egress.allowlist`, `env`, `git`, `skills` references, etc.; the +smolmachines backend reads the same fields as the docker backend. +`egress.allowlist` is enforced by pipelock on the host side +(unchanged from the docker backend); gvproxy's DNS resolves only +`proxy.internal` regardless of the allowlist's contents, so an +agent that bypasses pipelock by raw IP cannot resolve any name +gvproxy doesn't know about. + +The `BottleSpec` dataclass and the `Bottle` ABC do not change. + +### Selection wiring + +In `claude_bottle/backend/__init__.py`: + +```python +from .docker import DockerBottleBackend +from .smolmachines import SmolmachinesBottleBackend + +_BACKENDS: dict[str, BottleBackend[Any, Any]] = { + "docker": DockerBottleBackend(), + "smolmachines": SmolmachinesBottleBackend(), +} +``` + +The existing "unknown backend" `die()` path stays as-is. + +### External dependencies + +- `smolvm` CLI binary on `$PATH` (one new external dep, gated by + the preflight check). Pinned version policy is deferred to the + open questions; v1 reads `smolvm --version` and refuses to launch + outside a known-good range. +- `gvproxy` binary on `$PATH` + (`go install github.com/containers/gvisor-tap-vsock/cmd/gvproxy@latest`, + or vendored). Same preflight pattern as `smolvm`. +- `pyobjc-framework-Virtualization` *only* if smolmachines does + not expose a way to attach virtio-net to a unixgram socket and + we fall back to driving Virtualization.framework directly (see + open question 1). Default path is "no PyObjC needed." +- No new pure-Python packages. Subprocess + stdlib `tomllib` for + Smolfile authoring; the gvproxy YAML is small enough to render + by hand from a `dict[str, Any]`. + +### Acceptance test plan + +- **Unit (smolfile):** `tests/unit/test_smolfile.py` verifies the + renderer produces the expected TOML for a fixture bottle — + command line, env entries, virtio-net device referencing the + expected unixgram socket path, no TSI flags. +- **Unit (gvproxy config):** `tests/unit/test_gvproxy_config.py` + verifies the per-bottle YAML has exactly one DNS entry + (`proxy.internal`), one `port_forwards` entry per active + sidecar pointed at the resolved host loopback port, and a + per-bottle subnet/gateway derived from the slug. +- **Integration smoke:** `tests/integration/test_smolmachines_smoke.py` + with `prepare → launch → exec → teardown`, guarded by a + `smolvm` + `gvproxy` presence check + macOS / KVM platform check. +- **Localhost-reach probe:** a focused integration test that + brings up a bottle, has the host bind a test service on + `127.0.0.1:`, and asserts the in-bottle agent + cannot connect to it. This is the regression test for the + exact gap that motivated choosing gvproxy over TSI. +- **PRD 0022 re-run:** with `CLAUDE_BOTTLE_BACKEND=smolmachines`, + all five attack categories return sandbox-block markers and the + suite passes. The test code does not change beyond the env-var + flip — that's the contract the PRD 0022 abstraction was + designed for. + +## Sizing — into chunks + +PRD 0024's bundle image is a prerequisite — this PRD assumes +`claude-bottle-sidecars:` is available when chunk 3 lands. + +1. **Backend skeleton + selection + Smolfile + gvproxy renderers.** + Subpackage layout, `_resolve_plan` stub that emits both a + TOML Smolfile and a gvproxy YAML but doesn't launch anything, + `_BACKENDS` registration, preflight `smolvm` + `gvproxy` + checks. Unit tests on both renderers. No VM bringup yet. +2. **gvproxy + VM lifecycle + OCI archive build.** `smolvm.py` + and `gvproxy.py` subprocess wrappers, prepare-time image + build (existing Dockerfile → OCI archive), launch path that + starts gvproxy, brings up the VM attached to gvproxy's socket + via VFKT handshake, exec into the VM, tear everything down. + Smoke integration test: `exec("echo hi")` inside a started + VM. Includes the localhost-reach probe test from the + acceptance plan. +3. **Sidecar bundle lifecycle.** `sidecar_bundle.py`: per-bottle + bundle container brought up via `docker run`, with one to + three published host ports, gvproxy `port_forwards` pointed + at them, and teardown integrated into the bottle's lifecycle. + Port allocator. No provisioning yet beyond what the bundle + needs. +4. **Provisioning parity with Docker.** CA install via + `smolvm machine exec`, prompt/skills/.git copy-in, supervise + MCP config. End-to-end `start` works for a real agent manifest. +5. **PRD 0022 sandbox-escape suite green.** Skip-guard update, + small adjustments to test helpers if any (the test uses + `bottle.exec(script)` and inspects `returncode` + body for + sandbox markers — should be transport-agnostic, but verify). + Document the macOS-only scope in README. + +## Open questions + +1. **VMM choice: smolmachines vs PyObjC + Virtualization.framework.** + The network design requires libkrun's virtio-net mode attached + to a unixgram socket (so gvproxy is the gateway). The + smolmachines research note says libkrun *has* a virtio-net + mode but says it "does not support policy" — meaning libkrun + itself enforces no allowlist in that mode, which is exactly + what we want (gvproxy is the policy). What's unverified is + whether the Smolfile surface lets us point virtio-net at a + custom unixgram socket. If yes: this is a smolmachines backend + verbatim. If no: chunk 2 drops `smolvm` and drives + `Virtualization.framework` via PyObjC directly (the recipe in + `agent-vm-isolation.md` § "gvisor-tap-vsock + PyObjC + + Pipelock"), keeping the backend name "smolmachines" because + the operator-facing UX is unchanged. Resolve in chunk 1 via a + spike against the pinned smolmachines version. +2. **`smolvm` + `gvproxy` install policy.** Pin via brew / + `go install` versions, or vendor binaries in the repo. v1 + likely runs `smolvm --version` / `gvproxy --help` at preflight + and accepts a documented range; vendoring is heavier but + reduces "works on my Mac" drift. +3. **CA install inside the OCI overlay.** Two paths: bake at + prepare time (one OCI archive per CA fingerprint, big cache + key) vs. inject at start time via `smolvm machine exec` after + the VM is up. PRD 0006 chose the runtime path for Docker + (docker-cp + `update-ca-certificates`); smolvm has the same + shape via `machine exec`. Default to runtime injection unless + it conflicts with VM start order. +4. **gvproxy subnet collision.** Two concurrent bottles must not + land on the same `192.168.127.X/24` subnet — they'd both want + the same gateway IP. Derive the third octet from a hash of + the slug (mod 254, skip the docker-default 17), and at launch + time confirm the subnet isn't already in use by another + bottle's gvproxy. Resolve the hash-collision policy in + chunk 2. +5. **`bottle.exec(script)` exit-code fidelity.** The PRD 0022 test + suite reads `returncode` + stdout + stderr from + `ExecResult`. Confirm the VM-exec path (`smolvm machine exec` + or its PyObjC equivalent) propagates exit codes and separated + streams. The research note's "external integration is the CLI" + implies yes, but the embedded SDK bug it flagged suggests we + should verify before coding around it. +6. **CI gating.** Gitea's act_runner is Linux without nested KVM, + so this backend's integration tests will skip there for the + same structural reason the Docker bringup tests do (no real + isolation primitive available on the runner). The skip + predicate becomes `not (smolvm_available() and gvproxy_available() + and platform.system() == "Darwin")`. CI coverage for this + backend will come from local runs on the maintainer's macOS + host until a Darwin runner is wired up; ack that as a known + gap. +7. **Active bottle discovery.** Docker uses container labels to + enumerate active bottles (`list_active` queries the daemon). + The microVM enumeration story is `smolvm machine list` + (or the PyObjC backend's own bookkeeping); the plan is to + mirror the label scheme via Smolfile metadata + (`labels = { "claude-bottle" = "1" }`-style entries, if the + format supports it; otherwise via a deterministic name prefix + `claude-bottle-` + on-disk metadata under + `state//`). + +## References + +- `docs/research/agent-vm-isolation.md` — primary reference for + the gvproxy + `VZFileHandleNetworkDeviceAttachment` network + attachment used here. The "Full Setup: gvisor-tap-vsock + + PyObjC + Pipelock" section is the recipe the PyObjC fallback + in open question 1 would adopt verbatim. +- `docs/research/smolmachines-as-vm-backend.md` — evaluation of + smolmachines as the VM lifecycle wrapper. This PRD diverges + from its conclusion on the *network* primitive (rejecting TSI + in favor of gvproxy) but keeps its VM-lifecycle conclusion + conditional on the libkrun-virtio-net spike in open question 1. +- `docs/research/agent-sandbox-landscape.md` — identifies + `"runtime": "microvm"`-style opt-in as the borrowable idea; + smolmachines is the concrete implementation. +- PRD 0003 (`docs/prds/0003-bottle-backend-abstraction.md`) — the + backend abstraction this PRD is the first non-Docker consumer + of. +- PRD 0017 (`docs/prds/0017-egress-proxy-via-mitmproxy.md`) — the + egress sidecar the bundle reuses verbatim as pipelock's internal + upstream. +- PRD 0022 + (`docs/prds/0022-sandbox-escape-integration-test.md`) — the + acceptance gate for this PRD; the suite already runs through + `get_bottle_backend()` so the env-var flip is the only change + needed to exercise the smolmachines path. +- PRD 0024 + (`docs/prds/0024-consolidate-sidecar-bundle.md`) — defines the + single bundle image (`claude-bottle-sidecars`) this PRD + consumes. Prerequisite for chunk 3 of this PRD.