Reframe claudebox as box: the Claude box is one template among several #17

Closed
opened 2026-07-13 21:12:36 +00:00 by dan-claude-bot · 0 comments
dan-claude-bot commented 2026-07-13 21:12:36 +00:00 (Migrated from github.com)

Split from #12 (part 3 of 3: #15 audit → #16 isolation hardening → box + templates). Blocked by #16 — its tests/isolation.sh stands guard over this rename.

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 is already generic — a thin, honest wrapper over Incus. This issue renames it to box and makes the Claude box one template among several (blank, claude, and whatever comes later).

What is actually Claude-specific

The CLI is 587 lines; the whole repo ~1,250 (refs @ 0982a2d):

Where What
bin/claudebox:423-426 pins --profile claude-dev, the image images:debian/13/cloud, the single cloud-init/user-data.yaml, 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/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 unit
install.sh repo URL, CLAUDEBOX_* env vars, ~/.local/share/claudebox, the symlink, the tarball check (: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: shell/exec must know which user to become, and today they simply know. A blank box has no such user.

The mechanic: stamp template metadata onto the instance at launch

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

shell/exec read it back off the instance. Two subtleties:

  • incus config get prints an empty string and exits 0 for an unset key (audit B4) — so the fallback is ${u:-…}, never || echo root.
  • The fallback must not betray existing boxes: a pre-rename box carries user.claudebox=1 and no user.box.user, and 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}"
}

incus copy preserves config keys (audit B2), so new --from keeps working unchanged and a cloned box still knows what it is — the whole reuse story.

Tag matching is the other half of compat, and it is non-negotiable. resolve_box() (bin/claudebox:349-355) refuses any instance not tagged user.claudebox=1 — 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. Match both tags in resolve_box() and the list filters (:441,485), whatever the CLI-name compat answer — snapshots of old boxes outlive the release that minted them. Thanks to the command-table refactor (#13) that is one function, not per-verb edits.

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

Templates

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"

KEY="value", deliberately not YAML (zero-dependency bash tool; yq for two keys would be the largest cost in the change). But not sourced — sourcing hands every template arbitrary bash execution on the host at mint time. A strict allowlist parser (~15 lines of grep/case, still zero-dep) accepts exactly the BOX_* keys above and hard-errors on anything else. The parser is the enforcement of the rule below: there is simply no key for a network.

BOX_USER and the user created in user-data.yaml must agree by hand — the cloud-init file stays verbatim and unparsed, so the duplication is accepted; a grep warning at mint time is optional polish.

The rule that keeps this honest

A template must not be able to weaken isolation. The shared box-net profile is the placement contract: the NIC (with the hardening issue's security flags) and the root-disk device (with --profile, Incus does not apply the default profile, so the disk lives here) — nothing template-controlled. Templates set image, user, resources, cloud-init, and nothing else: BOX_CPU/BOX_MEMORY land as --config limits.* at launch, BOX_DISK as the VM's --device root,size= override. Then no template can ship a box onto the wrong network, and blank is "a box with nobody home", not "a box with the safety off".

Migration detail: existing boxes reference claude-dev, and Incus refuses to delete an in-use profile — claude-dev stays until its last box is gone (or setup-host reassigns live boxes with incus profile assign); pick one and say so in the PR.

CLI surface

box new --name work                      # default template (see open questions)
box new --name scratch --template blank
box new --name feature --from work/authed  # unchanged; metadata rides the clone
box templates                            # list what is installable

Template discovery post-install is free: bin/claudebox:8 resolves $root through the install symlink, so templates/ ships exactly like cloud-init/ does today.

Blast radius outside this repo

  • The .claudebox/ repo-runbook convention stays in v1 — consuming repos (e.g. heavy-duty/incubator) reference it; the tool's name and the runbook convention do not have to move together.
  • Installer/repo naming: the documented curl URL pins heavy-duty/claudebox; renaming the repo breaks it (GitHub redirects clones, not muscle memory), keeping it means a repo named claudebox shipping a binary named box — and box is a generic name on PATH. Open question below.
  • Old snapshots carry the old ~/.claude/CLAUDE.md text — cosmetic, they keep working.

Open questions (maintainer)

  1. Compat or clean cut for the CLI name? (a) claudebox shim → box new --template claude for a release; (b) clean cut at the next minor (current: 0.3.0), re-mint. Leaning (b); dual-tag matching and the legacy-user fallback ship under either answer.
  2. Default templateclaude (muscle memory survives) or force --template? Leaning claude.
  3. Repo/binary naming — rename the GitHub repo, or keep heavy-duty/claudebox shipping box? Is box distinctive enough on PATH?

Acceptance

  • box new --name x --template blank → no Claude, no claude user, same isolation as a claude box.
  • box new --name y --template claude behaviorally identical to today's claudebox new --name y.
  • box new --name z --from y/authed clones; box shell z lands in the right user without consulting the template.
  • A pre-rename box (user.claudebox=1, no new metadata): appears in box list, shells into claude, works as a --from source.
  • A box.env with an unknown key (e.g. BOX_NETWORK=lan) is rejected, naming the key.
  • box templates lists what is installable.
  • tests/isolation.sh (renamed with everything else) green after the rename.

About a day: ~150 lines in the CLI (template resolve + parser, metadata stamp, user lookup, dual-tag match), two template dirs, the profile split, the host renames, installer, docs.

Split from #12 (part 3 of 3: #15 audit → #16 isolation hardening → **box + templates**). **Blocked by #16** — its `tests/isolation.sh` stands guard over this rename. `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 is already generic — a thin, honest wrapper over Incus. This issue renames it to **`box`** and makes the Claude box **one template among several** (`blank`, `claude`, and whatever comes later). ## What is actually Claude-specific The CLI is 587 lines; the whole repo ~1,250 (refs @ `0982a2d`): | Where | What | | --- | --- | | `bin/claudebox:423-426` | pins `--profile claude-dev`, the image `images:debian/13/cloud`, the single `cloud-init/user-data.yaml`, 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`/`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 unit | | `install.sh` | repo URL, `CLAUDEBOX_*` env vars, `~/.local/share/claudebox`, the symlink, the tarball check (`: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: `shell`/`exec` must know which user to become, and today they simply *know*. A `blank` box has no such user. ## The mechanic: stamp template metadata onto the instance at launch ```bash incus launch "$image" "$instance" --profile box-net \ --config user.box=1 \ --config user.box.template=claude \ --config user.box.user=claude \ ... ``` `shell`/`exec` read it back off the instance. Two subtleties: - `incus config get` prints an **empty string and exits 0** for an unset key (audit B4) — so the fallback is `${u:-…}`, never `|| echo root`. - The fallback must not betray existing boxes: a pre-rename box carries `user.claudebox=1` and no `user.box.user`, and 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}" } ``` `incus copy` preserves config keys (audit B2), so **`new --from` keeps working unchanged and a cloned box still knows what it is** — the whole reuse story. **Tag matching is the other half of compat, and it is non-negotiable.** `resolve_box()` (`bin/claudebox:349-355`) refuses any instance not tagged `user.claudebox=1` — 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. Match both tags in `resolve_box()` and the `list` filters (`:441,485`), whatever the CLI-name compat answer — snapshots of old boxes outlive the release that minted them. Thanks to the command-table refactor (#13) that is one function, not per-verb edits. One deliberate consequence: `exec` always becomes the template's user (every template sets one), so the `root` fallback is effectively unreachable — anything needing root goes through the `incus` escape hatch (#13). A sentence in the help, not a flag. ## Templates ``` 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" ``` `KEY="value"`, deliberately not YAML (zero-dependency bash tool; `yq` for two keys would be the largest cost in the change). But **not `source`d** — sourcing hands every template arbitrary bash execution on the *host* at mint time. A strict allowlist parser (~15 lines of grep/case, still zero-dep) accepts exactly the `BOX_*` keys above and hard-errors on anything else. The parser *is* the enforcement of the rule below: there is simply no key for a network. `BOX_USER` and the user created in `user-data.yaml` must agree by hand — the cloud-init file stays verbatim and unparsed, so the duplication is accepted; a `grep` warning at mint time is optional polish. ### The rule that keeps this honest **A template must not be able to weaken isolation.** The shared `box-net` profile is the *placement contract*: the NIC (with the hardening issue's security flags) and the root-disk device (with `--profile`, Incus does not apply the default profile, so the disk lives here) — nothing template-controlled. Templates set image, user, resources, cloud-init, and nothing else: `BOX_CPU`/`BOX_MEMORY` land as `--config limits.*` at launch, `BOX_DISK` as the VM's `--device root,size=` override. Then no template can ship a box onto the wrong network, and `blank` is "a box with nobody home", not "a box with the safety off". Migration detail: existing boxes reference `claude-dev`, and Incus refuses to delete an in-use profile — `claude-dev` stays until its last box is gone (or setup-host reassigns live boxes with `incus profile assign`); pick one and say so in the PR. ## CLI surface ``` box new --name work # default template (see open questions) box new --name scratch --template blank box new --name feature --from work/authed # unchanged; metadata rides the clone box templates # list what is installable ``` Template discovery post-install is free: `bin/claudebox:8` resolves `$root` through the install symlink, so `templates/` ships exactly like `cloud-init/` does today. ## Blast radius outside this repo - **The `.claudebox/` repo-runbook convention stays in v1** — consuming repos (e.g. `heavy-duty/incubator`) reference it; the tool's name and the runbook convention do not have to move together. - **Installer/repo naming**: the documented `curl` URL pins `heavy-duty/claudebox`; renaming the repo breaks it (GitHub redirects clones, not muscle memory), keeping it means a repo named `claudebox` shipping a binary named `box` — and `box` is a generic name on PATH. Open question below. - Old snapshots carry the old `~/.claude/CLAUDE.md` text — cosmetic, they keep working. ## Open questions (maintainer) 1. **Compat or clean cut for the CLI name?** (a) `claudebox` shim → `box new --template claude` for a release; (b) clean cut at the next minor (current: 0.3.0), re-mint. Leaning (b); dual-tag matching and the legacy-user fallback ship under either answer. 2. **Default template** — `claude` (muscle memory survives) or force `--template`? Leaning `claude`. 3. **Repo/binary naming** — rename the GitHub repo, or keep `heavy-duty/claudebox` shipping `box`? Is `box` distinctive enough on PATH? ## Acceptance - [ ] `box new --name x --template blank` → no Claude, no `claude` user, same isolation as a `claude` box. - [ ] `box new --name y --template claude` behaviorally identical to today's `claudebox new --name y`. - [ ] `box new --name z --from y/authed` clones; `box shell z` lands in the right user **without consulting the template**. - [ ] A pre-rename box (`user.claudebox=1`, no new metadata): appears in `box list`, shells into `claude`, works as a `--from` source. - [ ] A `box.env` with an unknown key (e.g. `BOX_NETWORK=lan`) is **rejected**, naming the key. - [ ] `box templates` lists what is installable. - [ ] `tests/isolation.sh` (renamed with everything else) green after the rename. About a day: ~150 lines in the CLI (template resolve + parser, metadata stamp, user lookup, dual-tag match), two template dirs, the profile split, the host renames, installer, docs.
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#17
No description provided.