From 7e3d3cf5e07c95f702b97e3914cf72582b129210 Mon Sep 17 00:00:00 2001 From: thelastbackspace <23155651+thelastbackspace@users.noreply.github.com> Date: Tue, 3 Nov 2026 10:59:45 +0100 Subject: [PATCH] FIX: Reject unsupported system prompts at send under RAISE policy PromptTarget._validate_request now raises when a system message remains in the normalized conversation and the target does not natively support system prompts. Previously, with SYSTEM_PROMPT set to RAISE, the message was forwarded to the provider. ADAPT and native support are unchanged. Fixes #3862 --- pyrit/prompt_target/common/prompt_target.py | 10 +++++- .../test_normalize_async_integration.py | 36 +++++++++++++++++++ 2 files changed, 45 insertions(+), 1 deletion(-) diff --git a/pyrit/prompt_target/common/prompt_target.py b/pyrit/prompt_target/common/prompt_target.py index 50c8084a38..641909652c 100644 --- a/pyrit/prompt_target/common/prompt_target.py +++ b/pyrit/prompt_target/common/prompt_target.py @@ -369,7 +369,7 @@ def _validate_request(self, *, normalized_conversation: list[Message]) -> None: Called after the normalization pipeline has run. Validates the last message (the current request) for piece count, data types, and checks - whether the full conversation violates multi-turn constraints. + whether the full conversation violates multi-turn or system prompt constraints. Args: normalized_conversation: The normalized conversation to validate. @@ -415,6 +415,14 @@ def _validate_request(self, *, normalized_conversation: list[Message]) -> None: if not self.configuration.includes(capability=CapabilityName.MULTI_TURN) and len(normalized_conversation) > 1: raise ValueError(f"This target only supports a single turn conversation. {custom_configuration_message}") + if not self.configuration.includes(capability=CapabilityName.SYSTEM_PROMPT) and any( + piece.api_role == "system" for turn in normalized_conversation for piece in turn.message_pieces + ): + raise ValueError( + "This target does not support system prompts and no normalizer adapted the system message. " + f"{custom_configuration_message}" + ) + @staticmethod def _get_web_search_config(message: Message) -> WebSearchConfig: configs = { diff --git a/tests/unit/prompt_target/target/test_normalize_async_integration.py b/tests/unit/prompt_target/target/test_normalize_async_integration.py index 28c8adecf9..a5e72a4a5c 100644 --- a/tests/unit/prompt_target/target/test_normalize_async_integration.py +++ b/tests/unit/prompt_target/target/test_normalize_async_integration.py @@ -209,6 +209,42 @@ async def test_openai_chat_target_memory_not_mutated(): assert memory_conversation[0].get_piece().api_role == "system" +@pytest.mark.usefixtures("patch_central_database") +async def test_openai_chat_target_raises_for_unsupported_system_prompt_under_raise_policy(): + """A system message the target cannot accept must not reach the provider under RAISE.""" + target = OpenAIChatTarget( + model_name="gpt-4o", + endpoint="https://mock.azure.com/", + api_key="mock-api-key", + custom_configuration=TargetConfiguration( + capabilities=TargetCapabilities( + supports_multi_turn=True, + supports_editable_history=True, + supports_system_prompt=False, + input_modalities=frozenset({frozenset(["text"])}), + ), + policy=CapabilityHandlingPolicy( + behaviors={ + CapabilityName.MULTI_TURN: UnsupportedCapabilityBehavior.RAISE, + CapabilityName.SYSTEM_PROMPT: UnsupportedCapabilityBehavior.RAISE, + } + ), + ), + ) + + mock_memory = MagicMock(spec=MemoryInterface) + mock_memory.get_conversation_messages_async = AsyncMock( + return_value=[_make_message(role="system", content="Policy")] + ) + target._memory = mock_memory + target._async_client.chat.completions.create = AsyncMock(return_value=_create_mock_chat_completion()) + + with pytest.raises(ValueError, match="does not support system prompts"): + await target.send_prompt_async(message=_make_message(role="user", content="Question")) + + target._async_client.chat.completions.create.assert_not_called() + + # --------------------------------------------------------------------------- # OpenAIResponseTarget — normalize_async is called # ---------------------------------------------------------------------------