Commit graph

136 commits

Author SHA1 Message Date
Daniel Marin
d8731b4772
Merge pull request #25 from claude-hdb/drill/clean-before-setup
fix(drill): clean the host before setup-host, and bound it
2026-07-14 00:10:19 +01:00
dcc9f001b3 fix(drill): clean the host BEFORE setup-host, and bound it
Run 6 stalled in setup-host.sh, which should be seconds on a host that
already has incus. The ordering was wrong: cleanup ran AFTER setup, so
setup-host reconfigured claudenet's ACLs while an aborted run's boxes
were still attached to that network — 'incus network set' then has to
push the change onto every live NIC. The same aborted run also left the
D-phase mutations (dns.mode=none, NIC filtering) in place, so setup was
converging against a moving target.

Boxes are now deleted and the mutations reverted first, so setup-host is
the no-op it should be. It is also bounded (5 min) and, on timeout,
prints the three things worth checking instead of hanging: instances
still on the network, the firewall unit, the incus daemon. Every cleanup
call gets its own timeout, so a wedged instance cannot stall the run
before the drill has printed a single line.

RUNS.md gains this as trap 8.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-13 23:09:51 +00:00
Daniel Marin
8bda06d0dc
Merge pull request #24 from claude-hdb/drill/setup-visibility
fix(drill): stop going silent through host setup, and keep the run log in the repo
2026-07-14 00:07:46 +01:00
a3e5d1226e docs(drill): keep the run log in the repo, not in PR descriptions
Five runs of hard-won knowledge — which probes are answered, which bugs
the drill found in claudebox, and the seven traps the drill itself fell
into — has been living in PR bodies and chat, where the next person
debugging this cannot find it.

RUNS.md carries: the audit scoreboard (A3 still unanswered, and why that
matters), the claudebox findings, a traps section to read BEFORE adding
a probe, stall diagnostics, how to run a single probe by hand instead of
paying for a full run, and the B3 flip-flop as a standing lesson about
verdicts drawn from one observation of a system with restart semantics.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-13 23:06:38 +00:00
40a9e05e51 fix(drill): stop going silent through host setup
Run 5 looked stuck for minutes right after the "Host setup" header. The
setup stretch is the longest and most failure-prone part of the run, and
it was also the only part that printed nothing at all — two ways to
block, both invisible:

  · 'sudo apt-get install -y -qq incus >/dev/null 2>&1' swallows a sudo
    password prompt, so a stalled sudo is indistinguishable from a hang.
    Sudo is now pre-authorized up front, out loud.
  · apt's lock is routinely held by apt-daily / unattended-upgrades on a
    cloud image, and apt waits for it in complete silence. It now gets
    DPkg::Lock::Timeout=300 plus an outer timeout, announces that it may
    be waiting on the lock, and on failure prints the two commands that
    identify the holder — instead of hanging forever.

Also: skip the apt call entirely when incus is already installed (every
re-run on the same host), and narrate each setup sub-step, so a stall is
locatable rather than a blank terminal.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-13 23:05:34 +00:00
Daniel Marin
0b083f4da5
Merge pull request #23 from claude-hdb/drill/listenerless-probes
fix(drill): delete every listener — refused-vs-dropped already answers it
2026-07-14 00:03:14 +01:00
12350aaa3e fix(drill): delete every listener — refused-vs-dropped answers it already
The listener was never load-bearing. curl's exit code already separates
the two cases the audit cares about: 7 (connection refused) means the
packet ARRIVED and a closed port answered with a RST — reachable, not
isolated; 28 (timed out) means it was dropped in flight — isolated. A
closed port is a perfectly good target: it answers, or it doesn't.

So all three listeners go, and with them the whole class of bug that
wedged runs 1 and 4: a backgrounded process inside an 'incus exec'
session holds that session open no matter what you redirect, and the
host-side one leaked a python on every run. The drill now starts no
background process anywhere.

C4 gains ping as a second opinion — tcp dropped but icmp replying is
'partial isolation', which is a finding, not a pass. C2 drops its
gateway listener too: the host already listens there (dnsmasq on :53 by
design), so probing an unserved port and reading refused-vs-dropped is
both safer and more honest about what the firewall must prevent.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-13 23:02:48 +00:00
Daniel Marin
6cfe11e9f9
Merge pull request #22 from claude-hdb/drill/hangproof-exec
fix(drill): no in-box probe can hang the run again
2026-07-13 23:57:16 +01:00
2f5196f23f fix(drill): no in-box probe can hang the run again
Run 4 hung 10+ minutes at C4. The cause is a shell trap, not a network
one: $( ) waits for stdout to CLOSE, not for the command to exit, so a
grandchild inheriting the exec session's stdout holds the substitution
open forever — and 'timeout' does not save you, because it kills the
wrapper, not the process holding the pipe. 'claudebox exec' made it
likelier still: it becomes 'sudo -u claude -i', a login zsh with
oh-my-zsh, which is a lot of machinery to keep a fd alive.

Every in-box read now goes through in_box()/box_curl(): incus exec
directly, stdin pinned to /dev/null, output landed in a file rather than
a pipe, and 'timeout -k' to hard-kill. box_curl returns the curl exit
code as its finding — for A3 the exit code IS the answer (0 connected,
7 refused-so-it-arrived, 28 dropped). C4 also prints what it is probing
before it probes, so a stall is locatable instead of silent.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-13 22:55:43 +00:00
Daniel Marin
2230791191
Merge pull request #21 from claude-hdb/drill/a3-and-flaky-dns
fix(drill): read eth0 from inside the box; settle the flaky dns.mode verdict
2026-07-13 23:41:57 +01:00
be0fe99c22 fix(drill): read eth0 from inside the box, and settle the flaky dns.mode verdict
A3 has now gone unprobed for three runs, and the cause was in eth0_ip:
'incus list' name filters are not regexes, so the anchored "^peer$" form
matched nothing — while run 3's own DNS note showed peer resolving to
10.87.0.210, proving the address was there all along. Read it from
inside the box with 'ip -4 -o addr show dev eth0' instead: unambiguous,
no CSV quoting, no filter semantics to get wrong.

Runs 2 and 3 also DISAGREED on B3's egress half — broken, then intact.
Setting dns.mode restarts the network's dnsmasq, so a probe fired
immediately can catch it mid-restart. The probe now distinguishes a
transient outage (recovers within 30s) from a real break (does not), so
#16 gets a verdict it can design on rather than a coin flip.

The docker check now probes the daemon with sudo (a fresh exec session
may lack the docker group) and actually pulls and runs a container,
which is the thing ipv4_filtering could plausibly break.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-13 22:41:06 +00:00
Daniel Marin
6b52ee98a9
Merge pull request #20 from claude-hdb/drill/run2-fixes
fix: run-2 cascade (per-name cleanup), and claude on exec's PATH
2026-07-13 23:30:17 +01:00
d9c3d5680d fix(drill): per-name cleanup, hatch-aware diagnostics, full teardown sweep
Run 2's five cascade FAILs traced to one line: 'incus delete -f a b c'
aborts at the first missing name, so an interrupted run's boxes survived
into the next run — stale 'archive' broke the rename, stale 'peer' broke
the clone (UNIQUE constraint), and the un-renamed 'drill' survived
teardown. Deletion is now one name at a time, and teardown sweeps every
name the drill can have left, whatever branch a partial run took.

The claude-in-box diagnostics also read the wrong line: the escape hatch
prints its 'claudebox: incus exec …' announce before the output, and
'head -1' captured the announce. Diagnostics now filter it, and probe
whether the binary itself runs by full path. Docker's D-phase note gains
a dockerd systemctl readout.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-13 22:22:50 +00:00
9112f9e446 fix: make claude reachable from non-interactive shells in the box
'claudebox exec box -- claude --version' — the exact invocation the help
promises — failed on the first live drill: the binary lands in
~/.local/bin, but cloud-init only exported PATH in .bashrc/.zshrc, which
the non-interactive shell behind exec never reads (the box's login shell
is zsh, so even sudo -i reads neither). A symlink in /usr/local/bin is
on every shell's PATH, interactive or not. Found by the drill (#15).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-13 22:22:50 +00:00
Daniel Marin
5c1912a555
Merge pull request #19 from claude-hdb/drill/first-run-fixes
fix: first-run drill defects — pipefail false FAILs, exec-pty hang, DHCP race
2026-07-13 23:07:32 +01:00
4ea758fa81 fix: three first-run drill defects — pipefail false FAILs, exec-pty hang, DHCP race
The first live run (issue #15) hit all three: (1) 'set -o pipefail'
turned every 'refusal 2>&1 | grep -q' check into a false FAIL — the
refusals exit 1/2 by design, and 'grep -q' SIGPIPEs the left side on
early match; the pipeline verdict must be grep's alone, so pipefail is
gone with a comment explaining why. (2) The in-box background listener
inherited the exec pty, so 'incus exec' waited on it forever — the run
hung at C7 for 10+ minutes; the listener is now a helper with </dev/null
on the child and a timeout on the client. (3) eth0_ip raced DHCP — the
agent answers before the address exists — so the sibling probe never
ran; it now retries for 30s.

Also: the claude-in-box check now distinguishes 'installed but not on
exec's PATH' (a repo bug — the help promises that exact invocation)
from 'not installed', and dumps cloud-init status when it is the latter.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-13 22:05:32 +00:00
Daniel Marin
de671b0731
Merge pull request #18 from claude-hdb/drill/rehearsal
test: add an end-to-end drill against a real Incus
2026-07-13 22:34:43 +01:00
aba7015e53 test: fold the #15 audit into the drill — sibling probes and a hardening rehearsal
The drill covered the CLI surface and basic egress but never probed the
one claim everything hinges on: it only ever had one box running, so
sibling isolation — #12's central, never-reproduced finding — went
untested. Phase C now clones a peer and probes box-to-box reachability
(with a listener, so "refused" cannot masquerade as "dropped"), DNS
enumeration, IPv6-off, and host-to-box inbound. A new phase D applies
the exact changes #16 proposes (dns.mode=none, NIC filtering, @internal)
and watches what breaks — a FAIL there is a design veto caught before
the code is written. The run ends with an answers block to paste into
issue #15.

Also: defaults now install heavy-duty/claudebox@main (the old default
pointed at a fork branch that stopped moving when #13 merged), the
version check compares against the installed VERSION file instead of a
hardcoded 0.3.0, and the blind sleep after start became an agent poll.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-13 21:30:47 +00:00
69eff180d4 fix: setup-host installs nftables when neither nft nor UFW exists
A stock Debian 13 cloud image ships neither, so the no-UFW firewall path
died on its first nft call — on exactly the kind of fresh cloud host the
tool targets. Found by the drill; the drill's manual workaround becomes a
regression tripwire.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-13 21:30:47 +00:00
c1ca981f46 test: add an end-to-end drill against a real Incus
The repo has no tests and no CI, and everything in #9/#10/#13 was verified
against a stubbed incus. The interesting failures aren't in the bash — they're
in what Incus actually does, which is exactly what a stub gets wrong. So the
drill runs the real thing on a host you can format.

  A. Incus semantics — the assumptions claudebox is built on, probed directly:
     the user.claudebox=1 tag read (now on the path of every box command in
     #13), the list filter, the CSV shape, the state string, whether rename
     really refuses a running instance, the snapshot-list field order.
  B. The claudebox surface — mint, list, info, snapshot, clone-from-snapshot-of-
     a-renamed-box, rename, the escape hatch, the rm guard, the CLI contract —
     and the boundary: an instance claudebox did NOT mint has down/rm/hatch
     aimed at it, must refuse all three, and must still be standing after.
  C. Isolation — from inside a real box: public egress works, box → host is
     blocked, box → RFC1918 is dropped.

Destructive by design, and it says so before it starts: it asks for consent
(--yes to skip), and refuses outright with no TTY. Without /dev/kvm it reports
that the VM trust boundary was NOT validated rather than passing on a container.
2026-07-13 21:15:56 +00:00
Daniel Marin
0982a2d36f
Merge pull request #13 from claude-hdb/refactor/command-table
feat: make the command surface a table, add `rename` and an escape hatch
2026-07-13 21:56:45 +01:00
7d33ad8a00 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
2026-07-13 20:49:46 +00:00
Daniel Marin
f2dab00549
Merge pull request #10 from claude-hdb/fix/cli-help-and-contract
fix: standard help, honest flags, and an `rm` that asks first
2026-07-13 21:43:14 +01:00
Daniel Marin
754f1ca56b
Merge pull request #9 from claude-hdb/feat/list-command
feat: add `claudebox list`, with snapshot labels
2026-07-13 21:42:59 +01:00
8636d1abcb fix: standard help, honest flags, and an rm that asks first
The help was `sed -n '2,9p' "$0"` — the script scraping its own header comment
by line number — with no sections, no per-command help, no `-h`, and no
`--version`. Worse, parts of the interface it described weren't real: `--force`
was parsed and never read, so `claudebox rm work` force-deleted a running box
with no confirmation while advertising a flag that implied the bare form was
the careful one.

- usage()/help_cmd(): NAME/USAGE/COMMANDS/OPTIONS/EXAMPLES/EXIT STATUS, plus a
  page per command. `claudebox help <cmd>` and `claudebox <cmd> --help`.
- `-h`/`--help` anywhere; `--version`/`-V` (VERSION file).
- Unknown command → stderr, exit 2, and a did-you-mean by edit distance.
- Unknown options are rejected, not swallowed as positionals: `snapshot work
  --labl x` no longer names the snapshot "--labl". `--` still passes everything
  through for exec.
- `--force` is real: `rm` confirms on a TTY, refuses without one unless forced.
  BEHAVIOR CHANGE — a scripted `claudebox rm` now needs `--force`.
- Exit 2 for usage errors, 1 for runtime failures; accurate per-command usage
  strings (`exec` no longer omits `-- <cmd...>`).
- shellcheck is clean: SC2034 (dead `force`) and SC2015 both gone.

Closes #8
2026-07-13 20:41:21 +00:00
5ce2a0ae8d feat: add claudebox list and claudebox info
`status` was the only lister: misnamed, silently ignoring the `<box>` its own
help advertised, and unable to show a snapshot label — the one thing the
README's snapshot→clone flow requires you to know. Typing the obvious
`claudebox list` printed the help and exited 0.

- `list` — a table of your boxes (name, state, type, snapshot count).
- `info <box>` — detail, the snapshot labels, and the `--from` line to clone one.
- `list <box>` is a wrong guess we can answer: it points at `info`.
- `--json` passthrough; `--remote` honored; a real error on an unknown box.
- `status` stays as a deprecated alias for `list` so muscle memory keeps working.

Reads are filtered by the `user.claudebox=1` tag, so an Incus instance
claudebox didn't mint is never reported on or touched.

Closes #7
2026-07-13 20:40:17 +00:00
Daniel Marin
188c8b0f95
Add MIT License to the project 2026-07-11 21:33:51 +01:00
Daniel Marin
00e5ef524e
Merge pull request #5 from claude-hdb/fix/preinstall-gh-cli
fix: preinstall the GitHub CLI in boxes
2026-07-11 12:25:40 +01:00
1abd776850 fix: preinstall the GitHub CLI in boxes
The README's auth flow tells operators to run `gh auth login`, but the
cloud-init package set never installed gh — a fresh box couldn't follow
its own instructions. Debian 13 ships gh natively, so a plain packages
entry suffices.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-11 11:21:30 +00:00
Daniel Marin
34158aeafa
Merge pull request #4 from claude-hdb/feat/drop-name-prefix
feat: drop the claude- instance-name prefix
2026-07-10 18:11:30 +01:00
d956614269 feat: drop the claude- instance-name prefix; tag boxes instead
Instance names now equal the box name (claudebox new --name work -> 'work',
not 'claude-work'). To still tell claudebox's instances apart from any other
incus instances, tag each on creation with user.claudebox=1 and filter status
+ teardown by that tag instead of a name prefix. Clones (new --from) inherit
the tag.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-10 17:09:45 +00:00
Daniel Marin
0628ac81b6
Merge pull request #2 from claude-hdb/fix/install-public-canonical
fix: install from the public canonical repo
2026-07-10 17:00:21 +01:00
e175c416c3 fix: install from the public canonical heavy-duty/claudebox
The fork claude-hdb/claudebox is private, so an anonymous 'curl | bash' against
it 401s. Point the installer default and the README one-liner at the public
canonical repo so the install flow works without auth.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-10 15:08:18 +00:00
Daniel Marin
95ba6abbe2
Merge pull request #1 from claude-hdb/import-claudebox
Import claudebox: creds-free, trust-less Claude Code VMs
2026-07-10 16:01:39 +01:00
0c9971ecf8 Import claudebox: creds-free, trust-less Claude Code VMs
A CLI that mints trust-less, network-isolated Incus VMs with Claude Code
installed. Boxes are strictly creds-free — the operator logs into Claude
interactively inside; authenticated state is reused via snapshots. The tool
knows nothing about projects; a repo ships an optional agent-facing .claudebox/
runbook that Claude reads.

- bin/claudebox: new/shell/exec/snapshot/restore/down/start/rm/status; creds-free
  'new' (fresh launch or clone via --from <src>[/<snap>]).
- cloud-init: global ~/.claude/CLAUDE.md self-describing the box + .claudebox/ runbook.
- install.sh: curl-pipe-bash installer.
- host/: Incus isolation stack (claudenet + claude-isolate ACL + claude-dev
  profile + firewall).
- docs/: design + .claudebox/ convention.

Initial canonical import (prototyped separately; re-homed onto the heavy-duty fork).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-10 15:00:36 +00:00
Daniel Marin
ec41ade3fc
Create README.md 2026-07-10 15:53:40 +01:00