diff --git a/CHANGELOG.md b/CHANGELOG.md index 74a878a..b4a5fac 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/commands/lib/users-config.sh b/commands/lib/users-config.sh index c0b0904..5e2da63 100644 --- a/commands/lib/users-config.sh +++ b/commands/lib/users-config.sh @@ -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 diff --git a/test/cli.sh b/test/cli.sh index 70ad27b..75aea97 100644 --- a/test/cli.sh +++ b/test/cli.sh @@ -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