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>
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 #306. Please review that one first; this PR's own changes are the last two commits, 8e8d237 and c11dc88.
Split out of #306 at @lfengad's suggestion. The commits here also fix the span bug found in that review.
The problem
The matcher fuses neighbouring words. That is what catches
shot gunas the entryshotgun, but it also turns ordinary prose into an entry:a robot sitting at a desk in the backgroundis blocked asdeskin. The matcher's whitelist only holds single tokens, and whitelisting either half cannot help, because fusing does not consult the whitelist.What changes
desk<U+200B>in, which renders as the entry, cannot reach the exemption.flatfromfiat), is now logged.Review follow-up: real match spans
The previous version rebuilt each match's span from the censored output, by counting tokens. Thank you for spotting that this is ambiguous. In the example from the review, the blocklist is
nicecameraandshotgun, the whitelist isnice camera shotandgun show, and the prompt isnice camera shot gun show. The guess split it asnice camera shot+gun, both inside a phrase, so the prompt was allowed.Fixing it turned up two more cases of the same kind, both fixed here:
deskinandin gunboth listed,a desk in gunonly ever reportsdesk in. Thein gunmatch never appears in the censored output. The old "second occurrence" test passed only because the miscounted span happened to block it.batma n,plan bor a one-letter entry, censored on its own, matched nothing, and a whitelisted phrase elsewhere in the prompt cleared it (snow white batma n morewithsnow whitewhitelisted). An independent review pass found this one.How it works now:
fused_matches()tries every span on its own, with a word appended after it so the matcher reads the span as it would inside a prompt. A span counts as a match when the matcher reduces it to one censored token.MAX_NUMBER_COMBINATIONS.Results:
is_safe)a desk in, a desk in the corner)The randomized checks:
desk in shot gun nice camera show bat man …, with punctuation and case);mainand a brute-force reference that knows nothing about the span code.main.mainallows.mainblocks, every occurrence of an entry that the matcher recognises lies inside a phrase.These checks cover the shapes in those vocabularies; they are not a proof.
Cost
The fused path runs only when a prompt contains both a whitelisted phrase and a match. To bound it:
is_safechecks;Measured with the shipped lists plus a
desk inphrase (main is 0.01–0.13 s on all of these):Known limits
desks in), because the exemption applies on the lemmatized pass too.a desk, in the cornerstays blocked). This errs strict.desk inis a data change for the list owners.Tests
pytest cosmos_framework/auxiliary/guardrail/blocklist/: 107 passed.ruffandpre-commit(both CI configs, all files) are clean.🤖 Generated with Claude Code