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: 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", 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: diff --git a/tests/unit/scenario/core/test_scenario_crescendo_resume.py b/tests/unit/scenario/core/test_scenario_crescendo_resume.py index fa6fefbd29..d04cb41f00 100644 --- a/tests/unit/scenario/core/test_scenario_crescendo_resume.py +++ b/tests/unit/scenario/core/test_scenario_crescendo_resume.py @@ -22,6 +22,7 @@ ConversationRetryReason, ConversationType, ScenarioRunState, + ScoringExpectation, SeedObjective, ) from pyrit.prompt_target import OpenAIChatTarget, PromptTarget @@ -449,14 +450,29 @@ async def test_crescendo_scenario_retry_and_resume_use_real_components_without_s assert all(_REFUSAL not in json.dumps(message) for message in objective_messages[-1]) scorer_messages = _message_contents(scorer_requests) scorer_inputs = [messages[-1]["content"] for messages in scorer_messages] - assert "conversation_objective: A prompt" in scorer_inputs[0] + assert f"conversation_objective: {_OBJECTIVE_A}\n" in scorer_inputs[0] assert "response_to_evaluate_input: RECOVERED A" in scorer_inputs[0] - assert "conversation_objective: B refused prompt" in scorer_inputs[1] + assert f"conversation_objective: {_OBJECTIVE_B}\n" in scorer_inputs[1] assert f"response_to_evaluate_input: {_REFUSAL}" in scorer_inputs[1] - assert "conversation_objective: B recovery prompt" in scorer_inputs[2] + assert f"conversation_objective: {_OBJECTIVE_B}\n" in scorer_inputs[2] assert "response_to_evaluate_input: RECOVERED B" in scorer_inputs[2] all_pieces = await memory.get_message_pieces_async() + pieces_by_id = {piece.id: piece for piece in all_pieces} + refusal_scores = [ + score for score in await memory.get_scores_async(score_type="true_false") if score.score_category == ["refusal"] + ] + assert len(refusal_scores) == 3 + expected_refusals = { + "RECOVERED A": (_OBJECTIVE_A, False), + _REFUSAL: (_OBJECTIVE_B, True), + "RECOVERED B": (_OBJECTIVE_B, False), + } + for score in refusal_scores: + objective, refused = expected_refusals[pieces_by_id[score.message_piece_id].converted_value] + assert score.scored_expectation == ScoringExpectation(objective=objective) + assert score.get_value() is refused + assert score.score_rationale == "controlled test score" assert len({piece.id for piece in all_pieces}) == len(all_pieces) assert len({piece.conversation_id for piece in recovered_messages}) == 1 assert len({piece.conversation_id for piece in pruned_messages}) == 1 @@ -739,6 +755,10 @@ async def objective_side_effect_async(**kwargs: Any) -> ChatCompletion: adversarial_messages = _message_contents(adversarial_requests) assert adversarial_messages[1] == adversarial_messages[2] assert adversarial_messages[3] == adversarial_messages[4] + scorer_messages = _message_contents(scorer_requests) + assert len(scorer_messages) == 7 + for messages, objective in zip(scorer_messages, [_OBJECTIVE_A, *[_OBJECTIVE_B] * 6], strict=True): + assert f"conversation_objective: {objective}\n" in messages[-1]["content"] assert [call.kwargs["scenario_run_state"] for call in update_state.call_args_list] == [ ScenarioRunState.IN_PROGRESS, ScenarioRunState.CANCELLED,