feat: make the command surface a table, add rename and an escape hatch
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 <box> -- <args...>`: 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
This commit is contained in:
parent
f2dab00549
commit
7d33ad8a00
4 changed files with 293 additions and 97 deletions
22
README.md
22
README.md
|
|
@ -82,9 +82,11 @@ claudebox shell <box> # enter as the claude user
|
||||||
claudebox exec <box> -- <cmd...> # run a command in the box
|
claudebox exec <box> -- <cmd...> # run a command in the box
|
||||||
claudebox snapshot <box> [label] # checkpoint (label defaults to manual-<epoch>)
|
claudebox snapshot <box> [label] # checkpoint (label defaults to manual-<epoch>)
|
||||||
claudebox restore <box> <snap> # roll back to a snapshot
|
claudebox restore <box> <snap> # roll back to a snapshot
|
||||||
|
claudebox rename <box> <new> # rename a box (stop it first)
|
||||||
claudebox down <box> # stop (state kept; `start` resumes)
|
claudebox down <box> # stop (state kept; `start` resumes)
|
||||||
claudebox start <box> # start a stopped box
|
claudebox start <box> # start a stopped box
|
||||||
claudebox rm <box> [--force] # delete the box + its snapshots (asks first)
|
claudebox rm <box> [--force] # delete the box + its snapshots (asks first)
|
||||||
|
claudebox incus <box> -- <args...> # escape hatch: any incus command, box resolved
|
||||||
claudebox status # deprecated alias for `list`
|
claudebox status # deprecated alias for `list`
|
||||||
claudebox help [<command>] # full help, or one command's page
|
claudebox help [<command>] # 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
|
target; container mode (auto-fallback, `security.nesting=true`) is for hosts
|
||||||
without nested virt — weaker isolation, dev/test only.
|
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
|
## Isolation
|
||||||
|
|
||||||
Dedicated NAT bridge `claudenet` + Incus `claude-isolate` ACL dropping all
|
Dedicated NAT bridge `claudenet` + Incus `claude-isolate` ACL dropping all
|
||||||
|
|
|
||||||
2
VERSION
2
VERSION
|
|
@ -1 +1 @@
|
||||||
0.2.0
|
0.3.0
|
||||||
|
|
|
||||||
330
bin/claudebox
330
bin/claudebox
|
|
@ -1,38 +1,77 @@
|
||||||
#!/usr/bin/env bash
|
#!/usr/bin/env bash
|
||||||
# claudebox — trust-less, isolated Incus VMs with Claude Code, creds-free.
|
# 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
|
set -euo pipefail
|
||||||
|
|
||||||
root="$(cd "$(dirname "$(readlink -f "${BASH_SOURCE[0]}")")/.." && pwd)"
|
root="$(cd "$(dirname "$(readlink -f "${BASH_SOURCE[0]}")")/.." && pwd)"
|
||||||
remote=""; mode="auto"; name=""; from=""; force=0; json=0; want_help=0
|
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
|
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
|
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)"; }
|
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:<subcommand> run `incus <subcommand> <instance> [rest...]`
|
||||||
|
# fn:<function> 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 <box> [--from <src>[/<snap>]] [--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^<box> [--json]^box^One box: state, type, IP, and its snapshot labels^fn:cmd_info^"
|
||||||
|
"shell^<box>^box^Open a shell in a box, as the claude user^fn:cmd_shell^"
|
||||||
|
"exec^<box> -- <cmd...>^box^Run a command inside a box^fn:cmd_exec^"
|
||||||
|
"snapshot^<box> [<label>]^box^Checkpoint a box (label defaults to manual-<epoch>)^fn:cmd_snapshot^"
|
||||||
|
"restore^<box> <snapshot>^box,arg2^Roll a box back to one of its snapshots^incus:restore^restored {} to {1}"
|
||||||
|
"rename^<box> <new-name>^box,arg2,stopped^Rename a box (it must be stopped first)^incus:rename^renamed {} to {1}"
|
||||||
|
"down^<box>^box^Stop a box, keeping its state ('start' resumes it)^incus:stop^stopped {}"
|
||||||
|
"start^<box>^box^Start a stopped box^incus:start^started {}"
|
||||||
|
"rm^<box> [--force]^box,confirm^Delete a box and its snapshots — irreversible, and it asks first^incus:delete -f^removed {}"
|
||||||
|
"incus^<box> -- <args...>^box^Escape hatch: run any incus command against a box^fn:cmd_incus^"
|
||||||
|
"status^^^Deprecated alias for 'list'^fn:cmd_status^"
|
||||||
|
"help^[<command>]^^This help, or 'claudebox help <command>' for one command^fn:cmd_help^"
|
||||||
|
)
|
||||||
|
|
||||||
synopsis_of() {
|
cmd_row() { local r; for r in "${CMDS[@]}"; do case "$r" in "$1^"*) echo "$r"; return 0 ;; esac; done; return 1; }
|
||||||
case "$1" in
|
verbs() { local r; for r in "${CMDS[@]}"; do echo "${r%%^*}"; done; }
|
||||||
new) echo "claudebox new --name <box> [--from <src>[/<snap>]] [--vm|--container]" ;;
|
is_command() { cmd_row "$1" >/dev/null 2>&1; }
|
||||||
list) echo "claudebox list [--json]" ;;
|
# locals matter here: dispatch holds $pre/$action/$ok, and field() is called from
|
||||||
info) echo "claudebox info <box> [--json]" ;;
|
# error paths inside it — a global read would clobber the row being dispatched.
|
||||||
shell) echo "claudebox shell <box>" ;;
|
field() {
|
||||||
exec) echo "claudebox exec <box> -- <cmd...>" ;;
|
local r f_syn f_pre f_sum f_act f_ok
|
||||||
snapshot) echo "claudebox snapshot <box> [<label>]" ;;
|
r="$(cmd_row "$1")" || return 1
|
||||||
restore) echo "claudebox restore <box> <snapshot>" ;;
|
IFS='^' read -r _ f_syn f_pre f_sum f_act f_ok <<<"$r"
|
||||||
down) echo "claudebox down <box>" ;;
|
case "$2" in
|
||||||
start) echo "claudebox start <box>" ;;
|
syn) echo "$f_syn" ;; pre) echo "$f_pre" ;; sum) echo "$f_sum" ;;
|
||||||
rm) echo "claudebox rm <box> [--force]" ;;
|
act) echo "$f_act" ;; ok) echo "$f_ok" ;;
|
||||||
status) echo "claudebox status" ;;
|
|
||||||
help) echo "claudebox help [<command>]" ;;
|
|
||||||
esac
|
esac
|
||||||
}
|
}
|
||||||
|
synopsis_of() { local s; s="$(field "$1" syn)"; echo "claudebox $1${s:+ $s}"; }
|
||||||
|
|
||||||
# Nearest command by edit distance — a typo should point somewhere, not just fail.
|
# Nearest command by edit distance — a typo should point somewhere, not just fail.
|
||||||
suggest() {
|
suggest() {
|
||||||
printf '%s' "$COMMANDS" | tr ' ' '\n' | awk -v w="$(printf '%s' "$1" | tr '[:upper:]' '[:lower:]')" '
|
verbs | awk -v w="$(printf '%s' "$1" | tr '[:upper:]' '[:lower:]')" '
|
||||||
function dist(a, b, la, lb, i, j, c, prev, cur) {
|
function dist(a, b, la, lb, i, j, c, prev, cur) {
|
||||||
la = length(a); lb = length(b)
|
la = length(a); lb = length(b)
|
||||||
for (j = 0; j <= lb; j++) prev[j] = j
|
for (j = 0; j <= lb; j++) prev[j] = j
|
||||||
|
|
@ -72,18 +111,13 @@ USAGE
|
||||||
claudebox <command> [<args>] [options]
|
claudebox <command> [<args>] [options]
|
||||||
|
|
||||||
COMMANDS
|
COMMANDS
|
||||||
new Mint a box: fresh from cloud-init, or --from an existing box/snapshot
|
EOF
|
||||||
list List your boxes
|
local r v sum
|
||||||
info One box: state, type, IP, and its snapshot labels
|
for r in "${CMDS[@]}"; do
|
||||||
shell Open a shell in a box, as the claude user
|
IFS='^' read -r v _ _ sum _ _ <<<"$r"
|
||||||
exec Run a command inside a box
|
printf ' %-9s %s\n' "$v" "$sum"
|
||||||
snapshot Checkpoint a box (label defaults to manual-<epoch>)
|
done
|
||||||
restore Roll a box back to one of its snapshots
|
cat <<'EOF'
|
||||||
down Stop a box, keeping its state ('start' resumes it)
|
|
||||||
start Start a stopped box
|
|
||||||
rm Delete a box and its snapshots — irreversible, and it asks first
|
|
||||||
status Deprecated alias for 'list'
|
|
||||||
help This help, or 'claudebox help <command>' for one command
|
|
||||||
|
|
||||||
OPTIONS
|
OPTIONS
|
||||||
--name <box> Name for the new box (new)
|
--name <box> Name for the new box (new)
|
||||||
|
|
@ -115,6 +149,9 @@ EXAMPLES
|
||||||
# run something without opening a shell
|
# run something without opening a shell
|
||||||
claudebox exec work -- git -C project pull
|
claudebox exec work -- git -C project pull
|
||||||
|
|
||||||
|
# anything claudebox doesn't wrap: boxes are plain Incus instances
|
||||||
|
claudebox incus work -- config show
|
||||||
|
|
||||||
EXIT STATUS
|
EXIT STATUS
|
||||||
0 ok
|
0 ok
|
||||||
1 it went wrong (Incus failed, no such box, aborted at a prompt)
|
1 it went wrong (Incus failed, no such box, aborted at a prompt)
|
||||||
|
|
@ -126,10 +163,16 @@ THE MODEL
|
||||||
secret. A box reaches the public internet and nothing else — there is no
|
secret. A box reaches the public internet and nothing else — there is no
|
||||||
inbound path. Destroying a box loses nothing you didn't push.
|
inbound path. Destroying a box loses nothing you didn't push.
|
||||||
|
|
||||||
|
claudebox owns a command when it must enforce something Incus cannot see: the
|
||||||
|
user.claudebox=1 boundary, the isolation stack, or the creds-free snapshot
|
||||||
|
workflow. Everything else is Incus's job — and 'claudebox incus' is the door.
|
||||||
|
|
||||||
Docs: https://github.com/heavy-duty/claudebox
|
Docs: https://github.com/heavy-duty/claudebox
|
||||||
EOF
|
EOF
|
||||||
}
|
}
|
||||||
|
|
||||||
|
# The synopsis and the summary come from the table; only prose lives here, and
|
||||||
|
# only where a command has something to say beyond its summary.
|
||||||
help_cmd() {
|
help_cmd() {
|
||||||
echo "usage: $(synopsis_of "$1")"
|
echo "usage: $(synopsis_of "$1")"
|
||||||
echo
|
echo
|
||||||
|
|
@ -201,17 +244,15 @@ that snapshot is lost. 'claudebox info <box>' lists the labels.
|
||||||
claudebox restore work authed
|
claudebox restore work authed
|
||||||
EOF
|
EOF
|
||||||
;;
|
;;
|
||||||
down) cat <<'EOF'
|
rename) cat <<'EOF'
|
||||||
Stop a box. Its disk and state are kept — 'claudebox start' resumes it, Claude
|
Rename a box. Incus cannot rename a running instance, so stop it first:
|
||||||
login and all.
|
|
||||||
|
|
||||||
claudebox down work
|
claudebox down work
|
||||||
EOF
|
claudebox rename work archive
|
||||||
;;
|
claudebox start archive
|
||||||
start) cat <<'EOF'
|
|
||||||
Start a stopped box.
|
|
||||||
|
|
||||||
claudebox start work
|
Snapshots and Claude auth follow the box; anything referring to the old name by
|
||||||
|
hand (a --from line, a script) does not.
|
||||||
EOF
|
EOF
|
||||||
;;
|
;;
|
||||||
rm) cat <<'EOF'
|
rm) cat <<'EOF'
|
||||||
|
|
@ -222,6 +263,25 @@ confirmation first; --force (-f) skips the prompt. With no TTY to confirm on
|
||||||
claudebox rm work
|
claudebox rm work
|
||||||
claudebox rm work --force
|
claudebox rm work --force
|
||||||
EOF
|
EOF
|
||||||
|
;;
|
||||||
|
incus) cat <<'EOF'
|
||||||
|
The door out. claudebox wraps the box lifecycle and the isolation model, not
|
||||||
|
all of Incus — so when you need something it doesn't wrap, run Incus through
|
||||||
|
here and keep the safety rail that matters: the box name is resolved and
|
||||||
|
checked against the user.claudebox=1 tag, so you cannot aim it at an instance
|
||||||
|
claudebox didn't mint.
|
||||||
|
|
||||||
|
Everything after -- is passed to incus verbatim. A literal {} is replaced with
|
||||||
|
the resolved instance name; with no {}, the instance is appended at the end.
|
||||||
|
The command that will run is echoed before it runs.
|
||||||
|
|
||||||
|
claudebox incus work -- config show
|
||||||
|
claudebox incus work -- config device add {} extra disk source=/data path=/data
|
||||||
|
|
||||||
|
Changing the profile, the network, a device or a security.* key can take a box
|
||||||
|
outside the isolation stack. claudebox warns and then does as you asked — from
|
||||||
|
there, the trust boundary is yours to keep.
|
||||||
|
EOF
|
||||||
;;
|
;;
|
||||||
status) cat <<'EOF'
|
status) cat <<'EOF'
|
||||||
Deprecated alias for 'claudebox list'. It ignored the <box> argument it
|
Deprecated alias for 'claudebox list'. It ignored the <box> argument it
|
||||||
|
|
@ -233,9 +293,10 @@ EOF
|
||||||
Print the general help, or the help for one command.
|
Print the general help, or the help for one command.
|
||||||
|
|
||||||
claudebox help
|
claudebox help
|
||||||
claudebox help new
|
claudebox help rename
|
||||||
EOF
|
EOF
|
||||||
;;
|
;;
|
||||||
|
*) field "$1" sum ;; # no prose: the table's summary is the help
|
||||||
esac
|
esac
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -269,8 +330,8 @@ while [ $# -gt 0 ]; do
|
||||||
# An unrecognized flag used to be swallowed as a positional — so a typo'd
|
# An unrecognized flag used to be swallowed as a positional — so a typo'd
|
||||||
# --labl silently became a snapshot's label. Say so instead.
|
# --labl silently became a snapshot's label. Say so instead.
|
||||||
-*)
|
-*)
|
||||||
if [ "$cmd" = exec ]; then
|
if [ "$cmd" = exec ] || [ "$cmd" = incus ]; then
|
||||||
usage_error "unknown option: $1 — a command's own flags go after --, as in 'claudebox exec <box> -- <cmd...>'"
|
usage_error "unknown option: $1 — a command's own flags go after --, as in '$(synopsis_of "$cmd")'"
|
||||||
fi
|
fi
|
||||||
usage_error "unknown option: $1 (see 'claudebox help $cmd')" ;;
|
usage_error "unknown option: $1 (see 'claudebox help $cmd')" ;;
|
||||||
*) args+=("$1"); shift ;;
|
*) args+=("$1"); shift ;;
|
||||||
|
|
@ -279,13 +340,42 @@ done
|
||||||
|
|
||||||
if [ "$want_help" -eq 1 ]; then show_help "$cmd"; exit 0; fi
|
if [ "$want_help" -eq 1 ]; then show_help "$cmd"; exit 0; fi
|
||||||
|
|
||||||
iname_of() { echo "$remote$1"; } # instance name = box name; claudebox tags them with user.claudebox=1
|
# --- preconditions ---------------------------------------------------------
|
||||||
|
|
||||||
|
iname_of() { echo "$remote$1"; } # instance name = box name
|
||||||
|
|
||||||
|
# The boundary, enforced: a box is an Incus instance WE tagged. Anything else is
|
||||||
|
# somebody's VM, and claudebox will not stop, rename or delete it by accident.
|
||||||
|
resolve_box() {
|
||||||
|
local box="$1" i tag
|
||||||
|
i="$(iname_of "$box")"
|
||||||
|
tag="$(incus config get "$i" user.claudebox 2>/dev/null || true)"
|
||||||
|
[ "$tag" = "1" ] || die "no such box: $box (see 'claudebox list')"
|
||||||
|
echo "$i"
|
||||||
|
}
|
||||||
|
|
||||||
|
box_state() { incus list "$1" --format csv --columns s 2>/dev/null | head -n1; }
|
||||||
|
|
||||||
|
require_stopped() {
|
||||||
|
local i="$1" box="$2" st; st="$(box_state "$i")"
|
||||||
|
case "$st" in
|
||||||
|
STOPPED|Stopped|stopped) return 0 ;;
|
||||||
|
*) die "box '$box' is ${st:-not stopped} — Incus needs it stopped for this. Stop it: claudebox down $box" ;;
|
||||||
|
esac
|
||||||
|
}
|
||||||
|
|
||||||
need_name() {
|
need_name() {
|
||||||
if [ "${#args[@]}" -lt 1 ] || [ -z "${args[0]}" ]; then
|
if [ "${#args[@]}" -lt 1 ] || [ -z "${args[0]}" ]; then
|
||||||
usage_error "usage: $(synopsis_of "$cmd")"
|
usage_error "usage: $(synopsis_of "$cmd")"
|
||||||
fi
|
fi
|
||||||
}
|
}
|
||||||
|
|
||||||
|
need_arg2() {
|
||||||
|
if [ "${#args[@]}" -lt 2 ] || [ -z "${args[1]}" ]; then
|
||||||
|
usage_error "usage: $(synopsis_of "$cmd")"
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
confirm() { # $1 = prompt. --force, or a TTY to ask on, or we refuse.
|
confirm() { # $1 = prompt. --force, or a TTY to ask on, or we refuse.
|
||||||
if [ "$force" -eq 1 ]; then return 0; fi
|
if [ "$force" -eq 1 ]; then return 0; fi
|
||||||
[ -t 0 ] || usage_error "refusing to $1 without --force (no terminal to confirm on)"
|
[ -t 0 ] || usage_error "refusing to $1 without --force (no terminal to confirm on)"
|
||||||
|
|
@ -295,6 +385,8 @@ confirm() { # $1 = prompt. --force, or a TTY to ask on, or we refuse.
|
||||||
case "$reply" in y|Y|yes|YES|Yes) return 0 ;; *) die "aborted." ;; esac
|
case "$reply" in y|Y|yes|YES|Yes) return 0 ;; *) die "aborted." ;; esac
|
||||||
}
|
}
|
||||||
|
|
||||||
|
# --- commands with real work -----------------------------------------------
|
||||||
|
|
||||||
pick_mode() {
|
pick_mode() {
|
||||||
if [ "$mode" != auto ]; then echo "$mode"; return; fi
|
if [ "$mode" != auto ]; then echo "$mode"; return; fi
|
||||||
if [ -n "$remote" ] || [ -e /dev/kvm ]; then echo vm; else
|
if [ -n "$remote" ] || [ -e /dev/kvm ]; then echo vm; else
|
||||||
|
|
@ -313,8 +405,8 @@ wait_agent() {
|
||||||
done
|
done
|
||||||
}
|
}
|
||||||
|
|
||||||
new() {
|
cmd_new() {
|
||||||
[ -n "$name" ] || die "usage: claudebox new --name <box> [--from <src>[/<snap>]]"
|
[ -n "$name" ] || usage_error "usage: $(synopsis_of new)"
|
||||||
local instance; instance="$(iname_of "$name")"
|
local instance; instance="$(iname_of "$name")"
|
||||||
if [ -n "$from" ]; then
|
if [ -n "$from" ]; then
|
||||||
local src="${from%%/*}" snap="" srcref
|
local src="${from%%/*}" snap="" srcref
|
||||||
|
|
@ -350,7 +442,7 @@ boxes_csv() {
|
||||||
}
|
}
|
||||||
|
|
||||||
box_ipv4() { # first address only; strips Incus's " (iface)" suffix. "-" if none.
|
box_ipv4() { # first address only; strips Incus's " (iface)" suffix. "-" if none.
|
||||||
incus list "$(iname_of "$1")" --format csv --columns 4 2>/dev/null \
|
incus list "$1" --format csv --columns 4 2>/dev/null \
|
||||||
| tr -d '"' | sed 's/ (.*//' | grep -v '^[[:space:]]*$' | head -n1 \
|
| tr -d '"' | sed 's/ (.*//' | grep -v '^[[:space:]]*$' | head -n1 \
|
||||||
| grep . || echo "-"
|
| grep . || echo "-"
|
||||||
}
|
}
|
||||||
|
|
@ -383,40 +475,9 @@ list_all() {
|
||||||
sub(/ +$/, "", line); print line } }'
|
sub(/ +$/, "", line); print line } }'
|
||||||
}
|
}
|
||||||
|
|
||||||
info() {
|
|
||||||
local box="$1" row
|
|
||||||
# Same tagged set as list_all: an untagged instance is not a box, it's someone
|
|
||||||
# else's VM, and we say "no such box" rather than reaching into it.
|
|
||||||
row="$(boxes_csv | awk -F, -v b="$box" '$1 == b { print; exit }')"
|
|
||||||
[ -n "$row" ] || die "no such box: $box (see 'claudebox list')"
|
|
||||||
|
|
||||||
local state type snaps
|
|
||||||
IFS=, read -r _ state type snaps <<<"$row"
|
|
||||||
printf '%-11s%s\n' NAME "$box" STATE "${state:--}" TYPE "$(short_type "$type")" \
|
|
||||||
IPV4 "$(box_ipv4 "$box")"
|
|
||||||
|
|
||||||
echo
|
|
||||||
case "${snaps:-0}" in
|
|
||||||
''|0)
|
|
||||||
echo "SNAPSHOTS (none)"
|
|
||||||
echo
|
|
||||||
echo "Take one: claudebox snapshot $box authed"
|
|
||||||
return 0 ;;
|
|
||||||
esac
|
|
||||||
echo "SNAPSHOTS"
|
|
||||||
local first=""
|
|
||||||
while IFS=, read -r sname taken _; do
|
|
||||||
[ -n "$sname" ] || continue
|
|
||||||
[ -n "$first" ] || first="$sname"
|
|
||||||
printf ' %-14s%s\n' "$sname" "$taken"
|
|
||||||
done < <(incus snapshot list "$(iname_of "$box")" --format csv 2>/dev/null)
|
|
||||||
echo
|
|
||||||
echo "Clone one: claudebox new --name <new> --from $box/${first:-<snapshot>}"
|
|
||||||
}
|
|
||||||
|
|
||||||
# 'list' lists them all; 'info' shows one. A box name handed to 'list' is a wrong
|
# 'list' lists them all; 'info' shows one. A box name handed to 'list' is a wrong
|
||||||
# guess we can answer, not a surprise: point at the command that does want one.
|
# guess we can answer, not a surprise: point at the command that does want one.
|
||||||
list() {
|
cmd_list() {
|
||||||
if [ "${#args[@]}" -ge 1 ] && [ -n "${args[0]}" ]; then
|
if [ "${#args[@]}" -ge 1 ] && [ -n "${args[0]}" ]; then
|
||||||
die "list takes no box — for one box, use: claudebox info ${args[0]}"
|
die "list takes no box — for one box, use: claudebox info ${args[0]}"
|
||||||
fi
|
fi
|
||||||
|
|
@ -427,23 +488,100 @@ list() {
|
||||||
fi
|
fi
|
||||||
}
|
}
|
||||||
|
|
||||||
case "$cmd" in
|
cmd_info() {
|
||||||
new) new ;;
|
local box="${args[0]}" row
|
||||||
snapshot) need_name; label="${args[1]:-manual-$(date +%s)}"; incus snapshot create "$(iname_of "${args[0]}")" "$label"; echo "$label" ;;
|
if [ "$json" -eq 1 ]; then incus list "$inst" --format json; return; fi
|
||||||
restore) need_name; [ -n "${args[1]:-}" ] || die "usage: claudebox restore <box> <snapshot>"; incus restore "$(iname_of "${args[0]}")" "${args[1]}" ;;
|
|
||||||
shell) need_name; incus exec "$(iname_of "${args[0]}")" -- sudo -u claude -i ;;
|
row="$(boxes_csv | awk -F, -v b="$box" '$1 == b { print; exit }')"
|
||||||
exec) need_name; incus exec "$(iname_of "${args[0]}")" -- sudo -u claude -i "${args[@]:1}" ;;
|
[ -n "$row" ] || die "no such box: $box (see 'claudebox list')"
|
||||||
down) need_name; incus stop "$(iname_of "${args[0]}")" ;;
|
|
||||||
start) need_name; incus start "$(iname_of "${args[0]}")" ;;
|
local state type snaps
|
||||||
# -f is how Incus deletes a *running* instance; it is not our confirmation.
|
IFS=, read -r _ state type snaps <<<"$row"
|
||||||
# The prompt is: 'rm' destroys the box and every snapshot on it.
|
printf '%-11s%s\n' NAME "$box" STATE "${state:--}" TYPE "$(short_type "$type")" \
|
||||||
rm) need_name; inst="$(iname_of "${args[0]}")"
|
IPV4 "$(box_ipv4 "$inst")"
|
||||||
confirm "delete $inst and all its snapshots"
|
|
||||||
incus delete -f "$inst"; echo "claudebox: removed $inst" ;;
|
echo
|
||||||
list) list ;;
|
case "${snaps:-0}" in
|
||||||
info) need_name
|
''|0)
|
||||||
if [ "$json" -eq 1 ]; then incus list "$(iname_of "${args[0]}")" --format json; else info "${args[0]}"; fi ;;
|
echo "SNAPSHOTS (none)"
|
||||||
status) echo "claudebox: 'status' is deprecated — use 'claudebox list'." >&2; list_all ;;
|
echo
|
||||||
help) show_help "${args[0]:-}" ;;
|
echo "Take one: claudebox snapshot $box authed"
|
||||||
*) unknown_command "$cmd" ;;
|
return 0 ;;
|
||||||
|
esac
|
||||||
|
echo "SNAPSHOTS"
|
||||||
|
local first="" sname taken
|
||||||
|
while IFS=, read -r sname taken _; do
|
||||||
|
[ -n "$sname" ] || continue
|
||||||
|
[ -n "$first" ] || first="$sname"
|
||||||
|
printf ' %-14s%s\n' "$sname" "$taken"
|
||||||
|
done < <(incus snapshot list "$inst" --format csv 2>/dev/null)
|
||||||
|
echo
|
||||||
|
echo "Clone one: claudebox new --name <new> --from $box/${first:-<snapshot>}"
|
||||||
|
}
|
||||||
|
|
||||||
|
cmd_shell() { incus exec "$inst" -- sudo -u claude -i; }
|
||||||
|
cmd_exec() { incus exec "$inst" -- sudo -u claude -i "${args[@]:1}"; }
|
||||||
|
|
||||||
|
cmd_snapshot() {
|
||||||
|
local label="${args[1]:-manual-$(date +%s)}"
|
||||||
|
incus snapshot create "$inst" "$label"
|
||||||
|
echo "$label"
|
||||||
|
}
|
||||||
|
|
||||||
|
cmd_status() {
|
||||||
|
echo "claudebox: 'status' is deprecated — use 'claudebox list'." >&2
|
||||||
|
list_all
|
||||||
|
}
|
||||||
|
|
||||||
|
cmd_help() { show_help "${args[0]:-}"; }
|
||||||
|
|
||||||
|
# The escape hatch. The box is resolved and tag-checked; everything else is
|
||||||
|
# yours. {} is the instance name; without it, the instance goes last.
|
||||||
|
warn_isolation() {
|
||||||
|
case " $* " in
|
||||||
|
*" profile "*|*" network "*|*" device "*|*security.*|*" nic "*)
|
||||||
|
echo "claudebox: warning: this can move the box off the isolation stack" >&2
|
||||||
|
echo "claudebox: (profile / network / device / security.*). The trust boundary is yours from here." >&2 ;;
|
||||||
|
esac
|
||||||
|
}
|
||||||
|
|
||||||
|
cmd_incus() {
|
||||||
|
local rest=("${args[@]:1}") out=() a replaced=0
|
||||||
|
[ "${#rest[@]}" -gt 0 ] || usage_error "usage: $(synopsis_of incus)"
|
||||||
|
for a in "${rest[@]}"; do
|
||||||
|
case "$a" in
|
||||||
|
*"{}"*) out+=("${a//\{\}/$inst}"); replaced=1 ;;
|
||||||
|
*) out+=("$a") ;;
|
||||||
|
esac
|
||||||
|
done
|
||||||
|
[ "$replaced" -eq 1 ] || out+=("$inst")
|
||||||
|
warn_isolation "${out[@]}"
|
||||||
|
echo "claudebox: incus ${out[*]}" >&2 # no magic: show what runs
|
||||||
|
incus "${out[@]}"
|
||||||
|
}
|
||||||
|
|
||||||
|
# --- dispatch: driven by the table, not by a hand-written case --------------
|
||||||
|
|
||||||
|
row="$(cmd_row "$cmd")" || unknown_command "$cmd"
|
||||||
|
IFS='^' read -r _ _ pre _ action ok <<<"$row"
|
||||||
|
|
||||||
|
case ",$pre," in *,box,*) need_name; inst="$(resolve_box "${args[0]}")" ;; esac
|
||||||
|
case ",$pre," in *,arg2,*) need_arg2 ;; esac
|
||||||
|
case ",$pre," in *,stopped,*) require_stopped "$inst" "${args[0]}" ;; esac
|
||||||
|
case ",$pre," in *,confirm,*) confirm "delete $inst and all its snapshots" ;; esac
|
||||||
|
|
||||||
|
case "$action" in
|
||||||
|
fn:*)
|
||||||
|
"${action#fn:}"
|
||||||
|
;;
|
||||||
|
incus:*)
|
||||||
|
sub="${action#incus:}"
|
||||||
|
# word-split intentionally: a subcommand may carry a flag ("delete -f")
|
||||||
|
# shellcheck disable=SC2086
|
||||||
|
incus $sub "$inst" "${args[@]:1}"
|
||||||
|
if [ -n "$ok" ]; then
|
||||||
|
msg="${ok//\{\}/${args[0]}}"; msg="${msg//\{1\}/${args[1]:-}}"
|
||||||
|
echo "claudebox: $msg"
|
||||||
|
fi
|
||||||
|
;;
|
||||||
esac
|
esac
|
||||||
|
|
|
||||||
|
|
@ -54,6 +54,42 @@ Not host-executed shell. A repo that wants to be easy to stand up in a sandbox
|
||||||
ships a runbook (prose + optional scripts the agent may run). A repo that does
|
ships a runbook (prose + optional scripts the agent may run). A repo that does
|
||||||
not, you set up by hand. The tool enforces no contract; there is no `install`.
|
not, you set up by hand. The tool enforces no contract; there is no `install`.
|
||||||
|
|
||||||
|
## What claudebox owns, and what it doesn't
|
||||||
|
|
||||||
|
Boxes are ordinary Incus instances, tagged `user.claudebox=1`. That makes every
|
||||||
|
Incus verb a candidate feature request — `rename`, `info`, `file push`, on
|
||||||
|
forever — and wrapping them one at a time grows a worse `incus`. The rule:
|
||||||
|
|
||||||
|
> **claudebox owns a command when it must enforce an invariant Incus cannot see:**
|
||||||
|
> the `user.claudebox=1` boundary (never touch an instance we didn't mint), the
|
||||||
|
> isolation stack (`claude-dev` profile + `claudenet` + ACL), or the creds-free
|
||||||
|
> snapshot→clone workflow. Everything else is Incus's job.
|
||||||
|
|
||||||
|
The rule cuts both ways, and that's the point:
|
||||||
|
|
||||||
|
- `rename` **is** ours — not because it adds logic to `incus rename`, but because
|
||||||
|
resolving the name *is* the logic: check the tag, apply `--remote`, and notice
|
||||||
|
the box is running (Incus won't rename a running instance) so we can say "stop
|
||||||
|
it first" rather than leak an Incus error.
|
||||||
|
- `incus config set security.nesting=false` is **not** ours. It dismantles the
|
||||||
|
trust boundary; wrapping it would imply we bless it.
|
||||||
|
|
||||||
|
Two mechanisms keep this honest.
|
||||||
|
|
||||||
|
**The command table** (`CMDS` in `bin/claudebox`) is the single source of truth
|
||||||
|
for what exists, its synopsis, its help line, its preconditions and what runs.
|
||||||
|
Dispatch and help are both rendered from it, so the help cannot describe a
|
||||||
|
command that doesn't exist — the failure that produced #8. A thin verb is one
|
||||||
|
row; a verb that can't be expressed as a row and enforces no invariant of ours
|
||||||
|
doesn't belong in the tool.
|
||||||
|
|
||||||
|
**The escape hatch** — `claudebox incus <box> -- <args...>` — resolves and
|
||||||
|
tag-checks the box, then hands the rest to Incus verbatim. It means "no" to a
|
||||||
|
proxy request is not "you can't do that", and it keeps the one rail that matters:
|
||||||
|
you cannot aim it at an instance claudebox didn't mint. If the command can move
|
||||||
|
the box off the isolation stack (profile, network, device, `security.*`), it
|
||||||
|
warns and proceeds — from there the trust boundary is yours to keep.
|
||||||
|
|
||||||
## Isolation (unchanged)
|
## Isolation (unchanged)
|
||||||
|
|
||||||
Dedicated NAT bridge `claudenet` + Incus `claude-isolate` ACL dropping all
|
Dedicated NAT bridge `claudenet` + Incus `claude-isolate` ACL dropping all
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue