fix(users): the root-door resolver matches whole fields, not substrings

Caught in review. root_door_of matched unanchored substrings, so any value
that EXTENDS a real one resolved as that value: `root-door=closedish` read as
`closed` and PASSED close-root's gate -- the one arm in this repo that
authorizes an irreversible act -- and `class=humanoid` did the same through
the compat arm. Both contradicted the function's own header, which promises a
value outside the set resolves empty and fails closed.

Only reachable by hand-editing a marker, so it was never a live incident. It
gets fixed anyway because this is the single function every consumer trusts --
close-root's gate, apply's root-SSH note, and bootstrap-tenant's machine
guard all ask it -- and a resolver that is nearly right about a root door is
the wrong kind of nearly.

The marker is one line of space-separated key=value fields (bootstrap writes
it with a single printf), so padding both ends and matching on field
boundaries is exact rather than heuristic. Whitespace is normalised first so a
hand-edit using tabs still reads correctly -- anchoring must not trade one
silent misread for another.

BOTH vocabularies are anchored. Fixing only the current spelling would have
left the hole open on every box bootstrapped before #77, which is precisely
the population the compat arm exists to serve.

Tests pin the resolver and the end-to-end refusal, since the resolver
returning "" is only safe because consumers treat it as one. Reverting the
anchoring turns the suite red (447/4); restoring it returns 451/0. The
original compat proof still holds: removing the class= arm gives 441/10.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
dan-claude-bot 2026-07-20 11:18:37 +00:00
parent b1c1357f2b
commit 4bbf1babe0
3 changed files with 60 additions and 7 deletions

View file

@ -67,6 +67,21 @@ on the way to cutting its first release, and this file starts there.
told to re-run bootstrap — never a door welded shut on a machine whose only
entrance it was.
The resolver matches **whole fields, not substrings** — the marker is one
line of space-separated `key=value` pairs, so it pads both ends and matches
on field boundaries. Review caught the first cut doing unanchored matching,
which resolved any value that *extended* a real one: `root-door=closedish`
read as `closed` and passed close-root's gate — the single arm that
authorizes an irreversible act — and `class=humanoid` did the same through
the compat arm, both contradicting the resolver's own promise that a value
outside the set resolves empty and fails closed. Only reachable by hand-
editing a marker, so never a live incident, but this is the one function
every consumer trusts and it owes them exactness rather than nearly. Both
vocabularies are anchored: fixing only the current spelling would have left
every pre-#77 box carrying the hole. Whitespace is normalised first, so a
hand-edit using tabs reads the same rather than trading one silent misread
for another.
**New markers are written in the new vocabulary only.** Writing both would
keep an old rig reading a new marker, but it would entrench the retired
spelling on every box rig ever converges and make the disagreement case

View file

@ -158,14 +158,27 @@ read_role_marker() {
# unchanged from before: a marker that names no door policy cannot authorize
# shutting a door.
root_door_of() {
local marker="$1" new="" old=""
case "$marker" in
*root-door=closed*) new=closed ;;
*root-door=open*) new=open ;;
local marker="$1" new="" old="" padded
# FIELD-ANCHORED, not substring. The marker is one line of space-separated
# `key=value` fields (bootstrap writes it with a single printf), so padding
# both ends and matching whole fields is exact. Unanchored patterns matched
# any value that EXTENDS a real one: `root-door=closedish` resolved as
# `closed` and passed close-root's gate — the one arm that authorizes an
# irreversible act — and `class=humanoid` did the same through the compat
# arm. That contradicted this function's own promise above, that a value
# outside the set resolves empty and fails closed. Only reachable by hand
# editing, but this is the function every consumer trusts, so it owes them
# exactness rather than "close enough" (found in review on #77).
# Whitespace is normalised first so a hand-edit using tabs or double spaces
# is read the same way rather than silently failing to match.
padded=" ${marker//[[:space:]]/ } "
case "$padded" in
*" root-door=closed "*) new=closed ;;
*" root-door=open "*) new=open ;;
esac
case "$marker" in
*class=human*) old=closed ;;
*class=server*) old=open ;;
case "$padded" in
*" class=human "*) old=closed ;;
*" class=server "*) old=open ;;
esac
if [ -n "$new" ] && [ -n "$old" ] && [ "$new" != "$old" ]; then
printf 'conflict'; return 0

View file

@ -1604,6 +1604,31 @@ check "root_door_of: a tenant marker names no door at all" 0 "[]" \
door_of 'role=claude-box tenant=yes host=no'
check "root_door_of: disagreement is a conflict, not a coin flip" 0 "[conflict]" \
door_of 'role=custom root-door=open class=human host=no join=login'
# FIELD-ANCHORED, not substring (found in review on #77). A value that EXTENDS
# a real one must resolve EMPTY and fail closed, exactly as the function's
# header promises — before anchoring, `closedish` read as `closed` and PERMITTED
# close-root, the one arm that authorizes an irreversible act. Both vocabularies
# are checked: the compat arm had the identical hole, and a fix that anchored
# only the current spelling would leave every pre-#77 box exposed to it.
check "root_door_of: a value EXTENDING the current spelling resolves empty" 0 "[]" \
door_of 'role=x root-door=closedish host=no'
check "root_door_of: a value extending the pre-#77 spelling resolves empty too" 0 "[]" \
door_of 'role=x class=humanoid host=no'
check "root_door_of: a value PREFIXED by junk does not match either" 0 "[]" \
door_of 'role=x notroot-door=closed host=no'
# ...and the gate itself must refuse on those, not merely resolve empty: the
# resolver returning "" is only safe because every consumer treats it as a
# refusal, so the end-to-end behaviour is what gets pinned.
DOOR_FIX="$(mktemp -d)"
printf 'role=x root-door=closedish host=no\n' > "$DOOR_FIX/bogus"
# shellcheck disable=SC2016 # $1/$2 are the inner shell's positionals, not ours
check "close-root: refuses a marker whose door value merely LOOKS closed" 1 "names no root-door policy" \
bash -c '. "$1/commands/lib/users-config.sh"; assert_marker_closes_root "$2"' _ "$ROOT" "$DOOR_FIX/bogus"
# Whitespace normalisation: a hand-edit using tabs is still a real marker and
# must read the same, or anchoring would trade one silent misread for another.
check "root_door_of: tab-separated fields read the same as space-separated" 0 "[closed]" \
door_of "$(printf 'role=x\troot-door=closed\thost=no')"
rm -rf "$DOOR_FIX"
if [ "$(id -u)" -ne 0 ]; then
check "users close-root: refuses non-root" 1 "must run as root" "$ROOT/commands/users-close-root.sh"
else