Repository navigation
Conversation
is_safe ran to_ascii before any check, and to_ascii deletes every non-ASCII
run. A fullwidth, accented or lookalike spelling of a blocklist entry
(fullwidth "snow", "snów", "sn<U+043E>w" with a Cyrillic o) therefore reached
the matcher with letters missing and passed, while a reader still saw the
entry.
The passes now run first on the prompt as written, where censor_prompt folds
code points (NFKC, combining marks removed) before matching. The to_ascii
pass is kept after it, because some separators ("boston·dynamics") are only
turned into a space by to_ascii. On non-ASCII prompts the passes also run on
two more readings: invisible characters deleted rather than read as a space,
and letters from other scripts that are drawn like Latin ones folded to
Latin, using a vendored table generated from Unicode confusables.txt. Every
pass that ran before still runs, so a prompt blocked before is still blocked.
The list setup that __init__ did inline is moved into load_lists, shared with
a new from_lists constructor, so the tests run the real entry point, is_safe,
over the real bookkeeping instead of a copy of it.
This is the Unicode part of the earlier combined change. The whitelist
phrases and the list-maintenance tools move to their own pull requests.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Review on NVIDIA#306: censor_prompt folds fullwidth and accented letters itself, but the lemmatized pass and the exact-match check only see the prompt after to_ascii, which deletes those letters. alternate_spellings left out the plain folded reading, so an entry that is only on the exact-match list still passed when spelled in fullwidth or with an accent. alternate_spellings now includes the code-point-folded reading, so the whole chain of passes, exact-match included, runs on it. Tests cover fullwidth, precomposed and decomposed accents, lookalike and invisible spellings of an exact-match-only entry, and ordinary text that must stay allowed. Without the change, the fullwidth and both accent cases fail. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ng entries The matcher fuses neighbouring words, which is what catches "shot gun" as the entry "shotgun", but it also turns ordinary prose into an entry: "a robot sitting at a desk in the background" is blocked as "deskin". The matcher's whitelist only holds single tokens, and whitelisting either half cannot help, because fusing does not consult the whitelist. A whitelist entry with a space in it is now a phrase. A prompt is cleared only if every match lies entirely inside a whitelisted phrase. Matches that straddle a phrase boundary, and matches elsewhere in the prompt, stay blocked. Phrases are located before invisible characters are folded, so "desk<U+200B>in", which renders as the entry, cannot reach the exemption. With no phrase in the prompt the result and message are exactly what they were. The matches are found by trying every span on its own against the matcher, not reconstructed from the censored output. Review on NVIDIA#306 showed why: the censored output collapses each match to one token, so two adjacent matches ("nice camera" + "shot gun") were split from the token count alone as "nice camera shot" + "gun", both inside whitelisted phrases, and the prompt was allowed. The matcher is also greedy, so a match sharing a token with an earlier one ("in gun" after "desk in") never appeared in the output at all. Also logs a warning at load time when a whitelisted word is censored anyway as the leetspeak variant of another entry ("flat" from "fiat"), since that whitelist entry then does nothing. Moved here from NVIDIA#306, so the Unicode fix there can land on its own. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
… the cost A second review of the span fix found that better_profanity ignores a one-letter word at the very end of its input. Censored on its own, the span "batma n" (or "plan b", or a one-letter entry) matched nothing, so the match was never located, and a whitelisted phrase anywhere in the prompt cleared it: "snow white batma n more" passed with "snow white" whitelisted. Each span is now censored with a word after it, so the matcher reads it as it would inside a prompt. As a second guard, a prompt that was censored but in which no span matches on its own stays blocked. Locating every match is also bounded. A 32-token chunk that the matcher clears in one call is skipped, results are cached across the readings is_safe checks, and past 4000 matcher calls the prompt stays blocked, as it is without phrase support. The reviewer's worst case, 800 tokens of repeated phrase plus a non-ASCII character, goes from 25.5 s to 2.1 s. align_folded_to_source also skips a source token made only of invisible characters, which folds to nothing and shifted every later token by one. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…anges
hygiene.py flags blocklist entries that two ordinary dictionary words fuse
into, which is how prose collides with an entry ("desk in" -> "deskin"). It
reads lists with the production reader and checks what it advises.
coverage.py measures what a list change stops catching. It writes five
spellings of every entry (verbatim, split by a space, split by a zero-width
space, fullwidth, lookalike) and runs them through is_safe, the deployed entry
point, with the exact-match list and nltk data beside the lists when present.
Review on NVIDIA#306 pointed out that keying probes by entry index and spelling,
and ignoring keys present on only one side, let a removed or moved entry drop
out of the comparison with a clean exit. Probes are now identified by a
digest of the entry plus the spelling, so position does not matter. The
probe set can be frozen with --write-probes and replayed with --probes, so an
entry removed from the list keeps its probes and shows up as lost. Every
result file records the digest of the probe set it ran, and --compare exits
2 with "incomparable" when the two runs did not evaluate the same probes,
instead of reporting nothing lost. A file that is not a run, or holds no
probes, is also incomparable rather than a crash or a clean result. Probe
ids keep the entry's case, so case variants keep their own probes, and each
result records digests of the lists it measured and the lemmatizer used, and
--compare prints any of those that changed.
Moved here from NVIDIA#306, so the Unicode fix there can land on its own.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This branch has not been deployed
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Stacked on the whitelist-phrases PR (#313), which is stacked on #306. This PR's own change is the last commit, ce2d6e4.
Split out of #306 at @lfengad's suggestion. These are list-maintenance tools only: nothing in the request path imports them.
What it adds
hygiene.pyflags blocklist entries that two ordinary dictionary words fuse into, which is how prose collides with an entry (desk in→deskin). It reads the lists with the production reader, and its probe checks what it advises. By default it quotes only a few trademark examples that are safe to quote; everything else needs--show-entries.coverage.pymeasures what a list change stops catching. It writes five spellings of every blocklist entry (verbatim, split by a space, split by a zero-width space, fullwidth, lookalike) and runs them throughis_safe, the deployed entry point. When they sit beside the lists, it also uses the exact-match list and the nltk data.Review follow-up: comparable results
Thank you for pointing out that results were keyed by entry index and spelling, and that
--compareignored keys present on only one side. Together these let a removed or reordered entry drop out of the comparison with a clean exit. Now:--write-probes probes.json, then replayed against both versions with--probes probes.json. An entry removed from the list keeps its probes, so its spellings show up aslost. A probe file edited after it was written is refused. The probe file holds the prompts in plain text, so it should be kept with the lists, not attached to a ticket.--compareprints any of those that changed.--compareexits 2 withincomparableif the two runs used different probe sets, report different probe ids, or either file is not a run with probes. It never reports "lost 0" in those cases.New tests cover:
Run on the shipped lists
desk inas a phrase, over the frozen set: lost 0, gained 0, exit 0.Known limits
Tests
pytest cosmos_framework/auxiliary/guardrail/blocklist/: 142 passed.ruffandpre-commit(both CI configs, all files) are clean.🤖 Generated with Claude Code