From a5295a80b1403fc0d3bc8bd86a6b36a1d3e22dc0 Mon Sep 17 00:00:00 2001 From: Richard Lundeen Date: Mon, 17 Aug 2026 15:02:28 -0700 Subject: [PATCH 1/3] FEAT: Add Garak FigStep scenario Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 669aaa53-c978-4479-aea3-b2215bb42041 --- doc/code/datasets/1_loading_datasets.ipynb | 5 +- doc/code/datasets/1_loading_datasets.py | 4 +- doc/scanner/0_scanner.md | 2 +- doc/scanner/garak.ipynb | 68 +++- doc/scanner/garak.py | 49 ++- .../datasets/seed_datasets/remote/__init__.py | 8 +- .../seed_datasets/remote/figstep_dataset.py | 29 +- pyrit/scenario/scenarios/garak/__init__.py | 3 + pyrit/scenario/scenarios/garak/figstep.py | 207 +++++++++++ tests/unit/datasets/test_figstep_dataset.py | 20 +- tests/unit/scenario/garak/test_figstep.py | 351 ++++++++++++++++++ 11 files changed, 734 insertions(+), 12 deletions(-) create mode 100644 pyrit/scenario/scenarios/garak/figstep.py create mode 100644 tests/unit/scenario/garak/test_figstep.py diff --git a/doc/code/datasets/1_loading_datasets.ipynb b/doc/code/datasets/1_loading_datasets.ipynb index 7e50fc5232..d311f2c577 100644 --- a/doc/code/datasets/1_loading_datasets.ipynb +++ b/doc/code/datasets/1_loading_datasets.ipynb @@ -64,8 +64,8 @@ "(`garak_pypi_packages`, `garak_npm_packages`, `garak_crates_packages`,\n", "`garak_rubygems_packages`, `garak_dart_packages`, `garak_perl_packages`,\n", "`garak_raku_packages`), system-prompt libraries (`garak_drh_system_prompts`,\n", - "`garak_tm_system_prompts`), and an audio jailbreak set\n", - "(`garak_audio_achilles_heel`)." + "`garak_tm_system_prompts`), an audio jailbreak set\n", + "(`garak_audio_achilles_heel`), and visual jailbreak sets (`figstep`, `figstep_pro`)." ] }, { @@ -115,6 +115,7 @@ " 'decoding_trust_toxicity',\n", " 'equitymedqa',\n", " 'figstep',\n", + " 'figstep_pro',\n", " 'forbidden_questions',\n", " 'garak_access_shell_commands',\n", " 'garak_audio_achilles_heel',\n", diff --git a/doc/code/datasets/1_loading_datasets.py b/doc/code/datasets/1_loading_datasets.py index 98e1a3fe02..7e7838b999 100644 --- a/doc/code/datasets/1_loading_datasets.py +++ b/doc/code/datasets/1_loading_datasets.py @@ -68,8 +68,8 @@ # (`garak_pypi_packages`, `garak_npm_packages`, `garak_crates_packages`, # `garak_rubygems_packages`, `garak_dart_packages`, `garak_perl_packages`, # `garak_raku_packages`), system-prompt libraries (`garak_drh_system_prompts`, -# `garak_tm_system_prompts`), and an audio jailbreak set -# (`garak_audio_achilles_heel`). +# `garak_tm_system_prompts`), an audio jailbreak set +# (`garak_audio_achilles_heel`), and visual jailbreak sets (`figstep`, `figstep_pro`). # %% from pyrit.datasets import SeedDatasetProvider diff --git a/doc/scanner/0_scanner.md b/doc/scanner/0_scanner.md index 07dfcd4974..c9d369c968 100644 --- a/doc/scanner/0_scanner.md +++ b/doc/scanner/0_scanner.md @@ -35,7 +35,7 @@ PyRIT ships with scenarios organized into the following families: | **AIRT** | RapidResponse, Psychosocial, Cyber, Jailbreak, Leakage, Scam | [AIRT Scenarios](airt.ipynb) | | **Benchmark** | AdversarialBenchmark | [Benchmark Scenarios](benchmark.ipynb) | | **Foundry** | RedTeamAgent | [Foundry Scenarios](foundry.ipynb) | -| **Garak** | Encoding | [Garak Scenarios](garak.ipynb) | +| **Garak** | Encoding, FigStep | [Garak Scenarios](garak.ipynb) | Each scenario page shows how to run it with minimal configuration. diff --git a/doc/scanner/garak.ipynb b/doc/scanner/garak.ipynb index 810ff14641..572e6c1e8e 100644 --- a/doc/scanner/garak.ipynb +++ b/doc/scanner/garak.ipynb @@ -14,8 +14,9 @@ "data-exfiltration or cross-site-scripting payloads), a doctor probe (which applies the Policy\n", "Puppetry universal bypass), system-prompt-extraction probes (which test whether a target can be\n", "coaxed into revealing its own system prompt), package-hallucination probes (which test whether a\n", - "target recommends non-existent packages that an attacker could squat), and an audio probe (which\n", - "delivers spoken jailbreaks to multimodal targets).\n", + "target recommends non-existent packages that an attacker could squat), an audio probe (which\n", + "delivers spoken jailbreaks to multimodal targets), and FigStep visual jailbreaks (which place\n", + "harmful instructions in images).\n", "\n", "For full programming details, see the\n", "[Scenarios Programming Guide](../code/scenarios/0_scenarios.ipynb)." @@ -35,9 +36,11 @@ "from pyrit.output import output_scenario_async\n", "from pyrit.prompt_target import RealtimeTarget\n", "from pyrit.registry import TargetRegistry\n", + "from pyrit.scenario import DatasetAttackConfiguration\n", "from pyrit.scenario.garak import (\n", " Encoding,\n", " EncodingTechnique,\n", + " FigStep,\n", " SystemPromptExtraction,\n", " SystemPromptExtractionTechnique,\n", ")\n", @@ -115,6 +118,67 @@ "await output_scenario_async(scenario_result)" ] }, + { + "cell_type": "markdown", + "id": "50802805", + "metadata": {}, + "source": [ + "## FigStep\n", + "\n", + "Tests whether a vision-language target follows harmful instructions that appear in an image.\n", + "`FigStep` sends one typographic image and carrier text. `FigStep-Pro` splits the visual prompt\n", + "across several images. Both variants reuse the built-in SafeBench-Tiny groups, images, and carrier\n", + "text. PyRIT scores whether the response completes the harmful objective. It does not only check\n", + "whether the response contains numbered steps.\n", + "\n", + "**CLI examples:**\n", + "\n", + "```bash\n", + "pyrit_scan garak.figstep --target openai_chat --dataset-names figstep --max-dataset-size 1\n", + "pyrit_scan garak.figstep --target openai_chat --dataset-names figstep_pro --max-dataset-size 1\n", + "```\n", + "\n", + "> **Note:** The objective target must natively support multi-piece user messages and accept text\n", + "> and image input in the same message. Select exactly one of the `figstep` or `figstep_pro`\n", + "> datasets; unrelated named datasets are rejected because they do not contain the required visual\n", + "> payload. By default, PyRIT also sends each sampled objective as direct text. Use\n", + "> `--no-include-baseline` to omit this comparison." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "d6eeec65", + "metadata": {}, + "outputs": [], + "source": [ + "figstep_dataset_config = DatasetAttackConfiguration(dataset_names=[\"figstep\"], max_dataset_size=1)\n", + "\n", + "figstep_scenario = FigStep()\n", + "figstep_scenario.set_params_from_args( # type: ignore\n", + " args={\n", + " \"objective_target\": objective_target,\n", + " \"dataset_config\": figstep_dataset_config,\n", + " }\n", + ")\n", + "await figstep_scenario.initialize_async() # type: ignore\n", + "\n", + "print(f\"Scenario: {figstep_scenario.name}\")\n", + "print(f\"Atomic attacks: {figstep_scenario.atomic_attack_count}\")\n", + "\n", + "figstep_result = await figstep_scenario.run_async() # type: ignore" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "80e997c9", + "metadata": {}, + "outputs": [], + "source": [ + "await output_scenario_async(figstep_result)" + ] + }, { "cell_type": "markdown", "id": "5", diff --git a/doc/scanner/garak.py b/doc/scanner/garak.py index dd2c3c2e1b..da0e612738 100644 --- a/doc/scanner/garak.py +++ b/doc/scanner/garak.py @@ -18,8 +18,9 @@ # data-exfiltration or cross-site-scripting payloads), a doctor probe (which applies the Policy # Puppetry universal bypass), system-prompt-extraction probes (which test whether a target can be # coaxed into revealing its own system prompt), package-hallucination probes (which test whether a -# target recommends non-existent packages that an attacker could squat), and an audio probe (which -# delivers spoken jailbreaks to multimodal targets). +# target recommends non-existent packages that an attacker could squat), an audio probe (which +# delivers spoken jailbreaks to multimodal targets), and FigStep visual jailbreaks (which place +# harmful instructions in images). # # For full programming details, see the # [Scenarios Programming Guide](../code/scenarios/0_scenarios.ipynb). @@ -30,9 +31,11 @@ from pyrit.output import output_scenario_async from pyrit.prompt_target import RealtimeTarget from pyrit.registry import TargetRegistry +from pyrit.scenario import DatasetAttackConfiguration from pyrit.scenario.garak import ( Encoding, EncodingTechnique, + FigStep, SystemPromptExtraction, SystemPromptExtractionTechnique, ) @@ -89,6 +92,48 @@ # %% await output_scenario_async(scenario_result) +# %% [markdown] +# ## FigStep +# +# Tests whether a vision-language target follows harmful instructions that appear in an image. +# `FigStep` sends one typographic image and carrier text. `FigStep-Pro` splits the visual prompt +# across several images. Both variants reuse the built-in SafeBench-Tiny groups, images, and carrier +# text. PyRIT scores whether the response completes the harmful objective. It does not only check +# whether the response contains numbered steps. +# +# **CLI examples:** +# +# ```bash +# pyrit_scan garak.figstep --target openai_chat --dataset-names figstep --max-dataset-size 1 +# pyrit_scan garak.figstep --target openai_chat --dataset-names figstep_pro --max-dataset-size 1 +# ``` +# +# > **Note:** The objective target must natively support multi-piece user messages and accept text +# > and image input in the same message. Select exactly one of the `figstep` or `figstep_pro` +# > datasets; unrelated named datasets are rejected because they do not contain the required visual +# > payload. By default, PyRIT also sends each sampled objective as direct text. Use +# > `--no-include-baseline` to omit this comparison. + +# %% +figstep_dataset_config = DatasetAttackConfiguration(dataset_names=["figstep"], max_dataset_size=1) + +figstep_scenario = FigStep() +figstep_scenario.set_params_from_args( # type: ignore + args={ + "objective_target": objective_target, + "dataset_config": figstep_dataset_config, + } +) +await figstep_scenario.initialize_async() # type: ignore + +print(f"Scenario: {figstep_scenario.name}") +print(f"Atomic attacks: {figstep_scenario.atomic_attack_count}") + +figstep_result = await figstep_scenario.run_async() # type: ignore + +# %% +await output_scenario_async(figstep_result) + # %% [markdown] # ## WebInjection # diff --git a/pyrit/datasets/seed_datasets/remote/__init__.py b/pyrit/datasets/seed_datasets/remote/__init__.py index 986d24a844..251bd94320 100644 --- a/pyrit/datasets/seed_datasets/remote/__init__.py +++ b/pyrit/datasets/seed_datasets/remote/__init__.py @@ -41,7 +41,12 @@ _DecodingTrustToxicityDataset, ) from pyrit.datasets.seed_datasets.remote.equitymedqa_dataset import _EquityMedQADataset -from pyrit.datasets.seed_datasets.remote.figstep_dataset import FigStepCategory, FigStepVariant, _FigStepDataset +from pyrit.datasets.seed_datasets.remote.figstep_dataset import ( + FigStepCategory, + FigStepVariant, + _FigStepDataset, + _FigStepProDataset, +) from pyrit.datasets.seed_datasets.remote.forbidden_questions_dataset import _ForbiddenQuestionsDataset from pyrit.datasets.seed_datasets.remote.garak_audio_dataset import _GarakAudioAchillesHeelDataset from pyrit.datasets.seed_datasets.remote.garak_package_hallucination_dataset import ( @@ -182,6 +187,7 @@ "_DecodingTrustToxicityDataset", "_EquityMedQADataset", "_FigStepDataset", + "_FigStepProDataset", "_ForbiddenQuestionsDataset", "_GarakAudioAchillesHeelDataset", "_GarakCratesDataset", diff --git a/pyrit/datasets/seed_datasets/remote/figstep_dataset.py b/pyrit/datasets/seed_datasets/remote/figstep_dataset.py index fa6cad4a1c..40f30ba889 100644 --- a/pyrit/datasets/seed_datasets/remote/figstep_dataset.py +++ b/pyrit/datasets/seed_datasets/remote/figstep_dataset.py @@ -240,7 +240,7 @@ def __init__( @override def dataset_name(self) -> str: """The dataset name.""" - return "figstep" + return self.variant.value @override async def fetch_dataset_async(self, *, cache: bool = True) -> SeedDataset: @@ -660,3 +660,30 @@ async def _fetch_figstep_pro_sub_images_async( indexed_paths.sort(key=lambda item: item[0]) return [path for _, path in indexed_paths] + + +class _FigStepProDataset(_FigStepDataset): + """Provider entry for the FigStep-Pro SafeBench-Tiny variant.""" + + def __init__( + self, + *, + categories: list[FigStepCategory] | None = None, + source: str | None = None, + source_type: Literal["public_url", "file"] = "public_url", + ) -> None: + """ + Initialize the FigStep-Pro provider. + + Args: + categories (list[FigStepCategory] | None): Optional harmful-topic filter. + source (str | None): Optional question CSV URL or local path override. + source_type (Literal["public_url", "file"]): How to interpret ``source``. + """ + super().__init__( + use_tiny=True, + variant=FigStepVariant.FIGSTEP_PRO, + categories=categories, + source=source, + source_type=source_type, + ) diff --git a/pyrit/scenario/scenarios/garak/__init__.py b/pyrit/scenario/scenarios/garak/__init__.py index 92e8692189..bbaf31055b 100644 --- a/pyrit/scenario/scenarios/garak/__init__.py +++ b/pyrit/scenario/scenarios/garak/__init__.py @@ -8,6 +8,7 @@ from pyrit.scenario.scenarios.garak.audio_achilles_heel import AudioAchillesHeel, AudioAchillesHeelTechnique from pyrit.scenario.scenarios.garak.doctor import Doctor, _build_doctor_technique from pyrit.scenario.scenarios.garak.encoding import Encoding, EncodingTechnique +from pyrit.scenario.scenarios.garak.figstep import FigStep, FigStepTechnique from pyrit.scenario.scenarios.garak.package_hallucination import ( PackageHallucination, PackageHallucinationTechnique, @@ -41,6 +42,8 @@ def __getattr__(name: str) -> Any: "DoctorTechnique", "Encoding", "EncodingTechnique", + "FigStep", + "FigStepTechnique", "PackageHallucination", "PackageHallucinationTechnique", "SystemPromptExtraction", diff --git a/pyrit/scenario/scenarios/garak/figstep.py b/pyrit/scenario/scenarios/garak/figstep.py new file mode 100644 index 0000000000..812672e98a --- /dev/null +++ b/pyrit/scenario/scenarios/garak/figstep.py @@ -0,0 +1,207 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT license. + +"""FigStep visual-jailbreak scenario.""" + +from __future__ import annotations + +from typing import TYPE_CHECKING, ClassVar, cast + +from pyrit.common import apply_defaults +from pyrit.common.path import SCORER_SEED_PROMPT_PATH +from pyrit.executor.attack import AttackScoringConfig, PromptSendingAttack +from pyrit.models import AttackSeedGroup, PromptDataType +from pyrit.prompt_target import CapabilityName, TargetRequirements +from pyrit.scenario.core.atomic_attack import AtomicAttack +from pyrit.scenario.core.attack_technique import AttackTechnique +from pyrit.scenario.core.dataset_configuration import ( + INLINE_DATASET_NAME, + DatasetAttackConfiguration, + DatasetConfiguration, + DatasetSourceKind, +) +from pyrit.scenario.core.matrix_atomic_attack_builder import build_baseline_atomic_attack +from pyrit.scenario.core.scenario import Scenario +from pyrit.scenario.core.scenario_technique import ScenarioTechnique + +if TYPE_CHECKING: + from pathlib import Path + + from pyrit.scenario.core.scenario_context import ScenarioContext + from pyrit.score import TrueFalseScorer + + +DEFAULT_MAX_DATASET_SIZE = 10 +_FIGSTEP_DATASET_NAMES: frozenset[str] = frozenset({"figstep", "figstep_pro"}) +_FIGSTEP_INPUT_MODALITIES = cast( + "frozenset[frozenset[PromptDataType]]", + frozenset({frozenset({"text", "image_path"})}), +) + + +class FigStepTechnique(ScenarioTechnique): + """Techniques for the FigStep scenario.""" + + ALL = ("all", {"all"}) + VisualJailbreak = ("visual_jailbreak", set[str]()) + + +class FigStep(Scenario): + """ + Run FigStep or FigStep-Pro typographic-image jailbreaks against a multimodal target. + + The selected FigStep dataset supplies one harmful objective and a grouped text-plus-image + request for each SafeBench item. ``PromptSendingAttack`` sends that grouped request and scores + whether the response substantively fulfills the harmful objective. The default baseline sends + the same sampled objectives directly as text. + + Reference: [@gong2025figstep] + """ + + VERSION: int = 1 + TARGET_REQUIREMENTS: ClassVar[TargetRequirements] = TargetRequirements( + native_required=frozenset({CapabilityName.MULTI_MESSAGE_PIECES}), + required_input_modalities=_FIGSTEP_INPUT_MODALITIES, + ) + + @classmethod + def required_datasets(cls) -> list[str]: + """Return the built-in datasets supported by this scenario.""" + return sorted(_FIGSTEP_DATASET_NAMES) + + @classmethod + def _get_additional_scoring_questions(cls) -> list[Path]: + """Return the task-achieved rubric used to detect harmful compliance.""" + return [SCORER_SEED_PROMPT_PATH / "true_false_question" / "task_achieved.yaml"] + + @apply_defaults + def __init__( + self, + *, + objective_scorer: TrueFalseScorer | None = None, + scenario_result_id: str | None = None, + ) -> None: + """ + Initialize the FigStep scenario. + + Args: + objective_scorer (TrueFalseScorer | None): Scorer for harmful objective fulfillment. + scenario_result_id (str | None): Optional scenario result ID to resume. + """ + objective_scorer = objective_scorer or self._get_default_objective_scorer() + self._scorer_config = AttackScoringConfig(objective_scorer=objective_scorer) + + super().__init__( + version=self.VERSION, + technique_class=FigStepTechnique, + default_dataset_config=DatasetAttackConfiguration( + dataset_names=["figstep"], + max_dataset_size=DEFAULT_MAX_DATASET_SIZE, + ), + objective_scorer=objective_scorer, + scenario_result_id=scenario_result_id, + ) + + @staticmethod + def _get_dataset_label(*, dataset_config: DatasetConfiguration) -> str: + """ + Validate the dataset source and return its stable attack label. + + Inline groups are supported for programmatic use. Named configurations must select exactly + one built-in FigStep dataset because other datasets do not define the required visual + jailbreak payload. + + Returns: + str: ``figstep``, ``figstep_pro``, or ``inline``. + + Raises: + ValueError: If a named configuration does not select exactly one FigStep dataset. + """ + if dataset_config.source_kind is DatasetSourceKind.INLINE: + return INLINE_DATASET_NAME + + dataset_names = dataset_config.dataset_names + if len(dataset_names) != 1 or dataset_names[0] not in _FIGSTEP_DATASET_NAMES: + raise ValueError( + f"FigStep requires exactly one named dataset: 'figstep' or 'figstep_pro'. Received {dataset_names}." + ) + return dataset_names[0] + + @staticmethod + def _validate_seed_groups(*, seed_groups: list[AttackSeedGroup]) -> None: + """ + Validate that every group is a single-turn text-plus-image request. + + Raises: + ValueError: If no groups exist or a group lacks text or image input. + """ + if not seed_groups: + raise ValueError("FigStep requires at least one multimodal seed group to attack.") + + for seed_group in seed_groups: + if seed_group.prepended_conversation: + raise ValueError("FigStep seed groups must contain exactly one user message.") + message = seed_group.next_message + if message is None: + raise ValueError("FigStep seed groups must include a next message.") + data_types = {piece.original_value_data_type for piece in message.message_pieces} + if not {"text", "image_path"} <= data_types: + raise ValueError("FigStep seed groups must include text and image_path pieces in one next message.") + + async def _resolve_seed_groups_by_dataset_async( + self, *, apply_sampling: bool = True + ) -> dict[str, list[AttackSeedGroup]]: + """ + Resolve one approved FigStep dataset or validated inline groups. + + Returns: + dict[str, list[AttackSeedGroup]]: Valid FigStep groups keyed by dataset. + """ + self._get_dataset_label(dataset_config=self._dataset_config) + + validate_before_sampling = apply_sampling and self._dataset_config.max_dataset_size is not None + validation_sampling = apply_sampling and not validate_before_sampling + groups_by_dataset = await super()._resolve_seed_groups_by_dataset_async(apply_sampling=validation_sampling) + self._validate_seed_groups(seed_groups=[group for groups in groups_by_dataset.values() for group in groups]) + + if validate_before_sampling: + return await super()._resolve_seed_groups_by_dataset_async(apply_sampling=True) + return groups_by_dataset + + async def _build_atomic_attacks_async(self, *, context: ScenarioContext) -> list[AtomicAttack]: + """ + Build the direct-text baseline and selected visual-jailbreak attack. + + Returns: + list[AtomicAttack]: The ordered baseline and visual attacks. + """ + seed_groups = list(context.seed_groups) + self._validate_seed_groups(seed_groups=seed_groups) + dataset_label = self._get_dataset_label(dataset_config=context.dataset_config) + + atomic_attacks: list[AtomicAttack] = [] + if context.include_baseline: + baseline_seed_groups = [AttackSeedGroup(seeds=[seed_group.objective]) for seed_group in seed_groups] + atomic_attacks.append( + build_baseline_atomic_attack( + objective_target=context.objective_target, + objective_scorer=self._objective_scorer, + seed_groups=baseline_seed_groups, + memory_labels=context.memory_labels, + ) + ) + + attack = PromptSendingAttack( + objective_target=context.objective_target, + attack_scoring_config=self._scorer_config, + ) + atomic_attacks.append( + AtomicAttack( + atomic_attack_name=f"{FigStepTechnique.VisualJailbreak.value}_{dataset_label}", + display_group=dataset_label, + attack_technique=AttackTechnique(attack=attack), + seed_groups=seed_groups, + memory_labels=context.memory_labels, + ) + ) + return atomic_attacks diff --git a/tests/unit/datasets/test_figstep_dataset.py b/tests/unit/datasets/test_figstep_dataset.py index 83bf1dfe5c..2ab214567e 100644 --- a/tests/unit/datasets/test_figstep_dataset.py +++ b/tests/unit/datasets/test_figstep_dataset.py @@ -13,7 +13,9 @@ FigStepCategory, FigStepVariant, _FigStepDataset, + _FigStepProDataset, ) +from pyrit.datasets.seed_datasets.seed_dataset_provider import SeedDatasetProvider from pyrit.models import SeedDataset, SeedObjective, SeedPrompt @@ -418,7 +420,23 @@ async def test_fetch_pro_benign_index_out_of_range_skips_row(self, tmp_path: Pat def test_pro_dataset_name(self): loader = _FigStepDataset(variant=FigStepVariant.FIGSTEP_PRO) - assert loader.dataset_name == "figstep" + assert loader.dataset_name == "figstep_pro" + + +class TestFigStepProProvider: + """Unit tests for the registered FigStep-Pro provider entry.""" + + def test_provider_defaults_to_figstep_pro_tiny(self): + loader = _FigStepProDataset() + + assert loader.dataset_name == "figstep_pro" + assert loader.variant is FigStepVariant.FIGSTEP_PRO + assert loader.use_tiny is True + + def test_provider_is_registered(self): + providers = SeedDatasetProvider.get_all_providers() + + assert providers["_FigStepProDataset"] is _FigStepProDataset class TestFigStepProSubImageDiscovery: diff --git a/tests/unit/scenario/garak/test_figstep.py b/tests/unit/scenario/garak/test_figstep.py new file mode 100644 index 0000000000..e3f00ac30b --- /dev/null +++ b/tests/unit/scenario/garak/test_figstep.py @@ -0,0 +1,351 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT license. + +"""Tests for the Garak FigStep scenario.""" + +from typing import Any +from unittest.mock import AsyncMock, MagicMock, patch + +import pytest + +from pyrit.common.path import SCORER_SEED_PROMPT_PATH +from pyrit.executor.attack import PromptSendingAttack +from pyrit.models import AttackSeedGroup, ComponentIdentifier, SeedObjective, SeedPrompt +from pyrit.prompt_target import ( + CapabilityName, + PromptTarget, + TargetCapabilities, + TargetConfiguration, +) +from pyrit.registry import ScenarioRegistry +from pyrit.scenario.core.dataset_configuration import DatasetAttackConfiguration +from pyrit.scenario.garak import FigStep, FigStepTechnique # type: ignore[ty:unresolved-import] +from pyrit.scenario.scenarios.garak.figstep import DEFAULT_MAX_DATASET_SIZE +from pyrit.score import TrueFalseScorer +from tests.unit.mocks import MockPromptTarget + + +def _mock_id(name: str) -> ComponentIdentifier: + return ComponentIdentifier(class_name=name, class_module="test") + + +@pytest.fixture +def mock_objective_target(): + mock = MagicMock(spec=PromptTarget) + mock.get_identifier.return_value = _mock_id("MockObjectiveTarget") + return mock + + +@pytest.fixture +def mock_objective_scorer(): + mock = MagicMock(spec=TrueFalseScorer) + mock.get_identifier.return_value = _mock_id("MockObjectiveScorer") + return mock + + +def _figstep_group(*, objective: str = "Explain how to perform a harmful task") -> AttackSeedGroup: + return AttackSeedGroup( + seeds=[ + SeedObjective(value=objective), + SeedPrompt(value="/tmp/figstep.png", data_type="image_path", role="user", sequence=0), + SeedPrompt(value="The image shows a numbered list.", data_type="text", role="user", sequence=0), + ] + ) + + +def _inline_config(*, groups: list[AttackSeedGroup] | None = None) -> DatasetAttackConfiguration: + return DatasetAttackConfiguration(seed_groups=groups or [_figstep_group()]) + + +async def _initialize( + *, + scenario: FigStep, + target: PromptTarget, + dataset_config: DatasetAttackConfiguration, + include_baseline: bool | None = None, + memory_labels: dict[str, str] | None = None, +) -> None: + args: dict[str, Any] = { + "objective_target": target, + "dataset_config": dataset_config, + } + if include_baseline is not None: + args["include_baseline"] = include_baseline + if memory_labels is not None: + args["memory_labels"] = memory_labels + + with patch("pyrit.prompt_target.common.target_requirements.TargetRequirements.validate"): + scenario.set_params_from_args(args=args) + await scenario.initialize_async() + + +@pytest.mark.usefixtures("patch_central_database") +class TestFigStepInitialization: + def test_no_arg_instantiation(self, mock_objective_scorer): + with patch.object(FigStep, "_get_default_objective_scorer", return_value=mock_objective_scorer): + scenario = FigStep() + + assert scenario.name == "FigStep" + assert scenario.VERSION == 1 + + def test_custom_objective_scorer_is_used(self, mock_objective_scorer): + scenario = FigStep(objective_scorer=mock_objective_scorer) + + assert scenario._objective_scorer is mock_objective_scorer + assert scenario._scorer_config.objective_scorer is mock_objective_scorer + + def test_default_dataset_uses_figstep(self, mock_objective_scorer): + config = FigStep(objective_scorer=mock_objective_scorer)._default_dataset_config + + assert config.dataset_names == ["figstep"] + assert config.max_dataset_size == DEFAULT_MAX_DATASET_SIZE + + def test_required_datasets_include_both_figstep_datasets(self): + assert FigStep.required_datasets() == ["figstep", "figstep_pro"] + + def test_dataset_config_is_the_only_dataset_selector(self): + parameter_names = {parameter.name for parameter in FigStep.supported_parameters()} + + assert "dataset_config" in parameter_names + assert "variant" not in parameter_names + + def test_task_achieved_rubric_is_used(self): + expected = SCORER_SEED_PROMPT_PATH / "true_false_question" / "task_achieved.yaml" + + assert FigStep._get_additional_scoring_questions() == [expected] + + def test_registry_metadata_exposes_default_dataset_without_variant(self, mock_objective_scorer): + registry = ScenarioRegistry() + with patch.object(FigStep, "_get_default_objective_scorer", return_value=mock_objective_scorer): + metadata = registry.get_class_metadata(FigStep) + + assert metadata.registry_name == "garak.figstep" + assert metadata.default_datasets == ("figstep",) + assert "variant" not in {parameter.name for parameter in metadata.supported_parameters} + + def test_target_requirements_declare_grouped_text_and_image(self): + requirements = FigStep.TARGET_REQUIREMENTS + + assert requirements.native_required == frozenset({CapabilityName.MULTI_MESSAGE_PIECES}) + assert requirements.required_input_modalities == frozenset({frozenset({"text", "image_path"})}) + + @pytest.mark.parametrize( + ("capabilities", "expected_error"), + [ + ( + TargetCapabilities( + supports_multi_message_pieces=False, + input_modalities=frozenset({frozenset({"text", "image_path"})}), + ), + CapabilityName.MULTI_MESSAGE_PIECES.value, + ), + ( + TargetCapabilities( + supports_multi_message_pieces=True, + input_modalities=frozenset({frozenset({"text"}), frozenset({"image_path"})}), + ), + "input modality", + ), + ], + ) + def test_target_requirements_reject_incompatible_targets(self, capabilities, expected_error): + target = MockPromptTarget() + target._configuration = TargetConfiguration(capabilities=capabilities) + + with pytest.raises(ValueError, match=expected_error): + FigStep.TARGET_REQUIREMENTS.validate(target=target) + + +@pytest.mark.usefixtures("patch_central_database") +class TestFigStepDatasetResolution: + async def test_named_pro_dataset_selects_pro_attack(self, mock_objective_target, mock_objective_scorer): + groups = [_figstep_group()] + config = DatasetAttackConfiguration( + dataset_names=["figstep_pro"], + max_dataset_size=1, + auto_fetch=False, + ) + with ( + patch("pyrit.prompt_target.common.target_requirements.TargetRequirements.validate"), + patch.object( + DatasetAttackConfiguration, + "get_attack_groups_by_dataset_async", + new_callable=AsyncMock, + return_value={"figstep_pro": groups}, + ) as resolve_groups, + ): + scenario = FigStep(objective_scorer=mock_objective_scorer) + scenario.set_params_from_args( + args={ + "objective_target": mock_objective_target, + "dataset_config": config, + "include_baseline": False, + } + ) + await scenario.initialize_async() + + assert scenario._dataset_config.dataset_names == ["figstep_pro"] + assert scenario._atomic_attacks[0].atomic_attack_name == "visual_jailbreak_figstep_pro" + assert [call.kwargs for call in resolve_groups.await_args_list] == [ + {"apply_sampling": False}, + {"apply_sampling": True}, + ] + + async def test_unrelated_named_dataset_is_rejected(self, mock_objective_target, mock_objective_scorer): + scenario = FigStep(objective_scorer=mock_objective_scorer) + config = DatasetAttackConfiguration(dataset_names=["harmbench"], auto_fetch=False) + + with ( + patch("pyrit.prompt_target.common.target_requirements.TargetRequirements.validate"), + patch.object( + DatasetAttackConfiguration, + "get_attack_groups_by_dataset_async", + new_callable=AsyncMock, + ) as resolve_groups, + ): + scenario.set_params_from_args( + args={ + "objective_target": mock_objective_target, + "dataset_config": config, + } + ) + with pytest.raises(ValueError, match="exactly one named dataset"): + await scenario.initialize_async() + resolve_groups.assert_not_awaited() + + async def test_multiple_named_figstep_datasets_are_rejected(self, mock_objective_target, mock_objective_scorer): + scenario = FigStep(objective_scorer=mock_objective_scorer) + config = DatasetAttackConfiguration(dataset_names=["figstep", "figstep_pro"], auto_fetch=False) + + with patch("pyrit.prompt_target.common.target_requirements.TargetRequirements.validate"): + scenario.set_params_from_args( + args={ + "objective_target": mock_objective_target, + "dataset_config": config, + } + ) + with pytest.raises(ValueError, match="exactly one named dataset"): + await scenario.initialize_async() + + async def test_inline_group_must_contain_text_and_image(self, mock_objective_target, mock_objective_scorer): + invalid_group = AttackSeedGroup( + seeds=[ + SeedObjective(value="harmful objective"), + SeedPrompt(value="text only", data_type="text", role="user", sequence=0), + ] + ) + scenario = FigStep(objective_scorer=mock_objective_scorer) + + with pytest.raises(ValueError, match="text and image_path"): + await _initialize( + scenario=scenario, + target=mock_objective_target, + dataset_config=_inline_config(groups=[invalid_group]), + ) + + async def test_inline_group_must_be_single_turn(self, mock_objective_target, mock_objective_scorer): + invalid_group = AttackSeedGroup( + seeds=[ + SeedObjective(value="harmful objective"), + SeedPrompt(value="first turn", data_type="text", role="user", sequence=0), + SeedPrompt(value="/tmp/figstep.png", data_type="image_path", role="user", sequence=1), + SeedPrompt(value="carrier text", data_type="text", role="user", sequence=1), + ] + ) + scenario = FigStep(objective_scorer=mock_objective_scorer) + + with pytest.raises(ValueError, match="exactly one user message"): + await _initialize( + scenario=scenario, + target=mock_objective_target, + dataset_config=_inline_config(groups=[invalid_group]), + ) + + async def test_all_inline_groups_are_validated_before_sampling(self, mock_objective_target, mock_objective_scorer): + invalid_group = AttackSeedGroup( + seeds=[ + SeedObjective(value="invalid objective"), + SeedPrompt(value="text only", data_type="text", role="user", sequence=0), + ] + ) + config = DatasetAttackConfiguration( + seed_groups=[_figstep_group(), invalid_group], + max_dataset_size=1, + ) + scenario = FigStep(objective_scorer=mock_objective_scorer) + + with pytest.raises(ValueError, match="text and image_path"): + await _initialize( + scenario=scenario, + target=mock_objective_target, + dataset_config=config, + ) + + +@pytest.mark.usefixtures("patch_central_database") +class TestFigStepAtomicAttacks: + async def test_default_builds_text_baseline_then_visual_attack(self, mock_objective_target, mock_objective_scorer): + groups = [_figstep_group(objective="objective one"), _figstep_group(objective="objective two")] + scenario = FigStep(objective_scorer=mock_objective_scorer) + + await _initialize( + scenario=scenario, + target=mock_objective_target, + dataset_config=_inline_config(groups=groups), + ) + + assert [attack.atomic_attack_name for attack in scenario._atomic_attacks] == [ + "baseline", + "visual_jailbreak_inline", + ] + assert isinstance(scenario._atomic_attacks[1].attack_technique.attack, PromptSendingAttack) + assert scenario._atomic_attacks[1].seed_groups == groups + + async def test_baseline_preserves_objectives_but_removes_visual_prompts( + self, mock_objective_target, mock_objective_scorer + ): + groups = [_figstep_group(objective="objective one"), _figstep_group(objective="objective two")] + scenario = FigStep(objective_scorer=mock_objective_scorer) + + await _initialize( + scenario=scenario, + target=mock_objective_target, + dataset_config=_inline_config(groups=groups), + ) + + baseline, visual = scenario._atomic_attacks + assert baseline.objectives == visual.objectives == ["objective one", "objective two"] + assert all(group.next_message is None for group in baseline.seed_groups) + assert all(group.next_message is not None for group in visual.seed_groups) + + async def test_baseline_can_be_disabled(self, mock_objective_target, mock_objective_scorer): + scenario = FigStep(objective_scorer=mock_objective_scorer) + + await _initialize( + scenario=scenario, + target=mock_objective_target, + dataset_config=_inline_config(), + include_baseline=False, + ) + + assert [attack.atomic_attack_name for attack in scenario._atomic_attacks] == ["visual_jailbreak_inline"] + + async def test_memory_labels_are_forwarded(self, mock_objective_target, mock_objective_scorer): + scenario = FigStep(objective_scorer=mock_objective_scorer) + labels = {"operation": "figstep-test"} + + await _initialize( + scenario=scenario, + target=mock_objective_target, + dataset_config=_inline_config(), + memory_labels=labels, + ) + + assert all(attack._memory_labels == labels for attack in scenario._atomic_attacks) + + +class TestFigStepTechnique: + def test_all_expands_to_visual_jailbreak(self): + expanded = FigStepTechnique.expand({FigStepTechnique.ALL}) + + assert expanded == [FigStepTechnique.VisualJailbreak] From 2ff9cd6e9b3c0658377ca08d34830248406ad750 Mon Sep 17 00:00:00 2001 From: Richard Lundeen Date: Mon, 17 Aug 2026 17:19:07 -0700 Subject: [PATCH 2/3] MAINT: Normalize Garak notebook metadata Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 669aaa53-c978-4479-aea3-b2215bb42041 --- doc/scanner/garak.ipynb | 26 +++++++++++++------------- 1 file changed, 13 insertions(+), 13 deletions(-) diff --git a/doc/scanner/garak.ipynb b/doc/scanner/garak.ipynb index 572e6c1e8e..a822df769a 100644 --- a/doc/scanner/garak.ipynb +++ b/doc/scanner/garak.ipynb @@ -120,7 +120,7 @@ }, { "cell_type": "markdown", - "id": "50802805", + "id": "5", "metadata": {}, "source": [ "## FigStep\n", @@ -148,7 +148,7 @@ { "cell_type": "code", "execution_count": null, - "id": "d6eeec65", + "id": "6", "metadata": {}, "outputs": [], "source": [ @@ -172,7 +172,7 @@ { "cell_type": "code", "execution_count": null, - "id": "80e997c9", + "id": "7", "metadata": {}, "outputs": [], "source": [ @@ -181,7 +181,7 @@ }, { "cell_type": "markdown", - "id": "5", + "id": "8", "metadata": {}, "source": [ "## WebInjection\n", @@ -207,7 +207,7 @@ }, { "cell_type": "markdown", - "id": "6", + "id": "9", "metadata": {}, "source": [ "## Doctor\n", @@ -231,7 +231,7 @@ }, { "cell_type": "markdown", - "id": "7", + "id": "10", "metadata": {}, "source": [ "## SystemPromptExtraction\n", @@ -264,7 +264,7 @@ { "cell_type": "code", "execution_count": null, - "id": "8", + "id": "11", "metadata": {}, "outputs": [], "source": [ @@ -286,7 +286,7 @@ { "cell_type": "code", "execution_count": null, - "id": "9", + "id": "12", "metadata": {}, "outputs": [], "source": [ @@ -295,7 +295,7 @@ }, { "cell_type": "markdown", - "id": "10", + "id": "13", "metadata": {}, "source": [ "## PackageHallucination\n", @@ -326,7 +326,7 @@ }, { "cell_type": "markdown", - "id": "11", + "id": "14", "metadata": {}, "source": [ "## AudioAchillesHeel\n", @@ -354,7 +354,7 @@ { "cell_type": "code", "execution_count": null, - "id": "12", + "id": "15", "metadata": {}, "outputs": [], "source": [ @@ -382,7 +382,7 @@ { "cell_type": "code", "execution_count": null, - "id": "13", + "id": "16", "metadata": {}, "outputs": [], "source": [ @@ -391,7 +391,7 @@ }, { "cell_type": "markdown", - "id": "14", + "id": "17", "metadata": {}, "source": [ "For more details, see the [Scenarios Programming Guide](../code/scenarios/0_scenarios.ipynb) and\n", From 5e0d95f712069ceb15b188384202849d90981768 Mon Sep 17 00:00:00 2001 From: Richard Lundeen Date: Tue, 18 Aug 2026 13:00:30 -0700 Subject: [PATCH 3/3] FIX: Address FigStep review feedback Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 669aaa53-c978-4479-aea3-b2215bb42041 --- doc/scanner/1_pyrit_scan.ipynb | 6 ++++ doc/scanner/1_pyrit_scan.py | 6 ++++ doc/scanner/garak.ipynb | 34 +++++++++-------------- doc/scanner/garak.py | 3 +- pyrit/scenario/scenarios/garak/figstep.py | 18 ++++++++++-- tests/unit/scenario/garak/test_figstep.py | 26 +++++++++++++++++ 6 files changed, 67 insertions(+), 26 deletions(-) diff --git a/doc/scanner/1_pyrit_scan.ipynb b/doc/scanner/1_pyrit_scan.ipynb index af11f40ef9..9f096d421d 100644 --- a/doc/scanner/1_pyrit_scan.ipynb +++ b/doc/scanner/1_pyrit_scan.ipynb @@ -830,6 +830,12 @@ "- `--max-retries `: Maximum number of automatic retries if the scenario raises an exception\n", "- `--memory-labels `: Additional labels to apply to all attack runs (must be a JSON string with string keys and values)\n", "\n", + "Dataset-backed scenarios can also select a supported dataset:\n", + "\n", + "```shell\n", + "pyrit_scan garak.figstep --target openai_chat --dataset-names figstep_pro --max-dataset-size 1\n", + "```\n", + "\n", "You can also use custom initialization scripts by passing file paths. It is relative to your current working directory, but to avoid confusion, full paths are always better:\n", "\n", "```shell\n", diff --git a/doc/scanner/1_pyrit_scan.py b/doc/scanner/1_pyrit_scan.py index 1c17ee2bdb..d30bdf5acc 100644 --- a/doc/scanner/1_pyrit_scan.py +++ b/doc/scanner/1_pyrit_scan.py @@ -119,6 +119,12 @@ # - `--max-retries `: Maximum number of automatic retries if the scenario raises an exception # - `--memory-labels `: Additional labels to apply to all attack runs (must be a JSON string with string keys and values) # +# Dataset-backed scenarios can also select a supported dataset: +# +# ```shell +# pyrit_scan garak.figstep --target openai_chat --dataset-names figstep_pro --max-dataset-size 1 +# ``` +# # You can also use custom initialization scripts by passing file paths. It is relative to your current working directory, but to avoid confusion, full paths are always better: # # ```shell diff --git a/doc/scanner/garak.ipynb b/doc/scanner/garak.ipynb index a822df769a..0ac3a47af5 100644 --- a/doc/scanner/garak.ipynb +++ b/doc/scanner/garak.ipynb @@ -142,7 +142,7 @@ "> and image input in the same message. Select exactly one of the `figstep` or `figstep_pro`\n", "> datasets; unrelated named datasets are rejected because they do not contain the required visual\n", "> payload. By default, PyRIT also sends each sampled objective as direct text. Use\n", - "> `--no-include-baseline` to omit this comparison." + "> `--include-baseline False` to omit this comparison." ] }, { @@ -166,22 +166,14 @@ "print(f\"Scenario: {figstep_scenario.name}\")\n", "print(f\"Atomic attacks: {figstep_scenario.atomic_attack_count}\")\n", "\n", - "figstep_result = await figstep_scenario.run_async() # type: ignore" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "7", - "metadata": {}, - "outputs": [], - "source": [ + "figstep_result = await figstep_scenario.run_async() # type: ignore\n", + "\n", "await output_scenario_async(figstep_result)" ] }, { "cell_type": "markdown", - "id": "8", + "id": "7", "metadata": {}, "source": [ "## WebInjection\n", @@ -207,7 +199,7 @@ }, { "cell_type": "markdown", - "id": "9", + "id": "8", "metadata": {}, "source": [ "## Doctor\n", @@ -231,7 +223,7 @@ }, { "cell_type": "markdown", - "id": "10", + "id": "9", "metadata": {}, "source": [ "## SystemPromptExtraction\n", @@ -264,7 +256,7 @@ { "cell_type": "code", "execution_count": null, - "id": "11", + "id": "10", "metadata": {}, "outputs": [], "source": [ @@ -286,7 +278,7 @@ { "cell_type": "code", "execution_count": null, - "id": "12", + "id": "11", "metadata": {}, "outputs": [], "source": [ @@ -295,7 +287,7 @@ }, { "cell_type": "markdown", - "id": "13", + "id": "12", "metadata": {}, "source": [ "## PackageHallucination\n", @@ -326,7 +318,7 @@ }, { "cell_type": "markdown", - "id": "14", + "id": "13", "metadata": {}, "source": [ "## AudioAchillesHeel\n", @@ -354,7 +346,7 @@ { "cell_type": "code", "execution_count": null, - "id": "15", + "id": "14", "metadata": {}, "outputs": [], "source": [ @@ -382,7 +374,7 @@ { "cell_type": "code", "execution_count": null, - "id": "16", + "id": "15", "metadata": {}, "outputs": [], "source": [ @@ -391,7 +383,7 @@ }, { "cell_type": "markdown", - "id": "17", + "id": "16", "metadata": {}, "source": [ "For more details, see the [Scenarios Programming Guide](../code/scenarios/0_scenarios.ipynb) and\n", diff --git a/doc/scanner/garak.py b/doc/scanner/garak.py index da0e612738..c6b7cf0064 100644 --- a/doc/scanner/garak.py +++ b/doc/scanner/garak.py @@ -112,7 +112,7 @@ # > and image input in the same message. Select exactly one of the `figstep` or `figstep_pro` # > datasets; unrelated named datasets are rejected because they do not contain the required visual # > payload. By default, PyRIT also sends each sampled objective as direct text. Use -# > `--no-include-baseline` to omit this comparison. +# > `--include-baseline False` to omit this comparison. # %% figstep_dataset_config = DatasetAttackConfiguration(dataset_names=["figstep"], max_dataset_size=1) @@ -131,7 +131,6 @@ figstep_result = await figstep_scenario.run_async() # type: ignore -# %% await output_scenario_async(figstep_result) # %% [markdown] diff --git a/pyrit/scenario/scenarios/garak/figstep.py b/pyrit/scenario/scenarios/garak/figstep.py index 812672e98a..895db50e0f 100644 --- a/pyrit/scenario/scenarios/garak/figstep.py +++ b/pyrit/scenario/scenarios/garak/figstep.py @@ -11,9 +11,10 @@ from pyrit.common.path import SCORER_SEED_PROMPT_PATH from pyrit.executor.attack import AttackScoringConfig, PromptSendingAttack from pyrit.models import AttackSeedGroup, PromptDataType +from pyrit.prompt_normalizer import ConverterConfiguration from pyrit.prompt_target import CapabilityName, TargetRequirements from pyrit.scenario.core.atomic_attack import AtomicAttack -from pyrit.scenario.core.attack_technique import AttackTechnique +from pyrit.scenario.core.attack_technique_factory import AttackTechniqueFactory from pyrit.scenario.core.dataset_configuration import ( INLINE_DATASET_NAME, DatasetAttackConfiguration, @@ -46,6 +47,13 @@ class FigStepTechnique(ScenarioTechnique): VisualJailbreak = ("visual_jailbreak", set[str]()) +_VISUAL_JAILBREAK_FACTORY = AttackTechniqueFactory( + name=FigStepTechnique.VisualJailbreak.value, + attack_class=PromptSendingAttack, + technique_tags=["single_turn"], +) + + class FigStep(Scenario): """ Run FigStep or FigStep-Pro typographic-image jailbreaks against a multimodal target. @@ -191,15 +199,19 @@ async def _build_atomic_attacks_async(self, *, context: ScenarioContext) -> list ) ) - attack = PromptSendingAttack( + user_converters = ConverterConfiguration.from_converters( + converters=self._technique_converters.get(FigStepTechnique.VisualJailbreak.value, []) + ) + attack_technique = _VISUAL_JAILBREAK_FACTORY.create( objective_target=context.objective_target, attack_scoring_config=self._scorer_config, + extra_request_converters=user_converters, ) atomic_attacks.append( AtomicAttack( atomic_attack_name=f"{FigStepTechnique.VisualJailbreak.value}_{dataset_label}", display_group=dataset_label, - attack_technique=AttackTechnique(attack=attack), + attack_technique=attack_technique, seed_groups=seed_groups, memory_labels=context.memory_labels, ) diff --git a/tests/unit/scenario/garak/test_figstep.py b/tests/unit/scenario/garak/test_figstep.py index e3f00ac30b..b994d3e14f 100644 --- a/tests/unit/scenario/garak/test_figstep.py +++ b/tests/unit/scenario/garak/test_figstep.py @@ -9,6 +9,7 @@ import pytest from pyrit.common.path import SCORER_SEED_PROMPT_PATH +from pyrit.converter import Base64Converter, Converter from pyrit.executor.attack import PromptSendingAttack from pyrit.models import AttackSeedGroup, ComponentIdentifier, SeedObjective, SeedPrompt from pyrit.prompt_target import ( @@ -64,6 +65,7 @@ async def _initialize( dataset_config: DatasetAttackConfiguration, include_baseline: bool | None = None, memory_labels: dict[str, str] | None = None, + technique_converters: dict[str, list[Converter]] | None = None, ) -> None: args: dict[str, Any] = { "objective_target": target, @@ -73,6 +75,8 @@ async def _initialize( args["include_baseline"] = include_baseline if memory_labels is not None: args["memory_labels"] = memory_labels + if technique_converters is not None: + args["technique_converters"] = technique_converters with patch("pyrit.prompt_target.common.target_requirements.TargetRequirements.validate"): scenario.set_params_from_args(args=args) @@ -343,6 +347,28 @@ async def test_memory_labels_are_forwarded(self, mock_objective_target, mock_obj assert all(attack._memory_labels == labels for attack in scenario._atomic_attacks) + async def test_user_converters_are_applied_only_to_visual_attack( + self, mock_objective_target, mock_objective_scorer + ): + converter = Base64Converter() + scenario = FigStep(objective_scorer=mock_objective_scorer) + + await _initialize( + scenario=scenario, + target=mock_objective_target, + dataset_config=_inline_config(), + technique_converters={FigStepTechnique.VisualJailbreak.value: [converter]}, + ) + + baseline, visual = scenario._atomic_attacks + baseline_attack = baseline.attack_technique.attack + visual_attack = visual.attack_technique.attack + assert isinstance(baseline_attack, PromptSendingAttack) + assert isinstance(visual_attack, PromptSendingAttack) + assert baseline_attack._request_converters == [] + assert len(visual_attack._request_converters) == 1 + assert visual_attack._request_converters[0].converters == [converter] + class TestFigStepTechnique: def test_all_expands_to_visual_jailbreak(self):