box/host/grant-user.sh

351 lines
20 KiB
Bash
Raw Normal View History

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:13 +00:00
#!/usr/bin/env bash
# box grant <user> — give a host user the restricted tier (#74).
#
# The tier rides incus-user: the user lands in an auto-created project
# user-<uid> and can only ever see their own instances. What incus-user does
# NOT do is put them on box's hardened network — it auto-creates a private
# bridge incusbr-<uid> (a plain NAT bridge: no ACL, no DNS isolation, IPv6 on,
# none of box's contract) and pins the project to it. Measured on Debian 13 /
# Incus 6.0.4; the full write-up is in docs/plans/2026-07-18-restricted-tier.md.
#
# So granting is a per-user CONVERGENCE, and it must be run by an admin:
fix: grant the 'incus' membership to incus-admin members too (#101 review) The previous revision skipped `usermod -aG incus` for an incus-admin member, reasoning that 'incus' is a strict subset of what incus-admin already opens. That is true of the daemon API and false of the filesystem. On Debian 13 / Incus 6.0.4 the two sockets are two files with two owning groups: /var/lib/incus/unix.socket group incus-admin 0660 /var/lib/incus/unix.socket.user group incus 0660 incus-admin opens the first and not the second, and only the second provisions a user-<uid> project. So for the incus-admin-ONLY user — the canonical #99 case — the pinned provisioning touch took EACCES, the `|| true` swallowed it, no project appeared, and the grant died blaming a healthy incus-user. Both reviewers converged on this independently and were right. The membership is now granted for everyone, with output carrying the concern the old no-op was built around (it is the key to a file, not a privilege; box_tier still reads them as admin). Everything downstream moves with it: - the backout rolls that membership back and verified, while refusing to call the rollback a lockout — incus-admin is untouched and still opens the host - revoke's bare path takes the membership back and reports `partial:` instead of "no-op, nothing was taken", still declining to call them "out" - grant's closing "gpasswd -d <user> incus-admin (no re-grant needed)" is now a true promise: they keep 'incus', so the drop lands them in their project - the socket existence probe goes through $SUDO, matching revoke's measured discipline about /var/lib/incus lying to a non-root admin Tests: the cli.sh assertions that encoded the old no-op design are flipped and the decision is pinned at the seam that broke; the sudo shim now runs `test` for real in both directions. Because the shims model neither INCUS_SOCKET nor permissions and so cannot reproduce the EACCES, drill/multiuser.sh gains criterion (o): an incus-admin-only member granted on real Incus in CI, with the membership, the project, a live connect() to unix.socket.user, and the post-drop landing all measured. Mutation-checked: 11 of the new/flipped assertions fail against the previous implementation. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-19 16:50:42 +00:00
# 1. put the user in the 'incus' group (not incus-admin — that is the tier).
# An incus-admin member goes in too, and NOT for privilege: incus-user's
# socket is a FILE, group 'incus', mode 0660, so the membership is the
# only thing that lets step 2 connect() at all (#99, #101 review)
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:13 +00:00
# 2. touch incus-user AS the user, so the lazy project exists to converge
# 3. unpin the private bridge (drop eth0 from the project's default profile)
# 4. restrict the project's network access to boxnet and ONLY boxnet —
# "boxnet,incusbr-<uid>" would leave an unhardened NAT bridge one
# '--network' flag away from any box they mint
# 5. allow snapshots (incus-user blocks them; box's clone workflow is built
# on them)
feat: box export / import — state that survives the box and the host (#70) 'box rm' deletes a box and every snapshot it has; 'box new --from' clones, but the clone still lives on the same host. Nothing a box held could outlive a teardown — which made #66's upgrade refusal honest but lossy. This adds the way out and the way back: - box export <box> [<file>] [--instance-only]: wraps 'incus export' into one portable backup tarball (default <box>-<UTC stamp>.tar.gz), snapshots included by default. Requires the box stopped (require_stopped grew an honest reason parameter: export is down by OUR decision, not incus's). Credentials are SHOUTED, not scrubbed — the artifact carries the box's whole disk, and scrubbing a disk image is a promise tarball surgery cannot keep. - box import <file> [--name <box>]: reads the artifact's name from backup/index.yaml up front, refuses any name an existing instance holds (the resolve_box boundary from the other side), pre-flights the stack (require_stack, factored out of cmd_new), imports, then re-stamps the HOST's truth onto the artifact's: user.box=1 (legacy tag honored), the box-net placement (profile assign, the migrate-host move), fresh volatile MACs (imports restore volatile.* verbatim — a re-import beside its sibling collided at start with 'MAC address already defined on another NIC', measured live on Incus 6.0.4), and reset_identity, exactly like a clone. - restricted tier: box grant now converges restricted.backups allow — export rides the backup API, which incus-user's restricted projects block by default exactly like snapshots (incus 6.0 permissions.go, AllowBackupCreation). Import is plain instance creation and needs no key. - tests: driven usage errors + fail-closed grep/line-order guards for every daemon-gated invariant; CI's rehearsal job now runs a live round-trip (mint, write, snapshot, down, export, rm, import, assert the file, the snapshot, the tag, the agent, and the collision refusal). The whole flow was verified against a live Incus 6.0.4 daemon: running-box refusal, export, overwrite guard, rm, import with and without --name, re-home onto box-net, sibling re-import with distinct MACs and machine-ids, pre-export file and snapshot present in both. Closes #70 Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-18 15:10:12 +00:00
# 6. allow backups (blocked too; 'box export' rides the backup API — #70)
# 7. install the shipped box-net profile into their project
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:13 +00:00
#
# Idempotent: every step converges, so re-running (including after a box
# upgrade, to refresh the profile) is safe. incus-user never rewrites a
# project it already created (verified against its source: setup is skipped
# once the project exists and the user's certificate is trusted), so nothing
# here is fighting a re-sync.
set -euo pipefail
self="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/$(basename "${BASH_SOURCE[0]}")"
here="$(dirname "$(dirname "$self")")"
usage() { echo "usage: box grant <user>" >&2; exit 2; }
[ $# -eq 1 ] || usage
user="$1"
case "$user" in -*) usage ;; esac
# Root, or sudo — same decision, same reasons as setup-host.sh: granting
# needs usermod and a run-as-the-user touch, both root's to give.
if [ "$(id -u)" -eq 0 ]; then
SUDO=""
elif command -v sudo >/dev/null 2>&1; then
SUDO="sudo"
else
echo "ERROR: box grant needs root and 'sudo' was not found." >&2
echo " re-run as root: $self $user" >&2
exit 1
fi
# Run a command as the granted user. 'runuser' when we are root (sudo may not
# exist there), sudo -u otherwise. -H so incus's client state lands in THEIR
# home, not the admin's. stdin pinned: an incus client with a terminal on
# stdin can go interactive and wedge a script that will never answer it.
run_as() {
local u="$1"; shift
if [ -n "$SUDO" ]; then $SUDO -u "$u" -H -- "$@" </dev/null
else runuser -u "$u" -- "$@" </dev/null
fi
}
getent passwd "$user" >/dev/null || { echo "box grant: no such user: $user" >&2; exit 1; }
uid="$(id -u "$user")"
[ "$uid" -eq 0 ] && { echo "box grant: root does not need a tier — UID 0 owns the daemon socket outright." >&2; exit 1; }
fix: box grant provisions incus-admin members instead of refusing them The refusal at host/grant-user.sh conflated permission with provisioning. The 'incus' group is a strict subset of what incus-admin opens — true, and the whole of what the refusal reasoned about. The user-<uid> project, the boxnet narrowing, the snapshot and backup allowances and the box-net profile installed into that project are not permissions, and an incus-admin member had none of them: box_tier() resolves them to admin, so they worked in the shared default project with no world of their own, and the one command that provisions one refused to run for them. box grant now converges them fully. The group step is a reported no-op — adding 'incus' would grant nothing and leave a group list implying a restriction that was never in force — and steps 2-5 run unchanged. The incus-user touch is pinned at incus-user's socket, which this turns out to require: the incus client picks its socket by writability, so for an incus-admin member an unpinned client sails past incus-user entirely and the project is never created. The user-side proof names their project for the same reason. On success it prints the caveat the hard exit was gesturing at: the restrictions are a default placement, not a confinement, and their own commands keep landing in the default project until incus-admin goes. The backout learns the third case (nothing added, nothing rolled back, still loud), and box revoke mirrors the whole thing rather than claiming a lockout it did not perform. Unblocks heavy-duty/rig#49. Closes #99 Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-19 16:18:46 +00:00
# An incus-admin member gets the full convergence anyway (#99). This used to
# be a hard refusal, on the reasoning that admin membership wins at the socket
# so nothing here could restrict them. True — and beside the point, because it
# conflates the two separate things a grant hands over:
fix: grant the 'incus' membership to incus-admin members too (#101 review) The previous revision skipped `usermod -aG incus` for an incus-admin member, reasoning that 'incus' is a strict subset of what incus-admin already opens. That is true of the daemon API and false of the filesystem. On Debian 13 / Incus 6.0.4 the two sockets are two files with two owning groups: /var/lib/incus/unix.socket group incus-admin 0660 /var/lib/incus/unix.socket.user group incus 0660 incus-admin opens the first and not the second, and only the second provisions a user-<uid> project. So for the incus-admin-ONLY user — the canonical #99 case — the pinned provisioning touch took EACCES, the `|| true` swallowed it, no project appeared, and the grant died blaming a healthy incus-user. Both reviewers converged on this independently and were right. The membership is now granted for everyone, with output carrying the concern the old no-op was built around (it is the key to a file, not a privilege; box_tier still reads them as admin). Everything downstream moves with it: - the backout rolls that membership back and verified, while refusing to call the rollback a lockout — incus-admin is untouched and still opens the host - revoke's bare path takes the membership back and reports `partial:` instead of "no-op, nothing was taken", still declining to call them "out" - grant's closing "gpasswd -d <user> incus-admin (no re-grant needed)" is now a true promise: they keep 'incus', so the drop lands them in their project - the socket existence probe goes through $SUDO, matching revoke's measured discipline about /var/lib/incus lying to a non-root admin Tests: the cli.sh assertions that encoded the old no-op design are flipped and the decision is pinned at the seam that broke; the sudo shim now runs `test` for real in both directions. Because the shims model neither INCUS_SOCKET nor permissions and so cannot reproduce the EACCES, drill/multiuser.sh gains criterion (o): an incus-admin-only member granted on real Incus in CI, with the membership, the project, a live connect() to unix.socket.user, and the post-drop landing all measured. Mutation-checked: 11 of the new/flipped assertions fail against the previous implementation. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-19 16:50:42 +00:00
# · PERMISSION — the 'incus' group. At the DAEMON API they already hold
# strictly more through incus-admin, so this group adds no privilege. It
# is still required, because the two sockets are two FILES with two
# different owning groups (Debian 13 / Incus 6.0.4, measured):
# /var/lib/incus/unix.socket group incus-admin 0660
# /var/lib/incus/unix.socket.user group incus 0660
# incus-admin opens the first and not the second, and the second is the
# only one that provisions a user-<uid> project. An earlier revision of
# this script skipped the usermod for an admin member, reasoning that
# 'incus' is a subset of incus-admin — true of the API, false of the
# filesystem: the pinned touch below took EACCES, the '|| true' swallowed
# it, no project appeared, and the grant died blaming a healthy
# incus-user. So the group step is a real convergence for everyone.
fix: box grant provisions incus-admin members instead of refusing them The refusal at host/grant-user.sh conflated permission with provisioning. The 'incus' group is a strict subset of what incus-admin opens — true, and the whole of what the refusal reasoned about. The user-<uid> project, the boxnet narrowing, the snapshot and backup allowances and the box-net profile installed into that project are not permissions, and an incus-admin member had none of them: box_tier() resolves them to admin, so they worked in the shared default project with no world of their own, and the one command that provisions one refused to run for them. box grant now converges them fully. The group step is a reported no-op — adding 'incus' would grant nothing and leave a group list implying a restriction that was never in force — and steps 2-5 run unchanged. The incus-user touch is pinned at incus-user's socket, which this turns out to require: the incus client picks its socket by writability, so for an incus-admin member an unpinned client sails past incus-user entirely and the project is never created. The user-side proof names their project for the same reason. On success it prints the caveat the hard exit was gesturing at: the restrictions are a default placement, not a confinement, and their own commands keep landing in the default project until incus-admin goes. The backout learns the third case (nothing added, nothing rolled back, still loud), and box revoke mirrors the whole thing rather than claiming a lockout it did not perform. Unblocks heavy-duty/rig#49. Closes #99 Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-19 16:18:46 +00:00
# · PROVISIONING — the user-<uid> project, the boxnet narrowing, the
# snapshot and backup allowances, the box-net profile installed INTO that
# project. An incus-admin member has none of it: box_tier() resolves them
# to 'admin' (bin/box), so they work in the SHARED default project next to
# root and every other admin, with no world of their own. This script is
# the only thing that provisions one, and refusing left them unable to get
# it without first being taken out of incus-admin.
# So provision, and say plainly at the end what the provisioning does not do.
admin_member=0
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:13 +00:00
if id -nG "$user" | tr ' ' '\n' | grep -qx incus-admin; then
fix: box grant provisions incus-admin members instead of refusing them The refusal at host/grant-user.sh conflated permission with provisioning. The 'incus' group is a strict subset of what incus-admin opens — true, and the whole of what the refusal reasoned about. The user-<uid> project, the boxnet narrowing, the snapshot and backup allowances and the box-net profile installed into that project are not permissions, and an incus-admin member had none of them: box_tier() resolves them to admin, so they worked in the shared default project with no world of their own, and the one command that provisions one refused to run for them. box grant now converges them fully. The group step is a reported no-op — adding 'incus' would grant nothing and leave a group list implying a restriction that was never in force — and steps 2-5 run unchanged. The incus-user touch is pinned at incus-user's socket, which this turns out to require: the incus client picks its socket by writability, so for an incus-admin member an unpinned client sails past incus-user entirely and the project is never created. The user-side proof names their project for the same reason. On success it prints the caveat the hard exit was gesturing at: the restrictions are a default placement, not a confinement, and their own commands keep landing in the default project until incus-admin goes. The backout learns the third case (nothing added, nothing rolled back, still loud), and box revoke mirrors the whole thing rather than claiming a lockout it did not perform. Unblocks heavy-duty/rig#49. Closes #99 Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-19 16:18:46 +00:00
admin_member=1
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:13 +00:00
fi
# The stack the tier converges ONTO must exist first. Checked via the daemon,
# not config files: setup-host is the only thing that builds boxnet.
incus network show boxnet >/dev/null 2>&1 </dev/null \
|| { echo "box grant: no boxnet on this host — build the stack first: box setup-host" >&2; exit 1; }
# incus-user is the mechanism under the whole tier. Debian 13 and Ubuntu 24.04
# ship it in the incus package; a host without it cannot hold this tier at all.
if ! systemctl is-active --quiet incus-user.socket; then
$SUDO systemctl enable --now incus-user.socket 2>/dev/null \
|| { echo "box grant: incus-user.socket is not available — this Incus cannot serve the restricted tier (see #74)." >&2; exit 1; }
fi
# 1. The group. 'incus' is the restricted socket; membership takes effect at
# the user's next login, but run_as below starts a fresh process with the
# database's groups, so the grant itself never waits on a re-login.
revoke/grant: survive the live-session case — the review's one real hole (#74) Supplementary groups are read at LOGIN, so 'gpasswd -d' does nothing to a session the user already holds — and after --purge, a stale-group process could touch incus-user and lazily RECREATE the project with stock defaults: the unhardened NAT bridge, un-narrowed, strictly worse than the granted state. Adversarial review caught it; verified live, then closed: - revoke --purge terminates the user's sessions first (loginctl, then pkill), and refuses to purge under processes it cannot kill - bare revoke says out loud that held sessions keep the socket until they end, and names the loginctl command — instead of claiming a lockout it did not deliver (help/README/design doc reworded to match) - a failed grant backs out its own group-add on exit (trap, disarmed on success): no half-granted user holding an un-narrowed socket while the admin reads the error. Verified by injecting a bad profile YAML - the rehearsal now holds a session open across the purge and demands it dies with the tier (criterion l, 42nd check) Smaller review findings, same pass: the escape-hatch probes assert the refusal's REASON instead of any nonzero exit (an image hiccup must not read as 'the escape is closed'); probe_from maps an outer-timeout kill to dropped, not reachable; the rehearsal cleanup keeps the account when a purge fails so doctor can name the leftovers; the purge asserts the trust certificate's absence; cmd_new distinguishes a dead daemon from a missing stack before prescribing setup-host; grant's success message names the user-<uid> bridge variant correctly on big-uid hosts. Rehearsal after: 42/42 (containers). test/cli.sh: 76 checks. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-18 05:11:20 +00:00
#
# If THIS run granted the group and a later step fails, take it back on the
# way out: a half-granted user would otherwise hold live socket access to an
# UN-NARROWED project — the stock unhardened bridge attachable — until an
# admin re-runs. Backing out the group closes that window completely for a
# fresh grant (their existing sessions predate the membership, so no process
# holds it yet). A user who was already in the group keeps it: not ours to
fix: grant the 'incus' membership to incus-admin members too (#101 review) The previous revision skipped `usermod -aG incus` for an incus-admin member, reasoning that 'incus' is a strict subset of what incus-admin already opens. That is true of the daemon API and false of the filesystem. On Debian 13 / Incus 6.0.4 the two sockets are two files with two owning groups: /var/lib/incus/unix.socket group incus-admin 0660 /var/lib/incus/unix.socket.user group incus 0660 incus-admin opens the first and not the second, and only the second provisions a user-<uid> project. So for the incus-admin-ONLY user — the canonical #99 case — the pinned provisioning touch took EACCES, the `|| true` swallowed it, no project appeared, and the grant died blaming a healthy incus-user. Both reviewers converged on this independently and were right. The membership is now granted for everyone, with output carrying the concern the old no-op was built around (it is the key to a file, not a privilege; box_tier still reads them as admin). Everything downstream moves with it: - the backout rolls that membership back and verified, while refusing to call the rollback a lockout — incus-admin is untouched and still opens the host - revoke's bare path takes the membership back and reports `partial:` instead of "no-op, nothing was taken", still declining to call them "out" - grant's closing "gpasswd -d <user> incus-admin (no re-grant needed)" is now a true promise: they keep 'incus', so the drop lands them in their project - the socket existence probe goes through $SUDO, matching revoke's measured discipline about /var/lib/incus lying to a non-root admin Tests: the cli.sh assertions that encoded the old no-op design are flipped and the decision is pinned at the seam that broke; the sudo shim now runs `test` for real in both directions. Because the shims model neither INCUS_SOCKET nor permissions and so cannot reproduce the EACCES, drill/multiuser.sh gains criterion (o): an incus-admin-only member granted on real Incus in CI, with the membership, the project, a live connect() to unix.socket.user, and the post-drop landing all measured. Mutation-checked: 11 of the new/flipped assertions fail against the previous implementation. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-19 16:50:42 +00:00
# take on a re-run's failure. An incus-admin member now takes those same two
# paths (#101): the membership IS added for them and so IS backed out, with
# one thing extra to say either way — the rollback closes incus-user's socket
# and never their daemon access, which outlives this failure by a route the
# script never granted and must not pretend to control.
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
added_group=0; was_member=0
revoke/grant: survive the live-session case — the review's one real hole (#74) Supplementary groups are read at LOGIN, so 'gpasswd -d' does nothing to a session the user already holds — and after --purge, a stale-group process could touch incus-user and lazily RECREATE the project with stock defaults: the unhardened NAT bridge, un-narrowed, strictly worse than the granted state. Adversarial review caught it; verified live, then closed: - revoke --purge terminates the user's sessions first (loginctl, then pkill), and refuses to purge under processes it cannot kill - bare revoke says out loud that held sessions keep the socket until they end, and names the loginctl command — instead of claiming a lockout it did not deliver (help/README/design doc reworded to match) - a failed grant backs out its own group-add on exit (trap, disarmed on success): no half-granted user holding an un-narrowed socket while the admin reads the error. Verified by injecting a bad profile YAML - the rehearsal now holds a session open across the purge and demands it dies with the tier (criterion l, 42nd check) Smaller review findings, same pass: the escape-hatch probes assert the refusal's REASON instead of any nonzero exit (an image hiccup must not read as 'the escape is closed'); probe_from maps an outer-timeout kill to dropped, not reachable; the rehearsal cleanup keeps the account when a purge fails so doctor can name the leftovers; the purge asserts the trust certificate's absence; cmd_new distinguishes a dead daemon from a missing stack before prescribing setup-host; grant's success message names the user-<uid> bridge variant correctly on big-uid hosts. Rehearsal after: 42/42 (containers). test/cli.sh: 76 checks. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-18 05:11:20 +00:00
backout() {
if [ "$added_group" -eq 1 ]; then
$SUDO gpasswd -d "$user" incus >/dev/null 2>&1 || true
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
# VERIFY the removal — an unverified rollback printing a security
# guarantee is a lie waiting for its day. Exact-token match, live DB.
if id -nG "$user" 2>/dev/null | tr ' ' '\n' | grep -qx incus; then
echo "box grant: ROLLBACK INCOMPLETE — the grant failed AND $user is still in the 'incus' group." >&2
echo " remove it by hand NOW: gpasswd -d $user incus (then fix the cause and re-run)" >&2
exit 1
fi
echo "box grant: FAILED — removed $user from 'incus' again (verified against the group database); fix the cause and re-run" >&2
fix: grant the 'incus' membership to incus-admin members too (#101 review) The previous revision skipped `usermod -aG incus` for an incus-admin member, reasoning that 'incus' is a strict subset of what incus-admin already opens. That is true of the daemon API and false of the filesystem. On Debian 13 / Incus 6.0.4 the two sockets are two files with two owning groups: /var/lib/incus/unix.socket group incus-admin 0660 /var/lib/incus/unix.socket.user group incus 0660 incus-admin opens the first and not the second, and only the second provisions a user-<uid> project. So for the incus-admin-ONLY user — the canonical #99 case — the pinned provisioning touch took EACCES, the `|| true` swallowed it, no project appeared, and the grant died blaming a healthy incus-user. Both reviewers converged on this independently and were right. The membership is now granted for everyone, with output carrying the concern the old no-op was built around (it is the key to a file, not a privilege; box_tier still reads them as admin). Everything downstream moves with it: - the backout rolls that membership back and verified, while refusing to call the rollback a lockout — incus-admin is untouched and still opens the host - revoke's bare path takes the membership back and reports `partial:` instead of "no-op, nothing was taken", still declining to call them "out" - grant's closing "gpasswd -d <user> incus-admin (no re-grant needed)" is now a true promise: they keep 'incus', so the drop lands them in their project - the socket existence probe goes through $SUDO, matching revoke's measured discipline about /var/lib/incus lying to a non-root admin Tests: the cli.sh assertions that encoded the old no-op design are flipped and the decision is pinned at the seam that broke; the sudo shim now runs `test` for real in both directions. Because the shims model neither INCUS_SOCKET nor permissions and so cannot reproduce the EACCES, drill/multiuser.sh gains criterion (o): an incus-admin-only member granted on real Incus in CI, with the membership, the project, a live connect() to unix.socket.user, and the post-drop landing all measured. Mutation-checked: 11 of the new/flipped assertions fail against the previous implementation. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-19 16:50:42 +00:00
if [ "$admin_member" -eq 1 ]; then
# The rollback is real and verified, but for an admin member it is not a
# lockout and must not read as one: what came back was incus-user's
# socket key, not the daemon. Say what survives, and what would end it.
echo "box grant: NOTE — that rollback closed incus-user's socket, NOT $user's access." >&2
echo " They keep full admin socket access throughout via 'incus-admin', which this run" >&2
echo " neither granted nor removed, so every project on this host stays open to them." >&2
echo " Their project may be part-converged; a re-run converges the rest. To close their" >&2
echo " access you must take the admin group itself: gpasswd -d $user incus-admin" >&2
fi
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
# The one window the database cannot close: a login STARTED between our
# usermod and this backout keeps the group in its session credentials.
# For a fresh grant that is a rare race, but rare is not never — name it
# and the remedy instead of overclaiming.
if pgrep -u "$user" >/dev/null 2>&1; then
echo "box grant: NOTE — $user has live processes; a session begun during this grant would still hold" >&2
echo " the group until it ends: sudo loginctl terminate-user $user" >&2
fi
elif [ "$was_member" -eq 1 ]; then
# A user who was ALREADY in the group keeps it — stripping a membership
# this run did not add could break a working user over a failed re-run.
# But silence here would leave them holding a socket onto part-converged
# policy without the admin being told. Loud, with both remediations.
echo "box grant: FAILED with $user still holding socket access (their membership predates this run)." >&2
echo " their project may be part-converged — harmless in itself, and a re-run converges the rest." >&2
echo " if their access is not acceptable while you fix the cause: box revoke $user" >&2
fix: grant the 'incus' membership to incus-admin members too (#101 review) The previous revision skipped `usermod -aG incus` for an incus-admin member, reasoning that 'incus' is a strict subset of what incus-admin already opens. That is true of the daemon API and false of the filesystem. On Debian 13 / Incus 6.0.4 the two sockets are two files with two owning groups: /var/lib/incus/unix.socket group incus-admin 0660 /var/lib/incus/unix.socket.user group incus 0660 incus-admin opens the first and not the second, and only the second provisions a user-<uid> project. So for the incus-admin-ONLY user — the canonical #99 case — the pinned provisioning touch took EACCES, the `|| true` swallowed it, no project appeared, and the grant died blaming a healthy incus-user. Both reviewers converged on this independently and were right. The membership is now granted for everyone, with output carrying the concern the old no-op was built around (it is the key to a file, not a privilege; box_tier still reads them as admin). Everything downstream moves with it: - the backout rolls that membership back and verified, while refusing to call the rollback a lockout — incus-admin is untouched and still opens the host - revoke's bare path takes the membership back and reports `partial:` instead of "no-op, nothing was taken", still declining to call them "out" - grant's closing "gpasswd -d <user> incus-admin (no re-grant needed)" is now a true promise: they keep 'incus', so the drop lands them in their project - the socket existence probe goes through $SUDO, matching revoke's measured discipline about /var/lib/incus lying to a non-root admin Tests: the cli.sh assertions that encoded the old no-op design are flipped and the decision is pinned at the seam that broke; the sudo shim now runs `test` for real in both directions. Because the shims model neither INCUS_SOCKET nor permissions and so cannot reproduce the EACCES, drill/multiuser.sh gains criterion (o): an incus-admin-only member granted on real Incus in CI, with the membership, the project, a live connect() to unix.socket.user, and the post-drop landing all measured. Mutation-checked: 11 of the new/flipped assertions fail against the previous implementation. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-19 16:50:42 +00:00
if [ "$admin_member" -eq 1 ]; then
# Same correction as above, for the member who was in BOTH groups before
# this run: 'box revoke' takes the 'incus' key back, and still leaves
# them the whole daemon.
echo "box grant: NOTE — $user is also in 'incus-admin', which this run neither granted nor removed:" >&2
echo " 'box revoke' takes back incus-user's socket key and nothing more. To close their" >&2
echo " access: gpasswd -d $user incus-admin" >&2
fi
revoke/grant: survive the live-session case — the review's one real hole (#74) Supplementary groups are read at LOGIN, so 'gpasswd -d' does nothing to a session the user already holds — and after --purge, a stale-group process could touch incus-user and lazily RECREATE the project with stock defaults: the unhardened NAT bridge, un-narrowed, strictly worse than the granted state. Adversarial review caught it; verified live, then closed: - revoke --purge terminates the user's sessions first (loginctl, then pkill), and refuses to purge under processes it cannot kill - bare revoke says out loud that held sessions keep the socket until they end, and names the loginctl command — instead of claiming a lockout it did not deliver (help/README/design doc reworded to match) - a failed grant backs out its own group-add on exit (trap, disarmed on success): no half-granted user holding an un-narrowed socket while the admin reads the error. Verified by injecting a bad profile YAML - the rehearsal now holds a session open across the purge and demands it dies with the tier (criterion l, 42nd check) Smaller review findings, same pass: the escape-hatch probes assert the refusal's REASON instead of any nonzero exit (an image hiccup must not read as 'the escape is closed'); probe_from maps an outer-timeout kill to dropped, not reachable; the rehearsal cleanup keeps the account when a purge fails so doctor can name the leftovers; the purge asserts the trust certificate's absence; cmd_new distinguishes a dead daemon from a missing stack before prescribing setup-host; grant's success message names the user-<uid> bridge variant correctly on big-uid hosts. Rehearsal after: 42/42 (containers). test/cli.sh: 76 checks. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-18 05:11:20 +00:00
fi
}
trap backout EXIT
fix: grant the 'incus' membership to incus-admin members too (#101 review) The previous revision skipped `usermod -aG incus` for an incus-admin member, reasoning that 'incus' is a strict subset of what incus-admin already opens. That is true of the daemon API and false of the filesystem. On Debian 13 / Incus 6.0.4 the two sockets are two files with two owning groups: /var/lib/incus/unix.socket group incus-admin 0660 /var/lib/incus/unix.socket.user group incus 0660 incus-admin opens the first and not the second, and only the second provisions a user-<uid> project. So for the incus-admin-ONLY user — the canonical #99 case — the pinned provisioning touch took EACCES, the `|| true` swallowed it, no project appeared, and the grant died blaming a healthy incus-user. Both reviewers converged on this independently and were right. The membership is now granted for everyone, with output carrying the concern the old no-op was built around (it is the key to a file, not a privilege; box_tier still reads them as admin). Everything downstream moves with it: - the backout rolls that membership back and verified, while refusing to call the rollback a lockout — incus-admin is untouched and still opens the host - revoke's bare path takes the membership back and reports `partial:` instead of "no-op, nothing was taken", still declining to call them "out" - grant's closing "gpasswd -d <user> incus-admin (no re-grant needed)" is now a true promise: they keep 'incus', so the drop lands them in their project - the socket existence probe goes through $SUDO, matching revoke's measured discipline about /var/lib/incus lying to a non-root admin Tests: the cli.sh assertions that encoded the old no-op design are flipped and the decision is pinned at the seam that broke; the sudo shim now runs `test` for real in both directions. Because the shims model neither INCUS_SOCKET nor permissions and so cannot reproduce the EACCES, drill/multiuser.sh gains criterion (o): an incus-admin-only member granted on real Incus in CI, with the membership, the project, a live connect() to unix.socket.user, and the post-drop landing all measured. Mutation-checked: 11 of the new/flipped assertions fail against the previous implementation. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-19 16:50:42 +00:00
if id -nG "$user" | tr ' ' '\n' | grep -qx incus; then
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
was_member=1
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:13 +00:00
echo "group: $user already in 'incus'"
else
$SUDO usermod -aG incus "$user"
revoke/grant: survive the live-session case — the review's one real hole (#74) Supplementary groups are read at LOGIN, so 'gpasswd -d' does nothing to a session the user already holds — and after --purge, a stale-group process could touch incus-user and lazily RECREATE the project with stock defaults: the unhardened NAT bridge, un-narrowed, strictly worse than the granted state. Adversarial review caught it; verified live, then closed: - revoke --purge terminates the user's sessions first (loginctl, then pkill), and refuses to purge under processes it cannot kill - bare revoke says out loud that held sessions keep the socket until they end, and names the loginctl command — instead of claiming a lockout it did not deliver (help/README/design doc reworded to match) - a failed grant backs out its own group-add on exit (trap, disarmed on success): no half-granted user holding an un-narrowed socket while the admin reads the error. Verified by injecting a bad profile YAML - the rehearsal now holds a session open across the purge and demands it dies with the tier (criterion l, 42nd check) Smaller review findings, same pass: the escape-hatch probes assert the refusal's REASON instead of any nonzero exit (an image hiccup must not read as 'the escape is closed'); probe_from maps an outer-timeout kill to dropped, not reachable; the rehearsal cleanup keeps the account when a purge fails so doctor can name the leftovers; the purge asserts the trust certificate's absence; cmd_new distinguishes a dead daemon from a missing stack before prescribing setup-host; grant's success message names the user-<uid> bridge variant correctly on big-uid hosts. Rehearsal after: 42/42 (containers). test/cli.sh: 76 checks. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-18 05:11:20 +00:00
added_group=1
fix: grant the 'incus' membership to incus-admin members too (#101 review) The previous revision skipped `usermod -aG incus` for an incus-admin member, reasoning that 'incus' is a strict subset of what incus-admin already opens. That is true of the daemon API and false of the filesystem. On Debian 13 / Incus 6.0.4 the two sockets are two files with two owning groups: /var/lib/incus/unix.socket group incus-admin 0660 /var/lib/incus/unix.socket.user group incus 0660 incus-admin opens the first and not the second, and only the second provisions a user-<uid> project. So for the incus-admin-ONLY user — the canonical #99 case — the pinned provisioning touch took EACCES, the `|| true` swallowed it, no project appeared, and the grant died blaming a healthy incus-user. Both reviewers converged on this independently and were right. The membership is now granted for everyone, with output carrying the concern the old no-op was built around (it is the key to a file, not a privilege; box_tier still reads them as admin). Everything downstream moves with it: - the backout rolls that membership back and verified, while refusing to call the rollback a lockout — incus-admin is untouched and still opens the host - revoke's bare path takes the membership back and reports `partial:` instead of "no-op, nothing was taken", still declining to call them "out" - grant's closing "gpasswd -d <user> incus-admin (no re-grant needed)" is now a true promise: they keep 'incus', so the drop lands them in their project - the socket existence probe goes through $SUDO, matching revoke's measured discipline about /var/lib/incus lying to a non-root admin Tests: the cli.sh assertions that encoded the old no-op design are flipped and the decision is pinned at the seam that broke; the sudo shim now runs `test` for real in both directions. Because the shims model neither INCUS_SOCKET nor permissions and so cannot reproduce the EACCES, drill/multiuser.sh gains criterion (o): an incus-admin-only member granted on real Incus in CI, with the membership, the project, a live connect() to unix.socket.user, and the post-drop landing all measured. Mutation-checked: 11 of the new/flipped assertions fail against the previous implementation. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-19 16:50:42 +00:00
if [ "$admin_member" -eq 1 ]; then
# Say why, because the group list alone would imply a restriction that is
# not in force — the concern the old no-op was built around. It was a
# cosmetic concern and this is where it gets carried: in output, not in a
# skipped mutation that broke the mechanism.
echo "group: added $user to 'incus' — NOT a new privilege ('incus-admin' already opens the daemon,"
echo " and box_tier still reads them as 'admin'), but the key to a FILE: incus-user's socket is"
echo " group 'incus' mode 0660, and nothing below can provision $user without it"
else
echo "group: added $user to 'incus' (their next login picks it up; the grant does not wait)"
fi
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:13 +00:00
fi
project="user-$uid"
fix: box grant provisions incus-admin members instead of refusing them The refusal at host/grant-user.sh conflated permission with provisioning. The 'incus' group is a strict subset of what incus-admin opens — true, and the whole of what the refusal reasoned about. The user-<uid> project, the boxnet narrowing, the snapshot and backup allowances and the box-net profile installed into that project are not permissions, and an incus-admin member had none of them: box_tier() resolves them to admin, so they worked in the shared default project with no world of their own, and the one command that provisions one refused to run for them. box grant now converges them fully. The group step is a reported no-op — adding 'incus' would grant nothing and leave a group list implying a restriction that was never in force — and steps 2-5 run unchanged. The incus-user touch is pinned at incus-user's socket, which this turns out to require: the incus client picks its socket by writability, so for an incus-admin member an unpinned client sails past incus-user entirely and the project is never created. The user-side proof names their project for the same reason. On success it prints the caveat the hard exit was gesturing at: the restrictions are a default placement, not a confinement, and their own commands keep landing in the default project until incus-admin goes. The backout learns the third case (nothing added, nothing rolled back, still loud), and box revoke mirrors the whole thing rather than claiming a lockout it did not perform. Unblocks heavy-duty/rig#49. Closes #99 Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-19 16:18:46 +00:00
# The incus CLI picks its socket by WRITABILITY, not by intent: with no
# INCUS_SOCKET set it takes $INCUS_DIR/unix.socket when that is writable and
# only falls back to unix.socket.user when it is not (client/connection.go,
# stable-6.0 — the same branch that then defaults the project to user-<uid>).
# For a plain 'incus' member the fallback fires and every command below lands
# where we want it. For an incus-admin member the daemon socket IS writable,
# so an unpinned client sails straight past incus-user — the touch would not
# provision anything and the grant would die claiming incus-user was
# unhealthy. Pin the socket for them, by incus's own directory rule.
# INCUS_DIR first, then /run/incus if the daemon socket lives there, else
# /var/lib/incus: incus's resolution order, not an approximation of it — the
# pinned path has to name the same directory the client would have chosen.
user_socket=""
if [ "$admin_member" -eq 1 ]; then
incus_dir="${INCUS_DIR:-}"
if [ -z "$incus_dir" ]; then
incus_dir="/var/lib/incus"; [ -e /run/incus/unix.socket ] && incus_dir="/run/incus"
fi
user_socket="$incus_dir/unix.socket.user"
fix: grant the 'incus' membership to incus-admin members too (#101 review) The previous revision skipped `usermod -aG incus` for an incus-admin member, reasoning that 'incus' is a strict subset of what incus-admin already opens. That is true of the daemon API and false of the filesystem. On Debian 13 / Incus 6.0.4 the two sockets are two files with two owning groups: /var/lib/incus/unix.socket group incus-admin 0660 /var/lib/incus/unix.socket.user group incus 0660 incus-admin opens the first and not the second, and only the second provisions a user-<uid> project. So for the incus-admin-ONLY user — the canonical #99 case — the pinned provisioning touch took EACCES, the `|| true` swallowed it, no project appeared, and the grant died blaming a healthy incus-user. Both reviewers converged on this independently and were right. The membership is now granted for everyone, with output carrying the concern the old no-op was built around (it is the key to a file, not a privilege; box_tier still reads them as admin). Everything downstream moves with it: - the backout rolls that membership back and verified, while refusing to call the rollback a lockout — incus-admin is untouched and still opens the host - revoke's bare path takes the membership back and reports `partial:` instead of "no-op, nothing was taken", still declining to call them "out" - grant's closing "gpasswd -d <user> incus-admin (no re-grant needed)" is now a true promise: they keep 'incus', so the drop lands them in their project - the socket existence probe goes through $SUDO, matching revoke's measured discipline about /var/lib/incus lying to a non-root admin Tests: the cli.sh assertions that encoded the old no-op design are flipped and the decision is pinned at the seam that broke; the sudo shim now runs `test` for real in both directions. Because the shims model neither INCUS_SOCKET nor permissions and so cannot reproduce the EACCES, drill/multiuser.sh gains criterion (o): an incus-admin-only member granted on real Incus in CI, with the membership, the project, a live connect() to unix.socket.user, and the post-drop landing all measured. Mutation-checked: 11 of the new/flipped assertions fail against the previous implementation. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-19 16:50:42 +00:00
# $SUDO test, not a bare [ -e ]: revoke-user.sh documents from measurement
# that /var/lib/incus is not traversable by a non-root admin, so an
# unprivileged stat answers "absent" for a socket that is very much there —
# and this check EXITS on absent. Same discipline, same reason (#101 review).
$SUDO test -e "$user_socket" \
fix: box grant provisions incus-admin members instead of refusing them The refusal at host/grant-user.sh conflated permission with provisioning. The 'incus' group is a strict subset of what incus-admin opens — true, and the whole of what the refusal reasoned about. The user-<uid> project, the boxnet narrowing, the snapshot and backup allowances and the box-net profile installed into that project are not permissions, and an incus-admin member had none of them: box_tier() resolves them to admin, so they worked in the shared default project with no world of their own, and the one command that provisions one refused to run for them. box grant now converges them fully. The group step is a reported no-op — adding 'incus' would grant nothing and leave a group list implying a restriction that was never in force — and steps 2-5 run unchanged. The incus-user touch is pinned at incus-user's socket, which this turns out to require: the incus client picks its socket by writability, so for an incus-admin member an unpinned client sails past incus-user entirely and the project is never created. The user-side proof names their project for the same reason. On success it prints the caveat the hard exit was gesturing at: the restrictions are a default placement, not a confinement, and their own commands keep landing in the default project until incus-admin goes. The backout learns the third case (nothing added, nothing rolled back, still loud), and box revoke mirrors the whole thing rather than claiming a lockout it did not perform. Unblocks heavy-duty/rig#49. Closes #99 Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-19 16:18:46 +00:00
|| { echo "box grant: incus-user is active but $user_socket is not there — nothing can provision $project (journalctl -u incus-user)" >&2; exit 1; }
fi
# Run the incus CLI as the granted user, on the socket that will actually
# serve them: pinned to incus-user for an admin member, left to the CLI's own
# resolution for everyone else (whose fallback already gets it right).
run_as_incus() {
if [ -n "$user_socket" ]; then run_as "$user" env INCUS_SOCKET="$user_socket" "$@"
else run_as "$user" "$@"
fi
}
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:13 +00:00
# 2. The project is created LAZILY, on the user's first contact with
# incus-user — an admin cannot pre-create it (incus-user would fight over
# it), so make that first contact happen now, as the user.
if ! incus project show "$project" >/dev/null 2>&1 </dev/null; then
echo "project: touching incus-user as $user to create $project..."
fix: box grant provisions incus-admin members instead of refusing them The refusal at host/grant-user.sh conflated permission with provisioning. The 'incus' group is a strict subset of what incus-admin opens — true, and the whole of what the refusal reasoned about. The user-<uid> project, the boxnet narrowing, the snapshot and backup allowances and the box-net profile installed into that project are not permissions, and an incus-admin member had none of them: box_tier() resolves them to admin, so they worked in the shared default project with no world of their own, and the one command that provisions one refused to run for them. box grant now converges them fully. The group step is a reported no-op — adding 'incus' would grant nothing and leave a group list implying a restriction that was never in force — and steps 2-5 run unchanged. The incus-user touch is pinned at incus-user's socket, which this turns out to require: the incus client picks its socket by writability, so for an incus-admin member an unpinned client sails past incus-user entirely and the project is never created. The user-side proof names their project for the same reason. On success it prints the caveat the hard exit was gesturing at: the restrictions are a default placement, not a confinement, and their own commands keep landing in the default project until incus-admin goes. The backout learns the third case (nothing added, nothing rolled back, still loud), and box revoke mirrors the whole thing rather than claiming a lockout it did not perform. Unblocks heavy-duty/rig#49. Closes #99 Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-19 16:18:46 +00:00
run_as_incus timeout 60 incus project list >/dev/null 2>&1 || true
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:13 +00:00
incus project show "$project" >/dev/null 2>&1 </dev/null \
|| { echo "box grant: incus-user did not create $project — is incus-user.socket healthy? (journalctl -u incus-user)" >&2; exit 1; }
echo "project: $project created"
else
echo "project: $project already exists"
fi
# 3. Unpin the private bridge. incus-user's default profile carries an eth0
# on incusbr-<uid>; while ANY profile references that bridge, the narrowing
# below is rejected by incus's own validation. Removing the device is also
# what it looks like: the default profile in this project places no network —
# box-net is the only door, which is the placement contract working.
if incus --project "$project" profile device get default eth0 type >/dev/null 2>&1 </dev/null; then
incus --project "$project" profile device remove default eth0 >/dev/null </dev/null
echo "profile: removed the private-bridge eth0 from $project's default profile"
fi
# 4. boxnet, and ONLY boxnet. The auto-created incusbr-<uid> is a stock NAT
# bridge with none of box's hardening — listing it here would keep a
# one-flag escape from the isolation contract open forever. Narrowed, the
# hardened network is not the default placement but the only one possible.
# This can fail honestly: an instance the user already parked on the private
# bridge blocks the narrowing, and incus's error names it.
if ! err="$(incus project set "$project" restricted.networks.access boxnet 2>&1 </dev/null)"; then
echo "box grant: could not restrict $project to boxnet:" >&2
echo " $err" >&2
echo " (an instance still on the private bridge blocks this — move or delete it, then re-run)" >&2
exit 1
fi
revoke/grant: survive the live-session case — the review's one real hole (#74) Supplementary groups are read at LOGIN, so 'gpasswd -d' does nothing to a session the user already holds — and after --purge, a stale-group process could touch incus-user and lazily RECREATE the project with stock defaults: the unhardened NAT bridge, un-narrowed, strictly worse than the granted state. Adversarial review caught it; verified live, then closed: - revoke --purge terminates the user's sessions first (loginctl, then pkill), and refuses to purge under processes it cannot kill - bare revoke says out loud that held sessions keep the socket until they end, and names the loginctl command — instead of claiming a lockout it did not deliver (help/README/design doc reworded to match) - a failed grant backs out its own group-add on exit (trap, disarmed on success): no half-granted user holding an un-narrowed socket while the admin reads the error. Verified by injecting a bad profile YAML - the rehearsal now holds a session open across the purge and demands it dies with the tier (criterion l, 42nd check) Smaller review findings, same pass: the escape-hatch probes assert the refusal's REASON instead of any nonzero exit (an image hiccup must not read as 'the escape is closed'); probe_from maps an outer-timeout kill to dropped, not reachable; the rehearsal cleanup keeps the account when a purge fails so doctor can name the leftovers; the purge asserts the trust certificate's absence; cmd_new distinguishes a dead daemon from a missing stack before prescribing setup-host; grant's success message names the user-<uid> bridge variant correctly on big-uid hosts. Rehearsal after: 42/42 (containers). test/cli.sh: 76 checks. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-18 05:11:20 +00:00
# The private bridge's name follows incus-user's own rule (revoke-user.sh
# mirrors it too): incusbr-<uid>, or user-<uid> when that would not fit an
# interface name — naming the wrong one here would be a true claim with the
# wrong noun on big-uid (SSSD/AD) hosts.
bridge="incusbr-$uid"; [ "${#bridge}" -gt 15 ] && bridge="user-$uid"
echo "network: $project restricted to boxnet (the private $bridge is unreferenced and unreachable)"
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:13 +00:00
# 5. Snapshots. incus-user projects block them by default, and box's whole
# reuse story — log in once, snapshot, clone forever — is snapshots.
incus project set "$project" restricted.snapshots allow </dev/null
echo "snapshots: allowed"
feat: box export / import — state that survives the box and the host (#70) 'box rm' deletes a box and every snapshot it has; 'box new --from' clones, but the clone still lives on the same host. Nothing a box held could outlive a teardown — which made #66's upgrade refusal honest but lossy. This adds the way out and the way back: - box export <box> [<file>] [--instance-only]: wraps 'incus export' into one portable backup tarball (default <box>-<UTC stamp>.tar.gz), snapshots included by default. Requires the box stopped (require_stopped grew an honest reason parameter: export is down by OUR decision, not incus's). Credentials are SHOUTED, not scrubbed — the artifact carries the box's whole disk, and scrubbing a disk image is a promise tarball surgery cannot keep. - box import <file> [--name <box>]: reads the artifact's name from backup/index.yaml up front, refuses any name an existing instance holds (the resolve_box boundary from the other side), pre-flights the stack (require_stack, factored out of cmd_new), imports, then re-stamps the HOST's truth onto the artifact's: user.box=1 (legacy tag honored), the box-net placement (profile assign, the migrate-host move), fresh volatile MACs (imports restore volatile.* verbatim — a re-import beside its sibling collided at start with 'MAC address already defined on another NIC', measured live on Incus 6.0.4), and reset_identity, exactly like a clone. - restricted tier: box grant now converges restricted.backups allow — export rides the backup API, which incus-user's restricted projects block by default exactly like snapshots (incus 6.0 permissions.go, AllowBackupCreation). Import is plain instance creation and needs no key. - tests: driven usage errors + fail-closed grep/line-order guards for every daemon-gated invariant; CI's rehearsal job now runs a live round-trip (mint, write, snapshot, down, export, rm, import, assert the file, the snapshot, the tag, the agent, and the collision refusal). The whole flow was verified against a live Incus 6.0.4 daemon: running-box refusal, export, overwrite guard, rm, import with and without --name, re-home onto box-net, sibling re-import with distinct MACs and machine-ids, pre-export file and snapshot present in both. Closes #70 Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-18 15:10:12 +00:00
# 6. Backups. 'box export' rides incus's backup API — an export IS "create a
# backup, download it, delete it" — and a restricted project blocks that by
# default: restricted.backups=block the moment restricted=true (incus 6.0,
# internal/server/project/permissions.go, enforced by AllowBackupCreation).
# 'box import' needs no key of its own — restoring a backup file is plain
# instance creation. Same convergence as snapshots, for the same reason: the
# tier is the same workflows on your own boxes, and export/import are
# workflows (#70).
incus project set "$project" restricted.backups allow </dev/null
echo "backups: allowed ('box export' rides them, #70)"
# 7. The placement contract itself, installed into their project. Created if
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:13 +00:00
# missing, refreshed unconditionally — same convergence discipline as
# setup-host's own profile handling, so a box upgrade propagates by re-run.
incus --project "$project" profile show box-net >/dev/null 2>&1 </dev/null \
|| incus --project "$project" profile create box-net >/dev/null </dev/null
incus --project "$project" profile edit box-net < "$here/profiles/box-net.yaml"
echo "profile: box-net installed in $project"
# Prove the grant from the USER's side of the socket — the only side that
# matters. This catches the failure the steps above cannot see one at a time:
fix: box grant provisions incus-admin members instead of refusing them The refusal at host/grant-user.sh conflated permission with provisioning. The 'incus' group is a strict subset of what incus-admin opens — true, and the whole of what the refusal reasoned about. The user-<uid> project, the boxnet narrowing, the snapshot and backup allowances and the box-net profile installed into that project are not permissions, and an incus-admin member had none of them: box_tier() resolves them to admin, so they worked in the shared default project with no world of their own, and the one command that provisions one refused to run for them. box grant now converges them fully. The group step is a reported no-op — adding 'incus' would grant nothing and leave a group list implying a restriction that was never in force — and steps 2-5 run unchanged. The incus-user touch is pinned at incus-user's socket, which this turns out to require: the incus client picks its socket by writability, so for an incus-admin member an unpinned client sails past incus-user entirely and the project is never created. The user-side proof names their project for the same reason. On success it prints the caveat the hard exit was gesturing at: the restrictions are a default placement, not a confinement, and their own commands keep landing in the default project until incus-admin goes. The backout learns the third case (nothing added, nothing rolled back, still loud), and box revoke mirrors the whole thing rather than claiming a lockout it did not perform. Unblocks heavy-duty/rig#49. Closes #99 Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-19 16:18:46 +00:00
# a converged project the user still cannot reach. For an admin member the
# project is named explicitly: an unqualified profile show over the pinned
# incus-user socket asks about 'default', and over their own admin socket it
# would answer from the shared default project — a green that proves the
# convergence nothing at all.
if [ -n "$user_socket" ]; then
run_as_incus timeout 30 incus --project "$project" profile show box-net >/dev/null 2>&1 \
|| { echo "box grant: converged, but $user cannot reach $project's box-net profile through incus-user — check journalctl -u incus-user" >&2; exit 1; }
else
run_as "$user" timeout 30 incus profile show box-net >/dev/null 2>&1 \
|| { echo "box grant: converged, but $user cannot see the box-net profile through incus-user — check journalctl -u incus-user" >&2; exit 1; }
fi
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:13 +00:00
revoke/grant: survive the live-session case — the review's one real hole (#74) Supplementary groups are read at LOGIN, so 'gpasswd -d' does nothing to a session the user already holds — and after --purge, a stale-group process could touch incus-user and lazily RECREATE the project with stock defaults: the unhardened NAT bridge, un-narrowed, strictly worse than the granted state. Adversarial review caught it; verified live, then closed: - revoke --purge terminates the user's sessions first (loginctl, then pkill), and refuses to purge under processes it cannot kill - bare revoke says out loud that held sessions keep the socket until they end, and names the loginctl command — instead of claiming a lockout it did not deliver (help/README/design doc reworded to match) - a failed grant backs out its own group-add on exit (trap, disarmed on success): no half-granted user holding an un-narrowed socket while the admin reads the error. Verified by injecting a bad profile YAML - the rehearsal now holds a session open across the purge and demands it dies with the tier (criterion l, 42nd check) Smaller review findings, same pass: the escape-hatch probes assert the refusal's REASON instead of any nonzero exit (an image hiccup must not read as 'the escape is closed'); probe_from maps an outer-timeout kill to dropped, not reachable; the rehearsal cleanup keeps the account when a purge fails so doctor can name the leftovers; the purge asserts the trust certificate's absence; cmd_new distinguishes a dead daemon from a missing stack before prescribing setup-host; grant's success message names the user-<uid> bridge variant correctly on big-uid hosts. Rehearsal after: 42/42 (containers). test/cli.sh: 76 checks. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-18 05:11:20 +00:00
trap - EXIT # converged and verified: the grant stands
fix: box grant provisions incus-admin members instead of refusing them The refusal at host/grant-user.sh conflated permission with provisioning. The 'incus' group is a strict subset of what incus-admin opens — true, and the whole of what the refusal reasoned about. The user-<uid> project, the boxnet narrowing, the snapshot and backup allowances and the box-net profile installed into that project are not permissions, and an incus-admin member had none of them: box_tier() resolves them to admin, so they worked in the shared default project with no world of their own, and the one command that provisions one refused to run for them. box grant now converges them fully. The group step is a reported no-op — adding 'incus' would grant nothing and leave a group list implying a restriction that was never in force — and steps 2-5 run unchanged. The incus-user touch is pinned at incus-user's socket, which this turns out to require: the incus client picks its socket by writability, so for an incus-admin member an unpinned client sails past incus-user entirely and the project is never created. The user-side proof names their project for the same reason. On success it prints the caveat the hard exit was gesturing at: the restrictions are a default placement, not a confinement, and their own commands keep landing in the default project until incus-admin goes. The backout learns the third case (nothing added, nothing rolled back, still loud), and box revoke mirrors the whole thing rather than claiming a lockout it did not perform. Unblocks heavy-duty/rig#49. Closes #99 Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-19 16:18:46 +00:00
if [ "$admin_member" -eq 1 ]; then
# The honest version of what the old refusal was gesturing at. The project
# is real, converged and theirs — that is what they were missing and what
# this run supplied. What it is NOT is confinement, in two distinct ways
# that both come from incus-admin winning at the socket, and both belong in
# the output rather than in a hard exit:
# · nothing here binds them. Every restriction converged above describes
# project $project; the default project and every other user's instances
# stay one flag away, and no setting inside $project can say otherwise
# while they hold that group.
# · nothing here even routes them, yet. Their unpinned CLI resolves to the
# writable daemon socket and so to the DEFAULT project (the socket rule
# cited at step 2), so their 'box new' still lands beside the other
# admins' until they either drop incus-admin — at which point this
# project becomes their automatic home, no re-run needed — or pin
# INCUS_SOCKET at incus-user by hand.
fix: grant the 'incus' membership to incus-admin members too (#101 review) The previous revision skipped `usermod -aG incus` for an incus-admin member, reasoning that 'incus' is a strict subset of what incus-admin already opens. That is true of the daemon API and false of the filesystem. On Debian 13 / Incus 6.0.4 the two sockets are two files with two owning groups: /var/lib/incus/unix.socket group incus-admin 0660 /var/lib/incus/unix.socket.user group incus 0660 incus-admin opens the first and not the second, and only the second provisions a user-<uid> project. So for the incus-admin-ONLY user — the canonical #99 case — the pinned provisioning touch took EACCES, the `|| true` swallowed it, no project appeared, and the grant died blaming a healthy incus-user. Both reviewers converged on this independently and were right. The membership is now granted for everyone, with output carrying the concern the old no-op was built around (it is the key to a file, not a privilege; box_tier still reads them as admin). Everything downstream moves with it: - the backout rolls that membership back and verified, while refusing to call the rollback a lockout — incus-admin is untouched and still opens the host - revoke's bare path takes the membership back and reports `partial:` instead of "no-op, nothing was taken", still declining to call them "out" - grant's closing "gpasswd -d <user> incus-admin (no re-grant needed)" is now a true promise: they keep 'incus', so the drop lands them in their project - the socket existence probe goes through $SUDO, matching revoke's measured discipline about /var/lib/incus lying to a non-root admin Tests: the cli.sh assertions that encoded the old no-op design are flipped and the decision is pinned at the seam that broke; the sudo shim now runs `test` for real in both directions. Because the shims model neither INCUS_SOCKET nor permissions and so cannot reproduce the EACCES, drill/multiuser.sh gains criterion (o): an incus-admin-only member granted on real Incus in CI, with the membership, the project, a live connect() to unix.socket.user, and the post-drop landing all measured. Mutation-checked: 11 of the new/flipped assertions fail against the previous implementation. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-19 16:50:42 +00:00
# That "no re-run needed" is a real promise only because the group step
# above put them in 'incus' (#101): dropping incus-admin leaves them a
# plain 'incus' member, which is exactly the tier whose client falls back
# to unix.socket.user and lands in $project. Under the old no-op they would
# have been left in NEITHER group — box_tier 'none', no socket at all, and
# a converged project they could not open.
fix: box grant provisions incus-admin members instead of refusing them The refusal at host/grant-user.sh conflated permission with provisioning. The 'incus' group is a strict subset of what incus-admin opens — true, and the whole of what the refusal reasoned about. The user-<uid> project, the boxnet narrowing, the snapshot and backup allowances and the box-net profile installed into that project are not permissions, and an incus-admin member had none of them: box_tier() resolves them to admin, so they worked in the shared default project with no world of their own, and the one command that provisions one refused to run for them. box grant now converges them fully. The group step is a reported no-op — adding 'incus' would grant nothing and leave a group list implying a restriction that was never in force — and steps 2-5 run unchanged. The incus-user touch is pinned at incus-user's socket, which this turns out to require: the incus client picks its socket by writability, so for an incus-admin member an unpinned client sails past incus-user entirely and the project is never created. The user-side proof names their project for the same reason. On success it prints the caveat the hard exit was gesturing at: the restrictions are a default placement, not a confinement, and their own commands keep landing in the default project until incus-admin goes. The backout learns the third case (nothing added, nothing rolled back, still loud), and box revoke mirrors the whole thing rather than claiming a lockout it did not perform. Unblocks heavy-duty/rig#49. Closes #99 Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-19 16:18:46 +00:00
echo "granted: $user has their own converged project $project (boxnet-only, snapshots, backups, box-net)."
echo " CAVEAT — $user is in 'incus-admin', which wins at the socket: this is a"
echo " DEFAULT PLACEMENT, not a confinement. They can reach the default project and"
echo " every other user's instances whenever they choose to."
echo " And until incus-admin goes, their own 'box' commands keep landing in the DEFAULT"
echo " project — the admin socket is the one their client picks. To make $project theirs"
fix: grant the 'incus' membership to incus-admin members too (#101 review) The previous revision skipped `usermod -aG incus` for an incus-admin member, reasoning that 'incus' is a strict subset of what incus-admin already opens. That is true of the daemon API and false of the filesystem. On Debian 13 / Incus 6.0.4 the two sockets are two files with two owning groups: /var/lib/incus/unix.socket group incus-admin 0660 /var/lib/incus/unix.socket.user group incus 0660 incus-admin opens the first and not the second, and only the second provisions a user-<uid> project. So for the incus-admin-ONLY user — the canonical #99 case — the pinned provisioning touch took EACCES, the `|| true` swallowed it, no project appeared, and the grant died blaming a healthy incus-user. Both reviewers converged on this independently and were right. The membership is now granted for everyone, with output carrying the concern the old no-op was built around (it is the key to a file, not a privilege; box_tier still reads them as admin). Everything downstream moves with it: - the backout rolls that membership back and verified, while refusing to call the rollback a lockout — incus-admin is untouched and still opens the host - revoke's bare path takes the membership back and reports `partial:` instead of "no-op, nothing was taken", still declining to call them "out" - grant's closing "gpasswd -d <user> incus-admin (no re-grant needed)" is now a true promise: they keep 'incus', so the drop lands them in their project - the socket existence probe goes through $SUDO, matching revoke's measured discipline about /var/lib/incus lying to a non-root admin Tests: the cli.sh assertions that encoded the old no-op design are flipped and the decision is pinned at the seam that broke; the sudo shim now runs `test` for real in both directions. Because the shims model neither INCUS_SOCKET nor permissions and so cannot reproduce the EACCES, drill/multiuser.sh gains criterion (o): an incus-admin-only member granted on real Incus in CI, with the membership, the project, a live connect() to unix.socket.user, and the post-drop landing all measured. Mutation-checked: 11 of the new/flipped assertions fail against the previous implementation. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-19 16:50:42 +00:00
echo " for real: gpasswd -d $user incus-admin (no re-grant needed: they keep 'incus', so"
echo " their client falls straight back to incus-user and $project is already ready)."
echo " 'box revoke $user' unwinds this provisioning and takes the 'incus' membership back;"
echo " it cannot touch their admin access."
fix: box grant provisions incus-admin members instead of refusing them The refusal at host/grant-user.sh conflated permission with provisioning. The 'incus' group is a strict subset of what incus-admin opens — true, and the whole of what the refusal reasoned about. The user-<uid> project, the boxnet narrowing, the snapshot and backup allowances and the box-net profile installed into that project are not permissions, and an incus-admin member had none of them: box_tier() resolves them to admin, so they worked in the shared default project with no world of their own, and the one command that provisions one refused to run for them. box grant now converges them fully. The group step is a reported no-op — adding 'incus' would grant nothing and leave a group list implying a restriction that was never in force — and steps 2-5 run unchanged. The incus-user touch is pinned at incus-user's socket, which this turns out to require: the incus client picks its socket by writability, so for an incus-admin member an unpinned client sails past incus-user entirely and the project is never created. The user-side proof names their project for the same reason. On success it prints the caveat the hard exit was gesturing at: the restrictions are a default placement, not a confinement, and their own commands keep landing in the default project until incus-admin goes. The backout learns the third case (nothing added, nothing rolled back, still loud), and box revoke mirrors the whole thing rather than claiming a lockout it did not perform. Unblocks heavy-duty/rig#49. Closes #99 Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-19 16:18:46 +00:00
else
echo "granted: $user has the restricted tier — their 'box new' lands on the hardened boxnet."
echo " (their boxes are theirs alone; 'box revoke $user' takes the tier back)"
fi