Reframe claudebox as box: the Claude box is one template, and sibling isolation should be deliberate
#12
Labels
No labels
blocked
blocker:ci-red
blocker:conflict
blocker:drill-pending
blocker:unrequested
bug
claimed
documentation
enhancement
epic
merge-next
needs-triage
ready
release
scope:cli
scope:drill
scope:host
scope:installer
scope:templates
scope:tiers
stale
state:addressing
state:bots-reviewing
state:building
state:needs-human
No milestone
No project
No assignees
1 participant
Notifications
Due date
No due date set.
Dependencies
No dependencies set.
Reference: heavy-duty/box#12
Loading…
Reference in a new issue
No description provided.
Delete branch "%!s()"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
claudeboxmints exactly one kind of box: Debian 13 + Claude Code, one cloud-init file, one profile, one user namedclaude. That is a template, but it is welded to the tool. The tool underneath it is already generic — it is a thin, honest wrapper over Incus, and only a handful of lines in it know the word "claude".This issue proposes renaming the tool to
boxand making the Claude box one template among several (blank,claude, and whatever comes later) — and, while the naming is already being churned, closing a real gap in how boxes are isolated from each other.The two halves are separable and could ship as two PRs; they are filed together because they touch the same files and the same names.
Part 1 —
box, with templatesWhat is actually Claude-specific
The CLI is 587 lines; the whole repo, docs and installer included, is ~1,250. Everything that knows about Claude (line refs pinned to
main@0982a2d, post-PR-#13):bin/claudebox:423-426--profile claude-dev, the imageimages:debian/13/cloud, the singlecloud-init/user-data.yaml, and theuser.claudebox=1tagbin/claudebox:422profiles/claude-dev.yaml:4-5pins 4 CPU / 8GiBbin/claudebox:522-523sudo -u claude -iinshellandexec— the only structural blockerbin/claudebox:349-355,441,485resolve_box()gates every verb onuser.claudebox=1;listfilters on ithost/setup-host.sh,host/claudebox-firewall.sh,host/claudebox-firewall.service,host/teardown-host.shclaudenet,claude-isolate,claude-dev, nft tableinet claudebox,/usr/local/sbin/claudebox-firewall, the systemd unitinstall.shheavy-duty/claudeboxtarball),CLAUDEBOX_*env vars,~/.local/share/claudebox, theclaudeboxsymlink, and thebin/claudeboxtarball sanity check (install.sh:41,53)cloud-init/user-data.yaml:8~/.claude/CLAUDE.mdAll but one are find-and-replace. The
sudo -u claudeone is the design problem.The design problem, and the mechanic that solves it
shellandexechave to know which user to become inside the box, and today they simply know — it isclaude, always. Ablankbox has no such user.The fix is to stamp the template's metadata onto the instance at launch, so nothing downstream ever needs to re-read the template:
and then have
shell/execread it back off the instance. One subtlety:incus config getprints an empty string and exits 0 for an unset key, so the naive... || echo rootfallback never fires. And the fallback must not betray existing boxes — a pre-rename box carriesuser.claudebox=1and nouser.box.user, and it must keep landing inclaude, notroot:This matters more than it looks.
incus copypreserves config keys, sonew --fromkeeps working unchanged and a cloned box still knows what it is — which is the whole reuse story (log in once, snapshot, clone forever). Roughly 25 lines.And the user lookup is only half of compat. Since PR #13, every verb resolves through
resolve_box()(bin/claudebox:349-355), which refuses any instance not taggeduser.claudebox=1— so under a renameduser.box=1, an old box doesn't just land in the wrong user, it stops being a box at all: invisible tolist, unreachable byshell, unusable as a--fromsource. Whatever the compat answer, tag matching must accept bothuser.box=1anduser.claudebox=1— snapshots of old boxes outlive the release that minted them, and--from old/authedis the tool's core promise. The upside of PR #13: that dual match is now one function, not per-verb edits.One deliberate consequence:
execalways becomes the template's user, and every template sets one, so therootfallback is effectively unreachable. Anything genuinely needing root goes through theclaudebox incusescape hatch (PR #13) — worth a sentence in the help, not a flag.Template layout
The manifest is a
KEY="value"file, deliberately not YAML — this is a zero-dependency bash tool, and pulling inyqjust to readimage:anduser:would be the single largest cost in the change. But it must not besourced: sourcing hands every template arbitrary bash execution on the host at mint time, which breaks the honesty rule below in a different dimension. Read it with a strict allowlist parser (~10 lines of grep/case, still zero-dep) that accepts exactly theBOX_*keys above and rejects anything else. The parser is the enforcement mechanism for "a template cannot express a different network" — there is simply no key for it.Two keys have to agree by hand:
BOX_USERand the user created insideuser-data.yaml. The cloud-init file is passed through verbatim and never parsed by us (that stays true), so this duplication cannot be eliminated — it is accepted, andbox templatesshould be able to say so. A cheapgrep -q "name: $BOX_USER"sanity warning at mint time is optional polish, not a requirement.CLI surface
claudeboxsurvives as a shim forbox new --template claude, or is dropped outright — see Open questions.The rule that keeps this honest
A template must not be able to weaken isolation.
Keep the profile as the network attachment only — that is the isolation contract, identical for every box, one auditable object. Templates set image, user, resources, and cloud-init, and nothing else. Then no template can ever ship a box onto the wrong network by accident, and
blankis genuinely just "a box with nobody home" rather than "a box with the safety off".Precisely: the shared
box-netprofile carries the NIC and the root-disk device (with--profile, Incus does not apply the default profile, so the disk must live somewhere shared) — call it the placement contract: everything about where a box sits and what it can reach. The template'sBOX_CPU/BOX_MEMORYland as--config limits.cpu/limits.memoryat launch,BOX_DISKas the VM's--device root,size=override. Todayprofiles/claude-dev.yamlmixes contract and resources; splitting them is part of the work. (Migration detail: existing boxes referenceclaude-dev, and Incus refuses to delete a profile in use — soclaude-devstays until its last box is gone, or setup-host reassigns live boxes withincus profile assign. Either is fine; pick one and say so.)Enforcement is layered: the allowlist parser means a template file cannot name a network; the shared
box-netprofile means the CLI never takes one from anywhere else.Blast radius outside this repo
.claudebox/runbook convention is referenced by consuming repos (e.g.heavy-duty/incubator'sCLAUDE.mdand its.claudebox/SETUP.md). Renaming it to.box/breaks them. Recommendation: leave.claudebox/alone in v1 (or accept both). The tool's name and the repo-runbook convention do not have to move together.install.shis fetched ascurl .../heavy-duty/claudebox/.../install.sh | bashand installs to~/.local/share/claudeboxbehindCLAUDEBOX_*overrides. Renaming the GitHub repo breaks the documented curl URL (GitHub redirects clones, but docs and muscle memory still say claudebox); keeping it means a repo namedclaudeboxshipping a binary namedbox. This is a naming call, not code — flagged in Open questions. (Template discovery after install is free either way:bin/claudebox:8resolves$rootthrough the symlink withreadlink -f, and the installer already ships the whole tree, sotemplates/rides along exactly likecloud-init/does today.)boxis a generic name on PATH. Short, but collision-prone (other tools have claimed it). Worth a deliberate yes before committing.~/.claude/CLAUDE.mdtext. Cosmetic, not functional; they keep working.Part 2 — boxes are already isolated from each other, but by accident
The question that prompted this: multiple boxes share
claudenet— can they reach each other?They cannot. But for a reason nobody wrote down, and nothing tests.
host/setup-host.sh:31drops all egress to10.0.0.0/8. The claudenet subnet is10.87.0.0/24— which is inside10.0.0.0/8. So box A's packets to box B are dropped on A's own NIC, andsecurity.acls.default.ingress.action=drop(setup-host.sh:39) would drop them again at B. Sibling isolation is real, and it is belt-and-braces.The problem is that it is incidental.
docs/claudebox-design.md:95-97describes those drops as being about RFC1918 and the host's LAN. Sibling boxes are not mentioned anywhere in the design, the code, or a test. Move the subnet to192.168.50.0/24for some perfectly good reason and box-to-box isolation silently evaporates — while every stated design goal still reads as satisfied. That is the bug: not today's behavior, but that today's behavior is not load-bearing on purpose.The same shape of accident already has a second instance in the repo: IPv6 is off (
ipv6.address=none,setup-host.sh:24) and every ACL rule is IPv4-only. Today that is airtight; re-enable IPv6 for some perfectly good reason and there is not one rule covering it. The design should state that IPv6-off is part of the isolation contract, and the test below should assert it.Two gaps survive even with the drop working as intended:
DNS enumeration. The gateway carve-out (
setup-host.sh:30) exists so dnsmasq on10.87.0.1can serve DNS — and that same dnsmasq holds records for every instance on the network. Box A can very likely resolve box B's name and address. It cannot connect, but it can enumerate: names, existence, count.Layer 2. Incus ACLs are L3/L4; boxes share a bridge, and ARP lives underneath them.
profiles/claude-dev.yaml:7-10sets nosecurity.mac_filteringand nosecurity.ipv4_filtering. A hostile box poisoning the gateway's ARP cannot MITM its siblings — their replies die on its own ingress drop — but it can blackhole them. A cross-box denial of service still breaks "isolated even between them".Why not a network per box
The obvious fix — a UUID'd network per box — costs more than it looks:
10.87.0.0/16(256 of them, with real bookkeeping and a leak every time anrmgoes wrong).host/claudebox-firewall.shhardcodesNET=claudenetandGW=10.87.0.1. Its UFW / nft /DOCKER-USERhole-punching would have to become per-network and re-run on every mint — today it is a one-shot systemd unit at boot.Proposed instead
10.87.0.0/24written into it would recreate the original bug one renumbering later (the rule goes stale while reading as satisfied). Prefer the ACL subject selector@internalif it works on bridged networks (verify on a live Incus — support differs between bridge and OVN); otherwise havesetup-host.shderive the subnet when it creates the rule — noting thatincus network get claudenet ipv4.addressreturns the gateway CIDR (10.87.0.1/32-style host bits included,10.87.0.1/24), so deriving means masking to the network address, not pasting the value through. The gateway DNS carve-out itself can stay literal: if it drifts, DNS breaks loudly on the next mint, which is the good failure mode. Either way the drop is now intent, not side effect.security.mac_filtering=true+security.ipv4_filtering=trueon the NIC. Two lines; ends the L2 spoofing class. (Caveat to document:ipv4_filteringpins the box to its DHCP/static address and drops traffic for any other — fine for every current workload including in-box Docker, whose NAT hides behind eth0's address, but a box legitimately needing extra MACs/IPs on the wire is foreclosed.)dns.mode=noneon the network. Closes the enumeration leak; instances keep their upstream resolver (the curl line in the test proves it).Forward-looking: the right axis is named networks, not per-box UUIDs
Once these are general-purpose boxes, someone will want an app box that does talk to a database box. "Every box gets its own network" is precisely the wrong default for that world. The right axis is named networks a box opts into, defaulting to the isolated one — which is strictly better than per-box UUIDs and is where the explicit-sibling-drop design grows naturally. This issue does not build that; it just avoids foreclosing it. (When it comes, it comes as a CLI flag, never a template key — the honesty rule stands.)
Acceptance
box new --name x --template blankyields a box with no Claude, noclaudeuser, and the same isolation as aclaudebox.box new --name y --template claudeis behaviorally identical to today'sclaudebox new --name y.box new --name z --from y/authedclones andbox shell zlands in the right user without consulting the template (metadata read off the instance).user.claudebox=1, no new metadata) still shells intoclaude, notroot.box templateslists what is installable.box.envwith an unknown key (e.g.BOX_NETWORK=lan) is rejected, not ignored — a template cannot express "different network", and the parser proves it.The test
The
getentlines are the ones that currently fail to fail.Open questions
claudeboxis at0.3.0with a handful of boxes in existence. Options: (a)claudeboxshim →box new --template claudefor a release; (b) clean cut at the next minor, tell people to re-mint. Leaning (b) given the age and the user count — but the CLI name is the only thing actually on the table here: dual-tag matching and the legacy-user fallback ship under either answer (see Part 1 — old boxes and their snapshots must not fall out oflist/shell/--from).claude, or force--templateexplicitly? Leaningclaude, so today's muscle memory survives the rename.heavy-duty/claudeboxshipping aboxbinary? And isboxdistinctive enough on PATH? Maintainer's call; the code change is the same either way.Effort
About a day for Part 1 (~150 lines in the CLI: template resolve + allowlist parser, metadata stamp, user lookup with legacy fallback, tag rename; two template directories; the profile split; the host renames; docs). Part 2 is a morning (four isolation lines + the test), and can land first.
Analysis originally from a read at
f3036e0; revised 2026-07-13 againstmain@f2dab00— line refs re-pinned and independently re-verified; thebox_user()fallback fixed (unsetincus config getexits 0 with empty output, so|| echo rootnever fires); compat moved from user-lookup to tag matching; allowlist-not-source manifest parsing; the placement-contract profile spelled out (NIC + root disk, resources at launch); IPv6-off stated as contract; installer/repo-name blast radius; subnet derivation corrected for the gateway-CIDR return value; and the test's IP extraction fixed for multi-address (Docker) boxes. The Part 2 findings remain a code reading — not reproduced against a live Incus, which is exactly what the test above is for. Re-pinned same day to0982a2dafter PR #13 (command table,rename,incusescape hatch) merged — which centralized tag-gating inresolve_box()and made the dual-tag compat story a one-function change.Revised the body after re-verifying every claim against
main@f2dab00(all original line refs checked out; the analysis commitf3036e0had since merged as PR #10). Material changes, so watchers know what moved:box_user()snippet was broken as written —incus config geton an unset key prints empty and exits 0, so the|| echo rootfallback could never fire. Fixed, and given a legacy branch.list/infofilter onuser.claudebox=1, so under a renamed tag an old box doesn't land in the wrong user — it stops being a box at all (invisible tolist, unusable as--from). Dual-tag matching now ships under either compat answer; the open question is only the CLI name.box.envmust not besourced — that hands templates arbitrary host bash and makes "a template cannot express a different network" unenforceable. Strict allowlist parser instead; unknown keys are rejected, and there is an acceptance criterion proving it.--profile, Incus skips the default profile), resources stamped via--configat launch; plus the claude-dev migration detail (in-use profiles can't be deleted).getent hosts b.incus(defaultdns.domain) and an IPv6-off contract assertion.incus network get claudenet ipv4.addressreturns the gateway CIDR (10.87.0.1/24), so the explicit drop rule needs masking, not pasting.install.sh(curl URL,CLAUDEBOX_*, install dir, symlink), the firewall systemd unit,teardown-host.sh, and the repo/binary naming question (new open question 4).ipv6.address=none) named as the second instance of the same incidental-isolation shape — stated as contract and tested.Caveat unchanged: Part 2 is still a code reading, not reproduced on live Incus. The
@internalbridge-network support question and theincus copykey-preservation behavior are flagged for live verification in the implementation plan.Split into three ordered issues per the operator's call — each self-contained, so this analysis no longer needs to be read to implement:
@internalon bridges,incus copykey preservation,dns.mode=none,config getsemantics).tests/isolation.sh. Blocked by #15.The plan of record is PR #14, updated to map onto the three. The body above was re-pinned to
main@0982a2dafter PR #13 (command table) merged before closing, so the record stays accurate: refs shifted, andresolve_box()made the dual-tag compat story a one-function change.Closing as the umbrella; the history and open-question discussion live on in #15/#16/#17.