Operator watched /tmp/new.log through a claude mint and saw not one
message: cloud-init's progress dots are block-buffered the moment
stdout is not a tty, so a redirected mint shows nothing for the whole
install and then one burst — which reads exactly like a hang, on the
very night three real hangs happened.
PYTHONUNBUFFERED=1 on the cloud-init wait makes the dots arrive as
dots; box new also prints how to watch the box's own full narration
(incus exec <box> -- tail -f /var/log/cloud-init-output.log); and the
drill's logs are named for the box being minted (/tmp/mint-drill.log),
not for the verb that mints it.
All four mints (blank, claude, clone, peer) now run through mint_box:
box new's narration lands in the log as before, the drill prints where
to tail it, and a dot every 5s on the drill's own terminal proves the
run is alive. A silent multi-minute mint is indistinguishable from a
wedge, and that ambiguity has cost whole evenings — the operator said
so, verbatim.
Run 14's second catch: the blank mint's cloud-init finished, printed
'status: done' — and 'box new' hung for 15+ minutes on an exec session
that never closed ('incus operation list' showed it still RUNNING).
With a TTY on stdin (the drill redirects only stdout/stderr), incus
exec goes interactive, and the session can wedge open after the remote
command has exited. Same disease as drill trap 2 and doctor trap 13;
the CLI's own execs never got the cure.
Every non-interactive exec now pins stdin: wait_agent's probe, the
cloud-init wait, both failure-path reads, and the clone identity
reset. shell/exec/tmux keep the terminal — owning it is their job.
Also: wipe.sh keeps cached images on a plain wipe. An image is
upstream's artifact, content-addressed by fingerprint — deleting it
buys zero cleanliness and costs the next mint a full re-download. It
goes only with --purge-storage, where the pool it lives in goes too
(and it must go first: images block pool deletion).
Follow-up to the rename, per operator direction — the divergence is
reversed and the cut is complete:
- Host stack: boxnet (10.88.0.0/24 — a pre-rename host may still carry
claudenet on 10.87, two bridges must not claim one subnet),
box-isolate, nft tables 'inet box'/'bridge box', box-firewall.{sh,
service}. teardown-host now strips BOTH name generations, so one
script uninstalls a host of any age.
- Default template is blank: 'box new --name x' mints bare Debian;
the claude box is '--template claude'. The login hint follows the
EFFECTIVE template read off the instance, so clones of claude boxes
still get it and blank boxes are not told to run a binary they lack.
- The drill validates templates: listing, unknown-template refusal,
the allowlist rejecting BOX_NETWORK by name, and a full blank mint —
default resolves to blank, metadata stamped, box-net placement, exec
lands in 'dev', no claude binary, and isolation parity (egress +
pinned DNS) on the same contract as every template.
- drill/wipe.sh: scorched earth for drill hosts. Both tag generations,
every drill-named instance, networks/ACLs/profiles/firewall of both
generations, cached images, and (--purge-storage) the default pool.
Ends by asserting the ABSENCE of every artifact rather than trusting
the removals' exit codes.
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
Every fault the drill's doctor diagnoses is a user's fault first: a
wedged Incus daemon, a dnsmasq that silently isn't serving, a VPN
resolver boxes inherit, isolation claimed by config but off in the
kernel — each has killed a cold mint or weakened a boundary, with a
cloud-init error that names none of them. The CLI half-admitted it:
cmd_new's failure path hand-pointed at issue #33, doing one special
case of a doctor's job inline.
The verb delegates to the installed drill/doctor.sh (the tree ships
whole) — one hardened script, two audiences. Its flags pass through;
the verdict now reads 'fit to mint boxes (and to drill)'; the mint-
failure hint ends with 'claudebox doctor' instead of the hand-rolled
diagnosis.
Closes#46
The log stopped at run 10's headline (boxes reach each other). Three
runs later the arc is closed and RUNS.md should say so:
- run 11 (45/2): A3 re-answered with the fixed probe — BLOCKED,
security.port_isolation; both FAILs were the drill's own empty-host
assumption
- run 12 (46/0): first zero-failure run
- run 13 (47/0): full teardown → rebuild → drill from a bare host,
pinned resolver built from scratch on a Tailscale host
Answer table updated (A3/A4 show fixed + re-measured state), headline
flipped to match, traps 13–15 added (TTY stdin under timeout, the
gateway-ping false alarm, demanding an empty host), run history gains
the three rows and a closing note.
The bridge's dnsmasq forwarded to whatever sat in the host's
/etc/resolv.conf at that moment. On a Tailscale host that is MagicDNS:
box DNS flapped with the tailnet (killing cold mints), and tailnet peer
names and split-DNS zones resolved from inside a box — name-level
reconnaissance of a private network, the same class as the sibling
enumeration dns.mode=none already closes.
setup-host.sh now sets raw.dnsmasq to no-resolv + pinned public
upstreams (BOX_DNS overrides the default 1.1.1.1 8.8.8.8), answering
the issue's three open questions from live measurement: raw.dnsmasq is
the lever (no first-class upstream key on the bridge; verified by
doctor --pin-dns followed by a box resolving), upstreams are a setting
with a sane default, and the pin is unconditional.
The doctor's unpinned-state messages now point at setup-host.sh as the
durable fix, keeping --pin-dns as the quick test.
Closes#33
Run 11: 45 passed, 2 failed — and both failures were the same non-fact.
The host had two operator boxes (t1, t2) minted before the run, so
'claudebox list' could not say 'no boxes yet' at either end of the drill.
The empty-host message is only testable on an actually-empty host: skip
it (with a note) when tenants pre-exist, instead of failing it.
The teardown check contradicted its own rm loop: the loop deliberately
removes only the names the drill minted — the multi-tenant discipline —
and then the assertion demanded a globally empty host, flagging that
very restraint as a failure. Assert the drill's own names are gone;
name the survivor when one is.
claudebox-firewall.sh drops everything from a box to the host except
DNS (53) and DHCP (67); ICMP to 10.87.0.1 dies in that trailing drop on
every healthy host. The doctor used exactly that ping as its routing
probe, so it reported 'cannot even reach the gateway' — and a NOT-fit-
to-drill verdict — on a host whose very next line proved DNS working
through that same gateway.
Probe routing the way the contract states it: a box reaches the public
internet. curl to 1.1.1.1 by address, reusing the one probe for the
DNS-failure diagnosis instead of running it twice.
With a TTY on stdin, 'incus exec' goes interactive and puts the terminal
in raw mode. The DNS probes then hang forever: timeout's bare TERM never
lands (no -k escalation), and ^C is forwarded into the box as a keystroke
instead of killing the script. Observed live: doctor hung 15+ minutes at
'Can a box actually resolve DNS?' and survived Ctrl-C; the operator had
to kill the shell.
The drill already learned this exact lesson in #22 (exec_in pins stdin
and uses 'timeout -k'); the doctor's probe section was added later in
#34/#35 and never got the cure. All four probes now pin stdin to
/dev/null and escalate to SIGKILL.
Boxes are isolated. The kernel says so: 'isolated on' on both live bridge
ports. A box gets 100% packet loss pinging its sibling, and its TCP
connect burns the full timeout and dies with "Could not connect to
server" — not "Connection refused", which is what an arriving packet
gets, instantly.
The drill called that a FAIL. curl exit 7 is "failed to connect", and it
covers BOTH a refusal (a RST came back — the packet ARRIVED) and an
unreachable host (nothing came back — it was DROPPED). Opposite
conclusions, one exit code, and the drill mapped 7 → "arrived". So for
two runs after the isolation fix had landed and was working, the drill
reported a working boundary as a broken one.
The words distinguish what the number cannot. box_probe now returns
reachable | refused | dropped by reading the message, ping corroborates,
and every isolation probe (sibling, box→host, RFC1918, host→box) uses it.
RUNS.md gains trap 12. It is the same disease as all eleven before it:
trusting a proxy for the fact instead of the fact.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Run the doctor on a healthy host and it reported three problems, all of
them its own:
· dns.mode=none was flagged as leftover rehearsal dirt. It is now the
SHIPPED setting — it is what stops a box enumerating its siblings.
Its absence is the fault; its presence was being "fixed" away.
· the nft check used 'sudo -n', which fails without cached credentials,
so it reported the box-to-box rule MISSING on a host where 'sudo nft
list' plainly shows it.
· the kernel's bridge view — the one fact that settles the isolation
question — was skipped with "'bridge' not installed". It is installed;
it lives in /usr/sbin, which is not on a normal user's PATH.
· and the DNS probe looked only for the drill's own box names, so it
said "no box to probe with" while two boxes sat there RUNNING.
A diagnostic that cries wolf is worse than no diagnostic: it costs the
same trust as a real failure and teaches you to ignore it. All four now
report what is actually true.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Two bugs, one in each direction.
setup-host's new assertion ran 'nft list table bridge claudebox' without
sudo. nft needs root, so it failed with permission denied and printed
"the box-to-box drop is NOT active" about a rule that was demonstrably
there. A check that cries wolf is worse than no check.
And the deeper one: every check so far has asked the CONFIG whether
boxes are isolated. The config is a claim. Incus can accept
security.port_isolation and the kernel can still leave 'isolated off' on
the tap — and then boxes reach each other while every config in sight
says they cannot. That is precisely the shape of the original bug: the
ACL looked airtight and never saw the traffic.
So the doctor now reads the kernel's own view — 'bridge -d link show'
on claudenet's ports — and reports the isolated flag as the fact it is.
If the profile says true and the kernel says off, we learn that in a
second instead of after another ten-minute drill.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The nft bridge-family rule from the previous commit is LIVE on the host
and boxes still reach each other:
table bridge claudebox {
chain forward { ... meta ibrname "claudenet" meta obrname "claudenet" drop }
}
FAIL BOX A REACHES BOX B — sibling isolation does NOT hold [tcp: refused]
So the rule is not wrong about intent, it is wrong about mechanism —
whatever path these frames take, that hook does not stop them. Rather
than reason harder about netfilter (reasoning is what put the hole there
in the first place), use the mechanism Incus provides for exactly this:
security.port_isolation on the bridged NIC, which sets the kernel bridge
port's isolated flag so two isolated ports cannot exchange frames at all.
The nft rule stays as a second layer — it costs nothing — but the
profile flag is what carries the guarantee. doctor.sh checks it, because
the absence of this one is invisible: everything works and boxes can
simply reach each other.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The box-to-box drop shipped, the drill still found boxes reaching each
other, and the rule was simply not on the host. setup-host.sh ended with
'systemctl enable --now claudebox-firewall.service' — but the unit is
RemainAfterExit, so once it has run it stays "active" forever, and
'--now' does nothing to an active unit. Re-running setup-host after
upgrading claudebox therefore installed the new script to
/usr/local/sbin and never executed it. The host silently kept its old
firewall, and the box-to-box hole stayed open through the release that
claimed to close it.
This is worse than the original bug: every future firewall change would
have landed only on hosts that had never run setup-host before.
Restart the unit instead — the script is idempotent by design. Then
ASSERT the rule is live rather than assume it, because the absence of
this particular rule is invisible: everything keeps working and boxes
can simply reach each other. doctor.sh checks it too.
Also: dns.mode=none is now part of the shipped stack, so the drill must
stop treating it as leftover rehearsal dirt and reverting it.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
A live probe (drill run 10) found box A's SYN arriving at box B, and B
answering with a RST. Boxes were not isolated from each other at all,
and the README's contract — "a box reaches the public internet and
nothing else" — was false.
The ACL was not wrong; it simply never saw the traffic. Two boxes on one
bridge share an L2 segment, so their frames are SWITCHED between bridge
ports and never traverse the netfilter path where an L3 rule lives. The
drop on 10.0.0.0/8 (which contains claudenet) and the default ingress
drop both looked airtight and neither ever fired. This is why the
original reasoning — "belt and braces" — was plausible and wrong.
The bridge family does see it. Its forward hook fires exactly when a
frame passes from one bridge port to another, which on claudenet means
box→box and nothing else: frames for the gateway are delivered locally,
and so is anything routed out to the internet. Dropping every forwarded
frame on the bridge isolates the boxes and costs them nothing — DHCP and
ARP are unaffected, being broadcast and delivered on INPUT.
Also: dns.mode=none, so a box can no longer ENUMERATE its siblings
through the gateway's dnsmasq. Blocked connections with open
reconnaissance is not isolation.
security.ipv4_filtering is deliberately NOT used: it breaks the box's
networking (dockerd comes up but cannot pull or run a container).
The drill now ASSERTS all of this in phase C against the real stack;
phase D's rehearsal is retired, its findings recorded.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Run 10, on a doctor-clean host: 48/49, every #15 probe answered.
A3, the probe the whole exercise existed for and which nine runs failed
to fire, says sibling isolation DOES NOT HOLD. Box A's SYN reaches box
B and B answers with a RST. #12 reasoned from the code that the
10.0.0.0/8 egress drop covered box-to-box traffic "belt and braces";
two boxes on one bridge share an L2 segment, so their traffic is
switched, never routed, and never meets that rule. The tool's contract —
"a box reaches the public internet and nothing else" — is false: it also
reaches every other box on the host.
Also settled: ipv4_filtering breaks the box (measured on a baseline that
had already passed egress, unlike run 7's void verdict); dns.mode=none is
viable after all, retracting a veto I posted on one bad reading;
@internal is unsupported on bridge ACLs.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Two cold mints died in cloud-init with "Temporary failure resolving
deb.debian.org", on a host the doctor had just certified clean. The
cause was not DNS forwarding, not leftover mutations, and not the drill:
claudenet had NO dnsmasq. It never respawned after the SIGKILL that
recovered the wedged daemon in run 6, so boxes got no DHCP lease at all
— no address, no gateway, no DNS.
Incus does not surface this. The bridge is up, the config is pristine,
'incus network show' says status: Created. Only the process table knows.
So the doctor now asks the process table, and --fix restarts incus to
respawn it.
An hour of hunting and two dead mints went into learning this. It is a
five-second check.
RUNS.md gains trap 11: a network incus calls "Created" may have nothing
serving it.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Two cold mints in a row died with cloud-init 'status: error' on a host
the doctor had just certified clean — so the earlier "leftover mutations
poisoned the network" theory is dead, and the DNS failure is
reproducible rather than transient.
'claudebox new' printed four hundred dots and the word "error", leaving
the user with nothing to act on: the reason was in the box's own log and
nobody was told the log existed. It now prints cloud-init's status, the
fetch/resolve errors from the box's log, and how to inspect the box —
which is left running, because a box that failed to build is evidence,
not garbage. It also names the usual culprit: the host's resolver.
doctor.sh gains the diagnosis that keeps being done by hand:
· what the HOST resolves through, and whether that is a CGNAT/Tailscale
resolver the boxes inherit (issue #33);
· whether claudenet's resolver is pinned;
· and inside a box, the question that settles it — DNS is broken, but
can it still reach 1.1.1.1 BY ADDRESS? If yes, egress is fine and the
fault is purely the inherited forwarder.
· --pin-dns applies the #33 fix (raw.dnsmasq: no-resolv + public
servers) so the hypothesis can be TESTED rather than argued.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Run 8's cold mint failed with 'cloud-init status: error' — the box could
not resolve deb.debian.org, or claude.ai, or anything. The cause was not
in that run at all: run 7's phase D set dns.mode=none on claudenet, the
run ended before reverting it, and every box minted afterwards came up
with no DNS.
This is the worst failure mode the drill has: a poisoned host does not
fail the next run honestly, it produces confident wrong answers. It is
how a false design veto against #16 got posted, and it wasted a cold
mint plus an hour of diagnosis that had nothing to do with the code
under test.
Three defences:
· the phase-D revert is armed with a trap BEFORE the first mutation,
so it fires on any exit, Ctrl-C included;
· the revert is VERIFIED rather than fired into /dev/null, so a failed
unset can no longer masquerade as a successful one;
· the drill refuses to start on a host still carrying the mutations.
And drill/doctor.sh answers the question that kept being answered by
hand: what state is this host actually in? Network, profile, ACL,
leftover boxes, and whether a box can still resolve DNS — with --fix to
revert the leftovers.
RUNS.md gains trap 10.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Run 7's box had no network — a clone/source IP collision had taken it
down before a single hardening change was applied. Phase D measured
anyway and reported "L2 filtering BREAKS the box — design veto". It does
not. The box was already broken, and #16 would have been redesigned
around a fiction.
Phase D is now gated on baseline egress passing, and says loudly that it
is skipping rather than quietly producing a verdict. The dns.mode
rejection also stops swallowing incus's error message — the message IS
the finding.
RUNS.md gains trap 9: check that the thing you are measuring WITH still
works before you trust what it tells you. Same failure as the B3 flip,
different costume.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
A3, the one probe the whole audit exists for, has never fired in six
runs. It was never the network: the profile names the DEVICE eth0, but
inside a VM guest predictable naming renames it enp5s0, so every address
lookup — first the '(eth0)' CSV match, then 'ip addr show dev eth0' —
was hunting an interface that does not exist. I fixed that symptom twice
without ever questioning the assumption underneath it.
Read the address from inside the box and select by SUBNET (10.87.x, what
claudenet hands out) rather than by interface name. docker0's 172.17.x
is the decoy; the NIC's name is the guest's business, not ours.
A3 also gains a guard it should have had from the start: if the peer and
the source hold the SAME address, refuse to probe. That is not
hypothetical — clones were inheriting their source's machine-id, hence
its DHCP lease, hence its address, so 'archive → peer' was archive
probing itself and would have reported a cheerful 'reachable' as an
isolation failure.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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.