Skip to content

fix(guardrail): let the whitelist hold phrases, so prose stops matching entries - #313

Draft
rwagwani wants to merge 4 commits into
NVIDIA:mainfrom
rwagwani:feat/guardrail-whitelist-phrases
Draft

rwagwani wants to merge 4 commits into
NVIDIA:mainfrom
rwagwani:feat/guardrail-whitelist-phrases

Conversation

@rwagwani

Copy link
Copy Markdown
Collaborator

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 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.

What changes

  • A whitelist entry with a space in it is a phrase. A prompt is cleared only if every match lies entirely inside an occurrence of a whitelisted phrase. Matches that straddle a phrase boundary, and matches anywhere else in the prompt, keep it 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 the message are exactly what they were. This has its own early return. The production whitelist has no phrases today, so the mechanism is inert until list owners add one.
  • Load-time warning: a whitelisted word that the matcher censors anyway, as another entry's leetspeak variant (flat from fiat), 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 nicecamera and shotgun, the whitelist is nice camera shot and gun show, and the prompt is nice camera shot gun show. The guess split it as nice 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:

  • Greedy matching: the matcher is greedy, so with deskin and in gun both listed, a desk in gun only ever reports desk in. The in gun match never appears in the censored output. The old "second occurrence" test passed only because the miscounted span happened to block it.
  • One-letter final word: better_profanity ignores a one-letter word at the very end of its input. So a span such as batma n, plan b or a one-letter entry, censored on its own, matched nothing, and a whitelisted phrase elsewhere in the prompt cleared it (snow white batma n more with snow white whitelisted). 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.
  • The width is bounded by the matcher's own MAX_NUMBER_COMBINATIONS.
  • The exemption is decided per match.
  • Fail closed: if the prompt as a whole was censored but no span on its own matches, it stays blocked.

Results:

Check Result
Your example blocked
Adjacent, overlapping, three-in-a-row, punctuation, zero-width, fullwidth and one-letter-ending cases blocked (parametrized tests through is_safe)
Repeated exempt phrases (a desk in, a desk in the corner) allowed. The old count-based spans blocked them.
Old span code against the new tests the new tests fail
Removing both the appended word and the fail-closed guard the one-letter tests fail; either guard alone makes them pass
Randomized checks on the final code, 160,000 prompts in total (see below) 0 wrong passes
The same randomized checks on the previous head they find wrong passes

The randomized checks:

  • Three generators:
    • a collision-heavy vocabulary (desk in shot gun nice camera show bat man …, with punctuation and case);
    • the same vocabulary with one-letter words, leetspeak and hyphenated tokens added;
    • an unrelated phrase followed by random tails.
  • Compared against main and a brute-force reference that knows nothing about the span code.
  • They confirm three things:
    1. Without phrases, the result equals main.
    2. With phrases, nothing is blocked that main allows.
    3. Whenever a phrase clears a prompt that main blocks, 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:

  • a 32-token chunk that the matcher clears in one call is skipped;
  • results are cached across the readings is_safe checks;
  • past 4,000 matcher calls the prompt stays blocked, as it is without phrase support.

Measured with the shipped lists plus a desk in phrase (main is 0.01–0.13 s on all of these):

Prompt This PR
300-token ordinary prompt with one phrase about 0.7–0.9 s
800 tokens of repeated phrase + a non-ASCII character 2.1 s (was 25.5 s before the bound)
800 tokens of distinct split entries 1.7 s

Known limits

  • The exempted spelling itself passes. That is inherent to clearing the false positive.
  • The phrase also clears its lemmatized forms (desks in), because the exemption applies on the lemmatized pass too.
  • Punctuation inside the phrase is not exempted (a desk, in the corner stays blocked). This errs strict.
  • No phrase is added to the production whitelist here. Adding desk in is a data change for the list owners.

Tests

🤖 Generated with Claude Code

rwagwani and others added 4 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>
…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

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.

1 participant