box/docs/box-design.md
dan-claude-bot bf74744bda feat: kimi-box template — the Moonshot Kimi CLI agent seed
The fourth agent seed, thin per #81: Debian 13, the 'kimi' user, tmux
and rig — everything the box becomes lands via 'rig bootstrap kimi-box'
(heavy-duty/rig#109). Same resources as the sibling agent seeds. The
template loop in test/cli.sh discovers it dynamically; doc lists and the
README table gain the row.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-22 15:11:22 +00:00

15 KiB
Raw Blame History

box design

box is a CLI that mints and manages trust-less, network-isolated VMs with a coding agent installed (claude-box, codex-box, grok-box, kimi-box, or blank for none). It is infrastructure, not a project provisioner.

See issue #3 for the full reframe and rationale. This doc captures the durable design decisions.

Principle: separate the tool from the agent

  • The tool mints isolated boxes with the agent installed but unauthenticated. It knows nothing about projects, secrets, recipes, or memory.
  • The agent (Claude Code, Codex, Grok — whichever template, inside the box) reads an optional .box/ runbook in a cloned repo and acts on it. The recipe's consumer is the reasoning agent, not host machinery.

Boxes are strictly creds-free

box new --name <n> launches a blank box: everything installed, no git credentials and no agent credentials. The operator authenticates interactively inside the box:

  • The coding agent — e.g. claude/login (paste-a-code OAuth: copy the URL, open it in your own browser, paste the code back); codex and grok have their own login step. Works because the box is outbound-only; the tool never handles a token.
  • Git — the operator adds their own PAT / gh auth login inside the box.

The tool stores and injects no credentials, ever. This dissolves the multi-user problem: nothing shared, nothing committed.

Snapshots are the reuse mechanism

Re-authing every fresh box would be toil, so authenticated state is reused via snapshots, not a secrets store:

  • box snapshot <n> [label] — checkpoint after login + clone.
  • box new --name <n2> --from <src>[/<snapshot>] — clone an existing box or snapshot (authed state and all). Isolation is preserved: the clone keeps the box-net profile + boxnet + ACL.
  • box restore <n> <snapshot> — roll a box back to a checkpoint.

Log in once → snapshot → spin up authed boxes from it.

One checkpoint box takes itself: pristine, marked on every fresh mint after cloud-init and before the rig bootstrap hook (#104, child of heavy-duty/rig#62). That instant — pristine Debian plus box's thin seed, rig installed but not yet run — is the state "back to pristine Debian" names, it lasts a few seconds, and nobody is standing there to snapshot it. box restore <n> pristine undoes a tenant role wholesale, because everything the creds-free roles do is box-local and file-shaped. A --from clone takes no pristine: it never has the moment, so it inherits its source's snapshots or has none, and box refuses to label a worked-in state as pristine. On a dir pool the mark would be a full copy rather than a CoW one, so the mint skips it loudly.

A second, bootstrapped, is marked one step later (#130) — after the rig hook has run and box has watched it succeed. Same policy, literally the same function: default on, never fatal, skipped on dir, opt-out via BOX_SNAPSHOT_BOOTSTRAPPED=0. Where the two differ is deliberate. pristine marks a moment every fresh mint has, so it is unconditional; bootstrapped marks an event, so it is not. A blank box has no hook and therefore no event — marking it anyway would duplicate pristine byte for byte at twice the disk cost, or assert a convergence that never happened. A failed hook gets no mark either: box tells the operator to re-run the role through box shell, which is a run box does not watch, so it hands over box snapshot <n> bootstrapped instead of guessing. The consequence is a label that is only ever read one way — presence means "converged and untouched"; absence means nothing, and nothing in box may treat a missing bootstrapped as evidence a box is unconverged.

Snapshots are in-box state: box rm deletes a box and its snapshots, and a clone still lives on the same host. The off-host mechanism is box export / box import (#70) — one portable backup tarball, snapshots included by default, that survives rm, a host teardown, an upgrade, a move. The split of truths is the design: everything incus import restores is the artifact's (disk, config, snapshots); everything box re-stamps on import is the current host's (the user.box=1 boundary tag, the box-net placement, a fresh machine identity via the same reset_identity a clone gets, and the record that the trip happened). That last one is #131, and it is deliberately not origin=import: origin says how the instance came into being — mint or clone — and the import is a third, orthogonal fact. Overwriting origin would make an exported clone come back claiming to be an import, with its origin.from lineage left unreadable, so the import gets its own keys and the artifact's mint stamp survives the trip untouched. Auth state rides along deliberately — and because scrubbing a disk image is a promise tarball surgery cannot keep, export shouts that the file is a credential instead of pretending to sanitize it.

Thin templates: box mints, rig converges (#81)

A template is a thin, creds-free seed — base image, the tenant user, tmux, and rig preinstalled — and what the box becomes lives in rig's bootstrap roles (rig#31): box auto-runs the template's creds-free tenant role after cloud-init (rig bootstrap claude-box / codex-box / grok-box / kimi-box / staging-box — the roles carry a family suffix, -box for box tenants and -server for fleet machines, and the templates are named for the roles they converge, rig#76), which installs the agent CLI or server posture. The split is deliberate: cloud-init is a first-boot one-shot — not convergent, not re-runnable, only parse-and-grep testable — while a rig role is an idempotent script with effective-state asserts that can also converge an existing box to a newer spec. Anything that joins a tailnet or holds a key (the staging-box tenant's workload join) stays operator-run through box shell; box prints it as a next step and never sees the key. The seed's rig install is pinned by RIG_REPO/RIG_REF at mint (default heavy-duty/rig@main, unpinned — the honest edge until rig#32's releases), and box's template suite holds the line with fail-closed absence greps: no agent CLI, no docker, no tailscale, no context-file heredocs in any template, ever again.

The box announces itself to the agent

Every coding-agent box gets a global agent-context file (~/.claude/CLAUDE.md, ~/.codex/AGENTS.md, ~/.grok/AGENTS.md) telling the agent it is running in a box (trust-less, ephemeral, creds-free) and to treat a repo's .box/ folder as its bootstrap runbook. No "tell it" step, no host execution. The file is rendered by rig's tenant roles from one shared template (#81) — including the #80 guard: never run box setup-host, box teardown-host or the drill inside a box; a nested box stack claims the guest's own uplink subnet and silently breaks its networking.

.box/ is optional, agent-facing documentation

Not host-executed shell. A repo that wants to be easy to stand up in a sandbox ships a runbook (prose + optional scripts the agent may run). A repo that does not, you set up by hand. The tool enforces no contract; there is no install.

What box owns, and what it doesn't

Boxes are ordinary Incus instances, tagged user.box=1. That makes every Incus verb a candidate feature request — rename, info, file push, on forever — and wrapping them one at a time grows a worse incus. The rule:

box owns a command when it must enforce an invariant Incus cannot see: the user.box=1 boundary (never touch an instance we didn't mint), the isolation stack (box-net profile + boxnet + ACL), or the creds-free snapshot→clone workflow. Everything else is Incus's job.

The rule cuts both ways, and that's the point:

  • rename is ours — not because it adds logic to incus rename, but because resolving the name is the logic: check the tag, apply --remote, and notice the box is running (Incus won't rename a running instance) so we can say "stop it first" rather than leak an Incus error.
  • incus config set security.nesting=false is not ours. It dismantles the trust boundary; wrapping it would imply we bless it.

Two mechanisms keep this honest.

The command table (CMDS in bin/box) is the single source of truth for what exists, its synopsis, its help line, its preconditions and what runs. Dispatch and help are both rendered from it, so the help cannot describe a command that doesn't exist — the failure that produced #8. A thin verb is one row; a verb that can't be expressed as a row and enforces no invariant of ours doesn't belong in the tool.

The escape hatchbox incus <box> -- <args...> — resolves and tag-checks the box, then hands the rest to Incus verbatim. It means "no" to a proxy request is not "you can't do that", and it keeps the one rail that matters: you cannot aim it at an instance box didn't mint. If the command can move the box off the isolation stack (profile, network, device, security.*), it warns and proceeds — from there the trust boundary is yours to keep.

Isolation

Dedicated NAT bridge boxnet + Incus box-isolate ACL dropping all RFC1918/CGNAT/link-local egress, plus host-firewall rules blocking instance → host. Entry is incus exec over the local socket — no inbound path. The VM is the trust boundary.

A box reaches the public internet and nothing else — including no other box. That last clause is the one that was assumed and turned out to be false, so it is spelled out here with the mechanism, and drill/ tests it on every run.

  • Box → host, LAN, RFC1918, CGNAT, link-local: the box-isolate ACL.
  • Box → box: an nftables bridge-family rule (host/box-firewall.sh). It cannot be an ACL rule. Two boxes on one bridge share an L2 segment, so their frames are switched between bridge ports and never traverse the netfilter path an L3 ACL lives on — the ACL looked airtight (it drops 10.0.0.0/8, which contains boxnet) while box→box was in fact wide open. A live probe found box A's SYN arriving at box B. The bridge family's forward hook fires exactly on port-to-port frames, which on this bridge means box→box and nothing else: gateway traffic and routed egress are delivered locally, not forwarded. Dropping every forwarded frame therefore isolates the boxes and costs them nothing.
  • Box → box by NAME: dns.mode=none. dnsmasq on the gateway held a record for every instance, so a box could enumerate its siblings even where it could not reach them. Blocked connections with open reconnaissance is not isolation.
  • IPv6: off (ipv6.address=none), and that is a contract, not a default — every rule above is IPv4-only, so IPv6 would be an uncovered path.
  • security.ipv4_filtering: deliberately NOT used. It breaks the box's networking (in-box Docker cannot pull or run a container). Tested, vetoed.

The rule that keeps this honest: isolation claims are tested, never reasoned about. The box→box hole existed because a plausible code reading said it could not. See drill/RUNS.md.

Multi-user hosts: access tiers

The daemon socket is binary — incus-admin holds everything on the machine — so a shared host needs a second tier, and Incus ships one: incus-user confines an incus-group member to an auto-created project user-<uid>, behind a restricted certificate that cannot name any other project. The tier is decided once, from the process's live credentials (box_tier(): UID 0 or incus-admin → admin; incus alone → restricted; neither → none), and every tier-aware verb reads that one function.

What incus-user does not do is honor box's contract — measured on Debian 13 / Incus 6.0.4 (#74), after the design that assumed it (#72) was vetoed by its own Task-0 rehearsal:

  • it pins each user's project to a private auto-created bridge (incusbr-<uid>) — a stock NAT bridge with none of the hardening: no ACL, no dns.mode=none, no resolver pin, IPv6 on;
  • it blocks snapshots — box's entire reuse workflow;
  • the box-net profile lives in the default project, invisible to theirs.

So the tier is an admin-run convergence (box grant <user>), not a group membership: put them in incus, touch incus-user once as them (the project is created lazily; nothing exists to converge until it does), then rewire the project — network access narrowed to boxnet and only boxnet, snapshots allowed, the shipped profile installed. Narrowing is the load-bearing decision: granting boxnet,incusbr-<uid> (the obvious fix) would leave an unhardened NAT bridge one --network flag away from any box they mint. With the private bridge unreferenced (its eth0 is removed from their default profile) and outside restricted.networks.access, the hardened network is not their default placement — it is the only placement their certificate can express. The grant survives incus-user restarts by that tool's own design (it configures a project only at creation), and a restricted certificate cannot widen its own project — both measured, not read.

Cross-USER isolation is the same mechanism as cross-box isolation, on purpose: their instances share boxnet with everyone's, and the bridge-family drop + port isolation + dns.mode=none already make any two boxes strangers. A restricted user CAN strip security.port_isolation from the profile copy in their own project — or skip the profile entirely and attach boxnet raw (--network boxnet); the network must be usable for the profile to work, and Incus has no allow-via-profile-only lever. So the guarantee is scoped, and said plainly: per-NIC port isolation is guaranteed for box-minted instances; a raw attachment keeps every network-owned control (the ACL, dns.mode=none, the resolver pin) and every host-owned one (the nft bridge drop) — losing only the redundant per-NIC L2 layer. Scoped, and measured: drill/multiuser.sh criterion (m) launches exactly that raw instance and probes egress, RFC1918, both sibling directions and name enumeration from inside it. Defense in depth, every layer measured (criteria an).

box revoke is two strengths: bare, it removes the group — their boxes keep running (revoking a person does not kill their workloads), grant restores everything, and because supplementary groups are read at login, revoke warns when live sessions keep the socket until they end (and names the loginctl command). --purge terminates those sessions first — a stale-group process could otherwise touch incus-user after the purge and lazily recreate the project with stock, unhardened defaults, undoing the grant's whole point — then deletes their world (boxes, images, project, private bridge, trust-store certificate) and asserts the absence afterwards. A failed grant backs its own group-add out on exit for the same reason: no half-granted user holding an un-narrowed socket.

Non-goals

  • Interactive-first: install and setup prompt by default (BOX_YES=1 and the CI rehearsal job are the sanctioned unattended paths).
  • No credential storage or injection by the tool.
  • No per-user resource quotas on the restricted tier (Incus's limits.*/restricted.* project keys exist when someone needs them).