Commit graph

14 commits

Author SHA1 Message Date
dan-claude-bot
fee1929d10 feat(setup-host): auto-pick a free subnet — nested box-in-box, zero flags (#80)
#86 shipped the refuse half of #80's fix #1; this completes the other half:
"refuse … or automatically select a non-colliding subnet". The bare
setup-host that used to stop an agent mid-drill inside a box now decides
the subnet itself. choose_subnet is the one place that decision happens,
before any mutation — BOX_GW, the bridge, the ACL carve-out, the firewall
and the doctor's expectations all derive from its answer. Four deliberate
cases:

1. explicit BOX_SUBNET — honored or refused, never silently overridden:
   a script that says 10.90 gets 10.90 or a loud stop. The claimant
   refusal and the bridge agree-gate are unchanged in spirit.
2. no pin, boxnet exists — converge to the bridge's own subnet: the
   bridge IS the pin (boxes hold leases on it). The old agree-gate
   refusal on a bare re-run against a moved bridge becomes convergence,
   announced when the bridge is off-default, silent on the plain
   default-host re-run. A FOREIGN claimant on the bridge's own subnet
   still refuses — that is #80's poisoned state, and converging would
   rebuild the blackouts; the refusal names the bridge move instead.
3. no pin, no bridge, 10.88.0.0/24 free — the default, as always.
4. no pin, no bridge, default claimed — the nested case (a drill or
   rehearsal inside a box, whose own uplink owns 10.88): scan
   10.89.0.0/24 … 10.127.0.0/24 in order, take the first free candidate,
   announce the pick, the claimant and the BOX_SUBNET pin for scripts;
   refuse only when every candidate is claimed. The scan only ever runs
   bridge-less — an existing bridge is case 2, which precedes it.

Pure over ip (via subnet_claimant and the bridge read), so test/cli.sh
drives every case against canned tables with the shim ip.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-18 21:22:00 +00:00
dan-claude-bot
b429f5b65c feat(setup-host): refuse a claimed subnet, and BOX_SUBNET to move off one (#80)
Run inside a box, setup-host built a nested boxnet claiming the exact
subnet and gateway of the guest's own uplink: the guest then held
10.88.0.1 as a LOCAL address while its default route pointed at 10.88.0.1
as its GATEWAY, carried two connected routes for the subnet, and suffered
intermittent, self-recovering egress blackouts (~24-36 s, roughly hourly)
that no host-side check could attribute. The flagship use case funnels
agents toward exactly this — working on box, in a box — so the guard, not
the operator, has to catch it.

setup-host now scans the target subnet BEFORE any mutation: the default
route's gateway inside it (the smoking gun — that is this machine's own
uplink), or any non-boxnet interface holding an address in it, refuses
with the way out named (BOX_SUBNET). A prior boxnet owning the subnet is
the one claimant that is NOT a collision — the legitimate re-run — and an
existing bridge on a DIFFERENT subnet refuses too: setup-host converges a
bridge, it never re-addresses one under live leases.

BOX_SUBNET=<a.b.c.0/24> (validated at the gate, alongside BOX_DNS) moves
the whole stack from one place:
  · the bridge address derives (ipv4.address=<gw>/24)
  · the ACL's gateway carve-out derives — and the ruleset is now converged
    via 'network acl edit', not created once: the #80 escape hatch (moving
    the bridge) left the old /32 behind, stranding box DNS inside the
    10.0.0.0/8 drop, which presents as a dead resolver, never as a stale
    ACL
  · box-firewall reads the gateway off the live bridge ('|| true' because
    under pipefail an absent bridge would kill the script instead of
    answering "no bridge yet")
  · the drill, multiuser rehearsal and migrate-host probes derive the
    prefix from 'incus network get boxnet ipv4.address' instead of
    hardcoding 10.88 — a BOX_SUBNET host must not fail its own rehearsals

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-18 19:46:58 +00:00
dan-claude-bot
0429a11020 feat: the restricted tier — box grant/revoke converge users onto the hardened boxnet (#74)
incus-user confines an incus-group user to their own project, but its
defaults miss box's contract three measured ways (Debian 13 / Incus 6.0.4):
a private UNHARDENED NAT bridge per user (ipv6.nat=true, no ACL, no DNS
isolation), snapshots blocked, and the box-net profile invisible to their
project. So the tier is an admin-run idempotent convergence:

  box grant <user>   # incus group; touch incus-user (the project is lazy);
                     # drop the private-bridge eth0 from their default
                     # profile; restricted.networks.access=boxnet — and ONLY
                     # boxnet, or the unhardened bridge stays one --network
                     # flag away; restricted.snapshots=allow; install the
                     # shipped box-net profile into their project
  box revoke <user>  # group removal closes the socket, boxes keep running
         --purge     # ...or delete their world, and assert the absence

box_tier() (live credentials, argless id -nG; byte-identical copy in
setup-host.sh) drives the tier-aware surface: new pre-flights the profile
and names the right fix per tier, expose refuses before any daemon call
(without the guard the failure is a lie — restricted certs cannot read
boxnet's redacted config, so box_net_ip claims a running box has no
address), setup-host exits 0 with the honest note, doctor judges only what
the caller can see.

Also fixed while the rehearsal exercised the lifecycle: box restore
dispatched 'incus restore', which does not exist in Incus 6 (it is
'incus snapshot restore') — the verb had never worked. Fixed for every tier.

Convergence survives incus-user restarts by that tool's own design (it
configures a project only at creation) — read in its source, then measured.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-18 04:09:48 +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
claude-hdb
5defd40bca feat!: rename the host stack too, default to blank, drill the templates, add wipe
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.
2026-07-14 14:36:39 +00:00
claude-hdb
c11f3d7552 feat!: claudebox becomes box — the Claude box is one template among several
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
2026-07-14 14:22:50 +00:00
claude-hdb
c0611e5f0b fix: create the storage pool deliberately — btrfs, because cloning is the whole point
'incus admin init --minimal' picks the 'dir' backend, which has no
copy-on-write: every snapshot and clone is a full copy of the box's
root — several GB and minutes apiece once Docker, Node and Claude Code
are installed. Measured live: one clone took minutes, and a single
drill run pays that three times (snapshot + two clones). The workflow
the tool exists for — log in once, snapshot, clone forever — stops
being attractive at exactly that price.

setup-host.sh now bootstraps via a preseed that mirrors what --minimal
creates (default pool, incusbr0, default profile with root + eth0) with
only the driver deliberate: btrfs on a loop device (CoW — clones share
blocks and diverge on write), installing btrfs-progs if absent, falling
back to dir with a warning if btrfs cannot be had. Existing hosts are
untouched: the block is skipped whenever a 'default' pool exists.

Closes #29
2026-07-14 13:14:26 +00:00
claude-hdb
a2c0758ba6 fix: pin claudenet's resolver — a box's DNS is not a function of the host's VPN
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
2026-07-14 12:15:45 +00:00
a0400606c2 fix(doctor): read the isolation off the bridge, not off the config
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>
2026-07-14 02:16:57 +00:00
bd849181bd fix: the firewall unit never re-ran, so new rules were never applied
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>
2026-07-14 01:40:02 +00:00
dbefd66dd5 fix: boxes could reach each other — isolate them at the bridge
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>
2026-07-14 01:29:33 +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
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