# Drill run log What the drill has actually found, what has broken *in the drill itself*, and how to diagnose the next stall without starting from zero. Append a section per run; keep the traps table current — it is the part that saves time. The audit this fed, [#15](https://github.com/heavy-duty/claudebox/issues/15), is **complete** (run 10, 48/49) — and its headline finding is **fixed and re-measured**: sibling isolation shipped in #37/#38, and runs 11–13 hold at **zero failures**, run 13 from a bare host (full teardown → rebuild → drill, 47/47). [#16](https://github.com/heavy-duty/claudebox/issues/16) closed on that evidence. ## The audit's answer | Probe | Answer | | --- | --- | | A1/A5 egress + gateway DNS | PASS | | A2 box → host | dropped | | A2 box → RFC1918 | dropped | | **A3 sibling isolation** | run 10: 🔴 **FAIL — tcp REFUSED, i.e. the packet ARRIVED.** #12's central claim was wrong; #16 became a fix, not a formalization. **Fixed** (`security.port_isolation` + nft bridge drop, #37/#38); runs 11–13: **BLOCKED — tcp dropped + no icmp reply** | | A4 DNS enumeration | run 10: LEAKS. **Fixed** (`dns.mode=none` shipped); runs 11–13: **blocked** — and the resolver is pinned so tailnet/split-DNS names don't resolve either (#33/#45) | | A6 IPv6 off | `none` ✓ | | A7 inbound host → box | dropped | | B1 `@internal` on a bridge ACL | REJECTED — `Unsupported nftables subject` ⇒ #16 derives the subnet | | B2 `incus copy` preserves `user.*` | YES ⇒ #17's metadata design holds | | B3 `dns.mode=none` | VIABLE — closes the leak, egress survives, no outage window | | B4 `config get` unset key | empty + exit 0 ⇒ #17 must use `${var:-}` | | B5 L2 filtering | 🔴 `ipv4_filtering` **BREAKS the box** — design veto (measured on a healthy baseline) | **The headline, then:** the tool's contract — *"a box reaches the public internet and nothing else"* — was **false**: a box also reached every other box on the host. **The headline, now:** the contract holds, and it is *measured* — 47/47 from a bare host (run 13), every layer probed from inside the boxes. ## Findings in claudebox (not in the drill) | Finding | Status | | --- | --- | | `setup-host.sh` called `nft` but a stock Debian 13 cloud image ships neither nftables nor UFW — host setup died on a fresh cloud host | **fixed** (setup-host installs it) | | `claudebox exec box -- claude …` — the help's own example — failed: the binary is in `~/.local/bin`, but cloud-init exported PATH only in `.bashrc`/`.zshrc`, which the non-interactive shell behind `exec` never reads (login shell is zsh, so even `sudo -i` misses both) | **fixed** (symlink into `/usr/local/bin`) | | Cold mint takes **~95s**, not the ~10 min the docs claim — consistently. Either the host is fast, or `cloud-init status --wait` returns before `runcmd` finishes (which would hand over boxes whose installs are still running) | **open** — worth its own issue if run 6 shows cloud-init mid-flight | ## Traps this script has already fallen into Read this before adding a probe. Every one of these cost a run. 1. **`set -o pipefail` breaks refusal checks.** Half the drill is `claudebox 2>&1 | grep -q 'text'`. The refusal exits 1/2 *by design*, and `grep -q` SIGPIPEs the left side when it matches early. Under pipefail both become false FAILs. The pipeline's verdict must be grep's alone — hence `set -u` and no pipefail. 2. **`$( )` waits for stdout to CLOSE, not for the command to exit.** A grandchild inheriting an `incus exec` session's stdout holds the substitution open forever, and `timeout` does *not* save you: it kills the wrapper, not the process holding the pipe. Use `in_box`/`box_curl`, which talk to `incus exec` directly, pin stdin to `/dev/null`, and land output in a file rather than a pipe. 3. **Never start a background process inside a box.** Same mechanism as (2), and it is why the drill now runs **no listener anywhere**. It does not need one: `curl` exit `7` (refused) means the packet *arrived*, `28` (timeout) means it was *dropped*. A closed port answers the question. 4. **The box's address is hard to read, and every way of getting it wrong was tried.** (a) `incus list` name filters are **not regexes** — `incus list "^peer$"` silently matches nothing. (b) Its CSV quotes a multi-address box across lines. (c) **The interface is not `eth0`.** The *profile* names the device `eth0`, but inside a **VM guest** predictable naming renames it **`enp5s0`** — so `ip addr show dev eth0` finds nothing either. That is the real reason A3 went unprobed for six runs, through two "fixes" of mine that never questioned the interface name. Read it from inside the box and select by **subnet** (`10.87.x`), not by interface name: docker0 (`172.17.x`) is the decoy, and the NIC's name is the guest's business. *Lesson: when the same probe fails three different ways, stop patching the probe and go look at the thing itself.* 5. **`incus delete -f a b c` aborts at the first MISSING name.** One interrupted run then poisons the next: stale boxes survive cleanup and cascade into half a dozen unrelated FAILs. Delete one name at a time. 6. **`apt-get -qq … >/dev/null` hides both a sudo prompt and the apt lock.** `apt-daily`/`unattended-upgrades` hold the lock on a cloud image and apt waits in complete silence. Pre-authorize sudo, set `DPkg::Lock::Timeout`, and narrate. 7. **`claudebox exec` is `sudo -u claude -i`** — a *login zsh* with oh-my-zsh. Fine for a human, needless machinery for a probe, and one more thing that can hold an fd. Probes use `incus exec` directly. 8. **Clean before you set up, not after.** `setup-host.sh` reconfigures the network's ACLs, and a previous run's boxes are still *attached* to that network — `incus network set` then has to push the change onto every live NIC. An aborted run also leaves the D-phase mutations (`dns.mode=none`, NIC filtering) in place, so setup converges against a moving target. Delete the boxes and revert the mutations **first**. 9. **Never render a verdict on a broken baseline.** Run 7's box had no network (a clone/source IP collision), and phase D dutifully reported *"L2 filtering BREAKS the box — design veto"*. It did not; the box was already broken. A measurement taken on a broken instrument is not evidence, and #16 would have been redesigned around a fiction. Phase D is now gated on baseline egress passing, and refuses to judge otherwise. This is the same failure as the B3 flip, in a different costume: **check that the thing you are measuring with still works before you trust what it tells you.** 10. **The drill mutates the host, and those mutations outlive an aborted run.** Phase D sets `dns.mode=none` and NIC filtering. If the run dies before reverting them, **every box minted afterwards has no DNS** — cloud-init fails with `Temporary failure resolving deb.debian.org` — and the next run reports that breakage as a *finding*. This is the worst failure mode in the whole list: a poisoned host does not fail honestly, it produces confident wrong answers. Hence the `trap`-armed revert, the verified (not `/dev/null`-ed) unset, the refusal to start on a dirty host, and `doctor.sh`. 11. **A network Incus calls `Created` may have nothing serving it.** After an unclean daemon death (a wedge, a SIGKILL, an OOM), Incus can come back without respawning a network's **dnsmasq**. The bridge is up, `incus network show` is perfect, `status: Created` — and no DHCP server exists, so every box minted afterwards gets **no lease, no gateway, no DNS**, and dies deep in cloud-init blaming Debian's mirrors. Incus's own status does not cover this; the process table does. `doctor.sh` now checks it, because two cold mints and an hour went into learning it the other way. 12. **A `curl` exit code cannot tell you whether the packet arrived.** Exit 7 is "failed to connect", and it means *both* `Connection refused` (a RST came back — **reachable**) and `Could not connect` / `No route to host` (nothing came back — **isolated**). Opposite conclusions, one number. The drill mapped 7 → "it arrived" and reported a **working** boundary as a broken one for two full runs after the fix had landed, while the kernel had `isolated on` on the bridge ports the whole time. **Read the message.** A refusal is instant; an unreachable host burns the timeout. This is the same disease as every other trap here — trusting a proxy for the fact instead of the fact. 13. **A TTY on stdin makes `incus exec` interactive — and then `timeout` cannot kill it.** The doctor's probes ran bare `timeout N incus exec …` with the operator's terminal as stdin: exec attached it in raw mode, the 20s TERM never took (no `-k` escalation), and **Ctrl-C was forwarded into the box as a keystroke** — a 15-minute hang the operator could only end by killing the shell. The drill had already learned this in #22 (`exec_in` pins stdin, escalates with `-k 5`); the doctor's probe section was added later and never inherited the cure. Every `incus exec` in a script gets `