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.
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 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>
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>