From cb639c10341e5fa1dda1fa430e69a7b890dcbc85 Mon Sep 17 00:00:00 2001 From: Victor Valbuena Date: Mon, 21 Sep 2026 18:05:25 -0700 Subject: [PATCH 01/19] MAINT: get_mock_target gains real modality capabilities for tests MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Extend the shared ``get_mock_target`` helper with keyword-only ``input_modalities`` / ``output_modalities`` so tests can build a ``MagicMock(spec=PromptTarget)`` whose ``configuration.capabilities`` carries real ``frozenset`` modality combinations instead of MagicMock children. With no modality arguments the helper is unchanged. Add ``modality_combos()`` to canonicalise combination shapes, and a new ``tests/unit/modality_profiles.py`` with six named profiles (text-only, vision, image-edit, realtime audio in/out, video generation) as a shared vocabulary for capability-aware tests. Add a contract suite for the helper and profiles: back-compat pins (including that the default mock's capabilities are *not* real frozensets — the hazard downstream validation must tolerate), every ``PromptDataType`` round-trips on both sides, ``{text, }`` combos for each media type, iterable coercion, and proof that invalid input propagates pydantic's ``ValidationError`` rather than being swallowed. Migrate the ``_ModalityFeedbackRouter`` tests off their local ``_build_target`` helper onto ``get_mock_target``; no assertions change. Groundwork for plan-time scenario modality validation. Co-Authored-By: Claude Fable 5.1 --- .../attack/component/test_modality_router.py | 101 +++---- tests/unit/mocks.py | 44 ++- tests/unit/modality_profiles.py | 36 +++ tests/unit/test_mock_target.py | 262 ++++++++++++++++++ 4 files changed, 383 insertions(+), 60 deletions(-) create mode 100644 tests/unit/modality_profiles.py create mode 100644 tests/unit/test_mock_target.py diff --git a/tests/unit/executor/attack/component/test_modality_router.py b/tests/unit/executor/attack/component/test_modality_router.py index 7ddf955268..76ce35e4cf 100644 --- a/tests/unit/executor/attack/component/test_modality_router.py +++ b/tests/unit/executor/attack/component/test_modality_router.py @@ -6,35 +6,20 @@ from __future__ import annotations from typing import TYPE_CHECKING -from unittest.mock import MagicMock import pytest +from unit.mocks import get_mock_target from pyrit.executor.attack.component.modality_router import _ModalityFeedbackRouter from pyrit.models import Message, MessagePiece -from pyrit.prompt_target.common.target_capabilities import TargetCapabilities -from pyrit.prompt_target.common.target_configuration import TargetConfiguration if TYPE_CHECKING: - from collections.abc import Iterable - from pyrit.models.literals import PromptDataType # --------------------------------------------------------------------------- # Helpers # --------------------------------------------------------------------------- -def _build_target(*, input_modalities: Iterable[Iterable[str]]) -> MagicMock: - """Build a minimal mocked PromptTarget exposing ``configuration.capabilities``.""" - capabilities = TargetCapabilities( - input_modalities=frozenset(frozenset(combo) for combo in input_modalities), - ) - configuration = TargetConfiguration(capabilities=capabilities) - target = MagicMock() - target.configuration = configuration - return target - - def _make_response(*, data_type: PromptDataType, value: str = "some-value") -> Message: """Wrap a single assistant piece of the given data type in a Message.""" return Message( @@ -54,24 +39,24 @@ def _make_response(*, data_type: PromptDataType, value: str = "some-value") -> M class TestValidateFirstTurnSeed: def test_text_only_target_no_seed_required(self): router = _ModalityFeedbackRouter( - adversarial_chat=_build_target(input_modalities=[{"text"}]), - objective_target=_build_target(input_modalities=[{"text"}]), + adversarial_chat=get_mock_target(input_modalities=[{"text"}]), + objective_target=get_mock_target(input_modalities=[{"text"}]), ) router.validate_first_turn_seed(next_message=None) def test_multimodal_target_with_text_allowed_no_seed_required(self): router = _ModalityFeedbackRouter( - adversarial_chat=_build_target(input_modalities=[{"text"}]), - objective_target=_build_target(input_modalities=[{"text"}, {"text", "image_path"}]), + adversarial_chat=get_mock_target(input_modalities=[{"text"}]), + objective_target=get_mock_target(input_modalities=[{"text"}, {"text", "image_path"}]), ) router.validate_first_turn_seed(next_message=None) def test_edit_only_target_without_next_message_raises(self): router = _ModalityFeedbackRouter( - adversarial_chat=_build_target(input_modalities=[{"text"}]), - objective_target=_build_target(input_modalities=[{"text", "image_path"}]), + adversarial_chat=get_mock_target(input_modalities=[{"text"}]), + objective_target=get_mock_target(input_modalities=[{"text", "image_path"}]), ) with pytest.raises(ValueError, match="seed"): @@ -79,8 +64,8 @@ def test_edit_only_target_without_next_message_raises(self): def test_edit_only_target_with_text_only_next_message_raises(self): router = _ModalityFeedbackRouter( - adversarial_chat=_build_target(input_modalities=[{"text"}]), - objective_target=_build_target(input_modalities=[{"text", "image_path"}]), + adversarial_chat=get_mock_target(input_modalities=[{"text"}]), + objective_target=get_mock_target(input_modalities=[{"text", "image_path"}]), ) next_message = Message.from_prompt(prompt="just text", role="user") @@ -89,8 +74,8 @@ def test_edit_only_target_with_text_only_next_message_raises(self): def test_edit_only_target_with_media_seed_passes(self): router = _ModalityFeedbackRouter( - adversarial_chat=_build_target(input_modalities=[{"text"}]), - objective_target=_build_target(input_modalities=[{"text", "image_path"}]), + adversarial_chat=get_mock_target(input_modalities=[{"text"}]), + objective_target=get_mock_target(input_modalities=[{"text", "image_path"}]), ) shared_conv = "edit-only-conv" next_message = Message( @@ -121,8 +106,8 @@ class TestBuildAdversarialInputMessage: @pytest.mark.parametrize("media_type", ["image_path", "audio_path", "video_path"]) def test_text_only_adversarial_falls_back_to_text(self, media_type): router = _ModalityFeedbackRouter( - adversarial_chat=_build_target(input_modalities=[{"text"}]), - objective_target=_build_target(input_modalities=[{"text"}, {"text", media_type}]), + adversarial_chat=get_mock_target(input_modalities=[{"text"}]), + objective_target=get_mock_target(input_modalities=[{"text"}, {"text", media_type}]), ) last_response = _make_response(data_type=media_type, value="/tmp/output.bin") @@ -138,8 +123,8 @@ def test_text_only_adversarial_falls_back_to_text(self, media_type): @pytest.mark.parametrize("media_type", ["image_path", "audio_path", "video_path"]) def test_multimodal_adversarial_attaches_media(self, media_type): router = _ModalityFeedbackRouter( - adversarial_chat=_build_target(input_modalities=[{"text"}, {"text", media_type}]), - objective_target=_build_target(input_modalities=[{"text"}]), + adversarial_chat=get_mock_target(input_modalities=[{"text"}, {"text", media_type}]), + objective_target=get_mock_target(input_modalities=[{"text"}]), ) last_response = _make_response(data_type=media_type, value="/tmp/output.bin") @@ -158,8 +143,8 @@ def test_multimodal_adversarial_attaches_media(self, media_type): def test_no_last_response_returns_text_only(self): router = _ModalityFeedbackRouter( - adversarial_chat=_build_target(input_modalities=[{"text"}, {"text", "image_path"}]), - objective_target=_build_target(input_modalities=[{"text"}]), + adversarial_chat=get_mock_target(input_modalities=[{"text"}, {"text", "image_path"}]), + objective_target=get_mock_target(input_modalities=[{"text"}]), ) msg = router.build_adversarial_input_message( @@ -172,8 +157,8 @@ def test_no_last_response_returns_text_only(self): def test_seed_media_is_forwarded_to_adversarial_when_supported(self): router = _ModalityFeedbackRouter( - adversarial_chat=_build_target(input_modalities=[{"text"}, {"text", "image_path"}]), - objective_target=_build_target(input_modalities=[{"text"}, {"text", "image_path"}]), + adversarial_chat=get_mock_target(input_modalities=[{"text"}, {"text", "image_path"}]), + objective_target=get_mock_target(input_modalities=[{"text"}, {"text", "image_path"}]), ) seed_message = Message( message_pieces=[ @@ -208,8 +193,8 @@ def test_seed_media_is_forwarded_to_adversarial_when_supported(self): def test_response_media_type_not_supported_falls_back(self): # Adversarial accepts {text, image_path} but the response is audio_path. router = _ModalityFeedbackRouter( - adversarial_chat=_build_target(input_modalities=[{"text"}, {"text", "image_path"}]), - objective_target=_build_target(input_modalities=[{"text"}, {"text", "audio_path"}]), + adversarial_chat=get_mock_target(input_modalities=[{"text"}, {"text", "image_path"}]), + objective_target=get_mock_target(input_modalities=[{"text"}, {"text", "audio_path"}]), ) last_response = _make_response(data_type="audio_path", value="/tmp/out.wav") @@ -249,8 +234,8 @@ def _make_placeholder_message() -> Message: def test_replaces_placeholder_only(self): router = _ModalityFeedbackRouter( - adversarial_chat=_build_target(input_modalities=[{"text"}]), - objective_target=_build_target(input_modalities=[{"text", "image_path"}]), + adversarial_chat=get_mock_target(input_modalities=[{"text"}]), + objective_target=get_mock_target(input_modalities=[{"text", "image_path"}]), ) message = self._make_placeholder_message() @@ -268,8 +253,8 @@ def test_replaces_placeholder_only(self): def test_does_not_mutate_input_message(self): router = _ModalityFeedbackRouter( - adversarial_chat=_build_target(input_modalities=[{"text"}]), - objective_target=_build_target(input_modalities=[{"text", "image_path"}]), + adversarial_chat=get_mock_target(input_modalities=[{"text"}]), + objective_target=get_mock_target(input_modalities=[{"text", "image_path"}]), ) message = self._make_placeholder_message() @@ -284,8 +269,8 @@ def test_does_not_mutate_input_message(self): def test_filled_pieces_share_conversation_id(self): router = _ModalityFeedbackRouter( - adversarial_chat=_build_target(input_modalities=[{"text"}]), - objective_target=_build_target(input_modalities=[{"text", "image_path"}]), + adversarial_chat=get_mock_target(input_modalities=[{"text"}]), + objective_target=get_mock_target(input_modalities=[{"text", "image_path"}]), ) message = self._make_placeholder_message() @@ -304,8 +289,8 @@ def test_filled_pieces_share_conversation_id(self): class TestBuildObjectiveInputMessage: def test_turn_zero_text_only_target(self): router = _ModalityFeedbackRouter( - adversarial_chat=_build_target(input_modalities=[{"text"}]), - objective_target=_build_target(input_modalities=[{"text"}, {"text", "image_path"}]), + adversarial_chat=get_mock_target(input_modalities=[{"text"}]), + objective_target=get_mock_target(input_modalities=[{"text"}, {"text", "image_path"}]), ) last_response = _make_response(data_type="image_path", value="/tmp/prev.png") @@ -321,8 +306,8 @@ def test_turn_zero_text_only_target(self): def test_turn_zero_edit_only_target_raises_defensive(self): router = _ModalityFeedbackRouter( - adversarial_chat=_build_target(input_modalities=[{"text"}]), - objective_target=_build_target(input_modalities=[{"text", "image_path"}]), + adversarial_chat=get_mock_target(input_modalities=[{"text"}]), + objective_target=get_mock_target(input_modalities=[{"text", "image_path"}]), ) with pytest.raises(ValueError, match="text-only"): @@ -334,8 +319,8 @@ def test_turn_zero_edit_only_target_raises_defensive(self): def test_turn_n_attaches_prev_media_when_supported(self): router = _ModalityFeedbackRouter( - adversarial_chat=_build_target(input_modalities=[{"text"}]), - objective_target=_build_target(input_modalities=[{"text"}, {"text", "image_path"}]), + adversarial_chat=get_mock_target(input_modalities=[{"text"}]), + objective_target=get_mock_target(input_modalities=[{"text"}, {"text", "image_path"}]), ) last_response = _make_response(data_type="image_path", value="/tmp/prev.png") @@ -352,8 +337,8 @@ def test_turn_n_attaches_prev_media_when_supported(self): def test_turn_n_text_only_target_drops_media(self): router = _ModalityFeedbackRouter( - adversarial_chat=_build_target(input_modalities=[{"text"}]), - objective_target=_build_target(input_modalities=[{"text"}]), + adversarial_chat=get_mock_target(input_modalities=[{"text"}]), + objective_target=get_mock_target(input_modalities=[{"text"}]), ) last_response = _make_response(data_type="image_path", value="/tmp/prev.png") @@ -368,8 +353,8 @@ def test_turn_n_text_only_target_drops_media(self): def test_turn_n_no_prev_response_returns_text_only(self): router = _ModalityFeedbackRouter( - adversarial_chat=_build_target(input_modalities=[{"text"}]), - objective_target=_build_target(input_modalities=[{"text"}, {"text", "image_path"}]), + adversarial_chat=get_mock_target(input_modalities=[{"text"}]), + objective_target=get_mock_target(input_modalities=[{"text"}, {"text", "image_path"}]), ) msg = router.build_objective_input_message( @@ -384,8 +369,8 @@ def test_turn_n_no_prev_response_returns_text_only(self): @pytest.mark.parametrize("media_type", ["image_path", "audio_path", "video_path"]) def test_turn_n_generic_media_types(self, media_type): router = _ModalityFeedbackRouter( - adversarial_chat=_build_target(input_modalities=[{"text"}]), - objective_target=_build_target(input_modalities=[{"text"}, {"text", media_type}]), + adversarial_chat=get_mock_target(input_modalities=[{"text"}]), + objective_target=get_mock_target(input_modalities=[{"text"}, {"text", media_type}]), ) last_response = _make_response(data_type=media_type, value=f"/tmp/prev.{media_type}") @@ -405,16 +390,16 @@ def test_turn_n_generic_media_types(self, media_type): class TestProperties: def test_objective_target_requires_media_on_first_turn_when_text_excluded(self): router = _ModalityFeedbackRouter( - adversarial_chat=_build_target(input_modalities=[{"text"}]), - objective_target=_build_target(input_modalities=[{"text", "image_path"}]), + adversarial_chat=get_mock_target(input_modalities=[{"text"}]), + objective_target=get_mock_target(input_modalities=[{"text", "image_path"}]), ) assert router.objective_target_requires_media_on_first_turn is True def test_objective_does_not_require_media_when_text_allowed(self): router = _ModalityFeedbackRouter( - adversarial_chat=_build_target(input_modalities=[{"text"}]), - objective_target=_build_target(input_modalities=[{"text"}, {"text", "image_path"}]), + adversarial_chat=get_mock_target(input_modalities=[{"text"}]), + objective_target=get_mock_target(input_modalities=[{"text"}, {"text", "image_path"}]), ) assert router.objective_target_requires_media_on_first_turn is False diff --git a/tests/unit/mocks.py b/tests/unit/mocks.py index 5a6051979d..725b120017 100644 --- a/tests/unit/mocks.py +++ b/tests/unit/mocks.py @@ -5,7 +5,7 @@ import shutil import tempfile import uuid -from collections.abc import Generator, MutableSequence, Sequence +from collections.abc import Generator, Iterable, MutableSequence, Sequence from contextlib import AbstractAsyncContextManager from typing import Any from unittest.mock import MagicMock, patch @@ -19,8 +19,27 @@ ScenarioResult, flatten_to_message_pieces, ) +from pyrit.models.literals import PromptDataType from pyrit.prompt_target import PromptTarget, TargetCapabilities, TargetConfiguration, limit_requests_per_minute +# Modality combination shapes accepted by the helpers below. A target advertises *combinations* +# of data types it accepts in one request (e.g. ``{{"text"}, {"text", "image_path"}}``), so every +# helper takes an iterable of iterables and canonicalises it. +ModalityCombos = Iterable[Iterable[str]] + + +def modality_combos(*combos: Iterable[str]) -> frozenset[frozenset[PromptDataType]]: + """ + Canonicalise modality combinations into the ``frozenset[frozenset]`` shape targets use. + + Args: + *combos: Each argument is one accepted combination, e.g. ``{"text", "image_path"}``. + + Returns: + The canonical frozenset-of-frozensets form. + """ + return frozenset(frozenset(combo) for combo in combos) # type: ignore[arg-type] + def make_scenario_identifier( *, @@ -137,20 +156,41 @@ def get_mock_attack_identifier(name: str = "MockAttack", module: str = "tests.un ) -def get_mock_target(name: str = "MockTarget") -> MagicMock: +def get_mock_target( + name: str = "MockTarget", + *, + input_modalities: ModalityCombos | None = None, + output_modalities: ModalityCombos | None = None, +) -> MagicMock: """ Returns a MagicMock target whose ``get_identifier()`` returns a real ``ComponentIdentifier``. Use this wherever a ``MagicMock(spec=PromptTarget)`` is needed as an ``objective_target``. + By default ``target.configuration`` is left as a MagicMock child. When either modality + argument is supplied, ``target.configuration`` becomes a real ``TargetConfiguration`` so + ``configuration.capabilities.input_modalities`` / ``output_modalities`` are real frozensets + that capability-aware code (modality routing, scenario validation) can reason about. An + omitted side falls back to the ``TargetCapabilities`` default (text-only). Invalid data + types are rejected by ``TargetCapabilities`` and propagate as ``pydantic.ValidationError``. + Args: name: The class name for the mock target. Defaults to "MockTarget". + input_modalities: Accepted input combinations, e.g. ``[{"text"}, {"text", "image_path"}]``. + output_modalities: Produced output combinations, e.g. ``[{"text"}, {"audio_path"}]``. Returns: A MagicMock configured to return a real ComponentIdentifier. """ target = MagicMock(spec=PromptTarget) target.get_identifier.return_value = get_mock_target_identifier(name) + if input_modalities is not None or output_modalities is not None: + capability_kwargs: dict[str, frozenset[frozenset[PromptDataType]]] = {} + if input_modalities is not None: + capability_kwargs["input_modalities"] = modality_combos(*input_modalities) + if output_modalities is not None: + capability_kwargs["output_modalities"] = modality_combos(*output_modalities) + target.configuration = TargetConfiguration(capabilities=TargetCapabilities(**capability_kwargs)) return target diff --git a/tests/unit/modality_profiles.py b/tests/unit/modality_profiles.py new file mode 100644 index 0000000000..ba69558475 --- /dev/null +++ b/tests/unit/modality_profiles.py @@ -0,0 +1,36 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT license. + +""" +Named target modality profiles for capability-aware tests. + +A target advertises the *combinations* of ``PromptDataType`` values it accepts in one request +(and produces in one response) as ``frozenset[frozenset[PromptDataType]]``. These constants +give tests a shared vocabulary for realistic profiles so that modality routing and scenario +validation tests describe targets the same way. Pass them to ``unit.mocks.get_mock_target``: + + get_mock_target(input_modalities=VISION_INPUT_MODALITIES) +""" + +from pyrit.models.literals import PromptDataType +from unit.mocks import modality_combos + +#: Text in, text out. Matches the production default on ``TargetCapabilities``. +TEXT_ONLY_MODALITIES: frozenset[frozenset[PromptDataType]] = modality_combos({"text"}) + +#: A vision chat model: text alone, or text accompanied by an image. +VISION_INPUT_MODALITIES: frozenset[frozenset[PromptDataType]] = modality_combos({"text"}, {"text", "image_path"}) + +#: An image-edit model: media is required on *every* request — there is no bare ``{"text"}`` combo. +IMAGE_EDIT_INPUT_MODALITIES: frozenset[frozenset[PromptDataType]] = modality_combos({"text", "image_path"}) + +#: A realtime audio model: text, audio, or both together. +REALTIME_AUDIO_INPUT_MODALITIES: frozenset[frozenset[PromptDataType]] = modality_combos( + {"text"}, {"audio_path"}, {"text", "audio_path"} +) + +#: A realtime audio model's responses: spoken audio plus a text transcript. +REALTIME_AUDIO_OUTPUT_MODALITIES: frozenset[frozenset[PromptDataType]] = modality_combos({"text"}, {"audio_path"}) + +#: A video generation model's responses. +VIDEO_GENERATION_OUTPUT_MODALITIES: frozenset[frozenset[PromptDataType]] = modality_combos({"video_path"}) diff --git a/tests/unit/test_mock_target.py b/tests/unit/test_mock_target.py new file mode 100644 index 0000000000..6c71a6e4f8 --- /dev/null +++ b/tests/unit/test_mock_target.py @@ -0,0 +1,262 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT license. + +""" +Contract tests for ``unit.mocks.get_mock_target`` and the ``unit.modality_profiles`` constants. + +``get_mock_target`` is load-bearing for modality validation: scenario tests pass mock targets +into ``Scenario.initialize_async``, and any code reading ``target.configuration.capabilities`` +must see real ``frozenset`` values when a test asks for them — and must tolerate MagicMock +children when it does not. These tests pin both halves of that contract, prove every legal +``PromptDataType`` round-trips, and prove invalid input is rejected rather than swallowed. +""" + +from __future__ import annotations + +from typing import get_args + +import pytest +from pydantic import ValidationError + +from pyrit.models import ComponentIdentifier +from pyrit.models.literals import MEDIA_PATH_DATA_TYPES, PromptDataType +from pyrit.prompt_target import TargetCapabilities, TargetConfiguration +from unit.mocks import get_mock_target, modality_combos +from unit.modality_profiles import ( + IMAGE_EDIT_INPUT_MODALITIES, + REALTIME_AUDIO_INPUT_MODALITIES, + REALTIME_AUDIO_OUTPUT_MODALITIES, + TEXT_ONLY_MODALITIES, + VIDEO_GENERATION_OUTPUT_MODALITIES, + VISION_INPUT_MODALITIES, +) + +_ALL_DATA_TYPES: tuple[PromptDataType, ...] = get_args(PromptDataType) +_TEXT_AND_IMAGE = frozenset({frozenset({"text"}), frozenset({"text", "image_path"})}) + + +# --------------------------------------------------------------------------- +# Backward-compatibility pins (green before and after the change) +# --------------------------------------------------------------------------- +def test_get_mock_target_default_returns_real_identifier(): + """The identifier is a real ComponentIdentifier, not a MagicMock, so identity-based code works.""" + identifier = get_mock_target().get_identifier() + assert isinstance(identifier, ComponentIdentifier) + assert identifier.class_name == "MockTarget" + + +def test_get_mock_target_name_override(): + """The positional ``name`` still flows into the identifier.""" + assert get_mock_target("Custom").get_identifier().class_name == "Custom" + + +def test_get_mock_target_default_configuration_is_mock(): + """ + Without modality kwargs the helper is unchanged: ``configuration`` is a MagicMock child. + + This documents the hazard modality validation must tolerate — capabilities on the default + mock are *not* real frozensets, so a validator must treat them as unknown, never as a failure. + """ + target = get_mock_target() + assert not isinstance(target.configuration, TargetConfiguration) + assert not isinstance(target.configuration.capabilities.input_modalities, frozenset) + + +def test_get_mock_target_is_spec_bound(): + """``spec=PromptTarget`` is retained so typos in attribute names fail loudly.""" + with pytest.raises(AttributeError): + _ = get_mock_target().not_a_prompt_target_attribute + + +def test_get_mock_target_modalities_are_keyword_only(): + """A second positional is rejected; modalities must be passed by name.""" + with pytest.raises(TypeError): + get_mock_target("MockTarget", [{"text"}]) # type: ignore[misc] + + +# --------------------------------------------------------------------------- +# Real capabilities when modalities are supplied +# --------------------------------------------------------------------------- +def test_get_mock_target_input_modalities_sets_real_capabilities(): + """Supplying input modalities swaps in a real TargetConfiguration with real frozensets.""" + target = get_mock_target(input_modalities=[{"text"}, {"text", "image_path"}]) + assert isinstance(target.configuration, TargetConfiguration) + assert isinstance(target.configuration.capabilities, TargetCapabilities) + assert isinstance(target.configuration.capabilities.input_modalities, frozenset) + assert target.configuration.capabilities.input_modalities == _TEXT_AND_IMAGE + + +def test_get_mock_target_input_modalities_defaults_output_to_text_only(): + """An omitted output side falls back to the production default, never to something permissive.""" + target = get_mock_target(input_modalities=[{"text", "image_path"}]) + assert target.configuration.capabilities.output_modalities == TEXT_ONLY_MODALITIES + + +def test_get_mock_target_output_modalities_sets_real_capabilities(): + """Supplying output modalities alone also yields a real configuration.""" + target = get_mock_target(output_modalities=[{"image_path"}]) + assert isinstance(target.configuration, TargetConfiguration) + assert target.configuration.capabilities.output_modalities == frozenset({frozenset({"image_path"})}) + + +def test_get_mock_target_output_modalities_defaults_input_to_text_only(): + """An omitted input side falls back to the production default.""" + target = get_mock_target(output_modalities=[{"image_path"}]) + assert target.configuration.capabilities.input_modalities == TEXT_ONLY_MODALITIES + + +def test_get_mock_target_both_modalities_honored_independently(): + """Input and output are set independently when both are supplied.""" + target = get_mock_target(input_modalities=[{"text"}], output_modalities=[{"audio_path"}, {"text"}]) + capabilities = target.configuration.capabilities + assert capabilities.input_modalities == TEXT_ONLY_MODALITIES + assert capabilities.output_modalities == frozenset({frozenset({"audio_path"}), frozenset({"text"})}) + + +def test_get_mock_target_with_modalities_keeps_identifier_and_spec(): + """Real capabilities do not cost the real identifier or the spec binding.""" + target = get_mock_target("Vision", input_modalities=[{"text", "image_path"}]) + assert target.get_identifier().class_name == "Vision" + with pytest.raises(AttributeError): + _ = target.not_a_prompt_target_attribute + + +# --------------------------------------------------------------------------- +# Every legal PromptDataType round-trips, on both sides +# --------------------------------------------------------------------------- +@pytest.mark.parametrize("data_type", _ALL_DATA_TYPES) +def test_get_mock_target_round_trips_every_input_data_type(data_type: PromptDataType): + """The helper forwards every legal PromptDataType as an input modality, not just text/image.""" + target = get_mock_target(input_modalities=[{data_type}]) + assert target.configuration.capabilities.input_modalities == frozenset({frozenset({data_type})}) + + +@pytest.mark.parametrize("data_type", _ALL_DATA_TYPES) +def test_get_mock_target_round_trips_every_output_data_type(data_type: PromptDataType): + """The helper forwards every legal PromptDataType as an output modality.""" + target = get_mock_target(output_modalities=[{data_type}]) + assert target.configuration.capabilities.output_modalities == frozenset({frozenset({data_type})}) + + +@pytest.mark.parametrize("media_type", sorted(MEDIA_PATH_DATA_TYPES)) +def test_get_mock_target_media_with_text_combo(media_type: PromptDataType): + """ + ``{text, }`` combos round-trip for every media type. + + This is the exact shape ``_ModalityFeedbackRouter`` consults when deciding whether media may + accompany text, so it is the shape downstream validation tests will use most. + """ + target = get_mock_target(input_modalities=[{"text"}, {"text", media_type}]) + expected = frozenset({frozenset({"text"}), frozenset({"text", media_type})}) + assert target.configuration.capabilities.input_modalities == expected + + +# --------------------------------------------------------------------------- +# Input shape coercion +# --------------------------------------------------------------------------- +@pytest.mark.parametrize( + "combos", + [ + [["text"], ["text", "image_path"]], + [("text",), ("text", "image_path")], + ({"text"}, {"text", "image_path"}), + _TEXT_AND_IMAGE, + ], + ids=["list_of_lists", "list_of_tuples", "tuple_of_sets", "canonical_frozenset"], +) +def test_get_mock_target_coerces_iterables_to_frozensets(combos): + """Any iterable-of-iterables shape is canonicalised to frozenset-of-frozensets.""" + target = get_mock_target(input_modalities=combos) + assert target.configuration.capabilities.input_modalities == _TEXT_AND_IMAGE + + +def test_modality_combos_builds_canonical_frozenset(): + """The public coercion helper produces the same canonical form the mock uses internally.""" + assert modality_combos({"text"}, ["text", "image_path"]) == _TEXT_AND_IMAGE + + +def test_get_mock_target_accepts_empty_modalities(): + """No combos is legal input; it yields an empty frozenset (nothing covers any request).""" + target = get_mock_target(input_modalities=[]) + assert target.configuration.capabilities.input_modalities == frozenset() + + +# --------------------------------------------------------------------------- +# Invalid input is rejected by TargetCapabilities and must NOT be swallowed by the helper +# --------------------------------------------------------------------------- +def test_get_mock_target_rejects_unknown_input_modality(): + """An unknown data type propagates pydantic's ValidationError instead of yielding a bogus target.""" + with pytest.raises(ValidationError): + get_mock_target(input_modalities=[{"foobar"}]) # type: ignore[list-item] + + +def test_get_mock_target_rejects_unknown_output_modality(): + """Same rejection on the output side.""" + with pytest.raises(ValidationError): + get_mock_target(output_modalities=[{"foobar"}]) # type: ignore[list-item] + + +def test_get_mock_target_rejects_bare_string_combo(): + """ + ``["text"]`` (forgot the inner set) iterates the string to characters and is rejected. + + This is the most likely authoring mistake; it must fail loudly rather than build a target + that accepts ``{"t", "e", "x"}``. + """ + with pytest.raises(ValidationError): + get_mock_target(input_modalities=["text"]) # type: ignore[list-item] + + +def test_get_mock_target_rejects_non_string_member(): + """Non-string members are rejected.""" + with pytest.raises(ValidationError): + get_mock_target(input_modalities=[{123}]) # type: ignore[list-item] + + +# --------------------------------------------------------------------------- +# Named profiles — shared vocabulary for downstream modality tests +# --------------------------------------------------------------------------- +def test_profile_text_only(): + assert frozenset({frozenset({"text"})}) == TEXT_ONLY_MODALITIES + assert TargetCapabilities().input_modalities == TEXT_ONLY_MODALITIES # matches production default + + +def test_profile_vision_input_allows_bare_text_and_text_with_image(): + assert VISION_INPUT_MODALITIES == _TEXT_AND_IMAGE + assert frozenset({"text"}) in VISION_INPUT_MODALITIES + + +def test_profile_image_edit_input_has_no_bare_text_combo(): + """The edit-only shape: media is required on every request (the O8 case).""" + assert frozenset({frozenset({"text", "image_path"})}) == IMAGE_EDIT_INPUT_MODALITIES + assert frozenset({"text"}) not in IMAGE_EDIT_INPUT_MODALITIES + + +def test_profile_realtime_audio(): + assert ( + frozenset({frozenset({"text"}), frozenset({"audio_path"}), frozenset({"text", "audio_path"})}) + == REALTIME_AUDIO_INPUT_MODALITIES + ) + assert frozenset({frozenset({"text"}), frozenset({"audio_path"})}) == REALTIME_AUDIO_OUTPUT_MODALITIES + + +def test_profile_video_generation_output(): + assert frozenset({frozenset({"video_path"})}) == VIDEO_GENERATION_OUTPUT_MODALITIES + + +@pytest.mark.parametrize( + ("input_profile", "output_profile"), + [ + (TEXT_ONLY_MODALITIES, TEXT_ONLY_MODALITIES), + (VISION_INPUT_MODALITIES, TEXT_ONLY_MODALITIES), + (IMAGE_EDIT_INPUT_MODALITIES, frozenset({frozenset({"image_path"})})), + (REALTIME_AUDIO_INPUT_MODALITIES, REALTIME_AUDIO_OUTPUT_MODALITIES), + (TEXT_ONLY_MODALITIES, VIDEO_GENERATION_OUTPUT_MODALITIES), + ], + ids=["text_chat", "vision", "image_edit", "realtime_audio", "video_generation"], +) +def test_get_mock_target_builds_each_profile(input_profile, output_profile): + """Every named profile builds a real target whose capabilities equal the profile on both sides.""" + target = get_mock_target(input_modalities=input_profile, output_modalities=output_profile) + assert target.configuration.capabilities.input_modalities == input_profile + assert target.configuration.capabilities.output_modalities == output_profile From 45df3075bf0ba5a4ef4792a0dddfa76db5e89bb8 Mon Sep 17 00:00:00 2001 From: Victor Valbuena Date: Mon, 21 Sep 2026 18:18:28 -0700 Subject: [PATCH 02/19] MAINT: public scorer modality accessors MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Scorers already declare the data types they can score via ``ScorerPromptValidator(supported_data_types=...)``, but that declaration was only reachable through the private ``scorer._validator._supported_data_types`` and only enforced at score time — after an attack has already run. Expose it publicly so compatibility can be judged before a run. Add ``ScorerPromptValidator.supported_data_types`` and ``has_declared_data_types``. The latter distinguishes "declared nothing" (and so fell back to the permissive all-types default) from "deliberately declared every type" — a distinction plan-time code needs and the runtime default erases. The runtime default itself is deliberately unchanged. Add ``Scorer.supported_data_types`` returning ``None`` for "unknown", overridden by ``MessageScorer`` to read its validator. Wrapper scorers delegate: the inverter and threshold scorers report their child's declaration, and the composite reports the intersection of its children, or ``None`` if any child is unknown. Replace the two private reach-ins in ``AudioTranscriptHelper`` and ``VideoHelper`` with the public accessor. As a side effect these now accept wrapper scorers, which previously raised ``AttributeError`` for lack of a ``_validator``. Groundwork for plan-time scenario modality validation. Co-Authored-By: Claude Opus 5 (1M context) --- pyrit/score/audio_transcript_scorer.py | 8 +- pyrit/score/message_scorer.py | 13 + pyrit/score/scorer.py | 15 + pyrit/score/scorer_prompt_validator.py | 18 ++ .../float_scale_threshold_scorer.py | 11 + .../true_false/true_false_composite_scorer.py | 22 ++ .../true_false/true_false_inverter_scorer.py | 11 + pyrit/score/video_scorer.py | 8 +- .../score/test_scorer_modality_accessors.py | 256 ++++++++++++++++++ 9 files changed, 352 insertions(+), 10 deletions(-) create mode 100644 tests/unit/score/test_scorer_modality_accessors.py diff --git a/pyrit/score/audio_transcript_scorer.py b/pyrit/score/audio_transcript_scorer.py index 2994a6857c..84004f3835 100644 --- a/pyrit/score/audio_transcript_scorer.py +++ b/pyrit/score/audio_transcript_scorer.py @@ -131,11 +131,9 @@ def _validate_text_scorer(scorer: Scorer) -> None: Raises: ValueError: If the scorer does not support text data type. """ - if "text" not in scorer._validator._supported_data_types: - raise ValueError( - f"text_capable_scorer must support 'text' data type. " - f"Supported types: {scorer._validator._supported_data_types}" - ) + declared = scorer.supported_data_types + if declared is not None and "text" not in declared: + raise ValueError(f"text_capable_scorer must support 'text' data type. Supported types: {sorted(declared)}") async def _score_audio_async(self, *, message_piece: MessagePiece, objective: str | None = None) -> list[Score]: """ diff --git a/pyrit/score/message_scorer.py b/pyrit/score/message_scorer.py index 82f7045abf..443c9040d6 100644 --- a/pyrit/score/message_scorer.py +++ b/pyrit/score/message_scorer.py @@ -21,6 +21,7 @@ MessagePiece, MessageScorable, Observation, + PromptDataType, PromptResponseError, Scorable, ScorableUnion, @@ -245,6 +246,18 @@ def __init__( self._message_resolver = message_resolver or MessageScorableResolver() super().__init__(chat_target=chat_target) + @property + def supported_data_types(self) -> frozenset[PromptDataType] | None: + """ + The data types this scorer's validator declares, or ``None`` when it declared none. + + Returns: + frozenset[PromptDataType] | None: The declared data types, or ``None`` if undeclared. + """ + if not self._validator.has_declared_data_types: + return None + return frozenset(self._validator.supported_data_types) + def matched_conditions(self) -> frozenset[type[Condition]]: """ Return the conditions this message scorer uses as criteria. diff --git a/pyrit/score/scorer.py b/pyrit/score/scorer.py index b4b1a98188..3ca911a6f9 100644 --- a/pyrit/score/scorer.py +++ b/pyrit/score/scorer.py @@ -20,6 +20,7 @@ Message, MessageScorable, Observation, + PromptDataType, Scorable, ScorableUnion, Score, @@ -200,6 +201,20 @@ def __init__( if chat_target is not None: type(self).TARGET_REQUIREMENTS.validate(target=chat_target) + @property + def supported_data_types(self) -> frozenset[PromptDataType] | None: + """ + The data types this scorer declares it can score, or ``None`` when undeclared. + + ``None`` means "unknown", not "none of them": the scorer either has no validator, or its + validator fell back to the permissive all-types default. Callers deciding compatibility + before a run must treat ``None`` as indeterminate rather than as a failure. + + Returns: + frozenset[PromptDataType] | None: The declared data types, or ``None`` if unknown. + """ + return None + def matched_conditions(self) -> frozenset[type[Condition]]: """ Return the condition types this scorer can use as its criterion. diff --git a/pyrit/score/scorer_prompt_validator.py b/pyrit/score/scorer_prompt_validator.py index 986afca111..1d2173b6bf 100644 --- a/pyrit/score/scorer_prompt_validator.py +++ b/pyrit/score/scorer_prompt_validator.py @@ -56,6 +56,8 @@ def __init__( Set to True to raise an exception instead. is_objective_required (bool): Whether an objective must be provided for scoring. Defaults to False. """ + self._has_declared_data_types = bool(supported_data_types) + self._supported_data_types: Sequence[PromptDataType] if supported_data_types: self._supported_data_types = supported_data_types else: @@ -75,6 +77,22 @@ def __init__( self._is_objective_required = is_objective_required + @property + def supported_data_types(self) -> Sequence[PromptDataType]: + """ + The data types this validator accepts at score time. + + When the scorer declared none, this is every ``PromptDataType`` — the permissive runtime + default. Consult ``has_declared_data_types`` to tell that apart from a scorer that + deliberately declared every type. + """ + return self._supported_data_types + + @property + def has_declared_data_types(self) -> bool: + """Whether the scorer declared its data types rather than falling back to the default.""" + return self._has_declared_data_types + @property def is_objective_required(self) -> bool: """Whether the scorer uses the objective as a required criterion.""" diff --git a/pyrit/score/true_false/float_scale_threshold_scorer.py b/pyrit/score/true_false/float_scale_threshold_scorer.py index 3d730cf803..8ef51c3c01 100644 --- a/pyrit/score/true_false/float_scale_threshold_scorer.py +++ b/pyrit/score/true_false/float_scale_threshold_scorer.py @@ -10,6 +10,7 @@ from pyrit.models import ( ComponentIdentifier, Condition, + PromptDataType, Scorable, ScorableUnion, Score, @@ -78,6 +79,16 @@ def threshold(self) -> float: """The threshold value used for score comparison.""" return self._threshold + @property + def supported_data_types(self) -> frozenset[PromptDataType] | None: + """ + The wrapped float-scale scorer's declared types — thresholding does not change them. + + Returns: + frozenset[PromptDataType] | None: The wrapped scorer's declaration, or ``None``. + """ + return self._scorer.supported_data_types + def _build_identifier(self) -> ComponentIdentifier: """ Build the identifier for this scorer. diff --git a/pyrit/score/true_false/true_false_composite_scorer.py b/pyrit/score/true_false/true_false_composite_scorer.py index 5974ba630e..8affc074c7 100644 --- a/pyrit/score/true_false/true_false_composite_scorer.py +++ b/pyrit/score/true_false/true_false_composite_scorer.py @@ -13,6 +13,7 @@ from pyrit.models import ( ComponentIdentifier, Condition, + PromptDataType, Scorable, ScorableUnion, Score, @@ -70,6 +71,27 @@ def __init__( self._scorers = scorers + @property + def supported_data_types(self) -> frozenset[PromptDataType] | None: + """ + The intersection of what every child scorer declares. + + A composite can only score evidence all of its children can score. One child with an + undeclared (unknown) set makes the whole composite unknown — a declared sibling cannot + vouch for it. + + Returns: + frozenset[PromptDataType] | None: The shared declared data types, or ``None`` if any + child is undeclared. + """ + shared: frozenset[PromptDataType] | None = None + for scorer in self._scorers: + declared = scorer.supported_data_types + if declared is None: + return None + shared = declared if shared is None else shared & declared + return shared + def _build_identifier(self) -> ComponentIdentifier: """ Build the identifier for this scorer. diff --git a/pyrit/score/true_false/true_false_inverter_scorer.py b/pyrit/score/true_false/true_false_inverter_scorer.py index 88f7fd49f1..a00c8bb13e 100644 --- a/pyrit/score/true_false/true_false_inverter_scorer.py +++ b/pyrit/score/true_false/true_false_inverter_scorer.py @@ -10,6 +10,7 @@ from pyrit.models import ( ComponentIdentifier, Condition, + PromptDataType, Scorable, Score, ScoringExpectation, @@ -39,6 +40,16 @@ def __init__(self, *, scorer: TrueFalseScorer, validator: ScorerPromptValidator super().__init__() + @property + def supported_data_types(self) -> frozenset[PromptDataType] | None: + """ + The wrapped scorer's declared data types — inverting a verdict does not change them. + + Returns: + frozenset[PromptDataType] | None: The wrapped scorer's declaration, or ``None``. + """ + return self._scorer.supported_data_types + def _build_identifier(self) -> ComponentIdentifier: """ Build the identifier for this scorer. diff --git a/pyrit/score/video_scorer.py b/pyrit/score/video_scorer.py index 61cd5b1ded..e6c5f73520 100644 --- a/pyrit/score/video_scorer.py +++ b/pyrit/score/video_scorer.py @@ -89,11 +89,9 @@ def _validate_audio_scorer(scorer: Scorer) -> None: Raises: ValueError: If the scorer does not support audio_path data type. """ - if "audio_path" not in scorer._validator._supported_data_types: - raise ValueError( - f"audio_scorer must support 'audio_path' data type. " - f"Supported types: {scorer._validator._supported_data_types}" - ) + declared = scorer.supported_data_types + if declared is not None and "audio_path" not in declared: + raise ValueError(f"audio_scorer must support 'audio_path' data type. Supported types: {sorted(declared)}") async def _score_frames_async(self, *, message_piece: MessagePiece, objective: str | None = None) -> list[Score]: """ diff --git a/tests/unit/score/test_scorer_modality_accessors.py b/tests/unit/score/test_scorer_modality_accessors.py new file mode 100644 index 0000000000..aae381fb99 --- /dev/null +++ b/tests/unit/score/test_scorer_modality_accessors.py @@ -0,0 +1,256 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT license. + +""" +Contract tests for the public scorer modality accessors. + +Scorers have long declared the data types they can score via +``ScorerPromptValidator(supported_data_types=...)``, but that declaration was only reachable +through the private ``scorer._validator._supported_data_types`` and only *enforced* at score +time. These tests pin a public, plan-time-readable surface: + +* ``ScorerPromptValidator.supported_data_types`` / ``has_declared_data_types`` +* ``Scorer.supported_data_types`` — ``None`` when undeclared or unknown +* ``MessageScorer`` reads its validator; wrapper scorers delegate to their children +* the two former private reach-ins (audio transcript, video frames) use the public surface + +The runtime permissive default is deliberately unchanged: plan-time strictness distinguishes +"declared nothing" from "declared everything", runtime does not. +""" + +from __future__ import annotations + +from typing import get_args +from unittest.mock import MagicMock, PropertyMock + +import pytest + +from pyrit.models import MessagePiece, PromptDataType +from pyrit.score import ( + FloatScaleScorer, + FloatScaleThresholdScorer, + SubStringScorer, + TrueFalseCompositeScorer, + TrueFalseInverterScorer, + TrueFalseScoreAggregator, +) +from pyrit.score.audio_transcript_scorer import AudioTranscriptHelper +from pyrit.score.scorer_prompt_validator import ScorerPromptValidator +from pyrit.score.true_false.manual_scorer import ManualScorer +from pyrit.score.video_scorer import VideoHelper + +_ALL_DATA_TYPES: tuple[PromptDataType, ...] = get_args(PromptDataType) + + +def _substring_scorer(*, declared: list[PromptDataType] | None) -> SubStringScorer: + """Build a real MessageScorer whose validator declares ``declared`` (or nothing when None).""" + validator = ( + ScorerPromptValidator(supported_data_types=declared) if declared is not None else ScorerPromptValidator() + ) + return SubStringScorer(substring="x", validator=validator) + + +def _float_scale_scorer_declaring(declared: frozenset[PromptDataType] | None) -> MagicMock: + """A FloatScaleScorer stand-in whose ``supported_data_types`` reports ``declared``.""" + scorer = MagicMock(spec=FloatScaleScorer) + type(scorer).supported_data_types = PropertyMock(return_value=declared) + return scorer + + +# --------------------------------------------------------------------------- +# ScorerPromptValidator +# --------------------------------------------------------------------------- +def test_validator_supported_data_types_declared_returns_declared(): + """A declared sequence is reported back verbatim and marked as declared.""" + validator = ScorerPromptValidator(supported_data_types=["text", "image_path"]) + assert list(validator.supported_data_types) == ["text", "image_path"] + assert validator.has_declared_data_types is True + + +def test_validator_supported_data_types_undeclared_returns_all_types(): + """ + Undeclared falls back to every PromptDataType and is marked as *not* declared. + + Pins D10: the runtime default stays permissive; only the ``has_declared_data_types`` flag + lets plan-time code tell "declared everything" from "declared nothing". + """ + validator = ScorerPromptValidator() + assert tuple(validator.supported_data_types) == _ALL_DATA_TYPES + assert validator.has_declared_data_types is False + + +def test_validator_empty_sequence_treated_as_undeclared(): + """An empty declaration is falsy today (``if supported_data_types:``) and stays undeclared.""" + validator = ScorerPromptValidator(supported_data_types=[]) + assert tuple(validator.supported_data_types) == _ALL_DATA_TYPES + assert validator.has_declared_data_types is False + + +def test_validator_runtime_enforcement_unchanged_for_undeclared(): + """Plan-time strictness must not leak into runtime: an undeclared validator still accepts any piece.""" + validator = ScorerPromptValidator() + piece = MessagePiece(role="assistant", original_value="frame.png", original_value_data_type="image_path") + assert validator.is_message_piece_supported(message_piece=piece) is True + + +def test_validator_runtime_enforcement_unchanged_for_declared(): + """A declared validator still rejects pieces outside its declaration at runtime.""" + validator = ScorerPromptValidator(supported_data_types=["text"]) + piece = MessagePiece(role="assistant", original_value="frame.png", original_value_data_type="image_path") + assert validator.is_message_piece_supported(message_piece=piece) is False + + +# --------------------------------------------------------------------------- +# Scorer base: unknown is None, never an AttributeError +# --------------------------------------------------------------------------- +def test_scorer_base_supported_data_types_is_none_for_non_message_scorer(): + """A TrueFalseScorer that is not a MessageScorer has no validator and reports None, not AttributeError.""" + scorer = ManualScorer(value=True, rationale="r", user_identifier="u") + assert scorer.supported_data_types is None + + +# --------------------------------------------------------------------------- +# MessageScorer reads its validator +# --------------------------------------------------------------------------- +def test_message_scorer_declared_types_returns_frozenset(): + """SubStringScorer's default validator declares ["text"]; the accessor reports exactly that.""" + assert SubStringScorer(substring="x").supported_data_types == frozenset({"text"}) + + +def test_message_scorer_multi_type_declaration_round_trips(): + """Multiple declared types all appear, as a frozenset.""" + scorer = _substring_scorer(declared=["text", "image_path"]) + assert scorer.supported_data_types == frozenset({"text", "image_path"}) + + +def test_message_scorer_undeclared_types_returns_none(): + """A MessageScorer built on a bare validator reports None — not the all-types set.""" + assert _substring_scorer(declared=None).supported_data_types is None + + +@pytest.mark.parametrize("data_type", _ALL_DATA_TYPES) +def test_message_scorer_round_trips_every_data_type(data_type: PromptDataType): + """Every legal PromptDataType survives the validator → accessor path.""" + scorer = _substring_scorer(declared=[data_type]) + assert scorer.supported_data_types == frozenset({data_type}) + + +# --------------------------------------------------------------------------- +# Wrapper scorers delegate +# --------------------------------------------------------------------------- +def test_threshold_scorer_delegates_to_wrapped_scorer(): + """FloatScaleThresholdScorer reports whatever its wrapped FloatScaleScorer reports.""" + wrapped = _float_scale_scorer_declaring(frozenset({"text", "image_path"})) + scorer = FloatScaleThresholdScorer(scorer=wrapped, threshold=0.5) + assert scorer.supported_data_types == frozenset({"text", "image_path"}) + + +def test_threshold_scorer_delegates_none(): + """An undeclared wrapped scorer makes the threshold scorer undeclared too.""" + scorer = FloatScaleThresholdScorer(scorer=_float_scale_scorer_declaring(None), threshold=0.5) + assert scorer.supported_data_types is None + + +def test_inverter_scorer_delegates_to_wrapped_scorer(): + """TrueFalseInverterScorer reports its wrapped scorer's declaration.""" + scorer = TrueFalseInverterScorer(scorer=_substring_scorer(declared=["audio_path"])) + assert scorer.supported_data_types == frozenset({"audio_path"}) + + +def test_inverter_scorer_delegates_none(): + """An undeclared wrapped scorer makes the inverter undeclared.""" + scorer = TrueFalseInverterScorer(scorer=_substring_scorer(declared=None)) + assert scorer.supported_data_types is None + + +def test_composite_scorer_intersects_children(): + """A composite can only score what *every* child can score: the intersection.""" + scorer = TrueFalseCompositeScorer( + aggregator=TrueFalseScoreAggregator.AND, + scorers=[ + _substring_scorer(declared=["text", "image_path"]), + _substring_scorer(declared=["text", "audio_path"]), + ], + ) + assert scorer.supported_data_types == frozenset({"text"}) + + +def test_composite_scorer_identical_children_returns_shared_set(): + """Identical declarations intersect to themselves.""" + scorer = TrueFalseCompositeScorer( + aggregator=TrueFalseScoreAggregator.OR, + scorers=[_substring_scorer(declared=["text"]), _substring_scorer(declared=["text"])], + ) + assert scorer.supported_data_types == frozenset({"text"}) + + +def test_composite_scorer_returns_none_when_any_child_undeclared(): + """One unknown child makes the whole composite unknown — a declared sibling cannot vouch for it.""" + scorer = TrueFalseCompositeScorer( + aggregator=TrueFalseScoreAggregator.AND, + scorers=[_substring_scorer(declared=["text"]), _substring_scorer(declared=None)], + ) + assert scorer.supported_data_types is None + + +def test_nested_wrappers_propagate_declaration(): + """Inverter of composite of message scorers propagates the innermost declarations.""" + composite = TrueFalseCompositeScorer( + aggregator=TrueFalseScoreAggregator.AND, + scorers=[_substring_scorer(declared=["text", "image_path"]), _substring_scorer(declared=["image_path"])], + ) + assert TrueFalseInverterScorer(scorer=composite).supported_data_types == frozenset({"image_path"}) + + +# --------------------------------------------------------------------------- +# Former private reach-ins use the public surface +# --------------------------------------------------------------------------- +def test_audio_transcript_helper_rejects_scorer_declaring_no_text(): + """A text-capable scorer that declares types excluding text is still rejected.""" + with pytest.raises(ValueError, match="must support 'text'"): + AudioTranscriptHelper(text_capable_scorer=_substring_scorer(declared=["image_path"])) + + +def test_audio_transcript_helper_accepts_scorer_declaring_text(): + """A scorer declaring text is accepted.""" + helper = AudioTranscriptHelper(text_capable_scorer=_substring_scorer(declared=["text"])) + assert helper.text_scorer.supported_data_types == frozenset({"text"}) + + +def test_audio_transcript_helper_accepts_undeclared_scorer(): + """Undeclared is accepted — same outcome as before, when the permissive default included text.""" + AudioTranscriptHelper(text_capable_scorer=_substring_scorer(declared=None)) + + +def test_audio_transcript_helper_accepts_wrapper_scorer_declaring_text(): + """ + A wrapper whose delegate declares text is accepted. + + Previously this raised AttributeError because wrappers have no ``_validator``; the public + accessor delegates, so the wrapper is now judged by what it actually scores. + """ + wrapper = TrueFalseInverterScorer(scorer=_substring_scorer(declared=["text"])) + AudioTranscriptHelper(text_capable_scorer=wrapper) + + +def test_audio_transcript_helper_rejects_wrapper_scorer_declaring_no_text(): + """The same delegation rejects a wrapper whose delegate excludes text.""" + wrapper = TrueFalseInverterScorer(scorer=_substring_scorer(declared=["audio_path"])) + with pytest.raises(ValueError, match="must support 'text'"): + AudioTranscriptHelper(text_capable_scorer=wrapper) + + +def test_video_helper_rejects_audio_scorer_declaring_no_audio(): + """The video helper's audio-scorer check rejects a scorer that declares only text.""" + with pytest.raises(ValueError, match="must support 'audio_path'"): + VideoHelper._validate_audio_scorer(_substring_scorer(declared=["text"])) + + +def test_video_helper_accepts_audio_scorer_declaring_audio(): + """A scorer declaring audio_path passes the check.""" + VideoHelper._validate_audio_scorer(_substring_scorer(declared=["audio_path", "text"])) + + +def test_video_helper_accepts_undeclared_audio_scorer(): + """Undeclared is accepted, matching the former permissive-default behavior.""" + VideoHelper._validate_audio_scorer(_substring_scorer(declared=None)) From 8563ffe58e932355e3cd80e9840abb54b831fb99 Mon Sep 17 00:00:00 2001 From: Victor Valbuena Date: Mon, 21 Sep 2026 18:31:22 -0700 Subject: [PATCH 03/19] MAINT: extract project_request_chain from the technique factory ``AttackTechniqueFactory.can_append_request_converter`` already projected a text objective through a baked request-converter chain to decide whether another converter could be appended. Scenario-level plan-time modality validation needs the same projection, but starting from the seed's own data types and ending at the objective target rather than at an appended converter. Move the projection into ``pyrit/scenario/core/modality_validation.py`` as ``project_request_chain``, parameterised on the start types, and have the factory call it. Behaviour is unchanged: conditional configurations still preserve the unconverted type, ``indexes_to_apply`` still branches, and a converter that cannot accept the type reaching it still fails the chain. The extracted form additionally reports *which* converter broke the chain and what it does accept, and iterates start types in sorted order so that message is deterministic when several types are in play. The factory's existing tests are unchanged and still pass, which is the guard that the extraction preserved behaviour. Groundwork for plan-time scenario modality validation. Co-Authored-By: Claude Opus 5 (1M context) --- .../scenario/core/attack_technique_factory.py | 34 +--- pyrit/scenario/core/modality_validation.py | 87 +++++++++ .../scenario/core/test_modality_validation.py | 166 ++++++++++++++++++ 3 files changed, 261 insertions(+), 26 deletions(-) create mode 100644 pyrit/scenario/core/modality_validation.py create mode 100644 tests/unit/scenario/core/test_modality_validation.py diff --git a/pyrit/scenario/core/attack_technique_factory.py b/pyrit/scenario/core/attack_technique_factory.py index 9c373512f2..2f6150f947 100644 --- a/pyrit/scenario/core/attack_technique_factory.py +++ b/pyrit/scenario/core/attack_technique_factory.py @@ -32,13 +32,13 @@ AttackTechniqueSeedGroup, ComponentIdentifier, Identifiable, - PromptDataType, SeedIdentifier, SeedPrompt, SeedSimulatedConversation, ) from pyrit.models.seeds.seed_simulated_conversation import NextMessageSystemPromptPaths from pyrit.scenario.core.attack_technique import AttackTechnique +from pyrit.scenario.core.modality_validation import project_request_chain from pyrit.scenario.core.scenario_target_defaults import get_default_adversarial_target if TYPE_CHECKING: @@ -453,32 +453,14 @@ def can_append_request_converter(self, *, converter_type: type[Converter]) -> bo if "attack_converter_config" not in self._get_accepted_params(): return False - output_types: set[PromptDataType] = {"text"} converter_config = self._attack_kwargs.get("attack_converter_config") - if converter_config is None: - return "text" in converter_type.SUPPORTED_INPUT_TYPES - - for configuration in converter_config.request_converters: - next_output_types: set[PromptDataType] = set() - for output_type in output_types: - applies_to_type = ( - not configuration.prompt_data_types_to_apply - or output_type in configuration.prompt_data_types_to_apply - ) - if not applies_to_type: - next_output_types.add(output_type) - continue - - converted_types: set[PromptDataType] = {output_type} - for built_in_converter in configuration.converters: - if not all(built_in_converter.input_supported(data_type) for data_type in converted_types): - return False - converted_types = set(built_in_converter.supported_output_types) - - next_output_types.update(converted_types) - if configuration.indexes_to_apply: - next_output_types.add(output_type) - output_types = next_output_types + request_converters = converter_config.request_converters if converter_config is not None else [] + output_types, failure_reason = project_request_chain( + start_types={"text"}, + request_converters=request_converters, + ) + if failure_reason is not None: + return False return bool(output_types) and output_types.issubset(converter_type.SUPPORTED_INPUT_TYPES) diff --git a/pyrit/scenario/core/modality_validation.py b/pyrit/scenario/core/modality_validation.py new file mode 100644 index 0000000000..1612fd45db --- /dev/null +++ b/pyrit/scenario/core/modality_validation.py @@ -0,0 +1,87 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT license. + +""" +Derived modality compatibility for scenario attacks. + +Modality is not stored on a technique: whether a run is compatible depends on the seed pieces, +the request converters configured for that run, and the concrete target. This module derives +the answer instead, by projecting the data types a request starts with through the converter +chain and reporting what can reach the target. + +``project_request_chain`` is the shared core. ``AttackTechniqueFactory.can_append_request_converter`` +uses it to decide whether another converter may be appended, and scenario-level plan-time +validation uses it to decide whether a built ``AtomicAttack`` can run at all. Keeping one +implementation means the two cannot drift. +""" + +from __future__ import annotations + +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + from collections.abc import Iterable, Sequence + + from pyrit.models.literals import PromptDataType + from pyrit.prompt_normalizer import ConverterConfiguration + + +def project_request_chain( + *, + start_types: Iterable[PromptDataType], + request_converters: Sequence[ConverterConfiguration], +) -> tuple[set[PromptDataType], str | None]: + """ + Project ``start_types`` through a request-converter chain. + + Each configuration is applied in order. A configuration that declares + ``prompt_data_types_to_apply`` only converts the types it lists; any other type passes + through unchanged. A configuration with ``indexes_to_apply`` converts only some pieces, so + the unconverted type survives alongside the converted one and both can reach the target. + + Args: + start_types (Iterable[PromptDataType]): The data types the request starts with — the + pieces of the first message sent to the objective target. + request_converters (Sequence[ConverterConfiguration]): The request converter chain, in + application order. + + Returns: + tuple[set[PromptDataType], str | None]: The data types that can reach the target, and + ``None``; or an empty set and a message naming the first converter that could not accept + the type reaching it. + """ + output_types: set[PromptDataType] = set(start_types) + + for configuration in request_converters: + next_output_types: set[PromptDataType] = set() + + # Sorted for a deterministic failure message when several start types are in play. + for output_type in sorted(output_types): + applies_to_type = ( + not configuration.prompt_data_types_to_apply or output_type in configuration.prompt_data_types_to_apply + ) + if not applies_to_type: + next_output_types.add(output_type) + continue + + converted_types: set[PromptDataType] = {output_type} + for built_in_converter in configuration.converters: + unsupported = sorted( + data_type for data_type in converted_types if not built_in_converter.input_supported(data_type) + ) + if unsupported: + return set(), ( + f"{type(built_in_converter).__name__} does not accept {unsupported}; " + f"it accepts {sorted(built_in_converter.supported_input_types)}" + ) + converted_types = set(built_in_converter.supported_output_types) + + next_output_types.update(converted_types) + + # Partial application leaves some pieces unconverted, so the original type survives. + if configuration.indexes_to_apply: + next_output_types.add(output_type) + + output_types = next_output_types + + return output_types, None diff --git a/tests/unit/scenario/core/test_modality_validation.py b/tests/unit/scenario/core/test_modality_validation.py new file mode 100644 index 0000000000..0ec5c3d1fe --- /dev/null +++ b/tests/unit/scenario/core/test_modality_validation.py @@ -0,0 +1,166 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT license. + +""" +Unit tests for derived modality compatibility. + +``project_request_chain`` walks a request-converter chain and reports which +``PromptDataType`` values can reach the target, or which converter broke the chain. It is +extracted from ``AttackTechniqueFactory.can_append_request_converter`` so the factory's +append check and scenario-level plan-time validation share one implementation rather than +drifting apart. +""" + +from __future__ import annotations + +from pyrit.converter import ( + AudioEchoConverter, + Base64Converter, + ImageCompressionConverter, + QRCodeConverter, +) +from pyrit.prompt_normalizer import ConverterConfiguration +from pyrit.scenario.core.modality_validation import project_request_chain + + +def _configs(*converters) -> list[ConverterConfiguration]: + """One configuration per converter, mirroring ``ConverterConfiguration.from_converters``.""" + return ConverterConfiguration.from_converters(converters=list(converters)) + + +# --------------------------------------------------------------------------- +# Straight-line chains +# --------------------------------------------------------------------------- +def test_project_request_chain_no_converters_returns_start_types(): + """An empty chain passes the start types through untouched.""" + projected, reason = project_request_chain(start_types={"text"}, request_converters=[]) + assert projected == {"text"} + assert reason is None + + +def test_project_request_chain_text_converter_preserves_text(): + """A text-to-text converter leaves the projected type as text.""" + projected, reason = project_request_chain(start_types={"text"}, request_converters=_configs(Base64Converter())) + assert projected == {"text"} + assert reason is None + + +def test_project_request_chain_image_converter_yields_image_path(): + """A text-to-image converter shifts the projected type to image_path.""" + projected, reason = project_request_chain(start_types={"text"}, request_converters=_configs(QRCodeConverter())) + assert projected == {"image_path"} + assert reason is None + + +def test_project_request_chain_audio_converter_preserves_audio(): + """An audio-to-audio converter accepts an audio start and keeps it.""" + projected, reason = project_request_chain( + start_types={"audio_path"}, request_converters=_configs(AudioEchoConverter()) + ) + assert projected == {"audio_path"} + assert reason is None + + +def test_project_request_chain_two_converters_chain(): + """Consecutive converters compose: text becomes an image, which the next converter accepts.""" + projected, reason = project_request_chain( + start_types={"text"}, + request_converters=_configs(QRCodeConverter(), ImageCompressionConverter()), + ) + assert projected == {"image_path"} + assert reason is None + + +# --------------------------------------------------------------------------- +# Broken chains name the offending converter +# --------------------------------------------------------------------------- +def test_project_request_chain_unsupported_input_names_converter(): + """A converter that cannot accept the current type breaks the chain and is named in the reason.""" + projected, reason = project_request_chain( + start_types={"image_path"}, request_converters=_configs(Base64Converter()) + ) + assert projected == set() + assert reason is not None + assert "Base64Converter" in reason + assert "image_path" in reason + + +def test_project_request_chain_reports_first_failure_only(): + """The first converter that cannot accept the current type explains the failure.""" + _, reason = project_request_chain( + start_types={"text"}, + request_converters=_configs(QRCodeConverter(), Base64Converter()), + ) + assert reason is not None + assert "Base64Converter" in reason + assert "QRCodeConverter" not in reason + + +def test_project_request_chain_empty_start_returns_empty(): + """No start types means nothing can reach the target.""" + projected, reason = project_request_chain(start_types=set(), request_converters=_configs(Base64Converter())) + assert projected == set() + assert reason is None + + +# --------------------------------------------------------------------------- +# Conditional application +# --------------------------------------------------------------------------- +def test_project_request_chain_conditional_not_applying_preserves_type(): + """When ``prompt_data_types_to_apply`` excludes the current type the piece passes through unchanged.""" + config = ConverterConfiguration(converters=[QRCodeConverter()], prompt_data_types_to_apply=["image_path"]) + projected, reason = project_request_chain(start_types={"text"}, request_converters=[config]) + assert projected == {"text"} + assert reason is None + + +def test_project_request_chain_conditional_applying_converts(): + """When ``prompt_data_types_to_apply`` includes the current type the converter runs.""" + config = ConverterConfiguration(converters=[QRCodeConverter()], prompt_data_types_to_apply=["text"]) + projected, reason = project_request_chain(start_types={"text"}, request_converters=[config]) + assert projected == {"image_path"} + assert reason is None + + +def test_project_request_chain_indexes_to_apply_keeps_both_branches(): + """ + Partial application branches the projection. + + With ``indexes_to_apply`` set only some pieces are converted, so both the converted and the + unconverted type can reach the target and the target must accept both. + """ + config = ConverterConfiguration(converters=[QRCodeConverter()], indexes_to_apply=[0]) + projected, reason = project_request_chain(start_types={"text"}, request_converters=[config]) + assert projected == {"text", "image_path"} + assert reason is None + + +def test_project_request_chain_multi_type_start_projects_each_independently(): + """Each start type is projected on its own; a conditional converter touches only what it applies to.""" + config = ConverterConfiguration(converters=[QRCodeConverter()], prompt_data_types_to_apply=["text"]) + projected, reason = project_request_chain( + start_types={"text", "audio_path"}, + request_converters=[config], + ) + assert projected == {"image_path", "audio_path"} + assert reason is None + + +def test_project_request_chain_multi_type_start_fails_when_one_branch_breaks(): + """A converter that applies to a type it cannot accept breaks the whole chain.""" + projected, reason = project_request_chain( + start_types={"text", "image_path"}, + request_converters=_configs(Base64Converter()), + ) + assert projected == set() + assert reason is not None + assert "Base64Converter" in reason + + +def test_project_request_chain_image_converter_accepts_image_start(): + """A converter declaring image_path input accepts an image start with no bridge needed.""" + projected, reason = project_request_chain( + start_types={"image_path"}, request_converters=_configs(ImageCompressionConverter()) + ) + assert projected == {"image_path"} + assert reason is None From 6f42ad5694c2634d2b1e06db82bb6a528e79284a Mon Sep 17 00:00:00 2001 From: Victor Valbuena Date: Mon, 21 Sep 2026 18:42:18 -0700 Subject: [PATCH 04/19] FEAT: derive modality compatibility for a built atomic attack Add the verdict layer on top of ``project_request_chain``: given an ``AtomicAttack`` that has been constructed but not yet queued, decide whether its payload can actually reach its target and its scorer. Two chains are checked, both for turn 0. The request chain takes each seed group's own data types (the pieces of the ``next_message`` the attack will send, after merging any technique seed group), projects them through the request converters, and requires one of the target's advertised input modality combinations to cover the result. The response chain requires the scorer to declare every data type the target may emit. Seed groups are projected independently rather than unioned: two groups are two separate requests, so demanding a single combination that covers both types would reject pairings that run fine. A target that advertises no bare ``{"text"}`` combination requires media on every request. ``_ModalityFeedbackRouter`` already reads that signal to decide whether turn 0 can be built and raises when it cannot, so a text-only request to such a target is reported incompatible rather than merely unadvertised. Anything indeterminate is ``UNKNOWN`` and never blocks: a target whose capabilities are not readable, an attack exposing no scoring config, a scorer that never declared its types. An ``UNKNOWN`` leg does not cancel a ``COMPATIBLE`` one. Adds ``ModalityPolicy`` (``SKIP``/``WARN``/``RAISE``, mirroring ``ScorerOverridePolicy``) and ``ModalityValidationError``, a ``ValueError`` subclass so existing handlers keep working. The policy is not yet applied anywhere; that follows. Co-Authored-By: Claude Opus 5 (1M context) --- pyrit/scenario/core/__init__.py | 10 + pyrit/scenario/core/modality_validation.py | 348 ++++++++++++++++- .../scenario/core/test_modality_validation.py | 352 +++++++++++++++++- 3 files changed, 684 insertions(+), 26 deletions(-) diff --git a/pyrit/scenario/core/__init__.py b/pyrit/scenario/core/__init__.py index fa57884ad7..920655f53b 100644 --- a/pyrit/scenario/core/__init__.py +++ b/pyrit/scenario/core/__init__.py @@ -23,6 +23,12 @@ ResolvedDataset, require_nonempty, ) + from pyrit.scenario.core.modality_validation import ( + ModalityPolicy, + ModalityReport, + ModalityValidationError, + ModalityVerdict, + ) from pyrit.scenario.core.scenario import BaselineAttackPolicy, Scenario from pyrit.scenario.core.scenario_target_defaults import get_default_adversarial_target, get_default_scorer_target from pyrit.scenario.core.scenario_technique import ScenarioTechnique @@ -38,6 +44,10 @@ "DatasetConstraintError": "pyrit.scenario.core.dataset_configuration", "DatasetSourceKind": "pyrit.scenario.core.dataset_configuration", "INLINE_DATASET_NAME": "pyrit.scenario.core.dataset_configuration", + "ModalityPolicy": "pyrit.scenario.core.modality_validation", + "ModalityReport": "pyrit.scenario.core.modality_validation", + "ModalityValidationError": "pyrit.scenario.core.modality_validation", + "ModalityVerdict": "pyrit.scenario.core.modality_validation", "Parameter": "pyrit.models.parameter", "ResolvedDataset": "pyrit.scenario.core.dataset_configuration", "require_nonempty": "pyrit.scenario.core.dataset_configuration", diff --git a/pyrit/scenario/core/modality_validation.py b/pyrit/scenario/core/modality_validation.py index 1612fd45db..be92396329 100644 --- a/pyrit/scenario/core/modality_validation.py +++ b/pyrit/scenario/core/modality_validation.py @@ -6,24 +6,101 @@ Modality is not stored on a technique: whether a run is compatible depends on the seed pieces, the request converters configured for that run, and the concrete target. This module derives -the answer instead, by projecting the data types a request starts with through the converter -chain and reporting what can reach the target. +the answer instead. -``project_request_chain`` is the shared core. ``AttackTechniqueFactory.can_append_request_converter`` -uses it to decide whether another converter may be appended, and scenario-level plan-time -validation uses it to decide whether a built ``AtomicAttack`` can run at all. Keeping one -implementation means the two cannot drift. +Two chains are checked, both for turn 0 only: + +* the **request chain** — the seed's own data types, projected through the request converters, + must be covered by one of the objective target's advertised input modality combinations; +* the **response chain** — every data type the target may emit must be one the scorer declares + it can read. + +Anything indeterminate resolves to :data:`ModalityVerdict.UNKNOWN` and never blocks a run. A +target whose capabilities cannot be read, an attack that exposes no scoring config, and a +scorer that never declared its data types are all "cannot tell", not "incompatible". + +Turns after the first are not modelled here. Media routing across turns belongs to +``_ModalityFeedbackRouter``, which each multi-turn attack consults at execution time. """ from __future__ import annotations +import dataclasses +import logging +from dataclasses import dataclass, field +from enum import Enum from typing import TYPE_CHECKING if TYPE_CHECKING: from collections.abc import Iterable, Sequence + from pyrit.executor.attack.core.attack_strategy import AttackStrategy + from pyrit.models import AttackSeedGroup, AttackTechniqueSeedGroup from pyrit.models.literals import PromptDataType from pyrit.prompt_normalizer import ConverterConfiguration + from pyrit.prompt_target import PromptTarget + from pyrit.scenario.core.atomic_attack import AtomicAttack + from pyrit.score import Scorer + +logger = logging.getLogger(__name__) + + +class ModalityPolicy(str, Enum): + """ + Policy for what to do when an atomic attack's modality chain is incompatible. + + Mirrors ``ScorerOverridePolicy`` in vocabulary and meaning, with one difference: ``SKIP`` + logs a warning rather than staying silent, because dropping a whole atomic attack changes + what a run covers and should be visible even when the user allows it. + """ + + #: Drop the incompatible atomic attack and continue with the rest. + SKIP = "skip" + + #: Keep the attack and log a warning; it will fail later if the incompatibility is real. + WARN = "warn" + + #: Abort initialization with ``ModalityValidationError``. + RAISE = "raise" + + +class ModalityValidationError(ValueError): + """ + Raised when an atomic attack's modality chain cannot reach its target or scorer. + + Subclasses ``ValueError`` so existing ``except ValueError`` handlers around + ``initialize_async`` keep working, mirroring ``TechniqueResolutionError``. + """ + + +class ModalityVerdict(str, Enum): + """The outcome of checking one atomic attack, or one leg of it.""" + + #: Every checked chain can carry its payload end to end. + COMPATIBLE = "compatible" + + #: At least one chain provably cannot. + INCOMPATIBLE = "incompatible" + + #: Nothing could be determined — capabilities or declarations were unavailable. + UNKNOWN = "unknown" + + +@dataclass(frozen=True) +class ModalityReport: + """The result of validating one ``AtomicAttack``.""" + + #: The attack this report describes, used in the policy's log line and error message. + atomic_attack_name: str + + #: The overall verdict for the attack. + verdict: ModalityVerdict + + #: Human-readable explanations, each naming the converter, target, or scorer that failed. + reasons: tuple[str, ...] = field(default_factory=tuple) + + #: The union of data types the request chain produces across this attack's seed groups. + projected_request_types: frozenset[PromptDataType] = field(default_factory=frozenset) def project_request_chain( @@ -85,3 +162,262 @@ def project_request_chain( output_types = next_output_types return output_types, None + + +def target_accepts(*, target: PromptTarget, request_types: set[PromptDataType]) -> ModalityVerdict: + """ + Check a projected request against a target's advertised input modality combinations. + + A target advertises *combinations* it accepts in a single request, so one combination must + cover every projected type at once. Types spread across two combinations do not satisfy a + request that carries both. + + Args: + target (PromptTarget): The objective target. + request_types (set[PromptDataType]): The data types reaching the target. + + Returns: + ModalityVerdict: ``UNKNOWN`` when the target's capabilities cannot be read, otherwise + whether some advertised combination covers ``request_types``. + """ + supported = _read_modalities(target=target, direction="input") + if supported is None: + return ModalityVerdict.UNKNOWN + if not request_types: + return ModalityVerdict.INCOMPATIBLE + + requested = frozenset(request_types) + + # A target that advertises no bare ``{"text"}`` combination requires media on every request + # -- an image-edit model, for example. ``_ModalityFeedbackRouter`` reads the same signal to + # decide whether turn 0 can be constructed, and raises when it cannot, so a text-only + # request is genuinely unrunnable rather than merely unadvertised. + if requested == frozenset({"text"}) and frozenset({"text"}) not in supported: + return ModalityVerdict.INCOMPATIBLE + + covered = any(requested <= combination for combination in supported) + return ModalityVerdict.COMPATIBLE if covered else ModalityVerdict.INCOMPATIBLE + + +def scorer_accepts(*, scorer: Scorer | None, target: PromptTarget) -> tuple[ModalityVerdict, str | None]: + """ + Check what a target may emit against what its scorer declares it can read. + + This is a type-compatibility check only. It establishes that the scorer can *read* the + response, not that the resulting score is meaningful for the objective. + + Args: + scorer (Scorer | None): The objective scorer, if the attack exposes one. + target (PromptTarget): The objective target. + + Returns: + tuple[ModalityVerdict, str | None]: The verdict, and a reason when incompatible. + """ + if scorer is None: + return ModalityVerdict.UNKNOWN, None + + declared = scorer.supported_data_types + if declared is None: + return ModalityVerdict.UNKNOWN, None + + output_modalities = _read_modalities(target=target, direction="output") + if output_modalities is None: + return ModalityVerdict.UNKNOWN, None + + emitted = {data_type for combination in output_modalities for data_type in combination} + missing = sorted(emitted - declared) + if missing: + return ModalityVerdict.INCOMPATIBLE, ( + f"{type(scorer).__name__} cannot read {missing} which the objective target may emit; " + f"it declares {sorted(declared)}" + ) + return ModalityVerdict.COMPATIBLE, None + + +def validate_atomic_attack(*, atomic_attack: AtomicAttack) -> ModalityReport: + """ + Derive whether a built ``AtomicAttack`` can carry its payload to its target and scorer. + + Each seed group is projected independently — two groups are two separate requests, so their + data types are never combined into one. The attack is incompatible if any seed group is, and + compatible if at least one leg was determinable and none failed. + + Args: + atomic_attack (AtomicAttack): The attack to check, after construction and before queuing. + + Returns: + ModalityReport: The verdict and the reasons behind it. + """ + name = getattr(atomic_attack, "atomic_attack_name", "") + technique = atomic_attack.attack_technique + attack = technique.attack + + target = attack.get_objective_target() + request_converters = attack.get_request_converters() or [] + scoring_config = attack.get_attack_scoring_config() + scorer = getattr(scoring_config, "objective_scorer", None) if scoring_config is not None else None + + reads_next_message = _reads_next_message(attack=attack) + verdicts: set[ModalityVerdict] = set() + reasons: list[str] = [] + projected_all: set[PromptDataType] = set() + + for seed_group in _seed_groups_of(atomic_attack): + start_types = _effective_start_types( + seed_group=seed_group, + seed_technique=technique.seed_technique, + reads_next_message=reads_next_message, + ) + if start_types is None: + verdicts.add(ModalityVerdict.UNKNOWN) + continue + + projected, failure_reason = project_request_chain( + start_types=start_types, request_converters=request_converters + ) + if failure_reason is not None: + verdicts.add(ModalityVerdict.INCOMPATIBLE) + if failure_reason not in reasons: + reasons.append(failure_reason) + continue + + projected_all |= projected + request_verdict = target_accepts(target=target, request_types=projected) + verdicts.add(request_verdict) + if request_verdict is ModalityVerdict.INCOMPATIBLE: + reason = ( + f"objective target does not accept {sorted(projected)}; " + f"it accepts {_format_modalities(_read_modalities(target=target, direction='input'))}" + ) + if reason not in reasons: + reasons.append(reason) + + scorer_verdict, scorer_reason = scorer_accepts(scorer=scorer, target=target) + verdicts.add(scorer_verdict) + if scorer_reason is not None: + reasons.append(scorer_reason) + + if ModalityVerdict.INCOMPATIBLE in verdicts: + verdict = ModalityVerdict.INCOMPATIBLE + elif ModalityVerdict.COMPATIBLE in verdicts: + # A leg we could not determine does not cancel one we could. + verdict = ModalityVerdict.COMPATIBLE + else: + verdict = ModalityVerdict.UNKNOWN + + return ModalityReport( + atomic_attack_name=name, + verdict=verdict, + reasons=tuple(reasons), + projected_request_types=frozenset(projected_all), + ) + + +# --------------------------------------------------------------------------- # +# Internals +# --------------------------------------------------------------------------- # +def _seed_groups_of(atomic_attack: AtomicAttack) -> list[AttackSeedGroup]: + """Return the attack's seed groups, or an empty list when they cannot be read.""" + try: + return list(atomic_attack.seed_groups) + except (AttributeError, TypeError): + return [] + + +def _reads_next_message(*, attack: AttackStrategy) -> bool: + """ + Whether the attack builds its first request from the seed's ``next_message``. + + Attacks created via ``AttackParameters.excluding("next_message")`` build turn 0 from the + objective text instead, so their seed media never reaches the target on the first turn. + + Returns: + bool: ``True`` when the attack reads ``next_message``, including when it cannot be + determined (the common case for every standard ``AttackParameters``). + """ + try: + return any(f.name == "next_message" for f in dataclasses.fields(attack.params_type)) + except (AttributeError, TypeError): + return True + + +def _effective_start_types( + *, + seed_group: AttackSeedGroup, + seed_technique: AttackTechniqueSeedGroup | None, + reads_next_message: bool, +) -> set[PromptDataType] | None: + """ + Determine the data types the first request carries for one seed group. + + The technique seed group is merged in first, because a technique's own prompts travel with + the seed. ``with_technique`` raises for a group whose prompt sequences overlap a simulated + conversation, so compatibility is checked first and the unmerged group is used otherwise — + that pairing is rejected elsewhere and is not a modality problem. + + Returns: + set[PromptDataType] | None: The starting data types, or ``None`` when undeterminable. + """ + if not reads_next_message: + return {"text"} + + group = seed_group + if seed_technique is not None: + try: + if group.is_compatible_with_technique(technique=seed_technique): + group = group.with_technique(technique=seed_technique) + except (AttributeError, TypeError, ValueError): + return None + + try: + message = group.next_message + except (AttributeError, TypeError): + return None + + if message is None: + # No prompts on the group: the attack sends the objective text. + return {"text"} + + try: + types = {piece.converted_value_data_type for piece in message.message_pieces} + except (AttributeError, TypeError): + return None + + return types or {"text"} + + +def _read_modalities(*, target: PromptTarget, direction: str) -> frozenset[frozenset[PromptDataType]] | None: + """ + Read a target's declared input or output modality combinations. + + Args: + target (PromptTarget): The target to read. + direction (str): Either ``"input"`` or ``"output"``. + + Returns: + frozenset[frozenset[PromptDataType]] | None: The declared combinations, or ``None`` + when the value is not a real ``frozenset`` — most often a test double whose + capabilities were never configured. Callers treat that as unknown, not as a failure. + """ + try: + capabilities = target.configuration.capabilities + value = capabilities.input_modalities if direction == "input" else capabilities.output_modalities + except AttributeError: + return None + # A test double that never configured capabilities yields a mock here, not a frozenset. + return value if isinstance(value, frozenset) else None + + +def _format_modalities(modalities: frozenset[frozenset[PromptDataType]] | None) -> str: + """ + Render modality combinations deterministically for an error message. + + Args: + modalities (frozenset[frozenset[PromptDataType]] | None): The combinations to render. + + Returns: + str: A sorted, stable rendering, or ``""``. + """ + if modalities is None: + return "" + return str([sorted(combination) for combination in sorted(modalities, key=sorted)]) diff --git a/tests/unit/scenario/core/test_modality_validation.py b/tests/unit/scenario/core/test_modality_validation.py index 0ec5c3d1fe..2323a5d7ac 100644 --- a/tests/unit/scenario/core/test_modality_validation.py +++ b/tests/unit/scenario/core/test_modality_validation.py @@ -4,23 +4,54 @@ """ Unit tests for derived modality compatibility. -``project_request_chain`` walks a request-converter chain and reports which -``PromptDataType`` values can reach the target, or which converter broke the chain. It is -extracted from ``AttackTechniqueFactory.can_append_request_converter`` so the factory's -append check and scenario-level plan-time validation share one implementation rather than -drifting apart. +Modality is not stored on a technique — it is derived per run from the seed pieces, the +request converters, and the concrete target. These tests cover that derivation in layers: +projecting a converter chain, checking the projection against a target's declared input +modalities, checking the target's output modalities against the scorer, and assembling all of +it into a verdict for one built ``AtomicAttack``. + +``project_request_chain`` is extracted from +``AttackTechniqueFactory.can_append_request_converter`` so the factory's append check and +scenario-level plan-time validation share one implementation rather than drifting apart. """ from __future__ import annotations +import pytest +from unit.mocks import get_mock_target +from unit.modality_profiles import ( + IMAGE_EDIT_INPUT_MODALITIES, + TEXT_ONLY_MODALITIES, + VISION_INPUT_MODALITIES, +) + from pyrit.converter import ( AudioEchoConverter, Base64Converter, ImageCompressionConverter, QRCodeConverter, ) +from pyrit.executor.attack import ( + AttackConverterConfig, + AttackParameters, + AttackScoringConfig, + PromptSendingAttack, +) +from pyrit.models import AttackSeedGroup, AttackTechniqueSeedGroup, SeedObjective, SeedPrompt from pyrit.prompt_normalizer import ConverterConfiguration -from pyrit.scenario.core.modality_validation import project_request_chain +from pyrit.scenario.core.atomic_attack import AtomicAttack +from pyrit.scenario.core.attack_technique import AttackTechnique +from pyrit.scenario.core.modality_validation import ( + ModalityPolicy, + ModalityValidationError, + ModalityVerdict, + project_request_chain, + scorer_accepts, + target_accepts, + validate_atomic_attack, +) +from pyrit.score import SubStringScorer +from pyrit.score.scorer_prompt_validator import ScorerPromptValidator def _configs(*converters) -> list[ConverterConfiguration]: @@ -28,6 +59,54 @@ def _configs(*converters) -> list[ConverterConfiguration]: return ConverterConfiguration.from_converters(converters=list(converters)) +def _scorer_declaring(declared) -> SubStringScorer: + """A real scorer whose validator declares ``declared``, or nothing when ``None``.""" + validator = ( + ScorerPromptValidator(supported_data_types=declared) if declared is not None else ScorerPromptValidator() + ) + return SubStringScorer(substring="x", validator=validator) + + +def _text_seed_group() -> AttackSeedGroup: + """A seed group carrying only an objective, so the attack sends the objective text.""" + return AttackSeedGroup(seeds=[SeedObjective(value="a text objective")]) + + +def _media_seed_group(data_type="image_path", value="seed.png") -> AttackSeedGroup: + """A seed group whose next message is a single media piece.""" + return AttackSeedGroup( + seeds=[SeedObjective(value="a media objective"), SeedPrompt(value=value, data_type=data_type)] + ) + + +def _atomic( + *, + target, + converters=None, + scorer=None, + seed_groups=None, + seed_technique=None, + params_type=None, + name="atomic", +) -> AtomicAttack: + """Build a real AtomicAttack around a PromptSendingAttack with the given wiring.""" + kwargs = {"objective_target": target} + if converters is not None: + kwargs["attack_converter_config"] = AttackConverterConfig( + request_converters=ConverterConfiguration.from_converters(converters=list(converters)) + ) + if scorer is not None: + kwargs["attack_scoring_config"] = AttackScoringConfig(objective_scorer=scorer) + if params_type is not None: + kwargs["params_type"] = params_type + attack = PromptSendingAttack(**kwargs) + return AtomicAttack( + atomic_attack_name=name, + attack_technique=AttackTechnique(attack=attack, seed_technique=seed_technique), + seed_groups=seed_groups or [_text_seed_group()], + ) + + # --------------------------------------------------------------------------- # Straight-line chains # --------------------------------------------------------------------------- @@ -71,6 +150,15 @@ def test_project_request_chain_two_converters_chain(): assert reason is None +def test_project_request_chain_image_converter_accepts_image_start(): + """A converter declaring image_path input accepts an image start with no bridge needed.""" + projected, reason = project_request_chain( + start_types={"image_path"}, request_converters=_configs(ImageCompressionConverter()) + ) + assert projected == {"image_path"} + assert reason is None + + # --------------------------------------------------------------------------- # Broken chains name the offending converter # --------------------------------------------------------------------------- @@ -103,6 +191,17 @@ def test_project_request_chain_empty_start_returns_empty(): assert reason is None +def test_project_request_chain_multi_type_start_fails_when_one_branch_breaks(): + """A converter that applies to a type it cannot accept breaks the whole chain.""" + projected, reason = project_request_chain( + start_types={"text", "image_path"}, + request_converters=_configs(Base64Converter()), + ) + assert projected == set() + assert reason is not None + assert "Base64Converter" in reason + + # --------------------------------------------------------------------------- # Conditional application # --------------------------------------------------------------------------- @@ -146,21 +245,234 @@ def test_project_request_chain_multi_type_start_projects_each_independently(): assert reason is None -def test_project_request_chain_multi_type_start_fails_when_one_branch_breaks(): - """A converter that applies to a type it cannot accept breaks the whole chain.""" - projected, reason = project_request_chain( - start_types={"text", "image_path"}, - request_converters=_configs(Base64Converter()), - ) - assert projected == set() +# --------------------------------------------------------------------------- +# Target acceptance (request chain terminus) +# --------------------------------------------------------------------------- +def test_target_accepts_exact_combo_is_compatible(): + """A projection matching an advertised combo exactly is compatible.""" + target = get_mock_target(input_modalities=TEXT_ONLY_MODALITIES) + assert target_accepts(target=target, request_types={"text"}) is ModalityVerdict.COMPATIBLE + + +def test_target_accepts_subset_of_combo_is_compatible(): + """A projection that is a subset of an advertised combo is compatible.""" + target = get_mock_target(input_modalities=VISION_INPUT_MODALITIES) + assert target_accepts(target=target, request_types={"text", "image_path"}) is ModalityVerdict.COMPATIBLE + + +def test_target_accepts_no_covering_combo_is_incompatible(): + """The canonical failure: an image reaches a text-only target.""" + target = get_mock_target(input_modalities=TEXT_ONLY_MODALITIES) + assert target_accepts(target=target, request_types={"image_path"}) is ModalityVerdict.INCOMPATIBLE + + +def test_target_accepts_requires_one_combo_to_cover_all_types(): + """Types spread across two separate combos do not satisfy a single request.""" + target = get_mock_target(input_modalities=[{"text"}, {"image_path"}]) + assert target_accepts(target=target, request_types={"text", "image_path"}) is ModalityVerdict.INCOMPATIBLE + + +def test_target_accepts_edit_only_target_with_media_seed_is_compatible(): + """ + An image-edit target advertises only ``{text, image_path}``. + + A seed supplying both adversarial text and a media piece satisfies it, so plan-time + validation must not reject this shape — it is how edit flows legitimately run. + """ + target = get_mock_target(input_modalities=IMAGE_EDIT_INPUT_MODALITIES) + assert target_accepts(target=target, request_types={"text", "image_path"}) is ModalityVerdict.COMPATIBLE + + +def test_target_accepts_edit_only_target_with_text_only_seed_is_incompatible(): + """The same target rejects a text-only request, matching what the router raises at run time.""" + target = get_mock_target(input_modalities=IMAGE_EDIT_INPUT_MODALITIES) + assert target_accepts(target=target, request_types={"text"}) is ModalityVerdict.INCOMPATIBLE + + +def test_target_accepts_empty_projection_is_incompatible(): + """Nothing reaching the target cannot be compatible.""" + target = get_mock_target(input_modalities=TEXT_ONLY_MODALITIES) + assert target_accepts(target=target, request_types=set()) is ModalityVerdict.INCOMPATIBLE + + +def test_target_accepts_unknown_capabilities_is_unknown(): + """A mock target with no real capabilities is indeterminate, never a failure.""" + assert target_accepts(target=get_mock_target(), request_types={"image_path"}) is ModalityVerdict.UNKNOWN + + +@pytest.mark.parametrize("media_type", ["image_path", "audio_path", "video_path"]) +def test_target_accepts_media_with_text_combos(media_type): + """Every media type pairs with text the same way.""" + target = get_mock_target(input_modalities=[{"text"}, {"text", media_type}]) + assert target_accepts(target=target, request_types={"text", media_type}) is ModalityVerdict.COMPATIBLE + + +# --------------------------------------------------------------------------- +# Scorer acceptance (response chain) +# --------------------------------------------------------------------------- +def test_scorer_accepts_declared_superset_is_compatible(): + """A scorer declaring everything the target emits is compatible.""" + target = get_mock_target(output_modalities=[{"text"}]) + verdict, reason = scorer_accepts(scorer=_scorer_declaring(["text", "image_path"]), target=target) + assert verdict is ModalityVerdict.COMPATIBLE + assert reason is None + + +def test_scorer_accepts_missing_emitted_type_is_incompatible(): + """A text-only scorer cannot read an image the target may emit.""" + target = get_mock_target(output_modalities=[{"image_path"}]) + verdict, reason = scorer_accepts(scorer=_scorer_declaring(["text"]), target=target) + assert verdict is ModalityVerdict.INCOMPATIBLE assert reason is not None - assert "Base64Converter" in reason + assert "image_path" in reason -def test_project_request_chain_image_converter_accepts_image_start(): - """A converter declaring image_path input accepts an image start with no bridge needed.""" - projected, reason = project_request_chain( - start_types={"image_path"}, request_converters=_configs(ImageCompressionConverter()) +def test_scorer_accepts_none_scorer_is_unknown(): + """No scorer means nothing to check.""" + target = get_mock_target(output_modalities=[{"image_path"}]) + assert scorer_accepts(scorer=None, target=target)[0] is ModalityVerdict.UNKNOWN + + +def test_scorer_accepts_undeclared_scorer_is_unknown(): + """An undeclared scorer is indeterminate rather than assumed compatible.""" + target = get_mock_target(output_modalities=[{"image_path"}]) + assert scorer_accepts(scorer=_scorer_declaring(None), target=target)[0] is ModalityVerdict.UNKNOWN + + +def test_scorer_accepts_unknown_target_outputs_is_unknown(): + """A target with no real capabilities makes the response chain indeterminate.""" + assert scorer_accepts(scorer=_scorer_declaring(["text"]), target=get_mock_target())[0] is ModalityVerdict.UNKNOWN + + +# --------------------------------------------------------------------------- +# Policy and error surface +# --------------------------------------------------------------------------- +def test_modality_policy_mirrors_scorer_override_policy_values(): + """The policy reuses the SKIP/WARN/RAISE vocabulary already established by ScorerOverridePolicy.""" + assert {policy.value for policy in ModalityPolicy} == {"skip", "warn", "raise"} + + +def test_modality_validation_error_is_a_value_error(): + """Subclassing ValueError keeps existing ``except ValueError`` handlers working.""" + assert issubclass(ModalityValidationError, ValueError) + + +# --------------------------------------------------------------------------- +# validate_atomic_attack — one verdict per built attack +# --------------------------------------------------------------------------- +def test_validate_atomic_attack_text_chain_is_compatible(patch_central_database): + """A text seed with no converters reaching a text target is compatible.""" + target = get_mock_target(input_modalities=TEXT_ONLY_MODALITIES, output_modalities=TEXT_ONLY_MODALITIES) + report = validate_atomic_attack(atomic_attack=_atomic(target=target, scorer=_scorer_declaring(["text"]))) + assert report.verdict is ModalityVerdict.COMPATIBLE + assert report.reasons == () + + +def test_validate_atomic_attack_image_converter_into_text_target_is_incompatible(patch_central_database): + """The canonical case: a converter emits an image and the target is text-only.""" + target = get_mock_target(input_modalities=TEXT_ONLY_MODALITIES, output_modalities=TEXT_ONLY_MODALITIES) + report = validate_atomic_attack(atomic_attack=_atomic(target=target, converters=[QRCodeConverter()])) + assert report.verdict is ModalityVerdict.INCOMPATIBLE + assert any("image_path" in reason for reason in report.reasons) + + +def test_validate_atomic_attack_image_converter_into_vision_target_is_compatible(patch_central_database): + """The same chain against a capable target is compatible.""" + target = get_mock_target(input_modalities=VISION_INPUT_MODALITIES, output_modalities=TEXT_ONLY_MODALITIES) + report = validate_atomic_attack(atomic_attack=_atomic(target=target, converters=[QRCodeConverter()])) + assert report.verdict is ModalityVerdict.COMPATIBLE + + +def test_validate_atomic_attack_converter_break_names_the_converter(patch_central_database): + """A media seed hitting a text-only converter reports the converter, not the target.""" + target = get_mock_target(input_modalities=VISION_INPUT_MODALITIES, output_modalities=TEXT_ONLY_MODALITIES) + atomic = _atomic(target=target, converters=[Base64Converter()], seed_groups=[_media_seed_group()]) + report = validate_atomic_attack(atomic_attack=atomic) + assert report.verdict is ModalityVerdict.INCOMPATIBLE + assert any("Base64Converter" in reason for reason in report.reasons) + + +def test_validate_atomic_attack_media_seed_reaches_capable_target(patch_central_database): + """An image seed with no converters is compatible with a target that accepts images.""" + target = get_mock_target(input_modalities=[{"image_path"}], output_modalities=TEXT_ONLY_MODALITIES) + atomic = _atomic(target=target, seed_groups=[_media_seed_group()]) + assert validate_atomic_attack(atomic_attack=atomic).verdict is ModalityVerdict.COMPATIBLE + + +def test_validate_atomic_attack_scorer_cannot_read_target_output(patch_central_database): + """An incompatibility on the response chain fails the attack just as the request chain does.""" + target = get_mock_target(input_modalities=TEXT_ONLY_MODALITIES, output_modalities=[{"image_path"}]) + atomic = _atomic(target=target, scorer=_scorer_declaring(["text"])) + report = validate_atomic_attack(atomic_attack=atomic) + assert report.verdict is ModalityVerdict.INCOMPATIBLE + assert any("SubStringScorer" in reason for reason in report.reasons) + + +def test_validate_atomic_attack_unknown_target_is_unknown(patch_central_database): + """A plain mock target yields UNKNOWN so existing scenario tests are never blocked.""" + report = validate_atomic_attack(atomic_attack=_atomic(target=get_mock_target())) + assert report.verdict is ModalityVerdict.UNKNOWN + + +def test_validate_atomic_attack_without_scoring_config_still_checks_request_chain(patch_central_database): + """A missing scoring config leaves the response chain unknown but does not mask a request failure.""" + target = get_mock_target(input_modalities=TEXT_ONLY_MODALITIES, output_modalities=TEXT_ONLY_MODALITIES) + report = validate_atomic_attack(atomic_attack=_atomic(target=target, converters=[QRCodeConverter()])) + assert report.verdict is ModalityVerdict.INCOMPATIBLE + + +def test_validate_atomic_attack_report_carries_attack_name(patch_central_database): + """The report identifies which atomic attack it describes, for the policy's log and error.""" + target = get_mock_target(input_modalities=TEXT_ONLY_MODALITIES) + report = validate_atomic_attack(atomic_attack=_atomic(target=target, name="qr_image_hate")) + assert report.atomic_attack_name == "qr_image_hate" + + +def test_validate_atomic_attack_incompatible_if_any_seed_group_fails(patch_central_database): + """Seed groups are projected independently; one failure condemns the attack.""" + target = get_mock_target(input_modalities=TEXT_ONLY_MODALITIES, output_modalities=TEXT_ONLY_MODALITIES) + atomic = _atomic(target=target, seed_groups=[_text_seed_group(), _media_seed_group()]) + assert validate_atomic_attack(atomic_attack=atomic).verdict is ModalityVerdict.INCOMPATIBLE + + +def test_validate_atomic_attack_seed_groups_are_not_unioned(patch_central_database): + """ + Two seed groups of different types are separate requests, not one multimodal request. + + A target accepting text alone or an image alone must accept both groups; unioning their + types would wrongly demand a single combo containing both. + """ + target = get_mock_target(input_modalities=[{"text"}, {"image_path"}], output_modalities=TEXT_ONLY_MODALITIES) + atomic = _atomic(target=target, seed_groups=[_text_seed_group(), _media_seed_group()]) + assert validate_atomic_attack(atomic_attack=atomic).verdict is ModalityVerdict.COMPATIBLE + + +def test_validate_atomic_attack_merges_technique_seed_group(patch_central_database): + """ + A technique seed group merges into the seed group, so its pieces join the projection. + + This is the mechanism behind template techniques such as the four-panel jailbreak: the + merged request carries the template text alongside the seed's own media. + """ + target = get_mock_target(input_modalities=[{"image_path"}], output_modalities=TEXT_ONLY_MODALITIES) + technique = AttackTechniqueSeedGroup( + seeds=[SeedPrompt(value="a template", data_type="text", is_general_technique=True)] ) - assert projected == {"image_path"} - assert reason is None + atomic = _atomic(target=target, seed_groups=[_media_seed_group()], seed_technique=technique) + report = validate_atomic_attack(atomic_attack=atomic) + assert report.verdict is ModalityVerdict.INCOMPATIBLE + + +def test_validate_atomic_attack_attack_excluding_next_message_starts_from_text(patch_central_database): + """ + Attacks whose params type excludes ``next_message`` build turn 0 from the objective text. + + Their media seeds never reach the target on the first turn, so the projection starts at text. + """ + target = get_mock_target(input_modalities=TEXT_ONLY_MODALITIES, output_modalities=TEXT_ONLY_MODALITIES) + atomic = _atomic( + target=target, + seed_groups=[_media_seed_group()], + params_type=AttackParameters.excluding("next_message"), + ) + assert validate_atomic_attack(atomic_attack=atomic).verdict is ModalityVerdict.COMPATIBLE From fbeef14357fd08465900ac80659ab440387ac77b Mon Sep 17 00:00:00 2001 From: Victor Valbuena Date: Mon, 21 Sep 2026 19:08:53 -0700 Subject: [PATCH 05/19] FEAT: enforce modality compatibility before a scenario runs Apply the derived modality verdict in ``Scenario.initialize_async``, immediately after ``_build_atomic_attacks_async`` returns and before the display-group map, the persisted run plan, or any queued work is derived from the attack list. Modality mismatches surfaced only at execution time before this: a scorer rejecting a data type it cannot read, or a multi-turn attack failing to build its first request, both after the run had started spending. ``MODALITY_POLICY`` is a class attribute alongside ``BASELINE_ATTACK_POLICY``, defaulting to ``SKIP``. ``SKIP`` drops the attack and logs a warning; dropping a whole atomic attack changes what a run covers, so it is loud even though the policy allows it. ``WARN`` keeps the attack. ``RAISE`` aborts with ``ModalityValidationError``. Skipping every attack raises rather than reporting a successful run that tested nothing. Validation runs on the resume path too, since it sits before that branch returns. Attacks whose compatibility cannot be determined are always kept, so scenarios exercised against mock targets are unaffected. FigStep opts into ``WARN``. Its seeds are a single text-plus-image message and its caller-supplied technique converters are appended unscoped, so the normalizer runs them against both pieces; no converter in the library accepts ``text`` and ``image_path`` together, so any such converter raises ``Input type not supported`` at run time. The verdict is accurate but the limitation predates this check, so FigStep warns rather than dropping the attack until those converters are scoped to the text piece. Co-Authored-By: Claude Opus 5 (1M context) --- pyrit/scenario/core/scenario.py | 67 +++++ pyrit/scenario/scenarios/garak/figstep.py | 10 + .../core/test_scenario_modality_policy.py | 247 ++++++++++++++++++ 3 files changed, 324 insertions(+) create mode 100644 tests/unit/scenario/core/test_scenario_modality_policy.py diff --git a/pyrit/scenario/core/scenario.py b/pyrit/scenario/core/scenario.py index 8e87d288ca..f492a79977 100644 --- a/pyrit/scenario/core/scenario.py +++ b/pyrit/scenario/core/scenario.py @@ -51,6 +51,12 @@ from pyrit.registry.resolution import resolve_declared_params, resolve_reference_value from pyrit.scenario.core.atomic_attack import AtomicAttack from pyrit.scenario.core.dataset_configuration import DatasetAttackConfiguration +from pyrit.scenario.core.modality_validation import ( + ModalityPolicy, + ModalityValidationError, + ModalityVerdict, + validate_atomic_attack, +) from pyrit.scenario.core.scenario_context import ScenarioContext from pyrit.scenario.core.scenario_target_defaults import get_default_scorer_target from pyrit.scenario.core.scenario_technique import ScenarioTechnique @@ -127,6 +133,12 @@ class Scenario(ABC): #: caller-supplied ``include_baseline=True`` raises ``ValueError``. BASELINE_ATTACK_POLICY: ClassVar[BaselineAttackPolicy] = BaselineAttackPolicy.Enabled + #: How this scenario type treats an atomic attack whose payload provably cannot reach its + #: objective target or scorer. Derived per run in ``initialize_async`` once the attacks are + #: built, and before any of them is queued. ``SKIP`` drops the attack with a warning, + #: ``WARN`` keeps it, and ``RAISE`` aborts initialization. Skipping every attack raises. + MODALITY_POLICY: ClassVar[ModalityPolicy] = ModalityPolicy.SKIP + #: Whether the default estimator must mirror matrix-builder seed compatibility. RUN_SIZE_USES_FACTORY_COMPATIBILITY: ClassVar[bool] = False @@ -908,6 +920,8 @@ async def initialize_async(self) -> None: nor registered as a default), if a supplied target name is not registered in ``TargetRegistry``, or if ``include_baseline=True`` is set for a scenario whose ``BASELINE_ATTACK_POLICY`` is ``Forbidden``. + ModalityValidationError: If ``MODALITY_POLICY`` is ``RAISE`` and any atomic attack's + payload cannot reach its target or scorer, or if ``SKIP`` would leave no attacks. """ self._resolve_runtime_configuration(require_objective_target=True) @@ -927,6 +941,7 @@ async def initialize_async(self) -> None: seed_groups_by_dataset = await self._resolve_seed_groups_by_dataset_async(apply_sampling=not is_resume) context = self._build_scenario_context(seed_groups_by_dataset=seed_groups_by_dataset) self._atomic_attacks = await self._build_atomic_attacks_async(context=context) + self._atomic_attacks = self._apply_modality_policy(atomic_attacks=self._atomic_attacks) # Build the canonical scenario identifier once params/techniques/datasets # are resolved, so both the resume check and the new-result branch share the @@ -1415,6 +1430,58 @@ def _build_scenario_context(self, *, seed_groups_by_dataset: dict[str, list[Atta seed_groups_by_dataset=seed_groups_by_dataset, ) + def _apply_modality_policy(self, *, atomic_attacks: list[AtomicAttack]) -> list[AtomicAttack]: + """ + Derive each atomic attack's modality compatibility and apply ``MODALITY_POLICY``. + + Runs once per ``initialize_async``, on both the fresh and the resume path, before the + display-group map and the persisted result slots are built from the surviving attacks. + Attacks whose compatibility cannot be determined are always kept. + + Args: + atomic_attacks (list[AtomicAttack]): The attacks just built for this run. + + Returns: + list[AtomicAttack]: The attacks that should run. + + Raises: + ModalityValidationError: Under ``RAISE`` when any attack is incompatible, or under + ``SKIP`` when every attack would be dropped. + """ + reports = [validate_atomic_attack(atomic_attack=attack) for attack in atomic_attacks] + incompatible = [report for report in reports if report.verdict is ModalityVerdict.INCOMPATIBLE] + if not incompatible: + return atomic_attacks + + summary = "\n".join(f" - {report.atomic_attack_name}: {'; '.join(report.reasons)}" for report in incompatible) + policy = type(self).MODALITY_POLICY + + if policy is ModalityPolicy.RAISE: + raise ModalityValidationError( + f"{len(incompatible)} atomic attack(s) cannot reach the objective target or scorer:\n{summary}" + ) + + if policy is ModalityPolicy.WARN: + logger.warning( + f"Modality incompatibility in {len(incompatible)} atomic attack(s); running anyway:\n{summary}" + ) + return atomic_attacks + + for report in incompatible: + logger.warning(f"Skipping atomic attack '{report.atomic_attack_name}': {'; '.join(report.reasons)}") + + kept = [ + attack + for attack, report in zip(atomic_attacks, reports, strict=True) + if report.verdict is not ModalityVerdict.INCOMPATIBLE + ] + if not kept: + raise ModalityValidationError( + f"Modality validation skipped all {len(atomic_attacks)} atomic attack(s), " + f"so the scenario has nothing to run:\n{summary}" + ) + return kept + @abstractmethod async def _build_atomic_attacks_async(self, *, context: ScenarioContext) -> list[AtomicAttack]: """ diff --git a/pyrit/scenario/scenarios/garak/figstep.py b/pyrit/scenario/scenarios/garak/figstep.py index 895db50e0f..ba7c57bc87 100644 --- a/pyrit/scenario/scenarios/garak/figstep.py +++ b/pyrit/scenario/scenarios/garak/figstep.py @@ -22,6 +22,7 @@ DatasetSourceKind, ) from pyrit.scenario.core.matrix_atomic_attack_builder import build_baseline_atomic_attack +from pyrit.scenario.core.modality_validation import ModalityPolicy from pyrit.scenario.core.scenario import Scenario from pyrit.scenario.core.scenario_technique import ScenarioTechnique @@ -67,6 +68,15 @@ class FigStep(Scenario): """ VERSION: int = 1 + + #: FigStep seeds are a single text-plus-image message, and caller-supplied technique + #: converters are appended unscoped, so they run against *both* pieces. No converter in the + #: library accepts ``text`` and ``image_path`` together, which makes every such converter + #: report as incompatible. That is accurate -- the run would raise ``Input type not + #: supported`` -- but the limitation predates plan-time validation, so warn rather than drop + #: the attack until FigStep scopes those converters (e.g. ``prompt_data_types_to_apply``). + MODALITY_POLICY: ClassVar[ModalityPolicy] = ModalityPolicy.WARN + TARGET_REQUIREMENTS: ClassVar[TargetRequirements] = TargetRequirements( native_required=frozenset({CapabilityName.MULTI_MESSAGE_PIECES}), required_input_modalities=_FIGSTEP_INPUT_MODALITIES, diff --git a/tests/unit/scenario/core/test_scenario_modality_policy.py b/tests/unit/scenario/core/test_scenario_modality_policy.py new file mode 100644 index 0000000000..cbb30c5508 --- /dev/null +++ b/tests/unit/scenario/core/test_scenario_modality_policy.py @@ -0,0 +1,247 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT license. + +""" +Tests for ``Scenario.MODALITY_POLICY`` — plan-time enforcement of modality compatibility. + +Validation runs inside ``initialize_async``, immediately after ``_build_atomic_attacks_async`` +returns and before any attack is queued or any prompt is sent. The policy decides what happens +to an attack whose payload provably cannot reach its target or scorer. +""" + +from __future__ import annotations + +import logging +from typing import ClassVar +from unittest.mock import MagicMock + +import pytest +from unit.mocks import get_mock_target +from unit.modality_profiles import TEXT_ONLY_MODALITIES, VISION_INPUT_MODALITIES + +from pyrit.converter import QRCodeConverter +from pyrit.executor.attack import AttackConverterConfig, PromptSendingAttack +from pyrit.models import AttackSeedGroup, ComponentIdentifier, SeedObjective +from pyrit.prompt_normalizer import ConverterConfiguration +from pyrit.prompt_target.common.target_requirements import TargetRequirements +from pyrit.scenario.core import AtomicAttack, BaselineAttackPolicy, Scenario, ScenarioTechnique +from pyrit.scenario.core.attack_technique import AttackTechnique +from pyrit.scenario.core.dataset_configuration import DatasetConfiguration +from pyrit.scenario.core.modality_validation import ModalityPolicy, ModalityValidationError +from pyrit.score import Scorer + +_TEST_SCORER_ID = ComponentIdentifier(class_name="MockScorer", class_module="tests.unit.scenario") + + +class _PolicyScenario(Scenario): + """Minimal scenario returning a fixed list of atomic attacks.""" + + BASELINE_ATTACK_POLICY: ClassVar[BaselineAttackPolicy] = BaselineAttackPolicy.Forbidden + + def __init__(self, *, atomic_attacks_to_return=None, **kwargs): + class TestTechnique(ScenarioTechnique): + TEST = ("test", {"concrete"}, "Test technique description.") + ALL = ("all", {"all"}) + + @classmethod + def get_aggregate_tags(cls) -> set[str]: + return {"all"} + + kwargs.setdefault("technique_class", TestTechnique) + kwargs.setdefault("default_dataset_config", DatasetConfiguration()) + kwargs.setdefault("version", 1) + if "objective_scorer" not in kwargs: + scorer = MagicMock(spec=Scorer) + scorer.get_identifier.return_value = _TEST_SCORER_ID + scorer.get_scorer_metrics.return_value = None + kwargs["objective_scorer"] = scorer + super().__init__(**kwargs) + self._atomic_attacks_to_return = atomic_attacks_to_return or [] + + async def _resolve_seed_groups_by_dataset_async(self, *, apply_sampling: bool = True): + return {} + + async def _build_atomic_attacks_async(self, *, context): + return self._atomic_attacks_to_return + + +def _atomic(*, target, converters=None, name="atomic") -> AtomicAttack: + """A real AtomicAttack sending one objective through an optional converter chain.""" + kwargs = {"objective_target": target} + if converters is not None: + kwargs["attack_converter_config"] = AttackConverterConfig( + request_converters=ConverterConfiguration.from_converters(converters=list(converters)) + ) + return AtomicAttack( + atomic_attack_name=name, + attack_technique=AttackTechnique(attack=PromptSendingAttack(**kwargs)), + seed_groups=[AttackSeedGroup(seeds=[SeedObjective(value=f"objective for {name}")])], + ) + + +def _incompatible(*, name="incompatible") -> AtomicAttack: + """An attack whose converter emits an image into a text-only target.""" + target = get_mock_target(input_modalities=TEXT_ONLY_MODALITIES, output_modalities=TEXT_ONLY_MODALITIES) + return _atomic(target=target, converters=[QRCodeConverter()], name=name) + + +def _compatible(*, name="compatible") -> AtomicAttack: + """An attack whose converter emits an image into a target that accepts images.""" + target = get_mock_target(input_modalities=VISION_INPUT_MODALITIES, output_modalities=TEXT_ONLY_MODALITIES) + return _atomic(target=target, converters=[QRCodeConverter()], name=name) + + +async def _initialize(scenario: Scenario, *, target=None) -> None: + """Fill the parameter bag and initialize.""" + scenario.set_params_from_args(args={"objective_target": target or get_mock_target()}) + await scenario.initialize_async() + + +# --------------------------------------------------------------------------- +# Policy declaration +# --------------------------------------------------------------------------- +def test_modality_policy_defaults_to_skip(): + """Dropping unrunnable attacks is the default, matching BASELINE_ATTACK_POLICY's shape.""" + assert Scenario.MODALITY_POLICY is ModalityPolicy.SKIP + + +def test_modality_policy_is_overridable_per_scenario_class(): + """A scenario subclass can choose a stricter policy.""" + + class _Strict(_PolicyScenario): + MODALITY_POLICY: ClassVar[ModalityPolicy] = ModalityPolicy.RAISE + + assert _Strict.MODALITY_POLICY is ModalityPolicy.RAISE + assert _PolicyScenario.MODALITY_POLICY is ModalityPolicy.SKIP + + +# --------------------------------------------------------------------------- +# SKIP +# --------------------------------------------------------------------------- +async def test_skip_drops_incompatible_and_keeps_compatible(patch_central_database): + """The incompatible attack is removed; the compatible one survives.""" + scenario = _PolicyScenario(atomic_attacks_to_return=[_incompatible(), _compatible()]) + await _initialize(scenario) + assert [attack.atomic_attack_name for attack in scenario._atomic_attacks] == ["compatible"] + + +async def test_skip_keeps_attacks_whose_compatibility_is_unknown(patch_central_database): + """An attack against a target with unreadable capabilities is kept, never dropped.""" + scenario = _PolicyScenario(atomic_attacks_to_return=[_atomic(target=get_mock_target(), name="unknown")]) + await _initialize(scenario) + assert [attack.atomic_attack_name for attack in scenario._atomic_attacks] == ["unknown"] + + +async def test_skip_excludes_dropped_attack_from_display_group_map(patch_central_database): + """Filtering happens before the display-group map is built from the surviving attacks.""" + scenario = _PolicyScenario(atomic_attacks_to_return=[_incompatible(), _compatible()]) + await _initialize(scenario) + assert "incompatible" not in scenario._display_group_map + assert "compatible" in scenario._display_group_map + + +async def test_skip_excludes_dropped_attack_from_persisted_run_plan(patch_central_database): + """The persisted plan records only the attacks that will actually run.""" + scenario = _PolicyScenario(atomic_attacks_to_return=[_incompatible(), _compatible()]) + await _initialize(scenario) + [stored] = scenario._memory.get_scenario_results(scenario_result_ids=[scenario._scenario_result_id]) + planned = {group["atomic_attack_name"] for group in stored.metadata["run_plan"]["atomic_groups"]} + assert planned == {"compatible"} + + +async def test_skip_logs_a_warning_naming_the_attack_and_reason(patch_central_database, caplog): + """Dropping a whole atomic attack is loud even though the policy allows it.""" + scenario = _PolicyScenario(atomic_attacks_to_return=[_incompatible(), _compatible()]) + with caplog.at_level(logging.WARNING, logger="pyrit.scenario.core.scenario"): + await _initialize(scenario) + warnings = [record for record in caplog.records if record.levelno == logging.WARNING] + assert any("incompatible" in record.getMessage() for record in warnings) + assert any("image_path" in record.getMessage() for record in warnings) + + +async def test_skip_raises_when_every_attack_is_dropped(patch_central_database): + """A run that would proceed with zero attacks is a failure, not a success.""" + scenario = _PolicyScenario(atomic_attacks_to_return=[_incompatible(name="a"), _incompatible(name="b")]) + with pytest.raises(ModalityValidationError, match="all"): + await _initialize(scenario) + + +# --------------------------------------------------------------------------- +# WARN +# --------------------------------------------------------------------------- +async def test_warn_keeps_the_attack_and_logs(patch_central_database, caplog): + """WARN surfaces the problem but lets the run proceed.""" + + class _Warn(_PolicyScenario): + MODALITY_POLICY: ClassVar[ModalityPolicy] = ModalityPolicy.WARN + + scenario = _Warn(atomic_attacks_to_return=[_incompatible()]) + with caplog.at_level(logging.WARNING, logger="pyrit.scenario.core.scenario"): + await _initialize(scenario) + assert [attack.atomic_attack_name for attack in scenario._atomic_attacks] == ["incompatible"] + assert any("incompatible" in record.getMessage() for record in caplog.records) + + +# --------------------------------------------------------------------------- +# RAISE +# --------------------------------------------------------------------------- +async def test_raise_aborts_initialization(patch_central_database): + """RAISE stops the run before anything is persisted or queued.""" + + class _Raise(_PolicyScenario): + MODALITY_POLICY: ClassVar[ModalityPolicy] = ModalityPolicy.RAISE + + scenario = _Raise(atomic_attacks_to_return=[_incompatible(), _compatible()]) + with pytest.raises(ModalityValidationError) as excinfo: + await _initialize(scenario) + assert "incompatible" in str(excinfo.value) + assert "image_path" in str(excinfo.value) + + +async def test_raise_error_is_catchable_as_value_error(patch_central_database): + """Existing ``except ValueError`` handlers around initialize_async keep working.""" + + class _Raise(_PolicyScenario): + MODALITY_POLICY: ClassVar[ModalityPolicy] = ModalityPolicy.RAISE + + scenario = _Raise(atomic_attacks_to_return=[_incompatible()]) + with pytest.raises(ValueError): + await _initialize(scenario) + + +async def test_raise_happens_before_any_prompt_is_sent(patch_central_database): + """Fail fast: the target is never contacted when validation rejects the plan.""" + + class _Raise(_PolicyScenario): + MODALITY_POLICY: ClassVar[ModalityPolicy] = ModalityPolicy.RAISE + + attack = _incompatible() + target = attack.attack_technique.attack.get_objective_target() + scenario = _Raise(atomic_attacks_to_return=[attack]) + with pytest.raises(ModalityValidationError): + await _initialize(scenario) + assert target.send_prompt_async.call_count == 0 + + +# --------------------------------------------------------------------------- +# Ordering against the existing static check +# --------------------------------------------------------------------------- +async def test_target_requirements_failure_precedes_modality_validation(patch_central_database): + """ + ``TARGET_REQUIREMENTS`` is resolved before atomic attacks are built. + + A scenario whose target fails the static capability check reports that, not a derived + modality verdict — the static misconfiguration is the more fundamental problem. + """ + + class _NeedsVideo(_PolicyScenario): + TARGET_REQUIREMENTS: ClassVar[TargetRequirements] = TargetRequirements( + required_input_modalities=frozenset({frozenset({"video_path"})}) + ) + MODALITY_POLICY: ClassVar[ModalityPolicy] = ModalityPolicy.RAISE + + scenario = _NeedsVideo(atomic_attacks_to_return=[_incompatible()]) + text_target = get_mock_target(input_modalities=TEXT_ONLY_MODALITIES) + with pytest.raises(ValueError, match="video_path") as excinfo: + await _initialize(scenario, target=text_target) + assert not isinstance(excinfo.value, ModalityValidationError) From 06c1963a5a39c7ef12fce53dc12caa9f474ceca5 Mon Sep 17 00:00:00 2001 From: Victor Valbuena Date: Mon, 21 Sep 2026 19:09:07 -0700 Subject: [PATCH 06/19] DOC: document scenario modality validation Describe ``MODALITY_POLICY`` where scenario behaviour is documented. The scenarios notebook gains a "Modality Validation" section next to the baseline-attack discussion, and the scenario authoring contract gains a section stating what the base class checks, that an indeterminate verdict never blocks a run, and that only turn 0 is covered. The notebook's ``.py`` and ``.ipynb`` are edited together and verified to round-trip through jupytext. Also replaces a Sphinx reST role in the ``modality_validation`` module docstring with a plain literal. PyRIT renders docstrings with MyST, so those roles surface as raw text in the built docs; ``check-no-rest-roles`` enforces this. Co-Authored-By: Claude Opus 5 (1M context) --- .../instructions/scenarios.instructions.md | 18 +++++++++++++++ doc/code/scenarios/0_scenarios.ipynb | 22 ++++++++++++++++++- doc/code/scenarios/0_scenarios.py | 20 +++++++++++++++++ pyrit/scenario/core/modality_validation.py | 2 +- 4 files changed, 60 insertions(+), 2 deletions(-) diff --git a/.github/instructions/scenarios.instructions.md b/.github/instructions/scenarios.instructions.md index e9e18680df..c55556e789 100644 --- a/.github/instructions/scenarios.instructions.md +++ b/.github/instructions/scenarios.instructions.md @@ -46,6 +46,24 @@ the constructor — no classmethod indirection required. abstract extension point every scenario must define (see "AtomicAttack Construction" below). Matrix-shaped scenarios delegate to `build_matrix_atomic_attacks(context=...)` in one line. +## Modality Validation + +`Scenario.initialize_async` validates every `AtomicAttack` returned by +`_build_atomic_attacks_async` before any of them is queued, and applies `MODALITY_POLICY` +(`ModalityPolicy.SKIP` by default, also `WARN` and `RAISE`). Scenario authors get this for free +and normally do not override it. + +- The **request chain** projects each seed group's data types through the attack's request + converters; one of the target's advertised `input_modalities` combinations must cover the result. +- The **response chain** requires the scorer to declare every data type the target may emit. This + is a type check only — it does not establish that the resulting score is meaningful. +- Anything indeterminate is `UNKNOWN` and never blocks a run. +- Only turn 0 is checked. Media routing across later turns belongs to `_ModalityFeedbackRouter`, + which multi-turn attacks consult at execution time. + +Override `MODALITY_POLICY` to `RAISE` when an incompatible pairing means the run is +misconfigured rather than merely narrower than intended. + ## Constructor Pattern ```python diff --git a/doc/code/scenarios/0_scenarios.ipynb b/doc/code/scenarios/0_scenarios.ipynb index 4cb38c0b58..5e912db8d2 100644 --- a/doc/code/scenarios/0_scenarios.ipynb +++ b/doc/code/scenarios/0_scenarios.ipynb @@ -477,7 +477,27 @@ " when an unmodified-prompt comparison is valid but not useful enough to run by default.\n", "- **`Forbidden`** — the baseline is unavailable and passing `include_baseline=True` raises. Use\n", " when the scenario's semantics make a single-shot unmodified prompt meaningless as a comparator\n", - " (e.g., benchmarks comparing across adversarial models, or multi-turn-only scenarios)." + " (e.g., benchmarks comparing across adversarial models, or multi-turn-only scenarios).\n", + "\n", + "### Modality Validation\n", + "\n", + "Before any attack is queued, a scenario checks that each `AtomicAttack` can actually carry its\n", + "payload. The seed's data types are projected through the request converters and must be accepted\n", + "by the objective target, and whatever the target may emit must be readable by the scorer. A\n", + "mismatch — a converter that produces an image for a text-only target, say — is caught during\n", + "`initialize_async` rather than part-way through a run.\n", + "\n", + "`MODALITY_POLICY` decides what happens to an incompatible attack:\n", + "\n", + "- **`SKIP`** (default) — the attack is dropped with a warning and the rest of the run proceeds. If\n", + " every attack is dropped the scenario raises rather than reporting an empty success.\n", + "- **`WARN`** — the attack is kept and the problem is logged.\n", + "- **`RAISE`** — `initialize_async` aborts with `ModalityValidationError`, a `ValueError` subclass.\n", + "\n", + "Compatibility that cannot be determined never blocks a run: a target that does not declare its\n", + "capabilities, an attack that exposes no scoring config, and a scorer that never declared its data\n", + "types are all treated as unknown rather than incompatible. Only the first turn is checked — media\n", + "routing across later turns belongs to the multi-turn attacks themselves, at execution time." ] }, { diff --git a/doc/code/scenarios/0_scenarios.py b/doc/code/scenarios/0_scenarios.py index aa01af4930..0b67c3d470 100644 --- a/doc/code/scenarios/0_scenarios.py +++ b/doc/code/scenarios/0_scenarios.py @@ -194,6 +194,26 @@ async def _build_atomic_attacks_async(self, *, context): # - **`Forbidden`** — the baseline is unavailable and passing `include_baseline=True` raises. Use # when the scenario's semantics make a single-shot unmodified prompt meaningless as a comparator # (e.g., benchmarks comparing across adversarial models, or multi-turn-only scenarios). +# +# ### Modality Validation +# +# Before any attack is queued, a scenario checks that each `AtomicAttack` can actually carry its +# payload. The seed's data types are projected through the request converters and must be accepted +# by the objective target, and whatever the target may emit must be readable by the scorer. A +# mismatch — a converter that produces an image for a text-only target, say — is caught during +# `initialize_async` rather than part-way through a run. +# +# `MODALITY_POLICY` decides what happens to an incompatible attack: +# +# - **`SKIP`** (default) — the attack is dropped with a warning and the rest of the run proceeds. If +# every attack is dropped the scenario raises rather than reporting an empty success. +# - **`WARN`** — the attack is kept and the problem is logged. +# - **`RAISE`** — `initialize_async` aborts with `ModalityValidationError`, a `ValueError` subclass. +# +# Compatibility that cannot be determined never blocks a run: a target that does not declare its +# capabilities, an attack that exposes no scoring config, and a scorer that never declared its data +# types are all treated as unknown rather than incompatible. Only the first turn is checked — media +# routing across later turns belongs to the multi-turn attacks themselves, at execution time. # %% [markdown] # diff --git a/pyrit/scenario/core/modality_validation.py b/pyrit/scenario/core/modality_validation.py index be92396329..7330d03194 100644 --- a/pyrit/scenario/core/modality_validation.py +++ b/pyrit/scenario/core/modality_validation.py @@ -15,7 +15,7 @@ * the **response chain** — every data type the target may emit must be one the scorer declares it can read. -Anything indeterminate resolves to :data:`ModalityVerdict.UNKNOWN` and never blocks a run. A +Anything indeterminate resolves to ``ModalityVerdict.UNKNOWN`` and never blocks a run. A target whose capabilities cannot be read, an attack that exposes no scoring config, and a scorer that never declared its data types are all "cannot tell", not "incompatible". From c79d5fca9c7b1be018caa41f06c806faa4b36be4 Mon Sep 17 00:00:00 2001 From: Victor Valbuena Date: Tue, 22 Sep 2026 11:38:06 -0700 Subject: [PATCH 07/19] FIX: keep the modality capability guard under ty 0.0.80 Merging main brought a dependency bump that raises ty from 0.0.78 to 0.0.80, which adds the ``redundant-condition-strict`` rule. It flags the ``isinstance(value, frozenset)`` check in ``_read_modalities``: ``TargetCapabilities`` validates that field, so statically the condition is always true. The check is not redundant at runtime. A target whose capabilities were never configured -- every ``MagicMock(spec=PromptTarget)`` in the suite -- yields a mock there rather than a frozenset, and iterating it produces an empty combination set that would read as incompatible and drop the attack. The guard is what turns that case into an indeterminate verdict instead. Suppress the rule on that line with a comment explaining why the redundancy is deliberate, matching the reasoning behind the existing path-scoped override for the Alembic revisions' defensive checks. Co-Authored-By: Claude Opus 5 (1M context) --- pyrit/scenario/core/modality_validation.py | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/pyrit/scenario/core/modality_validation.py b/pyrit/scenario/core/modality_validation.py index 7330d03194..84f6f31fef 100644 --- a/pyrit/scenario/core/modality_validation.py +++ b/pyrit/scenario/core/modality_validation.py @@ -404,8 +404,12 @@ def _read_modalities(*, target: PromptTarget, direction: str) -> frozenset[froze value = capabilities.input_modalities if direction == "input" else capabilities.output_modalities except AttributeError: return None - # A test double that never configured capabilities yields a mock here, not a frozenset. - return value if isinstance(value, frozenset) else None + # Statically this is always a frozenset, because ``TargetCapabilities`` validates it, and ty + # says so. At runtime a test double that never configured capabilities yields a mock instead, + # and this guard is what turns that into "unknown" rather than an empty combination set that + # would wrongly read as incompatible. Deliberately redundant, like the defensive checks the + # Alembic revisions keep. + return value if isinstance(value, frozenset) else None # ty: ignore[redundant-condition-strict] def _format_modalities(modalities: frozenset[frozenset[PromptDataType]] | None) -> str: From f99617a4febc296ff6b8bc382e84782e3dab8ca3 Mon Sep 17 00:00:00 2001 From: Victor Valbuena Date: Tue, 22 Sep 2026 13:29:45 -0700 Subject: [PATCH 08/19] MAINT: read target input modalities literally in modality validation ``target_accepts`` previously accepted a request whose data types were a *subset* of some advertised input combination, with a special case borrowed from ``_ModalityFeedbackRouter`` so that a text-only request to a target advertising no bare ``{text}`` combination was still rejected. That rule was compensating for a test fixture that under-declared what real targets advertise: every vision profile in ``_KNOWN_CAPABILITIES`` and the ``OpenAIChatTarget`` default list ``{image_path}`` on its own, not just ``{text, image_path}``. Read the declarations literally instead: the request's set of data types must be exactly one of the advertised combinations. ``{text, image_path}`` means "text with an image", not "an image alone". This is simpler, needs no special case for the router's edit-only rule (a bare ``{text}`` request against ``{{text, image_path}}`` is simply not a match), and is more correct for targets that honestly omit a lone-media shape, such as video generation, where the subset test would have accepted a bare reference image that the API rejects. The vision test profile gains ``{image_path}`` to match production declarations. Three acceptance tests are renamed to say what they now assert, and one is added to pin that a lone image needs its own advertised combination. Targets whose defaults omit a lone-media combination are left as they are; a request that needs one will surface as incompatible and the declaration can be completed where it is genuinely missing. Co-Authored-By: Claude Fable 5.1 --- .../instructions/scenarios.instructions.md | 4 ++- pyrit/scenario/core/modality_validation.py | 29 ++++++++----------- tests/unit/modality_profiles.py | 9 ++++-- .../scenario/core/test_modality_validation.py | 24 +++++++++++---- tests/unit/test_mock_target.py | 9 ++++-- 5 files changed, 47 insertions(+), 28 deletions(-) diff --git a/.github/instructions/scenarios.instructions.md b/.github/instructions/scenarios.instructions.md index c55556e789..025983eb1b 100644 --- a/.github/instructions/scenarios.instructions.md +++ b/.github/instructions/scenarios.instructions.md @@ -54,7 +54,9 @@ the constructor — no classmethod indirection required. and normally do not override it. - The **request chain** projects each seed group's data types through the attack's request - converters; one of the target's advertised `input_modalities` combinations must cover the result. + converters; the resulting set of data types must be exactly one of the target's advertised + `input_modalities` combinations. Declarations are read literally — a target that accepts a + lone image advertises `{image_path}` as well as `{text, image_path}`. - The **response chain** requires the scorer to declare every data type the target may emit. This is a type check only — it does not establish that the resulting score is meaningful. - Anything indeterminate is `UNKNOWN` and never blocks a run. diff --git a/pyrit/scenario/core/modality_validation.py b/pyrit/scenario/core/modality_validation.py index 84f6f31fef..85aea6ebb9 100644 --- a/pyrit/scenario/core/modality_validation.py +++ b/pyrit/scenario/core/modality_validation.py @@ -11,7 +11,7 @@ Two chains are checked, both for turn 0 only: * the **request chain** — the seed's own data types, projected through the request converters, - must be covered by one of the objective target's advertised input modality combinations; + must be exactly one of the objective target's advertised input modality combinations; * the **response chain** — every data type the target may emit must be one the scorer declares it can read. @@ -168,9 +168,13 @@ def target_accepts(*, target: PromptTarget, request_types: set[PromptDataType]) """ Check a projected request against a target's advertised input modality combinations. - A target advertises *combinations* it accepts in a single request, so one combination must - cover every projected type at once. Types spread across two combinations do not satisfy a - request that carries both. + A target advertises the *combinations* of data types it accepts in a single request, and the + request's own set of data types must be exactly one of them. ``{text, image_path}`` means + "text with an image", not "an image alone": a target that accepts a lone image also + advertises ``{image_path}``, as the vision profiles do, while a video-generation target that + needs a prompt does not. Reading the declarations literally keeps plan-time in step with + ``_ModalityFeedbackRouter``, which treats a missing bare ``{text}`` combination as "media + required on every request". Args: target (PromptTarget): The objective target. @@ -178,25 +182,16 @@ def target_accepts(*, target: PromptTarget, request_types: set[PromptDataType]) Returns: ModalityVerdict: ``UNKNOWN`` when the target's capabilities cannot be read, otherwise - whether some advertised combination covers ``request_types``. + whether ``request_types`` is one of the advertised combinations. """ supported = _read_modalities(target=target, direction="input") if supported is None: return ModalityVerdict.UNKNOWN if not request_types: return ModalityVerdict.INCOMPATIBLE - - requested = frozenset(request_types) - - # A target that advertises no bare ``{"text"}`` combination requires media on every request - # -- an image-edit model, for example. ``_ModalityFeedbackRouter`` reads the same signal to - # decide whether turn 0 can be constructed, and raises when it cannot, so a text-only - # request is genuinely unrunnable rather than merely unadvertised. - if requested == frozenset({"text"}) and frozenset({"text"}) not in supported: - return ModalityVerdict.INCOMPATIBLE - - covered = any(requested <= combination for combination in supported) - return ModalityVerdict.COMPATIBLE if covered else ModalityVerdict.INCOMPATIBLE + if frozenset(request_types) in supported: + return ModalityVerdict.COMPATIBLE + return ModalityVerdict.INCOMPATIBLE def scorer_accepts(*, scorer: Scorer | None, target: PromptTarget) -> tuple[ModalityVerdict, str | None]: diff --git a/tests/unit/modality_profiles.py b/tests/unit/modality_profiles.py index ba69558475..3dd7c38b8f 100644 --- a/tests/unit/modality_profiles.py +++ b/tests/unit/modality_profiles.py @@ -18,8 +18,13 @@ #: Text in, text out. Matches the production default on ``TargetCapabilities``. TEXT_ONLY_MODALITIES: frozenset[frozenset[PromptDataType]] = modality_combos({"text"}) -#: A vision chat model: text alone, or text accompanied by an image. -VISION_INPUT_MODALITIES: frozenset[frozenset[PromptDataType]] = modality_combos({"text"}, {"text", "image_path"}) +#: A vision chat model: text alone, an image alone, or both together — the shape every vision +#: entry in ``_KNOWN_CAPABILITIES`` and the ``OpenAIChatTarget`` default declare. Each accepted +#: shape is listed explicitly; ``{text, image_path}`` on its own would mean "an image only with +#: text", which is what a video-generation target declares. +VISION_INPUT_MODALITIES: frozenset[frozenset[PromptDataType]] = modality_combos( + {"text"}, {"image_path"}, {"text", "image_path"} +) #: An image-edit model: media is required on *every* request — there is no bare ``{"text"}`` combo. IMAGE_EDIT_INPUT_MODALITIES: frozenset[frozenset[PromptDataType]] = modality_combos({"text", "image_path"}) diff --git a/tests/unit/scenario/core/test_modality_validation.py b/tests/unit/scenario/core/test_modality_validation.py index 2323a5d7ac..72c367bef2 100644 --- a/tests/unit/scenario/core/test_modality_validation.py +++ b/tests/unit/scenario/core/test_modality_validation.py @@ -254,24 +254,38 @@ def test_target_accepts_exact_combo_is_compatible(): assert target_accepts(target=target, request_types={"text"}) is ModalityVerdict.COMPATIBLE -def test_target_accepts_subset_of_combo_is_compatible(): - """A projection that is a subset of an advertised combo is compatible.""" +def test_target_accepts_advertised_multi_type_combo_is_compatible(): + """A text-plus-image request matches a target that advertises that exact combination.""" target = get_mock_target(input_modalities=VISION_INPUT_MODALITIES) assert target_accepts(target=target, request_types={"text", "image_path"}) is ModalityVerdict.COMPATIBLE -def test_target_accepts_no_covering_combo_is_incompatible(): +def test_target_accepts_no_matching_combo_is_incompatible(): """The canonical failure: an image reaches a text-only target.""" target = get_mock_target(input_modalities=TEXT_ONLY_MODALITIES) assert target_accepts(target=target, request_types={"image_path"}) is ModalityVerdict.INCOMPATIBLE -def test_target_accepts_requires_one_combo_to_cover_all_types(): - """Types spread across two separate combos do not satisfy a single request.""" +def test_target_accepts_types_split_across_combos_is_incompatible(): + """Types advertised only separately do not satisfy one request that carries both.""" target = get_mock_target(input_modalities=[{"text"}, {"image_path"}]) assert target_accepts(target=target, request_types={"text", "image_path"}) is ModalityVerdict.INCOMPATIBLE +def test_target_accepts_lone_media_needs_its_own_advertised_combo(): + """ + A lone image is acceptable only if the target advertises ``{image_path}`` by itself. + + ``{text, image_path}`` means "text with an image", not "an image alone". A video-generation + target declares exactly that shape and genuinely cannot take a bare reference image; a + vision chat model that can take one declares ``{image_path}`` too, and is accepted. + """ + text_with_image_only = get_mock_target(input_modalities=[{"text"}, {"text", "image_path"}]) + assert target_accepts(target=text_with_image_only, request_types={"image_path"}) is ModalityVerdict.INCOMPATIBLE + vision = get_mock_target(input_modalities=VISION_INPUT_MODALITIES) + assert target_accepts(target=vision, request_types={"image_path"}) is ModalityVerdict.COMPATIBLE + + def test_target_accepts_edit_only_target_with_media_seed_is_compatible(): """ An image-edit target advertises only ``{text, image_path}``. diff --git a/tests/unit/test_mock_target.py b/tests/unit/test_mock_target.py index 6c71a6e4f8..34e5cb67a0 100644 --- a/tests/unit/test_mock_target.py +++ b/tests/unit/test_mock_target.py @@ -221,9 +221,12 @@ def test_profile_text_only(): assert TargetCapabilities().input_modalities == TEXT_ONLY_MODALITIES # matches production default -def test_profile_vision_input_allows_bare_text_and_text_with_image(): - assert VISION_INPUT_MODALITIES == _TEXT_AND_IMAGE - assert frozenset({"text"}) in VISION_INPUT_MODALITIES +def test_profile_vision_input_declares_text_image_and_both(): + """Matches ``_TEXT_IMAGE_INPUT`` in ``target_capabilities.py``: every accepted shape is explicit.""" + assert frozenset({frozenset({"text"}), frozenset({"image_path"}), frozenset({"text", "image_path"})}) == ( + VISION_INPUT_MODALITIES + ) + assert frozenset({"image_path"}) in VISION_INPUT_MODALITIES def test_profile_image_edit_input_has_no_bare_text_combo(): From 0e9aa2dbe73975442c3a68a1025c6330881910d0 Mon Sep 17 00:00:00 2001 From: Victor Valbuena Date: Tue, 22 Sep 2026 18:19:34 -0700 Subject: [PATCH 09/19] Fix indexed request modality projection Preserve message-piece positions through converter chains and compare the final message types with target capabilities. Document the piece/message boundary and track remaining review findings. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 24c08221-0382-4f39-aeda-ec3baac3ff1a --- .../instructions/scenarios.instructions.md | 8 +- REVIEWER_COMMENTS.md | 141 ++++++++++++++++++ .../scenario/core/attack_technique_factory.py | 3 +- pyrit/scenario/core/modality_validation.py | 92 ++++++------ .../scenario/core/test_modality_validation.py | 102 ++++++++++--- 5 files changed, 276 insertions(+), 70 deletions(-) create mode 100644 REVIEWER_COMMENTS.md diff --git a/.github/instructions/scenarios.instructions.md b/.github/instructions/scenarios.instructions.md index 025983eb1b..9f5756da71 100644 --- a/.github/instructions/scenarios.instructions.md +++ b/.github/instructions/scenarios.instructions.md @@ -55,8 +55,12 @@ and normally do not override it. - The **request chain** projects each seed group's data types through the attack's request converters; the resulting set of data types must be exactly one of the target's advertised - `input_modalities` combinations. Declarations are read literally — a target that accepts a - lone image advertises `{image_path}` as well as `{text, image_path}`. + `input_modalities` combinations. Conversion selection (including `indexes_to_apply`) is + **per message piece**: preserve ordered pieces through each converter configuration, then + collapse their resulting types to a **message-level set** for target compatibility. A single + text piece selected at index 0 and converted to an image leaves no text; selecting only + index 0 of two text pieces leaves text in the second piece. Declarations are read literally — + a target that accepts a lone image advertises `{image_path}` as well as `{text, image_path}`. - The **response chain** requires the scorer to declare every data type the target may emit. This is a type check only — it does not establish that the resulting score is meaningful. - Anything indeterminate is `UNKNOWN` and never blocks a run. diff --git a/REVIEWER_COMMENTS.md b/REVIEWER_COMMENTS.md new file mode 100644 index 0000000000..f165f88b19 --- /dev/null +++ b/REVIEWER_COMMENTS.md @@ -0,0 +1,141 @@ +# PR #2773 reviewer-comment tracker + +Source: Copilot review overview and inline comments, plus Roman Lutz's review, +provided by the user. Fact-checked against local HEAD `c6c07bce1` on +`multimodal`; this does not independently verify a newer remote PR head. +The C1 fix is recorded below; the other five findings remain open. + +| ID | Reviewer | Finding | Verdict | Follow-up | +| --- | --- | --- | --- | --- | +| C1 | Copilot | Fully selected `indexes_to_apply` incorrectly preserves the original request type. | **Fixed locally** | Ordered piece projection and regressions for all-selected and partially selected messages. | +| C2 | Copilot | Resume validates unsampled attacks before restoring persisted seed groups. | **Confirmed** | Replay the stored selection before checking modalities; cover sampled and legacy resume. | +| R1 | Roman Lutz | Composite scorer intersects child modalities although non-applicable children can return `[]`. | **Confirmed** | Reflect actual child applicability, including disjoint modalities; test a real score. | +| R2 | Roman Lutz | Response check requires every target output type, unlike selective message scoring. | **Confirmed** | Compare per-response combinations with the scorer's actual filtering/strictness policy. | +| R3 | Roman Lutz | Response check omits configured response converters. | **Confirmed** | Project the response converter chain before scoring, or report `UNKNOWN` if indeterminate. | +| R4 | Roman Lutz | A wrapper without `next_message` is assumed to send text. | **Confirmed** | Inspect the actual child contract or return `UNKNOWN`; cover a media-seeded sequential attack. | + +The review overview's phrase "compound attacks" refers to R1 and R4; its +request for a fresh Copilot review is a workflow suggestion, not another finding. + +## Evidence and scope + +### C1 - fully selected indexes + +**The distinction:** conversion selects and changes **individual pieces by +index** (`pyrit/prompt_normalizer/prompt_normalizer.py:279-288`), but targets +declare compatibility with the **set of types in the complete message**. +The old projection discarded positions too early: a one-piece text message +and a two-piece text message both looked like `{"text"}`. It then assumed +any nonempty index selection left an unconverted type, even when `[0]` +selected the only piece. + +The local fix feeds the ordered first-message pieces into +`project_request_chain`, preserves their positions across converter +configurations, and combines the resulting types only before checking the +target. `[0]` on one text piece produces `{"image_path"}`; `[0]` on two +text pieces produces `{"image_path", "text"}`; `[0, 1]` on two produces +`{"image_path"}`. Factory-level converter checks have no concrete message +and deliberately retain their previous conservative branch projection via +`piece_indexes_known=False`. Runtime converter behavior is unchanged. + +### C2 - resumed plan + +`initialize_async` resolves all seed groups on resume and applies modality policy +to the rebuilt attacks **before** it reads the stored run plan and restores its +seed groups (`pyrit/scenario/core/scenario.py:941-978`). The policy checks every +seed group and drops the whole atomic attack when any is incompatible +(`modality_validation.py:258-299`, `scenario.py:1441-1485`). A formerly +unsampled incompatible group can therefore remove a planned atomic group, +which `_apply_persisted_run_plan` then declares unreconstructable +(`scenario.py:1133-1162`). Legacy runs without a stored plan instead narrow +by persisted objective hashes at `scenario.py:1167-1208`, also too late. +Existing resume coverage checks sample replay with modality-neutral groups +(`tests/unit/scenario/core/test_scenario.py:1287-1353`), not this interaction. +**Regression:** resume a sampled, persisted plan with an incompatible +unsampled row in the same atomic attack; repeat for legacy objective-hash replay. + +### R1 - composite scorer + +`TrueFalseCompositeScorer.supported_data_types` intersects every child's +declaration (`pyrit/score/true_false/true_false_composite_scorer.py:75-93`). +At runtime it filters out non-applicable child results before aggregating +(`true_false_composite_scorer.py:145-174`). A message scorer with no supported +piece returns `[]` rather than a negative score (`pyrit/score/message_scorer.py:1150-1170`, +`pyrit/score/true_false/true_false_scorer.py:170-185`); an existing test confirms +the composite ignores a non-applicable child, albeit via role filtering rather +than disjoint modalities +(`tests/unit/score/test_true_false_composite_scorer.py:215-225`). +Text-only and image-only children report an empty intersection even though the +text child can score a text response. The plan-time check rejects the empty +declaration (`modality_validation.py:214-229`). **Regression:** disjoint text +and image children, both the declared capability and an actual text score. +Keep `None` (undeclared child capability) distinct from an empty declared set. + +### R2 - selective response scoring + +The plan-time check flattens all target output combinations into a union and +requires every emitted type to be declared by the scorer +(`pyrit/scenario/core/modality_validation.py:197-229`). The default +`ScorerPromptValidator` does **not** require every piece to be valid +(`pyrit/score/scorer_prompt_validator.py:26-34,105-130`); `MessageScorer` +filters unsupported pieces before scoring +(`pyrit/score/message_scorer.py:1150-1170,1264-1269`). For a response that +contains both text and audio, `SubStringScorer` can read the text +(`pyrit/score/true_false/substring_scorer.py:23,64-84`), yet plan-time marks +the pair incompatible. The reviewer is right about this case; merely +flattening alternatives also loses whether the target might emit **audio +alone**, which a text scorer cannot meaningfully score. **Regression:** mixed +text/audio response with a default text scorer and the corresponding +`enforce_all_pieces_valid=True` validator; separately assess audio-only +output rather than declaring every target/scorer pairing compatible. + +### R3 - response converters + +`validate_atomic_attack` passes the raw target straight to `scorer_accepts` +(`pyrit/scenario/core/modality_validation.py:244-291`); the latter only +examines declared target output and scorer types (`modality_validation.py:197-229`). +But `PromptSendingAttack` configures response converters and passes them to +`PromptNormalizer` (`pyrit/executor/attack/single_turn/prompt_sending.py:95-103,325-339`). +The normalizer converts the **last** returned response before returning it +to the attack for scoring (`pyrit/prompt_normalizer/prompt_normalizer.py:186-210`, +`pyrit/executor/attack/single_turn/prompt_sending.py:341-375`). An +audio-to-text converter advertises `audio_path` input and `text` output +(`pyrit/converter/azure_speech_audio_to_text_converter.py:22-38`), illustrating +the reviewer example without needing a live Azure service. **Regression:** +offline audio-to-text conversion with an audio-output target and text scorer; +ensure opaque/conditional/indexed conversions produce `UNKNOWN` where their +final type cannot safely be inferred. The current validation has no explicit +response-converter accessor on `AttackStrategy`. + +### R4 - sequential wrapper + +`_reads_next_message` infers whether the first request is text solely from the +presence of the `next_message` parameter, and `_effective_start_types` returns +`{"text"}` whenever it is absent +(`pyrit/scenario/core/modality_validation.py:322-357`). +`SequentialAttack` deliberately excludes that parameter **because its child +attacks own their own seed groups**; it dispatches each child and its seed to +`AttackExecutor` (`pyrit/executor/attack/compound/sequential_attack.py:217-237,248-259,289-335`). +`AdaptiveTechniqueDispatcher` builds real `SequentialChildAttack` instances +from seed groups (`pyrit/scenario/scenarios/adaptive/dispatcher.py:212-234`), +and `AdaptiveScenario` wraps those attacks in `AtomicAttack` +(`pyrit/scenario/scenarios/adaptive/adaptive_scenario.py:466-495`). +Consequently a media-seeded child can target an image-only endpoint while the +outer wrapper is falsely projected as sending text. **Regression:** actual +media-seeded `SequentialChildAttack` and image-only child target; the compound +must not be rejected by the wrapper's invented request type. A wrapper may +also have child targets different from its nominal target, so projecting only +against that nominal target is not sufficient. + +## Verification performed + +The initial fact-check ran four existing tests (4 passed, 70 deselected): +`test_project_request_chain_indexes_to_apply_keeps_both_branches`, +`test_scorer_accepts_missing_emitted_type_is_incompatible`, +`test_composite_scorer_ignores_non_applicable_child`, and +`test_skip_excludes_dropped_attack_from_persisted_run_plan`. The C1 change +adds all-selected, partially selected, chained-index, unknown-index, and +atomic-attack regression coverage. The targeted modality, factory, and +scenario-policy files now pass together (144 passed), the broader scenario +core tests pass (538 passed), and targeted Ruff and `ty` checks pass. The +remaining findings are not fixed. diff --git a/pyrit/scenario/core/attack_technique_factory.py b/pyrit/scenario/core/attack_technique_factory.py index 2fc4063323..ebc230027e 100644 --- a/pyrit/scenario/core/attack_technique_factory.py +++ b/pyrit/scenario/core/attack_technique_factory.py @@ -489,8 +489,9 @@ def can_append_request_converter(self, *, converter_type: type[Converter]) -> bo converter_config = self._attack_kwargs.get("attack_converter_config") request_converters = converter_config.request_converters if converter_config is not None else [] output_types, failure_reason = project_request_chain( - start_types={"text"}, + start_types=["text"], request_converters=request_converters, + piece_indexes_known=False, ) if failure_reason is not None: return False diff --git a/pyrit/scenario/core/modality_validation.py b/pyrit/scenario/core/modality_validation.py index 85aea6ebb9..68b27e3a4c 100644 --- a/pyrit/scenario/core/modality_validation.py +++ b/pyrit/scenario/core/modality_validation.py @@ -32,7 +32,7 @@ from typing import TYPE_CHECKING if TYPE_CHECKING: - from collections.abc import Iterable, Sequence + from collections.abc import Sequence from pyrit.executor.attack.core.attack_strategy import AttackStrategy from pyrit.models import AttackSeedGroup, AttackTechniqueSeedGroup @@ -105,62 +105,64 @@ class ModalityReport: def project_request_chain( *, - start_types: Iterable[PromptDataType], + start_types: Sequence[PromptDataType], request_converters: Sequence[ConverterConfiguration], + piece_indexes_known: bool = True, ) -> tuple[set[PromptDataType], str | None]: """ - Project ``start_types`` through a request-converter chain. + Project the ordered piece types through a request-converter chain. - Each configuration is applied in order. A configuration that declares - ``prompt_data_types_to_apply`` only converts the types it lists; any other type passes - through unchanged. A configuration with ``indexes_to_apply`` converts only some pieces, so - the unconverted type survives alongside the converted one and both can reach the target. + Converter selection is per piece; target compatibility is checked against the resulting + message-level set of types. Retain piece positions until every configuration has run. + When the caller cannot know the message's indexes, an indexed configuration conservatively + retains both the converted and original types. Args: - start_types (Iterable[PromptDataType]): The data types the request starts with — the - pieces of the first message sent to the objective target. + start_types (Sequence[PromptDataType]): The ordered types of the request's message pieces. request_converters (Sequence[ConverterConfiguration]): The request converter chain, in application order. + piece_indexes_known (bool): Whether ``start_types`` includes every actual piece in order. + Set to ``False`` when projecting a factory without a concrete message. Returns: tuple[set[PromptDataType], str | None]: The data types that can reach the target, and ``None``; or an empty set and a message naming the first converter that could not accept the type reaching it. """ - output_types: set[PromptDataType] = set(start_types) + piece_types: list[set[PromptDataType]] = [{data_type} for data_type in start_types] for configuration in request_converters: - next_output_types: set[PromptDataType] = set() - - # Sorted for a deterministic failure message when several start types are in play. - for output_type in sorted(output_types): - applies_to_type = ( - not configuration.prompt_data_types_to_apply or output_type in configuration.prompt_data_types_to_apply - ) - if not applies_to_type: - next_output_types.add(output_type) + for index, types in enumerate(piece_types): + if piece_indexes_known and configuration.indexes_to_apply and index not in configuration.indexes_to_apply: continue - converted_types: set[PromptDataType] = {output_type} - for built_in_converter in configuration.converters: - unsupported = sorted( - data_type for data_type in converted_types if not built_in_converter.input_supported(data_type) - ) - if unsupported: - return set(), ( - f"{type(built_in_converter).__name__} does not accept {unsupported}; " - f"it accepts {sorted(built_in_converter.supported_input_types)}" - ) - converted_types = set(built_in_converter.supported_output_types) - - next_output_types.update(converted_types) - - # Partial application leaves some pieces unconverted, so the original type survives. - if configuration.indexes_to_apply: - next_output_types.add(output_type) - - output_types = next_output_types - + next_types: set[PromptDataType] = set() + for data_type in sorted(types): + if ( + configuration.prompt_data_types_to_apply + and data_type not in configuration.prompt_data_types_to_apply + ): + next_types.add(data_type) + continue + + converted_types: set[PromptDataType] = {data_type} + for converter in configuration.converters: + unsupported = sorted(t for t in converted_types if not converter.input_supported(t)) + if unsupported: + return set(), ( + f"{type(converter).__name__} does not accept {unsupported}; " + f"it accepts {sorted(converter.supported_input_types)}" + ) + converted_types = set(converter.supported_output_types) + next_types.update(converted_types) + + if not piece_indexes_known and configuration.indexes_to_apply: + next_types.add(data_type) + piece_types[index] = next_types + + output_types: set[PromptDataType] = set() + for types in piece_types: + output_types.update(types) return output_types, None @@ -341,7 +343,7 @@ def _effective_start_types( seed_group: AttackSeedGroup, seed_technique: AttackTechniqueSeedGroup | None, reads_next_message: bool, -) -> set[PromptDataType] | None: +) -> list[PromptDataType] | None: """ Determine the data types the first request carries for one seed group. @@ -351,10 +353,10 @@ def _effective_start_types( that pairing is rejected elsewhere and is not a modality problem. Returns: - set[PromptDataType] | None: The starting data types, or ``None`` when undeterminable. + list[PromptDataType] | None: The ordered starting piece types, or ``None`` when undeterminable. """ if not reads_next_message: - return {"text"} + return ["text"] group = seed_group if seed_technique is not None: @@ -371,14 +373,14 @@ def _effective_start_types( if message is None: # No prompts on the group: the attack sends the objective text. - return {"text"} + return ["text"] try: - types = {piece.converted_value_data_type for piece in message.message_pieces} + types = [piece.converted_value_data_type for piece in message.message_pieces] except (AttributeError, TypeError): return None - return types or {"text"} + return types or ["text"] def _read_modalities(*, target: PromptTarget, direction: str) -> frozenset[frozenset[PromptDataType]] | None: diff --git a/tests/unit/scenario/core/test_modality_validation.py b/tests/unit/scenario/core/test_modality_validation.py index 72c367bef2..0ec266fd43 100644 --- a/tests/unit/scenario/core/test_modality_validation.py +++ b/tests/unit/scenario/core/test_modality_validation.py @@ -83,6 +83,7 @@ def _atomic( *, target, converters=None, + converter_configurations=None, scorer=None, seed_groups=None, seed_technique=None, @@ -91,9 +92,13 @@ def _atomic( ) -> AtomicAttack: """Build a real AtomicAttack around a PromptSendingAttack with the given wiring.""" kwargs = {"objective_target": target} - if converters is not None: + if converters is not None or converter_configurations is not None: kwargs["attack_converter_config"] = AttackConverterConfig( - request_converters=ConverterConfiguration.from_converters(converters=list(converters)) + request_converters=( + converter_configurations + if converter_configurations is not None + else ConverterConfiguration.from_converters(converters=list(converters)) + ) ) if scorer is not None: kwargs["attack_scoring_config"] = AttackScoringConfig(objective_scorer=scorer) @@ -112,21 +117,21 @@ def _atomic( # --------------------------------------------------------------------------- def test_project_request_chain_no_converters_returns_start_types(): """An empty chain passes the start types through untouched.""" - projected, reason = project_request_chain(start_types={"text"}, request_converters=[]) + projected, reason = project_request_chain(start_types=["text"], request_converters=[]) assert projected == {"text"} assert reason is None def test_project_request_chain_text_converter_preserves_text(): """A text-to-text converter leaves the projected type as text.""" - projected, reason = project_request_chain(start_types={"text"}, request_converters=_configs(Base64Converter())) + projected, reason = project_request_chain(start_types=["text"], request_converters=_configs(Base64Converter())) assert projected == {"text"} assert reason is None def test_project_request_chain_image_converter_yields_image_path(): """A text-to-image converter shifts the projected type to image_path.""" - projected, reason = project_request_chain(start_types={"text"}, request_converters=_configs(QRCodeConverter())) + projected, reason = project_request_chain(start_types=["text"], request_converters=_configs(QRCodeConverter())) assert projected == {"image_path"} assert reason is None @@ -134,7 +139,7 @@ def test_project_request_chain_image_converter_yields_image_path(): def test_project_request_chain_audio_converter_preserves_audio(): """An audio-to-audio converter accepts an audio start and keeps it.""" projected, reason = project_request_chain( - start_types={"audio_path"}, request_converters=_configs(AudioEchoConverter()) + start_types=["audio_path"], request_converters=_configs(AudioEchoConverter()) ) assert projected == {"audio_path"} assert reason is None @@ -143,7 +148,7 @@ def test_project_request_chain_audio_converter_preserves_audio(): def test_project_request_chain_two_converters_chain(): """Consecutive converters compose: text becomes an image, which the next converter accepts.""" projected, reason = project_request_chain( - start_types={"text"}, + start_types=["text"], request_converters=_configs(QRCodeConverter(), ImageCompressionConverter()), ) assert projected == {"image_path"} @@ -153,7 +158,7 @@ def test_project_request_chain_two_converters_chain(): def test_project_request_chain_image_converter_accepts_image_start(): """A converter declaring image_path input accepts an image start with no bridge needed.""" projected, reason = project_request_chain( - start_types={"image_path"}, request_converters=_configs(ImageCompressionConverter()) + start_types=["image_path"], request_converters=_configs(ImageCompressionConverter()) ) assert projected == {"image_path"} assert reason is None @@ -165,7 +170,7 @@ def test_project_request_chain_image_converter_accepts_image_start(): def test_project_request_chain_unsupported_input_names_converter(): """A converter that cannot accept the current type breaks the chain and is named in the reason.""" projected, reason = project_request_chain( - start_types={"image_path"}, request_converters=_configs(Base64Converter()) + start_types=["image_path"], request_converters=_configs(Base64Converter()) ) assert projected == set() assert reason is not None @@ -176,7 +181,7 @@ def test_project_request_chain_unsupported_input_names_converter(): def test_project_request_chain_reports_first_failure_only(): """The first converter that cannot accept the current type explains the failure.""" _, reason = project_request_chain( - start_types={"text"}, + start_types=["text"], request_converters=_configs(QRCodeConverter(), Base64Converter()), ) assert reason is not None @@ -186,7 +191,7 @@ def test_project_request_chain_reports_first_failure_only(): def test_project_request_chain_empty_start_returns_empty(): """No start types means nothing can reach the target.""" - projected, reason = project_request_chain(start_types=set(), request_converters=_configs(Base64Converter())) + projected, reason = project_request_chain(start_types=[], request_converters=_configs(Base64Converter())) assert projected == set() assert reason is None @@ -194,7 +199,7 @@ def test_project_request_chain_empty_start_returns_empty(): def test_project_request_chain_multi_type_start_fails_when_one_branch_breaks(): """A converter that applies to a type it cannot accept breaks the whole chain.""" projected, reason = project_request_chain( - start_types={"text", "image_path"}, + start_types=["text", "image_path"], request_converters=_configs(Base64Converter()), ) assert projected == set() @@ -208,7 +213,7 @@ def test_project_request_chain_multi_type_start_fails_when_one_branch_breaks(): def test_project_request_chain_conditional_not_applying_preserves_type(): """When ``prompt_data_types_to_apply`` excludes the current type the piece passes through unchanged.""" config = ConverterConfiguration(converters=[QRCodeConverter()], prompt_data_types_to_apply=["image_path"]) - projected, reason = project_request_chain(start_types={"text"}, request_converters=[config]) + projected, reason = project_request_chain(start_types=["text"], request_converters=[config]) assert projected == {"text"} assert reason is None @@ -216,29 +221,55 @@ def test_project_request_chain_conditional_not_applying_preserves_type(): def test_project_request_chain_conditional_applying_converts(): """When ``prompt_data_types_to_apply`` includes the current type the converter runs.""" config = ConverterConfiguration(converters=[QRCodeConverter()], prompt_data_types_to_apply=["text"]) - projected, reason = project_request_chain(start_types={"text"}, request_converters=[config]) + projected, reason = project_request_chain(start_types=["text"], request_converters=[config]) assert projected == {"image_path"} assert reason is None -def test_project_request_chain_indexes_to_apply_keeps_both_branches(): - """ - Partial application branches the projection. +@pytest.mark.parametrize( + ("start_types", "indexes", "expected"), + [ + (["text"], [0], {"image_path"}), + (["text", "text"], [0], {"image_path", "text"}), + (["text", "text"], [0, 1], {"image_path"}), + (["text", "text"], [1], {"image_path", "text"}), + (["text", "text"], [2], {"text"}), + ], +) +def test_project_request_chain_indexes_to_apply_tracks_pieces(start_types, indexes, expected): + """Only the pieces not selected by an indexed converter retain their old type.""" + config = ConverterConfiguration(converters=[QRCodeConverter()], indexes_to_apply=indexes) + projected, reason = project_request_chain(start_types=start_types, request_converters=[config]) + assert projected == expected + assert reason is None - With ``indexes_to_apply`` set only some pieces are converted, so both the converted and the - unconverted type can reach the target and the target must accept both. - """ + +def test_project_request_chain_unknown_indexes_keeps_possible_branches(): + """A factory without a concrete message must allow for unselected pieces.""" config = ConverterConfiguration(converters=[QRCodeConverter()], indexes_to_apply=[0]) - projected, reason = project_request_chain(start_types={"text"}, request_converters=[config]) + projected, reason = project_request_chain( + start_types=["text"], request_converters=[config], piece_indexes_known=False + ) assert projected == {"text", "image_path"} assert reason is None +def test_project_request_chain_indexed_conversion_preserves_position_across_configurations(): + """Later indexed configurations select the same message piece after an earlier conversion.""" + configs = [ + ConverterConfiguration(converters=[QRCodeConverter()], indexes_to_apply=[0]), + ConverterConfiguration(converters=[ImageCompressionConverter()], indexes_to_apply=[0]), + ] + projected, reason = project_request_chain(start_types=["text", "text"], request_converters=configs) + assert projected == {"image_path", "text"} + assert reason is None + + def test_project_request_chain_multi_type_start_projects_each_independently(): """Each start type is projected on its own; a conditional converter touches only what it applies to.""" config = ConverterConfiguration(converters=[QRCodeConverter()], prompt_data_types_to_apply=["text"]) projected, reason = project_request_chain( - start_types={"text", "audio_path"}, + start_types=["text", "audio_path"], request_converters=[config], ) assert projected == {"image_path", "audio_path"} @@ -397,6 +428,33 @@ def test_validate_atomic_attack_image_converter_into_vision_target_is_compatible assert report.verdict is ModalityVerdict.COMPATIBLE +def test_validate_atomic_attack_fully_selected_index_reaches_image_only_target(patch_central_database): + """A one-piece text request converted at index zero no longer contains text.""" + target = get_mock_target(input_modalities=[{"image_path"}], output_modalities=TEXT_ONLY_MODALITIES) + config = ConverterConfiguration(converters=[QRCodeConverter()], indexes_to_apply=[0]) + atomic = _atomic(target=target, converter_configurations=[config]) + report = validate_atomic_attack(atomic_attack=atomic) + assert report.projected_request_types == frozenset({"image_path"}) + assert report.verdict is ModalityVerdict.COMPATIBLE + + +def test_validate_atomic_attack_partially_selected_index_retains_text(patch_central_database): + """Two text pieces with only the first converted form a text-and-image message.""" + target = get_mock_target(input_modalities=[{"image_path"}], output_modalities=TEXT_ONLY_MODALITIES) + config = ConverterConfiguration(converters=[QRCodeConverter()], indexes_to_apply=[0]) + seed_group = AttackSeedGroup( + seeds=[ + SeedObjective(value="objective"), + SeedPrompt(value="first", data_type="text"), + SeedPrompt(value="second", data_type="text"), + ] + ) + atomic = _atomic(target=target, converter_configurations=[config], seed_groups=[seed_group]) + report = validate_atomic_attack(atomic_attack=atomic) + assert report.projected_request_types == frozenset({"text", "image_path"}) + assert report.verdict is ModalityVerdict.INCOMPATIBLE + + def test_validate_atomic_attack_converter_break_names_the_converter(patch_central_database): """A media seed hitting a text-only converter reports the converter, not the target.""" target = get_mock_target(input_modalities=VISION_INPUT_MODALITIES, output_modalities=TEXT_ONLY_MODALITIES) From ea6a2d24e803d439f51d5fb24424d40bfec08af0 Mon Sep 17 00:00:00 2001 From: Victor Valbuena Date: Tue, 22 Sep 2026 18:30:54 -0700 Subject: [PATCH 10/19] Validate only replayed seed groups on scenario resume Replay the persisted plan before modality checks so unsampled groups cannot invalidate a valid run. Refuse to silently skip incompatible saved groups and cover plan and legacy resume paths. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 24c08221-0382-4f39-aeda-ec3baac3ff1a --- .../instructions/scenarios.instructions.md | 5 +- REVIEWER_COMMENTS.md | 37 +++-- pyrit/scenario/core/scenario.py | 24 ++- .../core/test_scenario_modality_policy.py | 140 +++++++++++++++++- 4 files changed, 173 insertions(+), 33 deletions(-) diff --git a/.github/instructions/scenarios.instructions.md b/.github/instructions/scenarios.instructions.md index 9f5756da71..6512a52a6c 100644 --- a/.github/instructions/scenarios.instructions.md +++ b/.github/instructions/scenarios.instructions.md @@ -51,7 +51,10 @@ the constructor — no classmethod indirection required. `Scenario.initialize_async` validates every `AtomicAttack` returned by `_build_atomic_attacks_async` before any of them is queued, and applies `MODALITY_POLICY` (`ModalityPolicy.SKIP` by default, also `WARN` and `RAISE`). Scenario authors get this for free -and normally do not override it. +and normally do not override it. On resume, it first reconstructs the persisted seed groups +from the full dataset without resampling, then checks only those groups. `SKIP` fails loudly +instead of removing an incompatible saved attack and silently changing the run plan; `WARN` +retains it as configured. - The **request chain** projects each seed group's data types through the attack's request converters; the resulting set of data types must be exactly one of the target's advertised diff --git a/REVIEWER_COMMENTS.md b/REVIEWER_COMMENTS.md index f165f88b19..ebf737b672 100644 --- a/REVIEWER_COMMENTS.md +++ b/REVIEWER_COMMENTS.md @@ -3,12 +3,12 @@ Source: Copilot review overview and inline comments, plus Roman Lutz's review, provided by the user. Fact-checked against local HEAD `c6c07bce1` on `multimodal`; this does not independently verify a newer remote PR head. -The C1 fix is recorded below; the other five findings remain open. +The C1 fix is committed; the C2 fix is local. The other four findings remain open. | ID | Reviewer | Finding | Verdict | Follow-up | | --- | --- | --- | --- | --- | | C1 | Copilot | Fully selected `indexes_to_apply` incorrectly preserves the original request type. | **Fixed locally** | Ordered piece projection and regressions for all-selected and partially selected messages. | -| C2 | Copilot | Resume validates unsampled attacks before restoring persisted seed groups. | **Confirmed** | Replay the stored selection before checking modalities; cover sampled and legacy resume. | +| C2 | Copilot | Resume validates unsampled attacks before restoring persisted seed groups. | **Fixed locally** | Replay the stored selection before checking modalities; cover sampled and legacy resume. | | R1 | Roman Lutz | Composite scorer intersects child modalities although non-applicable children can return `[]`. | **Confirmed** | Reflect actual child applicability, including disjoint modalities; test a real score. | | R2 | Roman Lutz | Response check requires every target output type, unlike selective message scoring. | **Confirmed** | Compare per-response combinations with the scorer's actual filtering/strictness policy. | | R3 | Roman Lutz | Response check omits configured response converters. | **Confirmed** | Project the response converter chain before scoring, or report `UNKNOWN` if indeterminate. | @@ -40,19 +40,17 @@ and deliberately retain their previous conservative branch projection via ### C2 - resumed plan -`initialize_async` resolves all seed groups on resume and applies modality policy -to the rebuilt attacks **before** it reads the stored run plan and restores its -seed groups (`pyrit/scenario/core/scenario.py:941-978`). The policy checks every -seed group and drops the whole atomic attack when any is incompatible -(`modality_validation.py:258-299`, `scenario.py:1441-1485`). A formerly -unsampled incompatible group can therefore remove a planned atomic group, -which `_apply_persisted_run_plan` then declares unreconstructable -(`scenario.py:1133-1162`). Legacy runs without a stored plan instead narrow -by persisted objective hashes at `scenario.py:1167-1208`, also too late. -Existing resume coverage checks sample replay with modality-neutral groups -(`tests/unit/scenario/core/test_scenario.py:1287-1353`), not this interaction. -**Regression:** resume a sampled, persisted plan with an incompatible -unsampled row in the same atomic attack; repeat for legacy objective-hash replay. +The original ordering applied modality policy to all rebuilt seed groups +*before* narrowing to the persisted plan, so an unsampled incompatible group +could discard an atomic attack needed by a compatible saved group. The local +change retains full-dataset resolution without random resampling, but +reconstructs the stored seed groups via `_apply_persisted_run_plan` (or +`_apply_persisted_objectives` for a legacy result) **before** modality +validation. On resume, default `SKIP` raises `ModalityValidationError` if a +saved group is incompatible: silently removing it would change the run being +resumed. `WARN` retains it, and new-run filtering is unchanged. Legacy run +plan metadata is only written after validation succeeds. Regression tests +exercise both resume formats with incompatible unsampled and saved groups. ### R1 - composite scorer @@ -135,7 +133,8 @@ The initial fact-check ran four existing tests (4 passed, 70 deselected): `test_composite_scorer_ignores_non_applicable_child`, and `test_skip_excludes_dropped_attack_from_persisted_run_plan`. The C1 change adds all-selected, partially selected, chained-index, unknown-index, and -atomic-attack regression coverage. The targeted modality, factory, and -scenario-policy files now pass together (144 passed), the broader scenario -core tests pass (538 passed), and targeted Ruff and `ty` checks pass. The -remaining findings are not fixed. +atomic-attack regression coverage. The C2 change adds five resume regression +cases: plan and legacy replay ignore incompatible unsampled groups, both +fail explicitly for incompatible saved groups, and `WARN` retains the saved +selection. The scenario core suite passes (543 tests); targeted Ruff and +`ty` checks pass. The four remaining findings are not fixed. diff --git a/pyrit/scenario/core/scenario.py b/pyrit/scenario/core/scenario.py index d292246985..3395de24d9 100644 --- a/pyrit/scenario/core/scenario.py +++ b/pyrit/scenario/core/scenario.py @@ -946,7 +946,8 @@ async def initialize_async(self) -> None: seed_groups_by_dataset = await self._resolve_seed_groups_by_dataset_async(apply_sampling=not is_resume) context = self._build_scenario_context(seed_groups_by_dataset=seed_groups_by_dataset) self._atomic_attacks = await self._build_atomic_attacks_async(context=context) - self._atomic_attacks = self._apply_modality_policy(atomic_attacks=self._atomic_attacks) + if not is_resume: + self._atomic_attacks = self._apply_modality_policy(atomic_attacks=self._atomic_attacks) # Build the canonical scenario identifier once params/techniques/datasets # are resolved, so both the resume check and the new-result branch share the @@ -975,6 +976,8 @@ async def initialize_async(self) -> None: self._apply_persisted_run_plan(stored_plan=stored_plan) else: self._apply_persisted_objectives(stored_result=stored_result) + self._atomic_attacks = self._apply_modality_policy(atomic_attacks=self._atomic_attacks, is_resume=True) + if stored_plan is None: reconstructed_plan = self._build_run_plan() metadata = dict(stored_result.metadata) metadata[SCENARIO_RUN_PLAN_METADATA_KEY] = reconstructed_plan.model_dump(mode="json", exclude_none=True) @@ -1438,23 +1441,26 @@ def _build_scenario_context(self, *, seed_groups_by_dataset: dict[str, list[Atta seed_groups_by_dataset=seed_groups_by_dataset, ) - def _apply_modality_policy(self, *, atomic_attacks: list[AtomicAttack]) -> list[AtomicAttack]: + def _apply_modality_policy( + self, *, atomic_attacks: list[AtomicAttack], is_resume: bool = False + ) -> list[AtomicAttack]: """ Derive each atomic attack's modality compatibility and apply ``MODALITY_POLICY``. - Runs once per ``initialize_async``, on both the fresh and the resume path, before the - display-group map and the persisted result slots are built from the surviving attacks. - Attacks whose compatibility cannot be determined are always kept. + Runs once per ``initialize_async``. On resume, the saved plan is reconstructed + first, so unsampled seed groups do not affect the verdict. Attacks whose + compatibility cannot be determined are always kept. Args: atomic_attacks (list[AtomicAttack]): The attacks just built for this run. + is_resume (bool): Whether these attacks are the saved run's reconstructed plan. Returns: list[AtomicAttack]: The attacks that should run. Raises: ModalityValidationError: Under ``RAISE`` when any attack is incompatible, or under - ``SKIP`` when every attack would be dropped. + ``SKIP`` when the saved plan would change or every new attack would be dropped. """ reports = [validate_atomic_attack(atomic_attack=attack) for attack in atomic_attacks] incompatible = [report for report in reports if report.verdict is ModalityVerdict.INCOMPATIBLE] @@ -1475,6 +1481,12 @@ def _apply_modality_policy(self, *, atomic_attacks: list[AtomicAttack]) -> list[ ) return atomic_attacks + if is_resume: + raise ModalityValidationError( + f"Scenario result id '{self._scenario_result_id}' cannot resume: " + f"{len(incompatible)} saved atomic attack(s) are modality incompatible:\n{summary}" + ) + for report in incompatible: logger.warning(f"Skipping atomic attack '{report.atomic_attack_name}': {'; '.join(report.reasons)}") diff --git a/tests/unit/scenario/core/test_scenario_modality_policy.py b/tests/unit/scenario/core/test_scenario_modality_policy.py index cbb30c5508..ef642b1b09 100644 --- a/tests/unit/scenario/core/test_scenario_modality_policy.py +++ b/tests/unit/scenario/core/test_scenario_modality_policy.py @@ -4,16 +4,16 @@ """ Tests for ``Scenario.MODALITY_POLICY`` — plan-time enforcement of modality compatibility. -Validation runs inside ``initialize_async``, immediately after ``_build_atomic_attacks_async`` -returns and before any attack is queued or any prompt is sent. The policy decides what happens -to an attack whose payload provably cannot reach its target or scorer. +Validation runs inside ``initialize_async`` before any attack is queued or any prompt is sent. +Fresh runs check built attacks; resumed runs check only the replayed seed groups. The policy +decides what happens to an attack whose payload provably cannot reach its target or scorer. """ from __future__ import annotations import logging -from typing import ClassVar -from unittest.mock import MagicMock +from typing import TYPE_CHECKING, ClassVar +from unittest.mock import MagicMock, patch import pytest from unit.mocks import get_mock_target @@ -21,15 +21,18 @@ from pyrit.converter import QRCodeConverter from pyrit.executor.attack import AttackConverterConfig, PromptSendingAttack -from pyrit.models import AttackSeedGroup, ComponentIdentifier, SeedObjective +from pyrit.models import SCENARIO_RUN_PLAN_METADATA_KEY, AttackSeedGroup, ComponentIdentifier, SeedObjective, SeedPrompt from pyrit.prompt_normalizer import ConverterConfiguration from pyrit.prompt_target.common.target_requirements import TargetRequirements from pyrit.scenario.core import AtomicAttack, BaselineAttackPolicy, Scenario, ScenarioTechnique from pyrit.scenario.core.attack_technique import AttackTechnique -from pyrit.scenario.core.dataset_configuration import DatasetConfiguration +from pyrit.scenario.core.dataset_configuration import DatasetAttackConfiguration, DatasetConfiguration from pyrit.scenario.core.modality_validation import ModalityPolicy, ModalityValidationError from pyrit.score import Scorer +if TYPE_CHECKING: + from pyrit.scenario.core.scenario_context import ScenarioContext + _TEST_SCORER_ID = ComponentIdentifier(class_name="MockScorer", class_module="tests.unit.scenario") @@ -65,6 +68,60 @@ async def _build_atomic_attacks_async(self, *, context): return self._atomic_attacks_to_return +class _SampledPolicyScenario(_PolicyScenario): + """Build one attack from the dataset's sampled or replayed seed groups.""" + + async def _resolve_seed_groups_by_dataset_async( + self, *, apply_sampling: bool = True + ) -> dict[str, list[AttackSeedGroup]]: + return await Scenario._resolve_seed_groups_by_dataset_async(self, apply_sampling=apply_sampling) + + async def _build_atomic_attacks_async(self, *, context: ScenarioContext) -> list[AtomicAttack]: + return [ + AtomicAttack( + atomic_attack_name="sampled", + attack_technique=AttackTechnique(attack=PromptSendingAttack(objective_target=context.objective_target)), + seed_groups=list(context.seed_groups), + memory_labels=context.memory_labels, + ) + ] + + +def _sampled_config() -> DatasetAttackConfiguration: + return DatasetAttackConfiguration( + seed_groups=[ + AttackSeedGroup(seeds=[SeedObjective(value="saved text")]), + AttackSeedGroup( + seeds=[SeedObjective(value="unsampled image"), SeedPrompt(value="seed.png", data_type="image_path")] + ), + ], + max_dataset_size=1, + ) + + +async def _start_sampled_scenario( + *, target, scenario_class: type[_SampledPolicyScenario] = _SampledPolicyScenario +) -> _SampledPolicyScenario: + with patch( + "pyrit.scenario.core.dataset_configuration.random.sample", + side_effect=lambda population, k: list(population)[:k], + ): + scenario = scenario_class(default_dataset_config=_sampled_config()) + await _initialize(scenario, target=target) + assert len(scenario._atomic_attacks[0].seed_groups) == 1 + assert scenario._atomic_attacks[0].seed_groups[0].objective.value == "saved text" + return scenario + + +async def _resume_sampled_scenario( + *, scenario_result_id: str, target, scenario_class: type[_SampledPolicyScenario] = _SampledPolicyScenario +) -> _SampledPolicyScenario: + resumed = scenario_class(default_dataset_config=_sampled_config(), scenario_result_id=scenario_result_id) + with patch("pyrit.scenario.core.dataset_configuration.random.sample", side_effect=AssertionError("resampled")): + await _initialize(resumed, target=target) + return resumed + + def _atomic(*, target, converters=None, name="atomic") -> AtomicAttack: """A real AtomicAttack sending one objective through an optional converter chain.""" kwargs = {"objective_target": target} @@ -149,6 +206,75 @@ async def test_skip_excludes_dropped_attack_from_persisted_run_plan(patch_centra assert planned == {"compatible"} +@pytest.mark.parametrize("legacy_plan", [False, True]) +async def test_resume_validates_only_persisted_seed_groups(patch_central_database, legacy_plan): + """An unsampled image group cannot invalidate the saved text-only attack.""" + target = get_mock_target(input_modalities=TEXT_ONLY_MODALITIES, output_modalities=TEXT_ONLY_MODALITIES) + original = await _start_sampled_scenario(target=target) + scenario_result_id = original._scenario_result_id + [stored] = original._memory.get_scenario_results(scenario_result_ids=[scenario_result_id]) + original_metadata = dict(stored.metadata) + if legacy_plan: + original_metadata.pop(SCENARIO_RUN_PLAN_METADATA_KEY) + original._memory.update_scenario_metadata(scenario_result_id=scenario_result_id, metadata=original_metadata) + + resumed = await _resume_sampled_scenario(scenario_result_id=scenario_result_id, target=target) + + assert resumed._scenario_result_id == scenario_result_id + assert len(resumed._atomic_attacks) == 1 + assert [group.logical_id for group in resumed._atomic_attacks[0].seed_groups] == [ + original._atomic_attacks[0].seed_groups[0].logical_id + ] + [after] = resumed._memory.get_scenario_results(scenario_result_ids=[scenario_result_id]) + assert SCENARIO_RUN_PLAN_METADATA_KEY in after.metadata + assert after.metadata["objective_hashes"] == original_metadata["objective_hashes"] + if not legacy_plan: + assert after.metadata[SCENARIO_RUN_PLAN_METADATA_KEY] == original_metadata[SCENARIO_RUN_PLAN_METADATA_KEY] + + +@pytest.mark.parametrize("legacy_plan", [False, True]) +async def test_resume_rejects_incompatible_persisted_seed_group(patch_central_database, legacy_plan): + """SKIP must not silently alter a stored plan when a saved group becomes incompatible.""" + target = get_mock_target(input_modalities=TEXT_ONLY_MODALITIES, output_modalities=TEXT_ONLY_MODALITIES) + original = await _start_sampled_scenario(target=target) + scenario_result_id = original._scenario_result_id + [stored] = original._memory.get_scenario_results(scenario_result_ids=[scenario_result_id]) + metadata = dict(stored.metadata) + if legacy_plan: + metadata.pop(SCENARIO_RUN_PLAN_METADATA_KEY) + original._memory.update_scenario_metadata(scenario_result_id=scenario_result_id, metadata=metadata) + + changed_target = get_mock_target(input_modalities=[{"image_path"}], output_modalities=TEXT_ONLY_MODALITIES) + with pytest.raises(ModalityValidationError, match="cannot resume.*saved.*incompatible"): + await _resume_sampled_scenario(scenario_result_id=scenario_result_id, target=changed_target) + + [after] = original._memory.get_scenario_results(scenario_result_ids=[scenario_result_id]) + assert after.metadata == metadata + + +async def test_resume_warn_retains_incompatible_persisted_seed_group(patch_central_database, caplog): + """WARN keeps the saved group rather than changing the replayed plan.""" + + class _WarnSampledPolicyScenario(_SampledPolicyScenario): + MODALITY_POLICY: ClassVar[ModalityPolicy] = ModalityPolicy.WARN + + target = get_mock_target(input_modalities=TEXT_ONLY_MODALITIES, output_modalities=TEXT_ONLY_MODALITIES) + original = await _start_sampled_scenario(target=target, scenario_class=_WarnSampledPolicyScenario) + changed_target = get_mock_target(input_modalities=[{"image_path"}], output_modalities=TEXT_ONLY_MODALITIES) + + with caplog.at_level(logging.WARNING, logger="pyrit.scenario.core.scenario"): + resumed = await _resume_sampled_scenario( + scenario_result_id=original._scenario_result_id, + target=changed_target, + scenario_class=_WarnSampledPolicyScenario, + ) + + assert [group.logical_id for group in resumed._atomic_attacks[0].seed_groups] == [ + original._atomic_attacks[0].seed_groups[0].logical_id + ] + assert any("modality incompatibility" in record.getMessage().lower() for record in caplog.records) + + async def test_skip_logs_a_warning_naming_the_attack_and_reason(patch_central_database, caplog): """Dropping a whole atomic attack is loud even though the policy allows it.""" scenario = _PolicyScenario(atomic_attacks_to_return=[_incompatible(), _compatible()]) From 96aed678660cb29bfb73ee760992f2bb66aec6da Mon Sep 17 00:00:00 2001 From: Victor Valbuena Date: Tue, 22 Sep 2026 18:48:44 -0700 Subject: [PATCH 11/19] Match composite modality declarations to applicable scorers Union modalities for children that skip unsupported evidence and leave strict or undeclared child compatibility unknown. Preserve existing OR/AND aggregation and cover disjoint, strict, nested, and plan-time cases. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 24c08221-0382-4f39-aeda-ec3baac3ff1a --- .github/instructions/scorers.instructions.md | 9 +++ REVIEWER_COMMENTS.md | 35 +++++----- pyrit/score/message_scorer.py | 5 ++ pyrit/score/scorer.py | 5 ++ pyrit/score/scorer_prompt_validator.py | 5 ++ .../float_scale_threshold_scorer.py | 5 ++ .../true_false/true_false_composite_scorer.py | 24 ++++--- .../true_false/true_false_inverter_scorer.py | 5 ++ .../scenario/core/test_modality_validation.py | 14 +++- .../score/test_scorer_modality_accessors.py | 31 +++++++-- .../score/test_true_false_composite_scorer.py | 65 +++++++++++++++++++ 11 files changed, 169 insertions(+), 34 deletions(-) diff --git a/.github/instructions/scorers.instructions.md b/.github/instructions/scorers.instructions.md index 779220c4af..6624d7cf52 100644 --- a/.github/instructions/scorers.instructions.md +++ b/.github/instructions/scorers.instructions.md @@ -8,6 +8,15 @@ Scorers evaluate model responses against an objective and live under `pyrit/scor **Does not own** (see [framework.md](../../doc/code/framework.md)): acting on its own result. A scorer evaluates a response and returns a score; branching on that score is the attack's job and aggregating scores across runs is analytics'. It may call a target to evaluate, but must not send the attack's objective prompt or manage the conversation. Flag such bleed in review. +## Composite modality declarations + +`TrueFalseCompositeScorer` aggregates only applicable child scores; a child returning `[]` +does not vote `False`. Therefore both AND and OR composites declare the **union** of their +children's modalities when every child can skip unsupported data types. If any child has +undeclared modalities or may raise instead of skipping, the composite declares `None` +(`UNKNOWN`) rather than asserting compatibility. Wrappers must forward this skip behavior +along with `supported_data_types` so nested composites remain accurate. + ## Constructor contract `Scorer` subclasses MUST use the keyword-only constructor shape: diff --git a/REVIEWER_COMMENTS.md b/REVIEWER_COMMENTS.md index ebf737b672..069ee6ef08 100644 --- a/REVIEWER_COMMENTS.md +++ b/REVIEWER_COMMENTS.md @@ -3,13 +3,13 @@ Source: Copilot review overview and inline comments, plus Roman Lutz's review, provided by the user. Fact-checked against local HEAD `c6c07bce1` on `multimodal`; this does not independently verify a newer remote PR head. -The C1 fix is committed; the C2 fix is local. The other four findings remain open. +The C1 and C2 fixes are committed. The R1 fix is local; R2-R4 remain open. | ID | Reviewer | Finding | Verdict | Follow-up | | --- | --- | --- | --- | --- | | C1 | Copilot | Fully selected `indexes_to_apply` incorrectly preserves the original request type. | **Fixed locally** | Ordered piece projection and regressions for all-selected and partially selected messages. | | C2 | Copilot | Resume validates unsampled attacks before restoring persisted seed groups. | **Fixed locally** | Replay the stored selection before checking modalities; cover sampled and legacy resume. | -| R1 | Roman Lutz | Composite scorer intersects child modalities although non-applicable children can return `[]`. | **Confirmed** | Reflect actual child applicability, including disjoint modalities; test a real score. | +| R1 | Roman Lutz | Composite scorer intersects child modalities although non-applicable children can return `[]`. | **Fixed locally** | Union known skippable child modalities; preserve `UNKNOWN` for undeclared or strict children. | | R2 | Roman Lutz | Response check requires every target output type, unlike selective message scoring. | **Confirmed** | Compare per-response combinations with the scorer's actual filtering/strictness policy. | | R3 | Roman Lutz | Response check omits configured response converters. | **Confirmed** | Project the response converter chain before scoring, or report `UNKNOWN` if indeterminate. | | R4 | Roman Lutz | A wrapper without `next_message` is assumed to send text. | **Confirmed** | Inspect the actual child contract or return `UNKNOWN`; cover a media-seeded sequential attack. | @@ -54,20 +54,19 @@ exercise both resume formats with incompatible unsampled and saved groups. ### R1 - composite scorer -`TrueFalseCompositeScorer.supported_data_types` intersects every child's -declaration (`pyrit/score/true_false/true_false_composite_scorer.py:75-93`). -At runtime it filters out non-applicable child results before aggregating -(`true_false_composite_scorer.py:145-174`). A message scorer with no supported -piece returns `[]` rather than a negative score (`pyrit/score/message_scorer.py:1150-1170`, -`pyrit/score/true_false/true_false_scorer.py:170-185`); an existing test confirms -the composite ignores a non-applicable child, albeit via role filtering rather -than disjoint modalities -(`tests/unit/score/test_true_false_composite_scorer.py:215-225`). -Text-only and image-only children report an empty intersection even though the -text child can score a text response. The plan-time check rejects the empty -declaration (`modality_validation.py:214-229`). **Regression:** disjoint text -and image children, both the declared capability and an actual text score. -Keep `None` (undeclared child capability) distinct from an empty declared set. +Runtime filters out children that return `[]` before aggregating, even with +AND (`pyrit/score/true_false/true_false_composite_scorer.py:145-174`). +The old intersection declared no types for a text-only child paired with an +image-only child, incorrectly rejecting a working text response. The local +fix unions declared modalities only when every child is known to skip +unsupported pieces. `ScorerPromptValidator` exposes that behavior from its +`enforce_all_pieces_valid` and `raise_on_no_valid_pieces` settings; +`MessageScorer` and the nested wrappers forward it. Undeclared or strict +children yield `None` (`UNKNOWN`) rather than falsely promising +compatibility. New regression coverage scores actual text through disjoint +children with **both OR and AND**, checks plan-time compatibility, and +exercises strict, undeclared, and nested children. Runtime aggregation is +unchanged. ### R2 - selective response scoring @@ -137,4 +136,6 @@ atomic-attack regression coverage. The C2 change adds five resume regression cases: plan and legacy replay ignore incompatible unsampled groups, both fail explicitly for incompatible saved groups, and `WARN` retains the saved selection. The scenario core suite passes (543 tests); targeted Ruff and -`ty` checks pass. The four remaining findings are not fixed. +`ty` checks pass. For R1, the scorer suite plus the affected scenario +modality and policy tests pass (2,447 tests); targeted Ruff and `ty` checks +also pass. R2-R4 are not fixed. diff --git a/pyrit/score/message_scorer.py b/pyrit/score/message_scorer.py index 989ffb73a6..6f2376a0b7 100644 --- a/pyrit/score/message_scorer.py +++ b/pyrit/score/message_scorer.py @@ -287,6 +287,11 @@ def supported_data_types(self) -> frozenset[PromptDataType] | None: return None return frozenset(self._validator.supported_data_types) + @property + def skips_unsupported_data_types(self) -> bool: + """Whether the message validator ignores unsupported pieces without raising.""" + return self._validator.skips_unsupported_data_types + def matched_conditions(self) -> frozenset[type[Condition]]: """ Return the conditions this message scorer uses as criteria. diff --git a/pyrit/score/scorer.py b/pyrit/score/scorer.py index d73242711e..31bc8fe7d5 100644 --- a/pyrit/score/scorer.py +++ b/pyrit/score/scorer.py @@ -217,6 +217,11 @@ def supported_data_types(self) -> frozenset[PromptDataType] | None: """ return None + @property + def skips_unsupported_data_types(self) -> bool: + """Whether unsupported data types are known to produce no score rather than an error.""" + return False + def matched_conditions(self) -> frozenset[type[Condition]]: """ Return the condition types this scorer can use as its criterion. diff --git a/pyrit/score/scorer_prompt_validator.py b/pyrit/score/scorer_prompt_validator.py index 1d2173b6bf..5fb781b6df 100644 --- a/pyrit/score/scorer_prompt_validator.py +++ b/pyrit/score/scorer_prompt_validator.py @@ -93,6 +93,11 @@ def has_declared_data_types(self) -> bool: """Whether the scorer declared its data types rather than falling back to the default.""" return self._has_declared_data_types + @property + def skips_unsupported_data_types(self) -> bool: + """Whether unsupported pieces can be ignored instead of raising an error.""" + return not self._enforce_all_pieces_valid and not self._raise_on_no_valid_pieces + @property def is_objective_required(self) -> bool: """Whether the scorer uses the objective as a required criterion.""" diff --git a/pyrit/score/true_false/float_scale_threshold_scorer.py b/pyrit/score/true_false/float_scale_threshold_scorer.py index bd078ffbd8..531216ed61 100644 --- a/pyrit/score/true_false/float_scale_threshold_scorer.py +++ b/pyrit/score/true_false/float_scale_threshold_scorer.py @@ -90,6 +90,11 @@ def supported_data_types(self) -> frozenset[PromptDataType] | None: """ return self._scorer.supported_data_types + @property + def skips_unsupported_data_types(self) -> bool: + """Whether the wrapped scorer skips unsupported response types.""" + return self._scorer.skips_unsupported_data_types + def _build_identifier(self) -> ComponentIdentifier: """ Build the identifier for this scorer. diff --git a/pyrit/score/true_false/true_false_composite_scorer.py b/pyrit/score/true_false/true_false_composite_scorer.py index d373ebdc46..e5e1f277dc 100644 --- a/pyrit/score/true_false/true_false_composite_scorer.py +++ b/pyrit/score/true_false/true_false_composite_scorer.py @@ -74,23 +74,27 @@ def __init__( @property def supported_data_types(self) -> frozenset[PromptDataType] | None: """ - The intersection of what every child scorer declares. + The types for which at least one child can provide an applicable score. - A composite can only score evidence all of its children can score. One child with an - undeclared (unknown) set makes the whole composite unknown — a declared sibling cannot - vouch for it. + Runtime ignores children that return ``[]``, regardless of aggregator. A child with + undeclared types or one that may raise on unsupported data makes this unknown. Returns: - frozenset[PromptDataType] | None: The shared declared data types, or ``None`` if any - child is undeclared. + frozenset[PromptDataType] | None: The union of declared child types, or ``None`` + when a child cannot safely be skipped. """ - shared: frozenset[PromptDataType] | None = None + supported: set[PromptDataType] = set() for scorer in self._scorers: declared = scorer.supported_data_types - if declared is None: + if declared is None or not scorer.skips_unsupported_data_types: return None - shared = declared if shared is None else shared & declared - return shared + supported.update(declared) + return frozenset(supported) + + @property + def skips_unsupported_data_types(self) -> bool: + """Whether every child can ignore unsupported data types without raising.""" + return all(scorer.skips_unsupported_data_types for scorer in self._scorers) def _build_identifier(self) -> ComponentIdentifier: """ diff --git a/pyrit/score/true_false/true_false_inverter_scorer.py b/pyrit/score/true_false/true_false_inverter_scorer.py index 28057eb4f7..acf1dc671e 100644 --- a/pyrit/score/true_false/true_false_inverter_scorer.py +++ b/pyrit/score/true_false/true_false_inverter_scorer.py @@ -50,6 +50,11 @@ def supported_data_types(self) -> frozenset[PromptDataType] | None: """ return self._scorer.supported_data_types + @property + def skips_unsupported_data_types(self) -> bool: + """Whether the wrapped scorer skips unsupported response types.""" + return self._scorer.skips_unsupported_data_types + def _build_identifier(self) -> ComponentIdentifier: """ Build the identifier for this scorer. diff --git a/tests/unit/scenario/core/test_modality_validation.py b/tests/unit/scenario/core/test_modality_validation.py index 0ec266fd43..2f67425156 100644 --- a/tests/unit/scenario/core/test_modality_validation.py +++ b/tests/unit/scenario/core/test_modality_validation.py @@ -50,7 +50,7 @@ target_accepts, validate_atomic_attack, ) -from pyrit.score import SubStringScorer +from pyrit.score import SubStringScorer, TrueFalseCompositeScorer, TrueFalseScoreAggregator from pyrit.score.scorer_prompt_validator import ScorerPromptValidator @@ -372,6 +372,18 @@ def test_scorer_accepts_missing_emitted_type_is_incompatible(): assert "image_path" in reason +def test_scorer_accepts_composite_with_disjoint_child_modalities(patch_central_database): + """A text target can be scored by a text child even when another child reads images.""" + target = get_mock_target(output_modalities=[{"text"}]) + scorer = TrueFalseCompositeScorer( + aggregator=TrueFalseScoreAggregator.OR, + scorers=[_scorer_declaring(["text"]), _scorer_declaring(["image_path"])], + ) + verdict, reason = scorer_accepts(scorer=scorer, target=target) + assert verdict is ModalityVerdict.COMPATIBLE + assert reason is None + + def test_scorer_accepts_none_scorer_is_unknown(): """No scorer means nothing to check.""" target = get_mock_target(output_modalities=[{"image_path"}]) diff --git a/tests/unit/score/test_scorer_modality_accessors.py b/tests/unit/score/test_scorer_modality_accessors.py index aae381fb99..3aaba3b63d 100644 --- a/tests/unit/score/test_scorer_modality_accessors.py +++ b/tests/unit/score/test_scorer_modality_accessors.py @@ -11,6 +11,7 @@ * ``ScorerPromptValidator.supported_data_types`` / ``has_declared_data_types`` * ``Scorer.supported_data_types`` — ``None`` when undeclared or unknown +* ``Scorer.skips_unsupported_data_types`` — whether unsupported evidence can be ignored * ``MessageScorer`` reads its validator; wrapper scorers delegate to their children * the two former private reach-ins (audio transcript, video frames) use the public surface @@ -67,6 +68,19 @@ def test_validator_supported_data_types_declared_returns_declared(): assert validator.has_declared_data_types is True +@pytest.mark.parametrize( + ("options", "expected"), + [ + ({}, True), + ({"enforce_all_pieces_valid": True}, False), + ({"raise_on_no_valid_pieces": True}, False), + ], +) +def test_validator_skip_contract_reflects_strictness(options, expected): + validator = ScorerPromptValidator(supported_data_types=["text"], **options) + assert validator.skips_unsupported_data_types is expected + + def test_validator_supported_data_types_undeclared_returns_all_types(): """ Undeclared falls back to every PromptDataType and is marked as *not* declared. @@ -107,6 +121,7 @@ def test_scorer_base_supported_data_types_is_none_for_non_message_scorer(): """A TrueFalseScorer that is not a MessageScorer has no validator and reports None, not AttributeError.""" scorer = ManualScorer(value=True, rationale="r", user_identifier="u") assert scorer.supported_data_types is None + assert scorer.skips_unsupported_data_types is False # --------------------------------------------------------------------------- @@ -114,7 +129,9 @@ def test_scorer_base_supported_data_types_is_none_for_non_message_scorer(): # --------------------------------------------------------------------------- def test_message_scorer_declared_types_returns_frozenset(): """SubStringScorer's default validator declares ["text"]; the accessor reports exactly that.""" - assert SubStringScorer(substring="x").supported_data_types == frozenset({"text"}) + scorer = SubStringScorer(substring="x") + assert scorer.supported_data_types == frozenset({"text"}) + assert scorer.skips_unsupported_data_types is True def test_message_scorer_multi_type_declaration_round_trips(): @@ -155,6 +172,7 @@ def test_inverter_scorer_delegates_to_wrapped_scorer(): """TrueFalseInverterScorer reports its wrapped scorer's declaration.""" scorer = TrueFalseInverterScorer(scorer=_substring_scorer(declared=["audio_path"])) assert scorer.supported_data_types == frozenset({"audio_path"}) + assert scorer.skips_unsupported_data_types is True def test_inverter_scorer_delegates_none(): @@ -163,8 +181,8 @@ def test_inverter_scorer_delegates_none(): assert scorer.supported_data_types is None -def test_composite_scorer_intersects_children(): - """A composite can only score what *every* child can score: the intersection.""" +def test_composite_scorer_unions_skippable_children(): + """A composite scores whichever children apply, even when its aggregator is AND.""" scorer = TrueFalseCompositeScorer( aggregator=TrueFalseScoreAggregator.AND, scorers=[ @@ -172,11 +190,12 @@ def test_composite_scorer_intersects_children(): _substring_scorer(declared=["text", "audio_path"]), ], ) - assert scorer.supported_data_types == frozenset({"text"}) + assert scorer.supported_data_types == frozenset({"text", "image_path", "audio_path"}) + assert scorer.skips_unsupported_data_types is True def test_composite_scorer_identical_children_returns_shared_set(): - """Identical declarations intersect to themselves.""" + """Identical declarations union to themselves.""" scorer = TrueFalseCompositeScorer( aggregator=TrueFalseScoreAggregator.OR, scorers=[_substring_scorer(declared=["text"]), _substring_scorer(declared=["text"])], @@ -199,7 +218,7 @@ def test_nested_wrappers_propagate_declaration(): aggregator=TrueFalseScoreAggregator.AND, scorers=[_substring_scorer(declared=["text", "image_path"]), _substring_scorer(declared=["image_path"])], ) - assert TrueFalseInverterScorer(scorer=composite).supported_data_types == frozenset({"image_path"}) + assert TrueFalseInverterScorer(scorer=composite).supported_data_types == frozenset({"text", "image_path"}) # --------------------------------------------------------------------------- diff --git a/tests/unit/score/test_true_false_composite_scorer.py b/tests/unit/score/test_true_false_composite_scorer.py index 27c1aac7bc..365903e19a 100644 --- a/tests/unit/score/test_true_false_composite_scorer.py +++ b/tests/unit/score/test_true_false_composite_scorer.py @@ -21,6 +21,7 @@ MessageTrueFalseScorer, ScorerPromptValidator, TrueFalseCompositeScorer, + TrueFalseInverterScorer, TrueFalseScoreAggregator, ) @@ -224,6 +225,70 @@ async def test_composite_scorer_ignores_non_applicable_child(mock_request, true_ assert scores[0].get_value() is True +@pytest.mark.parametrize("aggregator", [TrueFalseScoreAggregator.OR, TrueFalseScoreAggregator.AND]) +async def test_composite_scorer_disjoint_child_modalities_score_applicable_child( + mock_request, true_scorer, false_scorer, aggregator +): + """OR and AND both aggregate only children that score the response.""" + true_scorer._validator = ScorerPromptValidator(supported_data_types=["text"]) + false_scorer._validator = ScorerPromptValidator(supported_data_types=["image_path"]) + scorer = TrueFalseCompositeScorer(aggregator=aggregator, scorers=[true_scorer, false_scorer]) + + assert scorer.supported_data_types == frozenset({"text", "image_path"}) + scores = await scorer.score_async(scorable=MessageScorable.from_message(store_message(mock_request))) + assert len(scores) == 1 + assert scores[0].get_value() is True + assert "This is a true score" in scores[0].score_rationale + assert "This is a false score" not in scores[0].score_rationale + + +@pytest.mark.parametrize( + "validator_options", + [{"enforce_all_pieces_valid": True}, {"raise_on_no_valid_pieces": True}], +) +def test_composite_scorer_strict_child_modality_is_unknown( + patch_central_database, true_scorer, false_scorer, validator_options +): + """A child that raises instead of returning [] cannot vouch for the union.""" + true_scorer._validator = ScorerPromptValidator(supported_data_types=["text"]) + false_scorer._validator = ScorerPromptValidator(supported_data_types=["image_path"], **validator_options) + scorer = TrueFalseCompositeScorer(aggregator=TrueFalseScoreAggregator.OR, scorers=[true_scorer, false_scorer]) + assert scorer.supported_data_types is None + + +async def test_composite_scorer_strict_child_raises_instead_of_skipping(mock_request, true_scorer, false_scorer): + """The strict child prevents the composite from scoring text despite a text-capable sibling.""" + true_scorer._validator = ScorerPromptValidator(supported_data_types=["text"]) + false_scorer._validator = ScorerPromptValidator(supported_data_types=["image_path"], enforce_all_pieces_valid=True) + scorer = TrueFalseCompositeScorer(aggregator=TrueFalseScoreAggregator.OR, scorers=[true_scorer, false_scorer]) + + assert scorer.supported_data_types is None + with pytest.raises(RuntimeError, match="is not supported"): + await scorer.score_async(scorable=MessageScorable.from_message(store_message(mock_request))) + + +def test_composite_scorer_undeclared_child_modality_is_unknown(patch_central_database, true_scorer, false_scorer): + true_scorer._validator = ScorerPromptValidator(supported_data_types=["text"]) + scorer = TrueFalseCompositeScorer(aggregator=TrueFalseScoreAggregator.OR, scorers=[true_scorer, false_scorer]) + assert scorer.supported_data_types is None + + +async def test_composite_scorer_nested_wrapper_preserves_applicability(mock_request, true_scorer, false_scorer): + """A nested composite and inverter keep their children's skip behavior.""" + true_scorer._validator = ScorerPromptValidator(supported_data_types=["text"]) + false_scorer._validator = ScorerPromptValidator(supported_data_types=["image_path"]) + nested = TrueFalseCompositeScorer( + aggregator=TrueFalseScoreAggregator.AND, + scorers=[true_scorer, TrueFalseInverterScorer(scorer=false_scorer)], + ) + outer = TrueFalseCompositeScorer(aggregator=TrueFalseScoreAggregator.OR, scorers=[nested]) + + assert outer.supported_data_types == frozenset({"text", "image_path"}) + scores = await outer.score_async(scorable=MessageScorable.from_message(store_message(mock_request))) + assert len(scores) == 1 + assert scores[0].get_value() is True + + async def test_composite_routes_full_expectation_to_matching_and_nonmatching_leaves(mock_request): objective_scorer = MockScorer( score_value=True, From 1c14fad683ae06f717c268a865494cae752b3a19 Mon Sep 17 00:00:00 2001 From: Victor Valbuena Date: Wed, 23 Sep 2026 11:06:56 -0700 Subject: [PATCH 12/19] Honor selective scoring when validating target responses Evaluate each advertised output combination independently instead of flattening every emitted type. Permissive scorers need one readable piece per response; strict scorers need every piece. Partially scorable alternatives remain unknown rather than dropping viable attacks. Cover SKIP, WARN, and RAISE across text, mixed text/audio, strict mixed, audio-only, and alternative outputs. Runtime SubStringScorer and further response-path tests follow in the companion commit. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 24c08221-0382-4f39-aeda-ec3baac3ff1a --- .../instructions/scenarios.instructions.md | 7 +++- pyrit/scenario/core/modality_validation.py | 37 +++++++++++------ .../core/test_scenario_modality_policy.py | 41 +++++++++++++++++-- 3 files changed, 68 insertions(+), 17 deletions(-) diff --git a/.github/instructions/scenarios.instructions.md b/.github/instructions/scenarios.instructions.md index 6512a52a6c..2be83ec53c 100644 --- a/.github/instructions/scenarios.instructions.md +++ b/.github/instructions/scenarios.instructions.md @@ -64,8 +64,11 @@ retains it as configured. text piece selected at index 0 and converted to an image leaves no text; selecting only index 0 of two text pieces leaves text in the second piece. Declarations are read literally — a target that accepts a lone image advertises `{image_path}` as well as `{text, image_path}`. -- The **response chain** requires the scorer to declare every data type the target may emit. This - is a type check only — it does not establish that the resulting score is meaningful. +- The **response chain** checks each advertised target output combination against the scorer. + A scorer that skips unsupported pieces needs at least one readable type in each combination; + a strict scorer needs to read every piece. If only some possible combinations can be scored, + the response-chain verdict is `UNKNOWN`, not a reason to skip the attack. This type check + does not establish that the resulting score is meaningful. - Anything indeterminate is `UNKNOWN` and never blocks a run. - Only turn 0 is checked. Media routing across later turns belongs to `_ModalityFeedbackRouter`, which multi-turn attacks consult at execution time. diff --git a/pyrit/scenario/core/modality_validation.py b/pyrit/scenario/core/modality_validation.py index 68b27e3a4c..2ba13795bd 100644 --- a/pyrit/scenario/core/modality_validation.py +++ b/pyrit/scenario/core/modality_validation.py @@ -12,8 +12,8 @@ * the **request chain** — the seed's own data types, projected through the request converters, must be exactly one of the objective target's advertised input modality combinations; -* the **response chain** — every data type the target may emit must be one the scorer declares - it can read. +* the **response chain** — each advertised response combination is checked against what the + scorer declares it can read, including whether it can ignore unsupported pieces. Anything indeterminate resolves to ``ModalityVerdict.UNKNOWN`` and never blocks a run. A target whose capabilities cannot be read, an attack that exposes no scoring config, and a @@ -196,9 +196,13 @@ def target_accepts(*, target: PromptTarget, request_types: set[PromptDataType]) return ModalityVerdict.INCOMPATIBLE -def scorer_accepts(*, scorer: Scorer | None, target: PromptTarget) -> tuple[ModalityVerdict, str | None]: +def scorer_accepts( + *, + scorer: Scorer | None, + target: PromptTarget, +) -> tuple[ModalityVerdict, str | None]: """ - Check what a target may emit against what its scorer declares it can read. + Check each target output combination against the scorer's declared data types. This is a type-compatibility check only. It establishes that the scorer can *read* the response, not that the resulting score is meaningful for the objective. @@ -221,14 +225,23 @@ def scorer_accepts(*, scorer: Scorer | None, target: PromptTarget) -> tuple[Moda if output_modalities is None: return ModalityVerdict.UNKNOWN, None - emitted = {data_type for combination in output_modalities for data_type in combination} - missing = sorted(emitted - declared) - if missing: - return ModalityVerdict.INCOMPATIBLE, ( - f"{type(scorer).__name__} cannot read {missing} which the objective target may emit; " - f"it declares {sorted(declared)}" - ) - return ModalityVerdict.COMPATIBLE, None + if not output_modalities: + return ModalityVerdict.UNKNOWN, None + + if scorer.skips_unsupported_data_types: + scorable = [bool(combination & declared) for combination in output_modalities] + else: + scorable = [bool(combination) and combination <= declared for combination in output_modalities] + + if all(scorable): + return ModalityVerdict.COMPATIBLE, None + if any(scorable): + return ModalityVerdict.UNKNOWN, None + + return ModalityVerdict.INCOMPATIBLE, ( + f"{type(scorer).__name__} cannot score any objective target output combination " + f"{_format_modalities(output_modalities)}; it declares {sorted(declared)}" + ) def validate_atomic_attack(*, atomic_attack: AtomicAttack) -> ModalityReport: diff --git a/tests/unit/scenario/core/test_scenario_modality_policy.py b/tests/unit/scenario/core/test_scenario_modality_policy.py index ef642b1b09..57d72ed61a 100644 --- a/tests/unit/scenario/core/test_scenario_modality_policy.py +++ b/tests/unit/scenario/core/test_scenario_modality_policy.py @@ -20,7 +20,7 @@ from unit.modality_profiles import TEXT_ONLY_MODALITIES, VISION_INPUT_MODALITIES from pyrit.converter import QRCodeConverter -from pyrit.executor.attack import AttackConverterConfig, PromptSendingAttack +from pyrit.executor.attack import AttackConverterConfig, AttackScoringConfig, PromptSendingAttack from pyrit.models import SCENARIO_RUN_PLAN_METADATA_KEY, AttackSeedGroup, ComponentIdentifier, SeedObjective, SeedPrompt from pyrit.prompt_normalizer import ConverterConfiguration from pyrit.prompt_target.common.target_requirements import TargetRequirements @@ -28,7 +28,8 @@ from pyrit.scenario.core.attack_technique import AttackTechnique from pyrit.scenario.core.dataset_configuration import DatasetAttackConfiguration, DatasetConfiguration from pyrit.scenario.core.modality_validation import ModalityPolicy, ModalityValidationError -from pyrit.score import Scorer +from pyrit.score import Scorer, SubStringScorer +from pyrit.score.scorer_prompt_validator import ScorerPromptValidator if TYPE_CHECKING: from pyrit.scenario.core.scenario_context import ScenarioContext @@ -122,13 +123,15 @@ async def _resume_sampled_scenario( return resumed -def _atomic(*, target, converters=None, name="atomic") -> AtomicAttack: +def _atomic(*, target, converters=None, scorer=None, name="atomic") -> AtomicAttack: """A real AtomicAttack sending one objective through an optional converter chain.""" kwargs = {"objective_target": target} if converters is not None: kwargs["attack_converter_config"] = AttackConverterConfig( request_converters=ConverterConfiguration.from_converters(converters=list(converters)) ) + if scorer is not None: + kwargs["attack_scoring_config"] = AttackScoringConfig(objective_scorer=scorer) return AtomicAttack( atomic_attack_name=name, attack_technique=AttackTechnique(attack=PromptSendingAttack(**kwargs)), @@ -206,6 +209,38 @@ async def test_skip_excludes_dropped_attack_from_persisted_run_plan(patch_centra assert planned == {"compatible"} +@pytest.mark.parametrize("policy", list(ModalityPolicy)) +@pytest.mark.parametrize( + ("outputs", "strict", "incompatible"), + [ + ([{"text"}], False, False), + ([{"text", "audio_path"}], False, False), + ([{"text", "audio_path"}], True, True), + ([{"audio_path"}], False, True), + ([{"text"}, {"audio_path"}], False, False), + ], +) +async def test_modality_policy_selective_text_scorer_output_matrix( + patch_central_database, policy, outputs, strict, incompatible +): + """A working mixed response survives all policies; unscorable responses obey policy.""" + + class _ConfiguredPolicyScenario(_PolicyScenario): + MODALITY_POLICY: ClassVar[ModalityPolicy] = policy + + target = get_mock_target(input_modalities=TEXT_ONLY_MODALITIES, output_modalities=outputs) + validator = ScorerPromptValidator(supported_data_types=["text"], enforce_all_pieces_valid=strict) + scorer = SubStringScorer(substring="match", validator=validator) + scenario = _ConfiguredPolicyScenario(atomic_attacks_to_return=[_atomic(target=target, scorer=scorer)]) + + if incompatible and policy is not ModalityPolicy.WARN: + with pytest.raises(ModalityValidationError): + await _initialize(scenario, target=target) + else: + await _initialize(scenario, target=target) + assert len(scenario._atomic_attacks) == 1 + + @pytest.mark.parametrize("legacy_plan", [False, True]) async def test_resume_validates_only_persisted_seed_groups(patch_central_database, legacy_plan): """An unsampled image group cannot invalidate the saved text-only attack.""" From d2cea7ed12a1931dd095899631fd85e5a68560ad Mon Sep 17 00:00:00 2001 From: Victor Valbuena Date: Wed, 23 Sep 2026 11:10:29 -0700 Subject: [PATCH 13/19] Validate converted responses and avoid false wrapper requests Expose response converters from attacks and project advertised output combinations through their declared conversion chains before scorer compatibility. Return UNKNOWN when indexed response conversion or a converter path cannot be modeled without the actual response pieces. Do not infer a text first turn from SequentialAttack's excluded next_message parameter: its real children own the seeds, targets, and converters. Cover actual offline audio-to-text conversion and scoring, plus successful execution of a media-seeded sequential child that the old check dropped. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 24c08221-0382-4f39-aeda-ec3baac3ff1a --- .../instructions/scenarios.instructions.md | 6 + pyrit/executor/attack/core/attack_strategy.py | 12 +- pyrit/scenario/core/modality_validation.py | 34 +++- .../scenario/core/test_modality_validation.py | 159 +++++++++++++++++- 4 files changed, 198 insertions(+), 13 deletions(-) diff --git a/.github/instructions/scenarios.instructions.md b/.github/instructions/scenarios.instructions.md index 2be83ec53c..f728aff00f 100644 --- a/.github/instructions/scenarios.instructions.md +++ b/.github/instructions/scenarios.instructions.md @@ -65,6 +65,9 @@ retains it as configured. index 0 of two text pieces leaves text in the second piece. Declarations are read literally — a target that accepts a lone image advertises `{image_path}` as well as `{text, image_path}`. - The **response chain** checks each advertised target output combination against the scorer. + Project configured response converters before checking the types the scorer receives. + When response piece indexes cannot be known, report `UNKNOWN` rather than guessing which + pieces were converted. A scorer that skips unsupported pieces needs at least one readable type in each combination; a strict scorer needs to read every piece. If only some possible combinations can be scored, the response-chain verdict is `UNKNOWN`, not a reason to skip the attack. This type check @@ -72,6 +75,9 @@ retains it as configured. - Anything indeterminate is `UNKNOWN` and never blocks a run. - Only turn 0 is checked. Media routing across later turns belongs to `_ModalityFeedbackRouter`, which multi-turn attacks consult at execution time. +- A `SequentialAttack` wrapper delegates its actual request, target, converters, and scorer to + its children, so the wrapper itself reports `UNKNOWN` rather than treating the absence of + `next_message` as a text request to its nominal target. Override `MODALITY_POLICY` to `RAISE` when an incompatible pairing means the run is misconfigured rather than merely narrower than intended. diff --git a/pyrit/executor/attack/core/attack_strategy.py b/pyrit/executor/attack/core/attack_strategy.py index 1b35589845..465e206fef 100644 --- a/pyrit/executor/attack/core/attack_strategy.py +++ b/pyrit/executor/attack/core/attack_strategy.py @@ -61,6 +61,7 @@ ) from pyrit.executor.attack.core.attack_result_attribution import AttackResultAttribution from pyrit.message_normalizer import MessageListNormalizer + from pyrit.prompt_normalizer import ConverterConfiguration from pyrit.prompt_target import PromptTarget from pyrit.prompt_target.common.target_capabilities import CapabilityName @@ -626,7 +627,7 @@ def __init__( if not hasattr(self, "_request_converters"): self._request_converters: list[Any] = [] if not hasattr(self, "_response_converters"): - self._response_converters: list[Any] = [] + self._response_converters: list[ConverterConfiguration] = [] def _get_prepended_normalizer_overrides( self, @@ -820,6 +821,15 @@ def get_request_converters(self) -> list[Any]: """ return self._request_converters + def get_response_converters(self) -> list[ConverterConfiguration]: + """ + Return response converter configurations applied before objective scoring. + + Returns: + list[ConverterConfiguration]: The configured response converters. + """ + return self._response_converters + async def execute_with_context_async(self, *, context: AttackStrategyContextT) -> AttackStrategyResultT: """ Execute an attack and persist its completed result after teardown. diff --git a/pyrit/scenario/core/modality_validation.py b/pyrit/scenario/core/modality_validation.py index 2ba13795bd..82bd9f678a 100644 --- a/pyrit/scenario/core/modality_validation.py +++ b/pyrit/scenario/core/modality_validation.py @@ -13,7 +13,8 @@ * the **request chain** — the seed's own data types, projected through the request converters, must be exactly one of the objective target's advertised input modality combinations; * the **response chain** — each advertised response combination is checked against what the - scorer declares it can read, including whether it can ignore unsupported pieces. + scorer declares it can read after the response converters run, including whether it can + ignore unsupported pieces. Anything indeterminate resolves to ``ModalityVerdict.UNKNOWN`` and never blocks a run. A target whose capabilities cannot be read, an attack that exposes no scoring config, and a @@ -200,6 +201,7 @@ def scorer_accepts( *, scorer: Scorer | None, target: PromptTarget, + response_converters: Sequence[ConverterConfiguration] = (), ) -> tuple[ModalityVerdict, str | None]: """ Check each target output combination against the scorer's declared data types. @@ -210,6 +212,8 @@ def scorer_accepts( Args: scorer (Scorer | None): The objective scorer, if the attack exposes one. target (PromptTarget): The objective target. + response_converters (Sequence[ConverterConfiguration]): Converters applied to the final + target response before scoring. Returns: tuple[ModalityVerdict, str | None]: The verdict, and a reason when incompatible. @@ -228,10 +232,22 @@ def scorer_accepts( if not output_modalities: return ModalityVerdict.UNKNOWN, None + if any(configuration.indexes_to_apply for configuration in response_converters): + return ModalityVerdict.UNKNOWN, None + + projected_modalities: list[set[PromptDataType]] = [] + for combination in output_modalities: + projected, failure = project_request_chain( + start_types=sorted(combination), request_converters=response_converters + ) + if failure is not None: + return ModalityVerdict.UNKNOWN, None + projected_modalities.append(projected) + if scorer.skips_unsupported_data_types: - scorable = [bool(combination & declared) for combination in output_modalities] + scorable = [bool(combination & declared) for combination in projected_modalities] else: - scorable = [bool(combination) and combination <= declared for combination in output_modalities] + scorable = [bool(combination) and combination <= declared for combination in projected_modalities] if all(scorable): return ModalityVerdict.COMPATIBLE, None @@ -240,7 +256,7 @@ def scorer_accepts( return ModalityVerdict.INCOMPATIBLE, ( f"{type(scorer).__name__} cannot score any objective target output combination " - f"{_format_modalities(output_modalities)}; it declares {sorted(declared)}" + f"{[sorted(combination) for combination in projected_modalities]}; it declares {sorted(declared)}" ) @@ -262,6 +278,12 @@ def validate_atomic_attack(*, atomic_attack: AtomicAttack) -> ModalityReport: technique = atomic_attack.attack_technique attack = technique.attack + # The compound's nominal target does not send its children's requests. + from pyrit.executor.attack.compound import SequentialAttack + + if isinstance(attack, SequentialAttack): + return ModalityReport(atomic_attack_name=name, verdict=ModalityVerdict.UNKNOWN) + target = attack.get_objective_target() request_converters = attack.get_request_converters() or [] scoring_config = attack.get_attack_scoring_config() @@ -302,7 +324,9 @@ def validate_atomic_attack(*, atomic_attack: AtomicAttack) -> ModalityReport: if reason not in reasons: reasons.append(reason) - scorer_verdict, scorer_reason = scorer_accepts(scorer=scorer, target=target) + scorer_verdict, scorer_reason = scorer_accepts( + scorer=scorer, target=target, response_converters=attack.get_response_converters() + ) verdicts.add(scorer_verdict) if scorer_reason is not None: reasons.append(scorer_reason) diff --git a/tests/unit/scenario/core/test_modality_validation.py b/tests/unit/scenario/core/test_modality_validation.py index 2f67425156..e48114f852 100644 --- a/tests/unit/scenario/core/test_modality_validation.py +++ b/tests/unit/scenario/core/test_modality_validation.py @@ -17,8 +17,11 @@ from __future__ import annotations +import asyncio +import os + import pytest -from unit.mocks import get_mock_target +from unit.mocks import MockPromptTarget, get_image_message_piece, get_mock_target, store_message from unit.modality_profiles import ( IMAGE_EDIT_INPUT_MODALITIES, TEXT_ONLY_MODALITIES, @@ -28,6 +31,8 @@ from pyrit.converter import ( AudioEchoConverter, Base64Converter, + Converter, + ConverterResult, ImageCompressionConverter, QRCodeConverter, ) @@ -36,9 +41,20 @@ AttackParameters, AttackScoringConfig, PromptSendingAttack, + SequentialAttack, + SequentialChildAttack, +) +from pyrit.models import ( + AttackSeedGroup, + AttackTechniqueSeedGroup, + Message, + MessagePiece, + PromptDataType, + SeedObjective, + SeedPrompt, ) -from pyrit.models import AttackSeedGroup, AttackTechniqueSeedGroup, SeedObjective, SeedPrompt -from pyrit.prompt_normalizer import ConverterConfiguration +from pyrit.prompt_normalizer import ConverterConfiguration, PromptNormalizer +from pyrit.prompt_target.common.target_capabilities import TargetCapabilities from pyrit.scenario.core.atomic_attack import AtomicAttack from pyrit.scenario.core.attack_technique import AttackTechnique from pyrit.scenario.core.modality_validation import ( @@ -50,7 +66,7 @@ target_accepts, validate_atomic_attack, ) -from pyrit.score import SubStringScorer, TrueFalseCompositeScorer, TrueFalseScoreAggregator +from pyrit.score import MessageScorable, SubStringScorer, TrueFalseCompositeScorer, TrueFalseScoreAggregator from pyrit.score.scorer_prompt_validator import ScorerPromptValidator @@ -59,6 +75,16 @@ def _configs(*converters) -> list[ConverterConfiguration]: return ConverterConfiguration.from_converters(converters=list(converters)) +class _OfflineAudioToTextConverter(Converter): + SUPPORTED_INPUT_TYPES = ("audio_path",) + SUPPORTED_OUTPUT_TYPES = ("text",) + + async def convert_async(self, *, prompt: str, input_type: PromptDataType = "audio_path") -> ConverterResult: + if not self.input_supported(input_type): + raise ValueError(f"Unsupported input type: {input_type}") + return ConverterResult(output_text="matched transcript", output_type="text") + + def _scorer_declaring(declared) -> SubStringScorer: """A real scorer whose validator declares ``declared``, or nothing when ``None``.""" validator = ( @@ -84,6 +110,7 @@ def _atomic( target, converters=None, converter_configurations=None, + response_converter_configurations=None, scorer=None, seed_groups=None, seed_technique=None, @@ -92,13 +119,14 @@ def _atomic( ) -> AtomicAttack: """Build a real AtomicAttack around a PromptSendingAttack with the given wiring.""" kwargs = {"objective_target": target} - if converters is not None or converter_configurations is not None: + if converters is not None or converter_configurations is not None or response_converter_configurations is not None: kwargs["attack_converter_config"] = AttackConverterConfig( request_converters=( converter_configurations if converter_configurations is not None - else ConverterConfiguration.from_converters(converters=list(converters)) - ) + else ConverterConfiguration.from_converters(converters=list(converters or [])) + ), + response_converters=response_converter_configurations or [], ) if scorer is not None: kwargs["attack_scoring_config"] = AttackScoringConfig(objective_scorer=scorer) @@ -384,6 +412,89 @@ def test_scorer_accepts_composite_with_disjoint_child_modalities(patch_central_d assert reason is None +async def test_scorer_accepts_mixed_response_with_selective_text_scorer(patch_central_database): + """The text piece can be scored without reading the audio piece.""" + scorer = SubStringScorer(substring="matched") + response = Message( + message_pieces=[ + MessagePiece(role="assistant", original_value="matched text"), + MessagePiece(role="assistant", original_value="audio.wav", original_value_data_type="audio_path"), + ] + ) + scores = await scorer.score_async(scorable=MessageScorable.from_message(store_message(response))) + assert len(scores) == 1 + assert scores[0].get_value() is True + + target = get_mock_target(output_modalities=[{"text", "audio_path"}]) + assert scorer_accepts(scorer=scorer, target=target)[0] is ModalityVerdict.COMPATIBLE + + +def test_scorer_accepts_mixed_response_with_strict_text_scorer(): + """A strict scorer cannot process an unsupported audio piece in the same response.""" + scorer = SubStringScorer( + substring="matched", + validator=ScorerPromptValidator(supported_data_types=["text"], enforce_all_pieces_valid=True), + ) + target = get_mock_target(output_modalities=[{"text", "audio_path"}]) + assert scorer_accepts(scorer=scorer, target=target)[0] is ModalityVerdict.INCOMPATIBLE + + +def test_scorer_accepts_audio_only_response_with_text_scorer(): + target = get_mock_target(output_modalities=[{"audio_path"}]) + assert scorer_accepts(scorer=_scorer_declaring(["text"]), target=target)[0] is ModalityVerdict.INCOMPATIBLE + + +def test_scorer_accepts_alternative_text_or_audio_response_is_unknown(): + """One output may be scored and another may not; neither outcome is guaranteed.""" + target = get_mock_target(output_modalities=[{"text"}, {"audio_path"}]) + assert scorer_accepts(scorer=_scorer_declaring(["text"]), target=target)[0] is ModalityVerdict.UNKNOWN + + +async def test_scorer_accepts_converted_audio_response(patch_central_database): + """Runtime converts the returned audio piece to text before objective scoring.""" + scorer = SubStringScorer(substring="matched") + target = get_mock_target(output_modalities=[{"audio_path"}]) + configurations = _configs(_OfflineAudioToTextConverter()) + response = Message( + message_pieces=[ + MessagePiece(role="assistant", original_value="audio.wav", original_value_data_type="audio_path") + ] + ) + await PromptNormalizer().convert_values_async(converter_configurations=configurations, message=response) + scores = await scorer.score_async(scorable=MessageScorable.from_message(store_message(response))) + assert [piece.converted_value_data_type for piece in response.message_pieces] == ["text"] + assert len(scores) == 1 + assert scores[0].get_value() is True + + atomic = _atomic(target=target, scorer=scorer, response_converter_configurations=configurations) + assert validate_atomic_attack(atomic_attack=atomic).verdict is ModalityVerdict.COMPATIBLE + + +def test_scorer_accepts_conditional_response_conversion(patch_central_database): + """Only matching output types are converted, leaving any text piece readable.""" + target = get_mock_target(output_modalities=[{"text", "audio_path"}]) + configuration = ConverterConfiguration( + converters=[_OfflineAudioToTextConverter()], prompt_data_types_to_apply=["audio_path"] + ) + scorer = SubStringScorer( + substring="matched", + validator=ScorerPromptValidator(supported_data_types=["text"], enforce_all_pieces_valid=True), + ) + atomic = _atomic(target=target, scorer=scorer, response_converter_configurations=[configuration]) + assert validate_atomic_attack(atomic_attack=atomic).verdict is ModalityVerdict.COMPATIBLE + + +def test_scorer_accepts_indexed_response_conversion_is_unknown(patch_central_database): + """Without a response piece count, index 0 may or may not cover every audio piece.""" + target = get_mock_target(output_modalities=[{"audio_path"}]) + configuration = ConverterConfiguration(converters=[_OfflineAudioToTextConverter()], indexes_to_apply=[0]) + scorer = SubStringScorer(substring="matched") + atomic = _atomic(target=target, scorer=scorer, response_converter_configurations=[configuration]) + verdict, _ = scorer_accepts(scorer=scorer, target=target, response_converters=[configuration]) + assert verdict is ModalityVerdict.UNKNOWN + assert validate_atomic_attack(atomic_attack=atomic).verdict is not ModalityVerdict.INCOMPATIBLE + + def test_scorer_accepts_none_scorer_is_unknown(): """No scorer means nothing to check.""" target = get_mock_target(output_modalities=[{"image_path"}]) @@ -483,6 +594,40 @@ def test_validate_atomic_attack_media_seed_reaches_capable_target(patch_central_ assert validate_atomic_attack(atomic_attack=atomic).verdict is ModalityVerdict.COMPATIBLE +async def test_sequential_media_child_is_not_rejected_by_invented_text_request(patch_central_database): + """A real sequential child owns and sends its image seed to its image-only target.""" + child_target = MockPromptTarget() + child_target.apply_capabilities( + capabilities=TargetCapabilities( + input_modalities=frozenset({frozenset({"image_path"})}), + output_modalities=frozenset({frozenset({"text"})}), + ) + ) + nominal_target = get_mock_target(input_modalities=[{"image_path"}], output_modalities=TEXT_ONLY_MODALITIES) + media_piece = await asyncio.to_thread(get_image_message_piece) + try: + seed_group = _media_seed_group(value=media_piece.original_value) + child = SequentialChildAttack( + strategy=PromptSendingAttack(objective_target=child_target), + seed_group=seed_group, + ) + wrapper = SequentialAttack(objective_target=nominal_target, child_attacks=[child]) + atomic = AtomicAttack( + atomic_attack_name="sequential_image", + attack_technique=AttackTechnique(attack=wrapper), + seed_groups=[seed_group], + ) + + result = await wrapper.execute_async(objective=seed_group.objective.value) + assert len(result.child_attack_results) == 1 + assert child_target.prompt_sent == [media_piece.original_value] + report = validate_atomic_attack(atomic_attack=atomic) + assert report.verdict is ModalityVerdict.UNKNOWN + assert report.projected_request_types == frozenset() + finally: + await asyncio.to_thread(os.remove, media_piece.original_value) + + def test_validate_atomic_attack_scorer_cannot_read_target_output(patch_central_database): """An incompatibility on the response chain fails the attack just as the request chain does.""" target = get_mock_target(input_modalities=TEXT_ONLY_MODALITIES, output_modalities=[{"image_path"}]) From dd55b2ec135306b66387e1e87d81808241737378 Mon Sep 17 00:00:00 2001 From: Victor Valbuena Date: Wed, 23 Sep 2026 11:11:06 -0700 Subject: [PATCH 14/19] Track reviewer fixes and remaining validation limits Record the C1 through R4 fixes, their regression coverage, and the explicit UNKNOWN tradeoffs for mixed outputs, indexed response conversion, and delegated compound attacks. Preserve the limitation that an isolated main runtime matrix could not be executed. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 24c08221-0382-4f39-aeda-ec3baac3ff1a --- REVIEWER_COMMENTS.md | 124 ++++++++++++++++++++++++------------------- 1 file changed, 69 insertions(+), 55 deletions(-) diff --git a/REVIEWER_COMMENTS.md b/REVIEWER_COMMENTS.md index 069ee6ef08..b556ac334e 100644 --- a/REVIEWER_COMMENTS.md +++ b/REVIEWER_COMMENTS.md @@ -3,16 +3,17 @@ Source: Copilot review overview and inline comments, plus Roman Lutz's review, provided by the user. Fact-checked against local HEAD `c6c07bce1` on `multimodal`; this does not independently verify a newer remote PR head. -The C1 and C2 fixes are committed. The R1 fix is local; R2-R4 remain open. +The C1, C2, R1, R2, R3, and R4 fixes are committed. R3 and R4 share a +regression-test helper and were committed together as `d2cea7ed1`. | ID | Reviewer | Finding | Verdict | Follow-up | | --- | --- | --- | --- | --- | -| C1 | Copilot | Fully selected `indexes_to_apply` incorrectly preserves the original request type. | **Fixed locally** | Ordered piece projection and regressions for all-selected and partially selected messages. | -| C2 | Copilot | Resume validates unsampled attacks before restoring persisted seed groups. | **Fixed locally** | Replay the stored selection before checking modalities; cover sampled and legacy resume. | -| R1 | Roman Lutz | Composite scorer intersects child modalities although non-applicable children can return `[]`. | **Fixed locally** | Union known skippable child modalities; preserve `UNKNOWN` for undeclared or strict children. | -| R2 | Roman Lutz | Response check requires every target output type, unlike selective message scoring. | **Confirmed** | Compare per-response combinations with the scorer's actual filtering/strictness policy. | -| R3 | Roman Lutz | Response check omits configured response converters. | **Confirmed** | Project the response converter chain before scoring, or report `UNKNOWN` if indeterminate. | -| R4 | Roman Lutz | A wrapper without `next_message` is assumed to send text. | **Confirmed** | Inspect the actual child contract or return `UNKNOWN`; cover a media-seeded sequential attack. | +| C1 | Copilot | Fully selected `indexes_to_apply` incorrectly preserves the original request type. | **Committed (`0e9aa2dbe`)** | Ordered piece projection and regressions for all-selected and partially selected messages. | +| C2 | Copilot | Resume validates unsampled attacks before restoring persisted seed groups. | **Committed (`ea6a2d24e`)** | Replay the stored selection before checking modalities; cover sampled and legacy resume. | +| R1 | Roman Lutz | Composite scorer intersects child modalities although non-applicable children can return `[]`. | **Committed (`96aed6786`)** | Union known skippable child modalities; preserve `UNKNOWN` for undeclared or strict children. | +| R2 | Roman Lutz | Response check requires every target output type, unlike selective message scoring. | **Committed (`1c14fad68`)** | Check each output combination against scorer strictness; report `UNKNOWN` for partially scorable alternatives. | +| R3 | Roman Lutz | Response check omits configured response converters. | **Committed (`d2cea7ed1`)** | Project response converter declarations; report `UNKNOWN` for indexed or unsupported projection. | +| R4 | Roman Lutz | A wrapper without `next_message` is assumed to send text. | **Committed (`d2cea7ed1`)** | Treat `SequentialAttack` as `UNKNOWN`; cover actual media-seeded child execution. | The review overview's phrase "compound attacks" refers to R1 and R4; its request for a fresh Copilot review is a workflow suggestion, not another finding. @@ -70,59 +71,60 @@ unchanged. ### R2 - selective response scoring -The plan-time check flattens all target output combinations into a union and -requires every emitted type to be declared by the scorer -(`pyrit/scenario/core/modality_validation.py:197-229`). The default -`ScorerPromptValidator` does **not** require every piece to be valid -(`pyrit/score/scorer_prompt_validator.py:26-34,105-130`); `MessageScorer` -filters unsupported pieces before scoring -(`pyrit/score/message_scorer.py:1150-1170,1264-1269`). For a response that -contains both text and audio, `SubStringScorer` can read the text -(`pyrit/score/true_false/substring_scorer.py:23,64-84`), yet plan-time marks -the pair incompatible. The reviewer is right about this case; merely -flattening alternatives also loses whether the target might emit **audio -alone**, which a text scorer cannot meaningfully score. **Regression:** mixed -text/audio response with a default text scorer and the corresponding -`enforce_all_pieces_valid=True` validator; separately assess audio-only -output rather than declaring every target/scorer pairing compatible. +The old plan-time check flattened all target output combinations into a +single union and required every type, even though a default message scorer +can filter unsupported pieces. The fix checks **each +combination**: a scorer known to skip unsupported data needs at least one +supported type, while a strict scorer needs every type. If every combination +is scorable, the response-chain verdict is `COMPATIBLE`; if none are, it is +`INCOMPATIBLE`; if some are and others are not, it is `UNKNOWN`. This avoids +rejecting a working mixed text/audio response while preserving strict +validation and not promising that a text scorer can score audio-only output. +The request-chain verdict can still make the overall attack report +`COMPATIBLE` when the response leg is `UNKNOWN`; this is an existing report +aggregation convention and does not cause policy to skip. + +R2 regression coverage first demonstrated the old false rejection, then +exercised a real `SubStringScorer` scoring a text/audio message: the text +scores true and the audio piece is ignored. The branch policy matrix checks +`SKIP`, `WARN`, and `RAISE` for text-only, mixed text/audio, strict mixed, +audio-only, and alternative text-or-audio target outputs (15 cases). The +single mixed response is compatible under every policy; strict mixed and +audio-only are rejected under `SKIP`/`RAISE` and retained under `WARN`; +separate text-or-audio outputs yield a response-leg `UNKNOWN` and are kept. +Cached `origin/main` has no modality-policy module, so no comparison of +policy verdicts *on main* exists; attempts to create an isolated main +worktree were denied by the environment. The main `SubStringScorer` source +still declares only text, but its exact runtime matrix was **not run on +main**. Do not claim that the main runtime matrix was verified. ### R3 - response converters -`validate_atomic_attack` passes the raw target straight to `scorer_accepts` -(`pyrit/scenario/core/modality_validation.py:244-291`); the latter only -examines declared target output and scorer types (`modality_validation.py:197-229`). -But `PromptSendingAttack` configures response converters and passes them to -`PromptNormalizer` (`pyrit/executor/attack/single_turn/prompt_sending.py:95-103,325-339`). -The normalizer converts the **last** returned response before returning it -to the attack for scoring (`pyrit/prompt_normalizer/prompt_normalizer.py:186-210`, -`pyrit/executor/attack/single_turn/prompt_sending.py:341-375`). An -audio-to-text converter advertises `audio_path` input and `text` output -(`pyrit/converter/azure_speech_audio_to_text_converter.py:22-38`), illustrating -the reviewer example without needing a live Azure service. **Regression:** -offline audio-to-text conversion with an audio-output target and text scorer; -ensure opaque/conditional/indexed conversions produce `UNKNOWN` where their -final type cannot safely be inferred. The current validation has no explicit -response-converter accessor on `AttackStrategy`. +The code and the newer fork head both still forwarded response converters +through `PromptSendingAttack` to `PromptNormalizer`, which converts the +**last** response before scoring; neither exposed them to plan-time +`scorer_accepts`. The regression test ran an offline audio-to-text converter +through the real normalizer, then scored its converted response successfully, +while plan-time validation falsely rejected it before this fix. The local +fix adds an attack response-converter accessor and projects each declared +output combination through type-filtered converter configurations before +testing scorer applicability. Indexed conversion (unknown response piece +positions) and chains that fail projection return `UNKNOWN`, avoiding a +false skip. This does not change runtime conversion or pretend to know +the number and order of pieces in an actual target response. ### R4 - sequential wrapper -`_reads_next_message` infers whether the first request is text solely from the -presence of the `next_message` parameter, and `_effective_start_types` returns -`{"text"}` whenever it is absent -(`pyrit/scenario/core/modality_validation.py:322-357`). -`SequentialAttack` deliberately excludes that parameter **because its child -attacks own their own seed groups**; it dispatches each child and its seed to -`AttackExecutor` (`pyrit/executor/attack/compound/sequential_attack.py:217-237,248-259,289-335`). -`AdaptiveTechniqueDispatcher` builds real `SequentialChildAttack` instances -from seed groups (`pyrit/scenario/scenarios/adaptive/dispatcher.py:212-234`), -and `AdaptiveScenario` wraps those attacks in `AtomicAttack` -(`pyrit/scenario/scenarios/adaptive/adaptive_scenario.py:466-495`). -Consequently a media-seeded child can target an image-only endpoint while the -outer wrapper is falsely projected as sending text. **Regression:** actual -media-seeded `SequentialChildAttack` and image-only child target; the compound -must not be rejected by the wrapper's invented request type. A wrapper may -also have child targets different from its nominal target, so projecting only -against that nominal target is not sufficient. +`SequentialAttack` excludes `next_message` because its children each own +their seed group, target, converters and scorer; the old wrapper check +fabricated a text request and checked the nominal target. The new test +**executes** a real `SequentialChildAttack` with an image seed and image-only +target: the child succeeds, but pre-fix plan-time validation rejected its +wrapper. The local fix returns `UNKNOWN` for the wrapper instead of +misrepresenting its children's requests. That preserves scenario coverage +without claiming child-level validation; a future explicit per-child +first-request contract could add that precision. Direct attacks retain +their existing checks. ## Verification performed @@ -138,4 +140,16 @@ fail explicitly for incompatible saved groups, and `WARN` retains the saved selection. The scenario core suite passes (543 tests); targeted Ruff and `ty` checks pass. For R1, the scorer suite plus the affected scenario modality and policy tests pass (2,447 tests); targeted Ruff and `ty` checks -also pass. R2-R4 are not fixed. +also pass. The subsequent R2-R4 work is described below. + +Updated verification for R2-R4: scenario-core, sequential-attack, and +prompt-sending tests pass together (686 passed). Targeted Ruff, formatter, +`ty`, and `git diff --check` pass. The R3 audio-to-text regression first +failed because plan-time saw raw audio, then passed after response projection; +the test also ran the normalizer conversion and actual text score. R4 first +ran a real image-seeded child successfully, then demonstrated a failing +plan-time verdict; after the wrapper fix it returns `UNKNOWN` without +altering child execution. Main-only baseline runtime testing remains blocked +by denied worktree creation, as noted under R2. Staging and commits were denied temporarily while the user was unavailable; +staging succeeded after the user resumed. An isolated main worktree remained +unavailable, so the `main`-only runtime matrix was not executed; see R2. From 6ce547d7dfb861944f042f608d2bafaa265a0d6b Mon Sep 17 00:00:00 2001 From: Victor Valbuena Date: Wed, 23 Sep 2026 11:44:47 -0700 Subject: [PATCH 15/19] Account for TAP generated first roots in modality plans A text-capable TAP target seeds root zero but generates text for its other first-turn roots. Project both paths through request converters and mark mixed runnable/unrunnable roots UNKNOWN so scenario SKIP does not discard the runnable branches. Preserve rejection for width-one TAP and media-required targets. Add focused regression coverage and update the scenario guide and reviewer tracker. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 24c08221-0382-4f39-aeda-ec3baac3ff1a --- REVIEWER_COMMENTS.md | 21 ++++ doc/code/scenarios/0_scenarios.ipynb | 6 +- doc/code/scenarios/0_scenarios.py | 4 + .../attack/multi_turn/tree_of_attacks.py | 13 +++ pyrit/scenario/core/modality_validation.py | 64 +++++++---- .../core/test_scenario_modality_policy.py | 107 +++++++++++++++++- 6 files changed, 188 insertions(+), 27 deletions(-) diff --git a/REVIEWER_COMMENTS.md b/REVIEWER_COMMENTS.md index b556ac334e..bddf1a12ae 100644 --- a/REVIEWER_COMMENTS.md +++ b/REVIEWER_COMMENTS.md @@ -153,3 +153,24 @@ altering child execution. Main-only baseline runtime testing remains blocked by denied worktree creation, as noted under R2. Staging and commits were denied temporarily while the user was unavailable; staging succeeded after the user resumed. An isolated main worktree remained unavailable, so the `main`-only runtime matrix was not executed; see R2. + +## Follow-up: TAP's independently generated first roots + +The related-problem investigation found a false skip for TAP with `tree_width > 1`: +when the target accepts text-only requests, root 0 uses the seed's next message +but sibling roots generate their own text. An image-only seed may fail at root 0 +without preventing text siblings from running. Previously, plan-time validation +projected only the media seed and `SKIP` dropped the entire atomic attack. + +`TreeOfAttacksWithPruningAttack.has_unseeded_first_turn_roots` now exposes when +those sibling text requests exist. Validation projects both first-root paths +through the same request-converter chain; if some roots can run and others +cannot, it reports `UNKNOWN` and retains the attack. Width-one TAP and +media-required targets still check only their actual seeded first requests. +Regression tests cover these cases, a compatible media seed, converters that +make both paths incompatible, and a non-TAP control. This does not change TAP's +runtime branching or claim that the failing seeded root succeeds. + +Focused TAP, modality, and scenario-policy tests passed (224 tests). The +paired scenario documentation was updated. This follow-up was identified +after C1-R4 and is not one of the six original review comments. diff --git a/doc/code/scenarios/0_scenarios.ipynb b/doc/code/scenarios/0_scenarios.ipynb index 5e912db8d2..af8b2ed0ce 100644 --- a/doc/code/scenarios/0_scenarios.ipynb +++ b/doc/code/scenarios/0_scenarios.ipynb @@ -497,7 +497,11 @@ "Compatibility that cannot be determined never blocks a run: a target that does not declare its\n", "capabilities, an attack that exposes no scoring config, and a scorer that never declared its data\n", "types are all treated as unknown rather than incompatible. Only the first turn is checked — media\n", - "routing across later turns belongs to the multi-turn attacks themselves, at execution time." + "routing across later turns belongs to the multi-turn attacks themselves, at execution time.\n", + "For Tree of Attacks (TAP) with width greater than one, a text-capable objective gets sibling\n", + "roots starting from generated text, projected through the same converters. If only the seeded\n", + "media root is incompatible, the mixed-root attack reports unknown and is retained under `SKIP`.\n", + "If the objective requires media, TAP seeds every root; incompatible requests are still rejected." ] }, { diff --git a/doc/code/scenarios/0_scenarios.py b/doc/code/scenarios/0_scenarios.py index 0b67c3d470..344df0f005 100644 --- a/doc/code/scenarios/0_scenarios.py +++ b/doc/code/scenarios/0_scenarios.py @@ -214,6 +214,10 @@ async def _build_atomic_attacks_async(self, *, context): # capabilities, an attack that exposes no scoring config, and a scorer that never declared its data # types are all treated as unknown rather than incompatible. Only the first turn is checked — media # routing across later turns belongs to the multi-turn attacks themselves, at execution time. +# For Tree of Attacks (TAP) with width greater than one, a text-capable objective gets sibling +# roots starting from generated text, projected through the same converters. If only the seeded +# media root is incompatible, the mixed-root attack reports unknown and is retained under `SKIP`. +# If the objective requires media, TAP seeds every root; incompatible requests are still rejected. # %% [markdown] # diff --git a/pyrit/executor/attack/multi_turn/tree_of_attacks.py b/pyrit/executor/attack/multi_turn/tree_of_attacks.py index 9b10d2c6e9..361cd49b58 100644 --- a/pyrit/executor/attack/multi_turn/tree_of_attacks.py +++ b/pyrit/executor/attack/multi_turn/tree_of_attacks.py @@ -1753,6 +1753,19 @@ def get_attack_scoring_config(self) -> AttackScoringConfig | None: """ return self._attack_scoring_config + @property + def has_unseeded_first_turn_roots(self) -> bool: + """ + Whether sibling roots generate text instead of consuming the first-turn seed. + + Returns: + bool: True when more than one root exists and the objective permits text-only requests. + """ + return ( + self._configuration.tree_width > 1 + and not self._modality_router.objective_target_requires_media_on_first_turn + ) + def get_attack_adversarial_config(self) -> AttackAdversarialConfig | None: """ Get the effective adversarial configuration used by this strategy. diff --git a/pyrit/scenario/core/modality_validation.py b/pyrit/scenario/core/modality_validation.py index 82bd9f678a..62fe9d6eda 100644 --- a/pyrit/scenario/core/modality_validation.py +++ b/pyrit/scenario/core/modality_validation.py @@ -10,8 +10,8 @@ Two chains are checked, both for turn 0 only: -* the **request chain** — the seed's own data types, projected through the request converters, - must be exactly one of the objective target's advertised input modality combinations; +* the **request chain** — each first-turn root's data types, projected through the request + converters, must be exactly one of the objective target's advertised input modality combinations; * the **response chain** — each advertised response combination is checked against what the scorer declares it can read after the response converters run, including whether it can ignore unsupported pieces. @@ -83,7 +83,7 @@ class ModalityVerdict(str, Enum): #: At least one chain provably cannot. INCOMPATIBLE = "incompatible" - #: Nothing could be determined — capabilities or declarations were unavailable. + #: Compatibility is indeterminate or only some first-turn roots can run. UNKNOWN = "unknown" @@ -265,8 +265,9 @@ def validate_atomic_attack(*, atomic_attack: AtomicAttack) -> ModalityReport: Derive whether a built ``AtomicAttack`` can carry its payload to its target and scorer. Each seed group is projected independently — two groups are two separate requests, so their - data types are never combined into one. The attack is incompatible if any seed group is, and - compatible if at least one leg was determinable and none failed. + data types are never combined into one. TAP's independently generated text roots are checked + separately from its seeded root. A mixed runnable/unrunnable TAP root set is unknown rather + than incompatible, since skipping would discard runnable branches. Args: atomic_attack (AtomicAttack): The attack to check, after construction and before queuing. @@ -280,6 +281,7 @@ def validate_atomic_attack(*, atomic_attack: AtomicAttack) -> ModalityReport: # The compound's nominal target does not send its children's requests. from pyrit.executor.attack.compound import SequentialAttack + from pyrit.executor.attack.multi_turn.tree_of_attacks import TreeOfAttacksWithPruningAttack if isinstance(attack, SequentialAttack): return ModalityReport(atomic_attack_name=name, verdict=ModalityVerdict.UNKNOWN) @@ -291,6 +293,7 @@ def validate_atomic_attack(*, atomic_attack: AtomicAttack) -> ModalityReport: reads_next_message = _reads_next_message(attack=attack) verdicts: set[ModalityVerdict] = set() + partially_runnable_roots = False reasons: list[str] = [] projected_all: set[PromptDataType] = set() @@ -304,25 +307,40 @@ def validate_atomic_attack(*, atomic_attack: AtomicAttack) -> ModalityReport: verdicts.add(ModalityVerdict.UNKNOWN) continue - projected, failure_reason = project_request_chain( - start_types=start_types, request_converters=request_converters - ) - if failure_reason is not None: - verdicts.add(ModalityVerdict.INCOMPATIBLE) - if failure_reason not in reasons: - reasons.append(failure_reason) - continue + root_types: list[list[PromptDataType]] = [start_types] + if isinstance(attack, TreeOfAttacksWithPruningAttack) and attack.has_unseeded_first_turn_roots: + root_types.append(["text"]) + + root_verdicts: list[ModalityVerdict] = [] + root_reasons: list[str] = [] + for types in root_types: + projected, failure_reason = project_request_chain(start_types=types, request_converters=request_converters) + if failure_reason is not None: + root_verdicts.append(ModalityVerdict.INCOMPATIBLE) + root_reasons.append(failure_reason) + continue - projected_all |= projected - request_verdict = target_accepts(target=target, request_types=projected) - verdicts.add(request_verdict) - if request_verdict is ModalityVerdict.INCOMPATIBLE: - reason = ( - f"objective target does not accept {sorted(projected)}; " - f"it accepts {_format_modalities(_read_modalities(target=target, direction='input'))}" + projected_all |= projected + request_verdict = target_accepts(target=target, request_types=projected) + root_verdicts.append(request_verdict) + if request_verdict is ModalityVerdict.INCOMPATIBLE: + root_reasons.append( + f"objective target does not accept {sorted(projected)}; " + f"it accepts {_format_modalities(_read_modalities(target=target, direction='input'))}" + ) + + if ModalityVerdict.COMPATIBLE in root_verdicts and ModalityVerdict.INCOMPATIBLE in root_verdicts: + partially_runnable_roots = True + verdicts.add(ModalityVerdict.UNKNOWN) + reasons.append( + "TAP seeded root and generated text roots have mixed compatibility; " + "at least one root can run: " + "; ".join(root_reasons) ) - if reason not in reasons: - reasons.append(reason) + else: + verdicts.update(root_verdicts) + for reason in root_reasons: + if reason not in reasons: + reasons.append(reason) scorer_verdict, scorer_reason = scorer_accepts( scorer=scorer, target=target, response_converters=attack.get_response_converters() @@ -333,6 +351,8 @@ def validate_atomic_attack(*, atomic_attack: AtomicAttack) -> ModalityReport: if ModalityVerdict.INCOMPATIBLE in verdicts: verdict = ModalityVerdict.INCOMPATIBLE + elif partially_runnable_roots: + verdict = ModalityVerdict.UNKNOWN elif ModalityVerdict.COMPATIBLE in verdicts: # A leg we could not determine does not cancel one we could. verdict = ModalityVerdict.COMPATIBLE diff --git a/tests/unit/scenario/core/test_scenario_modality_policy.py b/tests/unit/scenario/core/test_scenario_modality_policy.py index 57d72ed61a..0dfa57d51e 100644 --- a/tests/unit/scenario/core/test_scenario_modality_policy.py +++ b/tests/unit/scenario/core/test_scenario_modality_policy.py @@ -16,18 +16,29 @@ from unittest.mock import MagicMock, patch import pytest -from unit.mocks import get_mock_target -from unit.modality_profiles import TEXT_ONLY_MODALITIES, VISION_INPUT_MODALITIES +from unit.mocks import MockPromptTarget, get_mock_target +from unit.modality_profiles import IMAGE_EDIT_INPUT_MODALITIES, TEXT_ONLY_MODALITIES, VISION_INPUT_MODALITIES from pyrit.converter import QRCodeConverter -from pyrit.executor.attack import AttackConverterConfig, AttackScoringConfig, PromptSendingAttack +from pyrit.executor.attack import ( + AttackAdversarialConfig, + AttackConverterConfig, + AttackScoringConfig, + PromptSendingAttack, + TreeOfAttacksWithPruningAttack, +) from pyrit.models import SCENARIO_RUN_PLAN_METADATA_KEY, AttackSeedGroup, ComponentIdentifier, SeedObjective, SeedPrompt from pyrit.prompt_normalizer import ConverterConfiguration from pyrit.prompt_target.common.target_requirements import TargetRequirements from pyrit.scenario.core import AtomicAttack, BaselineAttackPolicy, Scenario, ScenarioTechnique from pyrit.scenario.core.attack_technique import AttackTechnique from pyrit.scenario.core.dataset_configuration import DatasetAttackConfiguration, DatasetConfiguration -from pyrit.scenario.core.modality_validation import ModalityPolicy, ModalityValidationError +from pyrit.scenario.core.modality_validation import ( + ModalityPolicy, + ModalityValidationError, + ModalityVerdict, + validate_atomic_attack, +) from pyrit.score import Scorer, SubStringScorer from pyrit.score.scorer_prompt_validator import ScorerPromptValidator @@ -151,6 +162,94 @@ def _compatible(*, name="compatible") -> AtomicAttack: return _atomic(target=target, converters=[QRCodeConverter()], name=name) +def _tap_atomic(*, width: int, target: MockPromptTarget | MagicMock, seed_group: AttackSeedGroup) -> AtomicAttack: + attack = TreeOfAttacksWithPruningAttack( + objective_target=target, + attack_adversarial_config=AttackAdversarialConfig(target=MockPromptTarget()), + tree_width=width, + ) + return AtomicAttack( + atomic_attack_name="tap_media", + attack_technique=AttackTechnique(attack=attack), + seed_groups=[seed_group], + ) + + +@pytest.mark.parametrize( + ("width", "modalities", "seed", "expected", "projected"), + [ + (2, TEXT_ONLY_MODALITIES, "image", ModalityVerdict.UNKNOWN, {"image_path", "text"}), + (1, TEXT_ONLY_MODALITIES, "image", ModalityVerdict.INCOMPATIBLE, {"image_path"}), + (2, IMAGE_EDIT_INPUT_MODALITIES, "image", ModalityVerdict.INCOMPATIBLE, {"image_path"}), + (2, IMAGE_EDIT_INPUT_MODALITIES, "text_image", ModalityVerdict.COMPATIBLE, {"text", "image_path"}), + (2, VISION_INPUT_MODALITIES, "image", ModalityVerdict.COMPATIBLE, {"text", "image_path"}), + ], +) +async def test_tap_first_turn_roots_modality_and_skip( + patch_central_database, width, modalities, seed, expected, projected +): + """Only text-capable TAP siblings can rescue an incompatible seeded root.""" + seeds = [SeedObjective(value="objective"), SeedPrompt(value="seed.png", data_type="image_path")] + if seed == "text_image": + seeds.append(SeedPrompt(value="edit this", data_type="text")) + target = get_mock_target(input_modalities=modalities, output_modalities=TEXT_ONLY_MODALITIES) + atomic = _tap_atomic(width=width, target=target, seed_group=AttackSeedGroup(seeds=seeds)) + attack = atomic.attack_technique.attack + assert isinstance(attack, TreeOfAttacksWithPruningAttack) + assert attack.has_unseeded_first_turn_roots is (width > 1 and frozenset({"text"}) in modalities) + report = validate_atomic_attack(atomic_attack=atomic) + assert report.verdict is expected + assert report.projected_request_types == projected + if expected is ModalityVerdict.UNKNOWN: + assert any("seeded root" in reason and "text" in reason for reason in report.reasons) + scenario = _PolicyScenario(atomic_attacks_to_return=[atomic, _compatible(name="control")]) + await _initialize(scenario, target=target) + assert [attack.atomic_attack_name for attack in scenario._atomic_attacks] == ( + ["control"] if expected is ModalityVerdict.INCOMPATIBLE else ["tap_media", "control"] + ) + + +def test_non_tap_media_seed_does_not_gain_generated_text_root(patch_central_database): + target = get_mock_target(input_modalities=TEXT_ONLY_MODALITIES, output_modalities=TEXT_ONLY_MODALITIES) + atomic = AtomicAttack( + atomic_attack_name="single", + attack_technique=AttackTechnique(attack=PromptSendingAttack(objective_target=target)), + seed_groups=[ + AttackSeedGroup( + seeds=[SeedObjective(value="objective"), SeedPrompt(value="seed.png", data_type="image_path")] + ) + ], + ) + report = validate_atomic_attack(atomic_attack=atomic) + assert report.verdict is ModalityVerdict.INCOMPATIBLE + assert report.projected_request_types == frozenset({"image_path"}) + + +def test_tap_generated_roots_are_projected_through_request_converters(patch_central_database): + """Text siblings cannot rescue a media root if the converter also makes them incompatible.""" + target = get_mock_target(input_modalities=TEXT_ONLY_MODALITIES, output_modalities=TEXT_ONLY_MODALITIES) + seed = AttackSeedGroup( + seeds=[SeedObjective(value="objective"), SeedPrompt(value="seed.png", data_type="image_path")] + ) + attack = TreeOfAttacksWithPruningAttack( + objective_target=target, + attack_adversarial_config=AttackAdversarialConfig(target=MockPromptTarget()), + attack_converter_config=AttackConverterConfig( + request_converters=ConverterConfiguration.from_converters(converters=[QRCodeConverter()]) + ), + tree_width=2, + ) + atomic = AtomicAttack( + atomic_attack_name="converted_tap", + attack_technique=AttackTechnique(attack=attack), + seed_groups=[seed], + ) + report = validate_atomic_attack(atomic_attack=atomic) + assert report.verdict is ModalityVerdict.INCOMPATIBLE + assert report.projected_request_types == frozenset({"image_path"}) + assert any("QRCodeConverter" in reason for reason in report.reasons) + + async def _initialize(scenario: Scenario, *, target=None) -> None: """Fill the parameter bag and initialize.""" scenario.set_params_from_args(args={"objective_target": target or get_mock_target()}) From a9c0c4b2afbc0c694e01e60fec697b6cc45eaa18 Mon Sep 17 00:00:00 2001 From: Victor Valbuena Date: Thu, 24 Sep 2026 10:20:50 -0700 Subject: [PATCH 16/19] Remove local reviewer tracker from PR Keep the review working notes out of the feature branch; only the tracked REVIEWER_COMMENTS.md file is removed. No product code or other files change. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 24c08221-0382-4f39-aeda-ec3baac3ff1a --- REVIEWER_COMMENTS.md | 176 ------------------------------------------- 1 file changed, 176 deletions(-) delete mode 100644 REVIEWER_COMMENTS.md diff --git a/REVIEWER_COMMENTS.md b/REVIEWER_COMMENTS.md deleted file mode 100644 index bddf1a12ae..0000000000 --- a/REVIEWER_COMMENTS.md +++ /dev/null @@ -1,176 +0,0 @@ -# PR #2773 reviewer-comment tracker - -Source: Copilot review overview and inline comments, plus Roman Lutz's review, -provided by the user. Fact-checked against local HEAD `c6c07bce1` on -`multimodal`; this does not independently verify a newer remote PR head. -The C1, C2, R1, R2, R3, and R4 fixes are committed. R3 and R4 share a -regression-test helper and were committed together as `d2cea7ed1`. - -| ID | Reviewer | Finding | Verdict | Follow-up | -| --- | --- | --- | --- | --- | -| C1 | Copilot | Fully selected `indexes_to_apply` incorrectly preserves the original request type. | **Committed (`0e9aa2dbe`)** | Ordered piece projection and regressions for all-selected and partially selected messages. | -| C2 | Copilot | Resume validates unsampled attacks before restoring persisted seed groups. | **Committed (`ea6a2d24e`)** | Replay the stored selection before checking modalities; cover sampled and legacy resume. | -| R1 | Roman Lutz | Composite scorer intersects child modalities although non-applicable children can return `[]`. | **Committed (`96aed6786`)** | Union known skippable child modalities; preserve `UNKNOWN` for undeclared or strict children. | -| R2 | Roman Lutz | Response check requires every target output type, unlike selective message scoring. | **Committed (`1c14fad68`)** | Check each output combination against scorer strictness; report `UNKNOWN` for partially scorable alternatives. | -| R3 | Roman Lutz | Response check omits configured response converters. | **Committed (`d2cea7ed1`)** | Project response converter declarations; report `UNKNOWN` for indexed or unsupported projection. | -| R4 | Roman Lutz | A wrapper without `next_message` is assumed to send text. | **Committed (`d2cea7ed1`)** | Treat `SequentialAttack` as `UNKNOWN`; cover actual media-seeded child execution. | - -The review overview's phrase "compound attacks" refers to R1 and R4; its -request for a fresh Copilot review is a workflow suggestion, not another finding. - -## Evidence and scope - -### C1 - fully selected indexes - -**The distinction:** conversion selects and changes **individual pieces by -index** (`pyrit/prompt_normalizer/prompt_normalizer.py:279-288`), but targets -declare compatibility with the **set of types in the complete message**. -The old projection discarded positions too early: a one-piece text message -and a two-piece text message both looked like `{"text"}`. It then assumed -any nonempty index selection left an unconverted type, even when `[0]` -selected the only piece. - -The local fix feeds the ordered first-message pieces into -`project_request_chain`, preserves their positions across converter -configurations, and combines the resulting types only before checking the -target. `[0]` on one text piece produces `{"image_path"}`; `[0]` on two -text pieces produces `{"image_path", "text"}`; `[0, 1]` on two produces -`{"image_path"}`. Factory-level converter checks have no concrete message -and deliberately retain their previous conservative branch projection via -`piece_indexes_known=False`. Runtime converter behavior is unchanged. - -### C2 - resumed plan - -The original ordering applied modality policy to all rebuilt seed groups -*before* narrowing to the persisted plan, so an unsampled incompatible group -could discard an atomic attack needed by a compatible saved group. The local -change retains full-dataset resolution without random resampling, but -reconstructs the stored seed groups via `_apply_persisted_run_plan` (or -`_apply_persisted_objectives` for a legacy result) **before** modality -validation. On resume, default `SKIP` raises `ModalityValidationError` if a -saved group is incompatible: silently removing it would change the run being -resumed. `WARN` retains it, and new-run filtering is unchanged. Legacy run -plan metadata is only written after validation succeeds. Regression tests -exercise both resume formats with incompatible unsampled and saved groups. - -### R1 - composite scorer - -Runtime filters out children that return `[]` before aggregating, even with -AND (`pyrit/score/true_false/true_false_composite_scorer.py:145-174`). -The old intersection declared no types for a text-only child paired with an -image-only child, incorrectly rejecting a working text response. The local -fix unions declared modalities only when every child is known to skip -unsupported pieces. `ScorerPromptValidator` exposes that behavior from its -`enforce_all_pieces_valid` and `raise_on_no_valid_pieces` settings; -`MessageScorer` and the nested wrappers forward it. Undeclared or strict -children yield `None` (`UNKNOWN`) rather than falsely promising -compatibility. New regression coverage scores actual text through disjoint -children with **both OR and AND**, checks plan-time compatibility, and -exercises strict, undeclared, and nested children. Runtime aggregation is -unchanged. - -### R2 - selective response scoring - -The old plan-time check flattened all target output combinations into a -single union and required every type, even though a default message scorer -can filter unsupported pieces. The fix checks **each -combination**: a scorer known to skip unsupported data needs at least one -supported type, while a strict scorer needs every type. If every combination -is scorable, the response-chain verdict is `COMPATIBLE`; if none are, it is -`INCOMPATIBLE`; if some are and others are not, it is `UNKNOWN`. This avoids -rejecting a working mixed text/audio response while preserving strict -validation and not promising that a text scorer can score audio-only output. -The request-chain verdict can still make the overall attack report -`COMPATIBLE` when the response leg is `UNKNOWN`; this is an existing report -aggregation convention and does not cause policy to skip. - -R2 regression coverage first demonstrated the old false rejection, then -exercised a real `SubStringScorer` scoring a text/audio message: the text -scores true and the audio piece is ignored. The branch policy matrix checks -`SKIP`, `WARN`, and `RAISE` for text-only, mixed text/audio, strict mixed, -audio-only, and alternative text-or-audio target outputs (15 cases). The -single mixed response is compatible under every policy; strict mixed and -audio-only are rejected under `SKIP`/`RAISE` and retained under `WARN`; -separate text-or-audio outputs yield a response-leg `UNKNOWN` and are kept. -Cached `origin/main` has no modality-policy module, so no comparison of -policy verdicts *on main* exists; attempts to create an isolated main -worktree were denied by the environment. The main `SubStringScorer` source -still declares only text, but its exact runtime matrix was **not run on -main**. Do not claim that the main runtime matrix was verified. - -### R3 - response converters - -The code and the newer fork head both still forwarded response converters -through `PromptSendingAttack` to `PromptNormalizer`, which converts the -**last** response before scoring; neither exposed them to plan-time -`scorer_accepts`. The regression test ran an offline audio-to-text converter -through the real normalizer, then scored its converted response successfully, -while plan-time validation falsely rejected it before this fix. The local -fix adds an attack response-converter accessor and projects each declared -output combination through type-filtered converter configurations before -testing scorer applicability. Indexed conversion (unknown response piece -positions) and chains that fail projection return `UNKNOWN`, avoiding a -false skip. This does not change runtime conversion or pretend to know -the number and order of pieces in an actual target response. - -### R4 - sequential wrapper - -`SequentialAttack` excludes `next_message` because its children each own -their seed group, target, converters and scorer; the old wrapper check -fabricated a text request and checked the nominal target. The new test -**executes** a real `SequentialChildAttack` with an image seed and image-only -target: the child succeeds, but pre-fix plan-time validation rejected its -wrapper. The local fix returns `UNKNOWN` for the wrapper instead of -misrepresenting its children's requests. That preserves scenario coverage -without claiming child-level validation; a future explicit per-child -first-request contract could add that precision. Direct attacks retain -their existing checks. - -## Verification performed - -The initial fact-check ran four existing tests (4 passed, 70 deselected): -`test_project_request_chain_indexes_to_apply_keeps_both_branches`, -`test_scorer_accepts_missing_emitted_type_is_incompatible`, -`test_composite_scorer_ignores_non_applicable_child`, and -`test_skip_excludes_dropped_attack_from_persisted_run_plan`. The C1 change -adds all-selected, partially selected, chained-index, unknown-index, and -atomic-attack regression coverage. The C2 change adds five resume regression -cases: plan and legacy replay ignore incompatible unsampled groups, both -fail explicitly for incompatible saved groups, and `WARN` retains the saved -selection. The scenario core suite passes (543 tests); targeted Ruff and -`ty` checks pass. For R1, the scorer suite plus the affected scenario -modality and policy tests pass (2,447 tests); targeted Ruff and `ty` checks -also pass. The subsequent R2-R4 work is described below. - -Updated verification for R2-R4: scenario-core, sequential-attack, and -prompt-sending tests pass together (686 passed). Targeted Ruff, formatter, -`ty`, and `git diff --check` pass. The R3 audio-to-text regression first -failed because plan-time saw raw audio, then passed after response projection; -the test also ran the normalizer conversion and actual text score. R4 first -ran a real image-seeded child successfully, then demonstrated a failing -plan-time verdict; after the wrapper fix it returns `UNKNOWN` without -altering child execution. Main-only baseline runtime testing remains blocked -by denied worktree creation, as noted under R2. Staging and commits were denied temporarily while the user was unavailable; -staging succeeded after the user resumed. An isolated main worktree remained -unavailable, so the `main`-only runtime matrix was not executed; see R2. - -## Follow-up: TAP's independently generated first roots - -The related-problem investigation found a false skip for TAP with `tree_width > 1`: -when the target accepts text-only requests, root 0 uses the seed's next message -but sibling roots generate their own text. An image-only seed may fail at root 0 -without preventing text siblings from running. Previously, plan-time validation -projected only the media seed and `SKIP` dropped the entire atomic attack. - -`TreeOfAttacksWithPruningAttack.has_unseeded_first_turn_roots` now exposes when -those sibling text requests exist. Validation projects both first-root paths -through the same request-converter chain; if some roots can run and others -cannot, it reports `UNKNOWN` and retains the attack. Width-one TAP and -media-required targets still check only their actual seeded first requests. -Regression tests cover these cases, a compatible media seed, converters that -make both paths incompatible, and a non-TAP control. This does not change TAP's -runtime branching or claim that the failing seeded root succeeds. - -Focused TAP, modality, and scenario-policy tests passed (224 tests). The -paired scenario documentation was updated. This follow-up was identified -after C1-R4 and is not one of the six original review comments. From 36648c624ddc689292e0b60fa2ea20ac2d928570 Mon Sep 17 00:00:00 2001 From: Victor Valbuena Date: Thu, 24 Sep 2026 10:46:47 -0700 Subject: [PATCH 17/19] Validate effective next-message overrides in scenario plans Expose whether AtomicAttack supplies a constructor-level next_message override without changing execution behavior. Project the override's ordered converted piece types through request converters instead of a seed message that will never be sent; distinguish an explicit None from an absent override and keep unmodelable inputs indeterminate. Cover every declared prompt data type, an actual text-only target receiving text despite an image seed, inverse incompatibility, explicit None, converter projection, and unchanged no-override behavior. Update scenario guidance and mock atomic attacks for the new read-only contract. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 24c08221-0382-4f39-aeda-ec3baac3ff1a --- .../instructions/scenarios.instructions.md | 3 + pyrit/scenario/core/atomic_attack.py | 10 ++ pyrit/scenario/core/modality_validation.py | 23 ++- .../scenario/core/test_modality_validation.py | 149 ++++++++++++++++++ tests/unit/scenario/core/test_scenario.py | 7 + .../core/test_scenario_partial_results.py | 1 + .../unit/scenario/core/test_scenario_retry.py | 1 + 7 files changed, 190 insertions(+), 4 deletions(-) diff --git a/.github/instructions/scenarios.instructions.md b/.github/instructions/scenarios.instructions.md index f728aff00f..455d3eec7e 100644 --- a/.github/instructions/scenarios.instructions.md +++ b/.github/instructions/scenarios.instructions.md @@ -64,6 +64,9 @@ retains it as configured. text piece selected at index 0 and converted to an image leaves no text; selecting only index 0 of two text pieces leaves text in the second piece. Declarations are read literally — a target that accepts a lone image advertises `{image_path}` as well as `{text, image_path}`. + A constructor-supplied `AtomicAttack.next_message` execution override replaces the seed's + message before projection; an explicit `None` selects the objective-text fallback. Inputs + not safely representable at plan time remain `UNKNOWN`. - The **response chain** checks each advertised target output combination against the scorer. Project configured response converters before checking the types the scorer receives. When response piece indexes cannot be known, report `UNKNOWN` rather than guessing which diff --git a/pyrit/scenario/core/atomic_attack.py b/pyrit/scenario/core/atomic_attack.py index 557c82b880..a56f50497c 100644 --- a/pyrit/scenario/core/atomic_attack.py +++ b/pyrit/scenario/core/atomic_attack.py @@ -182,6 +182,16 @@ def attack_technique(self) -> AttackTechnique: """The attack technique for this atomic attack.""" return self._attack_technique + def get_next_message_override(self) -> tuple[bool, object]: + """ + Return whether execution supplies a replacement for the seed's next message. + + Returns: + tuple[bool, object]: Whether a constructor override exists and its value. + A present ``None`` overrides the seed and triggers the attack's fallback. + """ + return "next_message" in self._attack_execute_params, self._attack_execute_params.get("next_message") + @property def technique_name(self) -> str | None: """Catalog name of the technique that built this attack.""" diff --git a/pyrit/scenario/core/modality_validation.py b/pyrit/scenario/core/modality_validation.py index 62fe9d6eda..7acf44bd8d 100644 --- a/pyrit/scenario/core/modality_validation.py +++ b/pyrit/scenario/core/modality_validation.py @@ -292,6 +292,7 @@ def validate_atomic_attack(*, atomic_attack: AtomicAttack) -> ModalityReport: scorer = getattr(scoring_config, "objective_scorer", None) if scoring_config is not None else None reads_next_message = _reads_next_message(attack=attack) + has_next_message_override, next_message_override = atomic_attack.get_next_message_override() verdicts: set[ModalityVerdict] = set() partially_runnable_roots = False reasons: list[str] = [] @@ -302,6 +303,8 @@ def validate_atomic_attack(*, atomic_attack: AtomicAttack) -> ModalityReport: seed_group=seed_group, seed_technique=technique.seed_technique, reads_next_message=reads_next_message, + has_next_message_override=has_next_message_override, + next_message_override=next_message_override, ) if start_types is None: verdicts.add(ModalityVerdict.UNKNOWN) @@ -400,14 +403,17 @@ def _effective_start_types( seed_group: AttackSeedGroup, seed_technique: AttackTechniqueSeedGroup | None, reads_next_message: bool, + has_next_message_override: bool, + next_message_override: object, ) -> list[PromptDataType] | None: """ Determine the data types the first request carries for one seed group. - The technique seed group is merged in first, because a technique's own prompts travel with - the seed. ``with_technique`` raises for a group whose prompt sequences overlap a simulated - conversation, so compatibility is checked first and the unmerged group is used otherwise — - that pairing is rejected elsewhere and is not a modality problem. + A constructor-supplied ``next_message`` replaces the seed-derived message. Otherwise the + technique seed group is merged first, because a technique's prompts travel with the seed. + ``with_technique`` raises when prompt sequences overlap a simulated conversation, so + compatibility is checked first and the unmerged group is used otherwise — that pairing + is rejected elsewhere and is not a modality problem. Returns: list[PromptDataType] | None: The ordered starting piece types, or ``None`` when undeterminable. @@ -415,6 +421,15 @@ def _effective_start_types( if not reads_next_message: return ["text"] + if has_next_message_override: + if next_message_override is None: + return ["text"] + from pyrit.models import Message + + if not isinstance(next_message_override, Message): + return None + return [piece.converted_value_data_type for piece in next_message_override.message_pieces] or ["text"] + group = seed_group if seed_technique is not None: try: diff --git a/tests/unit/scenario/core/test_modality_validation.py b/tests/unit/scenario/core/test_modality_validation.py index e48114f852..9422c095c3 100644 --- a/tests/unit/scenario/core/test_modality_validation.py +++ b/tests/unit/scenario/core/test_modality_validation.py @@ -19,6 +19,7 @@ import asyncio import os +from typing import get_args import pytest from unit.mocks import MockPromptTarget, get_image_message_piece, get_mock_target, store_message @@ -594,6 +595,154 @@ def test_validate_atomic_attack_media_seed_reaches_capable_target(patch_central_ assert validate_atomic_attack(atomic_attack=atomic).verdict is ModalityVerdict.COMPATIBLE +@pytest.mark.parametrize("override_type", get_args(PromptDataType)) +def test_validate_atomic_attack_uses_constructor_message_override_for_every_type( + patch_central_database, override_type: PromptDataType +): + """The effective message type, not the unused seed's type, reaches the target.""" + seed_type: PromptDataType = "text" if override_type == "image_path" else "image_path" + target = get_mock_target(input_modalities=[{override_type}], output_modalities=TEXT_ONLY_MODALITIES) + override = Message( + message_pieces=[MessagePiece(role="user", original_value="override", original_value_data_type=override_type)] + ) + atomic = AtomicAttack( + atomic_attack_name="override", + attack_technique=AttackTechnique(attack=PromptSendingAttack(objective_target=target)), + seed_groups=[_media_seed_group(data_type=seed_type)], + next_message=override, + ) + + report = validate_atomic_attack(atomic_attack=atomic) + assert report.projected_request_types == frozenset({override_type}) + assert report.verdict is ModalityVerdict.COMPATIBLE + + +@pytest.mark.parametrize("override", [None, Message.from_prompt(prompt="override", role="user")]) +def test_validate_atomic_attack_none_or_text_override_replaces_media_seed( + patch_central_database, override: Message | None +): + target = get_mock_target(input_modalities=[{"text"}], output_modalities=TEXT_ONLY_MODALITIES) + atomic = AtomicAttack( + atomic_attack_name="override", + attack_technique=AttackTechnique(attack=PromptSendingAttack(objective_target=target)), + seed_groups=[_media_seed_group()], + next_message=override, + ) + report = validate_atomic_attack(atomic_attack=atomic) + assert report.projected_request_types == frozenset({"text"}) + assert report.verdict is ModalityVerdict.COMPATIBLE + + +def test_validate_atomic_attack_without_override_uses_media_seed(patch_central_database): + target = get_mock_target(input_modalities=[{"text"}], output_modalities=TEXT_ONLY_MODALITIES) + atomic = _atomic(target=target, seed_groups=[_media_seed_group()]) + assert validate_atomic_attack(atomic_attack=atomic).verdict is ModalityVerdict.INCOMPATIBLE + + +def test_validate_atomic_attack_incompatible_override_does_not_use_compatible_seed(patch_central_database): + target = get_mock_target(input_modalities=[{"text"}], output_modalities=TEXT_ONLY_MODALITIES) + override = Message( + message_pieces=[MessagePiece(role="user", original_value="image.png", original_value_data_type="image_path")] + ) + atomic = AtomicAttack( + atomic_attack_name="image_override", + attack_technique=AttackTechnique(attack=PromptSendingAttack(objective_target=target)), + seed_groups=[_text_seed_group()], + next_message=override, + ) + report = validate_atomic_attack(atomic_attack=atomic) + assert report.projected_request_types == frozenset({"image_path"}) + assert report.verdict is ModalityVerdict.INCOMPATIBLE + + +def test_validate_atomic_attack_unmodelable_override_is_unknown(patch_central_database): + """The validator must not reject an unused seed when it cannot model its replacement.""" + target = get_mock_target(input_modalities=[{"text"}], output_modalities=TEXT_ONLY_MODALITIES) + atomic = AtomicAttack( + atomic_attack_name="opaque_override", + attack_technique=AttackTechnique(attack=PromptSendingAttack(objective_target=target)), + seed_groups=[_media_seed_group()], + next_message=object(), + ) + report = validate_atomic_attack(atomic_attack=atomic) + assert report.projected_request_types == frozenset() + assert report.verdict is ModalityVerdict.UNKNOWN + + +@pytest.mark.parametrize( + ("override", "expected_sent"), + [ + (Message.from_prompt(prompt="actual text request", role="user"), "actual text request"), + (None, "a media objective"), + ], +) +async def test_validate_atomic_attack_override_matches_actual_text_request( + patch_central_database, override: Message | None, expected_sent: str +): + """The target sees only the override; the original image path need not exist.""" + target = MockPromptTarget() + target.apply_capabilities( + capabilities=TargetCapabilities( + input_modalities=frozenset({frozenset({"text"})}), + output_modalities=frozenset({frozenset({"text"})}), + ) + ) + atomic = AtomicAttack( + atomic_attack_name="override", + attack_technique=AttackTechnique(attack=PromptSendingAttack(objective_target=target)), + seed_groups=[_media_seed_group(value="nonexistent-seed.png")], + next_message=override, + ) + report = validate_atomic_attack(atomic_attack=atomic) + assert report.projected_request_types == frozenset({"text"}) + assert report.verdict is ModalityVerdict.COMPATIBLE + + result = await atomic.run_async() + assert len(result.completed_results) == 1 + assert not result.incomplete_objectives + assert target.prompt_sent == [expected_sent] + + +def test_validate_atomic_attack_projects_request_converters_after_override(patch_central_database): + """A converter runs on the override pieces, never on the unused media seed.""" + target = get_mock_target(input_modalities=[{"image_path"}], output_modalities=TEXT_ONLY_MODALITIES) + attack = PromptSendingAttack( + objective_target=target, + attack_converter_config=AttackConverterConfig(request_converters=_configs(QRCodeConverter())), + ) + atomic = AtomicAttack( + atomic_attack_name="converted_override", + attack_technique=AttackTechnique(attack=attack), + seed_groups=[_media_seed_group(data_type="audio_path")], + next_message=Message.from_prompt(prompt="text to convert", role="user"), + ) + report = validate_atomic_attack(atomic_attack=atomic) + assert report.projected_request_types == frozenset({"image_path"}) + assert report.verdict is ModalityVerdict.COMPATIBLE + + +def test_validate_atomic_attack_uses_converted_override_piece_type(patch_central_database): + target = get_mock_target(input_modalities=[{"text"}], output_modalities=TEXT_ONLY_MODALITIES) + override = Message( + message_pieces=[ + MessagePiece( + role="user", + original_value="original.png", + original_value_data_type="image_path", + converted_value="converted text", + converted_value_data_type="text", + ) + ] + ) + atomic = AtomicAttack( + atomic_attack_name="converted_override", + attack_technique=AttackTechnique(attack=PromptSendingAttack(objective_target=target)), + seed_groups=[_media_seed_group()], + next_message=override, + ) + assert validate_atomic_attack(atomic_attack=atomic).projected_request_types == frozenset({"text"}) + + async def test_sequential_media_child_is_not_rejected_by_invented_text_request(patch_central_database): """A real sequential child owns and sends its image seed to its image-only target.""" child_target = MockPromptTarget() diff --git a/tests/unit/scenario/core/test_scenario.py b/tests/unit/scenario/core/test_scenario.py index a22d43cca5..9a27f22c08 100644 --- a/tests/unit/scenario/core/test_scenario.py +++ b/tests/unit/scenario/core/test_scenario.py @@ -106,6 +106,7 @@ def mock_atomic_attacks(): mock_attack.get_attack_scoring_config.return_value = MagicMock() run1 = MagicMock(spec=AtomicAttack) + run1.get_next_message_override.return_value = (False, None) run1.atomic_attack_name = "attack_run_1" run1.display_group = "attack_run_1" run1._attack = mock_attack @@ -114,6 +115,7 @@ def mock_atomic_attacks(): type(run1).objectives = PropertyMock(return_value=["objective1"]) run2 = MagicMock(spec=AtomicAttack) + run2.get_next_message_override.return_value = (False, None) run2.atomic_attack_name = "attack_run_2" run2.display_group = "attack_run_2" run2._attack = mock_attack @@ -122,6 +124,7 @@ def mock_atomic_attacks(): type(run2).objectives = PropertyMock(return_value=["objective2"]) run3 = MagicMock(spec=AtomicAttack) + run3.get_next_message_override.return_value = (False, None) run3.atomic_attack_name = "attack_run_3" run3.display_group = "attack_run_3" run3._attack = mock_attack @@ -318,6 +321,7 @@ async def test_initialize_async_deduplicates_logical_seed_groups_in_run_plan(sel AttackSeedGroup(seeds=[SeedObjective(value="duplicate objective")]), ] atomic_attack = MagicMock(spec=AtomicAttack) + atomic_attack.get_next_message_override.return_value = (False, None) atomic_attack.atomic_attack_name = "duplicate_attack" atomic_attack.display_group = "duplicate_attack" atomic_attack.technique_eval_hash = "duplicate-technique" @@ -351,6 +355,7 @@ async def test_build_run_plan_preserves_unique_seed_group_order(self, mock_objec AttackSeedGroup(seeds=[SeedObjective(value="second objective")]), ] atomic_attack = MagicMock(spec=AtomicAttack) + atomic_attack.get_next_message_override.return_value = (False, None) atomic_attack.atomic_attack_name = "unique_attack" atomic_attack.display_group = "custom display group" atomic_attack.technique_name = "test" @@ -755,6 +760,7 @@ async def test_atomic_attack_count_with_different_sizes(self, mock_objective_tar mock_attack.get_attack_scoring_config.return_value = MagicMock() single_run_mock = MagicMock(spec=AtomicAttack) + single_run_mock.get_next_message_override.return_value = (False, None) single_run_mock.atomic_attack_name = "attack_1" single_run_mock.display_group = "attack_1" single_run_mock._attack = mock_attack @@ -777,6 +783,7 @@ async def test_atomic_attack_count_with_different_sizes(self, mock_objective_tar many_runs = [] for i in range(10): run = MagicMock(spec=AtomicAttack) + run.get_next_message_override.return_value = (False, None) run.atomic_attack_name = f"attack_{i}" run.display_group = f"attack_{i}" run._attack = mock_attack diff --git a/tests/unit/scenario/core/test_scenario_partial_results.py b/tests/unit/scenario/core/test_scenario_partial_results.py index cfb3b1e51a..bf025b02c1 100644 --- a/tests/unit/scenario/core/test_scenario_partial_results.py +++ b/tests/unit/scenario/core/test_scenario_partial_results.py @@ -77,6 +77,7 @@ def create_mock_atomic_attack(name: str, objectives: list[str]) -> MagicMock: mock_attack_strategy.get_attack_scoring_config.return_value = MagicMock() attack = MagicMock(spec=AtomicAttack) + attack.get_next_message_override.return_value = (False, None) attack.atomic_attack_name = name attack.display_group = name attack.technique_eval_hash = config_hash({"name": name, "objectives": objectives}) diff --git a/tests/unit/scenario/core/test_scenario_retry.py b/tests/unit/scenario/core/test_scenario_retry.py index d29bd0cd22..24e44780fa 100644 --- a/tests/unit/scenario/core/test_scenario_retry.py +++ b/tests/unit/scenario/core/test_scenario_retry.py @@ -145,6 +145,7 @@ def create_mock_atomic_attack(name: str, objectives: list[str], run_async_mock: mock_attack_strategy.get_attack_scoring_config.return_value = MagicMock() attack = MagicMock(spec=AtomicAttack) + attack.get_next_message_override.return_value = (False, None) attack.atomic_attack_name = name attack.display_group = name attack.technique_eval_hash = config_hash({"name": name, "objectives": objectives}) From 64b7d25170b1ed6c41dc54c87b0713b79d511931 Mon Sep 17 00:00:00 2001 From: Victor Valbuena Date: Thu, 24 Sep 2026 11:28:05 -0700 Subject: [PATCH 18/19] Fix mixed-response compatibility for raise-on-empty scorers Separate acceptance of unsupported pieces alongside readable evidence from the ability to return no score for wholly unreadable evidence. Plan-time validation now accepts mixed text/audio for a text scorer with raise_on_no_valid_pieces=True, without treating that scorer as safely skippable inside a composite. Forward the new read-only capability through scorer wrappers and add regressions for direct scoring, inversion, strict validators, composite applicability, and scenario modality policy. Runtime scoring is unchanged. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 24c08221-0382-4f39-aeda-ec3baac3ff1a --- .../instructions/scenarios.instructions.md | 6 +- .github/instructions/scorers.instructions.md | 7 +++ pyrit/scenario/core/modality_validation.py | 2 +- pyrit/score/message_scorer.py | 5 ++ pyrit/score/scorer.py | 5 ++ pyrit/score/scorer_prompt_validator.py | 5 ++ .../float_scale_threshold_scorer.py | 5 ++ .../true_false/true_false_composite_scorer.py | 5 ++ .../true_false/true_false_inverter_scorer.py | 5 ++ .../scenario/core/test_modality_validation.py | 59 ++++++++++++++++++- .../core/test_scenario_modality_policy.py | 22 ++++--- .../score/test_scorer_modality_accessors.py | 28 +++++++++ .../score/test_true_false_composite_scorer.py | 11 ++++ 13 files changed, 153 insertions(+), 12 deletions(-) diff --git a/.github/instructions/scenarios.instructions.md b/.github/instructions/scenarios.instructions.md index 455d3eec7e..c492c69a40 100644 --- a/.github/instructions/scenarios.instructions.md +++ b/.github/instructions/scenarios.instructions.md @@ -71,8 +71,10 @@ retains it as configured. Project configured response converters before checking the types the scorer receives. When response piece indexes cannot be known, report `UNKNOWN` rather than guessing which pieces were converted. - A scorer that skips unsupported pieces needs at least one readable type in each combination; - a strict scorer needs to read every piece. If only some possible combinations can be scored, + A scorer allowing unsupported pieces alongside readable ones needs at least one readable type + in each combination; a strict scorer needs to read every piece. This differs from the ability + to return no score for a wholly unreadable response, which composites use to assess their + children's applicability. If only some possible combinations can be scored, the response-chain verdict is `UNKNOWN`, not a reason to skip the attack. This type check does not establish that the resulting score is meaningful. - Anything indeterminate is `UNKNOWN` and never blocks a run. diff --git a/.github/instructions/scorers.instructions.md b/.github/instructions/scorers.instructions.md index 6624d7cf52..86573f0bd3 100644 --- a/.github/instructions/scorers.instructions.md +++ b/.github/instructions/scorers.instructions.md @@ -17,6 +17,13 @@ undeclared modalities or may raise instead of skipping, the composite declares ` (`UNKNOWN`) rather than asserting compatibility. Wrappers must forward this skip behavior along with `supported_data_types` so nested composites remain accurate. +For mixed responses, `allows_unsupported_pieces` separately reports whether readable +pieces can be scored alongside unsupported ones. `raise_on_no_valid_pieces=True` still +allows a mixed response when `enforce_all_pieces_valid=False`, but it prevents a wholly +unreadable response from yielding `[]`. Keep that case distinct from +`skips_unsupported_data_types`, which is required before a composite may assume a child +will be non-applicable rather than raise. + ## Constructor contract `Scorer` subclasses MUST use the keyword-only constructor shape: diff --git a/pyrit/scenario/core/modality_validation.py b/pyrit/scenario/core/modality_validation.py index 7acf44bd8d..b28596ecc0 100644 --- a/pyrit/scenario/core/modality_validation.py +++ b/pyrit/scenario/core/modality_validation.py @@ -244,7 +244,7 @@ def scorer_accepts( return ModalityVerdict.UNKNOWN, None projected_modalities.append(projected) - if scorer.skips_unsupported_data_types: + if scorer.allows_unsupported_pieces: scorable = [bool(combination & declared) for combination in projected_modalities] else: scorable = [bool(combination) and combination <= declared for combination in projected_modalities] diff --git a/pyrit/score/message_scorer.py b/pyrit/score/message_scorer.py index a126ccdb00..d0bdd7b13b 100644 --- a/pyrit/score/message_scorer.py +++ b/pyrit/score/message_scorer.py @@ -315,6 +315,11 @@ def skips_unsupported_data_types(self) -> bool: """Whether the message validator ignores unsupported pieces without raising.""" return self._validator.skips_unsupported_data_types + @property + def allows_unsupported_pieces(self) -> bool: + """Whether the message validator accepts a mix of readable and unsupported pieces.""" + return self._validator.allows_unsupported_pieces + def matched_conditions(self) -> frozenset[type[Condition]]: """ Return the conditions this message scorer uses as criteria. diff --git a/pyrit/score/scorer.py b/pyrit/score/scorer.py index 31bc8fe7d5..e1e19d1a2a 100644 --- a/pyrit/score/scorer.py +++ b/pyrit/score/scorer.py @@ -222,6 +222,11 @@ def skips_unsupported_data_types(self) -> bool: """Whether unsupported data types are known to produce no score rather than an error.""" return False + @property + def allows_unsupported_pieces(self) -> bool: + """Whether a readable response may contain pieces this scorer does not support.""" + return False + def matched_conditions(self) -> frozenset[type[Condition]]: """ Return the condition types this scorer can use as its criterion. diff --git a/pyrit/score/scorer_prompt_validator.py b/pyrit/score/scorer_prompt_validator.py index 5fb781b6df..be87dfb420 100644 --- a/pyrit/score/scorer_prompt_validator.py +++ b/pyrit/score/scorer_prompt_validator.py @@ -98,6 +98,11 @@ def skips_unsupported_data_types(self) -> bool: """Whether unsupported pieces can be ignored instead of raising an error.""" return not self._enforce_all_pieces_valid and not self._raise_on_no_valid_pieces + @property + def allows_unsupported_pieces(self) -> bool: + """Whether a readable response can also contain unsupported pieces.""" + return not self._enforce_all_pieces_valid + @property def is_objective_required(self) -> bool: """Whether the scorer uses the objective as a required criterion.""" diff --git a/pyrit/score/true_false/float_scale_threshold_scorer.py b/pyrit/score/true_false/float_scale_threshold_scorer.py index 531216ed61..4c127ffb8c 100644 --- a/pyrit/score/true_false/float_scale_threshold_scorer.py +++ b/pyrit/score/true_false/float_scale_threshold_scorer.py @@ -95,6 +95,11 @@ def skips_unsupported_data_types(self) -> bool: """Whether the wrapped scorer skips unsupported response types.""" return self._scorer.skips_unsupported_data_types + @property + def allows_unsupported_pieces(self) -> bool: + """Whether the wrapped scorer accepts mixed readable and unsupported pieces.""" + return self._scorer.allows_unsupported_pieces + def _build_identifier(self) -> ComponentIdentifier: """ Build the identifier for this scorer. diff --git a/pyrit/score/true_false/true_false_composite_scorer.py b/pyrit/score/true_false/true_false_composite_scorer.py index 67384acab6..e1c3839d1c 100644 --- a/pyrit/score/true_false/true_false_composite_scorer.py +++ b/pyrit/score/true_false/true_false_composite_scorer.py @@ -99,6 +99,11 @@ def skips_unsupported_data_types(self) -> bool: """Whether every child can ignore unsupported data types without raising.""" return all(scorer.skips_unsupported_data_types for scorer in self._scorers) + @property + def allows_unsupported_pieces(self) -> bool: + """Whether every child accepts unsupported pieces alongside readable ones.""" + return all(scorer.allows_unsupported_pieces for scorer in self._scorers) + def _build_identifier(self) -> ComponentIdentifier: """ Build the identifier for this scorer. diff --git a/pyrit/score/true_false/true_false_inverter_scorer.py b/pyrit/score/true_false/true_false_inverter_scorer.py index acf1dc671e..e2184d54ac 100644 --- a/pyrit/score/true_false/true_false_inverter_scorer.py +++ b/pyrit/score/true_false/true_false_inverter_scorer.py @@ -55,6 +55,11 @@ def skips_unsupported_data_types(self) -> bool: """Whether the wrapped scorer skips unsupported response types.""" return self._scorer.skips_unsupported_data_types + @property + def allows_unsupported_pieces(self) -> bool: + """Whether the wrapped scorer accepts mixed readable and unsupported pieces.""" + return self._scorer.allows_unsupported_pieces + def _build_identifier(self) -> ComponentIdentifier: """ Build the identifier for this scorer. diff --git a/tests/unit/scenario/core/test_modality_validation.py b/tests/unit/scenario/core/test_modality_validation.py index 9422c095c3..ab6736324d 100644 --- a/tests/unit/scenario/core/test_modality_validation.py +++ b/tests/unit/scenario/core/test_modality_validation.py @@ -67,7 +67,13 @@ target_accepts, validate_atomic_attack, ) -from pyrit.score import MessageScorable, SubStringScorer, TrueFalseCompositeScorer, TrueFalseScoreAggregator +from pyrit.score import ( + MessageScorable, + SubStringScorer, + TrueFalseCompositeScorer, + TrueFalseInverterScorer, + TrueFalseScoreAggregator, +) from pyrit.score.scorer_prompt_validator import ScorerPromptValidator @@ -440,6 +446,57 @@ def test_scorer_accepts_mixed_response_with_strict_text_scorer(): assert scorer_accepts(scorer=scorer, target=target)[0] is ModalityVerdict.INCOMPATIBLE +@pytest.mark.parametrize("inverted", [False, True]) +async def test_scorer_accepts_mixed_response_when_empty_score_raises(patch_central_database, inverted: bool): + """Raising on wholly unreadable evidence does not prevent selective mixed-piece scoring.""" + text_scorer = SubStringScorer( + substring="matched", + validator=ScorerPromptValidator(supported_data_types=["text"], raise_on_no_valid_pieces=True), + ) + scorer = TrueFalseInverterScorer(scorer=text_scorer) if inverted else text_scorer + response = Message( + message_pieces=[ + MessagePiece(role="assistant", original_value="matched text"), + MessagePiece(role="assistant", original_value="audio.wav", original_value_data_type="audio_path"), + ] + ) + scores = await scorer.score_async(scorable=MessageScorable.from_message(store_message(response))) + assert len(scores) == 1 + assert scores[0].get_value() is not inverted + + target = get_mock_target(output_modalities=[{"text", "audio_path"}]) + assert scorer_accepts(scorer=scorer, target=target)[0] is ModalityVerdict.COMPATIBLE + + +@pytest.mark.parametrize("inverted", [False, True]) +async def test_scorer_accepts_audio_only_when_empty_score_raises(patch_central_database, inverted: bool): + """The same scorer cannot read an audio-only response and must still raise.""" + text_scorer = SubStringScorer( + substring="matched", + validator=ScorerPromptValidator(supported_data_types=["text"], raise_on_no_valid_pieces=True), + ) + scorer = TrueFalseInverterScorer(scorer=text_scorer) if inverted else text_scorer + target = get_mock_target(output_modalities=[{"audio_path"}]) + assert scorer_accepts(scorer=scorer, target=target)[0] is ModalityVerdict.INCOMPATIBLE + response = Message( + message_pieces=[ + MessagePiece(role="assistant", original_value="audio.wav", original_value_data_type="audio_path") + ] + ) + expected_error = RuntimeError if inverted else ValueError + with pytest.raises(expected_error, match="There are no valid pieces to score"): + await scorer.score_async(scorable=MessageScorable.from_message(store_message(response))) + + +def test_scorer_accepts_alternative_output_when_empty_score_raises(patch_central_database): + scorer = SubStringScorer( + substring="matched", + validator=ScorerPromptValidator(supported_data_types=["text"], raise_on_no_valid_pieces=True), + ) + target = get_mock_target(output_modalities=[{"text", "audio_path"}, {"audio_path"}]) + assert scorer_accepts(scorer=scorer, target=target)[0] is ModalityVerdict.UNKNOWN + + def test_scorer_accepts_audio_only_response_with_text_scorer(): target = get_mock_target(output_modalities=[{"audio_path"}]) assert scorer_accepts(scorer=_scorer_declaring(["text"]), target=target)[0] is ModalityVerdict.INCOMPATIBLE diff --git a/tests/unit/scenario/core/test_scenario_modality_policy.py b/tests/unit/scenario/core/test_scenario_modality_policy.py index 0dfa57d51e..41dbee1b72 100644 --- a/tests/unit/scenario/core/test_scenario_modality_policy.py +++ b/tests/unit/scenario/core/test_scenario_modality_policy.py @@ -310,17 +310,19 @@ async def test_skip_excludes_dropped_attack_from_persisted_run_plan(patch_centra @pytest.mark.parametrize("policy", list(ModalityPolicy)) @pytest.mark.parametrize( - ("outputs", "strict", "incompatible"), + ("outputs", "strict", "raise_on_empty", "incompatible"), [ - ([{"text"}], False, False), - ([{"text", "audio_path"}], False, False), - ([{"text", "audio_path"}], True, True), - ([{"audio_path"}], False, True), - ([{"text"}, {"audio_path"}], False, False), + ([{"text"}], False, False, False), + ([{"text", "audio_path"}], False, False, False), + ([{"text", "audio_path"}], False, True, False), + ([{"text", "audio_path"}], True, False, True), + ([{"audio_path"}], False, False, True), + ([{"audio_path"}], False, True, True), + ([{"text"}, {"audio_path"}], False, False, False), ], ) async def test_modality_policy_selective_text_scorer_output_matrix( - patch_central_database, policy, outputs, strict, incompatible + patch_central_database, policy, outputs, strict, raise_on_empty, incompatible ): """A working mixed response survives all policies; unscorable responses obey policy.""" @@ -328,7 +330,11 @@ class _ConfiguredPolicyScenario(_PolicyScenario): MODALITY_POLICY: ClassVar[ModalityPolicy] = policy target = get_mock_target(input_modalities=TEXT_ONLY_MODALITIES, output_modalities=outputs) - validator = ScorerPromptValidator(supported_data_types=["text"], enforce_all_pieces_valid=strict) + validator = ScorerPromptValidator( + supported_data_types=["text"], + enforce_all_pieces_valid=strict, + raise_on_no_valid_pieces=raise_on_empty, + ) scorer = SubStringScorer(substring="match", validator=validator) scenario = _ConfiguredPolicyScenario(atomic_attacks_to_return=[_atomic(target=target, scorer=scorer)]) diff --git a/tests/unit/score/test_scorer_modality_accessors.py b/tests/unit/score/test_scorer_modality_accessors.py index 3aaba3b63d..b1f494e9e2 100644 --- a/tests/unit/score/test_scorer_modality_accessors.py +++ b/tests/unit/score/test_scorer_modality_accessors.py @@ -81,6 +81,20 @@ def test_validator_skip_contract_reflects_strictness(options, expected): assert validator.skips_unsupported_data_types is expected +@pytest.mark.parametrize( + ("options", "expected"), + [ + ({}, True), + ({"raise_on_no_valid_pieces": True}, True), + ({"enforce_all_pieces_valid": True}, False), + ({"enforce_all_pieces_valid": True, "raise_on_no_valid_pieces": True}, False), + ], +) +def test_validator_allows_unsupported_pieces_independently_of_raise_on_empty(options, expected): + validator = ScorerPromptValidator(supported_data_types=["text"], **options) + assert validator.allows_unsupported_pieces is expected + + def test_validator_supported_data_types_undeclared_returns_all_types(): """ Undeclared falls back to every PromptDataType and is marked as *not* declared. @@ -122,6 +136,7 @@ def test_scorer_base_supported_data_types_is_none_for_non_message_scorer(): scorer = ManualScorer(value=True, rationale="r", user_identifier="u") assert scorer.supported_data_types is None assert scorer.skips_unsupported_data_types is False + assert scorer.allows_unsupported_pieces is False # --------------------------------------------------------------------------- @@ -132,6 +147,16 @@ def test_message_scorer_declared_types_returns_frozenset(): scorer = SubStringScorer(substring="x") assert scorer.supported_data_types == frozenset({"text"}) assert scorer.skips_unsupported_data_types is True + assert scorer.allows_unsupported_pieces is True + + +def test_message_scorer_can_filter_pieces_but_cannot_skip_empty_score(): + scorer = SubStringScorer( + substring="x", + validator=ScorerPromptValidator(supported_data_types=["text"], raise_on_no_valid_pieces=True), + ) + assert scorer.allows_unsupported_pieces is True + assert scorer.skips_unsupported_data_types is False def test_message_scorer_multi_type_declaration_round_trips(): @@ -158,8 +183,10 @@ def test_message_scorer_round_trips_every_data_type(data_type: PromptDataType): def test_threshold_scorer_delegates_to_wrapped_scorer(): """FloatScaleThresholdScorer reports whatever its wrapped FloatScaleScorer reports.""" wrapped = _float_scale_scorer_declaring(frozenset({"text", "image_path"})) + type(wrapped).allows_unsupported_pieces = PropertyMock(return_value=True) scorer = FloatScaleThresholdScorer(scorer=wrapped, threshold=0.5) assert scorer.supported_data_types == frozenset({"text", "image_path"}) + assert scorer.allows_unsupported_pieces is True def test_threshold_scorer_delegates_none(): @@ -173,6 +200,7 @@ def test_inverter_scorer_delegates_to_wrapped_scorer(): scorer = TrueFalseInverterScorer(scorer=_substring_scorer(declared=["audio_path"])) assert scorer.supported_data_types == frozenset({"audio_path"}) assert scorer.skips_unsupported_data_types is True + assert scorer.allows_unsupported_pieces is True def test_inverter_scorer_delegates_none(): diff --git a/tests/unit/score/test_true_false_composite_scorer.py b/tests/unit/score/test_true_false_composite_scorer.py index 365903e19a..70234b9efa 100644 --- a/tests/unit/score/test_true_false_composite_scorer.py +++ b/tests/unit/score/test_true_false_composite_scorer.py @@ -256,6 +256,17 @@ def test_composite_scorer_strict_child_modality_is_unknown( assert scorer.supported_data_types is None +async def test_composite_scorer_raise_on_empty_child_does_not_claim_safe_skip(mock_request, true_scorer, false_scorer): + true_scorer._validator = ScorerPromptValidator(supported_data_types=["text"]) + false_scorer._validator = ScorerPromptValidator(supported_data_types=["image_path"], raise_on_no_valid_pieces=True) + scorer = TrueFalseCompositeScorer(aggregator=TrueFalseScoreAggregator.OR, scorers=[true_scorer, false_scorer]) + assert scorer.supported_data_types is None + assert scorer.skips_unsupported_data_types is False + assert scorer.allows_unsupported_pieces is True + with pytest.raises(RuntimeError, match="There are no valid pieces to score"): + await scorer.score_async(scorable=MessageScorable.from_message(store_message(mock_request))) + + async def test_composite_scorer_strict_child_raises_instead_of_skipping(mock_request, true_scorer, false_scorer): """The strict child prevents the composite from scoring text despite a text-capable sibling.""" true_scorer._validator = ScorerPromptValidator(supported_data_types=["text"]) From 617beaabeb87bcfdedbf2b30a1d73eb9fae8ed92 Mon Sep 17 00:00:00 2001 From: Victor Valbuena Date: Thu, 24 Sep 2026 12:42:12 -0700 Subject: [PATCH 19/19] Preserve alternative converter outputs during modality projection Project each piece's possible output types through the ordered converter chain without treating alternative results as pieces of the same message. Compare each possible final message to the target's exact input combinations; accept all-compatible outcomes, reject only wholly incompatible outcomes, and keep partial or excessive branching indeterminate. Use the same alternatives for response-to-scorer projection while retaining the conservative union for factory append checks. Cover runtime output selection, multi-piece indexes, converter-chain failures, response conversion, and bounded branching; document the 256-combination limit and the scorer-refactor UNKNOWN tradeoff. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 24c08221-0382-4f39-aeda-ec3baac3ff1a --- .../instructions/scenarios.instructions.md | 11 +- pyrit/scenario/core/modality_validation.py | 109 ++++++++++++------ .../scenario/core/test_modality_validation.py | 99 ++++++++++++++++ 3 files changed, 182 insertions(+), 37 deletions(-) diff --git a/.github/instructions/scenarios.instructions.md b/.github/instructions/scenarios.instructions.md index 5e5dd419fc..451f5c2584 100644 --- a/.github/instructions/scenarios.instructions.md +++ b/.github/instructions/scenarios.instructions.md @@ -57,8 +57,12 @@ instead of removing an incompatible saved attack and silently changing the run p retains it as configured. - The **request chain** projects each seed group's data types through the attack's request - converters; the resulting set of data types must be exactly one of the target's advertised - `input_modalities` combinations. Conversion selection (including `indexes_to_apply`) is + converters; each possible final message combination is checked against the target's + advertised `input_modalities` combinations. A converter's multiple declared output types + are alternatives for one piece, not simultaneous message pieces. When only some outcomes + can reach the target, validation reports `UNKNOWN` rather than skipping the whole attack. + Projection is bounded at 256 piece-type combinations; larger searches are also `UNKNOWN`. + Conversion selection (including `indexes_to_apply`) is **per message piece**: preserve ordered pieces through each converter configuration, then collapse their resulting types to a **message-level set** for target compatibility. A single text piece selected at index 0 and converted to an image leaves no text; selecting only @@ -68,7 +72,8 @@ retains it as configured. message before projection; an explicit `None` selects the objective-text fallback. Inputs not safely representable at plan time remain `UNKNOWN`. - The **response chain** checks each advertised target output combination against the scorer. - Project configured response converters before checking the types the scorer receives. + Project alternative outputs of configured response converters separately before checking + the types the scorer receives. When response piece indexes cannot be known, report `UNKNOWN` rather than guessing which pieces were converted. A scorer allowing unsupported pieces alongside readable ones needs at least one readable type diff --git a/pyrit/scenario/core/modality_validation.py b/pyrit/scenario/core/modality_validation.py index b28596ecc0..951f478a7c 100644 --- a/pyrit/scenario/core/modality_validation.py +++ b/pyrit/scenario/core/modality_validation.py @@ -30,6 +30,7 @@ import logging from dataclasses import dataclass, field from enum import Enum +from itertools import product from typing import TYPE_CHECKING if TYPE_CHECKING: @@ -100,7 +101,7 @@ class ModalityReport: #: Human-readable explanations, each naming the converter, target, or scorer that failed. reasons: tuple[str, ...] = field(default_factory=tuple) - #: The union of data types the request chain produces across this attack's seed groups. + #: All possible request data types, not necessarily present in the same message. projected_request_types: frozenset[PromptDataType] = field(default_factory=frozenset) @@ -111,12 +112,11 @@ def project_request_chain( piece_indexes_known: bool = True, ) -> tuple[set[PromptDataType], str | None]: """ - Project the ordered piece types through a request-converter chain. + Return the possible output types for converter append checks. - Converter selection is per piece; target compatibility is checked against the resulting - message-level set of types. Retain piece positions until every configuration has run. - When the caller cannot know the message's indexes, an indexed configuration conservatively - retains both the converted and original types. + This union is conservative when the message shape is unknown. For target compatibility, + use ``project_request_combinations`` so alternative types are not mistaken for pieces + of one message. Args: start_types (Sequence[PromptDataType]): The ordered types of the request's message pieces. @@ -126,11 +126,41 @@ def project_request_chain( Set to ``False`` when projecting a factory without a concrete message. Returns: - tuple[set[PromptDataType], str | None]: The data types that can reach the target, and - ``None``; or an empty set and a message naming the first converter that could not accept - the type reaching it. + tuple[set[PromptDataType], str | None]: Possible output types, or an empty set + and the reason a converter cannot accept a possible type. + """ + combinations, failure_reason = project_request_combinations( + start_types=start_types, request_converters=request_converters, piece_indexes_known=piece_indexes_known + ) + if combinations is None or failure_reason is not None: + return set(), failure_reason or "Too many possible converter output combinations to check safely" + return {data_type for combination in combinations for data_type in combination}, None + + +def project_request_combinations( + *, + start_types: Sequence[PromptDataType], + request_converters: Sequence[ConverterConfiguration], + piece_indexes_known: bool = True, + max_combinations: int = 256, +) -> tuple[set[frozenset[PromptDataType]] | None, str | None]: + """ + Project individual message pieces without merging alternative converter outputs. + + Args: + start_types (Sequence[PromptDataType]): Ordered input piece types. + request_converters (Sequence[ConverterConfiguration]): Configurations in runtime order. + piece_indexes_known (bool): Whether the input piece indexes are known. + max_combinations (int): Maximum enumerated final piece-type combinations. + + Returns: + tuple[set[frozenset[PromptDataType]] | None, str | None]: Possible final message + type combinations and the first converter failure on any branch. ``None`` means + the possibilities exceed the bound and compatibility cannot be determined. A + failure alongside successful combinations makes the overall verdict indeterminate. """ piece_types: list[set[PromptDataType]] = [{data_type} for data_type in start_types] + failure_reason: str | None = None for configuration in request_converters: for index, types in enumerate(piece_types): @@ -150,10 +180,13 @@ def project_request_chain( for converter in configuration.converters: unsupported = sorted(t for t in converted_types if not converter.input_supported(t)) if unsupported: - return set(), ( - f"{type(converter).__name__} does not accept {unsupported}; " - f"it accepts {sorted(converter.supported_input_types)}" - ) + if failure_reason is None: + failure_reason = ( + f"{type(converter).__name__} does not accept {unsupported}; " + f"it accepts {sorted(converter.supported_input_types)}" + ) + converted_types = set() + break converted_types = set(converter.supported_output_types) next_types.update(converted_types) @@ -161,10 +194,12 @@ def project_request_chain( next_types.add(data_type) piece_types[index] = next_types - output_types: set[PromptDataType] = set() + count = 1 for types in piece_types: - output_types.update(types) - return output_types, None + count *= len(types) + if count > max_combinations: + return None, failure_reason + return {frozenset(types) for types in product(*piece_types)}, failure_reason def target_accepts(*, target: PromptTarget, request_types: set[PromptDataType]) -> ModalityVerdict: @@ -235,14 +270,14 @@ def scorer_accepts( if any(configuration.indexes_to_apply for configuration in response_converters): return ModalityVerdict.UNKNOWN, None - projected_modalities: list[set[PromptDataType]] = [] + projected_modalities: list[frozenset[PromptDataType]] = [] for combination in output_modalities: - projected, failure = project_request_chain( + projected, failure = project_request_combinations( start_types=sorted(combination), request_converters=response_converters ) - if failure is not None: + if projected is None or failure is not None: return ModalityVerdict.UNKNOWN, None - projected_modalities.append(projected) + projected_modalities.extend(projected) if scorer.allows_unsupported_pieces: scorable = [bool(combination & declared) for combination in projected_modalities] @@ -317,28 +352,34 @@ def validate_atomic_attack(*, atomic_attack: AtomicAttack) -> ModalityReport: root_verdicts: list[ModalityVerdict] = [] root_reasons: list[str] = [] for types in root_types: - projected, failure_reason = project_request_chain(start_types=types, request_converters=request_converters) + combinations, failure_reason = project_request_combinations( + start_types=types, request_converters=request_converters + ) + if combinations is None: + root_verdicts.append(ModalityVerdict.UNKNOWN) + continue if failure_reason is not None: root_verdicts.append(ModalityVerdict.INCOMPATIBLE) root_reasons.append(failure_reason) - continue - - projected_all |= projected - request_verdict = target_accepts(target=target, request_types=projected) - root_verdicts.append(request_verdict) - if request_verdict is ModalityVerdict.INCOMPATIBLE: - root_reasons.append( - f"objective target does not accept {sorted(projected)}; " - f"it accepts {_format_modalities(_read_modalities(target=target, direction='input'))}" - ) + for projected in combinations: + projected_all.update(projected) + request_verdict = target_accepts(target=target, request_types=set(projected)) + root_verdicts.append(request_verdict) + if request_verdict is ModalityVerdict.INCOMPATIBLE: + root_reasons.append( + f"objective target does not accept {sorted(projected)}; " + f"it accepts {_format_modalities(_read_modalities(target=target, direction='input'))}" + ) if ModalityVerdict.COMPATIBLE in root_verdicts and ModalityVerdict.INCOMPATIBLE in root_verdicts: partially_runnable_roots = True verdicts.add(ModalityVerdict.UNKNOWN) - reasons.append( - "TAP seeded root and generated text roots have mixed compatibility; " - "at least one root can run: " + "; ".join(root_reasons) + mixed_reason = ( + "TAP seeded root and generated text roots have mixed compatibility" + if isinstance(attack, TreeOfAttacksWithPruningAttack) and attack.has_unseeded_first_turn_roots + else "Possible first-turn requests have mixed compatibility" ) + reasons.append(f"{mixed_reason}; at least one can run: " + "; ".join(root_reasons)) else: verdicts.update(root_verdicts) for reason in root_reasons: diff --git a/tests/unit/scenario/core/test_modality_validation.py b/tests/unit/scenario/core/test_modality_validation.py index 8da99b8efa..3b163d4c91 100644 --- a/tests/unit/scenario/core/test_modality_validation.py +++ b/tests/unit/scenario/core/test_modality_validation.py @@ -63,6 +63,7 @@ ModalityValidationError, ModalityVerdict, project_request_chain, + project_request_combinations, scorer_accepts, target_accepts, validate_atomic_attack, @@ -92,6 +93,20 @@ async def convert_async(self, *, prompt: str, input_type: PromptDataType = "audi return ConverterResult(output_text="matched transcript", output_type="text") +class _TextOrImageConverter(Converter): + SUPPORTED_INPUT_TYPES = ("text",) + SUPPORTED_OUTPUT_TYPES = ("text", "image_path") + + def __init__(self, *, output_type: PromptDataType = "text") -> None: + super().__init__() + self._output_type = output_type + + async def convert_async(self, *, prompt: str, input_type: PromptDataType = "text") -> ConverterResult: + if not self.input_supported(input_type): + raise ValueError(f"Unsupported input type: {input_type}") + return ConverterResult(output_text=prompt, output_type=self._output_type) + + def _scorer_declaring(declared) -> SubStringScorer: """A real scorer whose validator declares ``declared``, or nothing when ``None``.""" validator = ( @@ -311,6 +326,33 @@ def test_project_request_chain_multi_type_start_projects_each_independently(): assert reason is None +def test_project_request_combinations_preserves_alternative_outputs(): + combinations, reason = project_request_combinations( + start_types=["text"], request_converters=_configs(_TextOrImageConverter()) + ) + assert combinations == {frozenset({"text"}), frozenset({"image_path"})} + assert reason is None + + +def test_project_request_combinations_preserves_piece_positions_and_indexes(): + configuration = ConverterConfiguration(converters=[_TextOrImageConverter()], indexes_to_apply=[0]) + combinations, reason = project_request_combinations( + start_types=["text", "text"], request_converters=[configuration] + ) + assert combinations == {frozenset({"text"}), frozenset({"text", "image_path"})} + assert reason is None + + +def test_project_request_combinations_branches_through_subsequent_converter(): + combinations, reason = project_request_combinations( + start_types=["text"], + request_converters=_configs(_TextOrImageConverter(), ImageCompressionConverter()), + ) + assert combinations == {frozenset({"image_path"})} + assert reason is not None + assert "ImageCompressionConverter" in reason + + # --------------------------------------------------------------------------- # Target acceptance (request chain terminus) # --------------------------------------------------------------------------- @@ -553,6 +595,19 @@ def test_scorer_accepts_indexed_response_conversion_is_unknown(patch_central_dat assert validate_atomic_attack(atomic_attack=atomic).verdict is not ModalityVerdict.INCOMPATIBLE +def test_scorer_accepts_alternative_response_converter_outputs(patch_central_database): + target = get_mock_target(output_modalities=[{"text"}]) + scorer = SubStringScorer( + substring="matched", + validator=ScorerPromptValidator(supported_data_types=["text"], enforce_all_pieces_valid=True), + ) + verdict, reason = scorer_accepts( + scorer=scorer, target=target, response_converters=_configs(_TextOrImageConverter()) + ) + assert verdict is ModalityVerdict.UNKNOWN + assert reason is None + + def test_scorer_accepts_none_scorer_is_unknown(): """No scorer means nothing to check.""" target = get_mock_target(output_modalities=[{"image_path"}]) @@ -609,6 +664,50 @@ def test_validate_atomic_attack_image_converter_into_vision_target_is_compatible assert report.verdict is ModalityVerdict.COMPATIBLE +@pytest.mark.parametrize("output_type", ["text", "image_path"]) +async def test_validate_atomic_attack_alternative_converter_matches_runtime( + patch_central_database, output_type: PromptDataType +): + """Each run produces one supported message type, not a synthetic mixed request.""" + target = get_mock_target(input_modalities=[{"text"}, {"image_path"}], output_modalities=TEXT_ONLY_MODALITIES) + converter = _TextOrImageConverter(output_type=output_type) + atomic = _atomic(target=target, converters=[converter]) + response = Message.from_prompt(prompt="request", role="user") + await PromptNormalizer().convert_values_async(converter_configurations=_configs(converter), message=response) + actual = {piece.converted_value_data_type for piece in response.message_pieces} + assert actual == {output_type} + assert target_accepts(target=target, request_types=actual) is ModalityVerdict.COMPATIBLE + report = validate_atomic_attack(atomic_attack=atomic) + assert report.verdict is ModalityVerdict.COMPATIBLE + assert report.projected_request_types == frozenset({"text", "image_path"}) + + +def test_validate_atomic_attack_some_alternatives_are_incompatible(patch_central_database): + target = get_mock_target(input_modalities=[{"text"}], output_modalities=TEXT_ONLY_MODALITIES) + report = validate_atomic_attack(atomic_attack=_atomic(target=target, converters=[_TextOrImageConverter()])) + assert report.verdict is ModalityVerdict.UNKNOWN + + +def test_validate_atomic_attack_no_alternatives_are_compatible(patch_central_database): + target = get_mock_target(input_modalities=[{"audio_path"}], output_modalities=TEXT_ONLY_MODALITIES) + report = validate_atomic_attack(atomic_attack=_atomic(target=target, converters=[_TextOrImageConverter()])) + assert report.verdict is ModalityVerdict.INCOMPATIBLE + + +def test_validate_atomic_attack_alternative_converter_does_not_invent_mixed_message(patch_central_database): + target = get_mock_target(input_modalities=[{"text", "image_path"}], output_modalities=TEXT_ONLY_MODALITIES) + report = validate_atomic_attack(atomic_attack=_atomic(target=target, converters=[_TextOrImageConverter()])) + assert report.verdict is ModalityVerdict.INCOMPATIBLE + + +def test_validate_atomic_attack_many_alternatives_are_unknown(patch_central_database): + """Bounded projection does not turn an explosion of possibilities into a false rejection.""" + target = get_mock_target(input_modalities=[{"text"}], output_modalities=TEXT_ONLY_MODALITIES) + group = AttackSeedGroup(seeds=[SeedObjective(value="objective"), *(SeedPrompt(value=str(i)) for i in range(9))]) + atomic = _atomic(target=target, converters=[_TextOrImageConverter()], seed_groups=[group]) + assert validate_atomic_attack(atomic_attack=atomic).verdict is ModalityVerdict.UNKNOWN + + def test_validate_atomic_attack_fully_selected_index_reaches_image_only_target(patch_central_database): """A one-piece text request converted at index zero no longer contains text.""" target = get_mock_target(input_modalities=[{"image_path"}], output_modalities=TEXT_ONLY_MODALITIES)