Repository navigation
fix(guardrail): harden the blocklist against lookalike, fullwidth and invisible spellings; whitelist phrases; measure coverage - #306
Conversation
is_safe flattened the prompt with to_ascii before handing it to censor_prompt, and to_ascii rewrites every non-ASCII run to a space. A fullwidth or accented letter is therefore deleted rather than normalized, so the word it spells never reaches the matcher: "a snow white poster" arrives as "a white poster" and is reported safe. The normalization added in NVIDIA#183 folds exactly those spellings, but nothing was left to fold by the time it ran. Run the first pass on the prompt as written, then flatten for the lemmatized pass as before. censor_prompt does its own code-point folding, so it is the one caller that needs the original text. The tests added in NVIDIA#183 called censor_prompt directly, which skips to_ascii, so they passed while the production entry point stayed vulnerable. Point them at is_safe instead. Without this commit two of them fail -- fullwidth letters and combining marks -- which is the gap. The invisible-character and unusual- space cases pass either way: to_ascii turns those code points into a space, which happens to restore the phrase rather than destroy it, so only the two letter-rewriting families were ever exposed through is_safe. _blocklist_with_words now sets the attributes is_safe reads, and the lemmatizer is a stub, so exercising the real entry point does not add a dependency on the nltk data that ships in the checkpoint directory. Two consequences of moving the flatten later, both pinned by tests: * the first pass now sees raw non-ASCII, so accented prose, an em dash, a CJK sentence and German umlauts must still come back safe; * a visible non-ASCII separator (arrow, emoji, ellipsis, middle dot) is not an invisible mark, so the first pass does not fold it -- it is the flattened second pass that still catches it. Neither pass is redundant, and the new test fails if the flattened pass is removed or moved ahead of the first. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…ng entries The matcher fuses adjacent words and tests the fused string against the blocklist, which is how "n ike" is caught. The same fusing means an entry that is itself two ordinary English words glued together matches the prose that contains them: "a robot sitting at a desk in the background" is reported as blocked, because "desk" + "in" fuses into an entry. That is how a Cosmos3-Edge QA output was blocked as gore. Whitelisting either half cannot fix it. The fusing never consults the whitelist, so "desk" on the whitelist leaves the sentence blocked (measured), and the halves are ordinary words that should not be exempt on their own anyway. A whitelisted phrase can. The phrase is replaced with a single-token placeholder before matching, so exactly one spelling stops matching and every other spelling of the entry is still caught: with "desk in" whitelisted, all 31 other space splits of the entry, every leetspeak spelling, the case and punctuation variants and both inflected forms stay blocked, verified against the production list. The placeholder is substituted between the two normalization steps, not after them. Folding invisible characters first would rewrite "desk<U+200B>in" -- which renders as the entry, with nothing between its halves -- into the whitelisted phrase, exempting an evasion the reader cannot see. For the same reason the censor path is now given the prompt as written: to_ascii rewrites every non-ASCII run to a space, which erases the difference between an invisible split and a typed space before censor_prompt can act on it. The lemmatized and exact-match passes still use the ASCII form. Whitelisted single words keep going to load_censor_words() and behave as before. On the 492-item Cosmos3-Edge reasoner corpus, adding the phrase clears the one false positive and changes nothing else. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
better_profanity defeats spaced-out evasions by fusing adjacent tokens and testing the fused string against the blocklist. Nothing about that fusing is conditional on the entry looking like an evasion, so a single-word entry which is itself two English words glued together matches ordinary prose as well: the entry "deskin" blocks "a robot sitting at a desk in the background", which is how a Cosmos3-Edge QA output was blocked as gore. Exempting fused prose in the matcher was tried and withdrawn (NVIDIA#177): measured against the shipped list it makes 108 of the 285 single-word entries evadable by typing one space, most of them content-safety terms rather than trademarks, and capping the exemption at two-token joins changes none of them. The collision is a property of the list, so the check belongs at list-edit time. Adds a hygiene check with two modes: gate, which exits non-zero when a candidate entry has a two-way dictionary split, and audit, which reports how many entries on an existing list have the shape. Against the shipped Guardrail1 list that is 110 of 285 single-word entries, every one confirmed to block its spaced form against the live matcher. On the 492-item QA corpus the check flags the entry behind the one item a reviewer called a false positive, and none of the entries behind the three real blocks. The remedy a flagged entry usually wants is a move to the exact-match list, which is compared against whole words and never fuses: "deskin the animal" stays blocked while "a desk in the background" is let through. Deleting the entry instead would stop blocking the stem, which means the same act as the inflected forms. The trade is that the fused spellings of that one stem ("d eskin") stop being caught, so it is an owner's call, not the tool's. The check is syntactic, so it also flags splits that are grammatical but not idiomatic ("deskinning" -> "desk" + "inning"). It is meant to be read by a list owner and waived where the split is not real English, not to auto-delete entries. Audit output names entries only with --show-entries, so its default output can be shared without reproducing content-safety terms. No guardrail behaviour changes. The vocabulary is injectable, so the tests run without a checkpoint download. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Two defects in the reachability probe, both raised in review.
gate checks a candidate that is not on the blocklist yet -- that is the point
of gating -- but the probe was built from the existing list alone. The matcher
could never fire on the candidate's own split, so every split was discarded as
unreachable and the gate passed anything handed to it:
gate deskin FLAGGED
gate deskin --confirm-reachable <list> ok
Include the candidates in the probe's word list for gate mode. audit is
unaffected: there the entries are already on the list being audited.
The probe also ran a bare better_profanity matcher, and a whitelist entry that
is a phrase never reaches better_profanity -- exempting it is something
censor_prompt does on top. So after following the tool's own advice
("whitelist the phrase 'desk in' to exempt that one spelling") the probe kept
reporting that split as reachable. Run censor_prompt instead, so the remedy
this tool prints is visible to the check that prints it. With 'desk in'
whitelisted, that split drops out and the entry stays flagged for its other
reachable splits, which is the honest answer.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
better_profanity drops whitelisted words before it expands the leetspeak
variants of the remaining entries (`if word in whitelist_words: continue`,
then `VaryingString(word, char_map=CHARS_MAPPING)`), so a whitelisted word
that is also a variant of some other entry stays censored. Raised in review,
and it reproduces in three lines:
custom=["fiat"], whitelist=["flat"]
"the floor is flat" -> "the floor is ****"
Nothing guarded it, and this PR is what makes the whitelist load-bearing. The
shipped lists are unaffected -- the single whitelisted word comes through
clean, checked -- so this is a warning at load time rather than a behaviour
change: the collision is named instead of being discovered in production.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…se itself
Masking replaced a whitelisted phrase with a placeholder before matching, so
any match overlapping the phrase disappeared with it. Raised in review, and it
reproduces:
entry "in gun", whitelist "desk in", prompt "a desk in gun on the table"
masking -> not blocked span-based -> blocked
Match against the whole prompt as before, then drop only the matches that fall
entirely inside an occurrence of a whitelisted phrase. A match that straddles
the boundary is kept, so the exemption scopes to exactly the spelling the
whitelist names.
Two pieces make that possible. censored_token_spans recovers which input
tokens each match covered, since the matcher collapses a multi-token match
into one censored token. align_folded_to_source maps folded tokens back to
source tokens, because folding an invisible character to a space splits one
source token into two.
Phrase occurrences are still located before invisible characters are folded,
which keeps the property the masking version had: "desk<U+200B>in" renders as
the entry with no space in it, so folding first would turn an evasion into the
whitelisted phrase and exempt it. That case stays blocked.
Measured against masking, same lists, phrase "desk in" whitelisted:
1532 adversarial prompts masking 1442 blocked, span-based 1442
boundary cases masking 1 of 3 correct, span-based 3 of 3
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
A corpus of ordinary model output answers one question: does a change block
things it did not block before. Any change that unblocks -- a whitelist entry,
a data move, a new exemption mechanism -- carries the opposite risk, and a
corpus cannot show it, because the phrasings that would newly slip through are
exactly the ones ordinary output does not contain.
This writes the missing side. For every entry it produces four prompts, one
per way the entry can be spelled while still reading as itself: verbatim, split
in the middle, split with a zero-width space, and in fullwidth letters. Run it
before and after a change and diff: anything blocked before and not after is a
spelling the change gave up. --compare exits non-zero when something was lost,
so it can gate a change in CI.
Entry text never appears in the output -- results are keyed by index and
spelling -- so the numbers can be pasted into a review.
Against the shipped list, 383 entries and 1532 prompts:
verbatim 382 blocked spaced 334 blocked
fullwidth 382 blocked invisible 334 blocked
Tests run on synthetic lists and need no checkpoint.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
- is_safe adds the raw pass instead of moving to_ascii, so a separator the
folding keeps (boston·dynamics) is still split by the ASCII pass. Covered by
a test with a lemmatizer that changes the entry, which the identity stand-in
could not catch.
- Phrase tokens keep the characters better_profanity treats as part of a word
(" $ ' * @) when compared with a whitelisted phrase, so a leetspeak spelling
is not read as the phrase.
- One setup path: __init__, the tests, the hygiene probe and the coverage
sweep all load lists through Blocklist.load_lists / from_lists instead of
four copies of the bookkeeping.
- The coverage sweep runs is_safe, the entry point callers use, with the
sibling exact_match list and nltk_data when present.
- The hygiene audit reads lists with the production reader, so a "#" line
that the guardrail enforces is no longer skipped.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The phrase tests called censor_prompt directly, which skips to_ascii. That is how a zero-width spelling of a whitelisted phrase could pass is_safe while these tests stayed green. They now go through is_safe and fail if the raw pass is removed. Adds the "camera shot gun" case from review: a fused match that straddles a whitelisted phrase stays blocked. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…atin ones
A Cyrillic or Greek letter that renders like a Latin one ("sn<U+043E>w" with a
Cyrillic o) survives NFKC, and to_ascii then deletes it, so every pass reads
the word with a letter missing. On the shipped list, 381 of 383 entries pass
when their letters are swapped for such twins.
is_safe now runs its passes once more on a spelling with those letters folded
to Latin. Like the raw pass, this only adds a check, and it never runs on an
ASCII prompt, so English prompts are unaffected by construction.
The table is generated from Unicode's confusables.txt 18.0.0 (UTS NVIDIA#39): 728
code points whose skeleton is a single Latin letter and that NFKC does not
already fold. It is vendored, so there is no new dependency, and
format_table regenerates it exactly.
Folding every listed letter everywhere also rewrites real Russian, Greek and
Ukrainian text into Latin fragments that fuse into entries: in a prototype over
50,000 three-word phrases per language, that blocked 9-11% of them. So a word
is folded only when it looks like a disguised Latin word: it contains a Latin
letter, or every letter in it is a lookalike. That catches the same disguises.
Through is_safe, it blocks 9 Russian, 66 Greek and 33 Ukrainian phrases of
49,998 each that main allowed (0.02-0.13%), and unblocks none.
coverage.py gains a fifth spelling, lookalike, which every entry is probed with.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…s a space NVIDIA#183 rewrites an invisible character to a space, which catches "snow<U+200B>white" as the entry "snow white". The same rewrite splits "sn<U+200B>ow white" into "sn ow white", which matches nothing, while a reader still sees the entry. On the shipped list, 44 of the 383 entries passed when split this way. is_safe now also checks the prompt with invisible characters deleted, and the lookalike fold runs on that reading too. Both readings are checked, so this only adds a check. It runs only on non-ASCII prompts. On the shipped list, the invisible spelling goes from 44 entries passing to 1, and that one passes verbatim too. Over 50,000 three-word phrases per language, the branch blocks no phrase that main allowed in Persian, Arabic or Hebrew. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
|
Review of 1. Fix the phrase-exemption span calculation before merging phrase support.
A static walkthrough gives this synthetic counterexample: The two matches occupy source spans 2. Fix coverage comparison before using it as a no-regression gate.
Please evaluate both versions against the same frozen probe corpus, retain stable opaque probe identities/corpus metadata, and fail or report “incomparable” if probe identities differ. Removing/moving an entry must not silently remove its old probes from the safety comparison. Alternatively, split this tool out rather than treating its current output as a reliable gate. Follow-up, not a new regression: fullwidth/accent hardening still misses terms present only in I4/downstream backport: the production module now imports The phrase mechanism and audit tools can also be split out so the core Unicode fix can proceed independently. These findings are from static code/dependency analysis; I did not execute the project tests or a downstream run. |
One PR in place of #193, #186 and #197, as asked. Those three were closed on 2026-08-13 until CI was fixed. CI on
mainis green now, and all three problems still reproduce onmainat 9214f43.The three PRs were built on b28c027 (2026-08-13). This branch is rebased onto 9214f43 (2026-10-23). None of the 69 commits in between touch the blocklist, so the rebase applied without conflicts. Every number below was measured on the new base.
Every open review point from those PRs is addressed (see the table at the end). Two more gaps of the same kind turned up while measuring, so this PR closes them too: Cyrillic and Greek lookalike letters, and an invisible character inside one word of a multi-word entry.
What this PR combines
The commits from the three closed PRs are kept as they were reviewed and rebased without changes, so each one can still be read on its own. The commits marked "new" were written for this PR.
to_asciideletes themdesk inhygiene.py: flags entries that two ordinary words fuse intocoverage.py: measures what a list change stops catchingis_safe; adds zero-width and phrase-boundary tests#193, #186 and #197 stay closed. #201 (reasoner video decode) is not included: it is unrelated code and is on hold separately.
The problems, on today's
mainAll of these were checked with the shipped
Cosmos-Guardrail1lists and the real WordNet lemmatizer, throughis_safe.maina snow white poster(fullwidth)a snöw white poster(accent)a sn<U+043E>w white poster(Cyrillic o)a sn<U+200B>ow white poster(zero-width inside a word)a boston·dynamics robot(middle dot)a robot sitting at a desk in the backgrounddesk inis whitelisteda robot at a desk<U+200B>in the back(zero-width), withdesk inwhitelisteda camera shot gun in frame, withcamera shotwhitelistedis_safe(was fix(guardrail): fold code points before to_ascii erases them #193).is_saferunsto_asciifirst, which deletes non-ASCII characters instead of folding them. So the folding added in fix(guardrail): whitelist restore corrupts the censored prompt, and invisible characters evade the blocklist #183 never sees them.is_safe(new). A Cyrillic or Greek letter drawn like a Latin one survives NFKC, andto_asciideletes it. On the shipped list, 381 of 383 entries pass when spelled this way.snow<U+200B>white. The same rule turnssn<U+200B>ow whiteintosn ow white, which matches nothing. 44 entries are affected.desk innever reaches it.What changes
is_safeonly adds passes; it never moves or removes one. Nothing thatmainblocks can pass, which is the property @lfengad suggested on fix(guardrail): fold code points before to_ascii erases them #193.dynamicsintodynamic, so the second pass cannot rescue it.lookalikes.py) is generated from Unicode'sconfusables.txt18.0.0 (UTS A2v sound encoder inference #39). It holds 728 code points whose skeleton is one Latin letter and that NFKC does not already fold. It is vendored, so there is no new dependency, andformat_tableregenerates it exactly.camera shot gunis still caught whencamera shotis whitelisted." $ ' * @when they are compared with a phrase, because better_profanity's leetspeak mapping uses those characters.desk in.coverage.pywrites five spellings of every entry and runs them throughis_safe: verbatim, split by a space, split by a zero-width space, fullwidth, and lookalike. It is run before and after a list change, and--compareexits non-zero if anything that was blocked now passes. Results are keyed by index, so entry text is never printed.hygiene.pyflags entries that split into two dictionary words, which is how ordinary prose collides with them. It reads lists with the production reader.__init__, the tests,hygiene.pyandcoverage.pyall load lists throughBlocklist.load_lists/from_lists. Before this, there were four hand-copied versions, and the copies are where the bugs found on fix(guardrail): do not block prose whose words fuse into a blocklist entry #177 lived.Measured on the shipped list
383 entries × 5 spellings = 1915 prompts, run through
is_safewith the production lists.Nothing that
mainblocks passes with this PR, checked prompt by prompt. Whitelistingdesk ingives up none of the 1915 spellings.The one entry that passes in every row also passes verbatim on
main. The entry text itself contains punctuation, so the matcher can never produce it. That is a data issue in the list and is left to its owners. Which entry it is can be shared privately.Cost on ordinary non-English text. Cosmos prompts are English, and an ASCII prompt is never touched. To bound the cost anyway, every run of three consecutive words from a 50,000-word frequency list per language (49,998 phrases each) was run through
is_safe, onmainand on this branch:All of the new blocks come from the lookalike fold. The invisible-character reading adds none.
Known limits
sn ow white), 44 entries, the same asmain. Joining words back together would also join ordinary words, which is the false-positive problem from fix(guardrail): let the whitelist hold phrases, so ordinary prose stops matching blocklist entries #186. It needs its own measurement, so it is left for a follow-up.a desk, in the corneranda desk. In the backgroundstay blocked even withdesk inwhitelisted, because the fused match spans the punctuation differently from the phrase. This errs strict.Review points from #177, #186 and #193
to_ascii; testboston·dynamicsis_safe, notcensor_promptis_safe. Removing the raw pass fails the fullwidth and accent tests.camera shot gun)camera shot gunandin gunboundary tests added.hygiene.pyL260#lines are skipped by the audit but loaded by productionhygiene.pycoverage.py --compare__init__bookkeepingload_listspath for all callers.string.punctuationoverlaps the leetspeak charactersdesk inis left to the list owners.Tests
pytest cosmos_framework/auxiliary/guardrail/blocklist/: 99 passed.ruff checkandruff format --check(0.12.7) are clean.pre-commit, base config and main config: all hooks pass, and no files are modified.mkdocs build --strictis clean.is_saferuns on their prompts. All 14 example prompts underinputs/,defaults/andexamples/pass on bothmainand this branch. They are all ASCII.Ask for the list owners
The phrase mechanism does nothing until a phrase is added. The whitelist lives in the
nvidia/Cosmos-Guardrail1checkpoint, not in this repo. @lfengad, could you point us to the owners of that list? Two things are waiting on them:desk in, once this merges;Supersedes #193, #186 and #197.
🤖 Generated with Claude Code