Commit graph

62 commits

Author SHA1 Message Date
Dan
77599ab3ee chore: bump version for cleanness 2026-07-18 14:39:45 +01:00
dan-claude-bot
8e6f3a4bb8 grant/rehearsal: the codex round — verified rollback, loud partial states, and the raw-attach guarantee measured (#75)
Review 4727756972 (A2): the backout no longer trusts gpasswd — it re-reads
the live group database after removal; verified-absent gets the safe
message, anything else screams ROLLBACK INCOMPLETE, exits nonzero, and
names the exact remediation. The concurrent-login window (a session begun
between usermod and backout keeps the group) is CLOSED to the extent the
database can't reach: the backout detects live processes and names
loginctl terminate-user, and the success wording claims only what was
verified.

Review 4727641752 (A1): a failed grant for a user whose membership predates
the run (the hand-added-user scenario) now fails LOUDLY — they retain
socket access on part-converged policy, and the message says so with both
remediations (box revoke now, or fix and re-run). Their membership is not
stripped: breaking a working user over a failed re-grant is its own hazard.
The default-profile eth0 removal is deliberately not restored on failure —
that mutation only reduces capability, and restoring it would move the
failure state AWAY from fail-closed. Injected-failure coverage is criterion
(n), both flavors: fresh-user backout (fault at the LAST mutation, so the
rollback runs after every earlier one) with the group's absence verified
and a converging re-run; blocked narrowing staged for real with an
instance-local NIC parked on the private bridge.

Review A3, resolution 3 with the measurement demanded: criterion (m)
launches exactly 'incus launch --network boxnet' as the restricted user and
probes the raw NIC from inside — egress works, RFC1918 dropped (the ACL is
the network's), sibling probes dropped BOTH directions (the nft drop is the
host's), name enumeration blocked. The scoped guarantee is now stated in
box-design.md and measured on every run: box-minted instances carry per-NIC
port_isolation; raw attachments keep every network- and host-owned control,
losing only that redundant L2 layer. Instrument lesson kept as MU-5: the
probe's first cut minted the non-cloud image — no DHCP client, no lease,
and a dead NIC passes every negative probe vacuously; it now requires the
lease before believing its own answers.

Rehearsal: 54/54 (containers). test/cli.sh: 82 checks.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-18 06:41:05 +00:00
dan-claude-bot
565e7693e8 docs: log MU-4 (the CI job's version-drift catch) and refresh the counts
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-18 05:38:04 +00:00
dan-claude-bot
2097982ac2 docs: the restricted tier — README, design doc, plan doc with measured results (#74)
The plan doc records what was measured and why each decision fell where it
did: the private bridge is worse than unhardened (a live NAT bridge with
IPv6 on), incus-user blocks snapshots, no daemon-level template exists (read
in incus-user's source), widening survives re-sync (same source, then
measured live). box-design gains the access-tiers section — including why
narrowing to boxnet-only is the load-bearing decision and why the nft bridge
drop is the layer a restricted user cannot strip. RUNS.md logs MU-1..3.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-18 04:09:48 +00:00
dan-claude-bot
9849021b6a feat: global install (#71) and tmux in templates (#65)
#71 — run as root, box installs once to /opt/box (world-readable) with a
/usr/local/bin shim, so every operator on a shared host has `box` on PATH
instead of `command not found`. Per-user ($HOME/.local) install is
byte-identical to before; BOX_HOME/BOX_BIN still override. A per-user tree
under /root is 0700 and unreadable to everyone else — the whole fleet got
`command not found` — so the root branch lands in a system location and
chmod -R a+rX's it, guarded on root. This is the path rig installs box through
(rig#24's `box` role).

#65 — `box tmux` runs `tmux new-session` inside the box, but the templates did
not install tmux, so it failed with `tmux: command not found`. tmux is now in
each template's cloud-init package list.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-18 00:01:15 +00:00
claude-bot-andresmgsl
d0a14a18a5 Redesign install flow: confirm, no-op if installed, opt-in host setup
Implements the flow @danmt specified on #66. The installer now asks before it
acts, and never overwrites itself.

  1. "Install box?" — prompted before anything is downloaded.
  2. If box is already installed: say so and stop. A re-run changes nothing, so
     curl|bash can no longer clobber a working tree or rebuild the host stack
     under live boxes. Upgrading is explicit — uninstall, then install fresh.
  3. On a fresh host: download, install, link onto PATH.
  4. "Set up this machine as a box host now?" — a separate decision, because the
     CLI and the host are not the same choice (you may host boxes elsewhere).

This replaces the version-diff refusal from the previous round with the simpler
rule Dan asked for: installed at all => no-op. It dissolves the same class of
"the upgrade ate my boxes" errors without the installer having to reason about
versions or enumerate boxes at all — you cannot lose boxes to an install that
refuses to touch an existing one.

Prompts read /dev/tty, because under curl|bash the script itself is stdin and a
plain read would eat the installer's own remaining lines. With no terminal
(CI, a pipe) BOX_YES=1 assumes yes and is required to proceed unattended;
without it we refuse rather than invent consent. BOX_SKIP_SETUP_HOST=1 declines
the second prompt.

The drill uninstalls before installing (the no-op rule would otherwise refuse
to re-lay the tree it re-proves each run) and sets BOX_YES=1 for the prompts;
BOX_FORCE_UPGRADE is gone with the refusal it drove.

Verified on a real host: cancel, fresh install, no-op re-run, and both prompts
driven through a pty (y/n and y-then-n), plus the no-tty refusal.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-17 16:10:34 +00:00
claude-bot-andresmgsl
043e556027 Give box-firewall.service RemainAfterExit so its state means something
Found by running the drill on a real host, which I could not do before.

The unit is Type=oneshot with no RemainAfterExit, so systemd marks it
'inactive (dead)' the moment ExecStart succeeds. The rules are applied and the
box-to-box drop is live, and the unit still reads as though it died. That is
precisely the question people ask this unit: drill.sh's own failure hint sends
you to 'systemctl status box-firewall.service' to find out whether the firewall
came up, and today the honest answer and the alarming one look identical.

setup-host.sh already believed this was set — 'The unit is RemainAfterExit, so
once it has run it stays "active" forever' — and reasoned from it to explain why
it uses restart instead of 'enable --now'. The reasoning is right and the
restart is right; only the unit was missing the line the comment assumed.

Verified live: before, 'nft list table bridge box' showed the drop present while
is-active said inactive. After, is-active says active (exited) with the drop
still present, and restart still re-applies.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-17 14:05:23 +00:00
claude-bot-andresmgsl
3e1143f1d3 Refuse to change versions under existing boxes; file the migration as #67
Per @danmt on #66: hatch first, the version-aware migration as its own issue.

Building the host stack from the installer means an upgrade is no longer a tree
swap — it reaches under every box attached to that stack. So the installer now
declines to guess. Same version and ref: it says so and changes nothing.
Version or ref change with boxes on the host: it refuses, lists them, and does
so BEFORE $DEST is touched, so a refusal leaves the working install intact. No
boxes: nothing to lose, proceed. BOX_FORCE_UPGRADE=1 overrides, and the drill
sets it, because arriving on a dirty host and wiping it is the drill's job.

Ref, not just VERSION: a branch and main carry the same VERSION string, so
VERSION alone would call an install of this very branch "unchanged" and skip the
hatch. Both tag generations count as boxes — a pre-rename user.claudebox=1 box
is just as much someone's work as a current one.

The box query runs unprivileged first and escalates only if the socket refuses:
anyone who owns boxes is already in incus-admin, and an installer should not
demand a sudo password merely to look.

The error deliberately does not suggest snapshot -> rm -> restore --from: 'box
rm' deletes a box AND every snapshot it has, so that path loses the data at the
rm. It says to copy anything needed out of the box first. Raised on #67.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-17 13:34:53 +00:00
claude-bot-andresmgsl
ce3a0c5076 Make setup-host privilege-aware; make the drill prove the new contract
Review found two real problems, both confirmed by reproducing them.

setup-host hardcoded 'sudo' for every privileged call, so install.sh's
deliberate root branch — the one that proceeds when id -u is 0 even with no
sudo installed — handed off to a script that died on 'sudo: command not found'
before doing anything (exit 127, reproduced with env -i and a minimal PATH).
The root path was nominal, not real. Privilege is now resolved once: nothing at
UID 0, sudo otherwise, a clear error if neither is possible.

Two things fell out of that. Root does not need incus-admin at all (UID 0 opens
the socket regardless), so adding root to the group was a no-op that also missed
the human — under 'sudo install.sh' that is SUDO_USER, who is now the one
granted the group. And apt must not hang: install.sh runs setup-host with nobody
watching, while a fresh cloud image holds the dpkg lock in apt-daily for its
first minutes, so the calls are now bounded and non-interactive.

The drill did not exercise any of this. It ran setup-host immediately after
install.sh, so the stack existed by the drill's own hand and a run passed
identically whether or not install.sh had done a thing — a fresh run converged
three times while its messages still described the pre-#63 "first pass may only
add you to the group" behaviour. It now asserts the post-install stack in-group,
before the clean or anything else mutates the host, which is the assertion that
actually proves #64. setup-host then runs exactly once more, after the clean —
that one is load-bearing, since the clean deliberately unsets dns.mode and
something has to converge it back. DRILL_OWNS_SETUP=1 hands sequencing back to
the drill. Pre-setup tripwires now read before install.sh, because install.sh is
what triggers setup now; read afterwards they said nothing.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-17 13:16:06 +00:00
claude-bot-andresmgsl
aad576a86a Make host setup complete in one run, and let the installer run it
box setup-host stopped halfway when it had to add you to incus-admin: it
usermod'd, printed a NOTE telling you to re-login and re-run, and exited 0 —
a success-shaped no-op with no boxnet, no ACL, no box-net profile and no
firewall behind it. It now re-execs itself under 'sg incus-admin' and
finishes in that same invocation.

The membership check was also asking the wrong question. 'id -nG "$USER"'
names a user, so it reads the group database — which lists incus-admin the
instant usermod returns, while the shell's own credentials still lack it
(supplementary groups are fixed at login). A same-session re-run therefore
passed the check and died further down on a bare permission error from incus
that mentioned neither the group nor the re-login. Argless 'id -nG' asks the
process what it actually holds, which is what incus checks when it opens
/var/lib/incus/unix.socket.

With one run now sufficient, install.sh runs the setup itself instead of
printing a warning and leaving the user a command: the install reported
success and 'box new' then failed on a host with no Incus. setup-host is
idempotent, so doing this on every install is also how an upgraded host picks
up stack changes. BOX_SKIP_SETUP_HOST=1 opts out, and a failed setup leaves
the install standing and says what to re-run.

Fixes #63
Fixes #64

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-17 12:52:50 +00:00
42c1f63c95 fix(doctor): a fresh host is not a dirty one
A bare host got two DIRTYs (the box-to-box drop 'MISSING', the host's
Tailscale resolver) and a 'NOT fit to mint (or to drill)' verdict — and
the very next drill run went 84/84 green from that exact state. Missing
from a stack and never set up are different findings: the network,
profile and ACL sections already knew this; the firewall and resolver
sections now do too. FRESH (no boxnet) downgrades both to information —
the VPN resolver is still named, as a fact about the host that setup-host
pins around, not a fault in a stack that does not exist. The clean
verdict on a fresh host now says what to actually do: run setup-host, or
the drill, which sets the host up itself.

Verified both paths live: standing stack → 'clean', post-teardown →
'fresh' with no DIRTYs.

Also: the measured drill count is 84 (README said 83 — the box-info
exposure check was a NOTE when last counted and is a PASS now).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-15 00:55:10 +00:00
85e70a326a docs: add CHANGELOG, starting at 0.5.0
One entry for now — 0.5.0 as merged plus the inline resource flags that
fold into it. Pre-0.5.0 history stays in git and drill/RUNS.md, which
this points at.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-15 00:21:18 +00:00