Reframe claudebox as box: the Claude box is one template, and sibling isolation should be deliberate #12

Closed
opened 2026-07-13 20:42:35 +00:00 by dan-claude-bot · 2 comments
dan-claude-bot commented 2026-07-13 20:42:35 +00:00 (Migrated from github.com)

claudebox mints exactly one kind of box: Debian 13 + Claude Code, one cloud-init file, one profile, one user named claude. 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 box and 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 templates

What 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):

Where What
bin/claudebox:423-426 pins --profile claude-dev, the image images:debian/13/cloud, the single cloud-init/user-data.yaml, and the user.claudebox=1 tag
bin/claudebox:422 pins 60GiB disk; profiles/claude-dev.yaml:4-5 pins 4 CPU / 8GiB
bin/claudebox:522-523 hardcodes sudo -u claude -i in shell and exec — the only structural blocker
bin/claudebox:349-355,441,485 resolve_box() gates every verb on user.claudebox=1; list filters on it
host/setup-host.sh, host/claudebox-firewall.sh, host/claudebox-firewall.service, host/teardown-host.sh names: claudenet, claude-isolate, claude-dev, nft table inet claudebox, /usr/local/sbin/claudebox-firewall, the systemd unit
install.sh the repo URL (heavy-duty/claudebox tarball), CLAUDEBOX_* env vars, ~/.local/share/claudebox, the claudebox symlink, and the bin/claudebox tarball sanity check (install.sh:41,53)
cloud-init/user-data.yaml:8 writes ~/.claude/CLAUDE.md

All but one are find-and-replace. The sudo -u claude one is the design problem.

The design problem, and the mechanic that solves it

shell and exec have to know which user to become inside the box, and today they simply know — it is claude, always. A blank box 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:

incus launch "$image" "$instance" --profile box-net \
  --config user.box=1 \
  --config user.box.template=claude \
  --config user.box.user=claude \
  ...

and then have shell/exec read it back off the instance. One subtlety: incus config get prints an empty string and exits 0 for an unset key, so the naive ... || echo root fallback never fires. And the fallback must not betray existing boxes — a pre-rename box carries user.claudebox=1 and no user.box.user, and it must keep landing in claude, not root:

box_user() {
  local i u; i="$(iname_of "$1")"
  u="$(incus config get "$i" user.box.user 2>/dev/null || true)"
  if [ -z "$u" ] && [ "$(incus config get "$i" user.claudebox 2>/dev/null || true)" = 1 ]; then
    u=claude   # legacy box, pre-metadata
  fi
  echo "${u:-root}"
}

This matters more than it looks. incus copy preserves config keys, so new --from keeps 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 tagged user.claudebox=1 — so under a renamed user.box=1, an old box doesn't just land in the wrong user, it stops being a box at all: invisible to list, unreachable by shell, unusable as a --from source. Whatever the compat answer, tag matching must accept both user.box=1 and user.claudebox=1 — snapshots of old boxes outlive the release that minted them, and --from old/authed is the tool's core promise. The upside of PR #13: that dual match is now one function, not per-verb edits.

One deliberate consequence: exec always becomes the template's user, and every template sets one, so the root fallback is effectively unreachable. Anything genuinely needing root goes through the claudebox incus escape hatch (PR #13) — worth a sentence in the help, not a flag.

Template layout

templates/
  claude/
    box.env          # image, user, resources
    user-data.yaml   # cloud-init, passed to Incus verbatim
  blank/
    box.env
    user-data.yaml   # creates a user; stops
# templates/claude/box.env
BOX_DESCRIPTION="Claude Code, creds-free"
BOX_IMAGE="images:debian/13/cloud"
BOX_USER="claude"
BOX_CPU="4"
BOX_MEMORY="8GiB"
BOX_DISK="60GiB"

The manifest is a KEY="value" file, deliberately not YAML — this is a zero-dependency bash tool, and pulling in yq just to read image: and user: would be the single largest cost in the change. But it must not be sourced: 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 the BOX_* 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_USER and the user created inside user-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, and box templates should be able to say so. A cheap grep -q "name: $BOX_USER" sanity warning at mint time is optional polish, not a requirement.

CLI surface

box new --name work                      # default template (configurable; `claude` initially)
box new --name scratch --template blank
box new --name feature --from work/authed  # unchanged; template metadata rides along on the clone
box templates                            # list what is installable

claudebox survives as a shim for box 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 blank is genuinely just "a box with nobody home" rather than "a box with the safety off".

Precisely: the shared box-net profile 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's BOX_CPU/BOX_MEMORY land as --config limits.cpu/limits.memory at launch, BOX_DISK as the VM's --device root,size= override. Today profiles/claude-dev.yaml mixes contract and resources; splitting them is part of the work. (Migration detail: existing boxes reference claude-dev, and Incus refuses to delete a profile in use — so claude-dev stays until its last box is gone, or setup-host reassigns live boxes with incus 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-net profile means the CLI never takes one from anywhere else.

Blast radius outside this repo

  • The .claudebox/ runbook convention is referenced by consuming repos (e.g. heavy-duty/incubator's CLAUDE.md and 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.
  • The installer and the repo name. install.sh is fetched as curl .../heavy-duty/claudebox/.../install.sh | bash and installs to ~/.local/share/claudebox behind CLAUDEBOX_* 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 named claudebox shipping a binary named box. This is a naming call, not code — flagged in Open questions. (Template discovery after install is free either way: bin/claudebox:8 resolves $root through the symlink with readlink -f, and the installer already ships the whole tree, so templates/ rides along exactly like cloud-init/ does today.)
  • box is a generic name on PATH. Short, but collision-prone (other tools have claimed it). Worth a deliberate yes before committing.
  • Snapshots already taken carry the old ~/.claude/CLAUDE.md text. 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:31 drops all egress to 10.0.0.0/8. The claudenet subnet is 10.87.0.0/24 — which is inside 10.0.0.0/8. So box A's packets to box B are dropped on A's own NIC, and security.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-97 describes 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 to 192.168.50.0/24 for 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 on 10.87.0.1 can 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-10 sets no security.mac_filtering and no security.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:

  • Every Incus bridge spawns its own dnsmasq and needs its own subnet, so you are now allocating and reclaiming /24s out of 10.87.0.0/16 (256 of them, with real bookkeeping and a leak every time an rm goes wrong).
  • host/claudebox-firewall.sh hardcodes NET=claudenet and GW=10.87.0.1. Its UFW / nft / DOCKER-USER hole-punching would have to become per-network and re-run on every mint — today it is a one-shot systemd unit at boot.
  • And it buys isolation we already have.

Proposed instead

  1. Make sibling isolation explicit — without hardcoding the subnet. An explicit drop rule with 10.87.0.0/24 written into it would recreate the original bug one renumbering later (the rule goes stale while reading as satisfied). Prefer the ACL subject selector @internal if it works on bridged networks (verify on a live Incus — support differs between bridge and OVN); otherwise have setup-host.sh derive the subnet when it creates the rule — noting that incus network get claudenet ipv4.address returns 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.
  2. security.mac_filtering=true + security.ipv4_filtering=true on the NIC. Two lines; ends the L2 spoofing class. (Caveat to document: ipv4_filtering pins 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.)
  3. dns.mode=none on the network. Closes the enumeration leak; instances keep their upstream resolver (the curl line in the test proves it).
  4. State IPv6-off as contract, per above.
  5. A test that would have caught this (below).

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 blank yields a box with no Claude, no claude user, and the same isolation as a claude box.
  • box new --name y --template claude is behaviorally identical to today's claudebox new --name y.
  • box new --name z --from y/authed clones and box shell z lands in the right user without consulting the template (metadata read off the instance).
  • A pre-existing box (tagged user.claudebox=1, no new metadata) still shells into claude, not root.
  • box templates lists what is installable.
  • A box.env with an unknown key (e.g. BOX_NETWORK=lan) is rejected, not ignored — a template cannot express "different network", and the parser proves it.
  • Isolation is unchanged and now explicit: a box cannot reach the host, the LAN, or another box, and cannot enumerate another box over DNS.
  • The isolation test below is in the repo and passing.

The test

box new --name a && box new --name b

# B's address on the box network specifically. A claude box runs Docker, so it
# reports several IPv4s quoted across CSV lines — the naive `--columns 4` paste
# yields a multi-line variable and the ping "fails" without testing anything.
# eth0 is the profile-pinned NIC name, so select on it, exact-match the name.
IP_B=$(incus list ^b$ --format csv --columns 4 | tr -d '"' | tr ',' '\n' \
        | awk '/\(eth0\)$/ { print $1; exit }')
[ -n "$IP_B" ] || { echo "no eth0 address for b"; exit 1; }

box exec a -- ping -c1 -W2 "$IP_B"    # MUST fail  (sibling isolation)
box exec a -- getent hosts b          # MUST fail  (DNS enumeration — leaks today)
box exec a -- getent hosts b.incus    # MUST fail  (same, via the default dns.domain)
box exec a -- curl -sS --max-time 3 https://example.com >/dev/null  # MUST pass (egress + DNS intact)
incus network get claudenet ipv6.address | grep -qx none            # MUST pass (IPv6-off is contract)

The getent lines are the ones that currently fail to fail.

Open questions

  1. Compat, or a clean cut? claudebox is at 0.3.0 with a handful of boxes in existence. Options: (a) claudebox shim → box new --template claude for 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 of list/shell/--from).
  2. Default templateclaude, or force --template explicitly? Leaning claude, so today's muscle memory survives the rename.
  3. Ship as one PR or two? The isolation hardening (Part 2) is independently valuable and independently reviewable; it only rides along because it touches the same names. Leaning two, Part 2 first — it hardens the current tool and its test then guards the rename.
  4. What happens to the repo and installer names? Rename the GitHub repo (breaks the documented curl URL; GitHub redirects the rest), or keep heavy-duty/claudebox shipping a box binary? And is box distinctive 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 against main @ f2dab00 — line refs re-pinned and independently re-verified; the box_user() fallback fixed (unset incus config get exits 0 with empty output, so || echo root never 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 to 0982a2d after PR #13 (command table, rename, incus escape hatch) merged — which centralized tag-gating in resolve_box() and made the dual-tag compat story a one-function change.

`claudebox` mints exactly one kind of box: Debian 13 + Claude Code, one cloud-init file, one profile, one user named `claude`. 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 **`box`** and 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 templates ### What 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): | Where | What | | --- | --- | | `bin/claudebox:423-426` | pins `--profile claude-dev`, the image `images:debian/13/cloud`, the single `cloud-init/user-data.yaml`, and the `user.claudebox=1` tag | | `bin/claudebox:422` | pins 60GiB disk; `profiles/claude-dev.yaml:4-5` pins 4 CPU / 8GiB | | **`bin/claudebox:522-523`** | **hardcodes `sudo -u claude -i`** in `shell` and `exec` — the only structural blocker | | `bin/claudebox:349-355,441,485` | `resolve_box()` gates every verb on `user.claudebox=1`; `list` filters on it | | `host/setup-host.sh`, `host/claudebox-firewall.sh`, `host/claudebox-firewall.service`, `host/teardown-host.sh` | names: `claudenet`, `claude-isolate`, `claude-dev`, nft table `inet claudebox`, `/usr/local/sbin/claudebox-firewall`, the systemd unit | | `install.sh` | the repo URL (`heavy-duty/claudebox` tarball), `CLAUDEBOX_*` env vars, `~/.local/share/claudebox`, the `claudebox` symlink, and the `bin/claudebox` tarball sanity check (`install.sh:41,53`) | | `cloud-init/user-data.yaml:8` | writes `~/.claude/CLAUDE.md` | All but one are find-and-replace. The `sudo -u claude` one is the design problem. ### The design problem, and the mechanic that solves it `shell` and `exec` have to know which user to become inside the box, and today they simply *know* — it is `claude`, always. A `blank` box 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: ```bash incus launch "$image" "$instance" --profile box-net \ --config user.box=1 \ --config user.box.template=claude \ --config user.box.user=claude \ ... ``` and then have `shell`/`exec` read it back off the instance. One subtlety: `incus config get` prints an **empty string and exits 0** for an unset key, so the naive `... || echo root` fallback never fires. And the fallback must not betray existing boxes — a pre-rename box carries `user.claudebox=1` and no `user.box.user`, and it must keep landing in `claude`, not `root`: ```bash box_user() { local i u; i="$(iname_of "$1")" u="$(incus config get "$i" user.box.user 2>/dev/null || true)" if [ -z "$u" ] && [ "$(incus config get "$i" user.claudebox 2>/dev/null || true)" = 1 ]; then u=claude # legacy box, pre-metadata fi echo "${u:-root}" } ``` This matters more than it looks. `incus copy` preserves config keys, so **`new --from` keeps 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 tagged `user.claudebox=1` — so under a renamed `user.box=1`, an old box doesn't just land in the wrong user, it **stops being a box at all**: invisible to `list`, unreachable by `shell`, unusable as a `--from` source. Whatever the compat answer, tag matching must accept both `user.box=1` and `user.claudebox=1` — snapshots of old boxes outlive the release that minted them, and `--from old/authed` is the tool's core promise. The upside of PR #13: that dual match is now **one function**, not per-verb edits. One deliberate consequence: `exec` always becomes the template's user, and every template sets one, so the `root` fallback is effectively unreachable. Anything genuinely needing root goes through the `claudebox incus` escape hatch (PR #13) — worth a sentence in the help, not a flag. ### Template layout ``` templates/ claude/ box.env # image, user, resources user-data.yaml # cloud-init, passed to Incus verbatim blank/ box.env user-data.yaml # creates a user; stops ``` ```sh # templates/claude/box.env BOX_DESCRIPTION="Claude Code, creds-free" BOX_IMAGE="images:debian/13/cloud" BOX_USER="claude" BOX_CPU="4" BOX_MEMORY="8GiB" BOX_DISK="60GiB" ``` **The manifest is a `KEY="value"` file, deliberately not YAML** — this is a zero-dependency bash tool, and pulling in `yq` just to read `image:` and `user:` would be the single largest cost in the change. But it must **not be `source`d**: 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 the `BOX_*` 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_USER` and the user created inside `user-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, and `box templates` should be able to say so. A cheap `grep -q "name: $BOX_USER"` sanity warning at mint time is optional polish, not a requirement. ### CLI surface ``` box new --name work # default template (configurable; `claude` initially) box new --name scratch --template blank box new --name feature --from work/authed # unchanged; template metadata rides along on the clone box templates # list what is installable ``` `claudebox` survives as a shim for `box 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 `blank` is genuinely just "a box with nobody home" rather than "a box with the safety off". Precisely: the shared `box-net` profile 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's `BOX_CPU`/`BOX_MEMORY` land as `--config limits.cpu/limits.memory` at launch, `BOX_DISK` as the VM's `--device root,size=` override. Today `profiles/claude-dev.yaml` mixes contract and resources; splitting them is part of the work. (Migration detail: existing boxes reference `claude-dev`, and Incus refuses to delete a profile in use — so `claude-dev` stays until its last box is gone, or setup-host reassigns live boxes with `incus 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-net` profile means the CLI never takes one from anywhere else. ### Blast radius outside this repo - **The `.claudebox/` runbook convention** is referenced by consuming repos (e.g. `heavy-duty/incubator`'s `CLAUDE.md` and 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. - **The installer and the repo name.** `install.sh` is fetched as `curl .../heavy-duty/claudebox/.../install.sh | bash` and installs to `~/.local/share/claudebox` behind `CLAUDEBOX_*` 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 named `claudebox` shipping a binary named `box`. This is a naming call, not code — flagged in *Open questions*. (Template *discovery* after install is free either way: `bin/claudebox:8` resolves `$root` through the symlink with `readlink -f`, and the installer already ships the whole tree, so `templates/` rides along exactly like `cloud-init/` does today.) - **`box` is a generic name on PATH.** Short, but collision-prone (other tools have claimed it). Worth a deliberate yes before committing. - Snapshots already taken carry the old `~/.claude/CLAUDE.md` text. 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:31` drops all egress to `10.0.0.0/8`. The claudenet subnet is `10.87.0.0/24` — which is *inside* `10.0.0.0/8`. So box A's packets to box B are dropped on A's own NIC, and `security.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-97` describes 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 to `192.168.50.0/24` for 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 on `10.87.0.1` can 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-10` sets no `security.mac_filtering` and no `security.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: - Every Incus bridge spawns **its own dnsmasq** and needs its own subnet, so you are now allocating and reclaiming /24s out of `10.87.0.0/16` (256 of them, with real bookkeeping and a leak every time an `rm` goes wrong). - `host/claudebox-firewall.sh` hardcodes `NET=claudenet` and `GW=10.87.0.1`. Its UFW / nft / `DOCKER-USER` hole-punching would have to become **per-network and re-run on every mint** — today it is a one-shot systemd unit at boot. - And it buys isolation **we already have**. ### Proposed instead 1. **Make sibling isolation explicit — without hardcoding the subnet.** An explicit drop rule with `10.87.0.0/24` written into it would recreate the original bug one renumbering later (the rule goes stale while reading as satisfied). Prefer the ACL subject selector `@internal` if it works on bridged networks (verify on a live Incus — support differs between bridge and OVN); otherwise have `setup-host.sh` *derive* the subnet when it creates the rule — noting that `incus network get claudenet ipv4.address` returns 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. 2. **`security.mac_filtering=true` + `security.ipv4_filtering=true`** on the NIC. Two lines; ends the L2 spoofing class. (Caveat to document: `ipv4_filtering` pins 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.) 3. **`dns.mode=none`** on the network. Closes the enumeration leak; instances keep their upstream resolver (the curl line in the test proves it). 4. **State IPv6-off as contract**, per above. 5. **A test that would have caught this** (below). ### 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 blank` yields a box with no Claude, no `claude` user, and the same isolation as a `claude` box. - [ ] `box new --name y --template claude` is behaviorally identical to today's `claudebox new --name y`. - [ ] `box new --name z --from y/authed` clones and `box shell z` lands in the right user **without consulting the template** (metadata read off the instance). - [ ] A pre-existing box (tagged `user.claudebox=1`, no new metadata) still shells into `claude`, not `root`. - [ ] `box templates` lists what is installable. - [ ] A `box.env` with an unknown key (e.g. `BOX_NETWORK=lan`) is **rejected**, not ignored — a template cannot express "different network", and the parser proves it. - [ ] Isolation is unchanged and now *explicit*: a box cannot reach the host, the LAN, or **another box**, and cannot enumerate another box over DNS. - [ ] The isolation test below is in the repo and passing. ### The test ```bash box new --name a && box new --name b # B's address on the box network specifically. A claude box runs Docker, so it # reports several IPv4s quoted across CSV lines — the naive `--columns 4` paste # yields a multi-line variable and the ping "fails" without testing anything. # eth0 is the profile-pinned NIC name, so select on it, exact-match the name. IP_B=$(incus list ^b$ --format csv --columns 4 | tr -d '"' | tr ',' '\n' \ | awk '/\(eth0\)$/ { print $1; exit }') [ -n "$IP_B" ] || { echo "no eth0 address for b"; exit 1; } box exec a -- ping -c1 -W2 "$IP_B" # MUST fail (sibling isolation) box exec a -- getent hosts b # MUST fail (DNS enumeration — leaks today) box exec a -- getent hosts b.incus # MUST fail (same, via the default dns.domain) box exec a -- curl -sS --max-time 3 https://example.com >/dev/null # MUST pass (egress + DNS intact) incus network get claudenet ipv6.address | grep -qx none # MUST pass (IPv6-off is contract) ``` The `getent` lines are the ones that currently fail to fail. ## Open questions 1. **Compat, or a clean cut?** `claudebox` is at `0.3.0` with a handful of boxes in existence. Options: (a) `claudebox` shim → `box new --template claude` for 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 of `list`/`shell`/`--from`). 2. **Default template** — `claude`, or force `--template` explicitly? Leaning `claude`, so today's muscle memory survives the rename. 3. **Ship as one PR or two?** The isolation hardening (Part 2) is independently valuable and independently reviewable; it only rides along because it touches the same names. Leaning two, Part 2 first — it hardens the *current* tool and its test then guards the rename. 4. **What happens to the repo and installer names?** Rename the GitHub repo (breaks the documented curl URL; GitHub redirects the rest), or keep `heavy-duty/claudebox` shipping a `box` binary? And is `box` distinctive 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 against `main` @ `f2dab00` — line refs re-pinned and independently re-verified; the `box_user()` fallback fixed (unset `incus config get` exits 0 with **empty output**, so `|| echo root` never 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 to `0982a2d` after PR #13 (command table, `rename`, `incus` escape hatch) merged — which centralized tag-gating in `resolve_box()` and made the dual-tag compat story a one-function change.*
dan-claude-bot commented 2026-07-13 20:59:53 +00:00 (Migrated from github.com)

Revised the body after re-verifying every claim against main @ f2dab00 (all original line refs checked out; the analysis commit f3036e0 had since merged as PR #10). Material changes, so watchers know what moved:

  • box_user() snippet was broken as writtenincus config get on an unset key prints empty and exits 0, so the || echo root fallback could never fire. Fixed, and given a legacy branch.
  • Compat relocated from user-lookup to tag matching. list/info filter on user.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 to list, unusable as --from). Dual-tag matching now ships under either compat answer; the open question is only the CLI name.
  • box.env must not be sourced — 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.
  • The profile split is now the "placement contract" (NIC + root-disk device — with --profile, Incus skips the default profile), resources stamped via --config at launch; plus the claude-dev migration detail (in-use profiles can't be deleted).
  • The isolation test's IP extraction was itself broken for the case the repo documents (Docker boxes report multiple quoted IPv4s across CSV lines — the ping would fail without testing anything). Fixed to select eth0 exactly; added getent hosts b.incus (default dns.domain) and an IPv6-off contract assertion.
  • Subnet derivation corrected: incus network get claudenet ipv4.address returns the gateway CIDR (10.87.0.1/24), so the explicit drop rule needs masking, not pasting.
  • Blast radius extended to 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-off (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 @internal bridge-network support question and the incus copy key-preservation behavior are flagged for live verification in the implementation plan.

Revised the body after re-verifying every claim against `main` @ `f2dab00` (all original line refs checked out; the analysis commit `f3036e0` had since merged as PR #10). Material changes, so watchers know what moved: - **`box_user()` snippet was broken as written** — `incus config get` on an unset key prints empty and exits 0, so the `|| echo root` fallback could never fire. Fixed, and given a legacy branch. - **Compat relocated from user-lookup to tag matching.** `list`/`info` filter on `user.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 to `list`, unusable as `--from`). Dual-tag matching now ships under either compat answer; the open question is only the CLI name. - **`box.env` must not be `source`d** — 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. - **The profile split is now the "placement contract"** (NIC + root-disk device — with `--profile`, Incus skips the default profile), resources stamped via `--config` at launch; plus the claude-dev migration detail (in-use profiles can't be deleted). - **The isolation test's IP extraction was itself broken** for the case the repo documents (Docker boxes report multiple quoted IPv4s across CSV lines — the ping would fail without testing anything). Fixed to select eth0 exactly; added `getent hosts b.incus` (default `dns.domain`) and an IPv6-off contract assertion. - **Subnet derivation corrected**: `incus network get claudenet ipv4.address` returns the gateway CIDR (`10.87.0.1/24`), so the explicit drop rule needs masking, not pasting. - **Blast radius extended** to `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-off (`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 `@internal` bridge-network support question and the `incus copy` key-preservation behavior are flagged for live verification in the implementation plan.
dan-claude-bot commented 2026-07-13 21:17:50 +00:00 (Migrated from github.com)

Split into three ordered issues per the operator's call — each self-contained, so this analysis no longer needs to be read to implement:

  • #15 — audit: verify live that the boundary behaves as designed (the sibling-isolation claim here was a code reading), and pin the four Incus behaviors the later work leans on (@internal on bridges, incus copy key preservation, dns.mode=none, config get semantics).
  • #16 — isolation hardening (Part 2 of this issue): explicit sibling drop, L2 filtering, DNS enumeration, IPv6 contract, tests/isolation.sh. Blocked by #15.
  • #17 — box + templates (Part 1 of this issue): the rename, template system, metadata stamping, dual-tag compat, profile split. Blocked by #16.

The plan of record is PR #14, updated to map onto the three. The body above was re-pinned to main @ 0982a2d after PR #13 (command table) merged before closing, so the record stays accurate: refs shifted, and resolve_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.

Split into three ordered issues per the operator's call — each self-contained, so this analysis no longer needs to be read to implement: - **#15 — audit**: verify live that the boundary behaves as designed (the sibling-isolation claim here was a code reading), and pin the four Incus behaviors the later work leans on (`@internal` on bridges, `incus copy` key preservation, `dns.mode=none`, `config get` semantics). - **#16 — isolation hardening** (Part 2 of this issue): explicit sibling drop, L2 filtering, DNS enumeration, IPv6 contract, `tests/isolation.sh`. Blocked by #15. - **#17 — box + templates** (Part 1 of this issue): the rename, template system, metadata stamping, dual-tag compat, profile split. Blocked by #16. The plan of record is PR #14, updated to map onto the three. The body above was re-pinned to `main` @ `0982a2d` after PR #13 (command table) merged before closing, so the record stays accurate: refs shifted, and `resolve_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.
Sign in to join this conversation.
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set.

Reference: heavy-duty/box#12
No description provided.