feat!: claudebox becomes box — the Claude box is one template among several
The tool underneath was already generic: a thin, honest wrapper over Incus. What was Claude-specific was welded on — one image, one profile, one cloud-init file, one hardcoded 'sudo -u claude'. The weld is now a template. The mechanic: 'box new' stamps the template's identity onto the instance (user.box=1, user.box.template, user.box.user); shell/exec/ tmux read the user back off the instance, and 'incus copy' carries user.* keys (audit B2), so a clone knows what it is without consulting the template. Templates are box.env (parsed against a strict allowlist, never sourced — no key for a network exists, on purpose) plus a verbatim cloud-init. Every template launches with the shared box-net profile: the isolated NIC and root disk, nothing template-controlled — resources land per-instance from box.env, overridable via BOX_CPU/ BOX_MEMORY/BOX_DISK (which is also how the drill shrinks boxes on a small host now that profile edits can't). The three open calls, taken as recommended: clean cut at 0.4.0 (no claudebox shim; the installer retires the old symlink); default template = claude (muscle memory survives); repo stays heavy-duty/ claudebox, binary is box. Compat is the tag, not the name: resolve_box and list honor the legacy user.claudebox=1 forever, and the legacy tag maps to the claude user — a pre-rename box lists, shells, clones, unchanged. Deliberate divergence from #17's table: the host-stack resource names (claudenet, claude-isolate, nft tables, claudebox-firewall.*) are NOT renamed — they are host-internal, invisible to users, and renaming them breaks every provisioned host for zero user-visible gain. claude-dev is no longer created; setup-host creates box-net, teardown removes both. Closes #17
This commit is contained in:
parent
ffdfb52512
commit
c11f3d7552
13 changed files with 500 additions and 282 deletions
121
README.md
121
README.md
|
|
@ -1,8 +1,10 @@
|
|||
# claudebox
|
||||
# claudebox — ships the `box` CLI
|
||||
|
||||
A CLI to run **headless, trust-less Claude Code in throwaway VMs**. One command
|
||||
mints a fresh, network-isolated Incus box with Claude Code installed. The box is
|
||||
the product — you log in and work; destroying it loses nothing you didn't push.
|
||||
**Headless, trust-less, throwaway dev VMs.** One command mints a fresh,
|
||||
network-isolated Incus box from a **template**; the flagship template is
|
||||
`claude` — Debian 13 with Claude Code installed, the box this repo is named
|
||||
for. The box is the product — you log in and work; destroying it loses
|
||||
nothing you didn't push.
|
||||
|
||||
**Strictly creds-free.** A box ships with everything installed and **no**
|
||||
credentials — no Claude token, no git PAT, nothing. You authenticate
|
||||
|
|
@ -10,20 +12,30 @@ interactively *inside* the box. The tool never stores or injects a secret. That
|
|||
means there's nothing shared or committed, so it's safe for multiple operators
|
||||
out of the box.
|
||||
|
||||
**Templates set what's in the box, never what it can reach.** A template is
|
||||
image + user + resources + cloud-init; the network and every security flag
|
||||
live in a shared profile no template can touch, so `blank` is a box with
|
||||
nobody home — not a box with the safety off.
|
||||
|
||||
**The tool knows nothing about your projects.** You just `git clone` inside a
|
||||
box. A repo can ship an optional [`.claudebox/`](docs/claudebox-recipe.md)
|
||||
runbook that Claude Code reads and acts on — there is no `install` step and no
|
||||
host-run setup. See [docs/claudebox-design.md](docs/claudebox-design.md) for the
|
||||
design rationale.
|
||||
|
||||
> **0.4.0 renamed the CLI** from `claudebox` to `box` — a clean cut, no shim.
|
||||
> Existing boxes minted by any earlier version keep working under every verb
|
||||
> (their legacy tag is honored forever); only the old command name retired.
|
||||
|
||||
## Install
|
||||
|
||||
```sh
|
||||
curl -fsSL https://raw.githubusercontent.com/heavy-duty/claudebox/main/install.sh | bash
|
||||
```
|
||||
|
||||
Installs the tree to `~/.local/share/claudebox` and links `claudebox` onto your
|
||||
`PATH`. Re-run any time to upgrade. (No `git clone` needed.)
|
||||
Installs the tree to `~/.local/share/claudebox` and links `box` onto your
|
||||
`PATH`. Re-run any time to upgrade — upgrading from a pre-0.4.0 install also
|
||||
retires the old `claudebox` symlink. (No `git clone` needed.)
|
||||
|
||||
## One-time host setup (Ubuntu 24.04 / Debian 13)
|
||||
|
||||
|
|
@ -34,7 +46,7 @@ Installs the tree to `~/.local/share/claudebox` and links `claudebox` onto your
|
|||
Idempotent. Installs Incus and creates the isolation stack: the `claudenet` NAT
|
||||
bridge (sibling-name resolution off, resolver pinned to public upstreams —
|
||||
`BOX_DNS` overrides), the `claude-isolate` ACL (drops all RFC1918/CGNAT/
|
||||
link-local egress), the `claude-dev` profile (port-isolated NICs — boxes can't
|
||||
link-local egress), the `box-net` profile (port-isolated NICs — boxes can't
|
||||
reach each other), and firewall rules blocking instance → host. All rules
|
||||
re-apply at boot via `claudebox-firewall.service` — no post-reboot ritual. If
|
||||
the host lacks `dnsmasq-base` (Debian cloud images skip Recommends):
|
||||
|
|
@ -43,8 +55,8 @@ the host lacks `dnsmasq-base` (Debian cloud images skip Recommends):
|
|||
## Quick start
|
||||
|
||||
```sh
|
||||
claudebox new --name work # mint a fresh, creds-free box (~10 min cold)
|
||||
claudebox shell work # enter as the claude user
|
||||
box new --name work # mint a fresh, creds-free claude box (~10 min cold)
|
||||
box shell work # enter as the template's user
|
||||
```
|
||||
|
||||
Inside the box, authenticate as needed:
|
||||
|
|
@ -57,69 +69,92 @@ git clone https://github.com/you/project && cd project
|
|||
claude # if the repo has .claudebox/, Claude reads it and sets up
|
||||
```
|
||||
|
||||
## Templates
|
||||
|
||||
The claude box is one template among several. A template is a directory under
|
||||
`templates/`: a `box.env` (image, user, resources — parsed against a strict
|
||||
allowlist, never sourced) and a `user-data.yaml` (cloud-init, passed to Incus
|
||||
verbatim).
|
||||
|
||||
```sh
|
||||
box templates # list what this install can mint
|
||||
box new --name scratch --template blank # bare Debian: same isolation, no tooling
|
||||
```
|
||||
|
||||
A template **cannot** name a network, a profile, or a `security.*` flag —
|
||||
there is no key for them. Every box launches with the shared `box-net`
|
||||
profile (the isolated NIC + root disk), so every template gets the identical
|
||||
trust boundary. Resources come from the template's `box.env`;
|
||||
`BOX_CPU` / `BOX_MEMORY` / `BOX_DISK` environment variables override them at
|
||||
mint time. The template's identity (name, user) is stamped onto the instance,
|
||||
so `shell`, `exec` and `tmux` land in the right user — and a clone still
|
||||
knows, because `incus copy` carries the metadata.
|
||||
|
||||
## Log in once, reuse via snapshots
|
||||
|
||||
Because every fresh box is creds-free, re-authenticating each time would be
|
||||
toil. Snapshot an authenticated box and clone from it instead:
|
||||
|
||||
```sh
|
||||
claudebox snapshot work authed # checkpoint after you've logged in
|
||||
claudebox new --name feature --from work/authed # clone the authed state into a new box
|
||||
box snapshot work authed # checkpoint after you've logged in
|
||||
box new --name feature --from work/authed # clone the authed state into a new box
|
||||
```
|
||||
|
||||
`--from` copies the whole box (Claude login, git creds, clones and all) while
|
||||
preserving isolation. You can also `claudebox new --name x --from work` to clone
|
||||
a box's live state, or roll a box back with `claudebox restore work authed`.
|
||||
preserving isolation. You can also `box new --name x --from work` to clone
|
||||
a box's live state, or roll a box back with `box restore work authed`.
|
||||
|
||||
Forgotten what you called a checkpoint? `claudebox info work` prints the box's
|
||||
Forgotten what you called a checkpoint? `box info work` prints the box's
|
||||
snapshot labels and the `--from` line to clone one.
|
||||
|
||||
## Commands
|
||||
|
||||
```
|
||||
claudebox new --name <box> [--from <src>[/<snap>]] [--vm|--container] [--remote r]
|
||||
claudebox list # list your boxes
|
||||
claudebox info <box> # one box: state, IP, snapshot labels
|
||||
claudebox shell <box> # enter as the claude user
|
||||
claudebox exec <box> -- <cmd...> # run a command in the box
|
||||
claudebox tmux <box> [session] # attach/create a tmux session — survives disconnects
|
||||
claudebox snapshot <box> [label] # checkpoint (label defaults to manual-<epoch>)
|
||||
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 start <box> # start a stopped box
|
||||
claudebox rm <box> [--force] # delete the box + its snapshots (asks first)
|
||||
claudebox incus <box> -- <args...> # escape hatch: any incus command, box resolved
|
||||
claudebox doctor [--fix|--pin-dns] # is this host fit to mint boxes? diagnose from ground truth
|
||||
claudebox status # deprecated alias for `list`
|
||||
claudebox help [<command>] # full help, or one command's page
|
||||
box new --name <box> [--template <t>] [--from <src>[/<snap>]] [--vm|--container] [--remote r]
|
||||
box templates # list the templates this install can mint
|
||||
box list # list your boxes
|
||||
box info <box> # one box: state, IP, snapshot labels
|
||||
box shell <box> # enter as the template's user
|
||||
box exec <box> -- <cmd...> # run a command in the box
|
||||
box tmux <box> [session] # attach/create a tmux session — survives disconnects
|
||||
box snapshot <box> [label] # checkpoint (label defaults to manual-<epoch>)
|
||||
box restore <box> <snap> # roll back to a snapshot
|
||||
box rename <box> <new> # rename a box (stop it first)
|
||||
box down <box> # stop (state kept; `start` resumes)
|
||||
box start <box> # start a stopped box
|
||||
box rm <box> [--force] # delete the box + its snapshots (asks first)
|
||||
box incus <box> -- <args...> # escape hatch: any incus command, box resolved
|
||||
box doctor [--fix|--pin-dns] # is this host fit to mint boxes? diagnose from ground truth
|
||||
box status # deprecated alias for `list`
|
||||
box help [<command>] # full help, or one command's page
|
||||
```
|
||||
|
||||
Every command takes `--help`, and options come after the command
|
||||
(`claudebox list --json`). Exit status: `0` ok, `1` it went wrong, `2` you asked
|
||||
(`box list --json`). Exit status: `0` ok, `1` it went wrong, `2` you asked
|
||||
wrong.
|
||||
|
||||
`new` fresh-launches from cloud-init, or with `--from` clones an existing box or
|
||||
snapshot. VM mode (`--vm`, the default where `/dev/kvm` exists) is the trust-less
|
||||
target; container mode (auto-fallback, `security.nesting=true`) is for hosts
|
||||
without nested virt — weaker isolation, dev/test only.
|
||||
`new` fresh-launches from a template (default: `claude`), or with `--from`
|
||||
clones an existing box or snapshot. VM mode (`--vm`, the default where
|
||||
`/dev/kvm` exists) is the trust-less 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
|
||||
A box is an ordinary Incus instance tagged `user.box=1` (pre-0.4.0 boxes
|
||||
carry `user.claudebox=1`, honored forever). box 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 {}
|
||||
box incus work -- config show # instance name appended
|
||||
box 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 —
|
||||
stack (profile, network, device, `security.*`), box 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.
|
||||
|
|
@ -147,8 +182,8 @@ enforces it, layer by layer:
|
|||
the host's public IPs. Entry is `incus exec` over the local socket only —
|
||||
**no inbound path exists.**
|
||||
|
||||
The VM is the trust boundary: Claude can run arbitrary code inside and touch
|
||||
nothing you care about.
|
||||
The VM is the trust boundary: whatever runs inside — Claude, or anything a
|
||||
template ships — can run arbitrary code and touch nothing you care about.
|
||||
|
||||
### Measured, not claimed
|
||||
|
||||
|
|
@ -184,7 +219,7 @@ host-executed script. See [docs/claudebox-recipe.md](docs/claudebox-recipe.md).
|
|||
```sh
|
||||
~/.local/share/claudebox/host/teardown-host.sh # boxes, network, ACL, profile, firewall
|
||||
~/.local/share/claudebox/host/teardown-host.sh --purge-incus # ...and Incus itself
|
||||
rm -rf ~/.local/share/claudebox ~/.local/bin/claudebox # the CLI
|
||||
rm -rf ~/.local/share/claudebox ~/.local/bin/box # the CLI
|
||||
```
|
||||
|
||||
## Non-goals
|
||||
|
|
|
|||
2
VERSION
2
VERSION
|
|
@ -1 +1 @@
|
|||
0.3.0
|
||||
0.4.0
|
||||
|
|
|
|||
|
|
@ -1,17 +1,17 @@
|
|||
#!/usr/bin/env bash
|
||||
# claudebox — trust-less, isolated Incus VMs with Claude Code, creds-free.
|
||||
# box — trust-less, isolated Incus VMs with Claude Code, creds-free.
|
||||
# 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
|
||||
remote=""; mode="auto"; name=""; from=""; template=""; 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)"; }
|
||||
die() { echo "box: $*" >&2; exit 1; } # 1 = it went wrong
|
||||
usage_error() { echo "box: $*" >&2; echo "try 'box help'." >&2; exit 2; } # 2 = you asked wrong
|
||||
version() { echo "box $(cat "$root/VERSION" 2>/dev/null || echo unknown) ($root)"; }
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# The command table.
|
||||
|
|
@ -22,7 +22,7 @@ version() { echo "claudebox $(cat "$root/VERSION" 2>/dev/null || echo unknown) (
|
|||
#
|
||||
# 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
|
||||
# isn't tagged user.box=1 (or the legacy 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
|
||||
|
|
@ -34,13 +34,14 @@ version() { echo "claudebox $(cat "$root/VERSION" 2>/dev/null || echo unknown) (
|
|||
# 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.
|
||||
# doesn't enforce a box invariant, it is incus's job, not ours — that is
|
||||
# what `box 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^"
|
||||
"new^--name <box> [--template <t>] [--from <src>[/<snap>]] [--vm|--container]^^Mint a box from a template (default: claude), or --from an existing box/snapshot^fn:cmd_new^"
|
||||
"templates^^^List the templates this install can mint^fn:cmd_templates^"
|
||||
"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^"
|
||||
"shell^<box>^box^Open a shell in a box, as its template's user^fn:cmd_shell^"
|
||||
"exec^<box> -- <cmd...>^box^Run a command inside a box^fn:cmd_exec^"
|
||||
"tmux^<box> [<session>]^box^Attach or create a tmux session in a box — survives disconnects^fn:cmd_tmux^"
|
||||
"snapshot^<box> [<label>]^box^Checkpoint a box (label defaults to manual-<epoch>)^fn:cmd_snapshot^"
|
||||
|
|
@ -52,7 +53,7 @@ CMDS=(
|
|||
"incus^<box> -- <args...>^box^Escape hatch: run any incus command against a box^fn:cmd_incus^"
|
||||
"doctor^[--fix | --pin-dns]^^Is this host fit to mint boxes? Diagnose the daemon, network, DNS, isolation^fn:cmd_doctor^"
|
||||
"status^^^Deprecated alias for 'list'^fn:cmd_status^"
|
||||
"help^[<command>]^^This help, or 'claudebox help <command>' for one command^fn:cmd_help^"
|
||||
"help^[<command>]^^This help, or 'box help <command>' for one command^fn:cmd_help^"
|
||||
)
|
||||
|
||||
cmd_row() { local r; for r in "${CMDS[@]}"; do case "$r" in "$1^"*) echo "$r"; return 0 ;; esac; done; return 1; }
|
||||
|
|
@ -69,7 +70,7 @@ field() {
|
|||
act) echo "$f_act" ;; ok) echo "$f_ok" ;;
|
||||
esac
|
||||
}
|
||||
synopsis_of() { local s; s="$(field "$1" syn)"; echo "claudebox $1${s:+ $s}"; }
|
||||
synopsis_of() { local s; s="$(field "$1" syn)"; echo "box $1${s:+ $s}"; }
|
||||
|
||||
# Nearest command by edit distance — a typo should point somewhere, not just fail.
|
||||
suggest() {
|
||||
|
|
@ -97,20 +98,20 @@ suggest() {
|
|||
unknown_command() {
|
||||
local hint; hint="$(suggest "$1")"
|
||||
if [ -n "$hint" ]; then
|
||||
echo "claudebox: unknown command: $1 — did you mean '$hint'?" >&2
|
||||
echo "box: unknown command: $1 — did you mean '$hint'?" >&2
|
||||
else
|
||||
echo "claudebox: unknown command: $1" >&2
|
||||
echo "box: unknown command: $1" >&2
|
||||
fi
|
||||
echo "try 'claudebox help' for the command list." >&2
|
||||
echo "try 'box help' for the command list." >&2
|
||||
exit 2
|
||||
}
|
||||
|
||||
usage() {
|
||||
cat <<'EOF'
|
||||
claudebox — trust-less, network-isolated Incus VMs with Claude Code, creds-free.
|
||||
box — trust-less, network-isolated Incus VMs with Claude Code, creds-free.
|
||||
|
||||
USAGE
|
||||
claudebox <command> [<args>] [options]
|
||||
box <command> [<args>] [options]
|
||||
|
||||
COMMANDS
|
||||
EOF
|
||||
|
|
@ -123,6 +124,7 @@ EOF
|
|||
|
||||
OPTIONS
|
||||
--name <box> Name for the new box (new)
|
||||
--template <t> Template to mint from (default: claude) (new)
|
||||
--from <src>[/<snap>] Clone from box <src>, or from its snapshot (new)
|
||||
--vm Force VM mode: the trust-less target (new)
|
||||
--container Force container mode: weaker isolation, (new)
|
||||
|
|
@ -131,28 +133,32 @@ OPTIONS
|
|||
--force, -f Delete without the confirmation prompt (rm)
|
||||
--remote <r> Act on Incus remote <r> (any)
|
||||
--help, -h Help; after a command, help for that command
|
||||
--version, -V Print the claudebox version
|
||||
--version, -V Print the box version
|
||||
|
||||
Options come after the command: 'claudebox list --json', not 'claudebox --json list'.
|
||||
Options come after the command: 'box list --json', not 'box --json list'.
|
||||
|
||||
EXAMPLES
|
||||
# mint a box and log in inside it — the tool never handles your token
|
||||
claudebox new --name work
|
||||
claudebox shell work # then: run 'claude', then /login
|
||||
box new --name work
|
||||
box shell work # then: run 'claude', then /login
|
||||
|
||||
# log in once, reuse forever: checkpoint the authed box, clone from it
|
||||
claudebox snapshot work authed
|
||||
claudebox new --name feature --from work/authed
|
||||
box snapshot work authed
|
||||
box new --name feature --from work/authed
|
||||
|
||||
# a box with nobody home — same isolation, no Claude
|
||||
box new --name scratch --template blank
|
||||
box templates
|
||||
|
||||
# what have I got, and what can I clone?
|
||||
claudebox list
|
||||
claudebox info work
|
||||
box list
|
||||
box info work
|
||||
|
||||
# run something without opening a shell
|
||||
claudebox exec work -- git -C project pull
|
||||
box exec work -- git -C project pull
|
||||
|
||||
# anything claudebox doesn't wrap: boxes are plain Incus instances
|
||||
claudebox incus work -- config show
|
||||
# anything box doesn't wrap: boxes are plain Incus instances
|
||||
box incus work -- config show
|
||||
|
||||
EXIT STATUS
|
||||
0 ok
|
||||
|
|
@ -161,15 +167,15 @@ EXIT STATUS
|
|||
|
||||
THE MODEL
|
||||
A box carries NO credentials. You authenticate interactively inside it
|
||||
('claude' then /login; 'gh auth login'); claudebox never stores or injects a
|
||||
('claude' then /login; 'gh auth login'); box never stores or injects a
|
||||
secret. A box reaches the public internet and nothing else — there is no
|
||||
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.
|
||||
box owns a command when it must enforce something Incus cannot see: the
|
||||
user.box=1 boundary, the isolation stack, or the creds-free snapshot
|
||||
workflow. Everything else is Incus's job — and 'box incus' is the door.
|
||||
|
||||
Docs: https://github.com/heavy-duty/claudebox
|
||||
Docs: https://github.com/heavy-duty/box
|
||||
EOF
|
||||
}
|
||||
|
||||
|
|
@ -180,29 +186,50 @@ help_cmd() {
|
|||
echo
|
||||
case "$1" in
|
||||
new) cat <<'EOF'
|
||||
Mint a box. Without --from, launches a fresh Debian 13 box from cloud-init
|
||||
(~10 min cold) with Claude Code installed and NO credentials. With --from,
|
||||
clones an existing box or one of its snapshots — Claude login, git creds and
|
||||
clones carry over, isolation is preserved.
|
||||
Mint a box. Without --from, launches a fresh box from a template (default:
|
||||
claude — Debian 13 with Claude Code installed and NO credentials, ~10 min
|
||||
cold). With --from, clones an existing box or one of its snapshots — login
|
||||
state, git creds and clones carry over, isolation is preserved, and the
|
||||
clone knows its template's user without being told.
|
||||
|
||||
--name <box> Required. The box's name.
|
||||
--template <t> Template to mint from; 'box templates' lists them.
|
||||
A template sets image, user and resources — never
|
||||
the network: every template gets the same isolation.
|
||||
--from <src>[/<snap>] Clone src's live state, or its snapshot <snap>.
|
||||
--vm | --container Force the mode. VM is the trust boundary and the
|
||||
default wherever /dev/kvm exists; container mode
|
||||
(security.nesting=true) is the fallback for hosts
|
||||
without nested virt — weaker isolation, dev/test only.
|
||||
|
||||
claudebox new --name work
|
||||
claudebox new --name feature --from work/authed
|
||||
Resources come from the template's box.env; BOX_CPU / BOX_MEMORY / BOX_DISK
|
||||
environment variables override them at mint time (a small host shrinks a box
|
||||
without editing a template it doesn't own).
|
||||
|
||||
box new --name work
|
||||
box new --name scratch --template blank
|
||||
box new --name feature --from work/authed
|
||||
EOF
|
||||
;;
|
||||
templates) cat <<'EOF'
|
||||
List the templates this install can mint, with their descriptions. A template
|
||||
is a directory under templates/: a box.env (image, user, resources — parsed
|
||||
against an allowlist, never sourced) and a user-data.yaml (cloud-init, passed
|
||||
to Incus verbatim). Templates cannot touch the network or security flags —
|
||||
the shared box-net profile is the placement contract, so every template gets
|
||||
the same isolation.
|
||||
|
||||
box templates
|
||||
box new --name scratch --template blank
|
||||
EOF
|
||||
;;
|
||||
list) cat <<'EOF'
|
||||
List the boxes claudebox minted on this host: name, state, type, snapshot count.
|
||||
Takes no box — for one box, that's 'claudebox info <box>'.
|
||||
List the boxes box minted on this host: name, state, type, snapshot count.
|
||||
Takes no box — for one box, that's 'box info <box>'.
|
||||
|
||||
--json Incus's JSON, straight through, for scripting.
|
||||
|
||||
claudebox list
|
||||
box list
|
||||
EOF
|
||||
;;
|
||||
info) cat <<'EOF'
|
||||
|
|
@ -211,23 +238,23 @@ labels of its snapshots, with the --from line to clone one.
|
|||
|
||||
--json Incus's JSON, straight through, for scripting.
|
||||
|
||||
claudebox info work
|
||||
box info work
|
||||
EOF
|
||||
;;
|
||||
shell) cat <<'EOF'
|
||||
Open an interactive shell in a running box as the 'claude' user. This is the
|
||||
only entry path — there is no SSH and no inbound route to a box.
|
||||
|
||||
claudebox shell work
|
||||
box shell work
|
||||
EOF
|
||||
;;
|
||||
exec) cat <<'EOF'
|
||||
Run a command inside a box as the 'claude' user. Everything after -- is passed
|
||||
through untouched; the -- is required, or claudebox will read your command's
|
||||
through untouched; the -- is required, or box will read your command's
|
||||
flags as its own.
|
||||
|
||||
claudebox exec work -- git -C project pull
|
||||
claudebox exec work -- claude --version
|
||||
box exec work -- git -C project pull
|
||||
box exec work -- claude --version
|
||||
EOF
|
||||
;;
|
||||
tmux) cat <<'EOF'
|
||||
|
|
@ -240,35 +267,35 @@ remember.
|
|||
|
||||
The session name (default: main) buys parallel streams in one box:
|
||||
|
||||
claudebox tmux work # attach or create 'main'
|
||||
claudebox tmux work run-1 # a second, independent stream, same box
|
||||
box tmux work # attach or create 'main'
|
||||
box tmux work run-1 # a second, independent stream, same box
|
||||
|
||||
Detach with Ctrl-b d; 'exit' ends the session. For a plain shell with none of
|
||||
tmux's semantics, 'claudebox shell' is unchanged.
|
||||
tmux's semantics, 'box shell' is unchanged.
|
||||
EOF
|
||||
;;
|
||||
snapshot) cat <<'EOF'
|
||||
Checkpoint a box. Snapshots are how an authenticated box is reused: log in
|
||||
once, snapshot, then 'new --from <box>/<label>' as often as you like. The
|
||||
label defaults to manual-<epoch>; 'claudebox info <box>' shows the labels you
|
||||
label defaults to manual-<epoch>; 'box info <box>' shows the labels you
|
||||
have.
|
||||
|
||||
claudebox snapshot work authed
|
||||
box snapshot work authed
|
||||
EOF
|
||||
;;
|
||||
restore) cat <<'EOF'
|
||||
Roll a box back to one of its snapshots, in place. Anything in the box since
|
||||
that snapshot is lost. 'claudebox info <box>' lists the labels.
|
||||
that snapshot is lost. 'box info <box>' lists the labels.
|
||||
|
||||
claudebox restore work authed
|
||||
box restore work authed
|
||||
EOF
|
||||
;;
|
||||
rename) cat <<'EOF'
|
||||
Rename a box. Incus cannot rename a running instance, so stop it first:
|
||||
|
||||
claudebox down work
|
||||
claudebox rename work archive
|
||||
claudebox start archive
|
||||
box down work
|
||||
box rename work archive
|
||||
box start archive
|
||||
|
||||
Snapshots and Claude auth follow the box; anything referring to the old name by
|
||||
hand (a --from line, a script) does not.
|
||||
|
|
@ -279,26 +306,26 @@ Delete a box and every snapshot it has. This cannot be undone, so it asks for
|
|||
confirmation first; --force (-f) skips the prompt. With no TTY to confirm on
|
||||
(a script, a pipe), it refuses unless --force is given.
|
||||
|
||||
claudebox rm work
|
||||
claudebox rm work --force
|
||||
box rm work
|
||||
box rm work --force
|
||||
EOF
|
||||
;;
|
||||
incus) cat <<'EOF'
|
||||
The door out. claudebox wraps the box lifecycle and the isolation model, not
|
||||
The door out. box 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.
|
||||
checked against the user.box=1 tag (or its legacy spelling), so you cannot aim it at an instance
|
||||
box 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
|
||||
box incus work -- config show
|
||||
box 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
|
||||
outside the isolation stack. box warns and then does as you asked — from
|
||||
there, the trust boundary is yours to keep.
|
||||
EOF
|
||||
;;
|
||||
|
|
@ -314,15 +341,15 @@ cloud-init error that names none of them.
|
|||
--pin-dns pin claudenet's resolver to public upstreams and re-test
|
||||
(setup-host.sh now pins by default; this is the quick test)
|
||||
|
||||
claudebox doctor
|
||||
claudebox doctor --fix
|
||||
box doctor
|
||||
box doctor --fix
|
||||
|
||||
Exit 0 = clean; 1 = problems found (each printed with its fix). Read-only
|
||||
unless --fix or --pin-dns is given.
|
||||
EOF
|
||||
;;
|
||||
status) cat <<'EOF'
|
||||
Deprecated alias for 'claudebox list'. It ignored the <box> argument it
|
||||
Deprecated alias for 'box list'. It ignored the <box> argument it
|
||||
advertised, so it was split into 'list' (all boxes) and 'info <box>' (one). It
|
||||
still works, and forwards to 'list'.
|
||||
EOF
|
||||
|
|
@ -330,8 +357,8 @@ EOF
|
|||
help) cat <<'EOF'
|
||||
Print the general help, or the help for one command.
|
||||
|
||||
claudebox help
|
||||
claudebox help rename
|
||||
box help
|
||||
box help rename
|
||||
EOF
|
||||
;;
|
||||
*) field "$1" sum ;; # no prose: the table's summary is the help
|
||||
|
|
@ -349,7 +376,7 @@ cmd="${1:-help}"; shift || true
|
|||
case "$cmd" in
|
||||
-h|--help) usage; exit 0 ;;
|
||||
-V|--version) version; exit 0 ;;
|
||||
-*) usage_error "options come after the command — try 'claudebox <command> $cmd ...'" ;;
|
||||
-*) usage_error "options come after the command — try 'box <command> $cmd ...'" ;;
|
||||
esac
|
||||
|
||||
args=()
|
||||
|
|
@ -357,6 +384,7 @@ while [ $# -gt 0 ]; do
|
|||
case "$1" in
|
||||
--name) [ $# -ge 2 ] || usage_error "--name needs a value"; name="$2"; shift 2 ;;
|
||||
--from) [ $# -ge 2 ] || usage_error "--from needs a value"; from="$2"; shift 2 ;;
|
||||
--template) [ $# -ge 2 ] || usage_error "--template needs a value"; template="$2"; shift 2 ;;
|
||||
--remote) [ $# -ge 2 ] || usage_error "--remote needs a value"; remote="$2:"; shift 2 ;;
|
||||
--vm) mode=vm; shift ;;
|
||||
--container) mode=container; shift ;;
|
||||
|
|
@ -368,12 +396,12 @@ while [ $# -gt 0 ]; do
|
|||
# An unrecognized flag used to be swallowed as a positional — so a typo'd
|
||||
# --labl silently became a snapshot's label. Say so instead.
|
||||
-*)
|
||||
# doctor's flags belong to the doctor script, not to claudebox
|
||||
# doctor's flags belong to the doctor script, not to box
|
||||
if [ "$cmd" = doctor ]; then args+=("$1"); shift; continue; fi
|
||||
if [ "$cmd" = exec ] || [ "$cmd" = incus ]; then
|
||||
usage_error "unknown option: $1 — a command's own flags go after --, as in '$(synopsis_of "$cmd")'"
|
||||
fi
|
||||
usage_error "unknown option: $1 (see 'claudebox help $cmd')" ;;
|
||||
usage_error "unknown option: $1 (see 'box help $cmd')" ;;
|
||||
*) args+=("$1"); shift ;;
|
||||
esac
|
||||
done
|
||||
|
|
@ -385,12 +413,16 @@ if [ "$want_help" -eq 1 ]; then show_help "$cmd"; exit 0; fi
|
|||
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.
|
||||
# somebody's VM, and box 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')"
|
||||
tag="$(incus config get "$i" user.box 2>/dev/null || true)"
|
||||
# A pre-rename box carries user.claudebox=1 and nothing else. Snapshots of
|
||||
# old boxes outlive the release that minted them — the legacy tag is honored
|
||||
# forever, or an old box stops being a box at all.
|
||||
[ "$tag" = "1" ] || tag="$(incus config get "$i" user.claudebox 2>/dev/null || true)"
|
||||
[ "$tag" = "1" ] || die "no such box: $box (see 'box list')"
|
||||
echo "$i"
|
||||
}
|
||||
|
||||
|
|
@ -400,7 +432,7 @@ 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" ;;
|
||||
*) die "box '$box' is ${st:-not stopped} — Incus needs it stopped for this. Stop it: box down $box" ;;
|
||||
esac
|
||||
}
|
||||
|
||||
|
|
@ -420,7 +452,7 @@ confirm() { # $1 = prompt. --force, or a TTY to ask on, or we refuse.
|
|||
if [ "$force" -eq 1 ]; then return 0; fi
|
||||
[ -t 0 ] || usage_error "refusing to $1 without --force (no terminal to confirm on)"
|
||||
local reply
|
||||
printf 'claudebox: %s? this cannot be undone. [y/N] ' "$1"
|
||||
printf 'box: %s? this cannot be undone. [y/N] ' "$1"
|
||||
read -r reply
|
||||
case "$reply" in y|Y|yes|YES|Yes) return 0 ;; *) die "aborted." ;; esac
|
||||
}
|
||||
|
|
@ -430,14 +462,14 @@ confirm() { # $1 = prompt. --force, or a TTY to ask on, or we refuse.
|
|||
pick_mode() {
|
||||
if [ "$mode" != auto ]; then echo "$mode"; return; fi
|
||||
if [ -n "$remote" ] || [ -e /dev/kvm ]; then echo vm; else
|
||||
echo "claudebox: no /dev/kvm — using container mode (weaker isolation, dev/test only)" >&2
|
||||
echo "box: no /dev/kvm — using container mode (weaker isolation, dev/test only)" >&2
|
||||
echo container
|
||||
fi
|
||||
}
|
||||
|
||||
wait_agent() {
|
||||
local n="$1" i
|
||||
echo "claudebox: waiting for instance agent..."
|
||||
echo "box: waiting for instance agent..."
|
||||
for i in $(seq 1 90); do
|
||||
if incus exec "$n" -- true >/dev/null 2>&1; then return; fi
|
||||
[ "$i" -eq 90 ] && die "instance agent never came up (incus console $n to inspect)"
|
||||
|
|
@ -449,14 +481,14 @@ wait_agent() {
|
|||
# rides along inside the disk — and systemd derives its DHCP client identifier
|
||||
# (DUID) from it. Same client-id, same dnsmasq lease: two boxes, one IP address,
|
||||
# to the second on the lease timer. Every box cloned from one snapshot collided
|
||||
# on the network, which is exactly the workflow claudebox exists for (log in
|
||||
# on the network, which is exactly the workflow box exists for (log in
|
||||
# once, snapshot, clone forever).
|
||||
#
|
||||
# Truncating /etc/machine-id makes systemd mint a fresh one on the next boot, so
|
||||
# the reset costs one reboot. Do it before handing the box over, never after.
|
||||
reset_identity() {
|
||||
local i="$1"
|
||||
echo "claudebox: giving the clone its own identity (machine-id, DHCP lease)..."
|
||||
echo "box: giving the clone its own identity (machine-id, DHCP lease)..."
|
||||
# Do NOT truncate machine-id and reboot: systemd needs a valid one to shut
|
||||
# down cleanly, so the graceful stop hangs and the reboot never happens —
|
||||
# leaving the clone on its source's identity, which is the bug we are here to
|
||||
|
|
@ -475,10 +507,60 @@ reset_identity() {
|
|||
wait_agent "$i"
|
||||
}
|
||||
|
||||
# Templates set image, user, resources and cloud-init — NOTHING else. The
|
||||
# box.env file is parsed against this allowlist, never sourced: sourcing would
|
||||
# hand every template arbitrary bash execution on the HOST at mint time. And
|
||||
# there is deliberately no key for a network or a security flag — the shared
|
||||
# box-net profile is the placement contract, so no template can weaken
|
||||
# isolation. 'blank' is a box with nobody home, not a box with the safety off.
|
||||
load_template() {
|
||||
local t="$1" dir line key val
|
||||
dir="$root/templates/$t"
|
||||
[ -d "$dir" ] || die "no such template: $t (see 'box templates')"
|
||||
[ -f "$dir/box.env" ] || die "template '$t' has no box.env"
|
||||
T_DESC=""; T_IMAGE=""; T_USER=""; T_CPU=""; T_MEMORY=""; T_DISK=""
|
||||
while IFS= read -r line || [ -n "$line" ]; do
|
||||
case "$line" in ''|\#*) continue ;; esac
|
||||
case "$line" in
|
||||
*=*) key="${line%%=*}"; val="${line#*=}" ;;
|
||||
*) die "template '$t': not a KEY=\"value\" line: $line" ;;
|
||||
esac
|
||||
val="${val#\"}"; val="${val%\"}"
|
||||
case "$key" in
|
||||
BOX_DESCRIPTION) T_DESC="$val" ;;
|
||||
BOX_IMAGE) T_IMAGE="$val" ;;
|
||||
BOX_USER) T_USER="$val" ;;
|
||||
BOX_CPU) T_CPU="$val" ;;
|
||||
BOX_MEMORY) T_MEMORY="$val" ;;
|
||||
BOX_DISK) T_DISK="$val" ;;
|
||||
*) die "template '$t': unknown key '$key' — a template sets image, user and resources, nothing else (there is no key for a network, on purpose)" ;;
|
||||
esac
|
||||
done <"$dir/box.env"
|
||||
[ -n "$T_IMAGE" ] && [ -n "$T_USER" ] || die "template '$t': BOX_IMAGE and BOX_USER are required"
|
||||
# Environment overrides beat the file — this is how a small host (or the
|
||||
# drill) shrinks a box without editing a template it doesn't own.
|
||||
T_CPU="${BOX_CPU:-${T_CPU:-4}}"
|
||||
T_MEMORY="${BOX_MEMORY:-${T_MEMORY:-8GiB}}"
|
||||
T_DISK="${BOX_DISK:-${T_DISK:-60GiB}}"
|
||||
}
|
||||
|
||||
cmd_templates() {
|
||||
local d t desc
|
||||
echo "TEMPLATES"
|
||||
for d in "$root/templates"/*/; do
|
||||
t="$(basename "$d")"
|
||||
desc="$(grep -m1 '^BOX_DESCRIPTION=' "$d/box.env" 2>/dev/null | cut -d= -f2- | tr -d '"')"
|
||||
printf ' %-10s %s\n' "$t" "$desc"
|
||||
done
|
||||
echo
|
||||
echo "mint one: box new --name <box> --template <template>"
|
||||
}
|
||||
|
||||
cmd_new() {
|
||||
[ -n "$name" ] || usage_error "usage: $(synopsis_of new)"
|
||||
local instance; instance="$(iname_of "$name")"
|
||||
if [ -n "$from" ]; then
|
||||
[ -z "$template" ] || usage_error "--from clones an existing box; its template rides along (drop --template)"
|
||||
local src="${from%%/*}" snap="" srcref
|
||||
case "$from" in */*) snap="${from#*/}" ;; esac
|
||||
srcref="$(iname_of "$src")"; [ -n "$snap" ] && srcref="$srcref/$snap"
|
||||
|
|
@ -486,49 +568,67 @@ cmd_new() {
|
|||
incus start "$instance"
|
||||
wait_agent "$instance"
|
||||
reset_identity "$instance"
|
||||
echo "claudebox: cloned $srcref — isolation and Claude auth carry over from the source."
|
||||
echo "box: cloned $srcref — isolation and auth state carry over from the source."
|
||||
else
|
||||
local m extra=(); m="$(pick_mode)"
|
||||
# shellcheck disable=SC2054 # "root,size=60GiB" is a single incus argument
|
||||
if [ "$m" = vm ]; then extra+=(--vm --device root,size=60GiB); else extra+=(--config security.nesting=true); fi
|
||||
incus launch images:debian/13/cloud "$instance" --profile claude-dev \
|
||||
--config user.claudebox=1 \
|
||||
--config cloud-init.user-data="$(cat "$root/cloud-init/user-data.yaml")" \
|
||||
local t="${template:-claude}" m extra=()
|
||||
load_template "$t"
|
||||
m="$(pick_mode)"
|
||||
# shellcheck disable=SC2054 # "root,size=..." is a single incus argument
|
||||
if [ "$m" = vm ]; then extra+=(--vm --device "root,size=$T_DISK"); else extra+=(--config security.nesting=true); fi
|
||||
# The template's identity is stamped ONTO the instance: which template,
|
||||
# which user. 'incus copy' preserves user.* keys (audit B2), so a clone
|
||||
# knows what it is without ever consulting the template again.
|
||||
incus launch "$T_IMAGE" "$instance" --profile box-net \
|
||||
--config user.box=1 \
|
||||
--config user.box.template="$t" \
|
||||
--config user.box.user="$T_USER" \
|
||||
--config limits.cpu="$T_CPU" \
|
||||
--config limits.memory="$T_MEMORY" \
|
||||
--config cloud-init.user-data="$(cat "$root/templates/$t/user-data.yaml")" \
|
||||
"${extra[@]}"
|
||||
wait_agent "$instance"
|
||||
echo "claudebox: waiting for phase-1 (cloud-init)..."
|
||||
echo "box: waiting for phase-1 (cloud-init)..."
|
||||
# A failed cloud-init used to print a screen of dots and the word "error",
|
||||
# with nothing to act on — the box's own log holds the reason, and nobody
|
||||
# was told it existed. Show it, and leave the box up to inspect.
|
||||
if ! incus exec "$instance" -- cloud-init status --wait; then
|
||||
echo >&2
|
||||
echo "claudebox: cloud-init FAILED in $name. What it says:" >&2
|
||||
echo "box: cloud-init FAILED in $name. What it says:" >&2
|
||||
incus exec "$instance" -- cloud-init status --long 2>&1 | sed 's/^/ /' >&2
|
||||
echo >&2
|
||||
echo "claudebox: the errors, from the box's log:" >&2
|
||||
echo "box: the errors, from the box's log:" >&2
|
||||
incus exec "$instance" -- sh -c \
|
||||
"grep -iE '^(E:|Err:)|Temporary failure|Could not resolve|Unable to fetch' /var/log/cloud-init-output.log | tail -8" \
|
||||
2>/dev/null | sed 's/^/ /' >&2
|
||||
echo >&2
|
||||
echo "claudebox: '$name' is still up — inspect it, then delete it:" >&2
|
||||
echo " claudebox incus $name -- exec {} -- tail -50 /var/log/cloud-init-output.log" >&2
|
||||
echo " claudebox rm $name" >&2
|
||||
echo "claudebox: a failed mint is usually the HOST's fault (a wedged daemon, a dnsmasq" >&2
|
||||
echo " not serving, a VPN resolver the box inherits). Diagnose it: claudebox doctor" >&2
|
||||
echo "box: '$name' is still up — inspect it, then delete it:" >&2
|
||||
echo " box incus $name -- exec {} -- tail -50 /var/log/cloud-init-output.log" >&2
|
||||
echo " box rm $name" >&2
|
||||
echo "box: a failed mint is usually the HOST's fault (a wedged daemon, a dnsmasq" >&2
|
||||
echo " not serving, a VPN resolver the box inherits). Diagnose it: box doctor" >&2
|
||||
die "cloud-init failed — the box is incomplete, so refusing to hand it over"
|
||||
fi
|
||||
fi
|
||||
echo "claudebox: ready — 'claudebox shell $name'. Log into Claude inside: run 'claude' then /login."
|
||||
if [ -z "$from" ] && [ "${template:-claude}" != claude ]; then
|
||||
echo "box: ready — 'box shell $name'."
|
||||
else
|
||||
echo "box: ready — 'box shell $name'. Log into Claude inside: run 'claude' then /login."
|
||||
fi
|
||||
}
|
||||
|
||||
# Boxes are ordinary Incus instances tagged user.claudebox=1 — that tag is the
|
||||
# only thing that makes them ours, so every read below is filtered by it and we
|
||||
# never report on (or touch) an instance claudebox didn't mint.
|
||||
# Boxes are ordinary Incus instances tagged user.box=1 — that tag is the only
|
||||
# thing that makes them ours, so every read below is filtered by it and we
|
||||
# never report on (or touch) an instance box didn't mint. Pre-rename boxes
|
||||
# carry user.claudebox=1 instead and are ours forever; a box can't hold both
|
||||
# tags via any path we mint, but the dedupe costs nothing.
|
||||
# Emits: name,state,type,snapshot-count — none of which can contain a comma or a
|
||||
# newline, so a plain -F, split is safe. (IPv4 can: a box running docker has
|
||||
# several addresses and Incus quotes them across lines. It's fetched separately.)
|
||||
boxes_csv() {
|
||||
{
|
||||
incus list ${remote:+"$remote"} "user.box=1" --format csv --columns nstS
|
||||
incus list ${remote:+"$remote"} "user.claudebox=1" --format csv --columns nstS
|
||||
} 2>/dev/null | awk -F, '!seen[$1]++'
|
||||
}
|
||||
|
||||
box_ipv4() { # first address only; strips Incus's " (iface)" suffix. "-" if none.
|
||||
|
|
@ -549,7 +649,7 @@ short_type() {
|
|||
list_all() {
|
||||
local rows; rows="$(boxes_csv)"
|
||||
if [ -z "$rows" ]; then
|
||||
echo "claudebox: no boxes yet — create one with: claudebox new --name work" >&2
|
||||
echo "box: no boxes yet — create one with: box new --name work" >&2
|
||||
return 0
|
||||
fi
|
||||
{
|
||||
|
|
@ -569,10 +669,10 @@ list_all() {
|
|||
# guess we can answer, not a surprise: point at the command that does want one.
|
||||
cmd_list() {
|
||||
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: box info ${args[0]}"
|
||||
fi
|
||||
if [ "$json" -eq 1 ]; then
|
||||
incus list ${remote:+"$remote"} "user.claudebox=1" --format json
|
||||
incus list ${remote:+"$remote"} "user.box=1" --format json
|
||||
else
|
||||
list_all
|
||||
fi
|
||||
|
|
@ -583,7 +683,7 @@ cmd_info() {
|
|||
if [ "$json" -eq 1 ]; then incus list "$inst" --format json; return; fi
|
||||
|
||||
row="$(boxes_csv | awk -F, -v b="$box" '$1 == b { print; exit }')"
|
||||
[ -n "$row" ] || die "no such box: $box (see 'claudebox list')"
|
||||
[ -n "$row" ] || die "no such box: $box (see 'box list')"
|
||||
|
||||
local state type snaps
|
||||
IFS=, read -r _ state type snaps <<<"$row"
|
||||
|
|
@ -595,7 +695,7 @@ cmd_info() {
|
|||
''|0)
|
||||
echo "SNAPSHOTS (none)"
|
||||
echo
|
||||
echo "Take one: claudebox snapshot $box authed"
|
||||
echo "Take one: box snapshot $box authed"
|
||||
return 0 ;;
|
||||
esac
|
||||
echo "SNAPSHOTS"
|
||||
|
|
@ -606,11 +706,28 @@ cmd_info() {
|
|||
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>}"
|
||||
echo "Clone one: box 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}"; }
|
||||
# Which user does a shell land in? The template stamped it on the instance at
|
||||
# mint time (user.box.user), and 'incus copy' carries user.* keys — so a clone
|
||||
# knows without consulting the template. Two subtleties, both from the audit:
|
||||
# 'incus config get' prints EMPTY + exit 0 for an unset key (B4), hence ${u:-},
|
||||
# never '||'; and a pre-rename box has no metadata but is always a Claude box,
|
||||
# so the legacy tag maps to 'claude'. The root fallback is effectively
|
||||
# unreachable (every template sets a user) — anything that truly needs root
|
||||
# goes through the 'box incus' escape hatch.
|
||||
box_user() {
|
||||
local u
|
||||
u="$(incus config get "$1" user.box.user 2>/dev/null || true)"
|
||||
if [ -z "$u" ] && [ "$(incus config get "$1" user.claudebox 2>/dev/null || true)" = 1 ]; then
|
||||
u=claude
|
||||
fi
|
||||
echo "${u:-root}"
|
||||
}
|
||||
|
||||
cmd_shell() { incus exec "$inst" -- sudo -u "$(box_user "$inst")" -i; }
|
||||
cmd_exec() { incus exec "$inst" -- sudo -u "$(box_user "$inst")" -i "${args[@]:1}"; }
|
||||
|
||||
# A shell is a child of the exec connection: drop the terminal and everything
|
||||
# in it is SIGHUP'd — a long Claude run dies with it. tmux 'new-session -A'
|
||||
|
|
@ -622,7 +739,7 @@ cmd_tmux() {
|
|||
case "$session" in
|
||||
*[!A-Za-z0-9_-]*) usage_error "session names are letters, digits, '-' and '_' — got '$session'" ;;
|
||||
esac
|
||||
incus exec "$inst" -- sudo -u claude -i tmux new-session -A -s "$session"
|
||||
incus exec "$inst" -- sudo -u "$(box_user "$inst")" -i tmux new-session -A -s "$session"
|
||||
}
|
||||
|
||||
cmd_snapshot() {
|
||||
|
|
@ -632,7 +749,7 @@ cmd_snapshot() {
|
|||
}
|
||||
|
||||
cmd_status() {
|
||||
echo "claudebox: 'status' is deprecated — use 'claudebox list'." >&2
|
||||
echo "box: 'status' is deprecated — use 'box list'." >&2
|
||||
list_all
|
||||
}
|
||||
|
||||
|
|
@ -655,8 +772,8 @@ cmd_help() { show_help "${args[0]:-}"; }
|
|||
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 ;;
|
||||
echo "box: warning: this can move the box off the isolation stack" >&2
|
||||
echo "box: (profile / network / device / security.*). The trust boundary is yours from here." >&2 ;;
|
||||
esac
|
||||
}
|
||||
|
||||
|
|
@ -671,7 +788,7 @@ cmd_incus() {
|
|||
done
|
||||
[ "$replaced" -eq 1 ] || out+=("$inst")
|
||||
warn_isolation "${out[@]}"
|
||||
echo "claudebox: incus ${out[*]}" >&2 # no magic: show what runs
|
||||
echo "box: incus ${out[*]}" >&2 # no magic: show what runs
|
||||
incus "${out[@]}"
|
||||
}
|
||||
|
||||
|
|
@ -696,7 +813,7 @@ case "$action" in
|
|||
incus $sub "$inst" "${args[@]:1}"
|
||||
if [ -n "$ok" ]; then
|
||||
msg="${ok//\{\}/${args[0]}}"; msg="${msg//\{1\}/${args[1]:-}}"
|
||||
echo "claudebox: $msg"
|
||||
echo "box: $msg"
|
||||
fi
|
||||
;;
|
||||
esac
|
||||
|
|
@ -1,6 +1,6 @@
|
|||
#!/usr/bin/env bash
|
||||
# doctor.sh — is this host fit to mint boxes (and to drill), and if not, what
|
||||
# is wrong? Users reach it as 'claudebox doctor'; the drill runs it directly.
|
||||
# is wrong? Users reach it as 'box doctor'; the drill runs it directly.
|
||||
#
|
||||
# bash drill/doctor.sh # report
|
||||
# bash drill/doctor.sh --fix # report, then revert what the drill left behind
|
||||
|
|
@ -93,29 +93,38 @@ else
|
|||
inf " (or: sudo systemctl restart claudebox-firewall.service)"
|
||||
fi
|
||||
|
||||
head_ "Profile — claude-dev (the NIC is the isolation contract)"
|
||||
if incus profile show claude-dev >/dev/null 2>&1; then
|
||||
iso="$(incus profile device get claude-dev eth0 security.port_isolation 2>/dev/null)"
|
||||
# box-net is the placement contract since the 0.4.0 rename; claude-dev is its
|
||||
# pre-rename ancestor and may linger while legacy boxes still reference it.
|
||||
# Check whichever exist — an unisolated NIC is a fault on either.
|
||||
PROFILES=""
|
||||
incus profile show box-net >/dev/null 2>&1 && PROFILES="box-net"
|
||||
incus profile show claude-dev >/dev/null 2>&1 && PROFILES="$PROFILES claude-dev"
|
||||
head_ "Profile — the NIC is the isolation contract"
|
||||
if [ -n "$PROFILES" ]; then
|
||||
for p in $PROFILES; do
|
||||
[ "$p" = claude-dev ] && inf "claude-dev is legacy (pre-rename boxes still reference it)"
|
||||
iso="$(incus profile device get "$p" eth0 security.port_isolation 2>/dev/null)"
|
||||
if [ "$iso" = "true" ]; then
|
||||
ok "security.port_isolation = true — boxes cannot reach each other at L2"
|
||||
ok "$p: security.port_isolation = true — boxes cannot reach each other at L2"
|
||||
else
|
||||
no "security.port_isolation is NOT set — BOXES CAN REACH EACH OTHER"
|
||||
no "$p: security.port_isolation is NOT set — BOXES CAN REACH EACH OTHER"
|
||||
inf "an L3 ACL cannot do this: two boxes on one bridge are on the same L2"
|
||||
inf "segment, so their frames are switched, never routed past the ACL."
|
||||
inf "fix: re-run ~/.local/share/claudebox/host/setup-host.sh"
|
||||
fi
|
||||
for k in security.mac_filtering security.ipv4_filtering; do
|
||||
v="$(incus profile device get claude-dev eth0 "$k" 2>/dev/null)"
|
||||
v="$(incus profile device get "$p" eth0 "$k" 2>/dev/null)"
|
||||
if [ -z "$v" ]; then
|
||||
ok "$k unset (as shipped)"
|
||||
ok "$p: $k unset (as shipped)"
|
||||
else
|
||||
no "$k = $v ← phase D left this behind. A box can fail to get on the network at all."
|
||||
[ "$FIX" = 1 ] && { incus profile device unset claude-dev eth0 "$k" && inf "reverted: $k unset"; }
|
||||
no "$p: $k = $v ← phase D left this behind. A box can fail to get on the network at all."
|
||||
[ "$FIX" = 1 ] && { incus profile device unset "$p" eth0 "$k" && inf "reverted: $k unset"; }
|
||||
fi
|
||||
done
|
||||
inf "cpu/mem: $(incus profile get claude-dev limits.cpu 2>/dev/null)/$(incus profile get claude-dev limits.memory 2>/dev/null) (the drill lowers these on a small host)"
|
||||
done
|
||||
inf "resources are per-box since 0.4.0 (stamped from the template at mint; BOX_CPU/BOX_MEMORY override)"
|
||||
else
|
||||
inf "claude-dev does not exist (a fresh host)"
|
||||
inf "box-net does not exist (a fresh host — setup-host.sh will create it)"
|
||||
fi
|
||||
|
||||
head_ "ACL — claude-isolate"
|
||||
|
|
@ -203,8 +212,10 @@ if [ "$PIN" = 1 ]; then
|
|||
fi
|
||||
|
||||
head_ "Can a box actually resolve DNS?"
|
||||
# Any box will do — the drill's names are not the only boxes on a host.
|
||||
probe="$(incus list "user.claudebox=1" --format csv --columns ns 2>/dev/null \
|
||||
# Any box will do — the drill's names are not the only boxes on a host, and
|
||||
# a pre-rename box (legacy tag) is as good a probe as a new one.
|
||||
probe="$({ incus list "user.box=1" --format csv --columns ns 2>/dev/null
|
||||
incus list "user.claudebox=1" --format csv --columns ns 2>/dev/null; } \
|
||||
| awk -F, '$2 == "RUNNING" { print $1; exit }')"
|
||||
if [ -n "$probe" ] && [ "$FIX" != 1 ]; then
|
||||
# Stdin MUST be pinned to /dev/null: with a TTY on stdin, 'incus exec' goes
|
||||
|
|
|
|||
164
drill/drill.sh
164
drill/drill.sh
|
|
@ -1,5 +1,5 @@
|
|||
#!/usr/bin/env bash
|
||||
# drill.sh — end-to-end drill for claudebox, against a real Incus.
|
||||
# drill.sh — end-to-end drill for box (the claudebox repo), against a real Incus.
|
||||
#
|
||||
# ⚠ DESTRUCTIVE, AND MEANT TO BE. Run it on a THROWAWAY host you can format.
|
||||
# It installs Incus, rewrites the host's firewall rules, installs a systemd
|
||||
|
|
@ -8,13 +8,13 @@
|
|||
#
|
||||
# bash drill/drill.sh # asks first
|
||||
# bash drill/drill.sh --yes # no prompt (CI, or you've read it)
|
||||
# bash drill/drill.sh --ref main # drill a different branch of claudebox
|
||||
# bash drill/drill.sh --ref main # drill a different branch of the repo
|
||||
# bash drill/drill.sh --keep-boxes # leave the boxes up to poke at
|
||||
#
|
||||
# Four phases:
|
||||
# A. Incus semantics — the assumptions claudebox is built on, probed directly.
|
||||
# A. Incus semantics — the assumptions box is built on, probed directly.
|
||||
# These were only ever verified against a stub.
|
||||
# B. The claudebox surface — the whole CLI, end to end, including the boundary.
|
||||
# B. The box surface — the whole CLI, end to end, including the boundary.
|
||||
# C. Isolation baseline — does the trust boundary actually hold? (#15 section A)
|
||||
# D. Hardening rehearsal — #16's proposed changes, applied live and re-probed
|
||||
# (#15 section B). FAILs here are design vetoes, not code bugs.
|
||||
|
|
@ -60,7 +60,7 @@ aud() { audit+=("$*"); } # an answer for the #15 audit
|
|||
wait_box() { # poll until exec answers (the VM agent can take a while), ~2 min
|
||||
local b="$1" _i
|
||||
for _i in $(seq 1 60); do
|
||||
claudebox exec "$b" -- true >/dev/null 2>&1 && return 0
|
||||
box exec "$b" -- true >/dev/null 2>&1 && return 0
|
||||
sleep 2
|
||||
done
|
||||
return 1
|
||||
|
|
@ -69,7 +69,7 @@ wait_box() { # poll until exec answers (the VM agent can take a while), ~2 min
|
|||
# Read from inside a box WITHOUT ever hanging the drill.
|
||||
#
|
||||
# Two traps, both hit for real:
|
||||
# · 'claudebox exec' becomes 'sudo -u claude -i' — a LOGIN zsh (oh-my-zsh and
|
||||
# · 'box exec' becomes 'sudo -u <template user> -i' — a LOGIN zsh (oh-my-zsh and
|
||||
# all). Fine for a person, needless machinery for a probe.
|
||||
# · $( ) waits for stdout to CLOSE, not for the command to exit. A grandchild
|
||||
# inheriting the exec session's stdout keeps the substitution open forever,
|
||||
|
|
@ -155,7 +155,7 @@ EOF
|
|||
case "$reply" in y|Y|yes) ;; *) echo "stopped."; exit 1 ;; esac
|
||||
fi
|
||||
|
||||
phase "Installing claudebox ($REPO@$REF)"
|
||||
phase "Installing box ($REPO@$REF)"
|
||||
CLAUDEBOX_REPO="$REPO" CLAUDEBOX_REF="$REF" \
|
||||
bash -c "$(curl -fsSL "https://raw.githubusercontent.com/$REPO/$REF/install.sh")" \
|
||||
|| { echo "install failed"; exit 1; }
|
||||
|
|
@ -214,12 +214,16 @@ KEEP="${KEEP:-0}"
|
|||
# DNS-enumeration leak), so it is no longer "dirt" from a rehearsal — do not
|
||||
# revert it. Only the vetoed NIC filtering counts as leftover.
|
||||
dirty=""
|
||||
[ -n "$(incus profile device get claude-dev eth0 security.ipv4_filtering 2>/dev/null)" ] && dirty="$dirty ipv4_filtering"
|
||||
[ -n "$(incus profile device get claude-dev eth0 security.mac_filtering 2>/dev/null)" ] && dirty="$dirty mac_filtering"
|
||||
for p in box-net claude-dev; do
|
||||
[ -n "$(incus profile device get "$p" eth0 security.ipv4_filtering 2>/dev/null)" ] && dirty="$dirty $p:ipv4_filtering"
|
||||
[ -n "$(incus profile device get "$p" eth0 security.mac_filtering 2>/dev/null)" ] && dirty="$dirty $p:mac_filtering"
|
||||
done
|
||||
if [ -n "$dirty" ]; then
|
||||
note "this host carries the VETOED NIC filtering from an old rehearsal:$dirty — reverting"
|
||||
incus profile device unset claude-dev eth0 security.mac_filtering >/dev/null 2>&1
|
||||
incus profile device unset claude-dev eth0 security.ipv4_filtering >/dev/null 2>&1
|
||||
for p in box-net claude-dev; do
|
||||
incus profile device unset "$p" eth0 security.mac_filtering >/dev/null 2>&1
|
||||
incus profile device unset "$p" eth0 security.ipv4_filtering >/dev/null 2>&1
|
||||
done
|
||||
fi
|
||||
|
||||
inf "clearing anything a previous run left behind…"
|
||||
|
|
@ -231,10 +235,12 @@ done
|
|||
if incus network show claudenet >/dev/null 2>&1; then
|
||||
timeout -k 5 30 incus network unset claudenet dns.mode >/dev/null 2>&1
|
||||
fi
|
||||
if incus profile show claude-dev >/dev/null 2>&1; then
|
||||
timeout -k 5 30 incus profile device unset claude-dev eth0 security.mac_filtering >/dev/null 2>&1
|
||||
timeout -k 5 30 incus profile device unset claude-dev eth0 security.ipv4_filtering >/dev/null 2>&1
|
||||
fi
|
||||
for p in box-net claude-dev; do
|
||||
if incus profile show "$p" >/dev/null 2>&1; then
|
||||
timeout -k 5 30 incus profile device unset "$p" eth0 security.mac_filtering >/dev/null 2>&1
|
||||
timeout -k 5 30 incus profile device unset "$p" eth0 security.ipv4_filtering >/dev/null 2>&1
|
||||
fi
|
||||
done
|
||||
left="$(incus list --format csv --columns n 2>/dev/null | tr '\n' ' ')"
|
||||
[ -n "$left" ] && inf "instances still on this host (not ours, left alone): $left"
|
||||
|
||||
|
|
@ -252,38 +258,41 @@ if ! timeout -k 10 300 ~/.local/share/claudebox/host/setup-host.sh; then
|
|||
fi
|
||||
inf "host setup complete"
|
||||
|
||||
# A real server has room for the production profile (8GiB/4cpu), and drilling the
|
||||
# real profile is worth more than drilling a shrunken one. Only shrink if we must.
|
||||
# A real server has room for the claude template's resources (8GiB/4cpu), and
|
||||
# drilling the real numbers is worth more than drilling shrunken ones. Only
|
||||
# shrink if we must. Since 0.4.0 resources are per-box, stamped from the
|
||||
# template at mint — a profile edit no longer reaches them; the supported
|
||||
# override is the BOX_* environment, which every 'box new' below inherits.
|
||||
ram="$(awk '/MemTotal/{print int($2/1024/1024)}' /proc/meminfo)"
|
||||
if [ "$ram" -lt 20 ]; then
|
||||
incus profile set claude-dev limits.memory=3GiB limits.cpu=2
|
||||
note "host has ${ram}GiB RAM — lowered claude-dev to 3GiB/2cpu for the drill (production profile is 8GiB/4cpu, and that is what was NOT drilled)"
|
||||
export BOX_MEMORY=3GiB BOX_CPU=2
|
||||
note "host has ${ram}GiB RAM — minting at 3GiB/2cpu via BOX_MEMORY/BOX_CPU (the claude template's 8GiB/4cpu is what was NOT drilled)"
|
||||
else
|
||||
inf "host has ${ram}GiB RAM — drilling the production profile (8GiB/4cpu) unchanged"
|
||||
inf "host has ${ram}GiB RAM — drilling the claude template's resources (8GiB/4cpu) unchanged"
|
||||
fi
|
||||
|
||||
KVM=0; [ -e /dev/kvm ] && KVM=1
|
||||
[ "$KVM" = 1 ] && inf "/dev/kvm present — boxes will be VMs (the real trust boundary)" \
|
||||
|| note "NO /dev/kvm on this host — claudebox will fall back to CONTAINER mode, so this run does NOT validate the VM trust boundary"
|
||||
|| note "NO /dev/kvm on this host — box will fall back to CONTAINER mode, so this run does NOT validate the VM trust boundary"
|
||||
|
||||
# ===========================================================================
|
||||
phase "A. Incus semantics — the assumptions claudebox is built on"
|
||||
phase "A. Incus semantics — the assumptions box is built on"
|
||||
# ===========================================================================
|
||||
incus launch images:debian/13 cbprobe --config user.claudebox=1 >/dev/null 2>&1
|
||||
incus launch images:debian/13 cbprobe --config user.box=1 >/dev/null 2>&1
|
||||
incus launch images:debian/13 cbnotours >/dev/null 2>&1 # untagged: not ours
|
||||
sleep 3
|
||||
|
||||
# A1 — the tag read. #13 puts this on the path of EVERY box command.
|
||||
t="$(incus config get cbprobe user.claudebox 2>&1)"
|
||||
[ "$t" = "1" ] && ok "config get user.claudebox → '1'" \
|
||||
|| no "config get user.claudebox → '$t' (expected '1'; every box command would fail closed)"
|
||||
t="$(incus config get cbprobe user.box 2>&1)"
|
||||
[ "$t" = "1" ] && ok "config get user.box → '1'" \
|
||||
|| no "config get user.box → '$t' (expected '1'; every box command would fail closed)"
|
||||
|
||||
# A2 — the list filter, and that it EXCLUDES an instance we didn't mint
|
||||
f="$(incus list user.claudebox=1 --format csv --columns nstS 2>&1)"
|
||||
f="$(incus list user.box=1 --format csv --columns nstS 2>&1)"
|
||||
if echo "$f" | grep -q '^cbprobe,' && ! echo "$f" | grep -q '^cbnotours,'; then
|
||||
ok "list filter user.claudebox=1 selects ours, excludes theirs"
|
||||
ok "list filter user.box=1 selects ours, excludes theirs"
|
||||
else
|
||||
no "list filter user.claudebox=1 is wrong — got: $(echo "$f" | tr '\n' ' ')"
|
||||
no "list filter user.box=1 is wrong — got: $(echo "$f" | tr '\n' ' ')"
|
||||
fi
|
||||
|
||||
# A3 — four fields, no commas/newlines to mangle the awk table
|
||||
|
|
@ -340,36 +349,37 @@ incus delete -f cbcopy >/dev/null 2>&1
|
|||
incus delete -f cbprobe cbnotours >/dev/null 2>&1
|
||||
|
||||
# ===========================================================================
|
||||
phase "B. The claudebox surface"
|
||||
phase "B. The box surface"
|
||||
# ===========================================================================
|
||||
# Compare against the installed tree's VERSION file, not a hardcoded number —
|
||||
# a pinned literal here would fail the drill on every release.
|
||||
expected="$(cat "$HOME/.local/share/claudebox/VERSION" 2>/dev/null || echo '?')"
|
||||
v="$(claudebox --version 2>&1)"
|
||||
case "$v" in *"$expected"*) ok "claudebox --version → $v" ;; *) no "version mismatch: CLI says '$v', VERSION file says '$expected'" ;; esac
|
||||
v="$(box --version 2>&1)"
|
||||
case "$v" in *"$expected"*) ok "box --version → $v" ;; *) no "version mismatch: CLI says '$v', VERSION file says '$expected'" ;; esac
|
||||
|
||||
# The drill must not require an empty host: operator boxes tagged
|
||||
# user.claudebox=1 are legitimate tenants, and the teardown below deliberately
|
||||
# user.box=1 (or the legacy tag) are legitimate tenants, and the teardown below deliberately
|
||||
# refuses to touch them. The empty-host message is only TESTABLE when the host
|
||||
# is actually empty — on a shared host, skip it instead of failing it.
|
||||
tenants="$(incus list user.claudebox=1 --format csv --columns n 2>/dev/null | tr '\n' ' ')"
|
||||
tenants="$({ incus list user.box=1 --format csv --columns n 2>/dev/null
|
||||
incus list user.claudebox=1 --format csv --columns n 2>/dev/null; } | sort -u | tr '\n' ' ')"
|
||||
if [ -n "${tenants% }" ]; then
|
||||
inf "host already has claudebox boxes (${tenants% }) — the empty-host message cannot be tested this run"
|
||||
inf "host already has boxes (${tenants% }) — the empty-host message cannot be tested this run"
|
||||
else
|
||||
claudebox list >/dev/null 2>&1 && claudebox list 2>&1 | grep -q 'no boxes yet' \
|
||||
box list >/dev/null 2>&1 && box list 2>&1 | grep -q 'no boxes yet' \
|
||||
&& ok "empty host: 'no boxes yet', exit 0" || no "empty-host message wrong"
|
||||
fi
|
||||
|
||||
printf '\n minting a box (cold, ~10 min)…\n'
|
||||
t0=$SECONDS
|
||||
if claudebox new --name drill >/tmp/new.log 2>&1; then
|
||||
ok "claudebox new --name drill ($((SECONDS - t0))s)"
|
||||
if box new --name drill >/tmp/new.log 2>&1; then
|
||||
ok "box new --name drill ($((SECONDS - t0))s)"
|
||||
else
|
||||
no "claudebox new FAILED — tail: $(tail -3 /tmp/new.log | tr '\n' ' ')"
|
||||
no "box new FAILED — tail: $(tail -3 /tmp/new.log | tr '\n' ' ')"
|
||||
echo; echo "── cannot continue without a box"; printf ' %s\n' "${findings[@]}"; exit 1
|
||||
fi
|
||||
|
||||
typ="$(claudebox list | awk '$1 == "drill" { print $3 }')"
|
||||
typ="$(box list | awk '$1 == "drill" { print $3 }')"
|
||||
if [ "$KVM" = 1 ]; then
|
||||
[ "$typ" = VM ] && ok "the box is a VM — the trust boundary is real" \
|
||||
|| no "the box is '$typ' but /dev/kvm exists — it should have been a VM"
|
||||
|
|
@ -377,80 +387,80 @@ else
|
|||
note "the box is '$typ' (no /dev/kvm on this host)"
|
||||
fi
|
||||
|
||||
claudebox info drill | grep -q '^IPV4' && ok "info shows an IPv4" || no "info has no IPV4 row"
|
||||
claudebox info drill | grep -q 'SNAPSHOTS (none)' && ok "info: no snapshots yet, offers to take one" || no "info snapshot-empty state wrong"
|
||||
box info drill | grep -q '^IPV4' && ok "info shows an IPv4" || no "info has no IPV4 row"
|
||||
box info drill | grep -q 'SNAPSHOTS (none)' && ok "info: no snapshots yet, offers to take one" || no "info snapshot-empty state wrong"
|
||||
|
||||
if claudebox exec drill -- claude --version >/dev/null 2>&1; then
|
||||
if box exec drill -- claude --version >/dev/null 2>&1; then
|
||||
ok "Claude Code is installed in the box"
|
||||
elif timeout 30 claudebox exec drill -- bash -lc 'claude --version' >/dev/null 2>&1; then
|
||||
no "'claude' is installed but NOT on exec's PATH — repo bug: the help promises 'claudebox exec work -- claude --version'"
|
||||
inf "PATH as exec sees it: $(timeout 30 claudebox exec drill -- printenv PATH 2>/dev/null)"
|
||||
elif timeout 30 box exec drill -- bash -lc 'claude --version' >/dev/null 2>&1; then
|
||||
no "'claude' is installed but NOT on exec's PATH — repo bug: the help promises 'box exec work -- claude --version'"
|
||||
inf "PATH as exec sees it: $(timeout 30 box exec drill -- printenv PATH 2>/dev/null)"
|
||||
else
|
||||
no "'claude --version' failed inside the box"
|
||||
# diag output must skip the hatch's own 'claudebox: incus exec …' announce lines
|
||||
hatch_out() { timeout 30 claudebox incus drill -- exec {} -- "$@" 2>&1 | grep -v '^claudebox:' | tail -1 | cut -c1-120; }
|
||||
# diag output must skip the hatch's own 'box: incus exec …' announce lines
|
||||
hatch_out() { timeout 30 box incus drill -- exec {} -- "$@" 2>&1 | grep -v '^box:' | tail -1 | cut -c1-120; }
|
||||
inf "cloud-init: $(hatch_out cloud-init status)"
|
||||
inf "binary runs? $(hatch_out sudo -u claude /home/claude/.local/bin/claude --version)"
|
||||
inf "exec PATH: $(timeout 30 claudebox exec drill -- printenv PATH 2>/dev/null | tail -1)"
|
||||
inf "exec PATH: $(timeout 30 box exec drill -- printenv PATH 2>/dev/null | tail -1)"
|
||||
fi
|
||||
claudebox exec drill -- gh --version >/dev/null 2>&1 \
|
||||
box exec drill -- gh --version >/dev/null 2>&1 \
|
||||
&& ok "the GitHub CLI is installed in the box (PR #5)" || no "'gh --version' failed inside the box"
|
||||
|
||||
# --- the snapshot → clone workflow, which is the whole point of the tool ---
|
||||
claudebox snapshot drill authed 2>&1 | grep -q authed && ok "snapshot drill authed" || no "snapshot failed"
|
||||
claudebox info drill | grep -q 'authed' && ok "info lists the snapshot label" || no "info does not show the label"
|
||||
claudebox info drill | grep -q -- '--from drill/authed' && ok "info prints the --from line to clone it" || no "info lacks the --from hint"
|
||||
box snapshot drill authed 2>&1 | grep -q authed && ok "snapshot drill authed" || no "snapshot failed"
|
||||
box info drill | grep -q 'authed' && ok "info lists the snapshot label" || no "info does not show the label"
|
||||
box info drill | grep -q -- '--from drill/authed' && ok "info prints the --from line to clone it" || no "info lacks the --from hint"
|
||||
|
||||
# --- the boundary: an instance claudebox did NOT mint ----------------------
|
||||
# --- the boundary: an instance box did NOT mint ----------------------
|
||||
incus launch images:debian/13 payroll >/dev/null 2>&1 # somebody else's instance
|
||||
sleep 2
|
||||
claudebox down payroll 2>&1 | grep -q 'no such box' && ok "boundary: 'down' refuses an untagged instance" || no "boundary: 'down' touched an instance claudebox didn't mint!"
|
||||
claudebox rm payroll --force 2>&1 | grep -q 'no such box' && ok "boundary: 'rm' refuses an untagged instance" || no "boundary: 'rm' would DELETE a foreign instance!"
|
||||
claudebox incus payroll -- config show 2>&1 | grep -q 'no such box' && ok "boundary: the escape hatch refuses it too" || no "boundary: the hatch reached a foreign instance!"
|
||||
box down payroll 2>&1 | grep -q 'no such box' && ok "boundary: 'down' refuses an untagged instance" || no "boundary: 'down' touched an instance box didn't mint!"
|
||||
box rm payroll --force 2>&1 | grep -q 'no such box' && ok "boundary: 'rm' refuses an untagged instance" || no "boundary: 'rm' would DELETE a foreign instance!"
|
||||
box incus payroll -- config show 2>&1 | grep -q 'no such box' && ok "boundary: the escape hatch refuses it too" || no "boundary: the hatch reached a foreign instance!"
|
||||
incus list payroll --format csv --columns ns | grep -q '^payroll,RUNNING' && ok "…and payroll is still running, untouched" || no "payroll was harmed — the boundary leaked"
|
||||
incus delete -f payroll >/dev/null 2>&1
|
||||
|
||||
# --- rename, and its precondition -----------------------------------------
|
||||
claudebox rename drill archive 2>&1 | grep -qi 'RUNNING' && ok "rename refuses a running box, and says how to fix it" || no "rename did not refuse a running box"
|
||||
claudebox down drill >/dev/null 2>&1 && ok "down drill" || no "down failed"
|
||||
claudebox rename drill archive 2>&1 | grep -q 'renamed drill to archive' && ok "rename drill → archive (stopped)" || no "rename failed on a stopped box"
|
||||
claudebox list | grep -q '^archive' && ok "list shows the new name" || no "list still shows the old name"
|
||||
claudebox info archive | grep -q authed && ok "the snapshot followed the rename" || no "snapshot lost across the rename"
|
||||
box rename drill archive 2>&1 | grep -qi 'RUNNING' && ok "rename refuses a running box, and says how to fix it" || no "rename did not refuse a running box"
|
||||
box down drill >/dev/null 2>&1 && ok "down drill" || no "down failed"
|
||||
box rename drill archive 2>&1 | grep -q 'renamed drill to archive' && ok "rename drill → archive (stopped)" || no "rename failed on a stopped box"
|
||||
box list | grep -q '^archive' && ok "list shows the new name" || no "list still shows the old name"
|
||||
box info archive | grep -q authed && ok "the snapshot followed the rename" || no "snapshot lost across the rename"
|
||||
|
||||
# --- clone from a snapshot of a renamed box --------------------------------
|
||||
printf '\n cloning from the snapshot…\n'
|
||||
if claudebox new --name clone --from archive/authed >/tmp/clone.log 2>&1; then
|
||||
if box new --name clone --from archive/authed >/tmp/clone.log 2>&1; then
|
||||
ok "new --from archive/authed (clone of a snapshot of a renamed box)"
|
||||
claudebox exec clone -- true >/dev/null 2>&1 && ok "the clone is alive and enterable" || no "the clone is not enterable"
|
||||
box exec clone -- true >/dev/null 2>&1 && ok "the clone is alive and enterable" || no "the clone is not enterable"
|
||||
else
|
||||
no "clone FAILED — tail: $(tail -3 /tmp/clone.log | tr '\n' ' ')"
|
||||
fi
|
||||
|
||||
# --- the escape hatch ------------------------------------------------------
|
||||
claudebox incus archive -- config show 2>/dev/null | grep -q 'user.claudebox' && ok "hatch: 'incus archive -- config show', instance appended" || no "hatch passthrough failed"
|
||||
h="$(claudebox incus archive -- config device add {} scratch disk source=/tmp path=/mnt/scratch 2>&1)"
|
||||
box incus archive -- config show 2>/dev/null | grep -q 'user.box' && ok "hatch: 'incus archive -- config show', instance appended" || no "hatch passthrough failed"
|
||||
h="$(box incus archive -- config device add {} scratch disk source=/tmp path=/mnt/scratch 2>&1)"
|
||||
echo "$h" | grep -q 'isolation stack' && ok "hatch warns when a command can break isolation" || no "hatch did not warn on a device add"
|
||||
claudebox incus archive -- config device remove {} scratch >/dev/null 2>&1
|
||||
box incus archive -- config device remove {} scratch >/dev/null 2>&1
|
||||
|
||||
# --- rm, and the guard that did not used to exist --------------------------
|
||||
claudebox rm clone </dev/null 2>&1 | grep -q 'refusing' && ok "rm with no TTY and no --force refuses (exit 2)" || no "rm destroyed a box with no confirmation!"
|
||||
claudebox rm clone --force 2>&1 | grep -q 'removed' && ok "rm --force removes the clone" || no "rm --force failed"
|
||||
box rm clone </dev/null 2>&1 | grep -q 'refusing' && ok "rm with no TTY and no --force refuses (exit 2)" || no "rm destroyed a box with no confirmation!"
|
||||
box rm clone --force 2>&1 | grep -q 'removed' && ok "rm --force removes the clone" || no "rm --force failed"
|
||||
|
||||
# --- the CLI contract ------------------------------------------------------
|
||||
claudebox lst 2>&1 | grep -q "did you mean 'list'" && ok "typo → did-you-mean, exit 2" || no "unknown command not suggested"
|
||||
claudebox list archive 2>&1 | grep -q 'claudebox info archive' && ok "'list <box>' points at info" || no "'list <box>' does not point at info"
|
||||
claudebox snapshot archive --labl x 2>&1 | grep -q 'unknown option' && ok "typo'd flag rejected (not swallowed as a label)" || no "unknown flag was swallowed"
|
||||
box lst 2>&1 | grep -q "did you mean 'list'" && ok "typo → did-you-mean, exit 2" || no "unknown command not suggested"
|
||||
box list archive 2>&1 | grep -q 'box info archive' && ok "'list <box>' points at info" || no "'list <box>' does not point at info"
|
||||
box snapshot archive --labl x 2>&1 | grep -q 'unknown option' && ok "typo'd flag rejected (not swallowed as a label)" || no "unknown flag was swallowed"
|
||||
|
||||
# ===========================================================================
|
||||
phase "C. Isolation baseline — does the boundary actually hold? (#15 section A)"
|
||||
# ===========================================================================
|
||||
claudebox start archive >/dev/null 2>&1
|
||||
box start archive >/dev/null 2>&1
|
||||
wait_box archive && ok "archive is back up (agent answering)" \
|
||||
|| no "archive did not come back within 2 min of start"
|
||||
|
||||
# Sibling isolation needs a sibling. Clone from the snapshot — fast, no cold mint.
|
||||
printf '\n cloning a peer for the sibling probes…\n'
|
||||
if claudebox new --name peer --from archive/authed >/tmp/peer.log 2>&1 && wait_box peer; then
|
||||
if box new --name peer --from archive/authed >/tmp/peer.log 2>&1 && wait_box peer; then
|
||||
ok "peer minted from archive/authed and answering"
|
||||
else
|
||||
no "peer clone failed or never answered — tail: $(tail -3 /tmp/peer.log | tr '\n' ' ')"
|
||||
|
|
@ -607,15 +617,15 @@ fi
|
|||
# ===========================================================================
|
||||
if [ "$KEEP" = 1 ]; then
|
||||
phase "Boxes left up (--keep-boxes)"
|
||||
claudebox list
|
||||
box list
|
||||
inf "note: the D-phase mutations (dns.mode=none, NIC filtering) are still applied"
|
||||
else
|
||||
# every name the drill can have left, whatever branch a partial run took
|
||||
for n in drill clone archive peer; do claudebox rm "$n" --force >/dev/null 2>&1; done
|
||||
for n in drill clone archive peer; do box rm "$n" --force >/dev/null 2>&1; done
|
||||
# Assert OUR boxes are gone — not that the host is empty. The rm loop above
|
||||
# already embodies the discipline (only names the drill minted); demanding
|
||||
# 'no boxes yet' here would flag any pre-existing operator box as a failure.
|
||||
leftover="$(claudebox list 2>/dev/null | grep -E '^(drill|clone|archive|peer)([[:space:]]|$)' || true)"
|
||||
leftover="$(box list 2>/dev/null | grep -E '^(drill|clone|archive|peer)([[:space:]]|$)' || true)"
|
||||
[ -z "$leftover" ] && ok "teardown: every box the drill minted is gone" \
|
||||
|| no "a drill box survived teardown: $(printf '%s' "$leftover" | awk '{print $1}' | tr '\n' ' ')"
|
||||
fi
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
#!/usr/bin/env bash
|
||||
# One-time host setup: install Incus, create the isolated network + ACL and
|
||||
# the claude-dev profile. Idempotent. Ubuntu 24.04 / Debian 13.
|
||||
# the box-net profile. Idempotent. Ubuntu 24.04 / Debian 13.
|
||||
set -euo pipefail
|
||||
|
||||
here="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||
|
|
@ -129,11 +129,15 @@ sudo systemctl enable claudebox-firewall.service
|
|||
# claimed to close it. Restart re-runs the script, which is idempotent by design.
|
||||
sudo systemctl restart claudebox-firewall.service
|
||||
|
||||
# Profile
|
||||
if ! incus profile show claude-dev >/dev/null 2>&1; then
|
||||
incus profile create claude-dev
|
||||
# Profile — box-net, the placement contract: the isolated NIC and the root
|
||||
# disk, nothing a template controls (resources are stamped per-instance from
|
||||
# the template at mint time). A legacy claude-dev profile is left alone:
|
||||
# Incus refuses to delete an in-use profile, and pre-rename boxes reference
|
||||
# it until their last one is gone — teardown-host removes it then.
|
||||
if ! incus profile show box-net >/dev/null 2>&1; then
|
||||
incus profile create box-net
|
||||
fi
|
||||
incus profile edit claude-dev < "$here/profiles/claude-dev.yaml"
|
||||
incus profile edit box-net < "$here/profiles/box-net.yaml"
|
||||
|
||||
# The sibling drop is the one rule whose absence is invisible: everything keeps
|
||||
# working, and boxes can simply reach each other. Assert it landed.
|
||||
|
|
@ -144,4 +148,4 @@ else
|
|||
echo " check: sudo /usr/local/sbin/claudebox-firewall ; sudo nft list table bridge claudebox" >&2
|
||||
fi
|
||||
|
||||
echo "Host ready. Launch with: claudebox new --name <box>"
|
||||
echo "Host ready. Launch with: box new --name <box>"
|
||||
|
|
|
|||
|
|
@ -21,7 +21,8 @@ for i in $(incus list "user.claudebox=1" -f csv -c n || true); do
|
|||
incus delete -f "$i"
|
||||
done
|
||||
|
||||
incus profile delete claude-dev 2>/dev/null || true
|
||||
incus profile delete box-net 2>/dev/null || true
|
||||
incus profile delete claude-dev 2>/dev/null || true # legacy, pre-rename
|
||||
incus network delete claudenet 2>/dev/null || true
|
||||
incus network acl delete claude-isolate 2>/dev/null || true
|
||||
|
||||
|
|
|
|||
25
install.sh
25
install.sh
|
|
@ -4,7 +4,7 @@ set -euo pipefail
|
|||
# claudebox installer — intended for: curl -fsSL .../install.sh | bash
|
||||
#
|
||||
# Downloads the claudebox repo tarball, installs the whole tree under
|
||||
# $DEST, and puts a `claudebox` symlink on PATH via $BINDIR.
|
||||
# $DEST, and puts a `box` symlink on PATH via $BINDIR.
|
||||
|
||||
REPO="${CLAUDEBOX_REPO:-heavy-duty/claudebox}"
|
||||
REF="${CLAUDEBOX_REF:-main}"
|
||||
|
|
@ -26,7 +26,7 @@ trap cleanup EXIT
|
|||
|
||||
URL="https://github.com/$REPO/archive/refs/heads/$REF.tar.gz"
|
||||
|
||||
log "installing claudebox ($REPO@$REF)"
|
||||
log "installing box (the claudebox repo) ($REPO@$REF)"
|
||||
log "downloading $URL"
|
||||
curl -fsSL "$URL" -o "$TMPDIR/claudebox.tar.gz" \
|
||||
|| die "failed to download $URL"
|
||||
|
|
@ -38,7 +38,7 @@ tar -xzf "$TMPDIR/claudebox.tar.gz" -C "$TMPDIR" \
|
|||
# GitHub archives extract to a single top-level dir like claudebox-<ref>/
|
||||
EXTRACTED="$(find "$TMPDIR" -maxdepth 1 -type d -name 'claudebox-*' | head -n1)"
|
||||
[ -n "$EXTRACTED" ] || die "could not find extracted claudebox-* directory in archive"
|
||||
[ -f "$EXTRACTED/bin/claudebox" ] || die "archive does not contain bin/claudebox — is $REPO@$REF correct?"
|
||||
[ -f "$EXTRACTED/bin/box" ] || die "archive does not contain bin/box — is $REPO@$REF correct?"
|
||||
|
||||
# --- atomically replace $DEST ---------------------------------------------
|
||||
log "installing into $DEST"
|
||||
|
|
@ -46,12 +46,19 @@ rm -rf "$DEST"
|
|||
mkdir -p "$(dirname "$DEST")"
|
||||
mv "$EXTRACTED" "$DEST"
|
||||
|
||||
chmod +x "$DEST/bin/claudebox"
|
||||
chmod +x "$DEST/bin/box"
|
||||
|
||||
# --- put claudebox on PATH -------------------------------------------------
|
||||
# --- put box on PATH -------------------------------------------------------
|
||||
mkdir -p "$BINDIR"
|
||||
ln -sf "$DEST/bin/claudebox" "$BINDIR/claudebox"
|
||||
log "linked $BINDIR/claudebox -> $DEST/bin/claudebox"
|
||||
ln -sf "$DEST/bin/box" "$BINDIR/box"
|
||||
log "linked $BINDIR/box -> $DEST/bin/box"
|
||||
# 0.4.0 renamed the binary (clean cut): clear a stale claudebox symlink so it
|
||||
# cannot dangle at the old bin path forever. Old BOXES keep working — the CLI
|
||||
# honors their legacy tag — it is only the old command name that retires.
|
||||
if [ -L "$BINDIR/claudebox" ]; then
|
||||
rm -f "$BINDIR/claudebox"
|
||||
log "removed the old claudebox symlink — the command is 'box' now (your existing boxes keep working)"
|
||||
fi
|
||||
|
||||
# --- PATH check ------------------------------------------------------------
|
||||
case ":$PATH:" in
|
||||
|
|
@ -65,8 +72,8 @@ esac
|
|||
|
||||
# --- environment check -----------------------------------------------------
|
||||
if ! command -v incus >/dev/null 2>&1; then
|
||||
warn "incus was not found. claudebox needs Incus on the host."
|
||||
warn "incus was not found. box needs Incus on the host."
|
||||
warn " run the one-time host setup: $DEST/host/setup-host.sh"
|
||||
fi
|
||||
|
||||
log "done — try: claudebox new --name test"
|
||||
log "done — try: box new --name test"
|
||||
|
|
|
|||
|
|
@ -1,8 +1,11 @@
|
|||
name: claude-dev
|
||||
description: Trust-less claudebox (resources + isolated NIC)
|
||||
config:
|
||||
limits.cpu: "4"
|
||||
limits.memory: 8GiB
|
||||
# The placement contract. Every box, whatever its template, launches with
|
||||
# exactly this profile: the isolated NIC and the root disk — and NOTHING a
|
||||
# template controls. Resources (limits.*) are per-instance, stamped from the
|
||||
# template's box.env at mint time; they do not belong here, because a profile
|
||||
# a template could point away from is a network a template could escape.
|
||||
name: box-net
|
||||
description: The box placement contract — isolated NIC + root disk, nothing else
|
||||
config: {}
|
||||
devices:
|
||||
eth0:
|
||||
type: nic
|
||||
9
templates/blank/box.env
Normal file
9
templates/blank/box.env
Normal file
|
|
@ -0,0 +1,9 @@
|
|||
# The blank template — a box with nobody home, not a box with the safety off.
|
||||
# Same image, same isolation, same entry path as every other template; the
|
||||
# only thing missing is the tooling. Start here to build a new template.
|
||||
BOX_DESCRIPTION="Bare Debian 13 — same isolation, no tooling"
|
||||
BOX_IMAGE="images:debian/13/cloud"
|
||||
BOX_USER="dev"
|
||||
BOX_CPU="2"
|
||||
BOX_MEMORY="2GiB"
|
||||
BOX_DISK="20GiB"
|
||||
9
templates/blank/user-data.yaml
Normal file
9
templates/blank/user-data.yaml
Normal file
|
|
@ -0,0 +1,9 @@
|
|||
#cloud-config
|
||||
# The whole template: create the user and stop. BOX_USER in box.env must
|
||||
# match the name here — the file is passed to Incus verbatim.
|
||||
users:
|
||||
- name: dev
|
||||
shell: /bin/bash
|
||||
sudo: "ALL=(ALL) NOPASSWD:ALL"
|
||||
lock_passwd: true
|
||||
package_update: false
|
||||
12
templates/claude/box.env
Normal file
12
templates/claude/box.env
Normal file
|
|
@ -0,0 +1,12 @@
|
|||
# The claude template — Debian 13 + Claude Code, creds-free.
|
||||
# KEY="value" only. Parsed against an allowlist, never sourced; there is no
|
||||
# key for a network or a security flag, on purpose — the shared box-net
|
||||
# profile is the placement contract and no template can weaken it.
|
||||
# BOX_USER must match the user user-data.yaml creates (the cloud-init file is
|
||||
# passed to Incus verbatim, so the duplication is deliberate and by hand).
|
||||
BOX_DESCRIPTION="Claude Code on Debian 13, creds-free (the original claudebox)"
|
||||
BOX_IMAGE="images:debian/13/cloud"
|
||||
BOX_USER="claude"
|
||||
BOX_CPU="4"
|
||||
BOX_MEMORY="8GiB"
|
||||
BOX_DISK="60GiB"
|
||||
|
|
@ -13,7 +13,7 @@ write_files:
|
|||
# You are running inside a claudebox
|
||||
|
||||
A claudebox is a trust-less, network-isolated, ephemeral VM created by the
|
||||
`claudebox` CLI. Keep this context in mind:
|
||||
`box` CLI (template: claude). Keep this context in mind:
|
||||
|
||||
- **Creds-free by default.** The box starts with no Claude and no git
|
||||
credentials. If you need to authenticate Claude, the operator runs `/login`
|
||||
|
|
@ -23,7 +23,7 @@ write_files:
|
|||
- **Isolated.** The box reaches the public internet but nothing on the host or
|
||||
local network. There is no inbound path.
|
||||
- **Disposable.** Nothing here is backed up. State is discarded when the box is
|
||||
removed; the operator persists work via git push and via `claudebox snapshot`.
|
||||
removed; the operator persists work via git push and via `box snapshot`.
|
||||
- **Bootstrap runbook.** If the repository you are working in contains a
|
||||
`.claudebox/` folder, read it as your setup runbook — how to install
|
||||
dependencies, start services, template environment files, seed data, and
|
||||
Loading…
Reference in a new issue