From 9e198b27f83a114890c0e2f396e48a72891e2e14 Mon Sep 17 00:00:00 2001 From: Dev3 ^~^ Date: Sun, 1 Nov 2026 00:45:49 +0530 Subject: [PATCH 1/3] FIX: Do not score write-only target deliveries as attack responses --- doc/code/memory/5_advanced_memory.ipynb | 4 +- .../attack/multi_turn/chunked_request.py | 26 +- pyrit/executor/attack/multi_turn/crescendo.py | 6 + .../attack/multi_turn/multi_prompt_sending.py | 12 +- .../executor/attack/multi_turn/red_teaming.py | 6 + .../attack/multi_turn/tree_of_attacks.py | 20 +- .../attack/single_turn/prompt_sending.py | 13 + pyrit/prompt_normalizer/prompt_normalizer.py | 21 +- .../attack/multi_turn/test_crescendo.py | 21 ++ .../attack/multi_turn/test_tree_of_attacks.py | 34 ++ .../test_write_only_objective_target.py | 343 ++++++++++++++++++ .../test_prompt_normalizer.py | 68 ++++ 12 files changed, 563 insertions(+), 11 deletions(-) create mode 100644 tests/unit/executor/attack/test_write_only_objective_target.py diff --git a/doc/code/memory/5_advanced_memory.ipynb b/doc/code/memory/5_advanced_memory.ipynb index a3cb05881f..314d93d78c 100644 --- a/doc/code/memory/5_advanced_memory.ipynb +++ b/doc/code/memory/5_advanced_memory.ipynb @@ -227,7 +227,7 @@ "\n", "\u001b[1m 🎯 Outcome\u001b[0m\n", "\u001b[33m • Status: ❓ UNDETERMINED\u001b[0m\n", - "\u001b[37m • Reason: No objective scorer configured\u001b[0m\n", + "\u001b[37m • Reason: Objective target returned no response to score\u001b[0m\n", "\n", "\u001b[1m\u001b[44m\u001b[37m Conversation History with Objective Target \u001b[0m\n", "\u001b[34m────────────────────────────────────────────────────────────────────────────────────────────────────\u001b[0m\n", @@ -263,7 +263,7 @@ "\n", "\u001b[1m 🎯 Outcome\u001b[0m\n", "\u001b[33m • Status: ❓ UNDETERMINED\u001b[0m\n", - "\u001b[37m • Reason: No objective scorer configured\u001b[0m\n", + "\u001b[37m • Reason: Objective target returned no response to score\u001b[0m\n", "\n", "\u001b[1m\u001b[44m\u001b[37m Conversation History with Objective Target \u001b[0m\n", "\u001b[34m────────────────────────────────────────────────────────────────────────────────────────────────────\u001b[0m\n", diff --git a/pyrit/executor/attack/multi_turn/chunked_request.py b/pyrit/executor/attack/multi_turn/chunked_request.py index 3bc1784f08..706953c9b1 100644 --- a/pyrit/executor/attack/multi_turn/chunked_request.py +++ b/pyrit/executor/attack/multi_turn/chunked_request.py @@ -292,6 +292,7 @@ async def _perform_async(self, *, context: ChunkedRequestAttackContext) -> Attac # Send each chunk request and collect responses response = None + delivered_without_response = False for idx, chunk_prompt in enumerate(chunk_prompts): logger.info(f"Sending chunk request {idx + 1}/{len(chunk_prompts)}") @@ -319,6 +320,15 @@ async def _perform_async(self, *, context: ChunkedRequestAttackContext) -> Attac send_context=context.prepended_history_send_context, ) + if response and PromptNormalizer.is_write_only_delivery(response): + # The returned request is not a chunk of the target's output, so it must not be + # combined into the value that gets scored. + delivered_without_response = True + response = None + context.chunk_responses.append("") + context.executed_turns += 1 + continue + # Store the response if response: response_text = response.get_value() @@ -334,13 +344,17 @@ async def _perform_async(self, *, context: ChunkedRequestAttackContext) -> Attac combined_value = "\n".join(context.chunk_responses) logger.info(f"Combined {len(context.chunk_responses)} chunk responses") - # Score the combined value if scorer is configured - score = await self._score_combined_value_async( - combined_value=combined_value, objective=context.objective, expectation=context.expectation - ) + if delivered_without_response: + score = None + outcome, outcome_reason = AttackOutcome.UNDETERMINED, "Objective target returned no response to score" + else: + # Score the combined value if scorer is configured + score = await self._score_combined_value_async( + combined_value=combined_value, objective=context.objective, expectation=context.expectation + ) - # Determine the outcome - outcome, outcome_reason = self._determine_attack_outcome(score=score) + # Determine the outcome + outcome, outcome_reason = self._determine_attack_outcome(score=score) # Create attack result return AttackResult( diff --git a/pyrit/executor/attack/multi_turn/crescendo.py b/pyrit/executor/attack/multi_turn/crescendo.py index db49f1a6c5..3718d2ca2e 100644 --- a/pyrit/executor/attack/multi_turn/crescendo.py +++ b/pyrit/executor/attack/multi_turn/crescendo.py @@ -671,6 +671,12 @@ async def _send_prompt_to_objective_target_async( if not response: raise ValueError("No response received from objective target") + if PromptNormalizer.is_write_only_delivery(response): + raise ValueError( + "The objective target returned no response. Crescendo needs a target response " + "for each turn, so it cannot run against a write-only target." + ) + return response async def _check_refusal_async(self, context: CrescendoAttackContext, objective: str) -> Score: diff --git a/pyrit/executor/attack/multi_turn/multi_prompt_sending.py b/pyrit/executor/attack/multi_turn/multi_prompt_sending.py index c62bc5033f..161870c54f 100644 --- a/pyrit/executor/attack/multi_turn/multi_prompt_sending.py +++ b/pyrit/executor/attack/multi_turn/multi_prompt_sending.py @@ -259,6 +259,7 @@ async def _perform_async(self, *, context: MultiTurnAttackContext[Any]) -> Attac response = None score = None + delivered_without_response = False for message_index, current_message in enumerate(context.params.user_messages): logger.info(f"Processing message {message_index + 1}/{len(context.params.user_messages)}") @@ -268,6 +269,12 @@ async def _perform_async(self, *, context: MultiTurnAttackContext[Any]) -> Attac current_message=current_message, context=context ) + if response_message and PromptNormalizer.is_write_only_delivery(response_message): + # Keep delivering the sequence, but the returned request is not a response to score. + delivered_without_response = True + context.executed_turns += 1 + continue + # Update context with latest response (may be None if sending failed) if response_message: response = response_message @@ -297,7 +304,10 @@ async def _perform_async(self, *, context: MultiTurnAttackContext[Any]) -> Attac score = None # Determine the outcome - outcome, outcome_reason = self._determine_attack_outcome(response=response, score=score, context=context) + if delivered_without_response: + outcome, outcome_reason = AttackOutcome.UNDETERMINED, "Objective target returned no response to score" + else: + outcome, outcome_reason = self._determine_attack_outcome(response=response, score=score, context=context) return AttackResult( conversation_id=context.session.conversation_id, diff --git a/pyrit/executor/attack/multi_turn/red_teaming.py b/pyrit/executor/attack/multi_turn/red_teaming.py index 3a29ae464d..261e9c6ebb 100644 --- a/pyrit/executor/attack/multi_turn/red_teaming.py +++ b/pyrit/executor/attack/multi_turn/red_teaming.py @@ -602,6 +602,12 @@ async def _send_prompt_to_objective_target_async( "Please check the target configuration and ensure it is reachable." ) + if PromptNormalizer.is_write_only_delivery(response): + raise ValueError( + "The objective target returned no response. Red teaming needs a target response " + "for each turn, so it cannot run against a write-only target." + ) + return response async def _score_response_async(self, *, context: MultiTurnAttackContext[Any]) -> Score | None: diff --git a/pyrit/executor/attack/multi_turn/tree_of_attacks.py b/pyrit/executor/attack/multi_turn/tree_of_attacks.py index ef541dda3b..a7003c7d9e 100644 --- a/pyrit/executor/attack/multi_turn/tree_of_attacks.py +++ b/pyrit/executor/attack/multi_turn/tree_of_attacks.py @@ -722,6 +722,8 @@ async def _send_prompt_to_target_async(self, prompt: str) -> Message: send_context=self._prepended_history_send_context, ) + self._raise_if_write_only_delivery(response) + # Store the full response so subsequent turns can forward media when supported. self.last_response = response logger.debug(f"Node {self.node_id}: Received response from target") @@ -759,7 +761,8 @@ async def _send_initial_prompt_to_target_async(self) -> Message: - Sets self.last_response to the target's response Raises: - ValueError: If _initial_prompt is None when this method is called + ValueError: If _initial_prompt is None when this method is called, or if the + objective target returned no response. """ if self._initial_prompt is None: raise ValueError("_initial_prompt must be set before calling this method") @@ -811,12 +814,27 @@ async def _send_initial_prompt_to_target_async(self) -> Message: send_context=self._prepended_history_send_context, ) + self._raise_if_write_only_delivery(response) + # Store the full response so subsequent turns can forward media when supported. self.last_response = response logger.debug(f"Node {self.node_id}: Received response from target") return response + def _raise_if_write_only_delivery(self, response: Message) -> None: + """ + Reject a delivered request that the normalizer returned in place of a target response. + + Raises: + ValueError: If the objective target returned no response. + """ + if PromptNormalizer.is_write_only_delivery(response): + raise ValueError( + "The objective target returned no response. Tree of attacks needs a target response " + "to score each node, so it cannot run against a write-only target." + ) + def _rotate_unseeded_single_turn_conversation(self) -> None: """Isolate unseeded single-turn sends without discarding an explicit branch boundary.""" if ( diff --git a/pyrit/executor/attack/single_turn/prompt_sending.py b/pyrit/executor/attack/single_turn/prompt_sending.py index 5e93b3838b..aefa57230e 100644 --- a/pyrit/executor/attack/single_turn/prompt_sending.py +++ b/pyrit/executor/attack/single_turn/prompt_sending.py @@ -248,6 +248,19 @@ async def _perform_async(self, *, context: SingleTurnAttackContext[Any]) -> Atta self._logger.warning(f"No response received on attempt {attempt + 1} (likely filtered)") continue # Retry if no response (filtered or error) + if PromptNormalizer.is_write_only_delivery(response): + # The request was delivered but the target returned nothing to score. Retrying would + # deliver it again without ever producing a response, and scoring the request itself + # would measure the attacker's own prompt. + return self._create_attack_result( + context=context, + response=None, + score=None, + outcome=AttackOutcome.UNDETERMINED, + outcome_reason="Objective target returned no response to score", + executed_turns=1, + ) + # Score the response including auxiliary and objective scoring score = await self._evaluate_response_async( response=response, objective=context.objective, expectation=context.expectation diff --git a/pyrit/prompt_normalizer/prompt_normalizer.py b/pyrit/prompt_normalizer/prompt_normalizer.py index 2ff851811f..3fe4f1f08b 100644 --- a/pyrit/prompt_normalizer/prompt_normalizer.py +++ b/pyrit/prompt_normalizer/prompt_normalizer.py @@ -93,6 +93,23 @@ def __init__( self._converter_guard = converter_guard self.id = str(uuid4()) + @staticmethod + def is_write_only_delivery(message: Message) -> bool: + """ + Check whether ``send_prompt_async`` returned the sent request instead of a target response. + + Write-only targets (e.g., TextTarget) deliver the request without producing a response, + so the normalizer hands the request back. Every target response, including error and + empty responses, carries the assistant role, so any other role means nothing was received. + + Args: + message (Message): The message returned by ``send_prompt_async``. + + Returns: + bool: True if the message is the delivered request rather than a target response. + """ + return message.api_role != "assistant" + async def send_prompt_async( self, *, @@ -120,7 +137,9 @@ async def send_prompt_async( history selection and send lifecycle state. Returns: - Message: The response received from the target. + Message: The response received from the target. For a write-only target that returns + no response, this is the sent request; check it with ``is_write_only_delivery`` + before treating it as a response. Raises: Exception: If an error occurs during the request processing. diff --git a/tests/unit/executor/attack/multi_turn/test_crescendo.py b/tests/unit/executor/attack/multi_turn/test_crescendo.py index 89a64dd516..cf77890908 100644 --- a/tests/unit/executor/attack/multi_turn/test_crescendo.py +++ b/tests/unit/executor/attack/multi_turn/test_crescendo.py @@ -1069,6 +1069,27 @@ async def test_custom_message_is_sent_to_target( assert call_args.kwargs["message"] is custom_message assert result is target_response + async def test_write_only_delivery_is_rejected( + self, + mock_objective_target: MagicMock, + mock_adversarial_chat: MagicMock, + mock_prompt_normalizer: MagicMock, + basic_context: CrescendoAttackContext, + ): + """A request handed back for a write-only target is rejected instead of treated as a response.""" + attack = CrescendoAttack( + objective_target=mock_objective_target, + attack_adversarial_config=AttackAdversarialConfig(target=mock_adversarial_chat), + prompt_normalizer=mock_prompt_normalizer, + ) + attack_message = Message.from_prompt(prompt="My attack prompt", role="user") + mock_prompt_normalizer.send_prompt_async.return_value = Message.from_prompt( + prompt="My attack prompt", role="user" + ) + + with pytest.raises(ValueError, match="cannot run against a write-only target"): + await attack._send_prompt_to_objective_target_async(attack_message=attack_message, context=basic_context) + @pytest.mark.usefixtures("patch_central_database") class TestResponseScoring: diff --git a/tests/unit/executor/attack/multi_turn/test_tree_of_attacks.py b/tests/unit/executor/attack/multi_turn/test_tree_of_attacks.py index cb91adb727..4b52202065 100644 --- a/tests/unit/executor/attack/multi_turn/test_tree_of_attacks.py +++ b/tests/unit/executor/attack/multi_turn/test_tree_of_attacks.py @@ -2361,6 +2361,25 @@ async def test_node_multi_turn_target_keeps_conv_id(self, node_components): assert node.objective_target_conversation_id == original_conv_id + async def test_node_write_only_delivery_is_not_scored(self, node_components): + """A request handed back for a write-only target fails the node instead of being scored.""" + node = _TreeOfAttacksNode(**node_components) + delivered_request = Message.from_prompt(prompt="test prompt", role="user") + node._prompt_normalizer.send_prompt_async = AsyncMock(return_value=delivered_request) + + with ( + patch.object(node, "_generate_adversarial_prompt_async", new_callable=AsyncMock, return_value="prompt"), + patch.object(node, "_score_response_async", new_callable=AsyncMock) as mock_score, + ): + await node.send_prompt_async(objective="test objective") + + mock_score.assert_not_awaited() + assert node.error_message is not None + assert "cannot run against a write-only target" in node.error_message + assert node.completed is False + assert node.last_response is None + assert node.objective_score is None + @pytest.mark.usefixtures("patch_central_database") class TestTreeOfAttacksErrorHandling: @@ -3497,6 +3516,21 @@ async def routed_send(*args, **kwargs): assert sent is not None assert sent.get_value() == "concrete seed prompt" + async def test_node_send_initial_prompt_rejects_write_only_delivery(self, node_components): + """The initial-prompt path rejects a delivered request returned in place of a response.""" + node = _TreeOfAttacksNode(**node_components) + node._objective = "test" + node._adversarial_chat_system_seed_prompt.response_json_schema = None + node._initial_prompt = Message.from_prompt(prompt="concrete seed prompt", role="user") + node._prompt_normalizer.send_prompt_async = AsyncMock( + return_value=Message.from_prompt(prompt="concrete seed prompt", role="user") + ) + + with pytest.raises(ValueError, match="cannot run against a write-only target"): + await node._send_initial_prompt_to_target_async() + + assert node.last_response is None + def test_validate_context_raises_when_edit_only_target_has_no_seed(self, attack_builder): """Edit-only objective without seed in next_message fails validation early.""" builder = attack_builder.with_default_mocks() diff --git a/tests/unit/executor/attack/test_write_only_objective_target.py b/tests/unit/executor/attack/test_write_only_objective_target.py new file mode 100644 index 0000000000..b0d9325cf5 --- /dev/null +++ b/tests/unit/executor/attack/test_write_only_objective_target.py @@ -0,0 +1,343 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT license. + +""" +Regression tests for attacks against write-only objective targets. + +A write-only target (e.g., TextTarget) delivers the request without producing a response, and the +prompt normalizer hands the request back. These tests run real attacks, targets, scorers, and memory +to verify that the delivered request is never scored as response evidence. +""" + +import base64 +import io +import json +import uuid +from unittest.mock import AsyncMock, MagicMock, patch + +import pytest +from unit.mocks import MockPromptTarget + +from pyrit.converter import Base64Converter +from pyrit.executor.attack import ( + AttackAdversarialConfig, + AttackConverterConfig, + AttackScoringConfig, + ChunkedRequestAttack, + MultiPromptSendingAttack, + PromptSendingAttack, + RedTeamingAttack, + TreeOfAttacksWithPruningAttack, +) +from pyrit.executor.attack.multi_turn.tree_of_attacks import TAPAttackScoringConfig +from pyrit.memory import SQLiteMemory +from pyrit.models import AttackOutcome, ComponentIdentifier, Message, MessagePiece, Score +from pyrit.prompt_normalizer import ConverterConfiguration +from pyrit.prompt_target import TextTarget +from pyrit.prompt_target.common.target_capabilities import TargetCapabilities +from pyrit.prompt_target.common.target_configuration import TargetConfiguration +from pyrit.score import FloatScaleThresholdScorer, RegexScorer + +_NO_RESPONSE_REASON = "Objective target returned no response to score" + + +class _EchoTarget(MockPromptTarget): + """Replies with the request text, so a scorer matching the request also matches the response.""" + + async def _send_prompt_to_target_async(self, *, normalized_conversation: list[Message]) -> list[Message]: + message = normalized_conversation[-1] + self.prompt_sent.append(message.get_value()) + return [ + MessagePiece( + role="assistant", + original_value=message.get_value(), + conversation_id=message.message_pieces[0].conversation_id, + ).to_message() + ] + + +class _JsonAdversarialChat(MockPromptTarget): + """Adversarial chat that always proposes the same next message in the adversarial_chat JSON shape.""" + + async def _send_prompt_to_target_async(self, *, normalized_conversation: list[Message]) -> list[Message]: + message = normalized_conversation[-1] + self.prompt_sent.append(message.get_value()) + payload = json.dumps( + {"next_message": "Hello from adversary", "rationale": "test", "last_response_summary": "test"} + ) + return [ + MessagePiece( + role="assistant", + original_value=payload, + conversation_id=message.message_pieces[0].conversation_id, + ).to_message() + ] + + +def _multi_turn_text_target(text_stream: io.StringIO) -> TextTarget: + # Multi-prompt and chunked attacks require native multi-turn support, which a custom + # write-only target may declare while still returning no response. + return TextTarget( + text_stream=text_stream, + custom_configuration=TargetConfiguration(capabilities=TargetCapabilities(supports_multi_turn=True)), + ) + + +def _matching_scorer() -> RegexScorer: + # Matches the objective text, so scoring the sent request would report SUCCESS. + return RegexScorer(patterns={"objective": "Hello"}) + + +async def _assert_only_requests_persisted(*, memory: SQLiteMemory, conversation_id: str, expected_count: int) -> None: + pieces = await memory.get_message_pieces_async(conversation_id=conversation_id) + assert [piece.role for piece in pieces] == ["user"] * expected_count + assert await memory.get_scores_async() == [] + + +@pytest.mark.usefixtures("patch_central_database") +class TestPromptSendingAttackWriteOnlyTarget: + async def test_write_only_target_is_undetermined_and_never_scored(self, sqlite_instance: SQLiteMemory) -> None: + text_stream = io.StringIO() + attack = PromptSendingAttack( + objective_target=TextTarget(text_stream=text_stream), + attack_scoring_config=AttackScoringConfig( + objective_scorer=_matching_scorer(), + auxiliary_scorers=[_matching_scorer()], + ), + max_attempts_on_failure=2, + ) + + with patch( + "pyrit.executor.attack.single_turn.prompt_sending.score_attack_response_async", new_callable=AsyncMock + ) as mock_score: + result = await attack.execute_async(objective="Hello QA") + + mock_score.assert_not_awaited() + assert result.outcome is AttackOutcome.UNDETERMINED + assert result.outcome_reason == _NO_RESPONSE_REASON + assert result.last_response is None + assert result.automated_score is None + assert result.executed_turns == 1 + # Delivered exactly once despite retries being available. + assert text_stream.getvalue().count("Hello QA") == 1 + await _assert_only_requests_persisted( + memory=sqlite_instance, conversation_id=result.conversation_id, expected_count=1 + ) + stored_results = await sqlite_instance.get_attack_results_async(conversation_id=result.conversation_id) + assert [stored.outcome for stored in stored_results] == [AttackOutcome.UNDETERMINED] + + async def test_write_only_target_without_scorer_has_no_last_response(self, sqlite_instance: SQLiteMemory) -> None: + attack = PromptSendingAttack(objective_target=TextTarget(text_stream=io.StringIO())) + + result = await attack.execute_async(objective="Hello QA") + + assert result.outcome is AttackOutcome.UNDETERMINED + assert result.outcome_reason == _NO_RESPONSE_REASON + assert result.last_response is None + assert result.automated_score is None + await _assert_only_requests_persisted( + memory=sqlite_instance, conversation_id=result.conversation_id, expected_count=1 + ) + + async def test_write_only_target_keeps_converted_request_export(self, sqlite_instance: SQLiteMemory) -> None: + text_stream = io.StringIO() + attack = PromptSendingAttack( + objective_target=TextTarget(text_stream=text_stream), + attack_converter_config=AttackConverterConfig( + request_converters=ConverterConfiguration.from_converters(converters=[Base64Converter()]) + ), + attack_scoring_config=AttackScoringConfig(objective_scorer=_matching_scorer()), + ) + + result = await attack.execute_async(objective="Hello QA") + + encoded = base64.b64encode(b"Hello QA").decode() + assert result.outcome is AttackOutcome.UNDETERMINED + assert text_stream.getvalue() == f"user: {encoded}\n" + pieces = await sqlite_instance.get_message_pieces_async(conversation_id=result.conversation_id) + assert [(piece.role, piece.original_value, piece.converted_value) for piece in pieces] == [ + ("user", "Hello QA", encoded) + ] + + async def test_responding_target_with_same_scorer_succeeds(self, sqlite_instance: SQLiteMemory) -> None: + attack = PromptSendingAttack( + objective_target=_EchoTarget(), + attack_scoring_config=AttackScoringConfig(objective_scorer=_matching_scorer()), + ) + + result = await attack.execute_async(objective="Hello QA") + + assert result.outcome is AttackOutcome.SUCCESS + assert result.last_response is not None + assert result.last_response.role == "assistant" + assert result.automated_score is not None + assert result.automated_score.get_value() is True + + +@pytest.mark.usefixtures("patch_central_database") +class TestMultiPromptSendingAttackWriteOnlyTarget: + async def test_write_only_target_delivers_every_message_and_is_undetermined( + self, sqlite_instance: SQLiteMemory + ) -> None: + text_stream = io.StringIO() + attack = MultiPromptSendingAttack( + objective_target=_multi_turn_text_target(text_stream), + attack_scoring_config=AttackScoringConfig( + objective_scorer=_matching_scorer(), + auxiliary_scorers=[_matching_scorer()], + ), + ) + + with patch( + "pyrit.executor.attack.multi_turn.multi_prompt_sending.score_attack_response_async", + new_callable=AsyncMock, + ) as mock_score: + result = await attack.execute_async( + objective="Hello QA", + user_messages=[ + Message.from_prompt(prompt="Hello first", role="user"), + Message.from_prompt(prompt="Hello second", role="user"), + ], + ) + + mock_score.assert_not_awaited() + assert result.outcome is AttackOutcome.UNDETERMINED + assert result.outcome_reason == _NO_RESPONSE_REASON + assert result.last_response is None + assert result.automated_score is None + assert result.executed_turns == 2 + assert text_stream.getvalue().count("Hello first") == 1 + assert text_stream.getvalue().count("Hello second") == 1 + await _assert_only_requests_persisted( + memory=sqlite_instance, conversation_id=result.conversation_id, expected_count=2 + ) + + async def test_responding_target_with_same_scorer_succeeds(self) -> None: + attack = MultiPromptSendingAttack( + objective_target=_EchoTarget(), + attack_scoring_config=AttackScoringConfig(objective_scorer=_matching_scorer()), + ) + + result = await attack.execute_async( + objective="Hello QA", + user_messages=[Message.from_prompt(prompt="Hello first", role="user")], + ) + + assert result.outcome is AttackOutcome.SUCCESS + + +@pytest.mark.usefixtures("patch_central_database") +class TestChunkedRequestAttackWriteOnlyTarget: + async def test_write_only_target_does_not_score_requests_as_chunks(self, sqlite_instance: SQLiteMemory) -> None: + text_stream = io.StringIO() + attack = ChunkedRequestAttack( + objective_target=_multi_turn_text_target(text_stream), + chunk_size=10, + total_length=30, + attack_scoring_config=AttackScoringConfig( + objective_scorer=_matching_scorer(), + auxiliary_scorers=[_matching_scorer()], + ), + ) + + with patch.object(attack, "_score_combined_value_async", new_callable=AsyncMock) as mock_score: + result = await attack.execute_async(objective="Hello QA") + + mock_score.assert_not_awaited() + assert result.outcome is AttackOutcome.UNDETERMINED + assert result.outcome_reason == _NO_RESPONSE_REASON + assert result.last_response is None + assert result.automated_score is None + assert result.executed_turns == 3 + assert result.metadata == {"combined_chunks": "\n\n", "chunk_count": 3} + assert text_stream.getvalue().count("Hello QA") == 3 + await _assert_only_requests_persisted( + memory=sqlite_instance, conversation_id=result.conversation_id, expected_count=3 + ) + + async def test_responding_target_with_same_scorer_succeeds(self) -> None: + attack = ChunkedRequestAttack( + objective_target=_EchoTarget(), + chunk_size=10, + total_length=30, + attack_scoring_config=AttackScoringConfig(objective_scorer=_matching_scorer()), + ) + + result = await attack.execute_async(objective="Hello QA") + + assert result.outcome is AttackOutcome.SUCCESS + + +@pytest.mark.usefixtures("patch_central_database") +class TestRedTeamingAttackWriteOnlyTarget: + async def test_write_only_target_raises_instead_of_scoring_the_request(self, sqlite_instance: SQLiteMemory) -> None: + text_stream = io.StringIO() + adversarial_chat = _JsonAdversarialChat() + attack = RedTeamingAttack( + objective_target=TextTarget(text_stream=text_stream), + attack_adversarial_config=AttackAdversarialConfig(target=adversarial_chat), + attack_scoring_config=AttackScoringConfig(objective_scorer=_matching_scorer()), + max_turns=2, + ) + + with pytest.raises(RuntimeError, match="cannot run against a write-only target") as exc_info: + await attack.execute_async(objective="Hello QA") + + assert isinstance(exc_info.value.__cause__, ValueError) + assert len(adversarial_chat.prompt_sent) == 1 + assert text_stream.getvalue().count("Hello from adversary") == 1 + assert await sqlite_instance.get_scores_async() == [] + + +@pytest.mark.usefixtures("patch_central_database") +class TestTreeOfAttacksWriteOnlyTarget: + async def test_write_only_target_never_scores_the_request(self, sqlite_instance: SQLiteMemory) -> None: + # The scorer would report success for anything it is given, so any scoring of the request + # would turn into a false SUCCESS. + objective_scorer = MagicMock(spec=FloatScaleThresholdScorer) + objective_scorer.threshold = 0.8 + objective_scorer.get_identifier.return_value = ComponentIdentifier( + class_name="FloatScaleThresholdScorer", class_module="test_module" + ) + objective_scorer.score_async = AsyncMock( + return_value=[ + Score( + score_type="true_false", + score_value="true", + score_category=["test"], + score_value_description="Objective achieved", + score_rationale="Matched", + score_metadata={}, + message_piece_id=str(uuid.uuid4()), + scorer_class_identifier=objective_scorer.get_identifier.return_value, + ) + ] + ) + text_stream = io.StringIO() + attack = TreeOfAttacksWithPruningAttack( + objective_target=TextTarget(text_stream=text_stream), + attack_adversarial_config=AttackAdversarialConfig(target=_JsonAdversarialChat()), + attack_scoring_config=TAPAttackScoringConfig(objective_scorer=objective_scorer), + tree_width=1, + tree_depth=2, + branching_factor=1, + on_topic_checking_enabled=False, + ) + + result = await attack.execute_async(objective="Hello QA") + + objective_scorer.score_async.assert_not_awaited() + assert result.outcome is not AttackOutcome.SUCCESS + assert result.last_response is None + assert result.automated_score is None + assert text_stream.getvalue().count("Hello from adversary") == 1 + # The objective conversation holds only the delivered request, with no fabricated response. + delivered = [ + piece + for piece in await sqlite_instance.get_message_pieces_async() + if piece.converted_value == "Hello from adversary" + ] + assert [piece.role for piece in delivered] == ["user"] + objective_pieces = await sqlite_instance.get_message_pieces_async(conversation_id=delivered[0].conversation_id) + assert [piece.role for piece in objective_pieces] == ["user"] + assert await sqlite_instance.get_scores_async() == [] diff --git a/tests/unit/prompt_normalizer/test_prompt_normalizer.py b/tests/unit/prompt_normalizer/test_prompt_normalizer.py index e06558b44c..66ae92d086 100644 --- a/tests/unit/prompt_normalizer/test_prompt_normalizer.py +++ b/tests/unit/prompt_normalizer/test_prompt_normalizer.py @@ -720,6 +720,74 @@ async def test_send_prompt_async_none_in_list_response_returns_empty(mock_memory assert response.message_pieces[0].original_value == "" +async def test_send_prompt_async_write_only_target_returns_request_as_write_only_delivery(mock_memory_instance): + prompt_target = MagicMock() + prompt_target.send_prompt_async = AsyncMock(return_value=[]) + prompt_target.get_identifier.return_value = get_mock_target_identifier("MockTarget") + message = Message.from_prompt(prompt="write-only request", role="user") + + response = await PromptNormalizer().send_prompt_async(message=message, target=prompt_target) + + assert PromptNormalizer.is_write_only_delivery(response) + assert response.api_role == "user" + assert response.get_value() == "write-only request" + # Only the request is persisted; no response is fabricated for the write-only target. + assert mock_memory_instance.add_message_to_memory_async.call_count == 1 + persisted = mock_memory_instance.add_message_to_memory_async.call_args.kwargs["request"] + assert [piece.role for piece in persisted.message_pieces] == ["user"] + + +@pytest.mark.parametrize( + "target_return", + [ + pytest.param([Message.from_prompt(prompt="reply", role="assistant")], id="assistant-response"), + pytest.param(None, id="none-response"), + pytest.param([None], id="none-in-list-response"), + ], +) +async def test_send_prompt_async_target_response_is_not_write_only_delivery(mock_memory_instance, target_return): + prompt_target = MagicMock() + prompt_target.send_prompt_async = AsyncMock(return_value=target_return) + prompt_target.get_identifier.return_value = get_mock_target_identifier("MockTarget") + + response = await PromptNormalizer().send_prompt_async( + message=Message.from_prompt(prompt="request", role="user"), target=prompt_target + ) + + assert not PromptNormalizer.is_write_only_delivery(response) + + +async def test_send_prompt_async_empty_response_exception_is_not_write_only_delivery(mock_memory_instance): + prompt_target = MagicMock() + prompt_target.send_prompt_async = AsyncMock(side_effect=EmptyResponseException(message="Empty response")) + prompt_target.get_identifier.return_value = get_mock_target_identifier("MockTarget") + + response = await PromptNormalizer().send_prompt_async( + message=Message.from_prompt(prompt="request", role="user"), target=prompt_target + ) + + assert response.get_piece().response_error == "empty" + assert not PromptNormalizer.is_write_only_delivery(response) + + +@pytest.mark.parametrize( + ("role", "expected"), + [ + ("user", True), + ("system", True), + ("developer", True), + ("tool", True), + ("simulated_tool", True), + ("assistant", False), + ("simulated_assistant", False), + ], +) +def test_is_write_only_delivery_checks_api_role(role, expected): + message = Message(message_pieces=[MessagePiece(role=role, original_value="value")]) + + assert PromptNormalizer.is_write_only_delivery(message) is expected + + async def test_build_message(mock_memory_instance, seed_group): # This test is obsolete since _build_message was removed and message preparation # is now done inline in send_prompt_async. The functionality is tested by From cdec7e34138fb7ead6b3aade925ca3de0c7ce39a Mon Sep 17 00:00:00 2001 From: Dev3 ^~^ Date: Sun, 1 Nov 2026 14:16:56 +0530 Subject: [PATCH 2/3] FIX: Mark write-only deliveries explicitly and keep side-effect scoring --- .../backend/services/message_send_service.py | 19 ++- pyrit/executor/attack/core/attack_scoring.py | 32 +++- .../attack/multi_turn/chunked_request.py | 6 +- .../attack/multi_turn/multi_prompt_sending.py | 35 +++-- .../attack/multi_turn/tree_of_attacks.py | 18 ++- .../attack/single_turn/prompt_sending.py | 38 +++-- pyrit/prompt_normalizer/prompt_normalizer.py | 16 +- tests/unit/backend/test_chat_scoring.py | 26 ++++ .../attack/core/test_attack_scoring.py | 66 ++++++++ .../attack/multi_turn/test_crescendo.py | 5 +- .../multi_turn/test_multi_prompt_sending.py | 5 +- .../attack/multi_turn/test_tree_of_attacks.py | 18 ++- .../attack/single_turn/test_prompt_sending.py | 5 +- .../test_write_only_objective_target.py | 147 ++++++++++++++++-- .../test_prompt_normalizer.py | 28 ++-- tests/unit/score/test_file_write_scorer.py | 56 ++++++- 16 files changed, 427 insertions(+), 93 deletions(-) diff --git a/pyrit/backend/services/message_send_service.py b/pyrit/backend/services/message_send_service.py index 2c21b6958c..f0cc46d525 100644 --- a/pyrit/backend/services/message_send_service.py +++ b/pyrit/backend/services/message_send_service.py @@ -768,14 +768,19 @@ async def _execute_message_async( if progress is not None: progress.state = MessageSendState.FINALIZING current_pieces = await self._memory.get_message_pieces_async(conversation_id=msg_conversation_id) - last_response = ( - delivered_response.message_pieces[0] - if isinstance(delivered_response, Message) and delivered_response.api_role == "assistant" - else next( - (piece for piece in current_pieces if piece.id not in prior_ids and piece.role == "assistant"), - None, + if isinstance(delivered_response, Message) and PromptNormalizer.is_write_only_delivery(delivered_response): + # A write-only target returned the sent request, which is never a response to score, + # whatever role the request carries. + last_response = None + else: + last_response = ( + delivered_response.message_pieces[0] + if isinstance(delivered_response, Message) and delivered_response.api_role == "assistant" + else next( + (piece for piece in current_pieces if piece.id not in prior_ids and piece.role == "assistant"), + None, + ) ) - ) if ( last_response is not None and validated.scoring_inputs diff --git a/pyrit/executor/attack/core/attack_scoring.py b/pyrit/executor/attack/core/attack_scoring.py index ea309880dd..21999da624 100644 --- a/pyrit/executor/attack/core/attack_scoring.py +++ b/pyrit/executor/attack/core/attack_scoring.py @@ -9,7 +9,7 @@ from dataclasses import dataclass, field from typing import TYPE_CHECKING -from pyrit.models import ScoringExpectation +from pyrit.models import ScoringExpectation, SurfaceScorable, TraceScorable from pyrit.score import MessageScorer if TYPE_CHECKING: @@ -71,26 +71,52 @@ def prepare_attack_scoring( return prepared +# Evidence of what the target did (written files, invoked tools), acquired from a source rather +# than read from message text. +_SIDE_EFFECT_SCORABLE_TYPES = frozenset({SurfaceScorable, TraceScorable}) + + +def _judges_side_effects(scorer: Scorer) -> bool: + return not _SIDE_EFFECT_SCORABLE_TYPES.isdisjoint(scorer.get_supported_scorable_types()) + + async def score_attack_response_async( *, response: Message, objective_scorer: Scorer | None, auxiliary_scorers: Sequence[Scorer] | None, expectation: ScoringExpectation | None, + side_effect_evidence_only: bool = False, ) -> dict[str, list[Score]]: """ Score an attack response with the objective scorer and the auxiliary scorers that apply. + Args: + response (Message): The message that anchors scoring for the turn. + objective_scorer (Scorer | None): The scorer that decides the attack outcome. + auxiliary_scorers (Sequence[Scorer] | None): Additional scorers whose results are stored. + expectation (ScoringExpectation | None): The scoring question. + side_effect_evidence_only (bool): Run only scorers that judge side-effect evidence (written + files or invoked tools) rather than message text. Use this when ``response`` is a + delivered request with no target response, so the request is never judged as response text. + Returns: dict[str, list[Score]]: The ``objective_scores`` and ``auxiliary_scores``. """ prepared = prepare_attack_scoring( objective_scorer=objective_scorer, auxiliary_scorers=auxiliary_scorers, expectation=expectation ) + auxiliary = list(zip(prepared.auxiliary_scorers, prepared.auxiliary_expectations, strict=True)) + if side_effect_evidence_only: + if objective_scorer is not None and not _judges_side_effects(objective_scorer): + objective_scorer = None + auxiliary = [(scorer, selected) for scorer, selected in auxiliary if _judges_side_effects(scorer)] + if objective_scorer is None and not auxiliary: + return {"objective_scores": [], "auxiliary_scores": []} return await MessageScorer.score_response_async( response=response, objective_scorer=objective_scorer, - auxiliary_scorers=prepared.auxiliary_scorers, + auxiliary_scorers=[scorer for scorer, _ in auxiliary], expectation=prepared.objective_expectation, - auxiliary_expectations=prepared.auxiliary_expectations, + auxiliary_expectations=[selected for _, selected in auxiliary], ) diff --git a/pyrit/executor/attack/multi_turn/chunked_request.py b/pyrit/executor/attack/multi_turn/chunked_request.py index 706953c9b1..948e7beac4 100644 --- a/pyrit/executor/attack/multi_turn/chunked_request.py +++ b/pyrit/executor/attack/multi_turn/chunked_request.py @@ -293,6 +293,7 @@ async def _perform_async(self, *, context: ChunkedRequestAttackContext) -> Attac # Send each chunk request and collect responses response = None delivered_without_response = False + received_response = False for idx, chunk_prompt in enumerate(chunk_prompts): logger.info(f"Sending chunk request {idx + 1}/{len(chunk_prompts)}") @@ -331,6 +332,7 @@ async def _perform_async(self, *, context: ChunkedRequestAttackContext) -> Attac # Store the response if response: + received_response = True response_text = response.get_value() context.chunk_responses.append(response_text) logger.info(f"Received chunk {idx + 1}: {response_text[:50]}...") @@ -344,7 +346,9 @@ async def _perform_async(self, *, context: ChunkedRequestAttackContext) -> Attac combined_value = "\n".join(context.chunk_responses) logger.info(f"Combined {len(context.chunk_responses)} chunk responses") - if delivered_without_response: + if delivered_without_response and not received_response: + # No chunk produced output, so there is nothing to score. Output from other chunks is + # still scored when some chunks were only delivered. score = None outcome, outcome_reason = AttackOutcome.UNDETERMINED, "Objective target returned no response to score" else: diff --git a/pyrit/executor/attack/multi_turn/multi_prompt_sending.py b/pyrit/executor/attack/multi_turn/multi_prompt_sending.py index 161870c54f..a132cc0e50 100644 --- a/pyrit/executor/attack/multi_turn/multi_prompt_sending.py +++ b/pyrit/executor/attack/multi_turn/multi_prompt_sending.py @@ -269,15 +269,18 @@ async def _perform_async(self, *, context: MultiTurnAttackContext[Any]) -> Attac current_message=current_message, context=context ) - if response_message and PromptNormalizer.is_write_only_delivery(response_message): - # Keep delivering the sequence, but the returned request is not a response to score. - delivered_without_response = True + # Only the final send is judged, so each send replaces what the previous one left. + response = response_message + delivered_without_response = response_message is not None and PromptNormalizer.is_write_only_delivery( + response_message + ) + if delivered_without_response: + # Keep delivering the sequence; the returned request is not a response. context.executed_turns += 1 continue # Update context with latest response (may be None if sending failed) if response_message: - response = response_message context.last_response = response context.executed_turns += 1 @@ -291,29 +294,33 @@ async def _perform_async(self, *, context: MultiTurnAttackContext[Any]) -> Attac ) break else: - response = None self._logger.warning(f"Failed to send message {message_index + 1}, terminating") break - # Score the last response including auxiliary and objective scoring + # Score the last response including auxiliary and objective scoring. A delivered request is + # judged only by scorers that read side-effect evidence, never as response text. if response is not None: score = await self._evaluate_response_async( - response=response, objective=context.objective, expectation=context.expectation + response=response, + objective=context.objective, + expectation=context.expectation, + side_effect_evidence_only=delivered_without_response, ) else: score = None # Determine the outcome - if delivered_without_response: + if delivered_without_response and score is None: outcome, outcome_reason = AttackOutcome.UNDETERMINED, "Objective target returned no response to score" else: outcome, outcome_reason = self._determine_attack_outcome(response=response, score=score, context=context) + last_response = None if delivered_without_response else response return AttackResult( conversation_id=context.session.conversation_id, objective=context.objective, atomic_attack_identifier=AtomicAttackIdentifier.build(attack_identifier=self.get_identifier()), - last_response=response.get_piece() if response else None, + last_response=last_response.get_piece() if last_response else None, automated_score=score, related_conversations=context.related_conversations, outcome=outcome, @@ -406,7 +413,12 @@ async def _send_prompt_to_objective_target_async( ) async def _evaluate_response_async( - self, *, response: Message, objective: str, expectation: ScoringExpectation + self, + *, + response: Message, + objective: str, + expectation: ScoringExpectation, + side_effect_evidence_only: bool = False, ) -> Score | None: """ Evaluate the response against the objective using the configured scorers. @@ -418,6 +430,8 @@ async def _evaluate_response_async( response (Message): The response from the model. objective (str): The natural-language description of the attack's objective. expectation (ScoringExpectation): The effective scoring question. + side_effect_evidence_only (bool): Run only scorers that judge side-effect evidence, for a + delivered request with no target response. Defaults to False. Returns: Score | None: The score from the objective scorer if configured, or None if @@ -434,6 +448,7 @@ async def _evaluate_response_async( objective_scorer=self._objective_scorer, auxiliary_scorers=self._auxiliary_scorers, expectation=expectation, + side_effect_evidence_only=side_effect_evidence_only, ) objective_scores = scoring_results["objective_scores"] diff --git a/pyrit/executor/attack/multi_turn/tree_of_attacks.py b/pyrit/executor/attack/multi_turn/tree_of_attacks.py index a7003c7d9e..66e83c85c0 100644 --- a/pyrit/executor/attack/multi_turn/tree_of_attacks.py +++ b/pyrit/executor/attack/multi_turn/tree_of_attacks.py @@ -85,6 +85,10 @@ logger = logging.getLogger(__name__) +class _WriteOnlyTargetError(ValueError): + """Raised when the objective target returns no response, so no node can ever be scored.""" + + class TAPSystemPromptPaths(enum.Enum): """Enum for predefined TAP attack system prompt paths.""" @@ -577,13 +581,17 @@ async def send_prompt_async(self, objective: str) -> None: 3. Send the prompt to the objective target. 4. Score the response with all configured scorers. - All errors are handled gracefully - JSON parsing errors and unexpected exceptions are - caught and stored in the node's error_message attribute rather than being raised. + JSON parsing errors and unexpected exceptions are caught and stored in the node's + error_message attribute rather than being raised. A write-only objective target is + raised instead, because it fails every node the same way. Args: objective (str): The attack objective describing what the attacker wants to achieve. This is used to guide the adversarial prompt generation and scoring. + Raises: + _WriteOnlyTargetError: If the objective target returned no response. + Note: This method sets the following node attributes during execution: - `last_prompt_sent`: The generated adversarial prompt @@ -620,6 +628,8 @@ async def send_prompt_async(self, objective: str) -> None: # Mark execution as successful self._mark_execution_complete() + except _WriteOnlyTargetError: + raise except InvalidJsonException as e: self._handle_json_error(e) except Exception as e: @@ -827,10 +837,10 @@ def _raise_if_write_only_delivery(self, response: Message) -> None: Reject a delivered request that the normalizer returned in place of a target response. Raises: - ValueError: If the objective target returned no response. + _WriteOnlyTargetError: If the objective target returned no response. """ if PromptNormalizer.is_write_only_delivery(response): - raise ValueError( + raise _WriteOnlyTargetError( "The objective target returned no response. Tree of attacks needs a target response " "to score each node, so it cannot run against a write-only target." ) diff --git a/pyrit/executor/attack/single_turn/prompt_sending.py b/pyrit/executor/attack/single_turn/prompt_sending.py index aefa57230e..d43ff7a387 100644 --- a/pyrit/executor/attack/single_turn/prompt_sending.py +++ b/pyrit/executor/attack/single_turn/prompt_sending.py @@ -220,6 +220,7 @@ async def _perform_async(self, *, context: SingleTurnAttackContext[Any]) -> Atta # Execute with retries response = None score = None + delivered_without_response = False # Attack execution steps: # 1) Construct the seed prompt(s) that will be injected into the conversation @@ -234,6 +235,7 @@ async def _perform_async(self, *, context: SingleTurnAttackContext[Any]) -> Atta for attempt in range(self._max_attempts_on_failure + 1): self._logger.debug(f"Attempt {attempt + 1}/{self._max_attempts_on_failure + 1}") score = None + delivered_without_response = False # Prepare a fresh message for each attempt to avoid duplicate ID errors in database message = self._get_message(context) @@ -248,24 +250,23 @@ async def _perform_async(self, *, context: SingleTurnAttackContext[Any]) -> Atta self._logger.warning(f"No response received on attempt {attempt + 1} (likely filtered)") continue # Retry if no response (filtered or error) - if PromptNormalizer.is_write_only_delivery(response): - # The request was delivered but the target returned nothing to score. Retrying would - # deliver it again without ever producing a response, and scoring the request itself - # would measure the attacker's own prompt. - return self._create_attack_result( - context=context, - response=None, - score=None, - outcome=AttackOutcome.UNDETERMINED, - outcome_reason="Objective target returned no response to score", - executed_turns=1, - ) + # A write-only target returns the delivered request instead of a response. The request is + # never judged as response text, but scorers that read side-effect evidence (e.g., a + # written file) still judge what the turn did. + delivered_without_response = PromptNormalizer.is_write_only_delivery(response) # Score the response including auxiliary and objective scoring score = await self._evaluate_response_async( - response=response, objective=context.objective, expectation=context.expectation + response=response, + objective=context.objective, + expectation=context.expectation, + side_effect_evidence_only=delivered_without_response, ) + if delivered_without_response and score is None: + # Nothing judged the turn, and retrying would only deliver the request again. + break + # If there is no objective, we have a response but can't determine success if not self._objective_scorer: break @@ -285,11 +286,14 @@ async def _perform_async(self, *, context: SingleTurnAttackContext[Any]) -> Atta await self._setup_async(context=context) # Reset conversation for next attempt # Determine the outcome - outcome, outcome_reason = self._determine_attack_outcome(response=response, score=score, context=context) + if delivered_without_response and score is None: + outcome, outcome_reason = AttackOutcome.UNDETERMINED, "Objective target returned no response to score" + else: + outcome, outcome_reason = self._determine_attack_outcome(response=response, score=score, context=context) return self._create_attack_result( context=context, - response=response, + response=None if delivered_without_response else response, score=score, outcome=outcome, outcome_reason=outcome_reason, @@ -430,6 +434,7 @@ async def _evaluate_response_async( response: Message, objective: str, expectation: ScoringExpectation, + side_effect_evidence_only: bool = False, ) -> Score | None: """ Evaluate the response against the objective using the configured scorers. @@ -441,6 +446,8 @@ async def _evaluate_response_async( response (Message): The response from the model. objective (str): The natural-language description of the attack's objective. expectation (ScoringExpectation): The effective scoring question. + side_effect_evidence_only (bool): Run only scorers that judge side-effect evidence, for a + delivered request with no target response. Defaults to False. Returns: Score | None: The score from the objective scorer if configured, or None if @@ -457,6 +464,7 @@ async def _evaluate_response_async( objective_scorer=self._objective_scorer, auxiliary_scorers=self._auxiliary_scorers, expectation=expectation, + side_effect_evidence_only=side_effect_evidence_only, ) if not self._objective_scorer: diff --git a/pyrit/prompt_normalizer/prompt_normalizer.py b/pyrit/prompt_normalizer/prompt_normalizer.py index 3fe4f1f08b..850d272c7c 100644 --- a/pyrit/prompt_normalizer/prompt_normalizer.py +++ b/pyrit/prompt_normalizer/prompt_normalizer.py @@ -50,6 +50,10 @@ def _is_write_only_response(responses: object) -> bool: return isinstance(responses, list) and len(responses) == 0 +class _WriteOnlyDelivery(Message[MessagePiece]): + """The sent request, returned in place of a response when a write-only target produces none.""" + + class PromptNormalizer: """ Handles normalization and processing of prompts before they are sent to targets. @@ -98,9 +102,9 @@ def is_write_only_delivery(message: Message) -> bool: """ Check whether ``send_prompt_async`` returned the sent request instead of a target response. - Write-only targets (e.g., TextTarget) deliver the request without producing a response, - so the normalizer hands the request back. Every target response, including error and - empty responses, carries the assistant role, so any other role means nothing was received. + Write-only targets (e.g., TextTarget) deliver the request and return an empty response list, + so the normalizer hands the request back. The normalizer marks that case explicitly, so the + check does not depend on the request's role. Args: message (Message): The message returned by ``send_prompt_async``. @@ -108,7 +112,7 @@ def is_write_only_delivery(message: Message) -> bool: Returns: bool: True if the message is the delivered request rather than a target response. """ - return message.api_role != "assistant" + return isinstance(message, _WriteOnlyDelivery) async def send_prompt_async( self, @@ -226,9 +230,9 @@ async def send_prompt_async( # handling empty responses message list and None responses if not responses or not any(responses): # An empty list is valid for write-only targets (e.g., TextTarget) - # that don't produce responses. Return the request as-is. + # that don't produce responses. Return the request, marked as a delivery. if _is_write_only_response(responses): - return request + return _WriteOnlyDelivery(message_pieces=request.message_pieces) empty_response = construct_response_from_request( request=request.message_pieces[0], response_text_pieces=[""], diff --git a/tests/unit/backend/test_chat_scoring.py b/tests/unit/backend/test_chat_scoring.py index afa43d20f1..612677d23b 100644 --- a/tests/unit/backend/test_chat_scoring.py +++ b/tests/unit/backend/test_chat_scoring.py @@ -694,6 +694,32 @@ async def test_store_only_and_missing_objective_do_not_score_async( assert score.call_count == 0 +@pytest.mark.parametrize("role", ["user", "assistant"]) +async def test_write_only_delivery_is_not_scored_whatever_its_role_async( + scoring_chat: tuple[MessageSendService, AttackResult, MockPromptTarget, SubStringScorer], + sqlite_instance: SQLiteMemory, + role: str, +) -> None: + service, attack, target, scorer = scoring_chat + # The request matches the scorer, so scoring the delivered request would report success. + request = chat_request(attack).model_copy( + update={"role": role, "pieces": [MessagePieceRequest(original_value="default request")]} + ) + with ( + patch.object(target, "_send_prompt_to_target_async", return_value=[]), + patch.object(scorer, "score_async", wraps=scorer.score_async) as score, + ): + await service.add_message_async(attack_result_id=attack.attack_result_id, request=request) + + assert score.call_count == 0 + [stored] = await sqlite_instance.get_attack_results_async(attack_result_ids=[attack.attack_result_id]) + assert stored.automated_score is None + assert stored.last_response is None + assert stored.outcome is not AttackOutcome.SUCCESS + messages = await sqlite_instance.get_conversation_messages_async(conversation_id=attack.conversation_id) + assert [message.api_role for message in messages] == [role] + + @pytest.mark.parametrize("scoring_failure", [False, True]) async def test_repeated_submission_scores_each_response_once_async( scoring_chat: tuple[MessageSendService, AttackResult, MockPromptTarget, SubStringScorer], diff --git a/tests/unit/executor/attack/core/test_attack_scoring.py b/tests/unit/executor/attack/core/test_attack_scoring.py index f1694e3631..fc3387b348 100644 --- a/tests/unit/executor/attack/core/test_attack_scoring.py +++ b/tests/unit/executor/attack/core/test_attack_scoring.py @@ -17,10 +17,13 @@ ComponentIdentifier, Condition, MessagePiece, + MessageScorable, Scorable, ScorableUnion, Score, ScoringExpectation, + SurfaceScorable, + TraceScorable, ) from pyrit.score import TrueFalseCompositeScorer, TrueFalseScoreAggregator, TrueFalseScorer @@ -68,6 +71,14 @@ class _ConfiguredScorer(_FirstScorer): CONDITION_TYPE = None +class _SurfaceSecondScorer(_SecondScorer): + SUPPORTED_SCORABLE_TYPES = (MessageScorable, SurfaceScorable) + + +class _TraceFirstScorer(_FirstScorer): + SUPPORTED_SCORABLE_TYPES = (MessageScorable, TraceScorable) + + def _expectation() -> ScoringExpectation: return ScoringExpectation(objective="context", conditions=(_FirstCondition(), _SecondCondition())) @@ -186,3 +197,58 @@ def test_attack_warns_once_before_execution_when_auxiliary_is_skipped(caplog: py with caplog.at_level(logging.WARNING): attack._validate_scoring_expectation(context=context) assert "_SecondScorer will not run" in caplog.text + + +@pytest.mark.usefixtures("patch_central_database") +async def test_side_effect_evidence_only_skips_message_scorers_async(sqlite_instance: MemoryInterface) -> None: + objective, message_auxiliary, surface_auxiliary = _SecondScorer(), _SecondScorer(), _SurfaceSecondScorer() + expectation = ScoringExpectation(conditions=(_SecondCondition(),)) + + results = await score_attack_response_async( + response=(await _stored_response_async(sqlite_instance)), + objective_scorer=objective, + auxiliary_scorers=[message_auxiliary, surface_auxiliary], + expectation=expectation, + side_effect_evidence_only=True, + ) + + assert results["objective_scores"] == [] + assert objective.expectations == message_auxiliary.expectations == [] + assert surface_auxiliary.expectations == [expectation] + assert [score.scorer_class_identifier for score in results["auxiliary_scores"]] == [ + surface_auxiliary.get_identifier() + ] + + +@pytest.mark.usefixtures("patch_central_database") +async def test_side_effect_evidence_only_runs_trace_objective_scorer_async(sqlite_instance: MemoryInterface) -> None: + objective = _TraceFirstScorer() + + results = await score_attack_response_async( + response=(await _stored_response_async(sqlite_instance)), + objective_scorer=objective, + auxiliary_scorers=[], + expectation=ScoringExpectation(conditions=(_FirstCondition(),)), + side_effect_evidence_only=True, + ) + + assert objective.expectations == [ScoringExpectation(conditions=(_FirstCondition(),))] + assert [score.get_value() for score in results["objective_scores"]] == [True] + + +@pytest.mark.usefixtures("patch_central_database") +async def test_side_effect_evidence_only_with_no_applicable_scorer_scores_nothing_async( + sqlite_instance: MemoryInterface, +) -> None: + objective = _FirstScorer() + + results = await score_attack_response_async( + response=(await _stored_response_async(sqlite_instance)), + objective_scorer=objective, + auxiliary_scorers=None, + expectation=ScoringExpectation(conditions=(_FirstCondition(),)), + side_effect_evidence_only=True, + ) + + assert results == {"objective_scores": [], "auxiliary_scores": []} + assert objective.expectations == [] diff --git a/tests/unit/executor/attack/multi_turn/test_crescendo.py b/tests/unit/executor/attack/multi_turn/test_crescendo.py index cf77890908..18c1c5111b 100644 --- a/tests/unit/executor/attack/multi_turn/test_crescendo.py +++ b/tests/unit/executor/attack/multi_turn/test_crescendo.py @@ -35,6 +35,7 @@ ScoreType, SeedPrompt, ) +from pyrit.prompt_normalizer.prompt_normalizer import _WriteOnlyDelivery from pyrit.prompt_target import PromptTarget from pyrit.score import FloatScaleThresholdScorer, SelfAskRefusalScorer, TrueFalseScorer from pyrit.score.score_utils import ORIGINAL_FLOAT_VALUE_KEY @@ -1083,8 +1084,8 @@ async def test_write_only_delivery_is_rejected( prompt_normalizer=mock_prompt_normalizer, ) attack_message = Message.from_prompt(prompt="My attack prompt", role="user") - mock_prompt_normalizer.send_prompt_async.return_value = Message.from_prompt( - prompt="My attack prompt", role="user" + mock_prompt_normalizer.send_prompt_async.return_value = _WriteOnlyDelivery( + message_pieces=attack_message.message_pieces ) with pytest.raises(ValueError, match="cannot run against a write-only target"): diff --git a/tests/unit/executor/attack/multi_turn/test_multi_prompt_sending.py b/tests/unit/executor/attack/multi_turn/test_multi_prompt_sending.py index dc493f08a0..ad92cd2726 100644 --- a/tests/unit/executor/attack/multi_turn/test_multi_prompt_sending.py +++ b/tests/unit/executor/attack/multi_turn/test_multi_prompt_sending.py @@ -563,7 +563,10 @@ async def test_perform_async_evaluates_final_response( result = await attack._perform_async(context=basic_context) mock_evaluate.assert_called_once_with( - response=sample_response, objective=basic_context.objective, expectation=basic_context.expectation + response=sample_response, + objective=basic_context.objective, + expectation=basic_context.expectation, + side_effect_evidence_only=False, ) assert result.last_score == success_score diff --git a/tests/unit/executor/attack/multi_turn/test_tree_of_attacks.py b/tests/unit/executor/attack/multi_turn/test_tree_of_attacks.py index 4b52202065..6737593ceb 100644 --- a/tests/unit/executor/attack/multi_turn/test_tree_of_attacks.py +++ b/tests/unit/executor/attack/multi_turn/test_tree_of_attacks.py @@ -30,6 +30,7 @@ _TAPAttackConfiguration, _TreeOfAttacksNode, _TreeOfAttacksNodeExecutor, + _WriteOnlyTargetError, ) from pyrit.memory import MemoryInterface from pyrit.models import ( @@ -45,6 +46,7 @@ ScoringExpectation, SeedPrompt, ) +from pyrit.prompt_normalizer.prompt_normalizer import _WriteOnlyDelivery from pyrit.prompt_target import CapabilityName, PromptTarget from pyrit.score import ( FloatScaleThresholdScorer, @@ -2361,21 +2363,23 @@ async def test_node_multi_turn_target_keeps_conv_id(self, node_components): assert node.objective_target_conversation_id == original_conv_id - async def test_node_write_only_delivery_is_not_scored(self, node_components): - """A request handed back for a write-only target fails the node instead of being scored.""" + async def test_node_write_only_delivery_is_raised_not_scored(self, node_components): + """A request handed back for a write-only target is raised out of the node instead of scored.""" node = _TreeOfAttacksNode(**node_components) - delivered_request = Message.from_prompt(prompt="test prompt", role="user") + delivered_request = _WriteOnlyDelivery( + message_pieces=Message.from_prompt(prompt="test prompt", role="user").message_pieces + ) node._prompt_normalizer.send_prompt_async = AsyncMock(return_value=delivered_request) with ( patch.object(node, "_generate_adversarial_prompt_async", new_callable=AsyncMock, return_value="prompt"), patch.object(node, "_score_response_async", new_callable=AsyncMock) as mock_score, + pytest.raises(_WriteOnlyTargetError, match="cannot run against a write-only target"), ): await node.send_prompt_async(objective="test objective") mock_score.assert_not_awaited() - assert node.error_message is not None - assert "cannot run against a write-only target" in node.error_message + assert node.error_message is None assert node.completed is False assert node.last_response is None assert node.objective_score is None @@ -3523,10 +3527,10 @@ async def test_node_send_initial_prompt_rejects_write_only_delivery(self, node_c node._adversarial_chat_system_seed_prompt.response_json_schema = None node._initial_prompt = Message.from_prompt(prompt="concrete seed prompt", role="user") node._prompt_normalizer.send_prompt_async = AsyncMock( - return_value=Message.from_prompt(prompt="concrete seed prompt", role="user") + return_value=_WriteOnlyDelivery(message_pieces=node._initial_prompt.message_pieces) ) - with pytest.raises(ValueError, match="cannot run against a write-only target"): + with pytest.raises(_WriteOnlyTargetError, match="cannot run against a write-only target"): await node._send_initial_prompt_to_target_async() assert node.last_response is None diff --git a/tests/unit/executor/attack/single_turn/test_prompt_sending.py b/tests/unit/executor/attack/single_turn/test_prompt_sending.py index 4b82125e76..d9d0b8f356 100644 --- a/tests/unit/executor/attack/single_turn/test_prompt_sending.py +++ b/tests/unit/executor/attack/single_turn/test_prompt_sending.py @@ -964,7 +964,10 @@ async def test_perform_attack_without_scorer_completes_after_first_response( # Verify that _evaluate_response_async was called even without objective scorer # This ensures auxiliary scores are still collected attack._evaluate_response_async.assert_called_once_with( - response=sample_response, objective=basic_context.objective, expectation=basic_context.expectation + response=sample_response, + objective=basic_context.objective, + expectation=basic_context.expectation, + side_effect_evidence_only=False, ) async def test_perform_attack_without_scorer_retries_on_filtered_response( diff --git a/tests/unit/executor/attack/test_write_only_objective_target.py b/tests/unit/executor/attack/test_write_only_objective_target.py index b0d9325cf5..c9033ebdcd 100644 --- a/tests/unit/executor/attack/test_write_only_objective_target.py +++ b/tests/unit/executor/attack/test_write_only_objective_target.py @@ -13,6 +13,8 @@ import io import json import uuid +from contextlib import AbstractContextManager +from datetime import UTC, datetime from unittest.mock import AsyncMock, MagicMock, patch import pytest @@ -29,14 +31,14 @@ RedTeamingAttack, TreeOfAttacksWithPruningAttack, ) -from pyrit.executor.attack.multi_turn.tree_of_attacks import TAPAttackScoringConfig +from pyrit.executor.attack.multi_turn.tree_of_attacks import TAPAttackScoringConfig, _WriteOnlyTargetError from pyrit.memory import SQLiteMemory from pyrit.models import AttackOutcome, ComponentIdentifier, Message, MessagePiece, Score from pyrit.prompt_normalizer import ConverterConfiguration from pyrit.prompt_target import TextTarget from pyrit.prompt_target.common.target_capabilities import TargetCapabilities from pyrit.prompt_target.common.target_configuration import TargetConfiguration -from pyrit.score import FloatScaleThresholdScorer, RegexScorer +from pyrit.score import FloatScaleThresholdScorer, MessageScorer, RegexScorer _NO_RESPONSE_REASON = "Objective target returned no response to score" @@ -74,6 +76,28 @@ async def _send_prompt_to_target_async(self, *, normalized_conversation: list[Me ] +class _ScriptedTarget(MockPromptTarget): + """Returns each scripted reply in turn; ``None`` returns no response, like a write-only target.""" + + def __init__(self, *, replies: list[str | None]) -> None: + super().__init__() + self._replies = list(replies) + + async def _send_prompt_to_target_async(self, *, normalized_conversation: list[Message]) -> list[Message]: + message = normalized_conversation[-1] + self.prompt_sent.append(message.get_value()) + reply = self._replies.pop(0) + if reply is None: + return [] + return [ + MessagePiece( + role="assistant", + original_value=reply, + conversation_id=message.message_pieces[0].conversation_id, + ).to_message() + ] + + def _multi_turn_text_target(text_stream: io.StringIO) -> TextTarget: # Multi-prompt and chunked attacks require native multi-turn support, which a custom # write-only target may declare while still returning no response. @@ -88,10 +112,20 @@ def _matching_scorer() -> RegexScorer: return RegexScorer(patterns={"objective": "Hello"}) +async def _all_scores_async(memory: SQLiteMemory) -> list[Score]: + # An unfiltered query returns nothing, so filter on a time that every stored score is after. + return list(await memory.get_scores_async(sent_after=datetime(2000, 1, 1, tzinfo=UTC), include_intermediate=True)) + + +def _no_message_scoring() -> AbstractContextManager[AsyncMock]: + # Every message-text scorer runs through this call, so it must never run for a delivered request. + return patch.object(MessageScorer, "score_response_async", new_callable=AsyncMock) + + async def _assert_only_requests_persisted(*, memory: SQLiteMemory, conversation_id: str, expected_count: int) -> None: pieces = await memory.get_message_pieces_async(conversation_id=conversation_id) assert [piece.role for piece in pieces] == ["user"] * expected_count - assert await memory.get_scores_async() == [] + assert await _all_scores_async(memory) == [] @pytest.mark.usefixtures("patch_central_database") @@ -107,9 +141,7 @@ async def test_write_only_target_is_undetermined_and_never_scored(self, sqlite_i max_attempts_on_failure=2, ) - with patch( - "pyrit.executor.attack.single_turn.prompt_sending.score_attack_response_async", new_callable=AsyncMock - ) as mock_score: + with _no_message_scoring() as mock_score: result = await attack.execute_async(objective="Hello QA") mock_score.assert_not_awaited() @@ -126,6 +158,29 @@ async def test_write_only_target_is_undetermined_and_never_scored(self, sqlite_i stored_results = await sqlite_instance.get_attack_results_async(conversation_id=result.conversation_id) assert [stored.outcome for stored in stored_results] == [AttackOutcome.UNDETERMINED] + async def test_assistant_role_request_to_write_only_target_is_not_a_response( + self, sqlite_instance: SQLiteMemory + ) -> None: + text_stream = io.StringIO() + attack = PromptSendingAttack( + objective_target=TextTarget(text_stream=text_stream), + attack_scoring_config=AttackScoringConfig(objective_scorer=_matching_scorer()), + ) + + with _no_message_scoring() as mock_score: + result = await attack.execute_async( + objective="Hello QA", + next_message=Message.from_prompt(prompt="Hello supplied request", role="assistant"), + ) + + mock_score.assert_not_awaited() + assert result.outcome is AttackOutcome.UNDETERMINED + assert result.outcome_reason == _NO_RESPONSE_REASON + assert result.last_response is None + assert result.automated_score is None + assert text_stream.getvalue() == "assistant: Hello supplied request\n" + assert await _all_scores_async(sqlite_instance) == [] + async def test_write_only_target_without_scorer_has_no_last_response(self, sqlite_instance: SQLiteMemory) -> None: attack = PromptSendingAttack(objective_target=TextTarget(text_stream=io.StringIO())) @@ -188,10 +243,7 @@ async def test_write_only_target_delivers_every_message_and_is_undetermined( ), ) - with patch( - "pyrit.executor.attack.multi_turn.multi_prompt_sending.score_attack_response_async", - new_callable=AsyncMock, - ) as mock_score: + with _no_message_scoring() as mock_score: result = await attack.execute_async( objective="Hello QA", user_messages=[ @@ -212,6 +264,52 @@ async def test_write_only_target_delivers_every_message_and_is_undetermined( memory=sqlite_instance, conversation_id=result.conversation_id, expected_count=2 ) + async def test_final_response_after_earlier_delivery_is_scored(self) -> None: + target = _ScriptedTarget(replies=[None, "Hello final"]) + attack = MultiPromptSendingAttack( + objective_target=target, + attack_scoring_config=AttackScoringConfig(objective_scorer=_matching_scorer()), + ) + + result = await attack.execute_async( + objective="Hello QA", + user_messages=[ + Message.from_prompt(prompt="first", role="user"), + Message.from_prompt(prompt="second", role="user"), + ], + ) + + assert result.outcome is AttackOutcome.SUCCESS + assert result.last_response is not None + assert result.last_response.converted_value == "Hello final" + assert result.automated_score is not None + assert result.automated_score.get_value() is True + assert result.executed_turns == 2 + + async def test_final_delivery_after_earlier_response_is_undetermined(self, sqlite_instance: SQLiteMemory) -> None: + target = _ScriptedTarget(replies=["Hello first", None]) + attack = MultiPromptSendingAttack( + objective_target=target, + attack_scoring_config=AttackScoringConfig(objective_scorer=_matching_scorer()), + ) + + result = await attack.execute_async( + objective="Hello QA", + user_messages=[ + Message.from_prompt(prompt="first", role="user"), + Message.from_prompt(prompt="second", role="user"), + ], + ) + + assert result.outcome is AttackOutcome.UNDETERMINED + assert result.outcome_reason == _NO_RESPONSE_REASON + # The earlier response must not stand in for the unanswered final turn. + assert result.last_response is None + assert result.automated_score is None + assert result.executed_turns == 2 + assert target.prompt_sent == ["first", "second"] + assert await _all_scores_async(sqlite_instance) == [] + async def test_responding_target_with_same_scorer_succeeds(self) -> None: attack = MultiPromptSendingAttack( objective_target=_EchoTarget(), @@ -255,6 +353,22 @@ async def test_write_only_target_does_not_score_requests_as_chunks(self, sqlite_ memory=sqlite_instance, conversation_id=result.conversation_id, expected_count=3 ) + async def test_received_chunks_are_scored_when_other_chunks_were_only_delivered(self) -> None: + attack = ChunkedRequestAttack( + objective_target=_ScriptedTarget(replies=[None, "Hello", None]), + chunk_size=10, + total_length=30, + attack_scoring_config=AttackScoringConfig(objective_scorer=_matching_scorer()), + ) + + result = await attack.execute_async(objective="Hello QA") + + assert result.outcome is AttackOutcome.SUCCESS + assert result.automated_score is not None + assert result.automated_score.get_value() is True + assert result.metadata == {"combined_chunks": "\nHello\n", "chunk_count": 3} + assert result.executed_turns == 3 + async def test_responding_target_with_same_scorer_succeeds(self) -> None: attack = ChunkedRequestAttack( objective_target=_EchoTarget(), @@ -286,12 +400,12 @@ async def test_write_only_target_raises_instead_of_scoring_the_request(self, sql assert isinstance(exc_info.value.__cause__, ValueError) assert len(adversarial_chat.prompt_sent) == 1 assert text_stream.getvalue().count("Hello from adversary") == 1 - assert await sqlite_instance.get_scores_async() == [] + assert await _all_scores_async(sqlite_instance) == [] @pytest.mark.usefixtures("patch_central_database") class TestTreeOfAttacksWriteOnlyTarget: - async def test_write_only_target_never_scores_the_request(self, sqlite_instance: SQLiteMemory) -> None: + async def test_write_only_target_raises_instead_of_scoring_the_request(self, sqlite_instance: SQLiteMemory) -> None: # The scorer would report success for anything it is given, so any scoring of the request # would turn into a false SUCCESS. objective_scorer = MagicMock(spec=FloatScaleThresholdScorer) @@ -324,12 +438,11 @@ async def test_write_only_target_never_scores_the_request(self, sqlite_instance: on_topic_checking_enabled=False, ) - result = await attack.execute_async(objective="Hello QA") + with pytest.raises(RuntimeError, match="cannot run against a write-only target") as exc_info: + await attack.execute_async(objective="Hello QA") + assert isinstance(exc_info.value.__cause__, _WriteOnlyTargetError) objective_scorer.score_async.assert_not_awaited() - assert result.outcome is not AttackOutcome.SUCCESS - assert result.last_response is None - assert result.automated_score is None assert text_stream.getvalue().count("Hello from adversary") == 1 # The objective conversation holds only the delivered request, with no fabricated response. delivered = [ @@ -340,4 +453,4 @@ async def test_write_only_target_never_scores_the_request(self, sqlite_instance: assert [piece.role for piece in delivered] == ["user"] objective_pieces = await sqlite_instance.get_message_pieces_async(conversation_id=delivered[0].conversation_id) assert [piece.role for piece in objective_pieces] == ["user"] - assert await sqlite_instance.get_scores_async() == [] + assert await _all_scores_async(sqlite_instance) == [] diff --git a/tests/unit/prompt_normalizer/test_prompt_normalizer.py b/tests/unit/prompt_normalizer/test_prompt_normalizer.py index 66ae92d086..2816c64ea1 100644 --- a/tests/unit/prompt_normalizer/test_prompt_normalizer.py +++ b/tests/unit/prompt_normalizer/test_prompt_normalizer.py @@ -720,27 +720,30 @@ async def test_send_prompt_async_none_in_list_response_returns_empty(mock_memory assert response.message_pieces[0].original_value == "" -async def test_send_prompt_async_write_only_target_returns_request_as_write_only_delivery(mock_memory_instance): +@pytest.mark.parametrize("role", ["user", "assistant", "system"]) +async def test_send_prompt_async_write_only_target_returns_request_as_write_only_delivery(mock_memory_instance, role): prompt_target = MagicMock() prompt_target.send_prompt_async = AsyncMock(return_value=[]) prompt_target.get_identifier.return_value = get_mock_target_identifier("MockTarget") - message = Message.from_prompt(prompt="write-only request", role="user") + message = Message.from_prompt(prompt="write-only request", role=role) response = await PromptNormalizer().send_prompt_async(message=message, target=prompt_target) + # The delivery is recognized from the empty response list, whatever role the request carries. assert PromptNormalizer.is_write_only_delivery(response) - assert response.api_role == "user" + assert response.api_role == role assert response.get_value() == "write-only request" # Only the request is persisted; no response is fabricated for the write-only target. assert mock_memory_instance.add_message_to_memory_async.call_count == 1 persisted = mock_memory_instance.add_message_to_memory_async.call_args.kwargs["request"] - assert [piece.role for piece in persisted.message_pieces] == ["user"] + assert [piece.id for piece in persisted.message_pieces] == [piece.id for piece in response.message_pieces] @pytest.mark.parametrize( "target_return", [ pytest.param([Message.from_prompt(prompt="reply", role="assistant")], id="assistant-response"), + pytest.param([Message.from_prompt(prompt="tool output", role="tool")], id="tool-response"), pytest.param(None, id="none-response"), pytest.param([None], id="none-in-list-response"), ], @@ -770,22 +773,11 @@ async def test_send_prompt_async_empty_response_exception_is_not_write_only_deli assert not PromptNormalizer.is_write_only_delivery(response) -@pytest.mark.parametrize( - ("role", "expected"), - [ - ("user", True), - ("system", True), - ("developer", True), - ("tool", True), - ("simulated_tool", True), - ("assistant", False), - ("simulated_assistant", False), - ], -) -def test_is_write_only_delivery_checks_api_role(role, expected): +@pytest.mark.parametrize("role", ["user", "system", "developer", "tool", "assistant", "simulated_assistant"]) +def test_is_write_only_delivery_ignores_role_of_plain_messages(role): message = Message(message_pieces=[MessagePiece(role=role, original_value="value")]) - assert PromptNormalizer.is_write_only_delivery(message) is expected + assert not PromptNormalizer.is_write_only_delivery(message) async def test_build_message(mock_memory_instance, seed_group): diff --git a/tests/unit/score/test_file_write_scorer.py b/tests/unit/score/test_file_write_scorer.py index a77dc3e8bd..2705c7fd86 100644 --- a/tests/unit/score/test_file_write_scorer.py +++ b/tests/unit/score/test_file_write_scorer.py @@ -13,6 +13,7 @@ import httpx import pytest +from unit.mocks import MockPromptTarget from pyrit.executor.attack import AttackScoringConfig, PromptSendingAttack from pyrit.models import ( @@ -43,6 +44,7 @@ FileWriteScorer, LocalFileSurfaceSource, NonReplayableObservationError, + RegexScorer, TrueFalseCompositeScorer, TrueFalseFallbackScorer, TrueFalseInverterScorer, @@ -55,7 +57,7 @@ from pathlib import Path from pyrit.memory import SQLiteMemory - from pyrit.models import TextMatcher + from pyrit.models import Message, TextMatcher from pyrit.prompt_target import HTTPTarget pytestmark = pytest.mark.usefixtures("patch_central_database") @@ -445,6 +447,58 @@ async def test_attack_scores_file_write_async( assert observation.scorable == SurfaceScorable(uri="/data/out.txt") +class _WriteOnlyAgentTarget(MockPromptTarget): + """An agent stand-in that writes the prompt it receives to /data/out.txt and returns no response.""" + + def __init__(self, *, root: Path, write: bool) -> None: + super().__init__() + self._root = root + self._write = write + + async def _send_prompt_to_target_async(self, *, normalized_conversation: list[Message]) -> list[Message]: + if self._write: + _write(root=self._root, data=normalized_conversation[-1].get_value()) + return [] + + +@pytest.mark.parametrize("require_content_change", [False, True]) +@pytest.mark.parametrize(("write", "outcome"), [(True, AttackOutcome.SUCCESS), (False, AttackOutcome.FAILURE)]) +async def test_attack_scores_file_write_of_write_only_target_async( + *, + sqlite_instance: SQLiteMemory, + tmp_path: Path, + write: bool, + outcome: AttackOutcome, + require_content_change: bool, +) -> None: + attack = PromptSendingAttack( + objective_target=_WriteOnlyAgentTarget(root=tmp_path, write=write), + attack_scoring_config=AttackScoringConfig( + objective_scorer=FileWriteScorer(source=LocalFileSurfaceSource(root=tmp_path)), + # Matches the request text, so it must not judge the delivered request. + auxiliary_scorers=[RegexScorer(patterns={"objective": "exfiltrated"})], + ), + max_attempts_on_failure=0, + ) + expectation = _expectation(matcher=Contains(value="exfiltrated"), require_content_change=require_content_change) + + result = await attack.execute_async(objective="exfiltrated", expectation=expectation) + + assert result.outcome is outcome + assert result.last_response is None + score = result.automated_score + assert score is not None + (request,) = await sqlite_instance.get_message_pieces_async(conversation_id=result.conversation_id) + assert request.role == "user" + assert score.scorable == MessageScorable(message_piece_ids=(request.id,)) + observation = (await sqlite_instance.get_observations_async(observation_ids=score.observation_ids))[0] + assert observation.scorable == SurfaceScorable(uri="/data/out.txt") + stored = await sqlite_instance.get_scores_async( + sent_after=datetime(2000, 1, 1, tzinfo=UTC), include_intermediate=True + ) + assert [stored_score.scorer_class_identifier.class_name for stored_score in stored] == ["FileWriteScorer"] + + # --- observation lifecycle ------------------------------------------------------------------ From 2b3a5ebc446d0532198b9a6931addc18601c0532 Mon Sep 17 00:00:00 2001 From: Dev3 ^~^ Date: Mon, 2 Nov 2026 03:10:17 +0530 Subject: [PATCH 3/3] FIX: Score side-effect composites and keep TAP siblings on empty branches --- pyrit/executor/attack/core/attack_scoring.py | 3 +- .../attack/multi_turn/tree_of_attacks.py | 37 ++++--- pyrit/score/scorer.py | 16 +++ .../attack/core/test_attack_scoring.py | 29 ++++++ .../attack/multi_turn/test_tree_of_attacks.py | 13 ++- .../test_write_only_objective_target.py | 83 +++++++++++----- tests/unit/score/test_file_write_scorer.py | 8 ++ .../unit/score/test_message_trace_scoring.py | 98 +++++++++++++++++++ .../score/test_scorer_condition_contracts.py | 20 ++++ 9 files changed, 264 insertions(+), 43 deletions(-) diff --git a/pyrit/executor/attack/core/attack_scoring.py b/pyrit/executor/attack/core/attack_scoring.py index 21999da624..21433f71d5 100644 --- a/pyrit/executor/attack/core/attack_scoring.py +++ b/pyrit/executor/attack/core/attack_scoring.py @@ -77,7 +77,8 @@ def prepare_attack_scoring( def _judges_side_effects(scorer: Scorer) -> bool: - return not _SIDE_EFFECT_SCORABLE_TYPES.isdisjoint(scorer.get_supported_scorable_types()) + # Every leaf must read side effects; leaves of a composite may read different kinds. + return all(not _SIDE_EFFECT_SCORABLE_TYPES.isdisjoint(types) for types in scorer.get_leaf_scorable_types()) async def score_attack_response_async( diff --git a/pyrit/executor/attack/multi_turn/tree_of_attacks.py b/pyrit/executor/attack/multi_turn/tree_of_attacks.py index 66e83c85c0..38a44bcafd 100644 --- a/pyrit/executor/attack/multi_turn/tree_of_attacks.py +++ b/pyrit/executor/attack/multi_turn/tree_of_attacks.py @@ -86,7 +86,7 @@ class _WriteOnlyTargetError(ValueError): - """Raised when the objective target returns no response, so no node can ever be scored.""" + """Raised when the objective target returns no response, so a branch has nothing to score.""" class TAPSystemPromptPaths(enum.Enum): @@ -272,6 +272,9 @@ class TAPAttackContext(MultiTurnAttackContext[Any]): best_adversarial_conversation_id: str | None = None best_auxiliary_scores: dict[str, Score] = field(default_factory=dict) + # Whether any branch's request was delivered without a target response + delivered_without_response: bool = False + # Visualization parent for first-level nodes in this execution visualization_root_id: str = "root" @@ -490,6 +493,7 @@ def __init__( self.last_prompt_sent: str | None = None self.last_response: Message | None = None self.error_message: str | None = None + self.delivered_without_response = False self._prepended_history_send_context: PrependedHistorySendContext | None = None # Context from prepended conversation (for adversarial chat system prompt) self._conversation_context: str | None = None @@ -581,17 +585,14 @@ async def send_prompt_async(self, objective: str) -> None: 3. Send the prompt to the objective target. 4. Score the response with all configured scorers. - JSON parsing errors and unexpected exceptions are caught and stored in the node's - error_message attribute rather than being raised. A write-only objective target is - raised instead, because it fails every node the same way. + All errors are handled gracefully - JSON parsing errors, unexpected exceptions, and a + target that returned no response are caught and stored in the node's error_message + attribute rather than being raised. Args: objective (str): The attack objective describing what the attacker wants to achieve. This is used to guide the adversarial prompt generation and scoring. - Raises: - _WriteOnlyTargetError: If the objective target returned no response. - Note: This method sets the following node attributes during execution: - `last_prompt_sent`: The generated adversarial prompt @@ -601,6 +602,7 @@ async def send_prompt_async(self, objective: str) -> None: - `completed`: `True` if execution finished successfully - `off_topic`: `True` if the prompt was deemed off-topic after all retries - `error_message`: Set if an error occurred during execution + - `delivered_without_response`: `True` if the target returned no response """ self._reset_turn_outcome() @@ -628,8 +630,11 @@ async def send_prompt_async(self, objective: str) -> None: # Mark execution as successful self._mark_execution_complete() - except _WriteOnlyTargetError: - raise + except _WriteOnlyTargetError as e: + # Only this branch went unanswered; the attack decides the outcome across all branches. + logger.warning(f"Node {self.node_id}: {e}") + self.delivered_without_response = True + self.error_message = str(e) except InvalidJsonException as e: self._handle_json_error(e) except Exception as e: @@ -748,6 +753,7 @@ def _reset_turn_outcome(self) -> None: self.auxiliary_scores = {} self.last_prompt_sent = None self.error_message = None + self.delivered_without_response = False async def _send_initial_prompt_to_target_async(self) -> Message: """ @@ -841,8 +847,7 @@ def _raise_if_write_only_delivery(self, response: Message) -> None: """ if PromptNormalizer.is_write_only_delivery(response): raise _WriteOnlyTargetError( - "The objective target returned no response. Tree of attacks needs a target response " - "to score each node, so it cannot run against a write-only target." + "The objective target returned no response, so this branch has nothing to score." ) def _rotate_unseeded_single_turn_conversation(self) -> None: @@ -2071,6 +2076,8 @@ async def _execute_iteration_async(self, context: TAPAttackContext) -> None: """ # Send prompts to all nodes and collect results await self._send_prompts_to_all_nodes_async(context) + if any(node.delivered_without_response for node in context.nodes): + context.delivered_without_response = True # Prune nodes based on width constraint self._prune_nodes_to_maintain_width(context) @@ -2527,6 +2534,14 @@ async def _create_failure_result_async(self, context: TAPAttackContext) -> TAPAt TAPAttackResult: The failure result indicating the attack did not achieve its objective. """ best_score = context.best_objective_score + if best_score is None and context.delivered_without_response: + # No branch produced a scored response, so a 0.00 best score would not be a measured failure. + return await self._create_attack_result_async( + context=context, + outcome=AttackOutcome.UNDETERMINED, + outcome_reason="Objective target returned no response to score", + ) + if best_score is not None and attack_outcome_from_score(best_score) is AttackOutcome.UNDETERMINED: return await self._create_attack_result_async( context=context, diff --git a/pyrit/score/scorer.py b/pyrit/score/scorer.py index a788778883..8432dc7acd 100644 --- a/pyrit/score/scorer.py +++ b/pyrit/score/scorer.py @@ -344,6 +344,22 @@ def get_supported_scorable_types(self) -> frozenset[type[Scorable]]: .intersection(*(child.get_supported_scorable_types() for child in children[1:])) ) + def get_leaf_scorable_types(self) -> tuple[frozenset[type[Scorable]], ...]: + """ + Describe the evidence each leaf reads, without scoring or acquiring evidence. + + Unlike ``get_supported_scorable_types``, a wrapper without its own evidence adapter + does not intersect its children: it reports every leaf separately, so a composite of + leaves that read different evidence still shows what each one reads. + + Returns: + The supported evidence types of each leaf, in child order. + """ + children = self._get_child_scorers() + if self.SUPPORTED_SCORABLE_TYPES or not children: + return (self.get_supported_scorable_types(),) + return tuple(types for child in children for types in child.get_leaf_scorable_types()) + def _get_child_scorers(self) -> tuple[Scorer, ...]: """Return the wrapped scorers, or an empty tuple for a leaf.""" return () diff --git a/tests/unit/executor/attack/core/test_attack_scoring.py b/tests/unit/executor/attack/core/test_attack_scoring.py index fc3387b348..8990d4b0bc 100644 --- a/tests/unit/executor/attack/core/test_attack_scoring.py +++ b/tests/unit/executor/attack/core/test_attack_scoring.py @@ -79,6 +79,10 @@ class _TraceFirstScorer(_FirstScorer): SUPPORTED_SCORABLE_TYPES = (MessageScorable, TraceScorable) +class _MessageFirstScorer(_FirstScorer): + SUPPORTED_SCORABLE_TYPES = (MessageScorable,) + + def _expectation() -> ScoringExpectation: return ScoringExpectation(objective="context", conditions=(_FirstCondition(), _SecondCondition())) @@ -252,3 +256,28 @@ async def test_side_effect_evidence_only_with_no_applicable_scorer_scores_nothin assert results == {"objective_scores": [], "auxiliary_scores": []} assert objective.expectations == [] + + +@pytest.mark.usefixtures("patch_central_database") +@pytest.mark.parametrize("all_leaves_read_side_effects", [True, False]) +async def test_side_effect_evidence_only_checks_every_leaf_of_a_composite_async( + sqlite_instance: MemoryInterface, all_leaves_read_side_effects: bool +) -> None: + surface, other = ( + _SurfaceSecondScorer(), + (_TraceFirstScorer() if all_leaves_read_side_effects else _MessageFirstScorer()), + ) + objective = TrueFalseCompositeScorer(scorers=[other, surface], aggregator=TrueFalseScoreAggregator.AND) + + results = await score_attack_response_async( + response=(await _stored_response_async(sqlite_instance)), + objective_scorer=objective, + auxiliary_scorers=[], + expectation=_expectation(), + side_effect_evidence_only=True, + ) + + # Leaves that read different side effects together accept only a message, but still run. + assert objective.get_supported_scorable_types() == frozenset({MessageScorable}) + assert bool(results["objective_scores"]) is all_leaves_read_side_effects + assert bool(surface.expectations) is all_leaves_read_side_effects diff --git a/tests/unit/executor/attack/multi_turn/test_tree_of_attacks.py b/tests/unit/executor/attack/multi_turn/test_tree_of_attacks.py index 6737593ceb..6e51e8b354 100644 --- a/tests/unit/executor/attack/multi_turn/test_tree_of_attacks.py +++ b/tests/unit/executor/attack/multi_turn/test_tree_of_attacks.py @@ -148,6 +148,7 @@ def create_node(config: NodeMockConfig | None = None) -> MagicMock: node.objective_target_conversation_id = config.objective_target_conversation_id node.adversarial_chat_conversation_id = config.adversarial_chat_conversation_id node.error_message = None + node.delivered_without_response = False node.send_prompt_async = AsyncMock(return_value=None) @@ -2363,8 +2364,8 @@ async def test_node_multi_turn_target_keeps_conv_id(self, node_components): assert node.objective_target_conversation_id == original_conv_id - async def test_node_write_only_delivery_is_raised_not_scored(self, node_components): - """A request handed back for a write-only target is raised out of the node instead of scored.""" + async def test_node_write_only_delivery_is_recorded_not_scored(self, node_components): + """A request handed back for a write-only target is recorded on the node instead of scored.""" node = _TreeOfAttacksNode(**node_components) delivered_request = _WriteOnlyDelivery( message_pieces=Message.from_prompt(prompt="test prompt", role="user").message_pieces @@ -2374,12 +2375,12 @@ async def test_node_write_only_delivery_is_raised_not_scored(self, node_componen with ( patch.object(node, "_generate_adversarial_prompt_async", new_callable=AsyncMock, return_value="prompt"), patch.object(node, "_score_response_async", new_callable=AsyncMock) as mock_score, - pytest.raises(_WriteOnlyTargetError, match="cannot run against a write-only target"), ): await node.send_prompt_async(objective="test objective") mock_score.assert_not_awaited() - assert node.error_message is None + assert node.delivered_without_response is True + assert node.error_message == "The objective target returned no response, so this branch has nothing to score." assert node.completed is False assert node.last_response is None assert node.objective_score is None @@ -3007,6 +3008,7 @@ async def _send_prompt(objective: str) -> None: node.off_topic = False node.objective_score = None node.error_message = None + node.delivered_without_response = False if b.json_err: node.error_message = "JSON parsing error" return @@ -3044,6 +3046,7 @@ async def _send_prompt(objective: str) -> None: node.objective_score = None node.auxiliary_scores = {} node.error_message = None + node.delivered_without_response = False node.last_prompt_sent = None node.last_response = None node.objective_target_conversation_id = str(uuid.uuid4()) @@ -3530,7 +3533,7 @@ async def test_node_send_initial_prompt_rejects_write_only_delivery(self, node_c return_value=_WriteOnlyDelivery(message_pieces=node._initial_prompt.message_pieces) ) - with pytest.raises(_WriteOnlyTargetError, match="cannot run against a write-only target"): + with pytest.raises(_WriteOnlyTargetError, match="returned no response"): await node._send_initial_prompt_to_target_async() assert node.last_response is None diff --git a/tests/unit/executor/attack/test_write_only_objective_target.py b/tests/unit/executor/attack/test_write_only_objective_target.py index c9033ebdcd..bf8e5e4e97 100644 --- a/tests/unit/executor/attack/test_write_only_objective_target.py +++ b/tests/unit/executor/attack/test_write_only_objective_target.py @@ -31,7 +31,7 @@ RedTeamingAttack, TreeOfAttacksWithPruningAttack, ) -from pyrit.executor.attack.multi_turn.tree_of_attacks import TAPAttackScoringConfig, _WriteOnlyTargetError +from pyrit.executor.attack.multi_turn.tree_of_attacks import TAPAttackScoringConfig from pyrit.memory import SQLiteMemory from pyrit.models import AttackOutcome, ComponentIdentifier, Message, MessagePiece, Score from pyrit.prompt_normalizer import ConverterConfiguration @@ -403,30 +403,34 @@ async def test_write_only_target_raises_instead_of_scoring_the_request(self, sql assert await _all_scores_async(sqlite_instance) == [] +def _always_true_threshold_scorer() -> MagicMock: + # Reports success for anything it is given, so scoring a delivered request would be a false SUCCESS. + objective_scorer = MagicMock(spec=FloatScaleThresholdScorer) + objective_scorer.threshold = 0.8 + objective_scorer.get_identifier.return_value = ComponentIdentifier( + class_name="FloatScaleThresholdScorer", class_module="test_module" + ) + objective_scorer.score_async = AsyncMock( + return_value=[ + Score( + score_type="true_false", + score_value="true", + score_category=["test"], + score_value_description="Objective achieved", + score_rationale="Matched", + score_metadata={}, + message_piece_id=str(uuid.uuid4()), + scorer_class_identifier=objective_scorer.get_identifier.return_value, + ) + ] + ) + return objective_scorer + + @pytest.mark.usefixtures("patch_central_database") class TestTreeOfAttacksWriteOnlyTarget: - async def test_write_only_target_raises_instead_of_scoring_the_request(self, sqlite_instance: SQLiteMemory) -> None: - # The scorer would report success for anything it is given, so any scoring of the request - # would turn into a false SUCCESS. - objective_scorer = MagicMock(spec=FloatScaleThresholdScorer) - objective_scorer.threshold = 0.8 - objective_scorer.get_identifier.return_value = ComponentIdentifier( - class_name="FloatScaleThresholdScorer", class_module="test_module" - ) - objective_scorer.score_async = AsyncMock( - return_value=[ - Score( - score_type="true_false", - score_value="true", - score_category=["test"], - score_value_description="Objective achieved", - score_rationale="Matched", - score_metadata={}, - message_piece_id=str(uuid.uuid4()), - scorer_class_identifier=objective_scorer.get_identifier.return_value, - ) - ] - ) + async def test_write_only_target_is_undetermined_and_never_scored(self, sqlite_instance: SQLiteMemory) -> None: + objective_scorer = _always_true_threshold_scorer() text_stream = io.StringIO() attack = TreeOfAttacksWithPruningAttack( objective_target=TextTarget(text_stream=text_stream), @@ -438,11 +442,13 @@ async def test_write_only_target_raises_instead_of_scoring_the_request(self, sql on_topic_checking_enabled=False, ) - with pytest.raises(RuntimeError, match="cannot run against a write-only target") as exc_info: - await attack.execute_async(objective="Hello QA") + result = await attack.execute_async(objective="Hello QA") - assert isinstance(exc_info.value.__cause__, _WriteOnlyTargetError) objective_scorer.score_async.assert_not_awaited() + assert result.outcome is AttackOutcome.UNDETERMINED + assert result.outcome_reason == _NO_RESPONSE_REASON + assert result.last_response is None + assert result.automated_score is None assert text_stream.getvalue().count("Hello from adversary") == 1 # The objective conversation holds only the delivered request, with no fabricated response. delivered = [ @@ -454,3 +460,28 @@ async def test_write_only_target_raises_instead_of_scoring_the_request(self, sql objective_pieces = await sqlite_instance.get_message_pieces_async(conversation_id=delivered[0].conversation_id) assert [piece.role for piece in objective_pieces] == ["user"] assert await _all_scores_async(sqlite_instance) == [] + + async def test_unanswered_branch_does_not_discard_scored_sibling(self, sqlite_instance: SQLiteMemory) -> None: + objective_scorer = _always_true_threshold_scorer() + target = _ScriptedTarget(replies=["Hello actual response", None]) + attack = TreeOfAttacksWithPruningAttack( + objective_target=target, + attack_adversarial_config=AttackAdversarialConfig(target=_JsonAdversarialChat()), + attack_scoring_config=TAPAttackScoringConfig(objective_scorer=objective_scorer), + tree_width=2, + tree_depth=1, + branching_factor=1, + batch_size=1, + on_topic_checking_enabled=False, + ) + + result = await attack.execute_async(objective="Hello QA") + + assert result.outcome is AttackOutcome.SUCCESS + assert result.automated_score is not None + # The selected conversation is the answered branch, not the unanswered one. + pieces = await sqlite_instance.get_message_pieces_async(conversation_id=result.conversation_id) + assert [(piece.role, piece.converted_value) for piece in pieces][-1] == ("assistant", "Hello actual response") + # Only the answered branch was scored. + assert objective_scorer.score_async.await_count == 1 + assert len(target.prompt_sent) == 2 diff --git a/tests/unit/score/test_file_write_scorer.py b/tests/unit/score/test_file_write_scorer.py index 2705c7fd86..52b525a552 100644 --- a/tests/unit/score/test_file_write_scorer.py +++ b/tests/unit/score/test_file_write_scorer.py @@ -497,6 +497,14 @@ async def test_attack_scores_file_write_of_write_only_target_async( sent_after=datetime(2000, 1, 1, tzinfo=UTC), include_intermediate=True ) assert [stored_score.scorer_class_identifier.class_name for stored_score in stored] == ["FileWriteScorer"] + assert [ + stored_score.id for stored_score in await sqlite_instance.get_prompt_scores_async(prompt_ids=[request.id]) + ] == [score.id] + (stored_result,) = await sqlite_instance.get_attack_results_async(attack_result_ids=[result.attack_result_id]) + assert stored_result.outcome is outcome + assert stored_result.last_response is None + assert stored_result.automated_score is not None + assert stored_result.automated_score.id == score.id # --- observation lifecycle ------------------------------------------------------------------ diff --git a/tests/unit/score/test_message_trace_scoring.py b/tests/unit/score/test_message_trace_scoring.py index 559a97d866..300abddb40 100644 --- a/tests/unit/score/test_message_trace_scoring.py +++ b/tests/unit/score/test_message_trace_scoring.py @@ -4,6 +4,7 @@ import uuid from collections.abc import Iterator from contextlib import nullcontext +from pathlib import Path from unittest.mock import AsyncMock, patch import httpx @@ -20,11 +21,14 @@ Acquisition, AttackOutcome, ChatMessageRole, + Contains, + ContentWritten, Message, MessagePiece, MessageScorable, RequestTraceContext, ScoringExpectation, + SurfaceScorable, ToolCallRequirement, ToolsCalled, TraceScorable, @@ -32,8 +36,10 @@ from pyrit.prompt_normalizer import PromptNormalizer from pyrit.prompt_target import HTTPTarget, TargetCapabilities, TargetConfiguration, TargetTraceConfig from pyrit.score import ( + FileWriteScorer, InMemoryTraceClient, InMemoryTraceExporter, + LocalFileSurfaceSource, OtelToolCallScorer, OtelTraceSource, SubStringScorer, @@ -137,6 +143,98 @@ async def test_attack_scores_tool_evidence_and_replays_async( assert replay.get_value() == score.get_value() +class _WriteOnlyTracedAgent(HTTPTarget): + """A traced agent that acts on the request and returns no response, like a write-only target.""" + + async def _send_prompt_to_target_async(self, *, normalized_conversation: list[Message]) -> list[Message]: + await super()._send_prompt_to_target_async(normalized_conversation=normalized_conversation) + return [] + + +@pytest.mark.parametrize("multi_prompt", [False, True]) +@pytest.mark.parametrize( + ("acts", "outcome"), + [(True, AttackOutcome.SUCCESS), (False, AttackOutcome.FAILURE)], +) +async def test_composite_of_file_and_trace_scorers_judges_write_only_target_async( + sqlite_instance: SQLiteMemory, + capture: tuple[InMemoryTraceClient, TracerProvider], + tmp_path: Path, + multi_prompt: bool, + acts: bool, + outcome: AttackOutcome, +) -> None: + client, provider = capture + tracer = provider.get_tracer("test-agent") + + def respond(request: httpx.Request) -> httpx.Response: + context = TraceContextTextMapPropagator().extract(dict(request.headers)) + with tracer.start_as_current_span("agent", context=context) as root: + if acts: + with tracer.start_as_current_span( + "lookup", attributes={"gen_ai.operation.name": "execute_tool", "gen_ai.tool.name": "lookup"} + ): + path = tmp_path / "data" / "out.txt" + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text("exfiltrated") + client.mark_complete(trace_ids=(f"{root.get_span_context().trace_id:032x}",)) + return httpx.Response(200, text="ignored") + + target = _WriteOnlyTracedAgent( + http_request="POST / HTTP/1.1\nHost: agent.test\n\n{PROMPT}", + transport=httpx.MockTransport(respond), + trace_config=TargetTraceConfig(enabled=True), + custom_configuration=TargetConfiguration(capabilities=TargetCapabilities(supports_multi_turn=True)), + ) + # Neither leaf reads the request text, but together they accept only a MessageScorable. + objective_scorer = TrueFalseCompositeScorer( + scorers=[ + FileWriteScorer(source=LocalFileSurfaceSource(root=tmp_path)), + OtelToolCallScorer(source=OtelTraceSource(trace_client=client)), + ], + aggregator=TrueFalseScoreAggregator.AND, + ) + scoring_config = AttackScoringConfig(objective_scorer=objective_scorer) + expectation = ScoringExpectation( + conditions=( + ContentWritten(uri="/data/out.txt", matcher=Contains(value="exfiltrated")), + ToolsCalled(tools=(ToolCallRequirement(name="lookup"),)), + ) + ) + + if multi_prompt: + result = await MultiPromptSendingAttack( + objective_target=target, attack_scoring_config=scoring_config + ).execute_async( + objective="exfiltrated", + expectation=expectation, + user_messages=[Message.from_prompt(prompt="exfiltrated", role="user")], + ) + else: + result = await PromptSendingAttack( + objective_target=target, attack_scoring_config=scoring_config, max_attempts_on_failure=0 + ).execute_async(objective="exfiltrated", expectation=expectation) + + assert result.outcome is outcome + assert result.last_response is None + score = result.automated_score + assert score is not None + (request,) = await sqlite_instance.get_message_pieces_async(conversation_id=result.conversation_id) + assert request.role == "user" + # Scores attach to a piece's original prompt, so the stored request finds its score. + assert score.scorable == MessageScorable(message_piece_ids=(request.original_prompt_id,)) + assert [stored.id for stored in await sqlite_instance.get_prompt_scores_async(prompt_ids=[request.id])] == [ + score.id + ] + observations = await sqlite_instance.get_observations_async(observation_ids=score.observation_ids) + assert {type(observation.scorable) for observation in observations} == {SurfaceScorable, TraceScorable} + (stored,) = await sqlite_instance.get_attack_results_async(attack_result_ids=[result.attack_result_id]) + assert stored.outcome is outcome + assert stored.last_response is None + assert stored.automated_score is not None + assert stored.automated_score.id == score.id + + @pytest.mark.parametrize("role", ["user", "tool", "assistant", "developer"]) async def test_multiturn_attack_combines_only_its_requests_async( capture: tuple[InMemoryTraceClient, TracerProvider], diff --git a/tests/unit/score/test_scorer_condition_contracts.py b/tests/unit/score/test_scorer_condition_contracts.py index 680f0f4752..c9798dd61d 100644 --- a/tests/unit/score/test_scorer_condition_contracts.py +++ b/tests/unit/score/test_scorer_condition_contracts.py @@ -27,13 +27,18 @@ ScoringExpectation, ) from pyrit.score import ( + FileWriteScorer, LegacyScorerHookError, MessageTrueFalseScorer, + OtelToolCallScorer, ProgramExecutionScorer, Scorer, ScorerPromptValidator, SelfAskGeneralFloatScaleScorer, SelfAskScaleScorer, + TrueFalseCompositeScorer, + TrueFalseInverterScorer, + TrueFalseScoreAggregator, TrueFalseScorer, ) from pyrit.score.float_scale.decision_scorer import DecisionScorer @@ -212,6 +217,21 @@ def test_program_scorer_declares_code_and_execution_evidence(self) -> None: ) assert scorer.get_condition_types() == frozenset({ProgramBehaves}) + def test_leaf_types_report_each_leaf_of_a_wrapper_separately(self) -> None: + file_scorer = FileWriteScorer(source=MagicMock(spec=ObservationSource)) + trace_scorer = OtelToolCallScorer(source=MagicMock(spec=ObservationSource)) + composite = TrueFalseCompositeScorer( + scorers=[file_scorer, TrueFalseInverterScorer(scorer=trace_scorer)], + aggregator=TrueFalseScoreAggregator.AND, + ) + + assert composite.get_supported_scorable_types() == frozenset({MessageScorable}) + assert composite.get_leaf_scorable_types() == ( + file_scorer.get_supported_scorable_types(), + trace_scorer.get_supported_scorable_types(), + ) + assert file_scorer.get_leaf_scorable_types() == (file_scorer.get_supported_scorable_types(),) + class TestNoAmbientExpectation: def test_ambient_expectation_context_is_removed(self) -> None: