Skip to content

FEAT: Add PuzzledConverter (word-puzzle jailbreak from arXiv:2508.01306)#2255

Closed
shashank03-dev wants to merge 9 commits into
microsoft:mainfrom
shashank03-dev:feature/puzzled-converter
Closed

FEAT: Add PuzzledConverter (word-puzzle jailbreak from arXiv:2508.01306)#2255
shashank03-dev wants to merge 9 commits into
microsoft:mainfrom
shashank03-dev:feature/puzzled-converter

Conversation

@shashank03-dev

Copy link
Copy Markdown

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:

  • It picks which words to hide. It prefers a built-in list of harm-related words, then a supplementary list (both from the paper's Table 4), then nouns and verbs via spaCy, and falls back to word length when the spaCy model isn't installed. spaCy is already a project dependency and there's no hard requirement on it.
  • It encodes those words as a word search, an anagram, or a crossword. The output is reproducible because all randomness comes from a random.Random you can seed.
  • Each hidden word gets a clue with its length and part of speech. If you pass a 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 Converter interface, 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.py and the citation to doc/references.bib and doc/bibliography.md. The converter cell runs offline; I didn't regenerate the paired .ipynb because other cells in that notebook need live model endpoints.

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
Copilot AI review requested due to automatic review settings July 22, 2026 18:41

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 PuzzledConverter plus supporting keyword masking and puzzle-building utilities under pyrit/converter/puzzled/.
  • Adds a converter seed prompt template and exports PuzzledConverter from pyrit.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.

Comment thread pyrit/converter/puzzled/puzzled_converter.py Outdated
Comment thread pyrit/converter/puzzled/puzzled_converter.py Outdated
Comment thread doc/code/converters/1_text_to_text_converters.py
Comment thread pyrit/converter/puzzled/puzzled_converter.py Outdated
@shashank03-dev

Copy link
Copy Markdown
Author

@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
Copilot AI review requested due to automatic review settings July 22, 2026 18:58

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 13 out of 13 changed files in this pull request and generated 5 comments.

Comment thread pyrit/converter/puzzled/puzzled_converter.py
Comment thread pyrit/converter/puzzled/puzzled_converter.py
Comment thread pyrit/converter/__init__.py
Comment thread pyrit/converter/puzzled/keyword_masker.py Outdated
Comment thread pyrit/converter/puzzled/puzzled_converter.py
- 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
Copilot AI review requested due to automatic review settings July 22, 2026 19:16

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 PuzzledConverter to the public converter exports is good, but the converter guidelines also require updating the modality table in doc/code/converters/0_converters.ipynb and its paired .py file when introducing a new converter. PuzzledConverter is not yet referenced there, so it will be missing from the published converter list.
from pyrit.converter.puzzled import PuzzledConverter

Comment thread pyrit/converter/puzzled/keyword_masker.py Outdated
Comment thread pyrit/converter/puzzled/keyword_masker.py
- 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
Copilot AI review requested due to automatic review settings July 22, 2026 19:31

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.ipynb and its paired 0_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

Comment thread tests/unit/converter/test_puzzled_keyword_masker.py Outdated
Comment thread tests/unit/converter/test_puzzled_converter.py
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.
Copilot AI review requested due to automatic review settings July 22, 2026 19:43

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.ipynb and the paired .py file) in addition to exporting from pyrit/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

Comment thread pyrit/converter/puzzled/puzzled_converter.py
Comment thread pyrit/converter/puzzled/puzzled_converter.py Outdated
Copilot AI review requested due to automatic review settings July 22, 2026 19:57

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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_prompt can legitimately return zero masked_words (e.g., when num_to_mask=0). In that case words is empty and the subsequent puzzle builders will raise ValueError (via _normalize) with a confusing message. Handle the empty-mask case explicitly (either as a no-op or with a clear ValueError).
        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

Comment thread pyrit/converter/puzzled/puzzled_converter.py
Comment thread doc/code/converters/1_text_to_text_converters.py
Copilot AI review requested due to automatic review settings July 22, 2026 20:31

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.ipynb and its paired .py percent file). This PR adds PuzzledConverter to the public API here, but it is not yet present in 0_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

Comment thread pyrit/converter/puzzled/puzzled_converter.py
Comment on lines 361 to +367
"# 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>
Copilot AI review requested due to automatic review settings July 22, 2026 20:47
@shashank03-dev

Copy link
Copy Markdown
Author

Closing in favor of #2256, which contains the same change squashed into a single clean commit. Same implementation and PR description; nothing dropped.

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 13 out of 13 changed files in this pull request and generated 4 comments.

Comment on lines +138 to +149
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
Comment on lines +242 to +246
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):
Comment thread doc/bibliography.md
: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]
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.

Add a PUZZLED converter: word-puzzle jailbreak (arXiv:2508.01306)

2 participants