33
44import asyncio
55import json
6+ import logging
67from typing import Any
8+ from unittest .mock import AsyncMock , patch
79
810import pytest
911from unit .mocks import MockPromptTarget
2123 SingleTurnAttackContext ,
2224)
2325from pyrit .executor .attack .compound import SequenceCompletionPolicy , SequentialAttack , SequentialChildAttack
26+ from pyrit .executor .attack .core .attack_strategy import _DefaultAttackStrategyEventHandler
27+ from pyrit .executor .core import Strategy
2428from pyrit .memory import SQLiteMemory
2529from pyrit .models import (
30+ AtomicAttackIdentifier ,
2631 AttackOutcome ,
2732 AttackResult ,
2833 AttackResultMetadata ,
@@ -107,6 +112,22 @@ async def _perform_async(self, *, context: SingleTurnAttackContext[Any]) -> Atta
107112 return result
108113
109114
115+ class _FailingStrategy (Strategy [SingleTurnAttackContext [AttackParameters ], AttackResult ]):
116+ """A generic strategy with an attack context but no component identifier."""
117+
118+ def _validate_context (self , * , context : SingleTurnAttackContext [AttackParameters ]) -> None :
119+ pass
120+
121+ async def _setup_async (self , * , context : SingleTurnAttackContext [AttackParameters ]) -> None :
122+ pass
123+
124+ async def _perform_async (self , * , context : SingleTurnAttackContext [AttackParameters ]) -> AttackResult :
125+ raise ValueError ("strategy failed" )
126+
127+ async def _teardown_async (self , * , context : SingleTurnAttackContext [AttackParameters ]) -> None :
128+ pass
129+
130+
110131async def _owned_ids_async (memory : SQLiteMemory , attack_result_id : str ) -> set [str ]:
111132 conversations = await memory .get_attack_result_conversations_async (attack_result_id = attack_result_id )
112133 return {conversation .conversation_id for conversation in conversations }
@@ -276,11 +297,10 @@ async def test_standalone_sequential_results_record_their_roles_without_a_parent
276297) -> None :
277298 target = _RecordingTarget (fail = fail )
278299 seed_group = AttackSeedGroup (seeds = [SeedObjective (value = "objective" )])
300+ child = PromptSendingAttack (objective_target = target )
279301 sequential = SequentialAttack (
280302 objective_target = target ,
281- child_attacks = [
282- SequentialChildAttack (strategy = PromptSendingAttack (objective_target = target ), seed_group = seed_group )
283- ],
303+ child_attacks = [SequentialChildAttack (strategy = child , seed_group = seed_group )],
284304 )
285305
286306 if fail :
@@ -299,6 +319,14 @@ async def test_standalone_sequential_results_record_their_roles_without_a_parent
299319 AttackResultRole .ORCHESTRATION ,
300320 AttackResultRole .TARGET_FACING ,
301321 }
322+ if fail :
323+ for result in stored :
324+ assert result .atomic_attack_identifier is not None
325+ assert result .atomic_attack_identifier .eval_hash is not None
326+ expected = sequential if result .attribution_data ["result_role" ] == "orchestration" else child
327+ technique = result .atomic_attack_identifier .get_child ("attack_technique" )
328+ assert technique is not None
329+ assert technique .get_child ("attack" ) == expected .get_identifier ()
302330
303331
304332async def test_history_from_an_earlier_execution_is_copied_into_a_new_conversation_async (
@@ -378,3 +406,108 @@ async def test_child_attack_conversations_link_to_the_child_async(sqlite_instanc
378406 for child in children :
379407 assert await _owned_ids_async (sqlite_instance , child .attack_result_id ) == {child .conversation_id }
380408 assert await _owned_ids_async (sqlite_instance , result .attack_result_id ) == set ()
409+
410+
411+ async def test_error_result_records_the_attack_identity_async (sqlite_instance : SQLiteMemory ) -> None :
412+ attack = PromptSendingAttack (objective_target = _RecordingTarget (fail = True ))
413+ context = SingleTurnAttackContext (params = AttackParameters (objective = "objective" ))
414+
415+ with pytest .raises (RuntimeError ):
416+ await attack .execute_with_context_async (context = context )
417+
418+ [stored ] = await sqlite_instance .get_attack_results_async (attack_classes = ["PromptSendingAttack" ])
419+ assert stored .outcome == AttackOutcome .ERROR
420+ assert stored .atomic_attack_identifier is not None
421+ technique = stored .atomic_attack_identifier .get_child ("attack_technique" )
422+ assert technique is not None
423+ assert technique .get_child ("attack" ) == attack .get_identifier ()
424+ assert stored .atomic_attack_identifier .eval_hash is not None
425+ assert context ._error_result_identifier_builder is None
426+
427+
428+ @pytest .mark .parametrize ("builder_stage" , ["get_identifier" , "build" ])
429+ async def test_error_identifier_failure_preserves_original_error_and_result_async (
430+ sqlite_instance : SQLiteMemory , caplog : pytest .LogCaptureFixture , builder_stage : str
431+ ) -> None :
432+ attack = PromptSendingAttack (objective_target = _RecordingTarget ())
433+ context = SingleTurnAttackContext (params = AttackParameters (objective = "objective" ))
434+ original_error = ValueError ("attack failed" )
435+ builder_error = RuntimeError ("identifier failed" )
436+ builder_owner = attack if builder_stage == "get_identifier" else AtomicAttackIdentifier
437+
438+ with (
439+ patch .object (attack , "_perform_async" , new_callable = AsyncMock , side_effect = original_error ),
440+ patch .object (builder_owner , builder_stage , side_effect = builder_error ) as builder ,
441+ caplog .at_level (logging .WARNING ),
442+ pytest .raises (RuntimeError , match = "attack failed" ) as raised ,
443+ ):
444+ await attack .execute_with_context_async (context = context )
445+
446+ assert raised .value .__cause__ is original_error
447+ builder .assert_called_once ()
448+ [stored ] = await sqlite_instance .get_attack_results_async (attack_result_ids = [context .attack_result_id ])
449+ assert stored .outcome is AttackOutcome .ERROR
450+ assert stored .error_type == "ValueError"
451+ assert stored .error_message == "attack failed"
452+ assert stored .atomic_attack_identifier is None
453+ assert context ._error_result_persistence_error is None
454+ assert context ._persisted_attack_result_id == stored .attack_result_id
455+ assert context ._error_result_identifier_builder is None
456+ [record ] = [record for record in caplog .records if "Could not build the attack identifier" in record .message ]
457+ assert record .levelno == logging .WARNING
458+ assert record .exc_info is not None
459+ assert record .exc_info [1 ] is builder_error
460+
461+
462+ async def test_generic_strategy_error_result_does_not_require_an_identifier_async (
463+ sqlite_instance : SQLiteMemory ,
464+ ) -> None :
465+ strategy = _FailingStrategy (
466+ context_type = SingleTurnAttackContext ,
467+ event_handler = _DefaultAttackStrategyEventHandler (),
468+ )
469+ context = SingleTurnAttackContext (params = AttackParameters (objective = "objective" ))
470+
471+ with pytest .raises (RuntimeError , match = "strategy failed" ) as raised :
472+ await strategy .execute_with_context_async (context = context )
473+
474+ assert isinstance (raised .value .__cause__ , ValueError )
475+ [stored ] = await sqlite_instance .get_attack_results_async (objective = "objective" )
476+ assert stored .outcome is AttackOutcome .ERROR
477+ assert stored .error_message == "strategy failed"
478+ assert stored .atomic_attack_identifier is None
479+ assert context ._persisted_attack_result_id == stored .attack_result_id
480+
481+
482+ @pytest .mark .parametrize ("first_fails" , [False , True ])
483+ async def test_reused_context_records_the_current_attack_identity_async (
484+ sqlite_instance : SQLiteMemory , first_fails : bool
485+ ) -> None :
486+ first = PromptSendingAttack (objective_target = _RecordingTarget (fail = first_fails ))
487+ second = PromptSendingAttack (
488+ objective_target = _RecordingTarget (fail = True ),
489+ attack_converter_config = AttackConverterConfig (
490+ request_converters = ConverterConfiguration .from_converters (converters = [_RecordingConverter ()])
491+ ),
492+ )
493+ context = SingleTurnAttackContext (params = AttackParameters (objective = "objective" ))
494+
495+ if first_fails :
496+ with pytest .raises (RuntimeError , match = "target unavailable" ):
497+ await first .execute_with_context_async (context = context )
498+ else :
499+ await first .execute_with_context_async (context = context )
500+ first_id = context .attack_result_id
501+ assert context ._error_result_identifier_builder is None
502+
503+ with pytest .raises (RuntimeError , match = "target unavailable" ):
504+ await second .execute_with_context_async (context = context )
505+
506+ assert context .attack_result_id != first_id
507+ assert context ._error_result_identifier_builder is None
508+ for result_id , attack in ((first_id , first ), (context .attack_result_id , second )):
509+ [stored ] = await sqlite_instance .get_attack_results_async (attack_result_ids = [result_id ])
510+ assert stored .atomic_attack_identifier is not None
511+ assert stored .atomic_attack_identifier == AtomicAttackIdentifier .build (
512+ attack_identifier = attack .get_identifier ()
513+ )
0 commit comments