From 7d33ad8a008f331e671935e4f00048e98614185a Mon Sep 17 00:00:00 2001 From: claude-hdb Date: Mon, 13 Jul 2026 20:49:46 +0000 Subject: [PATCH] feat: make the command surface a table, add rename and an escape hatch MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Every incus verb is a candidate feature request, and wrapping them one at a time grows a worse incus. This lands the rule instead: claudebox owns a command when it must enforce an invariant incus cannot see — the user.claudebox=1 boundary, the isolation stack, or the creds-free snapshot workflow. Everything else is incus's job, and now has a door. - CMDS table: one row per command, carrying synopsis, preconditions, summary, action and success message. Dispatch AND help are rendered from it, so the help can no longer describe a command that doesn't exist — the drift that produced #8 is now impossible, not merely fixed. - rename, via a table row: it needs the box stopped (incus won't rename a running instance), so it says so instead of leaking an incus error. - `claudebox incus -- `: the escape hatch. Box resolved and tag-checked, rest passed to incus verbatim, {} substituted, command echoed. Warns when it can move a box off the isolation stack. - The boundary is now ENFORCED, not assumed: every box-taking command resolves through the user.claudebox=1 tag, so claudebox will not stop, rename or delete an instance it didn't mint. - The rule, written into docs/claudebox-design.md. Closes #11 --- README.md | 22 +++ VERSION | 2 +- bin/claudebox | 330 +++++++++++++++++++++++++++------------ docs/claudebox-design.md | 36 +++++ 4 files changed, 293 insertions(+), 97 deletions(-) diff --git a/README.md b/README.md index 6fee60e..65eaaa2 100644 --- a/README.md +++ b/README.md @@ -82,9 +82,11 @@ claudebox shell # enter as the claude user claudebox exec -- # run a command in the box claudebox snapshot [label] # checkpoint (label defaults to manual-) claudebox restore # roll back to a snapshot +claudebox rename # rename a box (stop it first) claudebox down # stop (state kept; `start` resumes) claudebox start # start a stopped box claudebox rm [--force] # delete the box + its snapshots (asks first) +claudebox incus -- # escape hatch: any incus command, box resolved claudebox status # deprecated alias for `list` claudebox help [] # full help, or one command's page ``` @@ -98,6 +100,26 @@ snapshot. VM mode (`--vm`, the default where `/dev/kvm` exists) is the trust-les 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.claudebox=1`. claudebox 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: + +```sh +claudebox incus work -- config show # instance name appended +claudebox 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.*`), claudebox warns and proceeds — +the trust boundary is then yours to keep. See +[docs/claudebox-design.md](docs/claudebox-design.md) for the rule and why the +command surface is a table. + ## Isolation Dedicated NAT bridge `claudenet` + Incus `claude-isolate` ACL dropping all diff --git a/VERSION b/VERSION index 0ea3a94..0d91a54 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -0.2.0 +0.3.0 diff --git a/bin/claudebox b/bin/claudebox index 1e1017d..9da33b7 100755 --- a/bin/claudebox +++ b/bin/claudebox @@ -1,38 +1,77 @@ #!/usr/bin/env bash # claudebox — trust-less, isolated Incus VMs with Claude Code, creds-free. -# The help text lives in usage()/help_cmd(), not in this comment. +# The command surface is the CMDS table below: it is the single source of truth +# for what exists, what it looks like, what the help says, and what runs. The +# help cannot drift from the code, because it is rendered from the same rows. set -euo pipefail root="$(cd "$(dirname "$(readlink -f "${BASH_SOURCE[0]}")")/.." && pwd)" remote=""; mode="auto"; name=""; from=""; force=0; json=0; want_help=0 +inst="" # the resolved Incus instance, set by the 'box' precondition die() { echo "claudebox: $*" >&2; exit 1; } # 1 = it went wrong usage_error() { echo "claudebox: $*" >&2; echo "try 'claudebox help'." >&2; exit 2; } # 2 = you asked wrong version() { echo "claudebox $(cat "$root/VERSION" 2>/dev/null || echo unknown) ($root)"; } -COMMANDS="new list info shell exec snapshot restore down start rm status help" -is_command() { case " $COMMANDS " in *" $1 "*) return 0 ;; *) return 1 ;; esac; } +# --------------------------------------------------------------------------- +# The command table. +# +# verb ^ synopsis args ^ preconditions ^ one-line summary ^ action ^ ok message +# +# Fields are ^-separated because a synopsis may contain '|' ([--vm|--container]). +# +# preconditions (comma-separated): +# box first positional is a box: resolve it, and REFUSE if the instance +# isn't tagged user.claudebox=1 — the boundary, enforced, not assumed +# arg2 a second positional is required +# stopped the box must not be running +# confirm destructive: prompt unless --force +# +# action: +# incus: run `incus [rest...]` +# fn: call a shell function (it has real work to do) +# +# ok message: printed on success; {} = the box, {1} = the second positional. +# +# Adding a thin verb is one row. If a request can't be expressed as a row and +# doesn't enforce a claudebox invariant, it is incus's job, not ours — that is +# what `claudebox incus` is for. +CMDS=( + "new^--name [--from [/]] [--vm|--container]^^Mint a box: fresh from cloud-init, or --from an existing box/snapshot^fn:cmd_new^" + "list^[--json]^^List your boxes^fn:cmd_list^" + "info^ [--json]^box^One box: state, type, IP, and its snapshot labels^fn:cmd_info^" + "shell^^box^Open a shell in a box, as the claude user^fn:cmd_shell^" + "exec^ -- ^box^Run a command inside a box^fn:cmd_exec^" + "snapshot^ [