fix(acl): a whitespace-padded audience label was silently ungrantable#47
Conversation
An ACL config could mint a label no client scope can ever match, with no
error. A client's scope is split and stripped (answer.settings._labels),
while enforcement compares labels exactly (answer.index.visible), so
`{"unit": "Finance", "audiences": [" finance "]}` loaded fine, stamped
` finance ` onto every page and facts row, and reached NOBODY — verified
against scopes {finance}, {leadership} and {finance,leadership,all}; only
an unrestricted client saw the page. The operator who wrote the rule got
the opposite of what the file says.
This is the mirror image of the comma case (#30): silently over-restrictive
rather than silently open, so milder, but just as silent — and this file is
the access-control authority, which must never mean something other than
what it says. Rejected rather than stripped for the same reason, and
because the padding is usually a typo worth surfacing.
Both halves of the contract move together. _label_ok's docstring promised
the index rejects "exactly these" labels, so fixing only the config side
would have made that promise false; the index re-derives the rule from page
text and cannot assume the writer validated it. A page carrying a padded
label now resolves to restricted-to-nobody, which is the existing
all-or-nothing rule for an unrepresentable label rather than a new choice —
pinned here too, since a mixed [" finance ", "sales"] page served sales
before and serves nobody now (availability only; it can never widen, and an
unrestricted operator still sees the page, which is how it gets noticed).
Validation moves to cli(): the pass loads the config deep in the work path,
AFTER dedup_pending has deleted pages and facts rows and saved state, and
compose runs the worker `restart: unless-stopped` — so a rejected label
would have crash-looped a container that destroys state every lap and
processes nothing. Now it exits 1 with the message and no traceback, before
any work. CHANGELOG carries the upgrade note (a live padded label makes
clean refuse to start; no re-index is needed, since both encodings enforce
identically).
Label validity was only ever a promise in a docstring, so it is now a
machine-checked eval clause beside the visibility truth table, over 16
string shapes including NBSP/ZWSP/ideographic space: reverting either half
alone drops it. Scoped to STRING labels on purpose — the halves diverge for
non-str input, where the index refuses to coerce because str() would rename
a label into a grantable one. That divergence is a separate config-side
defect (rules' labels are stored raw, so a numeric label TypeErrors in
worker.py's ",".join) and is deliberately not what the clause measures.
Scorecard 25 -> 26.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Gate round 1 — core fix survived, 5 findings fixedAdversarial review ( Refuted attacks (fix survives)
Confirmed and fixed1. BREAKING-CHANGE — one padded label would crash-loop the whole pipeline. Validation lived deep in the work path, after 2. CORRECTNESS — a mixed list revokes a grant that used to work. 3. TEST-GAP — my own eval clause overclaimed. The halves deliberately disagree on every non- 4. Mutation — the one survivor is killed. 5. DOC-DRIFT — the published contract was left false. StateFull CI-equivalent suite green (7 packages, ruff, evals 26/26). clean 261 tests, answer 67. Mutation set with hash-verified restores, zero survivors across 9 mutants spanning both halves, the fail-fast path, and the eval clause. |
dc3cdcf to
fed2ea7
Compare
Found by an autonomous
bughuntsweep (lead0e, surfaced by PR #37's gate and verified here).Bug
An ACL config can mint an audience label that no client scope is able to match, and nothing errors. A client's scope is split and stripped (
answer.settings._labels), while enforcement compares labels exactly (answer.index.visible). So:A rule whose only audience is padded is reachable by nobody at all:
The operator who wrote
audiences: [" finance "]believes they granted finance access. Nobody receives it, and the ACL file — the access-control authority — silently means something other than what it says.Root cause
clean.acl._check_labelsrejects commas and blank labels (#30) but not surrounding whitespace, so the label passes validation and is stored verbatim onto every page and facts row.Fix
Reject it at config load, with a message naming the label the author meant. Rejected rather than stripped on purpose: silently normalizing would make the file mean something other than what it says, and the padding is usually a typo worth surfacing.
This is the mirror image of the comma case — silently over-restrictive rather than silently open, so milder, but just as silent.
Both halves of the contract move together.
_label_ok's docstring promised the index rejects "exactly these" labels, so fixing only the config side would have made that promise false — and the index re-derives the rule from page text, so it cannot assume whoever wrote the page ran that validation. A page carrying a padded label now resolves to restricted-to-nobody: still closed, but discoverable by an unrestricted operator instead of looking like a working grant.Label validity was only ever a promise in a docstring. It is now a machine-checked clause beside the visibility truth table, so the halves can't drift again — reverting either side alone drops it to 7/12 labels. Scorecard 25 → 26.
Everyday spellings are unaffected: YAML strips a trailing space from a plain scalar, so
acl: [finance , leadership]still indexes asfinance,leadership, and internal whitespace ("board members") remains legal.Test
pipeline/clean: padded labels rejected across 5 shapes (f,f,f, tab, newline), the suggestion names the intended label, and"board members"still loads.answer: a page carrying a padded label resolves toacl == ""— invisible to{finance}, visible to an unrestricted operator — plus the plain-scalar case that must keep working.evals: newcontract: ACL label validity parity (clean vs answer)clause over 12 label shapes.Full CI-equivalent suite green locally (7 packages, ruff, evals 26/26). Verified the test fails without the fix (
git stash push -- acl.py→ 1 failed).Mutation-tested with hash-verified restores, zero survivors:
lstriponlyrstriponlystrip()instead of reject (renames the label)The two
answermutants initially survived that package's own suite and were caught only by the eval, so I added the answer-side unit test — one mechanism is not enough to pin a contract.🤖 Generated with Claude Code