Skip to content

fix(guardrail): fold fullwidth, accented, lookalike and invisible spellings before to_ascii erases them - #306

Open
rwagwani wants to merge 2 commits into
NVIDIA:mainfrom
rwagwani:fix/guardrail-blocklist-combined
Open

rwagwani wants to merge 2 commits into
NVIDIA:mainfrom
rwagwani:fix/guardrail-blocklist-combined

Conversation

@rwagwani

@rwagwani rwagwani commented Oct 23, 2026 •

Copy link
Copy Markdown
Collaborator

Blocklist hardening against fullwidth, accented, lookalike and invisible spellings. Following @lfengad's review, this PR is now only the Unicode fix. The whitelist phrases and the list-maintenance tools have moved to their own pull requests, stacked on this one:

Rebased onto main at 0169db5 (2026-10-26). No commit since the previous base touches the blocklist.

The problem, on today's main

is_safe runs to_ascii before any check, and to_ascii deletes every non-ASCII character. A disguised spelling of an entry therefore reaches the matcher with letters missing and passes, while a reader still sees the entry.

Prompt (entry snow white) main This PR
a snow white poster (fullwidth) passes blocked
a snöw white poster (accent) passes blocked
a sn<U+043E>w white poster (Cyrillic o) passes blocked
a sn<U+200B>ow white poster (zero-width inside a word) passes blocked
a boston·dynamics robot (middle dot, entry boston dynamics) blocked blocked

The exact-match list had the same gap, and that is fixed here too (see below).

What changes

is_safe only adds passes. It never moves or removes one, so nothing main blocks can pass.

  • A new first pass sees the prompt as written. censor_prompt folds code points there (NFKC, combining marks removed) before matching. The existing to_ascii pass, the lemmatized pass and the exact-match check still run unchanged after it.

  • Other readings of a non-ASCII prompt go through the whole chain of passes. These are:

    • the code points folded;
    • invisible characters deleted, not only read as a space;
    • letters from other scripts that are drawn like Latin ones folded to Latin.

    An ASCII prompt never takes this path, and a test pins that.

  • lookalikes.py is a vendored table of 728 code points, generated from Unicode confusables.txt 18.0.0. A word is folded only if it contains a Latin letter or is made entirely of lookalikes, which keeps ordinary Russian, Greek and Ukrainian text from being rewritten.

  • One list-loading path. __init__ and the new from_lists both go through load_lists, so the tests run the real is_safe over the real bookkeeping.

Review follow-up: exact-match entries (commit 9a7f792)

Thank you for catching this. The exact-match check ran only after to_ascii, and alternate_spellings() left out the plain folded reading. So with custom ["deskin"] and exact-match ["chromebook"], the fullwidth and accented spellings of chromebook passed. The folded reading is now included, so the whole chain, exact-match included, runs on it. New tests cover fullwidth, precomposed and decomposed accents, lookalike and invisible spellings, and ordinary text that must stay allowed. Without the change, the fullwidth and both accent cases fail.

Measured with the shipped Cosmos-Guardrail1 lists

All prompts go through is_safe with the production lists and the real WordNet lemmatizer. Each entry gets six spellings in a photo of {entry} in the scene. The table counts entries that still pass:

Spelling custom list (383): main custom: this PR exact-match list (1414): main exact-match: previous head 5119bef exact-match: this PR
verbatim 1 1 0 0 0
split by a space 44 44 862 862 862
zero-width split 44 1 862 0 0
fullwidth 367 1 1394 1323 0
accent 93 1 1313 1278 0
lookalike 381 1 1410 0 0
  • No spelling that main blocks passes with this PR. This was checked prompt by prompt.
  • The one custom entry that passes in every row also passes verbatim on main. It contains punctuation, so the matcher can never produce it. That is a list-data issue for the owners.
  • The space-split row is unchanged on purpose. Joining ordinary words back together is the false-positive problem the phrase PR deals with.
  • Ordinary text: 1459 prompts collected from this repository's example inputs and our test captions (127 of them non-ASCII). They give the same result on main and on this PR: 0 newly blocked, 0 newly allowed.
  • From the earlier measurement on the previous head: over 49,998 three-word phrases per language, the lookalike fold newly blocks Russian 9, Greek 66, Ukrainian 33, and Persian, Arabic and Hebrew 0. Nothing is newly allowed. That code is unchanged here.

Tests

  • pytest cosmos_framework/auxiliary/guardrail/blocklist/: 72 passed.
  • ruff check and ruff format --check are clean, and pre-commit on the changed files passes.
  • Mutation checks:
    • Removing the folded reading fails the 3 fullwidth and accent exact-match tests.
    • Removing the alternate readings altogether fails 17 tests.
    • With the folded reading added, the raw first pass is now also covered by it on non-ASCII prompts. Removing the raw pass alone no longer fails a test. It is kept as the additive pass suggested on fix(guardrail): fold code points before to_ascii erases them #193.

Backport note (I4 / downstream)

blocklist.py now imports the new cosmos_framework/auxiliary/guardrail/blocklist/lookalikes.py. Please carry that file, and adapt its import, together with the other mapped blocklist files. An existing file manifest will not include it.

🤖 Generated with Claude Code

@lfengad

lfengad commented Oct 26, 2026

Copy link
Copy Markdown
Collaborator

Review of 5119bef: the Unicode hardening fixes real blocklist bypasses and is worth landing. I would keep the feedback scoped as follows—two correctness fixes if the phrase/audit features remain in this PR, one follow-up gap, and one downstream dependency requirement.

1. Fix the phrase-exemption span calculation before merging phrase support.

censored_token_spans() assigns the total remaining token-count reduction to the current censored match. With multiple collapsed matches, it attributes later matches' swallowed tokens to the first match.

A static walkthrough gives this synthetic counterexample:

blocklist:  ["nicecamera", "shotgun"]
whitelist:  ["nice camera shot", "gun show"]
prompt:     "nice camera shot gun show"

The two matches occupy source spans (0,2) and (2,4); the helper infers (0,3) and (3,4). Both inferred spans fall inside whitelist phrases, so the cross-boundary shot gun match can be incorrectly exempted. Please obtain reliable match/source spans rather than reconstructing each width from the global token-count difference, and add an end-to-end is_safe() regression for multiple matches/phrases and boundary-crossing combinations. This matters when phrase whitelisting is configured; it is not a claim that the unchanged shipped whitelist already triggers this case.

2. Fix coverage comparison before using it as a no-regression gate.

measure() identifies prompts only by entry index and spelling. List edits or reordering can make the same key refer to a different prompt. The CLI ignores missing keys and returns success whenever lost is empty, including potentially incomparable result sets.

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 exact_match. That check runs after to_ascii(), while alternate_spellings() omits the normalized-only reading. For example, with custom ["deskin"] and exact-match ["chromebook"], the fullwidth exact-only term loses its letters before the exact check. Include the normalized reading in the complete check chain, or explicitly scope and track this as a separate hardening follow-up.

I4/downstream backport: the production module now imports lookalikes.py. Carry that new helper and adapt its import together with the existing mapped files; do not assume an existing file manifest includes newly added production dependencies. This is a backport requirement, not a missing file in this public PR.

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.

rwagwani and others added 2 commits October 26, 2026 11:02
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>
@rwagwani
rwagwani force-pushed the fix/guardrail-blocklist-combined branch from 5119bef to 9a7f792 Compare October 26, 2026 12:58
@rwagwani rwagwani changed the title fix(guardrail): harden the blocklist against lookalike, fullwidth and invisible spellings; whitelist phrases; measure coverage fix(guardrail): fold fullwidth, accented, lookalike and invisible spellings before to_ascii erases them Oct 26, 2026
@rwagwani

Copy link
Copy Markdown
Collaborator Author

@lfengad Thank you very much for such a careful review. I reproduced every point with the real matcher and the shipped Cosmos-Guardrail1 lists, and all of them hold. I really appreciate you taking the time to trace them through the code.

I've taken your suggestion and split the PR, so the core Unicode fix can proceed on its own:

Point by point:

1. Phrase exemption spans: fixed in #313. You were right. With your example, the old head allows nice camera shot gun show. Fixing it turned up two more cases of the same kind:

  • The matcher is greedy, so with deskin and in gun both listed, the in gun inside a desk in gun never appears in the censored output at all. The old "second occurrence" test was passing only by accident.
  • better_profanity ignores a one-letter word at the very end of its input, so a span such as plan b checked on its own matched nothing.

Matches are now found by trying each span against the matcher itself, with a word after it. A prompt clears only if every match lies inside a phrase, and it stays blocked if a censored prompt yields no locatable match. Your example is now a test, alongside parametrized multiple-match, boundary-crossing and one-letter cases through is_safe. Randomized checks of 160,000 prompts against main and a brute-force reference find 0 wrong passes on the new code, while the same checks find wrong passes on the old head. The cost is bounded: past a fixed number of matcher calls, a prompt simply stays blocked, as it is today.

2. coverage.py comparability: fixed in #314. Thank you, that was a real hole. Probes now have stable, opaque ids: a digest of the entry, plus the spelling. The probe set can be frozen (--write-probes) and replayed against both versions (--probes), so a removed entry keeps its probes and shows up as lost. Each result also records digests of the lists it measured. --compare now exits 2 with "incomparable" whenever the probe sets or probe ids differ, or a file is not a run, and never reports a clean result in that case.

3. Exact-match follow-up: fixed here, in 9a7f792. The folded reading now goes through the whole chain of passes. On the shipped exact-match list (1414 entries), the number that still pass when spelled in fullwidth goes from 1394 on main (1323 on the previous head) to 0. For accents it goes from 1313 to 0. Your deskin / chromebook example is now a test.

4. Backport: noted in the description. blocklist.py now imports the new lookalikes.py. I've added a backport note asking that it be carried, and its import adapted, together with the mapped files.

On the shipped lists, nothing that main blocks passes with this PR, checked prompt by prompt. A corpus of 1459 ordinary prompts gives identical results on main and here. The updated description has the full tables.

Could you please take another look at #306 when you have a moment? There is no rush on the two stacked PRs; they can wait until this one is settled. Thank you again for the thorough review.

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants