From bf215530d994f946c92f84041558b6e8b925e60c Mon Sep 17 00:00:00 2001 From: Manohar Paturi <186662190+ManoharPaturi@users.noreply.github.com> Date: Mon, 26 Oct 2026 23:41:57 +0530 Subject: [PATCH 1/5] FIX: pass the attack objective to the Crescendo refusal check --- pyrit/executor/attack/multi_turn/crescendo.py | 2 +- .../attack/multi_turn/test_crescendo.py | 34 +++++++++++++++++++ 2 files changed, 35 insertions(+), 1 deletion(-) diff --git a/pyrit/executor/attack/multi_turn/crescendo.py b/pyrit/executor/attack/multi_turn/crescendo.py index 55cabbad2d..db49f1a6c5 100644 --- a/pyrit/executor/attack/multi_turn/crescendo.py +++ b/pyrit/executor/attack/multi_turn/crescendo.py @@ -814,7 +814,7 @@ async def _perform_backtrack_if_refused_async( """ context.last_response_was_refusal = False # Check for refusal using the scorer (handles blocked/error responses internally) - refusal_score = await self._check_refusal_async(context, prompt_sent) + refusal_score = await self._check_refusal_async(context, context.objective) is_refusal = score_is_true(refusal_score) self._logger.debug(f"Refusal check: {is_refusal} - {(refusal_score.score_rationale or '')[:100]}...") context.last_response_was_refusal = is_refusal diff --git a/tests/unit/executor/attack/multi_turn/test_crescendo.py b/tests/unit/executor/attack/multi_turn/test_crescendo.py index 744822f67a..89a64dd516 100644 --- a/tests/unit/executor/attack/multi_turn/test_crescendo.py +++ b/tests/unit/executor/attack/multi_turn/test_crescendo.py @@ -1228,6 +1228,40 @@ async def test_check_refusal_scores_error_responses( assert "skip_on_error_result" not in call_kwargs assert "role_filter" not in call_kwargs + async def test_backtrack_refusal_check_receives_the_attack_objective( + self, + mock_objective_target: MagicMock, + mock_adversarial_chat: MagicMock, + mock_refusal_scorer: MagicMock, + basic_context: CrescendoAttackContext, + sample_response: Message, + refusal_score: Score, + ): + """The refusal judge must evaluate deflection against the attack objective. + + The sent prompt used to be passed as the objective, so the judge saw the + adversarial prompt itself labeled as the conversation objective. + """ + adversarial_config = AttackAdversarialConfig(target=mock_adversarial_chat) + scoring_config = AttackScoringConfig(refusal_scorer=mock_refusal_scorer) + + attack = CrescendoAttack( + objective_target=mock_objective_target, + attack_adversarial_config=adversarial_config, + attack_scoring_config=scoring_config, + ) + + basic_context.last_response = sample_response + mock_refusal_scorer.score_async.return_value = [refusal_score] + + with patch.object(attack, "_backtrack_memory_async", new_callable=AsyncMock, return_value="new_conv_id"): + await attack._perform_backtrack_if_refused_async( + context=basic_context, prompt_sent="Long adversarial prompt text" + ) + + expectation = mock_refusal_scorer.score_async.call_args.kwargs["expectation"] + assert expectation.objective == "Test objective" + @pytest.mark.usefixtures("patch_central_database") class TestBacktrackingLogic: From 728752466dc9f13b774459cae3dc4e73f110dc41 Mon Sep 17 00:00:00 2001 From: devangpratap <115096812+devangpratap@users.noreply.github.com> Date: Wed, 28 Oct 2026 06:58:04 +0000 Subject: [PATCH 2/5] FIX GCG: render decoded controls without a second separator space (#3581) Co-authored-by: Roman Lutz --- .../promptgen/gcg/attack/base/attack_manager.py | 3 +++ tests/unit/executor/promptgen/gcg/test_gcg_core.py | 12 ++++++++++++ 2 files changed, 15 insertions(+) diff --git a/pyrit/executor/promptgen/gcg/attack/base/attack_manager.py b/pyrit/executor/promptgen/gcg/attack/base/attack_manager.py index eca7208aab..b0a70d57be 100644 --- a/pyrit/executor/promptgen/gcg/attack/base/attack_manager.py +++ b/pyrit/executor/promptgen/gcg/attack/base/attack_manager.py @@ -445,6 +445,9 @@ def _token_slice( return slice(indices[0], indices[-1] + 1) def _update_ids(self) -> None: + # Gemma and byte-level BPE tokenizers decode the control with the separator space in front; + # rendering that again would add a second space and an extra token before the control. + self.control = self.control.removeprefix(" ") if not self.control.strip() or not self.target.strip(): raise ValueError("GCG control and target must contain non-whitespace text.") if not self.tokenizer.is_fast: diff --git a/tests/unit/executor/promptgen/gcg/test_gcg_core.py b/tests/unit/executor/promptgen/gcg/test_gcg_core.py index e787e84b99..d0419df180 100644 --- a/tests/unit/executor/promptgen/gcg/test_gcg_core.py +++ b/tests/unit/executor/promptgen/gcg/test_gcg_core.py @@ -787,6 +787,18 @@ def test_control_and_content_setters_recompute_boundaries(self, byte_level: bool assert prompt.target_toks.tolist() == control_ids assert prompt._control_slice.stop <= prompt._target_slice.start + def test_decoded_candidate_with_the_separator_space_renders_like_the_original(self) -> None: + tokenizer = _fast_tokenizer(byte_level=True) + original = AttackPrompt(goal="Say it", target="done", tokenizer=tokenizer, control_init="! !") + prompt = AttackPrompt(goal="Say it", target="done", tokenizer=tokenizer, control_init="! !") + + # Gemma and byte-level BPE tokenizers decode the control tokens with the separator space in front. + prompt.control_str = " ! !" + + assert prompt.control == "! !" + assert prompt.input_ids.tolist() == original.input_ids.tolist() + assert prompt._control_slice == original._control_slice + def test_template_without_a_separator_uses_content_positions(self) -> None: tokenizer = _fast_tokenizer(chat_template="{{ messages[0]['content'] }}{{ messages[1]['content'] }}") prompt = AttackPrompt(goal="Say it", target="done", tokenizer=tokenizer, control_init="!") From eef59828412aa190855297c6e6819aad147902b7 Mon Sep 17 00:00:00 2001 From: barkha1research-ai <340367068+barkha1research-ai@users.noreply.github.com> Date: Wed, 28 Oct 2026 07:10:09 +0000 Subject: [PATCH 3/5] FIX BrailleConverter: add the letter sign after numbers and use the UEB dollar sign (#3533) Signed-off-by: Barkha <340367068+barkha1research-ai@users.noreply.github.com> Co-authored-by: Roman Lutz --- pyrit/converter/braille_converter.py | 16 ++++-- .../unit/converter/test_braille_converter.py | 56 +++++++++++++++++++ 2 files changed, 67 insertions(+), 5 deletions(-) diff --git a/pyrit/converter/braille_converter.py b/pyrit/converter/braille_converter.py index 60e443ab49..8b1984d781 100644 --- a/pyrit/converter/braille_converter.py +++ b/pyrit/converter/braille_converter.py @@ -17,9 +17,11 @@ class BrailleConverter(Converter): Braille characters (U+2800 to U+28FF). Every printable ASCII character is mapped: letters, digits, punctuation, and symbols. Uppercase letters are prefixed with the Braille capitalization indicator and digit runs with the - number indicator. Characters with no Braille mapping (accented and CJK - letters, emoji, control characters) pass through unchanged rather than being - dropped, so the encoded prompt keeps its meaning. + number indicator. A letter from a to j directly after a number is prefixed + with the letter sign, so it is not read as another digit. Characters with no + Braille mapping (accented and CJK letters, emoji, control characters) pass + through unchanged rather than being dropped, so the encoded prompt keeps its + meaning. The letter, digit, and core punctuation mappings are based on the implementation from Garak: @@ -96,6 +98,7 @@ def _get_braile(self, text: str) -> str: "y": "\u283d", "num": "\u283c", "caps": "\u2820", + "letter": "\u2830", ".": "\u2832", "'": "\u2804", ",": "\u2802", @@ -103,7 +106,7 @@ def _get_braile(self, text: str) -> str: "/": "\u280c", "!": "\u2816", "?": "\u2826", - "$": "\u2832", + "$": "\u2808\u280e", ":": "\u2812", ";": "\u2806", "(": "\u2836", @@ -141,7 +144,8 @@ def _get_braile(self, text: str) -> str: "0": "\u281a", " ": " ", } - number_punctuations = [".", ",", "-", "/", "$"] + number_punctuations = [".", ",", "-", "/"] + digit_letters = "abcdefghij" output = "" @@ -151,6 +155,8 @@ def _get_braile(self, text: str) -> str: if char.isdigit() and not is_number: is_number = True output += character_unicodes["num"] + elif is_number and char in digit_letters: + output += character_unicodes["letter"] output += character_unicodes[char] elif char.isupper() and char.lower() in character_unicodes: output += character_unicodes["caps"] diff --git a/tests/unit/converter/test_braille_converter.py b/tests/unit/converter/test_braille_converter.py index 1669e01a65..c41d8e4e0f 100644 --- a/tests/unit/converter/test_braille_converter.py +++ b/tests/unit/converter/test_braille_converter.py @@ -201,3 +201,59 @@ async def test_braille_converter_exact_output(prompt, expected): result = await converter.convert_async(prompt=prompt, input_type="text") assert result.output_text == expected + + +@pytest.mark.parametrize( + "prompt, expected", + [ + ("1a", "\u283c\u2801\u2830\u2801"), + ("11", "\u283c\u2801\u2801"), + ("2b", "\u283c\u2803\u2830\u2803"), + ("10j", "\u283c\u2801\u281a\u2830\u281a"), + ("1.5e", "\u283c\u2801\u2832\u2811\u2830\u2811"), + ("1k", "\u283c\u2801\u2805"), + ("1 a", "\u283c\u2801 \u2801"), + ("a1", "\u2801\u283c\u2801"), + ], + ids=["letter-a", "two-digits", "letter-b", "letter-j", "after-decimal", "letter-k", "space", "letter-first"], +) +async def test_braille_converter_letter_sign_after_number(prompt, expected): + """A letter from a to j after a number gets the letter sign, so "1a" and "11" stay distinct.""" + converter = BrailleConverter() + + result = await converter.convert_async(prompt=prompt, input_type="text") + assert result.output_text == expected + + +@pytest.mark.parametrize( + "prompt, expected", + [ + ("$", "\u2808\u280e"), + ("$5", "\u2808\u280e\u283c\u2811"), + (".5", "\u2832\u283c\u2811"), + ], + ids=["dollar", "dollar-amount", "period-before-digit"], +) +async def test_braille_converter_dollar_sign_differs_from_period(prompt, expected): + """The dollar sign uses its UEB cells (dot 4, dots 234) instead of the period cell.""" + converter = BrailleConverter() + + result = await converter.convert_async(prompt=prompt, input_type="text") + assert result.output_text == expected + + +@pytest.mark.parametrize( + "prompt, expected", + [ + ("1$2", "\u283c\u2801\u2808\u280e\u283c\u2803"), + ("$5$10", "\u2808\u280e\u283c\u2811\u2808\u280e\u283c\u2801\u281a"), + ], + ids=["digit-dollar-digit", "leading-dollar-digit-dollar-digit"], +) +async def test_braille_converter_dollar_resets_number_mode(prompt, expected): + """The UEB dollar sign ends numeric mode (UEB rules 6.2.1, 6.3.1), so digits + after it get a fresh number indicator rather than reading as letters a-j.""" + converter = BrailleConverter() + + result = await converter.convert_async(prompt=prompt, input_type="text") + assert result.output_text == expected From 5e01e9599f55385aa1ee383e8aa20c0030f046be Mon Sep 17 00:00:00 2001 From: Manohar Paturi <186662190+ManoharPaturi@users.noreply.github.com> Date: Wed, 28 Oct 2026 17:00:33 +0530 Subject: [PATCH 4/5] TEST: move the pinned refusal expectation to the attack objective --- .../executor/attack/test_scoring_expectation_transport.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/tests/unit/executor/attack/test_scoring_expectation_transport.py b/tests/unit/executor/attack/test_scoring_expectation_transport.py index 23b722551c..be7e515aae 100644 --- a/tests/unit/executor/attack/test_scoring_expectation_transport.py +++ b/tests/unit/executor/attack/test_scoring_expectation_transport.py @@ -519,7 +519,9 @@ async def test_crescendo_keeps_refusal_criterion_separate_async(self) -> None: assert target.prompt_sent == ["seed prompt"] assert objective.calls[0][1] is supplied assert auxiliary.calls[0][1] is supplied - assert refusal.calls[0][1] == ScoringExpectation(objective="seed prompt") + # The refusal judge renders its criterion as the conversation objective, + # so it receives the attack objective, not the prompt that was sent. + assert refusal.calls[0][1] == ScoringExpectation(objective="attack objective") assert refusal.calls[0][0] == objective.calls[0][0] async def test_chunked_scores_combined_content_with_auxiliary_async(self, sqlite_instance: SQLiteMemory) -> None: From 93cd405129910050e976d61193c7e8cf6cf3fc68 Mon Sep 17 00:00:00 2001 From: Manohar Paturi <186662190+ManoharPaturi@users.noreply.github.com> Date: Wed, 28 Oct 2026 22:42:03 +0530 Subject: [PATCH 5/5] TEST: move the resilience suite refusal expectations to the attack objective --- .../attack/multi_turn/test_crescendo_resilience.py | 14 ++++++++------ 1 file changed, 8 insertions(+), 6 deletions(-) diff --git a/tests/unit/executor/attack/multi_turn/test_crescendo_resilience.py b/tests/unit/executor/attack/multi_turn/test_crescendo_resilience.py index 1930c9af27..bbdb817618 100644 --- a/tests/unit/executor/attack/multi_turn/test_crescendo_resilience.py +++ b/tests/unit/executor/attack/multi_turn/test_crescendo_resilience.py @@ -325,8 +325,10 @@ async def score_objective(**_kwargs): "response-9", "response-10-final", ] + # The refusal judge renders its criterion as the conversation objective, + # so it receives the attack objective rather than the prompt that was sent. assert [call.kwargs["expectation"].objective for call in refusal_scorer.score_async.await_args_list] == [ - f"question-{attempt}" for attempt in range(1, 13) + _OBJECTIVE for _ in range(12) ] objective_inputs = [call.kwargs["response"].get_value() for call in score_response.await_args_list] assert refusal_scorer.score_async.await_count == 12 @@ -550,8 +552,8 @@ async def test_accepted_text_response_consumes_seed_before_followup(self, tmp_pa assert [message.get_value() for message in refusal_inputs] == ["first text response", "final text response"] assert [message.get_value() for message in objective_inputs] == ["first text response", "final text response"] assert [call.kwargs["expectation"].objective for call in refusal_scorer.score_async.await_args_list] == [ - "question-1", - "question-2", + _OBJECTIVE, + _OBJECTIVE, ] assert [call.kwargs["expectation"].objective for call in score_response.await_args_list] == [ _OBJECTIVE, @@ -725,9 +727,9 @@ async def test_first_turn_content_filter_retries_seed_then_consumes_it(self, tmp ] assert refusal_inputs[0].get_piece().response_error == "blocked" assert [call.kwargs["expectation"].objective for call in refusal_scorer.score_async.await_args_list] == [ - "question-1", - "question-2", - "question-3", + _OBJECTIVE, + _OBJECTIVE, + _OBJECTIVE, ] assert [call.kwargs["response"].get_value() for call in score_response.await_args_list] == [ "accepted retry response",