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 revoke <user> [--purge] — take the restricted tier back (#74).
#
# Two strengths, deliberately:
# · bare revoke removes the user from the 'incus' group. That closes the
# socket — the only path their certificate can travel — so access ends at
# their next login, while their project and boxes stay intact (and their
# boxes stay RUNNING: revoking a person does not kill their workloads).
# 'box grant' restores everything untouched.
# · --purge also deletes what the tier created: their boxes, their images,
# their project, the private bridge, the trust-store certificate, the
# incus-user state. Irreversible, so it asks first.
set -euo pipefail
usage( ) { echo "usage: box revoke <user> [--purge]" >& 2; exit 2; }
user = "" ; purge = 0
for a in " $@ " ; do
case " $a " in
--purge) purge = 1 ; ;
-*) usage ; ;
*) [ -z " $user " ] || usage; user = " $a " ; ;
esac
done
[ -n " $user " ] || usage
if [ " $( id -u) " -eq 0 ] ; then
SUDO = ""
elif command -v sudo >/dev/null 2>& 1; then
SUDO = "sudo"
else
echo "ERROR: box revoke needs root and 'sudo' was not found." >& 2
exit 1
fi
getent passwd " $user " >/dev/null || { echo " box revoke: no such user: $user " >& 2; exit 1; }
uid = " $( id -u " $user " ) "
project = " user- $uid "
# incus-user's own naming rule, mirrored exactly: the bridge is incusbr-<uid>
# unless that would not fit in an interface name (15 chars), then user-<uid>.
bridge = " incusbr- $uid "
[ " ${# bridge } " -gt 15 ] && bridge = " user- $uid "
if [ " $purge " -eq 1 ] ; then
# Destructive and irreversible: a TTY to ask on, or BOX_YES=1, or refuse —
# the same non-interactive contract as install.sh.
if [ -z " ${ BOX_YES :- } " ] ; then
if [ -t 0 ] ; then
printf 'box revoke: delete ALL of %s' \' 's boxes, images and their project %s? this cannot be undone. [y/N] ' " $user " " $project "
read -r reply
case " $reply " in y| Y| yes| YES| Yes) : ; ; *) echo "box revoke: aborted." >& 2; exit 1 ; ; esac
else
echo "box revoke: refusing to --purge without a terminal to confirm on. BOX_YES=1 means yes." >& 2
exit 2
fi
fi
fi
# The group, first — access ends even if a purge step below trips.
if id -nG " $user " | tr ' ' '\n' | grep -qx incus; then
$SUDO gpasswd -d " $user " incus >/dev/null
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
echo " group: removed $user from 'incus' "
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
else
echo " group: $user was not in 'incus' "
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
# Supplementary groups are fixed AT LOGIN: the database change above does
# nothing to a session the user already holds — a leftover tmux keeps the
# socket until it dies. For a bare revoke that is an honest warning. For
# --purge it is a hole: a stale-group process can touch incus-user AFTER the
# purge and lazily recreate the project with incus-user's stock defaults —
# the unhardened NAT bridge, un-narrowed — which is strictly worse than the
# granted state this script is unwinding. So --purge terminates the user's
# sessions first (it is already the destructive, confirmed path), and a bare
# revoke says out loud what it did not do.
if pgrep -u " $user " >/dev/null 2>& 1; then
if [ " $purge " -eq 1 ] ; then
echo " sessions: $user has live processes — terminating them (a stale session could recreate the project, unhardened, after the purge) "
$SUDO loginctl terminate-user " $user " 2>/dev/null || true
$SUDO pkill -u " $user " 2>/dev/null || true
sleep 1
$SUDO pkill -9 -u " $user " 2>/dev/null || true
if pgrep -u " $user " >/dev/null 2>& 1; then
echo " box revoke: could not terminate $user 's processes — refusing to purge under them " >& 2
echo " (they retain the socket until those sessions end, and could recreate the project)" >& 2
exit 1
fi
else
echo " WARNING: $user has live sessions, and group membership is read at login — "
echo " those sessions keep the socket until they end. To end them now:"
echo " sudo loginctl terminate-user $user "
fi
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
if [ " $purge " -eq 0 ] ; then
if incus project show " $project " >/dev/null 2>& 1 </dev/null; then
echo " kept: project $project and its boxes (still running — revoking a person does not kill their workloads) "
echo " 'box revoke $user --purge' deletes them; 'box grant $user ' restores access "
fi
echo " revoked: $user no longer has the restricted tier. "
exit 0
fi
# --purge: unmake what the tier made. Instances one at a time — a wildcard
# delete that half-fails leaves a state nobody can name; a loop that fails
# names the box it failed on (the wipe.sh discipline).
if incus project show " $project " >/dev/null 2>& 1 </dev/null; then
while IFS = , read -r inst _; do
[ -n " $inst " ] || continue
echo " purge: deleting instance $inst "
incus --project " $project " delete -f " $inst " </dev/null
done < <( incus --project " $project " list --format csv --columns n 2>/dev/null)
while IFS = , read -r fp _; do
[ -n " $fp " ] || continue
incus --project " $project " image delete " $fp " </dev/null
done < <( incus --project " $project " image list --format csv --columns f 2>/dev/null)
incus --project " $project " profile delete box-net >/dev/null 2>& 1 </dev/null || true
incus project delete " $project " </dev/null \
|| { echo " box revoke: could not delete $project — something is still in it (incus --project $project list / image list / storage volume list) " >& 2; exit 1; }
echo " purge: project $project removed "
fi
if incus network delete " $bridge " >/dev/null 2>& 1 </dev/null; then
echo " purge: private bridge $bridge removed "
fi
# The trust-store certificate incus-user minted for them. Named, not guessed:
# incus-user calls it incus-user-<uid>.
while IFS = , read -r name fp _; do
[ " $name " = " incus-user- $uid " ] || continue
incus config trust remove " $fp " </dev/null && echo " purge: trust-store certificate $name removed "
done < <( incus config trust list --format csv --columns nf 2>/dev/null)
# incus-user's per-user client state (their key pair). Removed so a future
# re-grant starts clean instead of trusting a key the purge revoked.
2026-07-18 16:01:57 +00:00
# $SUDO test, not a bare [ -d ]: /var/lib/incus is not traversable by a
# non-root admin, so an unprivileged stat answers "absent" for a directory
# that is very much there — the same lie the absence assert below must dodge.
if $SUDO test -d " /var/lib/incus/users/ $uid " 2>/dev/null; then
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
$SUDO rm -rf " /var/lib/incus/users/ $uid "
echo " purge: incus-user state for uid $uid removed "
fi
# Assert absence rather than trusting exit codes — the wipe.sh discipline.
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 certificate included: its removal above is set -e-exempt (left of &&),
2026-07-18 16:01:57 +00:00
# and a promise the header makes is a promise this block checks. The
# incus-user state directory too — it was purged for releases without being
# re-checked, which is exactly the gap this block exists to close.
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
leftover = ""
incus project show " $project " >/dev/null 2>& 1 </dev/null && leftover = " $leftover $project "
incus network show " $bridge " >/dev/null 2>& 1 </dev/null && leftover = " $leftover $bridge "
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
incus config trust list --format csv --columns nf 2>/dev/null | grep -q " ^incus-user- $uid , " \
&& leftover = " $leftover cert:incus-user- $uid "
2026-07-18 16:01:57 +00:00
$SUDO test -d " /var/lib/incus/users/ $uid " 2>/dev/null \
&& leftover = " $leftover /var/lib/incus/users/ $uid "
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 [ -n " $leftover " ] ; then
echo " box revoke: purge INCOMPLETE — still present: $leftover " >& 2
exit 1
fi
echo " revoked: $user is out, and everything the tier created is gone. "