FEAT: Add PuzzledConverter (word-puzzle jailbreak from arXiv:2508.01306)#2255
FEAT: Add PuzzledConverter (word-puzzle jailbreak from arXiv:2508.01306)#2255shashank03-dev wants to merge 9 commits into
Conversation
Implements the PUZZLED jailbreak (Ahn & Lee, arXiv:2508.01306) as a text-to-text converter. It masks a prompt's sensitive words and re-encodes them as a word search, anagram, or crossword that the target solves and reconstructs before following the instruction. - keyword masking with built-in harm-word lists, spaCy POS tagging, and a length-based fallback when the spaCy model is absent - three deterministic puzzle builders (seeded RNG for reproducibility) - optional LLM-generated semantic clues via converter_target, with a clean fallback to the length/part-of-speech clue - 54 unit tests, a usage example, and the paper citation Closes microsoft#2234
There was a problem hiding this comment.
Pull request overview
Adds a new PUZZLED technique implementation to PyRIT as a text-to-text converter, enabling prompts to be transformed into solvable word puzzles (word search / anagram / crossword) so sensitive terms are not present verbatim in the outgoing prompt.
Changes:
- Introduces
PuzzledConverterplus supporting keyword masking and puzzle-building utilities underpyrit/converter/puzzled/. - Adds a converter seed prompt template and exports
PuzzledConverterfrompyrit.converter. - Adds unit tests and documentation/citation updates for the new converter.
Reviewed changes
Copilot reviewed 12 out of 12 changed files in this pull request and generated 4 comments.
Show a summary per file
| File | Description |
|---|---|
| tests/unit/converter/test_puzzled_puzzle_builders.py | Unit tests for the three puzzle builders and PuzzleType. |
| tests/unit/converter/test_puzzled_keyword_masker.py | Unit tests for masking heuristics, placeholder replacement, and spaCy fallback behavior. |
| tests/unit/converter/test_puzzled_converter.py | Unit tests for converter behavior, determinism, and optional LLM-generated clues. |
| pyrit/datasets/converters/puzzled_converter.yaml | Seed prompt template used to scaffold the final “solve puzzle then execute” prompt. |
| pyrit/converter/puzzled/puzzled_converter.py | Main converter implementation, including optional semantic clue generation and rendering via seed prompt. |
| pyrit/converter/puzzled/puzzle_builders.py | Deterministic puzzle construction helpers (word search, anagram, crossword). |
| pyrit/converter/puzzled/keyword_masker.py | Word selection, ranking, POS tagging (spaCy optional), and placeholder masking logic. |
| pyrit/converter/puzzled/init.py | Public exports for the puzzled subpackage. |
| pyrit/converter/init.py | Exposes PuzzledConverter from the top-level converter package. |
| doc/references.bib | Adds the PUZZLED paper citation entry. |
| doc/code/converters/1_text_to_text_converters.py | Adds a usage example/import for PuzzledConverter. |
| doc/bibliography.md | Adds the new citation key to the hidden citations list. |
|
@microsoft-github-policy-service agree |
- Include the rejected input_type in the ValueError message - Parse the first complete JSON object with raw_decode instead of a greedy regex, so trailing text/braces can't break clue extraction - Load the prompt template once in __init__ to avoid blocking disk I/O in convert_async - Sync the paired .ipynb with the new converter example
…verter # Conflicts: # doc/bibliography.md
- mask_prompt now replaces every occurrence of a chosen word, not just the first, so repeated sensitive words are never left in cleartext - crossword falls back to an anagram when the words share no letters (e.g. a single masked word), which would otherwise emit the word verbatim - cache generated semantic clues per word set to avoid repeated model calls
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 13 out of 13 changed files in this pull request and generated 2 comments.
Comments suppressed due to low confidence (1)
pyrit/converter/init.py:67
- Adding
PuzzledConverterto the public converter exports is good, but the converter guidelines also require updating the modality table indoc/code/converters/0_converters.ipynband its paired.pyfile when introducing a new converter.PuzzledConverteris not yet referenced there, so it will be missing from the published converter list.
from pyrit.converter.puzzled import PuzzledConverter
- mask every occurrence case-insensitively in a single pass, so different casings of a sensitive word (e.g. "hack" vs "Hack") are all masked and an inserted placeholder is never re-matched - correct the mask_prompt docstring, which still described first-occurrence masking
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 13 out of 13 changed files in this pull request and generated 2 comments.
Comments suppressed due to low confidence (1)
pyrit/converter/init.py:68
- A new converter was added/exported here, but the converter guidelines also require updating the converter modality table (
doc/code/converters/0_converters.ipynband its paired0_converters.py) when introducing a new converter. Those files don't appear to be updated in this PR, so the public converter list will be incomplete.
from pyrit.converter.policy_puppetry_converter import PolicyPuppetryConverter, PolicyPuppetryTemplate
from pyrit.converter.puzzled import PuzzledConverter
from pyrit.converter.qr_code_converter import QRCodeConverter
Per .github/instructions/test.instructions.md, unit tests use patch.object / patch.dict rather than pytest's monkeypatch fixture, for consistency with the rest of the suite.
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 13 out of 13 changed files in this pull request and generated 2 comments.
Comments suppressed due to low confidence (1)
pyrit/converter/init.py:67
- New converters are expected to update the converter modality table (
doc/code/converters/0_converters.ipynband the paired.pyfile) in addition to exporting frompyrit/converter/__init__.py(see.github/instructions/converters.instructions.md). This PR adds the export but does not update the modality table, so the public converter list will be incomplete.
from pyrit.converter.puzzled import PuzzledConverter
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 13 out of 13 changed files in this pull request and generated 2 comments.
Comments suppressed due to low confidence (2)
pyrit/converter/puzzled/puzzled_converter.py:165
mask_promptcan legitimately return zeromasked_words(e.g., whennum_to_mask=0). In that casewordsis empty and the subsequent puzzle builders will raiseValueError(via_normalize) with a confusing message. Handle the empty-mask case explicitly (either as a no-op or with a clearValueError).
mask_result = mask_prompt(
prompt,
num_to_mask=self._num_to_mask,
essential_words=self._essential_words,
)
words = [masked.text for masked in mask_result.masked_words]
pyrit/converter/puzzled/puzzled_converter.py:171
- Crossword fallback only checks whether any shared letters exist. It’s still possible for
crossword_symbol_map(words)to be non-empty while one (or more) masked words contains none of the chosen top-3 shared letters, causing that word to be emitted verbatim in the crossword and defeating masking for that word. Consider falling back when any word would be unchanged by the mapping.
# A crossword only hides letters shared across words; with a single word, or words
# that share no letters, it would emit them verbatim, so fall back to an anagram.
puzzle_type = self._puzzle_type
if puzzle_type is PuzzleType.CROSSWORD and not crossword_symbol_map(words):
puzzle_type = PuzzleType.ANAGRAM
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 13 out of 13 changed files in this pull request and generated 2 comments.
Comments suppressed due to low confidence (1)
pyrit/converter/init.py:68
- New converters are required to be added to the converters modality table (
doc/code/converters/0_converters.ipynband its paired.pypercent file). This PR addsPuzzledConverterto the public API here, but it is not yet present in0_converters.*, so the docs list of built-in converters will be incomplete.
from pyrit.converter.pdf_converter import PDFConverter
from pyrit.converter.persuasion_converter import PersuasionConverter
from pyrit.converter.policy_puppetry_converter import PolicyPuppetryConverter, PolicyPuppetryTemplate
from pyrit.converter.puzzled import PuzzledConverter
from pyrit.converter.qr_code_converter import QRCodeConverter
| "# CodeChameleon [@lv2024codechameleon] encrypts and wraps in code\n", | ||
| "code_chameleon = CodeChameleonConverter(encrypt_type=\"reverse\")\n", | ||
| "print(\"CodeChameleon:\", await code_chameleon.convert_async(prompt=prompt)) # type: ignore" | ||
| "print(\"CodeChameleon:\", await code_chameleon.convert_async(prompt=prompt)) # type: ignore\n", | ||
| "\n", | ||
| "# PUZZLED [@ahn2025puzzled] hides sensitive words in a word puzzle the target must solve\n", | ||
| "puzzled = PuzzledConverter(puzzle_type=\"word_search\", seed=1)\n", | ||
| "print(\"Puzzled:\", await puzzled.convert_async(prompt=prompt)) # type: ignore" |
Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
|
Closing in favor of #2256, which contains the same change squashed into a single clean commit. Same implementation and PR description; nothing dropped. |
| global _nlp, _nlp_loaded | ||
| if _nlp_loaded: | ||
| return _nlp | ||
| _nlp_loaded = True | ||
| try: | ||
| import spacy # type: ignore[ty:unresolved-import] | ||
|
|
||
| _nlp = spacy.load("en_core_web_sm") | ||
| except Exception: | ||
| logger.info("spaCy model 'en_core_web_sm' unavailable; using length-based keyword selection instead.") | ||
| _nlp = None | ||
| return _nlp |
| cache_key = tuple(words) | ||
| if cache_key in self._clue_cache: | ||
| return self._clue_cache[cache_key] | ||
|
|
||
| instruction = _CLUE_GENERATION_INSTRUCTION.format(words=", ".join(words)) |
|
|
||
| {{ puzzle_body }} | ||
|
|
||
| Clues (one per hidden word, giving its length and part of speech): |
| :class: hidden-citations | ||
|
|
||
| [@aakanksha2024multilingual; @adversaai2023universal; @andriushchenko2024tense; @anthropic2024manyshot; @aqrawi2024singleturncrescendo; @atr2026; @bethany2024mathprompt; @bhardwaj2023harmfulqa; @bhardwaj2024homer; @boucher2023trojan; @brahman2024coconot; @bryan2025agentictaxonomy; @bullwinkel2025airtlessons; @bullwinkel2025repeng; @bullwinkel2026trigger; @chao2023pair; @chao2024jailbreakbench; @choi2026xlsafetybench; @cui2024orbench; @darkbench2025; @derczynski2024garak; @ding2023wolf; @embracethered2024unicode; @embracethered2025sneakybits; @gehman2020realtoxicityprompts; @ghosh2025aegis; @ghosh2025ailuminate; @gong2025figstep; @gupta2024walledeval; @haider2024phi3safety; @han2024medsafetybench; @han2024wildguard; @hiddenlayer2025policypuppetry; @hines2024spotlighting; @inie2025summon; @ji2023beavertails; @ji2024pkusaferlhf; @jiang2025sosbench; @jones2025computeruse; @kingma2014adam; @li2024drattack; @li2024mossbench; @li2024saladbench; @li2024wmdp; @lin2023toxicchat; @liu2024flipattack; @liu2024mmsafetybench; @lopez2024pyrit; @luo2024jailbreakv; @lv2024codechameleon; @mazeika2023tdc; @mazeika2024harmbench; @mckee2024transparency; @mehrotra2023tap; @microsoft2024skeletonkey; @odin2024; @palaskar2025vlsu; @pfohl2024equitymedqa; @promptfoo2025ccp; @robustintelligence2024bypass; @roccia2024promptintel; @rottger2023xstest; @rottger2025msts; @russinovich2024crescendo; @russinovich2025cca; @russinovich2025price; @scheuerman2025transphobia; @shaikh2022second; @shayegani2025computeruse; @shen2023donotanything; @sheshadri2024lat; @souly2024strongreject; @stok2023ansi; @tan2026comicjailbreak; @tang2025multilingual; @tedeschi2024alert; @vantaylor2024socialbias; @vidgen2023simplesafetytests; @wang2023decodingtrust; @wang2023donotanswer; @wang2025siuo; @wang2026visualleakbench; @wei2023jailbroken; @xie2024sorrybench; @yu2023gptfuzzer; @yuan2023cipherchat; @zeng2024persuasion; @zhang2024cbtbench; @ziems2022mic; @zong2024vlguard; @zou2023gcg] | ||
| [@aakanksha2024multilingual; @adversaai2023universal; @ahn2025puzzled; @andriushchenko2024tense; @anthropic2024manyshot; @aqrawi2024singleturncrescendo; @atr2026; @bethany2024mathprompt; @bhardwaj2023harmfulqa; @bhardwaj2024homer; @boucher2023trojan; @brahman2024coconot; @bryan2025agentictaxonomy; @bullwinkel2025airtlessons; @bullwinkel2025repeng; @bullwinkel2026trigger; @chao2023pair; @chao2024jailbreakbench; @choi2026xlsafetybench; @cui2024orbench; @darkbench2025; @derczynski2024garak; @ding2023wolf; @embracethered2024unicode; @embracethered2025sneakybits; @gehman2020realtoxicityprompts; @ghosh2025aegis; @ghosh2025ailuminate; @gong2025figstep; @gupta2024walledeval; @haider2024phi3safety; @han2024medsafetybench; @han2024wildguard; @hiddenlayer2025policypuppetry; @hines2024spotlighting; @inie2025summon; @ji2023beavertails; @ji2024pkusaferlhf; @jiang2025sosbench; @jones2025computeruse; @kingma2014adam; @li2024drattack; @li2024mossbench; @li2024saladbench; @li2024wmdp; @lin2023toxicchat; @liu2024flipattack; @liu2024mmsafetybench; @lopez2024pyrit; @luo2024jailbreakv; @lv2024codechameleon; @mazeika2023tdc; @mazeika2024harmbench; @mckee2024transparency; @mehrotra2023tap; @microsoft2024skeletonkey; @odin2024; @palaskar2025vlsu; @pfohl2024equitymedqa; @promptfoo2025ccp; @robustintelligence2024bypass; @roccia2024promptintel; @rottger2023xstest; @rottger2025msts; @russinovich2024crescendo; @russinovich2025cca; @russinovich2025price; @scheuerman2025transphobia; @shaikh2022second; @shayegani2025computeruse; @shen2023donotanything; @sheshadri2024lat; @souly2024strongreject; @stok2023ansi; @tan2026comicjailbreak; @tang2025multilingual; @tedeschi2024alert; @vantaylor2024socialbias; @vidgen2023simplesafetytests; @wang2023decodingtrust; @wang2023donotanswer; @wang2025siuo; @wang2026visualleakbench; @wei2023jailbroken; @xie2024sorrybench; @yu2023gptfuzzer; @yuan2023cipherchat; @zeng2024persuasion; @zhang2024cbtbench; @ziems2022mic; @zong2024vlguard; @zou2023gcg] |
Description
Adds
PuzzledConverter, which implements the PUZZLED jailbreak from "PUZZLED: Jailbreaking LLMs through Word-Based Puzzles" (Ahn & Lee, arXiv:2508.01306).Closes #2234. @romanlutz confirmed on the issue that a converter is the right shape for this.
The idea from the paper: instead of asking a model something harmful directly, you hide the sensitive words of the request inside a word puzzle and ask the model to solve the puzzle first, then follow the instruction it just rebuilt. The harmful wording never appears in plain text.
How the converter works:
random.Randomyou can seed.converter_target, it also asks that model for the paper's indirect "hint" clue. If that call fails or returns junk, it falls back to the plain clue.It uses the standard
Converterinterface, so it works with any target, scorer, or other converters.Tests and Documentation
54 unit tests in
tests/unit/converter/cover word selection and masking, all three puzzle builders, and the converter itself (including the optional LLM-clue path and its fallbacks). They're deterministic and don't touch the network.Docs: added a usage example to
doc/code/converters/1_text_to_text_converters.pyand the citation todoc/references.bibanddoc/bibliography.md. The converter cell runs offline; I didn't regenerate the paired.ipynbbecause other cells in that notebook need live model endpoints.