diff --git a/.github/instructions/scenarios.instructions.md b/.github/instructions/scenarios.instructions.md index e9e18680df..451f5c2584 100644 --- a/.github/instructions/scenarios.instructions.md +++ b/.github/instructions/scenarios.instructions.md @@ -46,6 +46,55 @@ 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. 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; 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 + 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 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 + 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. After the scorer condition-routing + refactor, composite scorers declare their response modality compatibility as `UNKNOWN` until + their per-child applicability can be reconciled with the new expectation-selection contract; + this keeps potentially runnable attacks but defers some early incompatibility detection. +- 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. + ## Constructor Pattern ```python diff --git a/.github/instructions/scorers.instructions.md b/.github/instructions/scorers.instructions.md index 0f8a9a91d1..55c32a2ce5 100644 --- a/.github/instructions/scorers.instructions.md +++ b/.github/instructions/scorers.instructions.md @@ -8,6 +8,23 @@ 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`. After the expectation-routing refactor, composite modality inference +remains deferred: the composite declares `None` (`UNKNOWN`) so plan-time validation does not +skip an attack on an unverified union of child types. This reduces early rejection but does +not change runtime scoring or each child's condition selection. One-to-one wrappers +(`TrueFalseInverterScorer` and `FloatScaleThresholdScorer`) still delegate their child's +modality declaration and skip behavior. + +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/doc/code/scenarios/0_scenarios.ipynb b/doc/code/scenarios/0_scenarios.ipynb index 4cb38c0b58..af8b2ed0ce 100644 --- a/doc/code/scenarios/0_scenarios.ipynb +++ b/doc/code/scenarios/0_scenarios.ipynb @@ -477,7 +477,31 @@ " 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.\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 aa01af4930..344df0f005 100644 --- a/doc/code/scenarios/0_scenarios.py +++ b/doc/code/scenarios/0_scenarios.py @@ -194,6 +194,30 @@ 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. +# 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/core/attack_strategy.py b/pyrit/executor/attack/core/attack_strategy.py index 279d91ddc6..b5677608f1 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/executor/attack/multi_turn/tree_of_attacks.py b/pyrit/executor/attack/multi_turn/tree_of_attacks.py index c4499d6f19..e3263443db 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/__init__.py b/pyrit/scenario/core/__init__.py index 7e1852274e..e17aee64b1 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, @@ -42,6 +48,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/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/attack_technique_factory.py b/pyrit/scenario/core/attack_technique_factory.py index 976fe587ad..ba9fbb3d42 100644 --- a/pyrit/scenario/core/attack_technique_factory.py +++ b/pyrit/scenario/core/attack_technique_factory.py @@ -37,7 +37,6 @@ AttackTechniqueSeedGroup, ComponentIdentifier, Identifiable, - PromptDataType, SeedIdentifier, SeedPrompt, SeedSimulatedConversation, @@ -48,6 +47,7 @@ ) 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: @@ -493,32 +493,15 @@ 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, + piece_indexes_known=False, + ) + 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..951f478a7c --- /dev/null +++ b/pyrit/scenario/core/modality_validation.py @@ -0,0 +1,537 @@ +# 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. + +Two chains are checked, both for turn 0 only: + +* 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. + +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". + +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 itertools import product +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + from collections.abc import 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" + + #: Compatibility is indeterminate or only some first-turn roots can run. + 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) + + #: All possible request data types, not necessarily present in the same message. + projected_request_types: frozenset[PromptDataType] = field(default_factory=frozenset) + + +def project_request_chain( + *, + start_types: Sequence[PromptDataType], + request_converters: Sequence[ConverterConfiguration], + piece_indexes_known: bool = True, +) -> tuple[set[PromptDataType], str | None]: + """ + Return the possible output types for converter append checks. + + 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. + 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]: 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): + if piece_indexes_known and configuration.indexes_to_apply and index not in configuration.indexes_to_apply: + continue + + 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: + 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) + + if not piece_indexes_known and configuration.indexes_to_apply: + next_types.add(data_type) + piece_types[index] = next_types + + count = 1 + for types in piece_types: + 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: + """ + Check a projected request against a target's advertised input modality combinations. + + 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. + request_types (set[PromptDataType]): The data types reaching the target. + + Returns: + ModalityVerdict: ``UNKNOWN`` when the target's capabilities cannot be read, otherwise + 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 + if frozenset(request_types) in supported: + return ModalityVerdict.COMPATIBLE + return ModalityVerdict.INCOMPATIBLE + + +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. + + 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. + 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. + """ + 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 + + 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[frozenset[PromptDataType]] = [] + for combination in output_modalities: + projected, failure = project_request_combinations( + start_types=sorted(combination), request_converters=response_converters + ) + if projected is None or failure is not None: + return ModalityVerdict.UNKNOWN, None + projected_modalities.extend(projected) + + 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] + + 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"{[sorted(combination) for combination in projected_modalities]}; it declares {sorted(declared)}" + ) + + +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. 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. + + Returns: + ModalityReport: The verdict and the reasons behind it. + """ + name = getattr(atomic_attack, "atomic_attack_name", "") + 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 + from pyrit.executor.attack.multi_turn.tree_of_attacks import TreeOfAttacksWithPruningAttack + + 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() + 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] = [] + 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, + has_next_message_override=has_next_message_override, + next_message_override=next_message_override, + ) + if start_types is None: + verdicts.add(ModalityVerdict.UNKNOWN) + 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: + 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) + 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) + 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: + if reason not in reasons: + reasons.append(reason) + + 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) + + 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 + 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, + has_next_message_override: bool, + next_message_override: object, +) -> list[PromptDataType] | None: + """ + Determine the data types the first request carries for one seed group. + + 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. + """ + 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: + 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 + # 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: + """ + 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/pyrit/scenario/core/scenario.py b/pyrit/scenario/core/scenario.py index 8fb8ce704c..4f14de52aa 100644 --- a/pyrit/scenario/core/scenario.py +++ b/pyrit/scenario/core/scenario.py @@ -54,6 +54,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 @@ -130,6 +136,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 @@ -958,6 +970,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) @@ -977,6 +991,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) + 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 @@ -1005,6 +1021,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) @@ -1468,6 +1486,67 @@ 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], is_resume: bool = False + ) -> list[AtomicAttack]: + """ + Derive each atomic attack's modality compatibility and apply ``MODALITY_POLICY``. + + 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 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] + 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 + + 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)}") + + 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/pyrit/score/audio_transcript_scorer.py b/pyrit/score/audio_transcript_scorer.py index 94547ccc9f..0ff5a919d9 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, expectation: ScoringExpectation | None diff --git a/pyrit/score/message_scorer.py b/pyrit/score/message_scorer.py index 279d8a2968..f21b36db48 100644 --- a/pyrit/score/message_scorer.py +++ b/pyrit/score/message_scorer.py @@ -26,6 +26,7 @@ MessagePiece, MessageScorable, Observation, + PromptDataType, PromptResponseError, Scorable, ScorableUnion, @@ -315,6 +316,23 @@ 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 declared by this message scorer's validator.""" + if not self._validator.has_declared_data_types: + return None + return frozenset(self._validator.supported_data_types) + + @property + def skips_unsupported_data_types(self) -> bool: + """Whether wholly unreadable evidence returns no score rather than raising.""" + return self._validator.skips_unsupported_data_types + + @property + def allows_unsupported_pieces(self) -> bool: + """Whether readable evidence may also contain unsupported pieces.""" + return self._validator.allows_unsupported_pieces + def _get_condition_type(self) -> type[Condition] | None: """Return the declared criterion, using the objective validator only for undeclared leaves.""" if self._get_child_scorers(): diff --git a/pyrit/score/scorer.py b/pyrit/score/scorer.py index b58096f69b..869d8c7ed7 100644 --- a/pyrit/score/scorer.py +++ b/pyrit/score/scorer.py @@ -22,6 +22,7 @@ Message, MessageScorable, Observation, + PromptDataType, Scorable, ScorableUnion, Score, @@ -220,6 +221,21 @@ 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 declared readable types, or None when compatibility cannot be determined.""" + return None + + @property + def skips_unsupported_data_types(self) -> bool: + """Whether wholly unsupported evidence safely yields no score.""" + return False + + @property + def allows_unsupported_pieces(self) -> bool: + """Whether unsupported pieces can accompany readable evidence.""" + return False + @property @final def condition_type(self) -> type[Condition] | None: diff --git a/pyrit/score/scorer_prompt_validator.py b/pyrit/score/scorer_prompt_validator.py index 986afca111..be87dfb420 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,32 @@ 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 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 479560fe17..706ae1ce74 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, + PromptDataType, Scorable, ScorableUnion, Score, @@ -79,6 +80,21 @@ 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 scorer's declared readable data types.""" + return self._scorer.supported_data_types + + @property + def skips_unsupported_data_types(self) -> bool: + """Whether the wrapped scorer skips wholly unsupported evidence.""" + return self._scorer.skips_unsupported_data_types + + @property + def allows_unsupported_pieces(self) -> bool: + """Whether the wrapped scorer accepts unsupported pieces alongside readable ones.""" + 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_inverter_scorer.py b/pyrit/score/true_false/true_false_inverter_scorer.py index 1451083532..23e57d5ef9 100644 --- a/pyrit/score/true_false/true_false_inverter_scorer.py +++ b/pyrit/score/true_false/true_false_inverter_scorer.py @@ -9,6 +9,7 @@ from pyrit.models import ( ComponentIdentifier, + PromptDataType, Scorable, Score, ScoringExpectation, @@ -39,6 +40,21 @@ def __init__(self, *, scorer: TrueFalseScorer, validator: ScorerPromptValidator super().__init__() + @property + def supported_data_types(self) -> frozenset[PromptDataType] | None: + """The wrapped scorer's declared readable data types.""" + return self._scorer.supported_data_types + + @property + def skips_unsupported_data_types(self) -> bool: + """Whether the wrapped scorer skips wholly unsupported evidence.""" + return self._scorer.skips_unsupported_data_types + + @property + def allows_unsupported_pieces(self) -> bool: + """Whether the wrapped scorer accepts unsupported pieces alongside readable ones.""" + return self._scorer.allows_unsupported_pieces + 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 4fbf4d0914..9ca118d29c 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)}") @staticmethod def _child_expectation( 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..3dd7c38b8f --- /dev/null +++ b/tests/unit/modality_profiles.py @@ -0,0 +1,41 @@ +# 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, 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"}) + +#: 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/scenario/core/test_modality_validation.py b/tests/unit/scenario/core/test_modality_validation.py new file mode 100644 index 0000000000..3b163d4c91 --- /dev/null +++ b/tests/unit/scenario/core/test_modality_validation.py @@ -0,0 +1,1012 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT license. + +""" +Unit tests for derived modality compatibility. + +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 asyncio +import os +from typing import get_args + +import pytest +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, + VISION_INPUT_MODALITIES, +) + +from pyrit.converter import ( + AudioEchoConverter, + Base64Converter, + Converter, + ConverterResult, + ImageCompressionConverter, + QRCodeConverter, +) +from pyrit.executor.attack import ( + AttackConverterConfig, + AttackParameters, + AttackScoringConfig, + PromptSendingAttack, + SequentialAttack, + SequentialChildAttack, +) +from pyrit.models import ( + AttackSeedGroup, + AttackTechniqueSeedGroup, + Message, + MessagePiece, + PromptDataType, + SeedObjective, + SeedPrompt, +) +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 ( + ModalityPolicy, + ModalityValidationError, + ModalityVerdict, + project_request_chain, + project_request_combinations, + scorer_accepts, + target_accepts, + validate_atomic_attack, +) +from pyrit.score import ( + MessageScorable, + SubStringScorer, + TrueFalseCompositeScorer, + TrueFalseInverterScorer, + TrueFalseScoreAggregator, +) +from pyrit.score.scorer_prompt_validator import ScorerPromptValidator + + +def _configs(*converters) -> list[ConverterConfiguration]: + """One configuration per converter, mirroring ``ConverterConfiguration.from_converters``.""" + 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") + + +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 = ( + 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, + converter_configurations=None, + response_converter_configurations=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 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 or [])) + ), + response_converters=response_converter_configurations or [], + ) + 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 +# --------------------------------------------------------------------------- +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 + + +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 +# --------------------------------------------------------------------------- +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=[], request_converters=_configs(Base64Converter())) + assert projected == set() + 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 +# --------------------------------------------------------------------------- +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 + + +@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 + + +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], 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"], + request_converters=[config], + ) + assert projected == {"image_path", "audio_path"} + 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) +# --------------------------------------------------------------------------- +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_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_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_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}``. + + 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 "image_path" in reason + + +def test_scorer_accepts_composite_with_disjoint_child_modalities_is_unknown(patch_central_database): + """Do not promise compatibility for a compound scorer pending post-refactor review.""" + 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.UNKNOWN + 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 + + +@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 + + +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_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"}]) + 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 + + +@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) + 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) + 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 + + +@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() + 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"}]) + 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)] + ) + 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 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_modality_policy.py b/tests/unit/scenario/core/test_scenario_modality_policy.py new file mode 100644 index 0000000000..41dbee1b72 --- /dev/null +++ b/tests/unit/scenario/core/test_scenario_modality_policy.py @@ -0,0 +1,513 @@ +# 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`` 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 TYPE_CHECKING, ClassVar +from unittest.mock import MagicMock, patch + +import pytest +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 ( + 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, + ModalityVerdict, + validate_atomic_attack, +) +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 + +_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 + + +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, 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)), + 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) + + +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()}) + 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"} + + +@pytest.mark.parametrize("policy", list(ModalityPolicy)) +@pytest.mark.parametrize( + ("outputs", "strict", "raise_on_empty", "incompatible"), + [ + ([{"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, raise_on_empty, 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, + 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)]) + + 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.""" + 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()]) + 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) 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}) 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..db763dae2a --- /dev/null +++ b/tests/unit/score/test_scorer_modality_accessors.py @@ -0,0 +1,303 @@ +# 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 +* ``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 + +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 + + +@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 + + +@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. + + 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 + assert scorer.skips_unsupported_data_types is False + assert scorer.allows_unsupported_pieces is False + + +# --------------------------------------------------------------------------- +# MessageScorer reads its validator +# --------------------------------------------------------------------------- +def test_message_scorer_declared_types_returns_frozenset(): + """SubStringScorer's default validator declares ["text"]; the accessor reports exactly that.""" + 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(): + """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"})) + 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(): + """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"}) + assert scorer.skips_unsupported_data_types is True + assert scorer.allows_unsupported_pieces is True + + +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_modality_is_unknown_pending_condition_routing_review(): + """Do not assert that unioned child types guarantee scorer compatibility.""" + scorer = TrueFalseCompositeScorer( + aggregator=TrueFalseScoreAggregator.AND, + scorers=[ + _substring_scorer(declared=["text", "image_path"]), + _substring_scorer(declared=["text", "audio_path"]), + ], + ) + assert scorer.supported_data_types is None + assert scorer.skips_unsupported_data_types is False + + +def test_composite_scorer_identical_children_are_still_unknown(): + """Even identical declarations do not establish the compound's routing contract.""" + scorer = TrueFalseCompositeScorer( + aggregator=TrueFalseScoreAggregator.OR, + scorers=[_substring_scorer(declared=["text"]), _substring_scorer(declared=["text"])], + ) + assert scorer.supported_data_types is None + + +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_keep_composite_modality_unknown(): + """One-to-one wrappers preserve an unknown compound declaration.""" + composite = TrueFalseCompositeScorer( + aggregator=TrueFalseScoreAggregator.AND, + scorers=[_substring_scorer(declared=["text", "image_path"]), _substring_scorer(declared=["image_path"])], + ) + assert TrueFalseInverterScorer(scorer=composite).supported_data_types is None + + +# --------------------------------------------------------------------------- +# 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)) diff --git a/tests/unit/score/test_true_false_composite_scorer.py b/tests/unit/score/test_true_false_composite_scorer.py index 0d3c639848..dbe9ed2a2f 100644 --- a/tests/unit/score/test_true_false_composite_scorer.py +++ b/tests/unit/score/test_true_false_composite_scorer.py @@ -224,6 +224,19 @@ async def test_composite_scorer_ignores_non_applicable_child(mock_request, true_ assert scores[0].get_value() is True +async def test_composite_modality_unknown_does_not_change_runtime_applicability( + mock_request, true_scorer, false_scorer +): + true_scorer._validator = ScorerPromptValidator(supported_data_types=["text"]) + false_scorer._validator = ScorerPromptValidator(supported_data_types=["image_path"]) + scorer = TrueFalseCompositeScorer(aggregator=TrueFalseScoreAggregator.OR, scorers=[true_scorer, false_scorer]) + + assert scorer.supported_data_types is None + scores = await scorer.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_supported_conditions_to_each_leaf(mock_request): objective_scorer = MockScorer( score_value=True, diff --git a/tests/unit/test_mock_target.py b/tests/unit/test_mock_target.py new file mode 100644 index 0000000000..34e5cb67a0 --- /dev/null +++ b/tests/unit/test_mock_target.py @@ -0,0 +1,265 @@ +# 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_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(): + """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