Skip to content

feat(guardrail): add list-maintenance tools that measure blocklist changes - #314

Draft
rwagwani wants to merge 5 commits into
NVIDIA:mainfrom
rwagwani:feat/guardrail-blocklist-tools
Draft

rwagwani wants to merge 5 commits into
NVIDIA:mainfrom
rwagwani:feat/guardrail-blocklist-tools

Conversation

@rwagwani

Copy link
Copy Markdown
Collaborator

Stacked on the whitelist-phrases PR (#313), which is stacked on #306. This PR's own change is the last commit, ce2d6e4.

Split out of #306 at @lfengad's suggestion. These are list-maintenance tools only: nothing in the request path imports them.

What it adds

  • hygiene.py flags blocklist entries that two ordinary dictionary words fuse into, which is how prose collides with an entry (desk in → deskin). It reads the lists with the production reader, and its probe checks what it advises. By default it quotes only a few trademark examples that are safe to quote; everything else needs --show-entries.
  • coverage.py measures what a list change stops catching. It writes five spellings of every blocklist entry (verbatim, split by a space, split by a zero-width space, fullwidth, lookalike) and runs them through is_safe, the deployed entry point. When they sit beside the lists, it also uses the exact-match list and the nltk data.

Review follow-up: comparable results

Thank you for pointing out that results were keyed by entry index and spelling, and that --compare ignored keys present on only one side. Together these let a removed or reordered entry drop out of the comparison with a clean exit. Now:

  • Probe ids are stable and opaque: a SHA-256 digest of the entry, plus the spelling. Moving an entry changes nothing, and entry text is never printed.
  • The probe set can be frozen with --write-probes probes.json, then replayed against both versions with --probes probes.json. An entry removed from the list keeps its probes, so its spellings show up as lost. A probe file edited after it was written is refused. The probe file holds the prompts in plain text, so it should be kept with the lists, not attached to a ticket.
  • Every result file records metadata: the digest of the probe set it ran, plus digests of the blocklist, whitelist and exact-match lists and the lemmatizer used. --compare prints any of those that changed.
  • --compare exits 2 with incomparable if the two runs used different probe sets, report different probe ids, or either file is not a run with probes. It never reports "lost 0" in those cases.
coverage.py <blocklist-dir> --write-probes probes.json
coverage.py <blocklist-dir> --probes probes.json --whitelist-dir <whitelist-dir> --json before.json
coverage.py <changed-dir>   --probes probes.json --whitelist-dir <changed-whitelist-dir> --json after.json
coverage.py --compare before.json after.json      # 0 nothing lost, 1 lost, 2 incomparable

New tests cover:

  • a removed entry showing as lost over a frozen set;
  • the same removal without a frozen set exiting 2;
  • a reordered list comparing cleanly;
  • each incomparable shape, including malformed and empty result files;
  • an edited probe file being refused;
  • case variants of an entry keeping their own probes;
  • the recorded metadata.

Run on the shipped lists

  • The shipped list gives 1890 probes; exact duplicate entries share one id.
  • Adding desk in as a phrase, over the frozen set: lost 0, gained 0, exit 0.

Known limits

  • Exact-match entries are not probed, only blocklist entries.

Tests

  • pytest cosmos_framework/auxiliary/guardrail/blocklist/: 142 passed.
  • ruff and pre-commit (both CI configs, all files) are clean.

🤖 Generated with Claude Code

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

hygiene.py flags blocklist entries that two ordinary dictionary words fuse
into, which is how prose collides with an entry ("desk in" -> "deskin"). It
reads lists with the production reader and checks what it advises.

coverage.py measures what a list change stops catching. It writes five
spellings of every entry (verbatim, split by a space, split by a zero-width
space, fullwidth, lookalike) and runs them through is_safe, the deployed entry
point, with the exact-match list and nltk data beside the lists when present.

Review on NVIDIA#306 pointed out that keying probes by entry index and spelling,
and ignoring keys present on only one side, let a removed or moved entry drop
out of the comparison with a clean exit. Probes are now identified by a
digest of the entry plus the spelling, so position does not matter. The
probe set can be frozen with --write-probes and replayed with --probes, so an
entry removed from the list keeps its probes and shows up as lost. Every
result file records the digest of the probe set it ran, and --compare exits
2 with "incomparable" when the two runs did not evaluate the same probes,
instead of reporting nothing lost. A file that is not a run, or holds no
probes, is also incomparable rather than a crash or a clean result. Probe
ids keep the entry's case, so case variants keep their own probes, and each
result records digests of the lists it measured and the lemmatizer used, and
--compare prints any of those that changed.

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>

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