Trust-less devboxes for creds-free and network-isolated coding agents
Find a file
claude-hdb 6f7c3bfd60 fix: run 17's four real findings — migrate retire, expose proxy, wait_box, grok PATH
The first drill run where every failure was the RELEASE CODE, not the
environment. 71 passed, 5 failed; all five traced to four bugs:

1. migrate-host --retire-legacy could NEVER succeed. Re-homing ADDS
   user.box=1 but never removed user.claudebox=1, and legacy_boxes()
   counted the old tag — so retire saw its own freshly-migrated box as
   un-migrated and refused forever ('legacy boxes still exist:
   legacybox'), leaving claudenet + claude-dev behind. Now: a verified
   re-home drops the legacy tag LAST (after the move is proven, so a
   failure anywhere above still leaves the box valid under one tag or
   the other), and legacy_boxes() ignores boxes already carrying
   user.box=1.

2. box expose died with a bare 'could not add the proxy device' — it
   swallowed incus's reason, exactly the sin this repo keeps punishing.
   Now it prints incus's error. And the mechanism is corrected: a VM's
   proxy needs NAT mode, which requires a static NIC address, so expose
   pins the box's current lease first (which also fixes the restart
   caveat — the exposure no longer points at a lease the box may lose).

3. wait_box's 2-minute window was too short: the legacy box was declared
   dead and then every migration check against it passed. 4 minutes.

4. The grok template hunted for a regular file named exactly
   'grok-build' under /home/grok and found nothing — an installer's drop
   may be a SYMLINK, and its binary name is upstream's to choose. Now it
   tries the plausible names and paths, falls back to any executable
   grok*, links both names, and SAYS what it found — or dumps what the
   installer actually left when it finds nothing. The drill likewise
   dumps the on-disk evidence and the cloud-init log on a --version
   failure instead of discarding the box.
2026-07-14 19:56:17 +00:00
bin fix: run 17's four real findings — migrate retire, expose proxy, wait_box, grok PATH 2026-07-14 19:56:17 +00:00
docs feat: host lifecycle as verbs (setup-host/teardown-host/migrate-host) + .box/ convention 2026-07-14 18:01:34 +00:00
drill fix: run 17's four real findings — migrate retire, expose proxy, wait_box, grok PATH 2026-07-14 19:56:17 +00:00
host fix: run 17's four real findings — migrate retire, expose proxy, wait_box, grok PATH 2026-07-14 19:56:17 +00:00
profiles fix: box-net's NIC still pointed at claudenet — the drill caught it in seconds 2026-07-14 14:43:43 +00:00
templates fix: run 17's four real findings — migrate retire, expose proxy, wait_box, grok PATH 2026-07-14 19:56:17 +00:00
.gitignore Import claudebox: creds-free, trust-less Claude Code VMs 2026-07-10 15:00:36 +00:00
install.sh fix: assert the install landed the ref we asked for — a silent wrong-install is worse than a failure 2026-07-14 19:10:48 +00:00
LICENSE Add MIT License to the project 2026-07-11 21:33:51 +01:00
README.md feat: host lifecycle as verbs (setup-host/teardown-host/migrate-host) + .box/ convention 2026-07-14 18:01:34 +00:00
VERSION chore: VERSION → 0.5.0 (templates, migrate-host, expose) 2026-07-14 16:13:08 +00:00

claudebox — ships the box CLI

Headless, trust-less, throwaway dev VMs. One command mints a fresh, network-isolated Incus box from a template; the flagship template is claude — Debian 13 with Claude Code installed, the box this repo is named for. The box is the product — you log in and work; destroying it loses nothing you didn't push.

Strictly creds-free. A box ships with everything installed and no credentials — no Claude token, no git PAT, nothing. You authenticate interactively inside the box. The tool never stores or injects a secret. That means there's nothing shared or committed, so it's safe for multiple operators out of the box.

Templates set what's in the box, never what it can reach. A template is image + user + resources + cloud-init; the network and every security flag live in a shared profile no template can touch, so blank is a box with nobody home — not a box with the safety off.

The tool knows nothing about your projects. You just git clone inside a box. A repo can ship an optional .box/ runbook that Claude Code reads and acts on — there is no install step and no host-run setup. See docs/box-design.md for the design rationale.

0.4.0 is a clean cut: the CLI is box (no claudebox shim), the host stack is boxnet/box-isolate/box-firewall on 10.88.0.0/24, and the default template is blank. Existing boxes minted by any earlier version keep working under every verb — their legacy tag is honored forever, and their old claudenet (10.87) is left standing beside the new bridge. To strip a host of both generations at once: host/teardown-host.sh, or drill/wipe.sh for the scorched-earth version.

Install

curl -fsSL https://raw.githubusercontent.com/heavy-duty/claudebox/main/install.sh | bash

Installs the tree to ~/.local/share/box and links box onto your PATH. Re-run any time to upgrade — upgrading from a pre-0.4.0 install also retires the old claudebox symlink. (No git clone needed.)

One-time host setup (Ubuntu 24.04 / Debian 13)

box setup-host   # run twice if it adds you to incus-admin (re-login between)

Idempotent. Installs Incus and creates the isolation stack: the boxnet NAT bridge (sibling-name resolution off, resolver pinned to public upstreams — BOX_DNS overrides), the box-isolate ACL (drops all RFC1918/CGNAT/ link-local egress), the box-net profile (port-isolated NICs — boxes can't reach each other), and firewall rules blocking instance → host. All rules re-apply at boot via box-firewall.service — no post-reboot ritual. If the host lacks dnsmasq-base (Debian cloud images skip Recommends): sudo apt-get install -y dnsmasq-base.

Quick start

box new --name work --template claude   # a creds-free Claude box (~10 min cold)
box shell work                   # enter as the template's user

Inside the box, authenticate as needed:

claude                           # then run /login — copy the URL (press c), open it
                                 #   in YOUR browser, paste the code back. No host CLI needed.
gh auth login                    # or drop a PAT in — your git credentials, your call
git clone https://github.com/you/project && cd project
claude                           # if the repo has .box/, Claude reads it and sets up

Templates

The claude box is one template among several. What ships today:

Template What's in it
blank Bare Debian 13 — same isolation, no tooling. The default.
claude Claude Code, creds-free (the original claudebox)
codex OpenAI Codex CLI, creds-free
grok xAI Grok Build CLI, creds-free

A template is a directory under templates/: a box.env (image, user, resources — parsed against a strict allowlist, never sourced) and a user-data.yaml (cloud-init, passed to Incus verbatim). The coding-CLI templates are all the same shape — install the CLI, put it on PATH, drop an agent-context file; none of them carry credentials.

box templates                    # list what this install can mint
box new --name scratch           # the DEFAULT template is blank: bare Debian,
                                 #   same isolation, nobody home

A template cannot name a network, a profile, or a security.* flag — there is no key for them. Every box launches with the shared box-net profile (the isolated NIC + root disk), so every template gets the identical trust boundary. Resources come from the template's box.env; BOX_CPU / BOX_MEMORY / BOX_DISK environment variables override them at mint time. The template's identity (name, user) is stamped onto the instance, so shell, exec and tmux land in the right user — and a clone still knows, because incus copy carries the metadata.

Log in once, reuse via snapshots

Because every fresh box is creds-free, re-authenticating each time would be toil. Snapshot an authenticated box and clone from it instead:

box snapshot work authed   # checkpoint after you've logged in
box new --name feature --from work/authed   # clone the authed state into a new box

--from copies the whole box (Claude login, git creds, clones and all) while preserving isolation. You can also box new --name x --from work to clone a box's live state, or roll a box back with box restore work authed.

Forgotten what you called a checkpoint? box info work prints the box's snapshot labels and the --from line to clone one.

Commands

box new --name <box> [--template <t>] [--from <src>[/<snap>]] [--vm|--container] [--remote r]
box templates                # list the templates this install can mint
box list                     # list your boxes
box info <box>               # one box: state, IP, snapshot labels
box shell <box>              # enter as the template's user
box exec <box> -- <cmd...>   # run a command in the box
box tmux <box> [session]     # attach/create a tmux session — survives disconnects
box snapshot <box> [label]   # checkpoint (label defaults to manual-<epoch>)
box restore <box> <snap>     # roll back to a snapshot
box rename <box> <new>       # rename a box (stop it first)
box down <box>               # stop (state kept; `start` resumes)
box start <box>              # start a stopped box
box rm <box> [--force]       # delete the box + its snapshots (asks first)
box expose <box> <port>      # forward a box port to host loopback — see a dev server
box incus <box> -- <args...> # escape hatch: any incus command, box resolved
box doctor [--fix|--pin-dns] # is this host fit to mint boxes? diagnose from ground truth
box status                   # deprecated alias for `list`
box help [<command>]         # full help, or one command's page

Every command takes --help, and options come after the command (box list --json). Exit status: 0 ok, 1 it went wrong, 2 you asked wrong.

new fresh-launches from a template (default: claude), or with --from clones an existing box or snapshot. VM mode (--vm, the default where /dev/kvm exists) is the trust-less target; container mode (auto-fallback, security.nesting=true) is for hosts without nested virt — weaker isolation, dev/test only.

Boxes are just Incus instances

A box is an ordinary Incus instance tagged user.box=1 (pre-0.4.0 boxes carry user.claudebox=1, honored forever). box wraps the box lifecycle and the isolation model — not all of Incus. It owns a command when it must enforce something Incus can't see: that tag (it will not stop, rename or delete an instance it didn't mint), the isolation stack, or the creds-free snapshot workflow. For everything else, there's the door:

box incus work -- config show        # instance name appended
box incus work -- file push x.tar {}/tmp/   # or placed with {}

The box is resolved and tag-checked; the rest is passed to incus verbatim, and the command is echoed before it runs. If it can move the box off the isolation stack (profile, network, device, security.*), box warns and proceeds — the trust boundary is then yours to keep. See docs/box-design.md for the rule and why the command surface is a table.

Isolation

The contract: a box reaches the public internet and nothing else. Not the host, not your LAN, not another box, not even another box's name. What enforces it, layer by layer:

  • Dedicated NAT bridge boxnet, IPv6 off. Every rule below is IPv4-only, so IPv6 would be an uncovered path — off is part of the contract, not a default.
  • box-isolate ACL — drops all egress to private space (RFC1918, CGNAT, link-local), with a single carve-out to the gateway so DNS works.
  • Sibling isolation, at L2 — two boxes on one bridge are switched, never routed, so no L3 rule can separate them (learned the hard way; see below). security.port_isolation on every box NIC plus an nft bridge-family drop mean box A cannot exchange frames with box B at all.
  • No name-level reconnaissancedns.mode=none stops the gateway resolving sibling names, and the bridge's resolver is pinned to public upstreams (no-resolv), so tailnet names and split-DNS zones from a host-level VPN don't resolve inside a box either.
  • Host firewall — instance → host is dropped except DNS/DHCP, including the host's public IPs. Entry is incus exec over the local socket only — no inbound path exists — unless you punch one with box expose, and that door only ever opens onto the host's own loopback (127.0.0.1), never the network.

The VM is the trust boundary: whatever runs inside — Claude, or anything a template ships — can run arbitrary code and touch nothing you care about.

Measured, not claimed

Every clause above is probed live by an end-to-end drill, because the one time this contract was reasoned about instead of measured, the reasoning was wrong: box→box traffic was "covered" by an L3 drop that L2-switched frames never meet — a hole found by probing, not by reading the rules. On a bare host the drill installs the whole stack, mints a box cold, snapshots and clones it, probes every boundary from inside the boxes, and removes what it minted — currently 47 checks, 47 passing. drill/RUNS.md is the full history, including every trap that fooled a run into a wrong verdict.

bash drill/doctor.sh    # read-only: is this host healthy and the stack live?
bash drill/drill.sh     # FULL end-to-end — mutates the host; use a machine you own
bash drill/wipe.sh      # scorched earth: strip BOTH name generations, images and
                        #   (--purge-storage) the pool, so a run starts from bare

The doctor reads ground truth, not config claims — the kernel's isolated on flag per bridge port, the process table, the resolver actually in use — and diagnoses the host faults that have actually happened: a wedged Incus daemon, a dnsmasq that silently isn't serving, a VPN resolver that boxes would inherit.

Recipes: the .box/ convention

A repo that wants to be easy to stand up in a box ships an optional .box/ folder — a runbook Claude reads and follows (install deps, start services, template env, seed data, smoke-test). It is agent-facing documentation, not a host-executed script. See docs/box-recipe.md.

Uninstall

box teardown-host                # boxes, network, ACL, profile, firewall
box teardown-host --purge-incus  # ...and Incus itself
rm -rf ~/.local/share/box ~/.local/bin/box   # the CLI itself

Non-goals

  • No unattended/CI bring-up. The flow is interactive (log in, clone, ask Claude). Reproducible-by-construction provisioning is out of scope.
  • No credential storage or injection by the tool. Boxes are creds-free; snapshots are the reuse mechanism, not a secrets store.