From f7b93a91cddb997bf9232dc87f19f0fbf217cbb0 Mon Sep 17 00:00:00 2001 From: dan-claude-bot Date: Sat, 18 Jul 2026 01:06:34 +0000 Subject: [PATCH 01/10] docs(plan): stake out restricted-tier PR for #74 (placeholder) Co-Authored-By: Claude Fable 5 --- docs/plans/2026-07-18-restricted-tier.md | 27 ++++++++++++++++++++++++ 1 file changed, 27 insertions(+) create mode 100644 docs/plans/2026-07-18-restricted-tier.md diff --git a/docs/plans/2026-07-18-restricted-tier.md b/docs/plans/2026-07-18-restricted-tier.md new file mode 100644 index 0000000..bade330 --- /dev/null +++ b/docs/plans/2026-07-18-restricted-tier.md @@ -0,0 +1,27 @@ +# Restricted incus tier — implementation plan (issue #74) + +> **Status: placeholder.** This PR is a work in progress; the design below is being +> validated by a live multi-user rehearsal before the implementation lands. Do not +> review yet — the PR stays in draft until the rehearsal passes. + +## Scope + +Deliver the restricted (`incus`-group) tier described in #74: + +- A restricted user can `box new/list/shell/snapshot/rm` **their own** boxes. +- Their boxes ride a network carrying box's full isolation contract + (ACL, `dns.mode=none`, resolver pin, `security.port_isolation`, nft box↔box drop). +- No cross-user visibility. Admin tier unchanged. +- The admin-side convergence is a documented, idempotent command — not manual + per-user `incus project set`. + +## Planned shape (subject to rehearsal) + +- `box grant ` / `box revoke ` — admin convergence hook per #74 + option 1: widen `restricted.networks.access` to include `boxnet` and install + the `box-net` profile into the user's `user-` project. +- CLI awareness of running inside a restricted project. +- `drill/multiuser.sh` rehearsal criteria (a)–(f) green on a real multi-user host. +- Test suite expansion + CI wiring. + +Tracking issue: heavy-duty/box#74. From 0429a11020ef5f67284c0161b1acf3df3a31770b Mon Sep 17 00:00:00 2001 From: dan-claude-bot Date: Sat, 18 Jul 2026 04:09:13 +0000 Subject: [PATCH 02/10] =?UTF-8?q?feat:=20the=20restricted=20tier=20?= =?UTF-8?q?=E2=80=94=20box=20grant/revoke=20converge=20users=20onto=20the?= =?UTF-8?q?=20hardened=20boxnet=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^ [