From 0429a11020ef5f67284c0161b1acf3df3a31770b Mon Sep 17 00:00:00 2001 From: dan-claude-bot Date: Sat, 18 Jul 2026 04:09:13 +0000 Subject: [PATCH] =?UTF-8?q?feat:=20the=20restricted=20tier=20=E2=80=94=20b?= =?UTF-8?q?ox=20grant/revoke=20converge=20users=20onto=20the=20hardened=20?= =?UTF-8?q?boxnet=20(#74)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 # 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 # 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 --- bin/box | 91 +++++++++++++++++++++++++- drill/doctor.sh | 59 +++++++++++++++++ host/grant-user.sh | 154 ++++++++++++++++++++++++++++++++++++++++++++ host/revoke-user.sh | 124 +++++++++++++++++++++++++++++++++++ host/setup-host.sh | 32 +++++++++ 5 files changed, 458 insertions(+), 2 deletions(-) create mode 100644 host/grant-user.sh create mode 100644 host/revoke-user.sh diff --git a/bin/box b/bin/box index 5613ded..607f770 100755 --- a/bin/box +++ b/bin/box @@ -14,6 +14,24 @@ die() { echo "box: $*" >&2; exit 1; } # 1 = it went wrong usage_error() { echo "box: $*" >&2; echo "try 'box help'." >&2; exit 2; } # 2 = you asked wrong version() { echo "box $(cat "$root/VERSION" 2>/dev/null || echo unknown) ($root)"; } +# Which tier is THIS PROCESS? Decided from live credentials (argless 'id -nG': +# what the kernel will present when incus opens the socket), never from the +# group database — the two disagree for exactly as long as a re-login is +# pending, and that window is where every wrong answer lives. +# UID 0 / incus-admin -> admin (the full daemon socket) +# incus (only) -> restricted (incus-user: your own project, nothing else) +# neither -> none (no socket at all) +# host/setup-host.sh carries a byte-identical copy (it runs before any install +# tree exists); test/cli.sh diffs the two so they cannot drift. +box_tier() { + [ "$(id -u)" -eq 0 ] && { printf 'admin\n'; return; } + local groups; groups="$(id -nG 2>/dev/null | tr ' ' '\n')" + if printf '%s\n' "$groups" | grep -qx incus-admin; then printf 'admin\n' + elif printf '%s\n' "$groups" | grep -qx incus; then printf 'restricted\n' + else printf 'none\n' + fi +} + # --------------------------------------------------------------------------- # The command table. # @@ -46,7 +64,7 @@ CMDS=( "exec^ -- ^box^Run a command inside a box^fn:cmd_exec^" "tmux^ []^box^Attach or create a tmux session in a box — survives disconnects^fn:cmd_tmux^" "snapshot^ [