Files
bot-bottle/docs/prds/0023-smolmachines-backend.md
T
didericis-codex 18e3b62b72
test / unit (pull_request) Successful in 28s
test / integration (pull_request) Successful in 40s
test / unit (push) Successful in 31s
test / integration (push) Successful in 44s
docs: rename CLAUDE.md to AGENTS.md and rebrand provider-agnostic
Delete CLAUDE.md in favor of AGENTS.md as the orientation doc, rebrand
the project from Codex-bottle to provider-agnostic bot-bottle, and
repoint every CLAUDE.md reference across PRDs, research notes, the
implementer agent example, and the yaml_subset comment.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-05-28 20:36:47 -04:00

34 KiB

PRD 0023: smolmachines bottle backend

  • Status: Draft
  • Author: didericis
  • Created: 2026-05-26

Summary

Ship a second concrete BottleBackendSmolmachinesBottleBackend, selected via BOT_BOTTLE_BACKEND=smolmachines — that runs each bottle inside a per-agent libkrun microVM via smolvm. Egress is enforced by libkrun's TSI ("Transport Socket Interface") allowlist set to a single /32 — the docker IP of the per-bottle sidecar bundle (PRD 0024) on a dedicated docker bridge. Everything else — host loopback, LAN, public internet directly — is denied at the VMM layer, before a host-side socket is ever opened.

The sidecar bundle is the same image PRD 0024 ships for the docker backend; this PRD consumes it. Inside the bundle, pipelock / git-gate / supervise bind 0.0.0.0:<port> so the agent (reaching the bundle via the allowed /32) can talk to them; egress (the internal upstream of pipelock) binds 127.0.0.1:9099 so it's only reachable from pipelock within the bundle — the agent can't dial it directly even though TSI's allowlist is IP-granular rather than port-granular.

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 BOT_BOTTLE_BACKEND=smolmachines.

Design pivot from the first draft

The original PRD landed (PR #53) calling for gvproxy as the network primitive — a userspace TCP/IP stack the guest's virtio-net device would hook into via VZFileHandleNetworkDeviceAttachment, with explicit port_forwards controlling what the guest could reach. That design was built around the smolmachines research note's claim that libkrun supports a virtio-net mode separate from TSI.

Chunk 1's empirical spike against smolvm 0.8.0's actual CLI contradicted that claim: smolvm exposes only TSI-style egress filters (--allow-host, --allow-cidr, --outbound-localhost-only), with no documented option to attach virtio-net to a custom unixgram socket. The gvproxy path would have required dropping smolvm entirely and driving Virtualization.framework via PyObjC.

Re-examining the "why gvproxy" argument with smolvm's real surface, the loopback gap PRD 0023 worried about only exists with --outbound-localhost-only. With --allow-cidr <bundle-ip>/32 instead — and no --outbound-localhost-only — the agent can reach exactly one IP (the bundle) and nothing else: not host loopback, not LAN, not public internet. That's the same security property the gvproxy design was chasing, enforced one layer lower (VMM socket interception, not a userspace TCP/IP stack we maintain), with significantly less code.

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 (bot_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 design closes both gaps via TSI's --allow-cidr <bundle-ip>/32: the guest can only dial that one IP, period. Host loopback, LAN, and the public internet are refused at the VMM layer.
  • 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 BOT_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.

How TSI's single-IP allowlist achieves the property

libkrun's TSI hijacks guest socket syscalls inside the VMM and opens the actual sockets from the host process, gated by a CIDR allowlist. Three flags expose the allowlist:

  • --outbound-localhost-only — opens up the whole 127.0.0.0/8 range, all ports. This is the flag the first draft of this PRD rejected, and we still reject it: it would let the agent dial any host-loopback service (local Postgres, IDE plugins, another bottle's sidecar).
  • --allow-cidr CIDR — IP/CIDR allowlist with no port filter.
  • --allow-host HOSTNAME — resolves the host on the host's DNS at VM-start time, stores the result as /32 CIDRs, and also enables guest-side DNS filtering (only the allowed hostname resolves).

This backend uses --allow-cidr <bundle-ip>/32 (single host) and nothing else. With the bundle running as a docker container with a known IP on a dedicated docker bridge, the agent can reach exactly one address: the bundle. Host loopback is denied (not in the allowlist). LAN is denied. Public internet directly is denied. DNS inside the guest is denied (no resolver in the allowlist) — the agent uses an IP literal for HTTPS_PROXY.

The one wrinkle TSI doesn't directly handle is port granularity within the allowed IP. The bundle runs four daemons; pipelock / git-gate / supervise are agent-facing, egress is pipelock's internal upstream. If egress were bound to 0.0.0.0:9099 inside the bundle, the agent could dial <bundle-ip>:9099 and bypass pipelock's DLP. We mitigate by binding egress to 127.0.0.1:9099 inside the bundle so only pipelock — also in the bundle, on the same localhost — can reach it. The bind-address strategy gives us port-level isolation that TSI's IP-only allowlist doesn't.

Net result: same security property the first draft chased with gvproxy, enforced at the VMM layer rather than via a userspace TCP/IP stack, with significantly less code (no gvproxy lifecycle, no VZFileHandleNetworkDeviceAttachment plumbing, no Smolfile virtio-net carve-out smolvm doesn't expose anyway).

Goals / Success Criteria

The feature works when all of the following are observable on a macOS host with smolmachines installed:

  • BOT_BOTTLE_BACKEND=smolmachines python3 cli.py start <agent> 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 <bottle> 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 bot_bottle/backend/smolmachines/ subpackage exists, mirroring the layout of bot_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 bot_bottle/backend/__init__.py:_BACKENDS.
  • Per-bottle Smolfile generation: a runtime-rendered TOML written to the bottle's stage dir using smolvm 0.8.0's actual schema (image, entrypoint, cmd, env = ["K=V", …], [network] allow_cidrs = ["<bundle-ip>/32"]). The renderer chunk 1 shipped emits the wrong shape (built around the gvproxy unixgram attachment) — it gets rewritten in this chunk plan as the cost of the design pivot.
  • Per-bottle docker bridge for the bundle: the sidecar bundle runs as a docker container on a dedicated per-bottle bridge network with a pinned IP (--ip <bundle-ip> against a per-slug /24 derived from the slug hash). The pinned IP is what TSI's allowlist points at; without pinning we'd need to inspect the running container's IP and feed it back into the Smolfile, which is a race.
  • Per-bottle sidecar bundle: one container per bottle running the bundle image defined in PRD 0024. pipelock / git-gate / supervise bind 0.0.0.0:<port> so the agent (reaching the bundle via the allowed /32) can reach them. egress binds 127.0.0.1:9099 inside the bundle so only pipelock can reach it — the agent sees <bundle-ip>:9099 refuse the connection even though TSI's allowlist permits the IP. The agent's environment carries IP-literal URLs (e.g. HTTPS_PROXY=http://<bundle-ip>:8888).
  • The agent guest image is produced from the existing Dockerfile via smolvm pack create.smolmachine artifact, then loaded into smolvm via machine create --from <path>. The image build step is part of prepare, analogous to docker_mod.build_image.
  • The PRD 0022 sandbox-escape suite, run with BOT_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 + AGENTS.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 BOT_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 --outbound-localhost-only. That TSI flag opens the entire 127.0.0.0/8 range and is the loopback gap the original draft of this PRD called out. Use --allow-cidr <bundle-ip>/32 instead so the agent reaches one IP and one IP only.
  • No gvproxy. Rejected after the chunk-1 spike against the real smolvm CLI: smolvm 0.8.0 exposes no virtio-net-over-unixgram attachment. Adopting gvproxy would have required dropping smolvm and driving Virtualization.framework via PyObjC; the TSI single-IP approach gives the same property at a fraction of the cost.
  • 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 bot_bottle/backend/smolmachines/ subpackage with the full set of BottleBackend overrides.
  • Smolfile generator (TOML) emitting the smolvm 0.8.0 schema: top-level image, entrypoint, cmd, env = [...], [network] allow_cidrs = ["<bundle-ip>/32"]. (The renderer that chunk 1 shipped under the gvproxy design — name=, [[net]] — gets rewritten as part of this chunk plan.)
  • A host-side sidecar-bundle lifecycle manager that brings up one container per bottle on a dedicated per-bottle docker bridge with a pinned IP (--ip <bundle-ip>), waits for the daemons to bind their ports, 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).
  • Per-bottle docker bridge: a bot-bottle-bundle-<slug> network with a /24 subnet derived from the slug hash; the bundle gets a pinned IP at .2 (gateway is .1). Pinning the IP at start time avoids a race between the bundle's IP being assigned and the Smolfile being written.
  • TSI policy: the Smolfile sets [network] allow_cidrs = ["<bundle-ip>/32"] and nothing else. The agent can reach the bundle's IP (any port) and nothing else; no DNS resolution is available inside the guest, so the agent uses IP-literal URLs.
  • Bundle bind addresses: egress binds 127.0.0.1:9099 inside the bundle (pipelock-only); pipelock / git-gate / supervise bind 0.0.0.0 so the agent can reach them. This is the port-granularity TSI's IP-only allowlist doesn't provide. PRD 0024's bundle init may need a config knob for this; raised as open question 4.
  • 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 (smolvm 0.8.0 shape).
    • 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

bot_bottle/backend/smolmachines/
  __init__.py            re-exports SmolmachinesBottleBackend
  backend.py             SmolmachinesBottleBackend façade
  bottle.py              SmolmachinesBottle (exec_agent / 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
  sidecar_bundle.py      host-side bundle lifecycle (per-bottle docker bridge + pinned IP)
  smolvm.py              thin subprocess wrapper: machine create/start/exec/stop, pack create
  util.py                slugify, subnet derivation, OCI archive helpers
  provision/             ca.py, prompt.py, skills.py, git.py, supervise.py

Note what's NOT here vs. the original draft: gvproxy.py, vfkit_attach.py. The gvproxy design needed both; the TSI single-IP design needs neither.

Network + egress topology

  ┌── macOS host ─────────────────────────────────────────────────────┐
  │                                                                   │
  │  ┌── per-bottle docker bridge bot-bottle-bundle-<slug> ──┐     │
  │  │  subnet: 192.168.X.0/24 (X = hash(slug) mod 254)          │    │
  │  │                                                            │   │
  │  │  ┌── bundle container (pinned --ip 192.168.X.2) ────────┐  │   │
  │  │  │  init.py (PRD 0024 Python supervisor)                │  │   │
  │  │  │   ├─ pipelock           (binds 0.0.0.0:8888)         │  │   │
  │  │  │   ├─ egress (mitmproxy)  (binds 127.0.0.1:9099)      │  │   │
  │  │  │   ├─ git-gate            (binds 0.0.0.0:9418)        │  │   │
  │  │  │   └─ supervise           (binds 0.0.0.0:9100)        │  │   │
  │  │  │  Internal-only egress is unreachable from outside    │  │   │
  │  │  │  the bundle even though TSI permits the IP.          │  │   │
  │  │  └──────────────────────────────────────────────────────┘  │   │
  │  └──────────────────────────────────────────────────────┬─────┘   │
  │                                                         │         │
  │  ┌── microVM (per bottle, libkrun via smolvm) ──────────▼─┐       │
  │  │  Smolfile: [network] allow_cidrs = ["192.168.X.2/32"]  │       │
  │  │  env: HTTPS_PROXY=http://192.168.X.2:8888              │       │
  │  │       GIT_GATE_URL=git://192.168.X.2:9418  (cond.)     │       │
  │  │       MCP_SUPERVISE_URL=http://192.168.X.2:9100 (cond) │       │
  │  │  No other host reachable — TSI denies any connect()    │       │
  │  │  that isn't to 192.168.X.2. No DNS inside the guest    │       │
  │  │  (no resolver in the allowlist).                       │       │
  │  └────────────────────────────────────────────────────────┘       │
  │                                                                   │
  └───────────────────────────────────────────────────────────────────┘

What the guest can reach, exhaustively: only <bundle-ip> on ports the bundle binds to 0.0.0.0. Egress's 127.0.0.1-only bind makes it bundle-internal; host loopback / LAN / public internet direct are all refused by TSI's allowlist.

Three changes vs. the Docker backend:

  1. One sidecar container per bottle, not four. Same bundle image PRD 0024 ships for the docker backend.
  2. Sidecar container is on a per-bottle docker bridge with a pinned IP, reached directly by the smolvm guest's allowed /32 — no localhost port allocation, no userspace TCP/IP stack in the middle.
  3. The agent dials IP literals, not hostnames. TSI doesn't filter DNS at the protocol level, and we don't put DNS resolvers in the allowlist, so name resolution is denied by construction.

Lifecycle

SmolmachinesBottleBackend.prepare(spec, stage_dir):

  1. Cross-backend validation via BottleBackend._validate (skills, git identity files).
  2. Derive a per-bottle docker subnet from sha256(slug) % 254 (skipping the docker-default 17): 192.168.X.0/24. The bundle IP is always 192.168.X.2 (gateway is .1).
  3. Resolve the agent guest image: docker build the existing Dockerfile, then convert the resulting image into a .smolmachine artifact. Empirically smolvm pack create only reads OCI registry refs — it rejects docker-daemon://, oci-layout://, docker-archive: tarballs, and every other transport tested. The conversion path is a registry hop: bring up an ephemeral registry:2.8.3 container bound to 127.0.0.1:<random>, docker tag + docker push into it, smolvm pack create --image localhost:<port>/bot-bottle:<id>, tear down the registry. The .smolmachine is cached under ~/.cache/bot-bottle/smolmachines/ keyed by the docker image ID, so Dockerfile changes invalidate the cache and unchanged rebuilds skip the whole pipeline.
  4. Render the per-bottle Smolfile to stage_dir/smolfile.toml using smolvm 0.8.0's schema:
    • image / entrypoint / cmd — bundled into the .smolmachine from the previous step (one Smolfile, one artifact).
    • env = [...]HTTPS_PROXY, NO_PROXY, NODE_EXTRA_CA_CERTS, etc., all pointing at IP-literal URLs (http://192.168.X.2:8888).
    • [network] allow_cidrs = ["192.168.X.2/32"] — TSI's single /32 allowlist.
  5. Resolve the in-VM CA paths so launch knows where to copy pipelock's CA after start.
  6. Return a SmolmachinesBottlePlan carrying the slug, bundle subnet/IP, .smolmachine artifact path, Smolfile path, and bundle run spec.

SmolmachinesBottleBackend.launch(plan):

  1. Create the per-bottle docker bridge network (bot-bottle-bundle-<slug> with the resolved subnet) and start the sidecar bundle container with docker run --network ... --ip <bundle-ip> .... Wait for its daemons to bind: pipelock on 8888, git-gate on 9418 (conditional), supervise on 9100 (conditional). Register teardown callbacks.
  2. smolvm machine create --from <stage>/agent.smolmachine --smolfile <stage>/smolfile.toml <name> and smolvm machine start --name <name>. The Smolfile's TSI allowlist gates outbound to the bundle's /32; libkrun's TSI layer enforces it.
  3. Provisioning: CA install → prompt → skills → git → supervise config, each via smolvm machine exec / smolvm machine cp.
  4. Yield a SmolmachinesBottle whose exec_agent / exec / cp_in all funnel through smolvm machine exec / smolvm machine cp.
  5. Teardown: stop and delete the VM → stop + remove the bundle container → remove the per-bottle docker network.

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 inside the bundle (unchanged from the docker backend); the guest has no DNS resolver in TSI's allowlist, so an agent that tries to dial an arbitrary hostname can't resolve it in the first place — the DNS-exfil attack from PRD 0022 test 4 is blocked at the resolver step.

The BottleSpec dataclass and the Bottle ABC do not change.

Selection wiring

In bot_bottle/backend/__init__.py:

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 (currently 0.8.x).
  • No gvproxy dep (the original draft listed it; dropped after the chunk-1 spike).
  • No pyobjc-framework-Virtualization dep (dropped from the original draft for the same reason).
  • No new pure-Python packages. Subprocess + stdlib tomllib for Smolfile authoring.

Acceptance test plan

  • Unit (smolfile): tests/unit/test_smolfile.py verifies the renderer produces the expected TOML for a fixture bottle in smolvm 0.8.0's schema — top-level image / entrypoint / cmd / env, plus [network] allow_cidrs = ["<bundle-ip>/32"] and nothing else under [network].
  • Unit (subnet derivation): the existing test_smolmachines_util.py covers the per-bottle subnet hash
    • collision-avoidance and stays as-is.
  • Integration smoke: tests/integration/test_smolmachines_smoke.py with prepare → launch → exec → teardown, guarded by a smolvm 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:<unused-port>, and asserts the in-bottle agent cannot connect to it. This is the regression test for the exact gap --outbound-localhost-only would have introduced — with --allow-cidr <bundle-ip>/32 only, the probe must fail.
  • Egress-port-bypass probe: also brings up a bottle and asserts the in-bottle agent's connect to <bundle-ip>:9099 (egress's port) is refused — confirming the bundle-internal bind of egress to 127.0.0.1 works as the port-granularity layer TSI doesn't provide.
  • PRD 0022 re-run: with BOT_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 bot-bottle-sidecars:<pinned> is available when chunk 3 lands.

  1. Backend skeleton + selection + Smolfile/gvproxy renderers. Shipped (PR #62), but under the now-rejected gvproxy design. The Smolfile renderer emits name = … / [[net]] instead of smolvm 0.8.0's image / [network] allow_cidrs. The gvproxy renderer is dead. Chunk 2 rewrites the Smolfile renderer and deletes gvproxy_config.py / its tests.
  2. VM lifecycle + bundle bringup + Smolfile rewrite. smolvm.py subprocess wrapper, prepare-time image conversion (smolvm pack create.smolmachine), per-bottle docker bridge + bundle container with pinned IP, launch path that starts the bundle and brings up the VM (smolvm machine create --from --smolfile), exec into the VM, tear everything down. Smoke integration test: exec("echo hi") inside a started VM. Includes the localhost-reach probe + egress-port-bypass probe from the acceptance plan. The chunk-1 Smolfile renderer gets rewritten to the smolvm 0.8.0 schema; gvproxy_config.py and gvproxy.py (if any) get deleted.
  3. Bundle bind-address mitigation. Update PRD 0024's bundle init to bind egress on 127.0.0.1:9099 instead of 0.0.0.0 (or expose a config knob — open question 4). Reverify the egress-port-bypass probe. Pipelock / git-gate / supervise continue to bind 0.0.0.0.
  4. Provisioning parity with Docker. CA install via smolvm machine cp, 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 Resolved. Chunk-1 spike against smolvm 0.8.0 confirmed there's no virtio-net-over-unixgram option; the gvproxy design isn't viable on top of smolvm. Resolved by switching to TSI --allow-cidr <bundle-ip>/32 + bundle bind-address mitigation; smolvm stays as the VMM. See the "Design pivot from the first draft" section.

  2. smolvm install policy. Pin via brew / curl install.sh, or vendor a binary in the repo. v1 likely runs smolvm --version at preflight and accepts a documented range (currently 0.8.x). The curl -sSL https://smolmachines.com/install.sh | sh path is what the operator used; document it in the README.

  3. CA install inside the agent guest. Two paths: bake at prepare time (one .smolmachine artifact per CA fingerprint, big cache key) vs. inject at start time via smolvm machine cp 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 cp + machine exec. Default to runtime injection.

  4. Bundle bind-address knob. PRD 0024's bundle currently runs all four daemons under one supervisor with daemon argv hardcoded. To make egress bind 127.0.0.1:9099 instead of 0.0.0.0:9099, either: (a) edit the supervisor's _DAEMONS entry to pass a --listen-host 127.0.0.1 flag to mitmdump, OR (b) introduce a per-daemon bind_localhost knob the renderer can set. Option (a) is simpler and matches that egress is bundle-internal regardless of backend; resolve in chunk 3.

  5. bottle.exec(script) exit-code fidelity. The PRD 0022 test suite reads returncode + stdout + stderr from ExecResult. Confirm smolvm machine exec propagates exit codes and separated streams. The CLI help mentions a --stream flag for streaming output; behavior under default (non-stream) mode is what we want — verify in chunk 2.

  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. The skip predicate becomes not (smolvm_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 ls --json; the plan is to filter on a deterministic name prefix bot-bottle-<slug> + cross-reference with on-disk metadata under state/<slug>/.

  8. Loopback scoping (Docker Desktop pivot). The original design pinned the bundle at a docker bridge IP and set TSI's allowlist to <bundle-ip>/32. On Docker Desktop / macOS the daemon runs inside its own Linux VM, so bridge IPs aren't reachable from macOS networking — TSI's syscall impersonation can't reach them. Resolution: publish each agent-facing bundle port on host loopback (-p 127.0.0.1::<port>) and set TSI to 127.0.0.1/32. This widens the TSI allowlist to anything bound to macOS's loopback — postgres, dev servers, other bottles' published ports, mDNSResponder, etc.

    Fix + smolvm 0.8.0 workaround. Allocate each bottle a unique loopback alias (127.0.0.16 .. 127.0.0.31), bind bundle port-forwards to it, set TSI's allowlist to that alias's /32. The agent can only reach its own bundle; other bottles' ports, host loopback services, and the internet are all denied.

    Smolvm 0.8.0 silently drops --allow-cidr when combined with --from <smolmachine> (verified empirically: agent.config.json shows allowed_cidrs:null despite the flag). The launcher patches smolvm's persistent state DB (~/Library/Application Support/smolvm/server/smolvm.db, vms.data BLOB) between machine create and machine start to set the allowlist directly. Smolvm reads the DB at start, so TSI enforces. Tested end-to-end: VM → 127.0.0.1 = "Permission denied"; VM → <alias>:<bundle-port> = connects.

    Other paths tried that didn't work: machine update --allow-cidr doesn't exist; stop-edit-agent.config.json- restart fails (file removed on stop); --smolfile mutually exclusive with --from; --image localhost:<port>/... fails because smolvm's pull agent can't reach host loopback during pull. When smolvm honors --allow-cidr with --from upstream, the DB patch becomes redundant and can be removed.

References

  • docs/research/agent-vm-isolation.md — describes the gvproxy + VZFileHandleNetworkDeviceAttachment path. The current design no longer needs that recipe (the TSI single-IP approach replaced it after the chunk-1 spike); kept for historical context if a future operator needs to drop smolvm and own the VM lifecycle directly.
  • docs/research/smolmachines-as-vm-backend.md — evaluation of smolmachines as the VM lifecycle wrapper. The research note's TSI-bad-due-to-loopback-gap argument turned out to apply only to --outbound-localhost-only, not to TSI generally; this PRD uses --allow-cidr <bundle-ip>/32 instead, sidestepping the gap.
  • 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 (bot-bottle-sidecars) this PRD consumes. Prerequisite for chunk 3 of this PRD.