diff --git a/doc/scanner/airt.ipynb b/doc/scanner/airt.ipynb index 7a1e2cf1eb..8336d53f8f 100644 --- a/doc/scanner/airt.ipynb +++ b/doc/scanner/airt.ipynb @@ -34,13 +34,7 @@ "text": [ "Found default environment files: ['./.pyrit/.env', './.pyrit/.env.local']\n", "Loaded environment file: ./.pyrit/.env\n", - "Loaded environment file: ./.pyrit/.env.local\n" - ] - }, - { - "name": "stdout", - "output_type": "stream", - "text": [ + "Loaded environment file: ./.pyrit/.env.local\n", "[pyrit:alembic] No new upgrade operations detected.\n" ] }, @@ -48,14 +42,7 @@ "name": "stderr", "output_type": "stream", "text": [ - "TextAdaptive: _EXCLUDED_TECHNIQUES entries ['prompt_sending'] are not in the current scenario-techniques catalog ['context_compliance', 'crescendo_history_lecture', 'crescendo_journalist_interview', 'crescendo_movie_director', 'crescendo_simulated', 'flip', 'many_shot', 'pair', 'red_teaming', 'role_play_movie_script', 'role_play_persuasion', 'role_play_persuasion_written', 'role_play_trivia_game', 'role_play_video_game', 'tap', 'violent_durian']; the exclusion is a no-op for those entries. Remove stale entries or update the catalog.\n" - ] - }, - { - "name": "stderr", - "output_type": "stream", - "text": [ - "\n" + "TextAdaptive: _EXCLUDED_TECHNIQUES entries ['prompt_sending'] are not in the current scenario-techniques catalog ['context_compliance', 'crescendo_history_lecture', 'crescendo_journalist_interview', 'crescendo_movie_director', 'crescendo_simulated', 'flip', 'many_shot', 'pair', 'red_teaming', 'role_play_movie_script', 'role_play_persuasion', 'role_play_persuasion_written', 'role_play_trivia_game', 'role_play_video_game', 'skeleton_key', 'tap', 'violent_durian']; the exclusion is a no-op for those entries. Remove stale entries or update the catalog.\n" ] } ], @@ -1267,6 +1254,157 @@ "cell_type": "markdown", "id": "15", "metadata": {}, + "source": [ + "## Multilingual\n", + "\n", + "Tests whether target safeguards remain effective when harmful objectives are presented in other\n", + "languages. The `prompt_sending` technique translates each objective into every selected language,\n", + "while `random_translation` translates individual words using the selected language pool. A baseline\n", + "sends each objective without translation and is included by default.\n", + "\n", + "```bash\n", + "pyrit_scan airt.multilingual \\\n", + " --initializers target load_default_datasets \\\n", + " --target openai_chat \\\n", + " --dataset-names harmbench \\\n", + " --max-dataset-size 1\n", + "```\n", + "\n", + "**Available techniques:** prompt_sending, random_translation. By default, both techniques run against\n", + "two randomly selected languages. Pass `num_languages` to change the random sample size or `languages`\n", + "to provide an explicit list; the two selectors are mutually exclusive." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "16", + "metadata": {}, + "outputs": [ + { + "data": { + "application/vnd.jupyter.widget-view+json": { + "model_id": "25112df131b34044b29b48f0ec339358", + "version_major": 2, + "version_minor": 0 + }, + "text/plain": [ + "Executing Multilingual: 0%| | 0/5 [00:00 Any: return _build_cyber_technique() if name == "JailbreakTechnique": return _build_jailbreak_technique() + if name == "MultilingualTechnique": + return _build_multilingual_technique() raise AttributeError(f"module {__name__!r} has no attribute {name!r}") @@ -41,6 +44,8 @@ def __getattr__(name: str) -> Any: "JailbreakTechnique", "Leakage", "LeakageTechnique", + "Multilingual", + "MultilingualTechnique", "Psychosocial", "PsychosocialTechnique", "RapidResponse", diff --git a/pyrit/scenario/scenarios/airt/multilingual.py b/pyrit/scenario/scenarios/airt/multilingual.py new file mode 100644 index 0000000000..56f3a67a26 --- /dev/null +++ b/pyrit/scenario/scenarios/airt/multilingual.py @@ -0,0 +1,312 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT license. + +from __future__ import annotations + +import logging +import random +from functools import cache +from typing import TYPE_CHECKING, Any, ClassVar + +from pyrit.common import apply_defaults +from pyrit.common.path import DATASETS_PATH +from pyrit.converter import Converter, RandomTranslationConverter, TranslationConverter +from pyrit.executor.attack import AttackConverterConfig, AttackScoringConfig, PromptSendingAttack +from pyrit.models import Parameter, SeedDataset +from pyrit.prompt_normalizer import ConverterConfiguration +from pyrit.registry.components.attack_technique_registry import AttackTechniqueRegistry +from pyrit.scenario.core import ( + AtomicAttack, + AttackTechnique, + AttackTechniqueFactory, + BaselineAttackPolicy, + DatasetAttackConfiguration, + Scenario, + ScenarioTechnique, + get_default_adversarial_target, +) +from pyrit.scenario.core.matrix_atomic_attack_builder import build_baseline_atomic_attack + +if TYPE_CHECKING: + from pyrit.models import AttackSeedGroup + from pyrit.prompt_target import PromptTarget + from pyrit.scenario.core import ScenarioTechnique + from pyrit.scenario.core.scenario_context import ScenarioContext + from pyrit.score import TrueFalseScorer + +logger = logging.getLogger(__name__) + +# Metadata key under which the resolved languages are persisted, so a resumed run +# replays the exact same set even when a random sample was drawn. +_LANGUAGES_METADATA_KEY = "languages" + +# How many languages a bare run draws at random. Kept small so the default run stays fast +# — languages multiply against objectives and techniques. Override per run with +# ``num_languages`` (random count) or ``languages`` (an explicit set). +_DEFAULT_NUM_LANGUAGES = 2 + +# Scenario-local default techniques. +# - ``prompt_sending`` sends the objective in each selected language. +# - ``random_translation`` sends the objective with word-level random translations. +_PROMPT_SENDING = "prompt_sending" +_RANDOM_TRANSLATION = "random_translation" + + +@cache +def _build_multilingual_technique() -> type[ScenarioTechnique]: + """ + Build the Multilingual technique class from scenario-local factories. + + Returns: + type[ScenarioTechnique]: The dynamically generated technique enum class. + """ + factories = [ + AttackTechniqueFactory( + name=_PROMPT_SENDING, + attack_class=PromptSendingAttack, + technique_tags=["single_turn"], + ), + AttackTechniqueFactory( + name=_RANDOM_TRANSLATION, + attack_class=PromptSendingAttack, + technique_tags=["single_turn"], + ), + ] + return AttackTechniqueRegistry.build_technique_class_from_factories( # type: ignore[ty:invalid-return-type] + class_name="MultilingualTechnique", + factories=factories, + default_tags={"single_turn"}, + ) + + +class Multilingual(Scenario): + """ + Multilingual scenario implementation for PyRIT. + + Tests how vulnerable a model is to non-English language use. + """ + + VERSION: int = 1 + BASELINE_ATTACK_POLICY: ClassVar[BaselineAttackPolicy] = BaselineAttackPolicy.Enabled + + # Default language list + _DEFAULT_LANGUAGES_SEED_PROMPT_PATH = DATASETS_PATH / "lexicons" / "languages_most_spoken.yaml" + + @classmethod + def required_datasets(cls) -> list[str]: + """Return a list of dataset names required by this scenario.""" + return ["harmbench"] + + @classmethod + def additional_parameters(cls) -> list[Parameter]: + """ + Declare the run-configurable parameters this scenario accepts (CLI / config file). + + Returns: + list[Parameter]: The language selectors (``num_languages``, ``languages``). + """ + return [ + Parameter( + name="num_languages", + description="Draw this many random languages. Mutually exclusive with languages.", + param_type=int, + default=None, + ), + Parameter( + name="languages", + description=( + "Explicit languages to use (e.g. French, German, Spanish). " + "When omitted, a random sample is drawn. Mutually exclusive with num_languages." + ), + param_type=list[str], + default=None, + ), + ] + + @apply_defaults + def __init__( + self, + *, + adversarial_chat: PromptTarget | None = None, + objective_scorer: TrueFalseScorer | None = None, + scenario_result_id: str | None = None, + ) -> None: + """ + Initialize the multilingual scenario. + + Args: + adversarial_chat (PromptTarget | None): Target used by the translation converters. + objective_scorer (TrueFalseScorer | None): Scorer used to evaluate target responses. + scenario_result_id (str | None): Optional ID of an existing scenario result to resume. + """ + self._adversarial_chat = adversarial_chat + self._objective_scorer: TrueFalseScorer = ( + objective_scorer if objective_scorer else self._get_default_objective_scorer() + ) + self._default_languages = self._get_default_languages() + self._resolved_languages: list[str] = [] + + technique_class = _build_multilingual_technique() + + super().__init__( + version=self.VERSION, + technique_class=technique_class, + default_dataset_config=DatasetAttackConfiguration(dataset_names=["harmbench"], max_dataset_size=4), + objective_scorer=self._objective_scorer, + scenario_result_id=scenario_result_id, + ) + + @classmethod + def _get_default_languages(cls) -> list[str]: + """ + Load the default languages from the public PyRIT lexicon. + + Returns: + list[str]: The list of most-spoken languages. + """ + dataset = SeedDataset.from_yaml_file(cls._DEFAULT_LANGUAGES_SEED_PROMPT_PATH) + return [str(seed.value) for seed in dataset.seeds] + + def _resolve_languages(self) -> list[str]: + """ + Resolve the languages for this run, replaying the persisted set on resume. + + On a fresh run this reads the run parameters: an explicit ``languages`` set or a random + ``num_languages`` sample (defaulting to a small random draw when neither is given). On resume + the originally chosen set is read back from the stored ``ScenarioResult`` metadata so a random + sample isn't redrawn (which would diverge from the persisted attacks). + + Returns: + list[str]: The explicit or randomly sampled languages for this run. + + Raises: + ValueError: If both ``num_languages`` and ``languages`` are provided, + or if ``num_languages`` is out of bounds. + """ + if self._scenario_result_id is not None: + stored = self._memory.get_scenario_results(scenario_result_ids=[self._scenario_result_id]) + if stored: + persisted = (stored[0].metadata or {}).get(_LANGUAGES_METADATA_KEY) + if persisted: + return list(persisted) + + num_languages = self.params.get("num_languages") + languages = self.params.get("languages") + + if num_languages and languages: + raise ValueError( + "Please provide only one of `num_languages` (random selection) or `languages` (specific selection)." + ) + + if languages: + return languages + + count = int(num_languages) if num_languages is not None else _DEFAULT_NUM_LANGUAGES + if count < 1 or count > len(self._default_languages): + raise ValueError(f"num_languages must be between 1 and {len(self._default_languages)}.") + return random.sample(self._default_languages, count) + + def _build_initial_scenario_metadata(self) -> dict[str, Any]: + """ + Persist the resolved languages alongside the base scenario metadata. + + Returns: + dict[str, Any]: The base metadata plus the resolved language set. + """ + metadata = super()._build_initial_scenario_metadata() + metadata[_LANGUAGES_METADATA_KEY] = list(self._resolved_languages) + return metadata + + async def _build_atomic_attacks_async(self, *, context: ScenarioContext) -> list[AtomicAttack]: + """ + Build the selected translation attacks over the resolved objective population. + + Args: + context (ScenarioContext): The resolved runtime inputs for this run. + + Returns: + list[AtomicAttack]: The atomic attacks to execute. + + Raises: + ValueError: If the scenario is not properly initialized. + """ + if self._objective_target is None: + raise ValueError( + "Scenario not properly initialized. Call await scenario.initialize_async() before running." + ) + + self._resolved_languages = self._resolve_languages() + adversarial_chat = self._adversarial_chat or get_default_adversarial_target() + techniques = {technique.value for technique in context.scenario_techniques} + seed_groups = list(context.seed_groups) + + atomic_attacks: list[AtomicAttack] = [] + if context.include_baseline: + atomic_attacks.append( + build_baseline_atomic_attack( + objective_target=context.objective_target, + objective_scorer=self._objective_scorer, + seed_groups=seed_groups, + memory_labels=context.memory_labels, + ) + ) + + if _PROMPT_SENDING in techniques: + atomic_attacks.extend( + self._build_atomic_attack( + context=context, + seed_groups=seed_groups, + converter=TranslationConverter(converter_target=adversarial_chat, language=language), + name=f"translation_{language.lower().replace(' ', '_')}", + display_group=language, + ) + for language in self._resolved_languages + ) + + if _RANDOM_TRANSLATION in techniques: + atomic_attacks.append( + self._build_atomic_attack( + context=context, + seed_groups=seed_groups, + converter=RandomTranslationConverter( + converter_target=adversarial_chat, + languages=self._resolved_languages, + ), + name="random_translation", + display_group="Random Translation", + ) + ) + + return atomic_attacks + + def _build_atomic_attack( + self, + *, + context: ScenarioContext, + seed_groups: list[AttackSeedGroup], + converter: Converter, + name: str, + display_group: str, + ) -> AtomicAttack: + """ + Build a prompt-sending atomic attack with one request converter. + + Returns: + AtomicAttack: The configured attack and its resolved seed groups. + """ + converter_config = AttackConverterConfig( + request_converters=ConverterConfiguration.from_converters(converters=[converter]) + ) + attack = PromptSendingAttack( + objective_target=context.objective_target, + attack_scoring_config=AttackScoringConfig(objective_scorer=self._objective_scorer), + attack_converter_config=converter_config, + ) + return AtomicAttack( + atomic_attack_name=name, + display_group=display_group, + attack_technique=AttackTechnique(attack=attack), + seed_groups=seed_groups, + objective_scorer=self._objective_scorer, + memory_labels=context.memory_labels, + ) diff --git a/tests/unit/scenario/airt/test_multilingual.py b/tests/unit/scenario/airt/test_multilingual.py new file mode 100644 index 0000000000..11e28db526 --- /dev/null +++ b/tests/unit/scenario/airt/test_multilingual.py @@ -0,0 +1,217 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT license. + +"""Tests for the Multilingual scenario.""" + +from unittest.mock import AsyncMock, MagicMock, patch + +import pytest + +from pyrit.converter import RandomTranslationConverter, TranslationConverter +from pyrit.models import AttackSeedGroup, ComponentIdentifier, SeedObjective +from pyrit.prompt_target import PromptTarget +from pyrit.scenario.scenarios.airt.multilingual import ( + _DEFAULT_NUM_LANGUAGES, + _LANGUAGES_METADATA_KEY, + Multilingual, + _build_multilingual_technique, +) +from pyrit.score import TrueFalseScorer + + +def _mock_identifier(name: str) -> ComponentIdentifier: + """Build a component identifier for a mock scenario dependency.""" + return ComponentIdentifier(class_name=name, class_module="test") + + +@pytest.fixture +def mock_memory_seed_groups() -> list[AttackSeedGroup]: + """Create an inline objective population.""" + return [AttackSeedGroup(seeds=[SeedObjective(value="test objective")])] + + +@pytest.fixture +def mock_objective_target() -> PromptTarget: + """Create the target under test.""" + mock = MagicMock(spec=PromptTarget) + mock.get_identifier.return_value = _mock_identifier("MockObjectiveTarget") + mock.configuration.includes.return_value = True + return mock + + +@pytest.fixture +def mock_adversarial_chat() -> PromptTarget: + """Create the target used by translation converters.""" + mock = MagicMock(spec=PromptTarget) + mock.get_identifier.return_value = _mock_identifier("MockAdversarialChat") + mock.capabilities.includes.return_value = True + return mock + + +@pytest.fixture +def mock_objective_scorer() -> TrueFalseScorer: + """Create the objective scorer.""" + mock = MagicMock(spec=TrueFalseScorer) + mock.get_identifier.return_value = _mock_identifier("MockObjectiveScorer") + return mock + + +def _patch_seed_groups(mock_memory_seed_groups): + return patch.object( + Multilingual, + "_resolve_seed_groups_by_dataset_async", + new_callable=AsyncMock, + return_value={"inline": mock_memory_seed_groups}, + ) + + +def _request_converter(atomic_attack): + """Return the single request converter configured on an atomic attack.""" + configurations = atomic_attack.attack_technique.attack.get_request_converters() + assert len(configurations) == 1 + assert len(configurations[0].converters) == 1 + return configurations[0].converters[0] + + +@pytest.mark.usefixtures("patch_central_database") +class TestMultilingual: + """Validate multilingual technique selection and converter construction.""" + + def test_technique_tags_define_aggregates(self) -> None: + technique_class = _build_multilingual_technique() + + expected = { + "prompt_sending", + "random_translation", + } + assert {technique.value for technique in technique_class.expand({technique_class.SINGLE_TURN})} == expected + + def test_declares_run_parameters(self) -> None: + """num_languages / languages are declared as run parameters.""" + names = {parameter.name for parameter in Multilingual.additional_parameters()} + assert names == {"num_languages", "languages"} + assert names.issubset({parameter.name for parameter in Multilingual.supported_parameters()}) + + async def test_default_draws_two_random_languages( + self, mock_objective_target, mock_adversarial_chat, mock_objective_scorer, mock_memory_seed_groups + ): + selected = ["French", "Spanish"] + + with ( + _patch_seed_groups(mock_memory_seed_groups), + patch("pyrit.scenario.scenarios.airt.multilingual.random.sample", return_value=selected) as sample, + ): + scenario = Multilingual( + adversarial_chat=mock_adversarial_chat, + objective_scorer=mock_objective_scorer, + ) + scenario.set_params_from_args(args={"objective_target": mock_objective_target}) + await scenario.initialize_async() + assert scenario._resolved_languages == selected + assert sample.call_args.args[1] == _DEFAULT_NUM_LANGUAGES + + async def test_num_languages_samples_that_many( + self, mock_objective_target, mock_adversarial_chat, mock_objective_scorer, mock_memory_seed_groups + ): + selected = ["French", "German", "Spanish"] + + with ( + _patch_seed_groups(mock_memory_seed_groups), + patch("pyrit.scenario.scenarios.airt.multilingual.random.sample", return_value=selected) as sample, + ): + scenario = Multilingual( + adversarial_chat=mock_adversarial_chat, + objective_scorer=mock_objective_scorer, + ) + scenario.set_params_from_args(args={"objective_target": mock_objective_target, "num_languages": 3}) + await scenario.initialize_async() + assert scenario._resolved_languages == selected + assert sample.call_args.args[1] == 3 + + async def test_explicit_languages_build_attacks_and_configure_random_translation( + self, mock_objective_target, mock_adversarial_chat, mock_objective_scorer, mock_memory_seed_groups + ): + with _patch_seed_groups(mock_memory_seed_groups): + scenario = Multilingual( + adversarial_chat=mock_adversarial_chat, + objective_scorer=mock_objective_scorer, + ) + scenario.set_params_from_args( + args={"objective_target": mock_objective_target, "languages": ["Canadian French", "Spanish"]} + ) + await scenario.initialize_async() + + assert [attack.atomic_attack_name for attack in scenario._atomic_attacks] == [ + "baseline", + "translation_canadian_french", + "translation_spanish", + "random_translation", + ] + converters = [_request_converter(attack) for attack in scenario._atomic_attacks[1:4]] + assert all(isinstance(converter, TranslationConverter) for converter in converters[0:2]) + assert isinstance(converters[2], RandomTranslationConverter) + assert converters[2].languages == ["Canadian French", "Spanish"] + + async def test_mutually_exclusive_selectors_raise( + self, mock_objective_target, mock_adversarial_chat, mock_objective_scorer, mock_memory_seed_groups + ): + with _patch_seed_groups(mock_memory_seed_groups): + scenario = Multilingual( + adversarial_chat=mock_adversarial_chat, + objective_scorer=mock_objective_scorer, + ) + scenario.set_params_from_args( + args={ + "objective_target": mock_objective_target, + "num_languages": 2, + "languages": ["French"], + } + ) + with pytest.raises(ValueError, match="only one of"): + await scenario.initialize_async() + + async def test_metadata_records_resolved_languages( + self, mock_objective_target, mock_adversarial_chat, mock_objective_scorer, mock_memory_seed_groups + ): + with _patch_seed_groups(mock_memory_seed_groups): + scenario = Multilingual( + adversarial_chat=mock_adversarial_chat, + objective_scorer=mock_objective_scorer, + ) + scenario.set_params_from_args( + args={ + "objective_target": mock_objective_target, + "languages": ["French", "Spanish"], + } + ) + await scenario.initialize_async() + + metadata = scenario._build_initial_scenario_metadata() + assert metadata[_LANGUAGES_METADATA_KEY] == ["French", "Spanish"] + + def test_resolve_languages_replays_persisted_set_on_resume(self, mock_adversarial_chat, mock_objective_scorer): + scenario = Multilingual( + adversarial_chat=mock_adversarial_chat, + objective_scorer=mock_objective_scorer, + scenario_result_id="existing-result", + ) + stored = MagicMock() + stored.metadata = {_LANGUAGES_METADATA_KEY: ["French", "Spanish"]} + + with patch.object(scenario._memory, "get_scenario_results", return_value=[stored]): + assert scenario._resolve_languages() == ["French", "Spanish"] + + async def test_baseline_is_prepended_by_default_with_same_seed_population( + self, mock_objective_target, mock_adversarial_chat, mock_objective_scorer, mock_memory_seed_groups + ): + with _patch_seed_groups(mock_memory_seed_groups): + scenario = Multilingual( + adversarial_chat=mock_adversarial_chat, + objective_scorer=mock_objective_scorer, + ) + scenario.set_params_from_args(args={"objective_target": mock_objective_target, "languages": ["French"]}) + await scenario.initialize_async() + + assert scenario._atomic_attacks[0].atomic_attack_name == "baseline" + assert scenario._atomic_attacks[0].seed_groups == scenario._atomic_attacks[1].seed_groups + assert scenario._atomic_attacks[0].seed_groups[0] is scenario._atomic_attacks[1].seed_groups[0]