Skip to content

Commit b28c027

Browse files
authored
fix(guardrail): re-land #183, reverted by the 2026-08-13 i4 release (#195)
# Re-land #183, which the 2026-08-13 release reverted #183 merged as 277c0f1 on 2026-08-13 at 10:52 UTC. The very next commit on `main` — **Release 2026-08-13 (i4 b5c36d02) (#194)**, an automated mirror of the internal i4 tree cut from a source commit that predates the merge — rewrote both files back to their pre-#183 contents. `main` today is byte-identical to `cd5e450`, the commit *before* #183 landed: ``` $ git diff cd5e450 origin/main -- cosmos_framework/auxiliary/guardrail/blocklist/ (no output) ``` `normalize_for_matching()` is gone, the misaligned `uncensor_whitelist()` is back, and `blocklist_test.py` has dropped from 32 tests to 8. Both defects described in #183 are live on `main` again — including the `IndexError` that a benign prompt like `'Snow White is flat'` raises against the production lists. ## What this PR does Restores the #183 state exactly, nothing more: ``` $ git diff 277c0f1 -- cosmos_framework/auxiliary/guardrail/blocklist/ (no output) ``` Two files, 258 insertions, 16 deletions — the same diff that was reviewed and approved on #183. #194 touches no other guardrail file, and no caller outside `blocklist.py` references either changed method, so there is nothing to reconcile between the two changes. ## Recap of what comes back 1. **The whitelist restore corrupted the censored prompt.** `censor_prompt` walked the input and the censored text side by side *by position* to put whitelisted words back, but a multi-word blocklist entry is replaced by a single censor token, so every such match shifts the censored list one token left. `'Snow White is flat'` → `IndexError`; `'a Snow White poster on a flat wall'` → the user is quoted `'a **** poster on a flat flat'`, with `wall` silently overwritten. The whitelist is already handed to `load_censor_words(whitelist_words=...)`, so the restore step is **removed** rather than repaired. 2. **Invisible characters walked past the matcher.** `normalize_for_matching()` folds them before censoring — NFKC, combining marks dropped, invisible and bidirectional controls rewritten to a space, whitespace runs collapsed. Without it a zero-width space, soft hyphen, fullwidth letter, or a second space evades a blocked phrase while rendering identically to a reader. As stated on #183, this blocks strictly more than before. Homoglyphs remain out of scope. The full analysis, the corpus measurement (same 4 of 492 Edge reasoner QA outputs block before and after, item for item), and the test-by-test rationale are on #183 and unchanged. ## Verification `blocklist_test.py` is back to 32 tests; all 32 pass locally against this branch. The tests build the matcher directly, so they need no checkpoint. ## Note for whoever owns the i4 → OSS sync This will happen again on the next release unless the change is also landed in the internal i4 tree, or the guardrail blocklist files are excluded from the mirror. Re-landing it here fixes `main` today; it does not stop the next automated release from reverting it a second time. 🤖 Generated with [Claude Code](https://claude.com/claude-code)
1 parent 08961b9 commit b28c027

2 files changed

Lines changed: 258 additions & 16 deletions

File tree

cosmos_framework/auxiliary/guardrail/blocklist/blocklist.py

Lines changed: 34 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@
44
import argparse
55
import os
66
import re
7-
import string
7+
import unicodedata
88
from difflib import SequenceMatcher
99

1010
import nltk
@@ -30,6 +30,12 @@
3030
# never used to decide whether something was censored.
3131
CENSOR = misc.Color.red("*")
3232

33+
# Characters that occupy no width, or that only reorder what is drawn: zero-width
34+
# spaces and joiners, the soft hyphen, and the bidirectional controls. They render
35+
# as nothing, so a blocklist entry split by one still reads as the entry to a human
36+
# while reaching the matcher as a token it does not recognise.
37+
INVISIBLE_CHARS = re.compile(r"[­​-‏‪-‮⁠-⁤]")
38+
3339

3440
class Blocklist(ContentSafetyGuardrail):
3541
def __init__(
@@ -61,16 +67,29 @@ def __init__(
6167
log.debug(f"Whitelisted {len(self.whitelist_words)} words/phrases from whitelist")
6268
log.debug(f"Loaded {len(self.exact_match_words)} exact match words/phrases from blocklist")
6369

64-
def uncensor_whitelist(self, input_prompt: str, censored_prompt: str) -> str:
65-
"""Explicitly uncensor words that are in the whitelist."""
66-
input_words = input_prompt.split()
67-
censored_words = censored_prompt.split()
68-
whitelist_words = set(self.whitelist_words)
69-
for i, token in enumerate(input_words):
70-
if token.strip(string.punctuation).lower() in whitelist_words:
71-
censored_words[i] = token
72-
censored_prompt = " ".join(censored_words)
73-
return censored_prompt
70+
@staticmethod
71+
def normalize_for_matching(prompt: str) -> str:
72+
"""Fold away the ways a word can be written to look normal but not match.
73+
74+
The matcher compares whitespace-delimited tokens, so anything that splits
75+
a word without being visible, or that spells its letters with a different
76+
code point, walks past it while the prompt still reads as the blocked word.
77+
Three foldings, all of them lossless as far as a reader is concerned:
78+
79+
* compatibility normalization (NFKC), which maps fullwidth and other
80+
presentation forms onto the ASCII letters they are drawn as;
81+
* removal of combining marks, so an accent added to a letter does not
82+
make a new word;
83+
* invisible characters rewritten to a space, then runs of whitespace
84+
collapsed, so a word split by any of them is matched as the split it
85+
renders as.
86+
87+
The collapse also closes a plainer hole: a multi-word entry was defeated
88+
by typing two spaces between its words.
89+
"""
90+
prompt = unicodedata.normalize("NFKC", prompt)
91+
prompt = "".join(c for c in unicodedata.normalize("NFKD", prompt) if not unicodedata.combining(c))
92+
return " ".join(INVISIBLE_CHARS.sub(" ", prompt).split())
7493

7594
def censor_prompt(self, input_prompt: str) -> tuple[bool, str]:
7695
"""Censor the prompt using the blocklist with better-profanity fuzzy matching.
@@ -89,9 +108,11 @@ def censor_prompt(self, input_prompt: str) -> tuple[bool, str]:
89108
# than substituting keeps the stricter reading: "n\x00ike" fuses back to
90109
# a blocked word instead of being split into two harmless tokens.
91110
input_prompt = input_prompt.replace(CENSOR_SENTINEL, "")
111+
input_prompt = self.normalize_for_matching(input_prompt)
112+
# The whitelist is handed to load_censor_words(), so the matcher already
113+
# leaves whitelisted words alone. Restoring them a second time here is
114+
# what introduced the token misalignment described in the commit message.
92115
censored_prompt = self.profanity.censor(input_prompt, censor_char=CENSOR_SENTINEL)
93-
# Uncensor whitelisted words that were censored from blocklist fuzzy matching
94-
censored_prompt = self.uncensor_whitelist(input_prompt, censored_prompt)
95116
if CENSOR_SENTINEL in censored_prompt:
96117
display_prompt = censored_prompt.replace(CENSOR_SENTINEL, CENSOR)
97118
return True, f"Prompt blocked by censorship: Censored Prompt: {display_prompt}"

cosmos_framework/auxiliary/guardrail/blocklist/blocklist_test.py

Lines changed: 224 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,8 @@
11
# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
22
# SPDX-License-Identifier: OpenMDW-1.1
33

4+
import unicodedata
5+
46
import pytest
57

68
from cosmos_framework.auxiliary.guardrail.blocklist.blocklist import Blocklist
@@ -45,18 +47,19 @@ def test_partial_match_with_threshold():
4547
assert match is False
4648

4749

48-
def _blocklist_with_words(words: list[str]) -> Blocklist:
50+
def _blocklist_with_words(words: list[str], whitelist: list[str] | None = None) -> Blocklist:
4951
"""Build a Blocklist around a small word list, without downloading a checkpoint.
5052
5153
censor_prompt only needs the profanity matcher and the whitelist, so the
5254
heavyweight __init__ (checkpoint download, nltk data) is bypassed here.
5355
"""
5456
from better_profanity.better_profanity import Profanity
5557

58+
whitelist = whitelist or []
5659
bl = Blocklist.__new__(Blocklist)
5760
bl.profanity = Profanity()
58-
bl.profanity.load_censor_words(custom_words=words, whitelist_words=[])
59-
bl.whitelist_words = []
61+
bl.profanity.load_censor_words(custom_words=words, whitelist_words=whitelist)
62+
bl.whitelist_words = whitelist
6063
return bl
6164

6265

@@ -126,3 +129,221 @@ def test_blocked_word_is_still_detected_alongside_markdown():
126129
# blocked word is masked.
127130
assert "**bold**" in message
128131
assert "badword" not in message
132+
133+
134+
@pytest.mark.L1
135+
def test_multi_word_match_beside_whitelisted_word_does_not_crash():
136+
"""A multi-word blocklist hit must not make a later whitelisted word crash.
137+
138+
Regression test: the censored text has one token per match, so a two-word
139+
entry made it one token shorter than the input. The whitelist restore walked
140+
the two lists by position, so a whitelisted word after the match indexed past
141+
the end of the censored list and raised IndexError on an ordinary prompt.
142+
"""
143+
bl = _blocklist_with_words(["snow white"], whitelist=["flat"])
144+
145+
blocked, message = bl.censor_prompt("Snow White is flat")
146+
147+
assert blocked is True
148+
assert "is flat" in message
149+
150+
151+
@pytest.mark.L1
152+
def test_multi_word_match_does_not_rewrite_a_later_word():
153+
"""The reported prompt must quote the input, not a shifted copy of it.
154+
155+
Same off-by-N as above, but landing inside the list instead of past its end:
156+
the write went to the wrong token and silently replaced it.
157+
"""
158+
bl = _blocklist_with_words(["snow white"], whitelist=["flat"])
159+
160+
blocked, message = bl.censor_prompt("a Snow White poster on a flat wall")
161+
162+
assert blocked is True
163+
assert "on a flat wall" in message
164+
assert "flat flat" not in message
165+
166+
167+
@pytest.mark.L1
168+
def test_several_multi_word_matches_stay_aligned():
169+
"""Every additional multi-word hit shifts the two lists one token further."""
170+
bl = _blocklist_with_words(["snow white", "boston dynamics"], whitelist=["flat"])
171+
172+
blocked, message = bl.censor_prompt("Snow White and Boston Dynamics on a flat wall")
173+
174+
assert blocked is True
175+
assert "on a flat wall" in message
176+
177+
178+
@pytest.mark.L1
179+
def test_whitelisted_word_is_not_censored():
180+
"""The whitelist still works: a whitelisted word is reported as written."""
181+
bl = _blocklist_with_words(["snow white"], whitelist=["flat"])
182+
183+
blocked, message = bl.censor_prompt("a Snow White poster on a flat desk")
184+
185+
assert blocked is True
186+
assert "a flat desk" in message
187+
188+
189+
@pytest.mark.L1
190+
def test_whitelisted_word_alone_does_not_block():
191+
"""A prompt with only whitelisted words is safe."""
192+
bl = _blocklist_with_words(["snow white"], whitelist=["flat"])
193+
194+
blocked, message = bl.censor_prompt("the floor is flat")
195+
196+
assert blocked is False
197+
assert message == ""
198+
199+
200+
@pytest.mark.L1
201+
def test_whitelisted_word_inside_a_blocked_phrase_stays_censored():
202+
"""A whitelisted word must not dissolve a phrase that genuinely matched.
203+
204+
'snow flat' is on the blocklist and 'flat' is whitelisted. The phrase is the
205+
match, so it stays censored; whitelisting a word does not license the phrase
206+
that contains it.
207+
"""
208+
bl = _blocklist_with_words(["snow flat"], whitelist=["flat"])
209+
210+
blocked, message = bl.censor_prompt("a snow flat scene")
211+
212+
assert blocked is True
213+
assert "snow flat" not in message
214+
215+
216+
@pytest.mark.L1
217+
def test_whitelisted_word_at_the_end_of_the_prompt():
218+
"""The final token is where the positional walk ran off the end."""
219+
bl = _blocklist_with_words(["snow white"], whitelist=["flat"])
220+
221+
blocked, message = bl.censor_prompt("a snow white poster flat")
222+
223+
assert blocked is True
224+
assert message.endswith("poster flat")
225+
226+
227+
@pytest.mark.L1
228+
def test_repeated_multi_word_matches():
229+
"""Two hits in a row shift the alignment twice over."""
230+
bl = _blocklist_with_words(["snow white"], whitelist=["flat"])
231+
232+
blocked, message = bl.censor_prompt("snow white snow white flat wall")
233+
234+
assert blocked is True
235+
assert "flat wall" in message
236+
237+
238+
@pytest.mark.L1
239+
def test_punctuation_around_a_whitelisted_word_is_preserved():
240+
"""Punctuation belongs to the token and must survive into the message."""
241+
bl = _blocklist_with_words(["snow white"], whitelist=["flat"])
242+
243+
blocked, message = bl.censor_prompt("snow white, flat, wall.")
244+
245+
assert blocked is True
246+
assert "flat, wall." in message
247+
248+
249+
@pytest.mark.L1
250+
def test_extra_spaces_do_not_defeat_a_multi_word_entry():
251+
"""A two-word entry was evaded by typing two spaces between its words."""
252+
bl = _blocklist_with_words(["snow white"])
253+
254+
blocked, _ = bl.censor_prompt("a snow white poster")
255+
256+
assert blocked is True
257+
258+
259+
@pytest.mark.L1
260+
@pytest.mark.parametrize(
261+
("name", "prompt"),
262+
[
263+
("zero width space", "a snow​white poster"),
264+
("zero width non joiner", "a snow‌white poster"),
265+
("soft hyphen", "a snow­white poster"),
266+
("right to left override", "a snow‮white poster"),
267+
("word joiner", "a snow⁠white poster"),
268+
("byte order mark", "a snowwhite poster"),
269+
],
270+
)
271+
def test_invisible_characters_do_not_split_a_blocked_phrase(name, prompt):
272+
"""An invisible character renders as nothing, so the prompt still reads as the entry.
273+
274+
Splitting "snow white" with one of these produced a token the matcher did not
275+
recognise, while a reader saw the blocked phrase unchanged.
276+
"""
277+
bl = _blocklist_with_words(["snow white"])
278+
279+
blocked, _ = bl.censor_prompt(prompt)
280+
281+
assert blocked is True, f"{name} was not folded away"
282+
283+
284+
@pytest.mark.L1
285+
@pytest.mark.parametrize(
286+
("name", "prompt"),
287+
[
288+
("non breaking space", "a snow white poster"),
289+
("en quad", "a snow white poster"),
290+
("ideographic space", "a snow white poster"),
291+
("narrow no break space", "a snow white poster"),
292+
],
293+
)
294+
def test_unusual_spaces_do_not_split_a_blocked_phrase(name, prompt):
295+
"""Any Unicode space between the words of an entry must still match."""
296+
bl = _blocklist_with_words(["snow white"])
297+
298+
blocked, _ = bl.censor_prompt(prompt)
299+
300+
assert blocked is True, f"{name} was not folded away"
301+
302+
303+
@pytest.mark.L1
304+
def test_fullwidth_letters_are_folded_to_ascii():
305+
"""Fullwidth forms are drawn as the ASCII letters they normalize to."""
306+
bl = _blocklist_with_words(["snow white"])
307+
308+
blocked, _ = bl.censor_prompt("a snow white poster")
309+
310+
assert blocked is True
311+
312+
313+
@pytest.mark.L1
314+
def test_combining_marks_do_not_make_a_new_word():
315+
"""An accent added to a letter must not create a word the blocklist misses."""
316+
bl = _blocklist_with_words(["snow white"])
317+
318+
for prompt in ("a snów white poster", unicodedata.normalize("NFD", "a snów white poster")):
319+
blocked, _ = bl.censor_prompt(prompt)
320+
assert blocked is True, f"{prompt!r} was not folded away"
321+
322+
323+
@pytest.mark.L1
324+
def test_normalization_does_not_block_ordinary_text():
325+
"""Folding must not invent matches in text that contains none."""
326+
bl = _blocklist_with_words(["snow white"], whitelist=["flat"])
327+
328+
for prompt in (
329+
"a robot on a flat desk",
330+
"an em dash — and an arrow → in the text",
331+
"café scene with Élodie",
332+
"snow and white are separate words here",
333+
"こんにちは from the model",
334+
):
335+
blocked, message = bl.censor_prompt(prompt)
336+
assert blocked is False, f"{prompt!r} was wrongly reported as blocked: {message}"
337+
338+
339+
@pytest.mark.L1
340+
def test_normalization_preserves_the_earlier_sentinel_and_markdown_behaviour():
341+
"""Folding runs after the sentinel strip and must not resurrect the "*" bug."""
342+
bl = _blocklist_with_words(["badword"])
343+
344+
blocked, _ = bl.censor_prompt("A **bold** heading with ​ in it")
345+
assert blocked is False
346+
347+
blocked, message = bl.censor_prompt("a bad\x00word in the text")
348+
assert blocked is True
349+
assert "badword" not in message

0 commit comments

Comments
 (0)