Skip to content

feat: Add BijectionConverter and BijectionAttack - #2301

Open
sajithamathi19 wants to merge 37 commits into
microsoft:mainfrom
sajithamathi19:feat/bijection-attack
Open

feat: Add BijectionConverter and BijectionAttack#2301
sajithamathi19 wants to merge 37 commits into
microsoft:mainfrom
sajithamathi19:feat/bijection-attack

Conversation

@sajithamathi19

@sajithamathi19 sajithamathi19 commented Jul 31, 2026

Copy link
Copy Markdown

Summary

Implements the Bijection Attack from arXiv:2410.01294 (Haize Labs) into PyRIT.

The attack works by teaching a target LLM a secret character mapping through demonstration shots, then sending harmful prompts encoded in that mapping to bypass safety filters. Responses are decoded using the inverse mapping.

Changes

New Files

  • pyrit/converter/bijection_converter.py - abstract base plus LetterBijectionConverter, DigitBijectionConverter, TokenBijectionConverter
  • pyrit/executor/attack/single_turn/bijection_attack.py - runs full bijection attack with teaching phase
  • tests/unit/converter/test_bijection_converter.py
  • tests/unit/executor/test_bijection_attack.py
  • doc/code/executor/attack/bijection_attack.ipynb - usage notebook

Modified Files

  • pyrit/converter/__init__.py - registered converters
  • pyrit/executor/attack/single_turn/__init__.py - registered BijectionAttack

How It Works

  • BijectionConverter generates a random secret mapping (letter, digit, or tokenizer-vocab based)
  • BijectionAttack sends teaching messages to the target to teach the mapping
  • Harmful prompt is encoded and sent as the task
  • Response is decoded using the inverse mapping and scored by the judge

Pattern Followed

  • BijectionConverter follows the FlipConverter pattern
  • BijectionAttack follows the FlipAttack pattern

Reference

Bugs fixed in this resubmission

  • TokenBijectionConverter - the mode @romanlutz originally flagged as producing garbage. Root cause was three separate bugs: no delimiter between encoded units, tokenizer word-initial markers (Ġ/) not handled, and teaching shots using hardcoded letter-cipher instructions even in token mode. All fixed; round-trips correctly now.
  • DigitBijectionConverter - found while re-verifying: digit tokens have no upper/lowercase, so capitalized input was silently lowercased on decode. Added a case-marker prefix so capitalization round-trips correctly.
  • Teaching-example coverage - per @romanlutz's review on feat: Add BijectionConverter and BijectionAttack (#1903) #1942, the original 5-sentence pool only demonstrated 14 of 26 letters. Expanded to 13 sentences covering the full alphabet.

Empirical validation against real models

Tested the full teaching protocol (not just converter round-trip logic) against real targets, since converter correctness and actual attack reliability are two different questions:

Target Coherent decode rate
Gemini 2.5 Flash Lite (small/fast) 2/9
Gemini 2.5 Pro 8/9
GPT-4o 6/9

Capable models now succeed the majority of the time (up from ~1/6 before the alphabet-coverage fix). It's not perfectly reliable - the small/fast model still mostly fails, and some runs on capable models still truncate or have letter-level errors - which tracks with the paper's own "scale-adaptive" framing (attack success is expected to depend on target capability, not be a fixed property of the implementation).

Status

  • CLA signed
  • Rebased against main (picked up the pyrit.prompt_converter -> pyrit.converter rename and other upstream changes); full converter + executor suite (2176 tests) passing
  • Resubmits #1942, which was auto-closed when the source account was accidentally deleted. All prior commit history and review context carries over.

sajithamathi19 and others added 30 commits May 28, 2026 17:14
- _RemoteDatasetLoader._fetch_zip_from_url:
  - keyword-only args (source, inner_files, cache)
  - streams download (requests stream=True + iter_content) to avoid
    double-buffering large archives
  - md5-keyed disk cache under DB_DATA_PATH / seed-prompt-entries when
    cache=True; named temp file otherwise (cleaned up after parse)
  - validates each inner_files extension against FILE_TYPE_HANDLERS;
    raises ValueError with a member preview if an inner file is missing
  - parses inner files via FILE_TYPE_HANDLERS and returns parsed dicts,
    so the open ZipFile never escapes the worker thread
  - adds the missing import zipfile that broke the previous commit
- _MICDataset:
  - drops unused io / json / requests imports (helper handles them)
  - delegates download + parse to the helper; only owns the seed
    construction loop
  - guards non-string Q values (in addition to NaN moral values)
  - forwards cache from fetch_dataset_async to the helper
  - factors authors into AUTHORS class constant
- Tests:
  - test_moral_integrity_corpus_dataset.py: stops mocking requests.get
    directly; patches _fetch_zip_from_url to return parsed dicts so
    tests don't depend on the helper's internal shape
  - adds test_fetch_dataset_non_string_q and
    test_fetch_dataset_passes_cache_flag
  - hoists imports into the right groups so ruff I001 stops firing
  - removes trailing whitespace / extra newlines
- test_remote_dataset_loader.py: adds TestFetchZipFromUrl covering
  happy path, on-disk caching (hits 1 network call across 2 fetches),
  cache=False does not persist, missing inner file raises ValueError,
  unsupported extension raises ValueError

Verified live against the real MIC.zip: 35,408 unique seeds across
all 6 moral foundations in ~2.4s cold / ~1.3s warm. All 559 dataset
unit tests pass; ruff clean.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
- Use tempfile.NamedTemporaryFile instead of fixed temp_audio.wav
  to prevent concurrent call collisions
- Wrap Azure upload in try/finally to ensure temp file is always
  deleted even when upload fails
- Add regression test to verify cleanup on upload failure

Fixes microsoft#1894
- Add BijectionConverter that generates random letter-to-letter mapping
- Add BijectionAttack that teaches the mapping to target AI and encodes harmful prompts
- Add unit tests for both converter and attack
- Add notebook demonstrating usage
- Update __init__.py files to register new classes

Based on arXiv:2410.01294 (Haize Labs bijection-learning)
- Remove @pytest.mark.asyncio decorators (asyncio_mode=auto)
- Fix __init__.py alphabetical ordering for BijectionConverter
- Use patch_central_database fixture in attack tests
- Use MagicMock(spec=PromptTarget) instead of plain MagicMock
- Remove dead num_digits parameter
- Add BijectionType StrEnum for bijection_type validation
- Use private attributes with underscore prefix
- Add _build_identifier() method
- Fix teaching shots cap with programmatic cycling
- Fix alternating user/assistant roles in teaching messages
- Fix response decoding in _perform_async
- Add BijectionConverter to _request_converters pipeline
- Fix notebook format and add paired .py jupytext file
- Register BijectionAttack in executor/attack/__init__.py
- Change Optional[X] to X | None (PEP 604)
- Change bijection_type: str to BijectionType in attack
- Register BijectionType in prompt_converter __init__.py
- Store decoded response in metadata instead of mutating last_response
- Fix teaching shots: user sends English, assistant responds in cipher
- Fix brittle test assertions to check structural properties
- Update end-to-end test to check metadata for decoded response
- Remove duplicate docstring line in bijection_attack.py
- Remove unused SeedPrompt import
- Bump num_teaching_shots default to 10 per paper spec
- Fix DigitBijectionConverter to map letters to digit strings (not digits to digits)
- Add num_digits validation (must be 1-4, raises ValueError)
- Fix bijection_attack.py notebook to correct jupytext format
- Fix bijection_attack.ipynb to use new API (LetterBijectionConverter)
- Add test_digit_converter_encodes_letters test
- Add test_digit_converter_invalid_num_digits test
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
- Rewrite docstrings in imperative mood with Args/Returns/Raises (D401, DOC201, DOC501)
- Add docstrings to public properties and subclass __init__ (D102, D107)
- Use dict comprehension/update and zip(strict=True) (PERF403, B905)
- Type _build_identifier as ComponentIdentifier; import it
- Add type: ignore[ty:invalid-parameter-default] on REQUIRED_VALUE default
- Wrap long intro prompt line (E501) and sort __init__ imports (I001)
- Register bijection_attack.ipynb in doc/myst.yml; strip kernelspec metadata

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
… test

- Add LetterBijectionConverter and DigitBijectionConverter examples to
  doc/code/converters/1_text_to_text_converters (.py and .ipynb)
- Add abstract BijectionConverter to test_converter_documentation exceptions,
  consistent with other abstract base classes

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
BijectionConverter is an abstract base class (ABC with abstractmethod), so
enumerating it broke the converter-service instantiation test. Skip classes
with non-empty __abstractmethods__ in get_converter_modalities so both the
documentation and instantiation tests only see concrete converters.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
- Add Bijection Learning paper to references and cite it from docs/source
- Fix digit bijection decoding for multi-character encoded tokens
- Reject one-digit digit mappings because 26 letters need 26 distinct values
- Add coverage for explicit mappings, identifiers, digit round trips, teaching messages, and attack metadata paths

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
- Use a system setup prompt that tells the target to answer only in the bijection code
- Make the final task instruction explicit about private decoding and coded answers
- Only expose decoded_response metadata when decoding appears to produce English
- Show a skip status for plaintext or invalid cipher responses instead of bogus decoded text
- Re-execute the bijection attack notebook after the semantic fix

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
- Use a system setup message for targets that support system prompts
- Fold setup instructions into the first user teaching shot for targets that do not
- Preserve user/assistant alternation in the fallback path
- Cover unsupported-system and zero-shot fallback behavior in tests

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
- Replace live OpenAIChatTarget usage with a local deterministic demo target
- Keep the notebook executable without external credentials
- Demonstrate a valid bijection-coded response and decoded_response metadata

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
- Restore the live OpenAIChatTarget example and original red-team objective
- Use Azure CLI credential with extended process timeout so notebook execution succeeds locally
- Commit the executed notebook output from the live target run

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
sajithamathi19 and others added 4 commits June 22, 2026 22:18
Encoded letter-tokens were concatenated with no delimiter, collapsing
multi-letter words into unsegmentable strings the target model couldn't
learn to produce or reliably decode. Candidate token selection also
ignored tokenizer-specific word-boundary markers (SentencePiece's leading
marker, GPT-2/RoBERTa's leading marker), so real HF vocabs mostly yielded
subword fragments instead of genuine standalone words.

Join per-letter code words with a hyphen (distinct from the literal
spaces that separate real words, so decode can't confuse the two),
filter candidates using the vocab's actual word-boundary convention,
and derive teaching-shot instructions/examples from the converter
itself instead of duplicating letter-cipher-specific logic.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Digit tokens have no upper/lower distinction, so an uppercase source
letter's case was silently destroyed at encode time ("25".upper() ==
"25"), not just mishandled at decode. Adds a leading case-marker
character before the digit token for uppercase letters; decode()
strips it and restores the case.

Verified against the real installed pyrit package: full converter +
attack suite (57 tests) passing, plus manual round-trip checks across
mixed capitalization, stray literal digits, and num_digits in {2,3,4}.
@sajithamathi19
sajithamathi19 marked this pull request as draft July 31, 2026 06:02
@sajithamathi19

Copy link
Copy Markdown
Author

Hi @romanlutz — resubmitting after my old account was accidentally deleted (all the review history and commits from #1942 are preserved here).

Before pinging you again I wanted to actually re-test this properly rather than just say "tests pass." Two things:

Fixed two real bugs while re-verifying converter correctness against the actual installed package (not just unit-test mocks):

  • TokenBijectionConverter — the one you originally flagged. Three root causes: no delimiter between encoded units, tokenizer word-initial markers (Ġ/) not handled, hardcoded letter-cipher teaching instructions even in token mode. All fixed.
  • DigitBijectionConverter — found this one while re-checking: digit tokens have no case, so capitalized input was silently lowercased. Fixed with a case marker.

Your actual bar — "a working bijection" from a real model response — is still not met. I tested the full teaching protocol against real targets instead of assuming the code fixes were enough:

  • Claude refuses the setup outright before any cipher behavior can occur.
  • Gemini 2.5 Flash Lite: 1/6 trials scored "valid" by the code's own heuristic, and that one was a degenerate repeated-nonsense loop, not real cipher-following. The rest dropped the cipher, looped, or produced undecodable text.
  • Tried reducing complexity (fixed_size) and a stronger model (GPT-4o) — better but still inconsistent, not reliable.

This tracks with the paper describing Bijection Learning as scale-adaptive (success depends on target capability), so I don't think this is something I can fix further in the wrapper code alone. Marked this as a draft rather than claim it's ready.

Would rather have your read on this now than dress it up: is this worth merging with clear documentation that reliability varies by target, or should it wait until it's consistent against some reference model? Full breakdown is in the updated PR description. Thanks for your patience through this one.

Per @romanlutz's review on microsoft#1942: the original 5-sentence pool only
demonstrated 14 of 26 letters (missing b, f, i, j, k, m, p, q, u, v,
x, z), so the target was never shown an encoding for those letters
and had nothing to reproduce when generating cipher-text that needed
them. Expanded to 13 sentences covering the full alphabet; also large
enough that shot counts beyond 5 introduce new coverage instead of
repeating identical demonstrations.

Updated test_teaching_messages_cycle_examples to match the new pool
size, and added test_teaching_messages_do_not_repeat_within_pool_size.

Manual testing against real models (not just unit tests) shows this
measurably improves bijection-following reliability against capable
targets, though reliability still varies significantly by target
model capability -- see PR description for full data.
@sajithamathi19
sajithamathi19 marked this pull request as ready for review July 31, 2026 07:08
@sajithamathi19

Copy link
Copy Markdown
Author

Re-tested against real models with the alphabet-coverage fix now actually in the code (not just the converter round-trip logic — the full teaching protocol as it runs today, default num_teaching_shots=5, no other changes):

Target Coherent decode rate
Gemini 2.5 Flash Lite (small/fast) 2/9
Gemini 2.5 Pro 8/9
GPT-4o 6/9

This is a real improvement — capable models now produce a coherent decoded response the majority of the time, up from roughly 1/6 before this fix. It's not fully reliable (some runs still truncate or have letter-level errors, and the small/fast model still mostly fails), which tracks with the paper's own "scale-adaptive" framing — but this is genuine, measured progress on the exact thing you flagged, not just a claim.

Converted this back to ready for review. Let me know if you want the raw transcripts from these runs, or if you'd rather see this validated against a different reference target before moving forward.

@sajithamathi19

Copy link
Copy Markdown
Author

@microsoft-github-policy-service agree

# Conflicts:
#	doc/bibliography.md
#	doc/code/converters/1_text_to_text_converters.ipynb
#	pyrit/converter/bijection_converter.py
#	pyrit/executor/attack/__init__.py
#	pyrit/executor/attack/single_turn/__init__.py
#	pyrit/prompt_converter/__init__.py
#	tests/unit/converter/test_bijection_converter.py
…labels removal

Following the merge from microsoft:main, the whole pyrit.prompt_converter
package was renamed to pyrit.converter (PromptConverter -> Converter,
PromptConverterConfiguration -> ConverterConfiguration), and
MessagePiece.labels was removed entirely. Updated all bijection
imports/usages and test mocks accordingly.

Full converter + executor suite (2176 tests) passing after the merge.
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.

3 participants