From 2b3d9879e110469ea5812c5f852787708560ceda Mon Sep 17 00:00:00 2001 From: Varun Joginpalli Date: Wed, 10 Jun 2026 21:22:07 +0000 Subject: [PATCH 01/17] rename mapper Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- pyrit/backend/mappers/__init__.py | 2 ++ pyrit/backend/mappers/target_mappers.py | 4 ++-- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/pyrit/backend/mappers/__init__.py b/pyrit/backend/mappers/__init__.py index 310b04e916..a6d169fc9f 100644 --- a/pyrit/backend/mappers/__init__.py +++ b/pyrit/backend/mappers/__init__.py @@ -20,6 +20,7 @@ converter_object_to_instance, ) from pyrit.backend.mappers.target_mappers import ( + target_capabilities_to_info, target_object_to_instance, ) @@ -31,5 +32,6 @@ "pyrit_scores_to_dto", "request_piece_to_pyrit_message_piece", "request_to_pyrit_message", + "target_capabilities_to_info", "target_object_to_instance", ] diff --git a/pyrit/backend/mappers/target_mappers.py b/pyrit/backend/mappers/target_mappers.py index b7f715aca0..6f5ced845e 100644 --- a/pyrit/backend/mappers/target_mappers.py +++ b/pyrit/backend/mappers/target_mappers.py @@ -15,7 +15,7 @@ _CAPABILITY_PARAM_NAMES = frozenset(cap.value for cap in CapabilityName) -def _target_capabilities_to_info(capabilities: TargetCapabilities) -> TargetCapabilitiesInfo: +def target_capabilities_to_info(capabilities: TargetCapabilities) -> TargetCapabilitiesInfo: """ Build a TargetCapabilitiesInfo DTO from a domain TargetCapabilities object. @@ -102,7 +102,7 @@ def target_object_to_instance(target_registry_name: str, target_obj: PromptTarge temperature=params.get("temperature"), top_p=params.get("top_p"), max_requests_per_minute=params.get("max_requests_per_minute"), - capabilities=_target_capabilities_to_info(target_obj.capabilities), + capabilities=target_capabilities_to_info(target_obj.capabilities), target_specific_params=combined_specific, inner_targets=inner_targets, identifier_hash=identifier.hash, From 0707fec74b94dcdb6ffc956bbc1374d9ce07d0d6 Mon Sep 17 00:00:00 2001 From: Varun Joginpalli Date: Wed, 10 Jun 2026 21:25:23 +0000 Subject: [PATCH 02/17] add validate response model --- pyrit/backend/models/__init__.py | 2 ++ pyrit/backend/models/targets.py | 37 ++++++++++++++++++++++++++++++++ 2 files changed, 39 insertions(+) diff --git a/pyrit/backend/models/__init__.py b/pyrit/backend/models/__init__.py index 388076fcd5..08792024be 100644 --- a/pyrit/backend/models/__init__.py +++ b/pyrit/backend/models/__init__.py @@ -63,6 +63,7 @@ TargetCapabilitiesInfo, TargetInstance, TargetListResponse, + ValidateCapabilitiesResponse, ) __all__ = [ @@ -117,4 +118,5 @@ "TargetCapabilitiesInfo", "TargetInstance", "TargetListResponse", + "ValidateCapabilitiesResponse", ] diff --git a/pyrit/backend/models/targets.py b/pyrit/backend/models/targets.py index 9e5ce0c56d..bb2fc4909e 100644 --- a/pyrit/backend/models/targets.py +++ b/pyrit/backend/models/targets.py @@ -77,6 +77,43 @@ class TargetListResponse(BaseModel): pagination: PaginationInfo = Field(..., description="Pagination metadata") +class ValidateCapabilitiesResponse(BaseModel): + """ + Response from validating a target's declared capabilities against observed behavior. + + Surfaces what the target class declares versus what live probing observed, + so users can spot drift caused by gateways stripping features, model + deployments lacking capabilities, or misconfiguration. + """ + + target_registry_name: str = Field(..., description="Target registry key the validation ran against") + declared: TargetCapabilitiesInfo = Field(..., description="Capabilities as declared by the target class") + observed: TargetCapabilitiesInfo = Field(..., description="Capabilities as observed by live probing") + # Drives the frontend "Not probed (no asset)" row beneath the input-modalities + # row. Without this field, the engine's `queried | (declared - test_modalities)` + # math at discover_target_capabilities.py:778 ORs non-probeable combinations + # back into observed, making observed == declared, and the frontend has no way + # to distinguish "genuinely confirmed" from "not probed". + non_probeable_input_modalities: list[str] = Field( + default_factory=list, + description=( + "Sorted list of declared input-modality combinations that could NOT be probed " + "because the engine has no packaged test asset for the contained types. Each " + "entry is a '+'-joined sorted combination (e.g., 'function_call' or 'image_path+url'). " + "The frontend renders the union of these as a single 'Not probed (no asset)' row " + "beneath the input-modalities row." + ), + ) + warnings: list[str] = Field( + default_factory=list, + description=( + "Operational notes for the user (e.g., 'this validation wrote test prompts to memory', " + "'output modalities are not probed and fall through to declared values', " + "'do not validate while an attack is actively running against this target')." + ), + ) + + class CreateTargetRequest(BaseModel): """Request to create a new target instance.""" From 833633121f5efa0357902c901b157840ad001fdc Mon Sep 17 00:00:00 2001 From: Varun Joginpalli Date: Wed, 10 Jun 2026 21:26:55 +0000 Subject: [PATCH 03/17] add validate_target_capabilities_async service method --- pyrit/backend/services/target_service.py | 174 ++++++++++++++++++++++- 1 file changed, 172 insertions(+), 2 deletions(-) diff --git a/pyrit/backend/services/target_service.py b/pyrit/backend/services/target_service.py index 6663dfa57b..b961321c91 100644 --- a/pyrit/backend/services/target_service.py +++ b/pyrit/backend/services/target_service.py @@ -12,6 +12,7 @@ - Retrieved from registry (pre-registered at startup or created earlier) """ +import asyncio import logging import os from functools import lru_cache @@ -20,14 +21,16 @@ from pyrit import prompt_target from pyrit.auth import get_azure_async_token_provider, get_azure_openai_auth -from pyrit.backend.mappers.target_mappers import target_object_to_instance +from pyrit.backend.mappers.target_mappers import target_capabilities_to_info, target_object_to_instance from pyrit.backend.models.common import PaginationInfo from pyrit.backend.models.targets import ( CreateTargetRequest, TargetInstance, TargetListResponse, + ValidateCapabilitiesResponse, ) -from pyrit.prompt_target import PromptTarget +from pyrit.models import PromptDataType +from pyrit.prompt_target import PromptTarget, discover_target_capabilities_async from pyrit.prompt_target.azure_ml_chat_target import AzureMLChatTarget from pyrit.prompt_target.openai.openai_target import OpenAITarget from pyrit.prompt_target.round_robin_target import RoundRobinTarget @@ -35,6 +38,19 @@ logger = logging.getLogger(__name__) +# Module-level allowlist of input modalities that the discovery engine actually +# has probe assets for. See `discover_target_capabilities.py:DEFAULT_TEST_ASSETS` +# (image_path, audio_path) and `_create_test_message` (text is synthetic). +# +# Any combination containing a modality outside this set will raise ValueError +# inside the engine, be silently skipped, and — when test_modalities is set +# explicitly — be DROPPED from the resolved input_modalities +# (`queried | (declared - frozenset(test_modalities))`). Filtering here prevents +# false red mismatches against real targets like OpenAIResponseTarget +# (declares function_call, tool_call, reasoning) and AzureBlobStorageTarget +# (declares url). +_PROBEABLE_INPUT_MODALITIES: frozenset[PromptDataType] = frozenset({"text", "image_path", "audio_path"}) + # Recognised Azure OpenAI / AI Foundry hostname suffixes. Used for strict # endpoint validation when Entra ID auth is requested, so a bearer token is # only ever issued for a known Microsoft-operated endpoint. @@ -139,9 +155,53 @@ class TargetService: # Scope for Azure Machine Learning managed online endpoints. _AZURE_ML_SCOPE: ClassVar[str] = "https://ml.azure.com/.default" + # Per-probe timeout for the GUI validation flow. The engine default is + # 30 s, which compounded across 5+ probes can exceed 2 min; 5 s keeps + # the GUI snappy while still catching real rejections. + _GUI_VALIDATE_TIMEOUT_S: ClassVar[float] = 5.0 + def __init__(self) -> None: """Initialize the target service.""" self._registry = TargetRegistry.get_registry_singleton() + # Per-target asyncio locks for capability validation. The discovery + # engine mutates `target._configuration` in place + # (discover_target_capabilities.py:_permissive_configuration) and the + # registry returns a singleton instance, so two concurrent validations + # on the same target can race on the restore. The lock dict is an + # INSTANCE attribute (not ClassVar): an asyncio.Lock lazy-binds to + # the running event loop on first await, and pytest gives each test a + # fresh event loop (pyproject.toml: asyncio_default_fixture_loop_scope + # = "function"). A ClassVar dict would leak locks from one test's + # loop into the next and raise RuntimeError. Instance dict = fresh + # per TargetService() = matches existing per-test pattern. + # Lock-map cardinality is bounded by registry size (one entry per + # registered target), not by call volume, so no eviction is needed + # for typical PyRIT workloads. + self._validate_locks: dict[str, asyncio.Lock] = {} + + def _get_validate_lock(self, *, target_registry_name: str) -> asyncio.Lock: + """ + Get-or-create the per-target validation lock. + + Kept synchronous on purpose: there is no ``await`` between the dict + ``get`` and the assignment, so two coroutines cannot interleave + between them and no extra guard lock is needed. Staying sync also + sidesteps the ``check-async-suffix`` hook (no ``_async`` suffix + needed for non-async methods). The returned ``asyncio.Lock`` binds + lazily to the running event loop on the caller's first + ``await lock.acquire()``. + + Args: + target_registry_name: The registry key of the target whose lock to fetch. + + Returns: + The per-target ``asyncio.Lock`` (created on first access). + """ + lock = self._validate_locks.get(target_registry_name) + if lock is None: + lock = asyncio.Lock() + self._validate_locks[target_registry_name] = lock + return lock def _get_target_class(self, *, target_type: str) -> type: """ @@ -241,6 +301,116 @@ def get_target_object(self, *, target_registry_name: str) -> Any | None: """ return self._registry.get_instance_by_name(target_registry_name) + async def validate_target_capabilities_async( + self, + *, + target_registry_name: str, + per_probe_timeout_s: float | None = None, + ) -> ValidateCapabilitiesResponse | None: + """ + Probe a target's live capabilities and return both declared and observed views. + + The probe writes test prompts to memory (existing behavior of the + discovery engine). Output modalities are not probed and fall through + to declared values. Probeable input modalities (text, image_path, + audio_path) listed in the target's declared capabilities are probed + explicitly so that rejections surface as drift; non-probeable + declared modalities (function_call, tool_call, reasoning, url, + video_path, binary_path, etc.) are reported as declared without + being probed and listed in ``non_probeable_input_modalities`` so + the frontend can render a single "Not probed (no asset)" row. + + Args: + target_registry_name: The registry key of the target to validate. + per_probe_timeout_s: Per-probe timeout in seconds. Defaults to + ``_GUI_VALIDATE_TIMEOUT_S`` (5.0) for interactive use. + + Returns: + ValidateCapabilitiesResponse, or None if the target is not in the registry. + """ + timeout_s = per_probe_timeout_s if per_probe_timeout_s is not None else self._GUI_VALIDATE_TIMEOUT_S + + target_obj = self.get_target_object(target_registry_name=target_registry_name) + if target_obj is None: + return None + + declared = target_capabilities_to_info(target_obj.capabilities) + + # CRITICAL: pass only the *probeable* declared modality combinations as + # ``test_modalities``. Without this filter, a combination like + # ``frozenset(["function_call"])`` raises ValueError inside + # ``_create_test_message`` (engine: discover_target_capabilities.py), + # the combo is silently skipped, and the result line + # ``queried | (declared - frozenset(test_modalities))`` drops it — + # producing a false red mismatch in the UI. The non-probeable combos + # are surfaced via ``non_probeable_input_modalities`` so the frontend + # can render them as "Not probed (no asset)" rather than mismatched. + declared_combinations: set[frozenset[PromptDataType]] = set(target_obj.capabilities.input_modalities) + probeable_combinations: set[frozenset[PromptDataType]] = { + combo for combo in declared_combinations if combo <= _PROBEABLE_INPUT_MODALITIES + } + non_probeable: set[frozenset[PromptDataType]] = declared_combinations - probeable_combinations + + # Per-target lock guards against the ``target._configuration`` race + # documented above. Helper is sync (see its docstring). Pass + # ``test_modalities=probeable_combinations`` even when empty: the engine + # short-circuits cleanly on empty set (logs "nothing to probe", returns + # empty) before entering ``_permissive_configuration``, avoiding an + # unnecessary configuration mutate+restore round-trip. Passing ``None`` + # would default the engine to all declared modalities and trigger + # ValueError-and-skip-log noise on every non-probeable combo. + lock = self._get_validate_lock(target_registry_name=target_registry_name) + async with lock: + observed_domain = await discover_target_capabilities_async( + target=target_obj, + per_probe_timeout_s=timeout_s, + test_modalities=probeable_combinations, + apply=False, + # retries left at the engine default (1) so cold-start targets + # don't false-negative; worst-case wait per probe is ~10 s. + ) + observed = target_capabilities_to_info(observed_domain) + + warnings = [ + ( + "Validation sent live requests to the target; this may incur cost " + "and produce real side effects (logs, billing, content policy hits)." + ), + "Test prompts written to memory are tagged with `capability_probe`.", + "Output modalities are reported as declared (not actively probed).", + ( + "Capability probes confirm request acceptance, not semantic enforcement " + "(e.g., a target that accepts a JSON-schema request may not actually " + "enforce the schema)." + ), + # The per-target lock above serializes Validate-vs-Validate but NOT + # Validate-vs-attack: the engine briefly mutates target._configuration, + # so an active attack on this target during validation may briefly + # observe permissive probe config. Surface as a user-visible warning. + ( + "Do not run Validate while an attack or scenario is actively using " + "this target — validation temporarily changes target configuration " + "during probing." + ), + ] + + # Format non-probeable combinations as a stable, '+'-joined sorted list + # so the frontend gets a typed, explicit signal (no warning-string parsing). + non_probeable_combos_pretty: list[str] = sorted("+".join(sorted(combo)) for combo in non_probeable) + if non_probeable_combos_pretty: + warnings.append( + "Some declared input modalities are reported as declared/not-probed " + f"(no packaged probe asset): {', '.join(non_probeable_combos_pretty)}." + ) + + return ValidateCapabilitiesResponse( + target_registry_name=target_registry_name, + declared=declared, + observed=observed, + non_probeable_input_modalities=non_probeable_combos_pretty, + warnings=warnings, + ) + async def create_target_async(self, *, request: CreateTargetRequest) -> TargetInstance: """ Create a new target instance from API request. From e9d74621f0ee07ceb30583389fc9bef714f3f1b8 Mon Sep 17 00:00:00 2001 From: Varun Joginpalli Date: Wed, 10 Jun 2026 21:28:24 +0000 Subject: [PATCH 04/17] add POST /targets/{name}/validate route --- pyrit/backend/routes/targets.py | 48 +++++++++++++++++++++++++++++++++ 1 file changed, 48 insertions(+) diff --git a/pyrit/backend/routes/targets.py b/pyrit/backend/routes/targets.py index bea53ddef2..23760f4639 100644 --- a/pyrit/backend/routes/targets.py +++ b/pyrit/backend/routes/targets.py @@ -15,6 +15,7 @@ CreateTargetRequest, TargetInstance, TargetListResponse, + ValidateCapabilitiesResponse, ) from pyrit.backend.services.target_service import get_target_service @@ -104,3 +105,50 @@ async def get_target(target_registry_name: str) -> TargetInstance: # pyrit-asyn ) return target + + +@router.post( + "/{target_registry_name}/validate", + response_model=ValidateCapabilitiesResponse, + responses={ + 404: {"model": ProblemDetail, "description": "Target not found"}, + 500: {"model": ProblemDetail, "description": "Validation failed"}, + }, +) +async def validate_target_capabilities( # pyrit-async-suffix-exempt + target_registry_name: str, +) -> ValidateCapabilitiesResponse: + """ + Validate a target by probing its live capabilities against declarations. + + The probe sends a small set of test requests to the target and reports + the declared vs observed capability flags and input modalities. Output + modalities are reported as declared (not actively probed). Test prompts + are written to memory. + + Returns: + ValidateCapabilitiesResponse: Declared and observed capabilities, plus + a list of declared input-modality combinations that could not be + probed because no test asset is packaged for them, plus operational + warnings (live-call cost, memory tagging, semantic-enforcement caveat, + validate-vs-active-attack caveat). + """ + service = get_target_service() + + try: + result = await service.validate_target_capabilities_async( + target_registry_name=target_registry_name, + ) + except Exception as e: + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail=f"Failed to validate target: {str(e)}", + ) from e + + if result is None: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail=f"Target '{target_registry_name}' not found", + ) + + return result From daf22915451512971b03d6cc223067fbf8cc23d2 Mon Sep 17 00:00:00 2001 From: Varun Joginpalli Date: Wed, 10 Jun 2026 21:29:55 +0000 Subject: [PATCH 05/17] add backend tests for target capability validation --- tests/unit/backend/test_api_routes.py | 59 ++++ tests/unit/backend/test_target_service.py | 407 ++++++++++++++++++++++ 2 files changed, 466 insertions(+) diff --git a/tests/unit/backend/test_api_routes.py b/tests/unit/backend/test_api_routes.py index 59bf407382..7819d295b9 100644 --- a/tests/unit/backend/test_api_routes.py +++ b/tests/unit/backend/test_api_routes.py @@ -38,6 +38,7 @@ TargetCapabilitiesInfo, TargetInstance, TargetListResponse, + ValidateCapabilitiesResponse, ) from pyrit.backend.routes.labels import get_label_options @@ -954,6 +955,64 @@ def test_get_target_includes_target_specific_params(self, client: TestClient) -> assert data["target_specific_params"]["presence_penalty"] == 0.3 assert data["target_specific_params"]["seed"] == 42 + def test_validate_target_returns_200_with_declared_and_observed(self, client: TestClient) -> None: + """Happy path: validate route returns 200 with full ValidateCapabilitiesResponse shape.""" + with patch("pyrit.backend.routes.targets.get_target_service") as mock_get_service: + mock_service = MagicMock() + mock_service.validate_target_capabilities_async = AsyncMock( + return_value=ValidateCapabilitiesResponse( + target_registry_name="target-1", + declared=TargetCapabilitiesInfo( + supports_json_schema=True, + supported_input_modalities=["image_path", "text"], + ), + observed=TargetCapabilitiesInfo( + supports_json_schema=False, + supported_input_modalities=["text"], + ), + non_probeable_input_modalities=["function_call"], + warnings=["Validation sent live requests to the target; ..."], + ) + ) + mock_get_service.return_value = mock_service + + response = client.post("/api/targets/target-1/validate") + + assert response.status_code == status.HTTP_200_OK + data = response.json() + assert data["target_registry_name"] == "target-1" + assert data["declared"]["supports_json_schema"] is True + assert data["observed"]["supports_json_schema"] is False + assert data["non_probeable_input_modalities"] == ["function_call"] + assert isinstance(data["warnings"], list) and data["warnings"] + + def test_validate_target_returns_404_when_target_missing(self, client: TestClient) -> None: + """Unknown target → service returns None → 404 with FastAPI's default {'detail': ...} body.""" + with patch("pyrit.backend.routes.targets.get_target_service") as mock_get_service: + mock_service = MagicMock() + mock_service.validate_target_capabilities_async = AsyncMock(return_value=None) + mock_get_service.return_value = mock_service + + response = client.post("/api/targets/missing/validate") + + assert response.status_code == status.HTTP_404_NOT_FOUND + # Backend has no HTTPException → ProblemDetail handler; default shape applies. + assert response.json() == {"detail": "Target 'missing' not found"} + + def test_validate_target_returns_500_when_probe_fails(self, client: TestClient) -> None: + """Engine raises → 500 with default {'detail': 'Failed to validate target: ...'} body.""" + with patch("pyrit.backend.routes.targets.get_target_service") as mock_get_service: + mock_service = MagicMock() + mock_service.validate_target_capabilities_async = AsyncMock(side_effect=RuntimeError("network blew up")) + mock_get_service.return_value = mock_service + + response = client.post("/api/targets/target-1/validate") + + assert response.status_code == status.HTTP_500_INTERNAL_SERVER_ERROR + body = response.json() + assert "Failed to validate target" in body["detail"] + assert "network blew up" in body["detail"] + # ============================================================================ # Converter Routes Tests diff --git a/tests/unit/backend/test_target_service.py b/tests/unit/backend/test_target_service.py index b86467eb88..ae00b30277 100644 --- a/tests/unit/backend/test_target_service.py +++ b/tests/unit/backend/test_target_service.py @@ -823,3 +823,410 @@ def test_target_eval_param_fallbacks_match_frontend(self) -> None: f"Update effectiveUnderlyingModel() in CreateTargetDialog.tsx to match, " f"then update this test's expected dict." ) + + +# ============================================================================ +# Capability Validation Tests +# ============================================================================ + + +def _fake_target_with_capabilities( + *, + input_modalities: frozenset[frozenset[str]] | None = None, + supports_json_schema: bool = True, +) -> MagicMock: + """ + Build a MagicMock target whose ``capabilities`` attribute is a real + ``TargetCapabilities`` object. The discovery engine is mocked separately, + so we don't need a real PromptTarget subclass — just the ``.capabilities`` + attribute that ``validate_target_capabilities_async`` reads. + """ + from pyrit.prompt_target.common.target_capabilities import TargetCapabilities + + caps = TargetCapabilities( + supports_multi_turn=True, + supports_multi_message_pieces=True, + supports_json_schema=supports_json_schema, + supports_json_output=True, + supports_editable_history=True, + supports_system_prompt=True, + input_modalities=(input_modalities if input_modalities is not None else frozenset({frozenset(["text"])})), + ) + target = MagicMock() + target.capabilities = caps + return target + + +def _fake_observed_capabilities( + *, + declared, # TargetCapabilities + drop_input_modalities: set[frozenset[str]] | None = None, + flip_json_schema_to_false: bool = False, +): + """ + Build a fake "observed" TargetCapabilities for the mock engine to return. + + Mirrors what the real engine produces: starts from ``declared`` and + selectively drops/flips fields to simulate drift. + """ + from pyrit.prompt_target.common.target_capabilities import TargetCapabilities + + observed_input = declared.input_modalities + if drop_input_modalities: + observed_input = frozenset(c for c in observed_input if c not in drop_input_modalities) + return TargetCapabilities( + supports_multi_turn=declared.supports_multi_turn, + supports_multi_message_pieces=declared.supports_multi_message_pieces, + supports_json_schema=False if flip_json_schema_to_false else declared.supports_json_schema, + supports_json_output=declared.supports_json_output, + supports_editable_history=declared.supports_editable_history, + supports_system_prompt=declared.supports_system_prompt, + input_modalities=observed_input, + output_modalities=declared.output_modalities, + ) + + +class TestValidateTargetCapabilities: + """Tests for TargetService.validate_target_capabilities_async.""" + + async def test_returns_none_for_unknown_target(self) -> None: + """Unknown registry name returns None; engine is NOT called.""" + from unittest.mock import AsyncMock + + service = TargetService() + with patch( + "pyrit.backend.services.target_service.discover_target_capabilities_async", + new_callable=AsyncMock, + ) as mock_probe: + result = await service.validate_target_capabilities_async(target_registry_name="missing") + assert result is None + mock_probe.assert_not_called() + + async def test_returns_response_for_known_target(self) -> None: + """Happy path: declared + observed populated, warnings present, no non-probeable.""" + from unittest.mock import AsyncMock + + service = TargetService() + fake_target = _fake_target_with_capabilities() + observed = _fake_observed_capabilities(declared=fake_target.capabilities) + with ( + patch.object(service, "get_target_object", return_value=fake_target), + patch( + "pyrit.backend.services.target_service.discover_target_capabilities_async", + new_callable=AsyncMock, + ) as mock_probe, + ): + mock_probe.return_value = observed + result = await service.validate_target_capabilities_async(target_registry_name="t1") + + assert result is not None + assert result.target_registry_name == "t1" + assert result.declared.supports_json_schema is True + assert result.observed.supports_json_schema is True + assert result.non_probeable_input_modalities == [] + # 5 base warnings, no 6th (no non-probeable) + assert len(result.warnings) == 5 + + async def test_passes_timeout_override(self) -> None: + """Caller-supplied per_probe_timeout_s reaches the discovery call.""" + from unittest.mock import AsyncMock + + service = TargetService() + fake_target = _fake_target_with_capabilities() + observed = _fake_observed_capabilities(declared=fake_target.capabilities) + with ( + patch.object(service, "get_target_object", return_value=fake_target), + patch( + "pyrit.backend.services.target_service.discover_target_capabilities_async", + new_callable=AsyncMock, + ) as mock_probe, + ): + mock_probe.return_value = observed + await service.validate_target_capabilities_async(target_registry_name="t1", per_probe_timeout_s=10.0) + assert mock_probe.call_args.kwargs["per_probe_timeout_s"] == 10.0 + + async def test_uses_gui_default_timeout_when_not_overridden(self) -> None: + """When per_probe_timeout_s is None, the GUI default (5.0) is passed.""" + from unittest.mock import AsyncMock + + service = TargetService() + fake_target = _fake_target_with_capabilities() + observed = _fake_observed_capabilities(declared=fake_target.capabilities) + with ( + patch.object(service, "get_target_object", return_value=fake_target), + patch( + "pyrit.backend.services.target_service.discover_target_capabilities_async", + new_callable=AsyncMock, + ) as mock_probe, + ): + mock_probe.return_value = observed + await service.validate_target_capabilities_async(target_registry_name="t1") + assert mock_probe.call_args.kwargs["per_probe_timeout_s"] == TargetService._GUI_VALIDATE_TIMEOUT_S + assert mock_probe.call_args.kwargs["per_probe_timeout_s"] == 5.0 + + async def test_passes_probeable_modalities_only(self) -> None: + """ + CRITICAL regression guard: only probeable modality combinations + reach the engine. Non-probeable combos appear in the response's + ``non_probeable_input_modalities`` list and in a warning. + """ + from unittest.mock import AsyncMock + + service = TargetService() + fake_target = _fake_target_with_capabilities( + input_modalities=frozenset( + { + frozenset(["text"]), + frozenset(["text", "image_path"]), + frozenset(["function_call"]), + frozenset(["url"]), + } + ) + ) + observed = _fake_observed_capabilities(declared=fake_target.capabilities) + with ( + patch.object(service, "get_target_object", return_value=fake_target), + patch( + "pyrit.backend.services.target_service.discover_target_capabilities_async", + new_callable=AsyncMock, + ) as mock_probe, + ): + mock_probe.return_value = observed + result = await service.validate_target_capabilities_async(target_registry_name="t1") + + # (a) only the two probeable combos reach the engine + passed = mock_probe.call_args.kwargs["test_modalities"] + assert passed == {frozenset(["text"]), frozenset(["text", "image_path"])} + + # (b) non-probeable types appear in the warnings list + assert result is not None + non_probed_warning = [w for w in result.warnings if "no packaged probe asset" in w] + assert len(non_probed_warning) == 1 + assert "function_call" in non_probed_warning[0] + assert "url" in non_probed_warning[0] + + # (c) typed field has the sorted, '+'-joined list + assert result.non_probeable_input_modalities == ["function_call", "url"] + + async def test_passes_empty_set_when_no_probeable_modalities(self) -> None: + """ + Declared modalities are all non-probeable. Method passes + ``test_modalities=set()`` (NOT None) so the engine short-circuits + cleanly without entering ``_permissive_configuration``. Warnings + still include the not-probed entry, and the typed field lists every + declared combo. + """ + from unittest.mock import AsyncMock + + service = TargetService() + fake_target = _fake_target_with_capabilities( + input_modalities=frozenset( + { + frozenset(["function_call"]), + frozenset(["url"]), + frozenset(["video_path"]), + } + ) + ) + observed = _fake_observed_capabilities(declared=fake_target.capabilities) + with ( + patch.object(service, "get_target_object", return_value=fake_target), + patch( + "pyrit.backend.services.target_service.discover_target_capabilities_async", + new_callable=AsyncMock, + ) as mock_probe, + ): + mock_probe.return_value = observed + result = await service.validate_target_capabilities_async(target_registry_name="t1") + + passed = mock_probe.call_args.kwargs["test_modalities"] + assert passed == set() + assert isinstance(passed, set) + assert result is not None + assert result.non_probeable_input_modalities == ["function_call", "url", "video_path"] + assert any("no packaged probe asset" in w for w in result.warnings) + + async def test_propagates_probe_exceptions(self) -> None: + """Engine raises → method raises. Lock is released even on exception.""" + from unittest.mock import AsyncMock + + service = TargetService() + fake_target = _fake_target_with_capabilities() + with ( + patch.object(service, "get_target_object", return_value=fake_target), + patch( + "pyrit.backend.services.target_service.discover_target_capabilities_async", + new_callable=AsyncMock, + side_effect=RuntimeError("engine boom"), + ), + ): + with pytest.raises(RuntimeError, match="engine boom"): + await service.validate_target_capabilities_async(target_registry_name="t1") + + # Lock must be released (the dict entry stays, but the lock isn't held). + lock = service._validate_locks["t1"] + assert not lock.locked(), "lock leaked after engine raised" + + async def test_serializes_concurrent_calls_on_same_target(self) -> None: + """ + Two concurrent calls on the same registry name within the same service + instance + same event loop serialize via the per-target lock. + """ + import asyncio + + service = TargetService() + fake_target = _fake_target_with_capabilities() + observed = _fake_observed_capabilities(declared=fake_target.capabilities) + + first_running = asyncio.Event() + release_first = asyncio.Event() + order: list[str] = [] + + async def slow_first(**_kwargs): + order.append("first-enter") + first_running.set() + await release_first.wait() + order.append("first-exit") + return observed + + async def fast_second(**_kwargs): + order.append("second-enter") + order.append("second-exit") + return observed + + call_count = {"n": 0} + + async def dispatch(**kwargs): + call_count["n"] += 1 + return await (slow_first(**kwargs) if call_count["n"] == 1 else fast_second(**kwargs)) + + with ( + patch.object(service, "get_target_object", return_value=fake_target), + patch( + "pyrit.backend.services.target_service.discover_target_capabilities_async", + new=dispatch, + ), + ): + task_first = asyncio.create_task(service.validate_target_capabilities_async(target_registry_name="t1")) + await first_running.wait() + task_second = asyncio.create_task(service.validate_target_capabilities_async(target_registry_name="t1")) + # Give scheduler a tick — second must NOT have started. + await asyncio.sleep(0.05) + assert "second-enter" not in order, f"second leaked through: {order}" + release_first.set() + await asyncio.gather(task_first, task_second) + + assert order == ["first-enter", "first-exit", "second-enter", "second-exit"] + + async def test_allows_concurrent_calls_on_different_targets(self) -> None: + """Two concurrent calls on different targets do NOT serialize.""" + import asyncio + + service = TargetService() + fake_a = _fake_target_with_capabilities() + fake_b = _fake_target_with_capabilities() + observed_a = _fake_observed_capabilities(declared=fake_a.capabilities) + observed_b = _fake_observed_capabilities(declared=fake_b.capabilities) + + a_running = asyncio.Event() + b_started = asyncio.Event() + + async def dispatch_a(**_kwargs): + a_running.set() + await b_started.wait() # must NOT block on B if locks are per-target + return observed_a + + async def dispatch_b(**_kwargs): + b_started.set() + return observed_b + + def get_target(*, target_registry_name: str): + return fake_a if target_registry_name == "a" else fake_b + + # Per-target dispatch via call_args inspection + async def probe(*, target, **kwargs): + if target is fake_a: + return await dispatch_a(**kwargs) + return await dispatch_b(**kwargs) + + with ( + patch.object(service, "get_target_object", side_effect=get_target), + patch( + "pyrit.backend.services.target_service.discover_target_capabilities_async", + new=probe, + ), + ): + task_a = asyncio.create_task(service.validate_target_capabilities_async(target_registry_name="a")) + await a_running.wait() + task_b = asyncio.create_task(service.validate_target_capabilities_async(target_registry_name="b")) + # If locks were shared, task_a would deadlock waiting on b_started. + result_a, result_b = await asyncio.wait_for(asyncio.gather(task_a, task_b), timeout=2.0) + assert result_a is not None and result_b is not None + + async def test_creates_fresh_lock_per_service_instance(self) -> None: + """ + Two TargetService() instances have independent _validate_locks dicts. + Guards the R5 instance-attribute fix against accidental re-promotion + to ClassVar (which would leak locks across pytest event loops). + """ + from unittest.mock import AsyncMock + + service_a = TargetService() + service_b = TargetService() + fake_target_a = _fake_target_with_capabilities() + fake_target_b = _fake_target_with_capabilities() + observed_a = _fake_observed_capabilities(declared=fake_target_a.capabilities) + observed_b = _fake_observed_capabilities(declared=fake_target_b.capabilities) + + # Trigger lock creation in both services for the same registry name + with ( + patch.object(service_a, "get_target_object", return_value=fake_target_a), + patch( + "pyrit.backend.services.target_service.discover_target_capabilities_async", + new_callable=AsyncMock, + return_value=observed_a, + ), + ): + await service_a.validate_target_capabilities_async(target_registry_name="shared") + + with ( + patch.object(service_b, "get_target_object", return_value=fake_target_b), + patch( + "pyrit.backend.services.target_service.discover_target_capabilities_async", + new_callable=AsyncMock, + return_value=observed_b, + ), + ): + await service_b.validate_target_capabilities_async(target_registry_name="shared") + + assert "shared" in service_a._validate_locks + assert "shared" in service_b._validate_locks + # Different lock objects per service instance. + assert service_a._validate_locks["shared"] is not service_b._validate_locks["shared"] + + async def test_includes_expected_warnings(self) -> None: + """All five base warnings are present, in the documented order.""" + from unittest.mock import AsyncMock + + service = TargetService() + fake_target = _fake_target_with_capabilities() + observed = _fake_observed_capabilities(declared=fake_target.capabilities) + with ( + patch.object(service, "get_target_object", return_value=fake_target), + patch( + "pyrit.backend.services.target_service.discover_target_capabilities_async", + new_callable=AsyncMock, + return_value=observed, + ), + ): + result = await service.validate_target_capabilities_async(target_registry_name="t1") + + assert result is not None + # Five base warnings (no 6th because no non-probeable modalities). + assert len(result.warnings) == 5 + joined = " | ".join(result.warnings) + assert "live requests" in joined # cost/side-effects + assert "capability_probe" in joined # memory tagging + assert "Output modalities are reported as declared" in joined + assert "semantic enforcement" in joined # request-vs-enforcement caveat + assert "Do not run Validate while an attack" in joined # validate-vs-attack From ad877f28337fff0a9b58d6b11262ca1f8ae8d036 Mon Sep 17 00:00:00 2001 From: Varun Joginpalli Date: Wed, 10 Jun 2026 21:30:34 +0000 Subject: [PATCH 06/17] add ValidateCapabilitiesDialog frontend component --- .../Config/ValidateCapabilitiesDialog.tsx | 281 ++++++++++++++++++ frontend/src/services/api.ts | 13 + frontend/src/types/index.ts | 19 ++ 3 files changed, 313 insertions(+) create mode 100644 frontend/src/components/Config/ValidateCapabilitiesDialog.tsx diff --git a/frontend/src/components/Config/ValidateCapabilitiesDialog.tsx b/frontend/src/components/Config/ValidateCapabilitiesDialog.tsx new file mode 100644 index 0000000000..75837a06c5 --- /dev/null +++ b/frontend/src/components/Config/ValidateCapabilitiesDialog.tsx @@ -0,0 +1,281 @@ +import { useCallback, useEffect, useState } from 'react' +import { + Dialog, + DialogSurface, + DialogTitle, + DialogBody, + DialogContent, + DialogActions, + Button, + Spinner, + MessageBar, + MessageBarBody, + Table, + TableHeader, + TableRow, + TableHeaderCell, + TableBody, + TableCell, + Text, + tokens, +} from '@fluentui/react-components' +import { + CheckmarkCircleFilled, + DismissCircleFilled, + WarningRegular, +} from '@fluentui/react-icons' +import { targetsApi } from '@/services/api' +import { toApiError } from '@/services/errors' +import type { + TargetInstance, + ValidateCapabilitiesResponse, +} from '../../types' + +interface ValidateCapabilitiesDialogProps { + open: boolean + target: TargetInstance | null + onClose: () => void +} + +// Boolean capability rows. Narrowed to bool-typed fields so the per-row render +// never has to deal with the string[] modality fields. +type BooleanCapabilityKey = + | 'supports_multi_turn' + | 'supports_multi_message_pieces' + | 'supports_json_schema' + | 'supports_json_output' + | 'supports_editable_history' + | 'supports_system_prompt' + +const CAPABILITY_ROWS: Array<{ key: BooleanCapabilityKey; label: string }> = [ + { key: 'supports_multi_turn', label: 'Multi-turn' }, + { key: 'supports_multi_message_pieces', label: 'Multi-message pieces' }, + { key: 'supports_json_schema', label: 'JSON Schema' }, + { key: 'supports_json_output', label: 'JSON Output' }, + { key: 'supports_editable_history', label: 'Editable history' }, + { key: 'supports_system_prompt', label: 'System prompt' }, +] + +/** Compare two flattened modality lists ignoring order. */ +function modalitiesEqual(a: string[] | null | undefined, b: string[] | null | undefined): boolean { + const sa = [...(a ?? [])].sort() + const sb = [...(b ?? [])].sort() + return JSON.stringify(sa) === JSON.stringify(sb) +} + +/** Render a green check, red X, or amber em-dash for one row. */ +function MatchIndicator({ kind }: { kind: 'match' | 'mismatch' | 'not-probed' }) { + if (kind === 'match') { + return ( + + + match + + ) + } + if (kind === 'mismatch') { + return ( + + + mismatch + + ) + } + return ( + + + not probed + + ) +} + +/** Format a modality list for display. */ +function formatModalities(list: string[] | null | undefined): string { + if (!list || list.length === 0) return '—' + return list.join(', ') +} + +export default function ValidateCapabilitiesDialog({ + open, + target, + onClose, +}: ValidateCapabilitiesDialogProps) { + const [loading, setLoading] = useState(false) + const [error, setError] = useState(null) + const [result, setResult] = useState(null) + + // Reset state on close (R5): the useEffect below depends on + // `target?.target_registry_name`, so re-clicking Validate on the SAME row + // would not re-fire without an explicit state reset — and the user would see + // stale results with no spinner. + const handleClose = useCallback(() => { + setResult(null) + setError(null) + setLoading(false) + onClose() + }, [onClose]) + + // Cancellation flag skips React state updates if the dialog closes/reopens + // for a different target while the request is still in flight. Note: this + // does NOT cancel the backend request — the per-target backend lock + // prevents the worst symptom (concurrent races); proper request + // cancellation via AbortController is captured as a follow-up. + useEffect(() => { + if (!open || !target) return + + let cancelled = false + setLoading(true) + setError(null) + setResult(null) + + targetsApi + .validateCapabilities(target.target_registry_name) + .then((data) => { + if (!cancelled) setResult(data) + }) + .catch((err) => { + if (!cancelled) setError(toApiError(err).detail) + }) + .finally(() => { + if (!cancelled) setLoading(false) + }) + + return () => { + cancelled = true + } + // eslint-disable-next-line react-hooks/exhaustive-deps -- intentional: only re-fire when the target's registry name actually changes, not on every parent re-render with a new TargetInstance object reference + }, [open, target?.target_registry_name]) + + if (!target) return null + + const declared = result?.declared + const observed = result?.observed + const inputMatch = modalitiesEqual( + declared?.supported_input_modalities, + observed?.supported_input_modalities, + ) + + return ( + { + if (!data.open) handleClose() + }} + > + + + Validate capabilities: {target.target_registry_name} + + {loading && ( +
+ + + Sending live test requests; results may take a few seconds. + +
+ )} + {error && !loading && ( + + {error} + + )} + {result && !loading && !error && ( + <> + {(target.inner_targets ?? []).length > 0 && ( + + + This is a composite target. Validation tests aggregate routing behavior, not each + inner endpoint independently. + + + )} + + + + Capability + Declared + Observed + Match + + + + {CAPABILITY_ROWS.map(({ key, label }) => { + const dval = declared ? declared[key] : false + const oval = observed ? observed[key] : false + const kind: 'match' | 'mismatch' = dval === oval ? 'match' : 'mismatch' + return ( + + {label} + {dval ? 'yes' : 'no'} + {oval ? 'yes' : 'no'} + + + + + ) + })} + + Input modalities + {formatModalities(declared?.supported_input_modalities)} + {formatModalities(observed?.supported_input_modalities)} + + + + + {result.non_probeable_input_modalities.length > 0 && ( + + + + Not probed (no asset) + + + + + {result.non_probeable_input_modalities.join(', ')} + + + + + + + )} + + Output modalities + {formatModalities(declared?.supported_output_modalities)} + {formatModalities(observed?.supported_output_modalities)} + + + + + +
+ {result.warnings.length > 0 && ( +
+ {result.warnings.map((w, idx) => ( + }> + {w} + + ))} +
+ )} + + )} +
+ + + +
+
+
+ ) +} diff --git a/frontend/src/services/api.ts b/frontend/src/services/api.ts index 3c04828cb0..320a9a2a97 100644 --- a/frontend/src/services/api.ts +++ b/frontend/src/services/api.ts @@ -20,6 +20,7 @@ import type { CreateConversationRequest, CreateConversationResponse, ChangeMainConversationResponse, + ValidateCapabilitiesResponse, } from '../types' const API_BASE_URL = import.meta.env.VITE_API_URL || '/api' @@ -162,6 +163,18 @@ export const targetsApi = { const response = await apiClient.post('/targets', request) return response.data }, + + validateCapabilities: async ( + targetRegistryName: string, + ): Promise => { + // POST is appropriate here: the call sends live requests to the target + // and writes probe rows to memory (side effects), even though the response + // shape is a read-only diff. + const response = await apiClient.post( + `/targets/${encodeURIComponent(targetRegistryName)}/validate`, + ) + return response.data + }, } export const convertersApi = { diff --git a/frontend/src/types/index.ts b/frontend/src/types/index.ts index 1c6dcc283e..fcf6f8bd55 100644 --- a/frontend/src/types/index.ts +++ b/frontend/src/types/index.ts @@ -98,6 +98,25 @@ export interface CreateTargetRequest { auth_mode?: 'api_key' | 'entra' } +export interface ValidateCapabilitiesResponse { + target_registry_name: string + declared: TargetCapabilitiesInfo + observed: TargetCapabilitiesInfo + /** + * Sorted '+'-joined declared input-modality combinations that the engine + * could not probe because no packaged test asset exists (e.g., + * 'function_call', 'image_path+url'). Used by ValidateCapabilitiesDialog to + * render a single "Not probed (no asset)" row beneath the input-modalities + * row, distinguishing "not probed" from "probed and confirmed". + */ + non_probeable_input_modalities: string[] + /** + * Operational notes for the user (live-call cost, memory tagging, output + * modalities not probed, semantic-enforcement caveat, validate-vs-active-attack). + */ + warnings: string[] +} + // --- Converters --- export interface ConverterInstance { From 557960715155efd2ff002049f60f27317d3cf1f7 Mon Sep 17 00:00:00 2001 From: Varun Joginpalli Date: Wed, 10 Jun 2026 21:31:08 +0000 Subject: [PATCH 07/17] wire Validate button into TargetTable --- .../src/components/Config/TargetTable.tsx | 51 +++++++++++++++---- 1 file changed, 41 insertions(+), 10 deletions(-) diff --git a/frontend/src/components/Config/TargetTable.tsx b/frontend/src/components/Config/TargetTable.tsx index 79d1df3777..b5679505ba 100644 --- a/frontend/src/components/Config/TargetTable.tsx +++ b/frontend/src/components/Config/TargetTable.tsx @@ -31,6 +31,7 @@ import { } from '@fluentui/react-icons' import type { TargetInstance } from '../../types' import { useTargetTableStyles } from './TargetTable.styles' +import ValidateCapabilitiesDialog from './ValidateCapabilitiesDialog' interface TargetTableProps { targets: TargetInstance[] @@ -244,6 +245,10 @@ export default function TargetTable({ targets, activeTarget, onSetActiveTarget } // We use a Set of target_registry_name strings — when a name is in the set, // that row's sub-rows are visible. const [expandedRows, setExpandedRows] = useState>(new Set()) + // The target whose Validate dialog is currently open, or null. + // Inner-target rows (composite expansion) do NOT get a Validate button — + // they aren't registered by name in the backend TargetRegistry. + const [validateTarget, setValidateTarget] = useState(null) const toggleExpanded = (registryName: string) => { setExpandedRows((prev) => { @@ -280,7 +285,17 @@ export default function TargetTable({ targets, activeTarget, onSetActiveTarget } - }>Active +
+ }>Active + +
@@ -401,19 +416,29 @@ export default function TargetTable({ targets, activeTarget, onSetActiveTarget } className={isActive(target) ? styles.activeRow : undefined} > - {isActive(target) ? ( - }> - Active - - ) : ( +
+ {isActive(target) ? ( + }> + Active + + ) : ( + + )} - )} +
@@ -465,6 +490,12 @@ export default function TargetTable({ targets, activeTarget, onSetActiveTarget } })} + + setValidateTarget(null)} + />
) } From ff7f0960a7ece1fe62fb70ae1bc8ce77e4fa5ef0 Mon Sep 17 00:00:00 2001 From: Varun Joginpalli Date: Wed, 10 Jun 2026 21:31:43 +0000 Subject: [PATCH 08/17] add ValidateCapabilitiesDialog and TargetTable tests --- .../components/Config/TargetTable.test.tsx | 104 +++++- .../ValidateCapabilitiesDialog.test.tsx | 299 ++++++++++++++++++ 2 files changed, 402 insertions(+), 1 deletion(-) create mode 100644 frontend/src/components/Config/ValidateCapabilitiesDialog.test.tsx diff --git a/frontend/src/components/Config/TargetTable.test.tsx b/frontend/src/components/Config/TargetTable.test.tsx index 17408d6d3c..2dfe8adac5 100644 --- a/frontend/src/components/Config/TargetTable.test.tsx +++ b/frontend/src/components/Config/TargetTable.test.tsx @@ -1,12 +1,21 @@ -import { render, screen, fireEvent } from '@testing-library/react' +import { render, screen, fireEvent, waitFor } from '@testing-library/react' import { FluentProvider, webLightTheme } from '@fluentui/react-components' import TargetTable from './TargetTable' import type { TargetInstance } from '../../types' +import { targetsApi } from '@/services/api' jest.mock('./TargetTable.styles', () => ({ useTargetTableStyles: () => new Proxy({}, { get: () => '' }), })) +jest.mock('@/services/api', () => ({ + targetsApi: { + validateCapabilities: jest.fn(), + }, +})) + +const mockedApi = targetsApi as jest.Mocked + const TestWrapper: React.FC<{ children: React.ReactNode }> = ({ children }) => ( {children} ) @@ -397,4 +406,97 @@ describe('TargetTable', () => { expect(screen.queryByLabelText('Expand inner targets')).not.toBeInTheDocument() }) + + // --- F5: Validate button wiring --- + + it('renders a Validate button on every top-level row', () => { + render( + + + , + ) + const validateButtons = screen.getAllByRole('button', { name: /^Validate$/ }) + // 3 sample targets, 1 button each (no active target → no extra active-row button) + expect(validateButtons).toHaveLength(3) + }) + + it('also renders a Validate button on the active-target summary row', () => { + render( + + + , + ) + const validateButtons = screen.getAllByRole('button', { name: /^Validate$/ }) + // 3 list rows + 1 active-row summary = 4 + expect(validateButtons).toHaveLength(4) + }) + + it('does NOT render Validate buttons on inner-target rows (composite expansion)', () => { + const rrTarget: TargetInstance = { + target_registry_name: 'rr_gpt4o', + target_type: 'RoundRobinTarget', + model_name: 'gpt-4o', + target_specific_params: { weights: [1, 1] }, + inner_targets: [ + { + target_registry_name: 'inner_a', + target_type: 'OpenAIChatTarget', + endpoint: 'https://a.openai.azure.com', + model_name: 'gpt-4o', + }, + { + target_registry_name: 'inner_b', + target_type: 'OpenAIChatTarget', + endpoint: 'https://b.openai.azure.com', + model_name: 'gpt-4o', + }, + ], + } + render( + + + , + ) + // Before expanding: 1 top-level row → 1 Validate button + expect(screen.getAllByRole('button', { name: /^Validate$/ })).toHaveLength(1) + // Expand + fireEvent.click(screen.getByLabelText('Expand inner targets')) + expect(screen.getByText('https://a.openai.azure.com')).toBeInTheDocument() + // After expanding: still only 1 Validate button (inner rows don't get one) + expect(screen.getAllByRole('button', { name: /^Validate$/ })).toHaveLength(1) + }) + + it('opens the validation dialog when a Validate button is clicked', async () => { + // Pending promise so the dialog stays in the loading state we can detect. + mockedApi.validateCapabilities.mockReturnValue(new Promise(() => {})) + render( + + + , + ) + const validateButtons = screen.getAllByRole('button', { name: /^Validate$/ }) + fireEvent.click(validateButtons[0]) + await waitFor(() => { + expect(mockedApi.validateCapabilities).toHaveBeenCalledWith('openai_chat_gpt4') + }) + expect(screen.getByText(/Validate capabilities: openai_chat_gpt4/i)).toBeInTheDocument() + }) + + it('disables the Validate button for the row whose dialog is currently open', async () => { + mockedApi.validateCapabilities.mockReturnValue(new Promise(() => {})) + render( + + + , + ) + const validateButtons = screen.getAllByRole('button', { name: /^Validate$/ }) + fireEvent.click(validateButtons[0]) + await waitFor(() => { + // The first row's Validate button is now disabled. + const stillButtons = screen.getAllByRole('button', { name: /^Validate$/ }) + expect(stillButtons[0]).toBeDisabled() + // The other rows' buttons remain enabled. + expect(stillButtons[1]).not.toBeDisabled() + }) + }) }) diff --git a/frontend/src/components/Config/ValidateCapabilitiesDialog.test.tsx b/frontend/src/components/Config/ValidateCapabilitiesDialog.test.tsx new file mode 100644 index 0000000000..5777b938b4 --- /dev/null +++ b/frontend/src/components/Config/ValidateCapabilitiesDialog.test.tsx @@ -0,0 +1,299 @@ +import { render, screen, waitFor } from '@testing-library/react' +import userEvent from '@testing-library/user-event' +import { FluentProvider, webLightTheme } from '@fluentui/react-components' +import ValidateCapabilitiesDialog from './ValidateCapabilitiesDialog' +import { targetsApi } from '@/services/api' +import type { TargetInstance, ValidateCapabilitiesResponse } from '@/types' + +jest.mock('@/services/api', () => ({ + targetsApi: { + validateCapabilities: jest.fn(), + }, +})) + +const mockedApi = targetsApi as jest.Mocked + +const TestWrapper: React.FC<{ children: React.ReactNode }> = ({ children }) => ( + {children} +) + +const sampleTarget: TargetInstance = { + target_registry_name: 'azure_chat_test', + target_type: 'OpenAIChatTarget', + endpoint: 'https://example.openai.azure.com', + model_name: 'gpt-4', + capabilities: { + supports_multi_turn: true, + supports_multi_message_pieces: true, + supports_json_schema: true, + supports_json_output: true, + supports_editable_history: true, + supports_system_prompt: true, + supported_input_modalities: ['text'], + supported_output_modalities: ['text'], + }, +} + +const allMatchResponse: ValidateCapabilitiesResponse = { + target_registry_name: 'azure_chat_test', + declared: sampleTarget.capabilities!, + observed: sampleTarget.capabilities!, + non_probeable_input_modalities: [], + warnings: [ + 'Validation sent live requests to the target; this may incur cost and produce real side effects.', + 'Test prompts written to memory are tagged with `capability_probe`.', + 'Output modalities are reported as declared (not actively probed).', + 'Capability probes confirm request acceptance, not semantic enforcement.', + 'Do not run Validate while an attack or scenario is actively using this target.', + ], +} + +const mismatchResponse: ValidateCapabilitiesResponse = { + target_registry_name: 'azure_chat_test', + declared: { + ...sampleTarget.capabilities!, + supports_json_schema: true, + supported_input_modalities: ['image_path', 'text'], + }, + observed: { + ...sampleTarget.capabilities!, + supports_json_schema: false, + supported_input_modalities: ['text'], + }, + non_probeable_input_modalities: [], + warnings: ['Validation sent live requests to the target; ...'], +} + +const notProbedResponse: ValidateCapabilitiesResponse = { + target_registry_name: 'openai_response_test', + declared: { + ...sampleTarget.capabilities!, + supported_input_modalities: ['function_call', 'reasoning', 'text', 'tool_call'], + }, + observed: { + ...sampleTarget.capabilities!, + supported_input_modalities: ['function_call', 'reasoning', 'text', 'tool_call'], + }, + non_probeable_input_modalities: ['function_call', 'reasoning', 'tool_call'], + warnings: [ + 'Validation sent live requests to the target; ...', + 'Some declared input modalities are reported as declared/not-probed (no packaged probe asset): function_call, reasoning, tool_call.', + ], +} + +describe('ValidateCapabilitiesDialog', () => { + beforeEach(() => { + mockedApi.validateCapabilities.mockReset() + }) + + it('renders nothing when closed', () => { + render( + + + , + ) + expect(screen.queryByText(/Validate capabilities/i)).not.toBeInTheDocument() + expect(mockedApi.validateCapabilities).not.toHaveBeenCalled() + }) + + it('renders nothing when target is null', () => { + render( + + + , + ) + expect(screen.queryByText(/Validate capabilities/i)).not.toBeInTheDocument() + expect(mockedApi.validateCapabilities).not.toHaveBeenCalled() + }) + + it('renders spinner while the request is in flight', async () => { + // Never-resolving promise so we observe the spinner state. + mockedApi.validateCapabilities.mockReturnValue(new Promise(() => {})) + render( + + + , + ) + await waitFor(() => expect(mockedApi.validateCapabilities).toHaveBeenCalledWith('azure_chat_test')) + expect(screen.getByText(/Probing target/i)).toBeInTheDocument() + }) + + it('renders error message when the API call rejects', async () => { + mockedApi.validateCapabilities.mockRejectedValue(new Error('boom')) + render( + + + , + ) + await waitFor(() => expect(screen.getByText('boom')).toBeInTheDocument()) + }) + + it('renders the capabilities table once the request resolves', async () => { + mockedApi.validateCapabilities.mockResolvedValue(allMatchResponse) + render( + + + , + ) + await waitFor(() => expect(screen.getByText('Multi-turn')).toBeInTheDocument()) + expect(screen.getByText('JSON Schema')).toBeInTheDocument() + expect(screen.getByText('Input modalities')).toBeInTheDocument() + expect(screen.getByText('Output modalities')).toBeInTheDocument() + }) + + it('shows red mismatch indicator when declared differs from observed', async () => { + mockedApi.validateCapabilities.mockResolvedValue(mismatchResponse) + render( + + + , + ) + await waitFor(() => expect(screen.getByText('Multi-turn')).toBeInTheDocument()) + // At least one mismatch indicator (JSON Schema differs, input modalities differ). + const mismatches = screen.getAllByText('mismatch') + expect(mismatches.length).toBeGreaterThanOrEqual(2) + }) + + it('shows green match indicator when declared equals observed', async () => { + mockedApi.validateCapabilities.mockResolvedValue(allMatchResponse) + render( + + + , + ) + await waitFor(() => expect(screen.getByText('Multi-turn')).toBeInTheDocument()) + const matches = screen.getAllByText('match') + // 6 boolean rows + 1 input-modalities row, all match. + expect(matches.length).toBeGreaterThanOrEqual(7) + }) + + it('renders all warning messages', async () => { + mockedApi.validateCapabilities.mockResolvedValue(allMatchResponse) + render( + + + , + ) + await waitFor(() => expect(screen.getByText(/Test prompts written to memory/i)).toBeInTheDocument()) + expect(screen.getByText(/Output modalities are reported as declared/i)).toBeInTheDocument() + expect(screen.getByText(/Do not run Validate while an attack/i)).toBeInTheDocument() + }) + + it('calls onClose when the Close button is clicked', async () => { + const user = userEvent.setup() + const onClose = jest.fn() + mockedApi.validateCapabilities.mockResolvedValue(allMatchResponse) + render( + + + , + ) + await waitFor(() => expect(screen.getByText('Multi-turn')).toBeInTheDocument()) + await user.click(screen.getByRole('button', { name: /close/i })) + expect(onClose).toHaveBeenCalled() + }) + + it('resets state when reopened for a different target', async () => { + mockedApi.validateCapabilities.mockResolvedValue(allMatchResponse) + const { rerender } = render( + + + , + ) + await waitFor(() => expect(mockedApi.validateCapabilities).toHaveBeenCalledWith('azure_chat_test')) + await waitFor(() => expect(screen.getByText('Multi-turn')).toBeInTheDocument()) + + const otherTarget: TargetInstance = { ...sampleTarget, target_registry_name: 'other' } + mockedApi.validateCapabilities.mockReturnValue(new Promise(() => {})) + rerender( + + + , + ) + await waitFor(() => expect(mockedApi.validateCapabilities).toHaveBeenCalledWith('other')) + // Spinner is visible (state reset for the new target). + expect(screen.getByText(/Probing target/i)).toBeInTheDocument() + }) + + it('resets state when reopened for the SAME target', async () => { + // First open: resolves successfully. + mockedApi.validateCapabilities.mockResolvedValueOnce(allMatchResponse) + const onClose = jest.fn() + const { rerender } = render( + + + , + ) + await waitFor(() => expect(screen.getByText('Multi-turn')).toBeInTheDocument()) + + // Close. + const user = userEvent.setup() + await user.click(screen.getByRole('button', { name: /close/i })) + expect(onClose).toHaveBeenCalled() + rerender( + + + , + ) + + // Reopen same target — must re-fire the request and show spinner again, + // not the stale prior result. + mockedApi.validateCapabilities.mockReturnValueOnce(new Promise(() => {})) + rerender( + + + , + ) + await waitFor(() => { + expect(mockedApi.validateCapabilities).toHaveBeenCalledTimes(2) + }) + expect(screen.getByText(/Probing target/i)).toBeInTheDocument() + // Stale "Multi-turn" row should be gone while loading. + expect(screen.queryByText('Multi-turn')).not.toBeInTheDocument() + }) + + it('renders the "Not probed (no asset)" row when non_probeable_input_modalities is non-empty', async () => { + mockedApi.validateCapabilities.mockResolvedValue(notProbedResponse) + render( + + + , + ) + await waitFor(() => expect(screen.getByTestId('not-probed-row')).toBeInTheDocument()) + expect(screen.getByText(/Not probed \(no asset\)/i)).toBeInTheDocument() + expect(screen.getByText('function_call, reasoning, tool_call')).toBeInTheDocument() + }) + + it('does NOT render the "Not probed" row when non_probeable_input_modalities is empty', async () => { + mockedApi.validateCapabilities.mockResolvedValue(allMatchResponse) + render( + + + , + ) + await waitFor(() => expect(screen.getByText('Multi-turn')).toBeInTheDocument()) + expect(screen.queryByTestId('not-probed-row')).not.toBeInTheDocument() + expect(screen.queryByText(/Not probed \(no asset\)/i)).not.toBeInTheDocument() + }) + + it('shows a warning banner for composite targets', async () => { + mockedApi.validateCapabilities.mockResolvedValue(allMatchResponse) + const compositeTarget: TargetInstance = { + ...sampleTarget, + inner_targets: [ + { ...sampleTarget, target_registry_name: 'inner_1' }, + { ...sampleTarget, target_registry_name: 'inner_2' }, + ], + } + render( + + + , + ) + await waitFor(() => expect(screen.getByText(/This is a composite target/i)).toBeInTheDocument()) + }) +}) From be08eaa4df288143579d4aff973305df563089ab Mon Sep 17 00:00:00 2001 From: Varun Joginpalli Date: Wed, 10 Jun 2026 21:33:39 +0000 Subject: [PATCH 09/17] document target capability validation --- doc/gui/0_gui.md | 16 +++++++++++++++- 1 file changed, 15 insertions(+), 1 deletion(-) diff --git a/doc/gui/0_gui.md b/doc/gui/0_gui.md index e93cbb83b8..11704bd623 100644 --- a/doc/gui/0_gui.md +++ b/doc/gui/0_gui.md @@ -136,7 +136,21 @@ The Configuration view manages the targets available for attacks. #### Target Table -Lists all registered targets with their type, endpoint, and model name. Click "Set Active" to select a target for use in the Chat view. The active target is highlighted with an "Active" badge. +Lists all registered targets with their type, endpoint, and model name. Click "Set Active" to select a target for use in the Chat view. The active target is highlighted with an "Active" badge. Click "Validate" on any top-level row to probe a target's live capabilities and see a declared-vs-observed diff (see [Validating Targets](#validating-targets) below). + +#### Validating Targets + +Each row in the target table has a **Validate** button that runs PyRIT's `discover_target_capabilities_async` engine against the selected target and opens a modal showing declared-vs-observed capability flags and input modalities. Use this when you want to confirm that a target actually accepts the request shapes its class declares (for example, when an Azure OpenAI gateway strips a feature, or when a multimodal class is pointed at a text-only deployment) before launching a long attack run. + +The dialog: + +- Sends real requests to the target — this may incur cost and produce side effects (logs, billing, content-policy hits). Test prompts are written to memory tagged `capability_probe`. +- Caps per-probe timeout at 5 seconds for GUI responsiveness. +- Reports output modalities as declared (those are not actively probed) and renders an amber em-dash for them. +- Reports declared input-modality combinations the engine has no packaged test asset for (e.g., `function_call`, `tool_call`, `reasoning`, `url`) in a separate "Not probed (no asset)" row rather than as false red mismatches. +- Should NOT be run while an attack or scenario is actively using the same target — validation temporarily changes the target's runtime configuration during probing. + +Only top-level registered targets get a Validate button; inner targets of composite wrappers (e.g., `RoundRobinTarget` children) are reachable only through the wrapper. #### Creating Targets From 8987f764a5f359f66e409c545f7a4c9be11abde1 Mon Sep 17 00:00:00 2001 From: Varun Joginpalli Date: Wed, 10 Jun 2026 22:26:35 +0000 Subject: [PATCH 10/17] bump validate timeout to 15s 5s caused false-negative mismatches against cold-started Azure targets (multi_turn flakes, cascading to editable_history). 15s gives enough headroom for cold starts while remaining interactive. Verified live: 5s flaked multi_turn=False, 15s probe returns the correct multi_turn=True against azure_openai_responses. --- frontend/src/components/Config/ValidateCapabilitiesDialog.tsx | 2 +- pyrit/backend/services/target_service.py | 4 ++-- tests/unit/backend/test_target_service.py | 2 +- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/frontend/src/components/Config/ValidateCapabilitiesDialog.tsx b/frontend/src/components/Config/ValidateCapabilitiesDialog.tsx index 75837a06c5..df2c634ff3 100644 --- a/frontend/src/components/Config/ValidateCapabilitiesDialog.tsx +++ b/frontend/src/components/Config/ValidateCapabilitiesDialog.tsx @@ -176,7 +176,7 @@ export default function ValidateCapabilitiesDialog({ padding: '24px 0', }} > - + Sending live test requests; results may take a few seconds. diff --git a/pyrit/backend/services/target_service.py b/pyrit/backend/services/target_service.py index b961321c91..3d1c3e97ab 100644 --- a/pyrit/backend/services/target_service.py +++ b/pyrit/backend/services/target_service.py @@ -158,7 +158,7 @@ class TargetService: # Per-probe timeout for the GUI validation flow. The engine default is # 30 s, which compounded across 5+ probes can exceed 2 min; 5 s keeps # the GUI snappy while still catching real rejections. - _GUI_VALIDATE_TIMEOUT_S: ClassVar[float] = 5.0 + _GUI_VALIDATE_TIMEOUT_S: ClassVar[float] = 15.0 def __init__(self) -> None: """Initialize the target service.""" @@ -323,7 +323,7 @@ async def validate_target_capabilities_async( Args: target_registry_name: The registry key of the target to validate. per_probe_timeout_s: Per-probe timeout in seconds. Defaults to - ``_GUI_VALIDATE_TIMEOUT_S`` (5.0) for interactive use. + ``_GUI_VALIDATE_TIMEOUT_S`` (15.0) for interactive use. Returns: ValidateCapabilitiesResponse, or None if the target is not in the registry. diff --git a/tests/unit/backend/test_target_service.py b/tests/unit/backend/test_target_service.py index ae00b30277..68f1d479b7 100644 --- a/tests/unit/backend/test_target_service.py +++ b/tests/unit/backend/test_target_service.py @@ -962,7 +962,7 @@ async def test_uses_gui_default_timeout_when_not_overridden(self) -> None: mock_probe.return_value = observed await service.validate_target_capabilities_async(target_registry_name="t1") assert mock_probe.call_args.kwargs["per_probe_timeout_s"] == TargetService._GUI_VALIDATE_TIMEOUT_S - assert mock_probe.call_args.kwargs["per_probe_timeout_s"] == 5.0 + assert mock_probe.call_args.kwargs["per_probe_timeout_s"] == 15.0 async def test_passes_probeable_modalities_only(self) -> None: """ From 09cd09c4f325a819fdc476a17e10af484fffe30c Mon Sep 17 00:00:00 2001 From: Varun Joginpalli Date: Wed, 10 Jun 2026 22:45:47 +0000 Subject: [PATCH 11/17] hide non-probeable types from Input modalities cells MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The engine ORs non-probeable combinations back into observed.input_modalities (discover_target_capabilities.py:778), which made the dialog show the same types in both the Observed cell and the 'Not probed (no asset)' row — a contradiction (claims confirmed AND claims not-probed for the same type). Filter the non-probeable types out of both Declared and Observed cells in the Input modalities row so the cells show only what was actually probed. The 'Not probed' row below already lists them separately — no info lost. Regression-guarded by an updated F6 test that asserts function_call appears exactly twice on screen (Not-probed row + warning text), not three times (which would mean it leaked back into the Input modalities cells). --- .../ValidateCapabilitiesDialog.test.tsx | 8 +++++++ .../Config/ValidateCapabilitiesDialog.tsx | 21 ++++++++++++++----- 2 files changed, 24 insertions(+), 5 deletions(-) diff --git a/frontend/src/components/Config/ValidateCapabilitiesDialog.test.tsx b/frontend/src/components/Config/ValidateCapabilitiesDialog.test.tsx index 5777b938b4..5cf4652c10 100644 --- a/frontend/src/components/Config/ValidateCapabilitiesDialog.test.tsx +++ b/frontend/src/components/Config/ValidateCapabilitiesDialog.test.tsx @@ -266,6 +266,14 @@ describe('ValidateCapabilitiesDialog', () => { await waitFor(() => expect(screen.getByTestId('not-probed-row')).toBeInTheDocument()) expect(screen.getByText(/Not probed \(no asset\)/i)).toBeInTheDocument() expect(screen.getByText('function_call, reasoning, tool_call')).toBeInTheDocument() + // Non-probeable types must NOT appear in the Input modalities cells — + // otherwise the user sees the same types in both Observed and Not-probed, + // which is contradictory. function_call should appear exactly twice on + // screen: once in the Not-probed row, once in the warning bar text. + // (Before the fix this would have been 3 — the third occurrence was the + // Input modalities Observed cell, which is the regression we're guarding.) + const functionCallOccurrences = screen.getAllByText(/function_call/) + expect(functionCallOccurrences).toHaveLength(2) }) it('does NOT render the "Not probed" row when non_probeable_input_modalities is empty', async () => { diff --git a/frontend/src/components/Config/ValidateCapabilitiesDialog.tsx b/frontend/src/components/Config/ValidateCapabilitiesDialog.tsx index df2c634ff3..8e2b67d531 100644 --- a/frontend/src/components/Config/ValidateCapabilitiesDialog.tsx +++ b/frontend/src/components/Config/ValidateCapabilitiesDialog.tsx @@ -150,10 +150,21 @@ export default function ValidateCapabilitiesDialog({ const declared = result?.declared const observed = result?.observed - const inputMatch = modalitiesEqual( - declared?.supported_input_modalities, - observed?.supported_input_modalities, + // Types that appear ONLY inside non-probeable combinations. The engine ORs + // these back into observed.input_modalities (line 778), making them appear + // confirmed in the cells even though they were never tested. Hide them from + // the Input modalities row so the cells show only what was actually probed; + // the "Not probed (no asset)" row below already lists them separately. + const nonProbeableTypes = new Set( + (result?.non_probeable_input_modalities ?? []).flatMap(combo => combo.split('+')), ) + const declaredProbeableInputs = (declared?.supported_input_modalities ?? []).filter( + t => !nonProbeableTypes.has(t), + ) + const observedProbeableInputs = (observed?.supported_input_modalities ?? []).filter( + t => !nonProbeableTypes.has(t), + ) + const inputMatch = modalitiesEqual(declaredProbeableInputs, observedProbeableInputs) return ( Input modalities - {formatModalities(declared?.supported_input_modalities)} - {formatModalities(observed?.supported_input_modalities)} + {formatModalities(declaredProbeableInputs)} + {formatModalities(observedProbeableInputs)} From e3e0742f7e16c761722de6fb0df0b6ac0c1dfd22 Mon Sep 17 00:00:00 2001 From: Varun Joginpalli Date: Wed, 10 Jun 2026 22:52:20 +0000 Subject: [PATCH 12/17] add 'spot-check, not ground truth' banner with known engine issues Validation surfaces request-acceptance (not enforcement) AND inherits any bugs in the probe engine. Two known bugs cause false negatives today: the packaged probe_image.png is corrupt, and OpenAI Responses API image payloads have a known engine format mismatch. Both make image_path show 'observed=no' on targets that actually support image input. Add a prominent warning banner at the top of every result so users know not to treat the diff as ground truth. When the listed engine bugs are fixed, drop the parenthetical. --- .../components/Config/ValidateCapabilitiesDialog.tsx | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/frontend/src/components/Config/ValidateCapabilitiesDialog.tsx b/frontend/src/components/Config/ValidateCapabilitiesDialog.tsx index 8e2b67d531..5c93593eba 100644 --- a/frontend/src/components/Config/ValidateCapabilitiesDialog.tsx +++ b/frontend/src/components/Config/ValidateCapabilitiesDialog.tsx @@ -200,6 +200,18 @@ export default function ValidateCapabilitiesDialog({ )} {result && !loading && !error && ( <> + + + Treat results as a spot-check, not ground truth. Each row + reports whether a probe request was accepted, not whether the + capability is correctly enforced. Known engine issues can also cause false + negatives — currently: image probes are affected by a packaged-asset + bug, and the OpenAI Responses API image payload format has a known engine + mismatch. A red mismatch may mean the target genuinely lacks + the capability, or it may mean the probe itself is misbehaving. When in + doubt, re-run or test manually before relying on the result. + + {(target.inner_targets ?? []).length > 0 && ( From 706d8d939667559e3eeb4938ca9e4df711919191 Mon Sep 17 00:00:00 2001 From: Varun Joginpalli Date: Wed, 10 Jun 2026 23:04:01 +0000 Subject: [PATCH 13/17] drop top banner; fold engine caveat into existing warning row MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Previous banner was bulky and read like a self-own — surfacing our own engine bugs at the top of the dialog before the user had even seen the data. Replaced with: nothing in the result header (removed banner), plus a short addition to the existing 'request acceptance, not semantic enforcement' warning to mention image probes may currently false-negative. This keeps the engine-bug context where it belongs (one warning among several, framed as a property of probing rather than a flaw of this UI) without making the dialog look unconfident on first impression. --- .../components/Config/ValidateCapabilitiesDialog.tsx | 12 ------------ pyrit/backend/services/target_service.py | 4 +++- 2 files changed, 3 insertions(+), 13 deletions(-) diff --git a/frontend/src/components/Config/ValidateCapabilitiesDialog.tsx b/frontend/src/components/Config/ValidateCapabilitiesDialog.tsx index 5c93593eba..8e2b67d531 100644 --- a/frontend/src/components/Config/ValidateCapabilitiesDialog.tsx +++ b/frontend/src/components/Config/ValidateCapabilitiesDialog.tsx @@ -200,18 +200,6 @@ export default function ValidateCapabilitiesDialog({ )} {result && !loading && !error && ( <> - - - Treat results as a spot-check, not ground truth. Each row - reports whether a probe request was accepted, not whether the - capability is correctly enforced. Known engine issues can also cause false - negatives — currently: image probes are affected by a packaged-asset - bug, and the OpenAI Responses API image payload format has a known engine - mismatch. A red mismatch may mean the target genuinely lacks - the capability, or it may mean the probe itself is misbehaving. When in - doubt, re-run or test manually before relying on the result. - - {(target.inner_targets ?? []).length > 0 && ( diff --git a/pyrit/backend/services/target_service.py b/pyrit/backend/services/target_service.py index 3d1c3e97ab..7af074ed0b 100644 --- a/pyrit/backend/services/target_service.py +++ b/pyrit/backend/services/target_service.py @@ -381,7 +381,9 @@ async def validate_target_capabilities_async( ( "Capability probes confirm request acceptance, not semantic enforcement " "(e.g., a target that accepts a JSON-schema request may not actually " - "enforce the schema)." + "enforce the schema). Image probes can also currently false-negative on " + "some targets due to known probe-asset and payload-format issues; re-run " + "or verify manually before relying on a red image result." ), # The per-target lock above serializes Validate-vs-Validate but NOT # Validate-vs-attack: the engine briefly mutates target._configuration, From 06903b1d22e56d5f4ea5d2c326c1cc220a37c974 Mon Sep 17 00:00:00 2001 From: Varun Joginpalli Date: Thu, 11 Jun 2026 22:01:11 +0000 Subject: [PATCH 14/17] fix: prevent dialog from hiding probeable-confirmed types in mixed combos MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ValidateCapabilitiesDialog.tsx flattened non_probeable_input_modalities by splitting each combo string on '+' and unioning the pieces. For a target declaring both {text} (probeable) and {text, function_call} (non-probeable), the resulting set stripped both text and function_call from the Input modalities cells — making confirmed text invisible and the row render as '— / — / green match' despite text having been probed and confirmed. No in-tree target currently declares such a mixed combo, so this bug was latent. It would surface the moment any non-OpenAI multi-piece target lands. Fix: backend computes and emits non_probeable_only_types — the types that appear ONLY in non-probeable combos (never in any probeable one). Frontend uses that for the cell-hide set. non_probeable_input_modalities is unchanged and continues to drive the 'Not probed (no asset)' row display. Regression tests on both sides: - test_non_probeable_only_types_excludes_types_confirmed_via_probeable_combo asserts a target with both a probeable singleton and a non-probeable mixed combo reports the bridging type as confirmed-probeable. - A frontend test asserts the Input modalities row keeps 'text' and excludes 'function_call' when given mixed-combo data. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../ValidateCapabilitiesDialog.test.tsx | 44 +++++++++++++++ .../Config/ValidateCapabilitiesDialog.tsx | 12 +++-- frontend/src/types/index.ts | 9 ++++ pyrit/backend/models/targets.py | 18 +++++++ pyrit/backend/services/target_service.py | 11 ++++ tests/unit/backend/test_api_routes.py | 2 + tests/unit/backend/test_target_service.py | 53 +++++++++++++++++++ 7 files changed, 144 insertions(+), 5 deletions(-) diff --git a/frontend/src/components/Config/ValidateCapabilitiesDialog.test.tsx b/frontend/src/components/Config/ValidateCapabilitiesDialog.test.tsx index 5cf4652c10..c05720df44 100644 --- a/frontend/src/components/Config/ValidateCapabilitiesDialog.test.tsx +++ b/frontend/src/components/Config/ValidateCapabilitiesDialog.test.tsx @@ -39,6 +39,7 @@ const allMatchResponse: ValidateCapabilitiesResponse = { declared: sampleTarget.capabilities!, observed: sampleTarget.capabilities!, non_probeable_input_modalities: [], + non_probeable_only_types: [], warnings: [ 'Validation sent live requests to the target; this may incur cost and produce real side effects.', 'Test prompts written to memory are tagged with `capability_probe`.', @@ -61,6 +62,7 @@ const mismatchResponse: ValidateCapabilitiesResponse = { supported_input_modalities: ['text'], }, non_probeable_input_modalities: [], + non_probeable_only_types: [], warnings: ['Validation sent live requests to the target; ...'], } @@ -75,6 +77,7 @@ const notProbedResponse: ValidateCapabilitiesResponse = { supported_input_modalities: ['function_call', 'reasoning', 'text', 'tool_call'], }, non_probeable_input_modalities: ['function_call', 'reasoning', 'tool_call'], + non_probeable_only_types: ['function_call', 'reasoning', 'tool_call'], warnings: [ 'Validation sent live requests to the target; ...', 'Some declared input modalities are reported as declared/not-probed (no packaged probe asset): function_call, reasoning, tool_call.', @@ -288,6 +291,47 @@ describe('ValidateCapabilitiesDialog', () => { expect(screen.queryByText(/Not probed \(no asset\)/i)).not.toBeInTheDocument() }) + it('keeps probeable-confirmed types in the Input modalities cells even when a sibling combo bundles them with a non-probeable type', async () => { + // Regression: a target declaring both {text} and {text, function_call} + // sends back non_probeable_input_modalities=['function_call+text'] (the + // mixed combo) AND non_probeable_only_types=['function_call'] (only + // function_call is exclusively non-probeable; text is confirmed via the + // {text} singleton). The cells must hide function_call but keep text — + // otherwise the user sees '— / —' for Input modalities and a green + // match indicator while text was actually probed and confirmed. + const mixedComboResponse: ValidateCapabilitiesResponse = { + target_registry_name: 'mixed_combo_target', + declared: { + ...sampleTarget.capabilities!, + supported_input_modalities: ['function_call', 'text'], + }, + observed: { + ...sampleTarget.capabilities!, + supported_input_modalities: ['function_call', 'text'], + }, + non_probeable_input_modalities: ['function_call+text'], + non_probeable_only_types: ['function_call'], + warnings: [], + } + mockedApi.validateCapabilities.mockResolvedValue(mixedComboResponse) + render( + + + , + ) + await waitFor(() => expect(screen.getByTestId('not-probed-row')).toBeInTheDocument()) + // The Input modalities row must show 'text' in both cells (probed and + // confirmed). The Not-probed row must show the mixed combo. + const inputRow = screen.getByTestId('input-modalities-row') + expect(inputRow).toHaveTextContent('text') + expect(inputRow).not.toHaveTextContent('function_call') + expect(screen.getByText('function_call+text')).toBeInTheDocument() + }) + it('shows a warning banner for composite targets', async () => { mockedApi.validateCapabilities.mockResolvedValue(allMatchResponse) const compositeTarget: TargetInstance = { diff --git a/frontend/src/components/Config/ValidateCapabilitiesDialog.tsx b/frontend/src/components/Config/ValidateCapabilitiesDialog.tsx index 8e2b67d531..ef656a8064 100644 --- a/frontend/src/components/Config/ValidateCapabilitiesDialog.tsx +++ b/frontend/src/components/Config/ValidateCapabilitiesDialog.tsx @@ -154,10 +154,12 @@ export default function ValidateCapabilitiesDialog({ // these back into observed.input_modalities (line 778), making them appear // confirmed in the cells even though they were never tested. Hide them from // the Input modalities row so the cells show only what was actually probed; - // the "Not probed (no asset)" row below already lists them separately. - const nonProbeableTypes = new Set( - (result?.non_probeable_input_modalities ?? []).flatMap(combo => combo.split('+')), - ) + // the "Not probed (no asset)" row below already lists the combos separately. + // IMPORTANT: use `non_probeable_only_types` (not splitting + // `non_probeable_input_modalities` on '+'), so types confirmed via a + // probeable singleton combo aren't dropped when a sibling combo bundles + // them with a non-probeable type. + const nonProbeableTypes = new Set(result?.non_probeable_only_types ?? []) const declaredProbeableInputs = (declared?.supported_input_modalities ?? []).filter( t => !nonProbeableTypes.has(t), ) @@ -233,7 +235,7 @@ export default function ValidateCapabilitiesDialog({ ) })} - + Input modalities {formatModalities(declaredProbeableInputs)} {formatModalities(observedProbeableInputs)} diff --git a/frontend/src/types/index.ts b/frontend/src/types/index.ts index fcf6f8bd55..8d4a0af6ee 100644 --- a/frontend/src/types/index.ts +++ b/frontend/src/types/index.ts @@ -110,6 +110,15 @@ export interface ValidateCapabilitiesResponse { * row, distinguishing "not probed" from "probed and confirmed". */ non_probeable_input_modalities: string[] + /** + * Sorted list of declared input-modality types that appear ONLY in + * non-probeable combinations (never in any probeable combination). Used + * by ValidateCapabilitiesDialog to filter the input-modality cells without + * accidentally hiding types confirmed via a probeable singleton combo — + * e.g., for a target declaring both `{text}` and `{text, function_call}`, + * this list contains only `function_call`, leaving `text` visible. + */ + non_probeable_only_types: string[] /** * Operational notes for the user (live-call cost, memory tagging, output * modalities not probed, semantic-enforcement caveat, validate-vs-active-attack). diff --git a/pyrit/backend/models/targets.py b/pyrit/backend/models/targets.py index bb2fc4909e..e59bd90b71 100644 --- a/pyrit/backend/models/targets.py +++ b/pyrit/backend/models/targets.py @@ -104,6 +104,24 @@ class ValidateCapabilitiesResponse(BaseModel): "beneath the input-modalities row." ), ) + # Distinct from ``non_probeable_input_modalities`` (which carries the + # combo display strings). When a target declares both a probeable combo + # like ``{text}`` and a non-probeable mixed combo like ``{text, + # function_call}``, splitting the combo string on '+' and stripping every + # piece from the input-modality cells would incorrectly hide ``text`` — + # which *was* probed and confirmed via the singleton combo. This field + # lists only the types that never appear in any probeable combo, so the + # frontend can safely filter cells without dropping confirmed modalities. + non_probeable_only_types: list[str] = Field( + default_factory=list, + description=( + "Sorted list of declared input modality types that appear ONLY in non-probeable " + "combinations (never in any probeable combination). The frontend uses this set to " + "hide truly unprobed types from the input-modality cells while leaving types that " + "were confirmed via a probeable singleton combo visible. Disjoint from the types " + "implicit in ``observed.supported_input_modalities`` that came from a probeable probe." + ), + ) warnings: list[str] = Field( default_factory=list, description=( diff --git a/pyrit/backend/services/target_service.py b/pyrit/backend/services/target_service.py index 7af074ed0b..8a1eb2885d 100644 --- a/pyrit/backend/services/target_service.py +++ b/pyrit/backend/services/target_service.py @@ -405,11 +405,22 @@ async def validate_target_capabilities_async( f"(no packaged probe asset): {', '.join(non_probeable_combos_pretty)}." ) + # Types that appear ONLY in non-probeable combos (never in a probeable + # one). The frontend uses this for cell-filtering: if a type also + # belongs to some probeable combo it WAS confirmed, so the cell should + # still show it. Splitting the combo strings on '+' and using the union + # would incorrectly hide ``text`` for a target declaring both + # ``{text}`` and ``{text, function_call}``. + probeable_types: set[PromptDataType] = set().union(*probeable_combinations) if probeable_combinations else set() + non_probeable_types: set[PromptDataType] = set().union(*non_probeable) if non_probeable else set() + non_probeable_only_types: list[str] = sorted(non_probeable_types - probeable_types) + return ValidateCapabilitiesResponse( target_registry_name=target_registry_name, declared=declared, observed=observed, non_probeable_input_modalities=non_probeable_combos_pretty, + non_probeable_only_types=non_probeable_only_types, warnings=warnings, ) diff --git a/tests/unit/backend/test_api_routes.py b/tests/unit/backend/test_api_routes.py index 7819d295b9..f78914a8c1 100644 --- a/tests/unit/backend/test_api_routes.py +++ b/tests/unit/backend/test_api_routes.py @@ -971,6 +971,7 @@ def test_validate_target_returns_200_with_declared_and_observed(self, client: Te supported_input_modalities=["text"], ), non_probeable_input_modalities=["function_call"], + non_probeable_only_types=["function_call"], warnings=["Validation sent live requests to the target; ..."], ) ) @@ -984,6 +985,7 @@ def test_validate_target_returns_200_with_declared_and_observed(self, client: Te assert data["declared"]["supports_json_schema"] is True assert data["observed"]["supports_json_schema"] is False assert data["non_probeable_input_modalities"] == ["function_call"] + assert data["non_probeable_only_types"] == ["function_call"] assert isinstance(data["warnings"], list) and data["warnings"] def test_validate_target_returns_404_when_target_missing(self, client: TestClient) -> None: diff --git a/tests/unit/backend/test_target_service.py b/tests/unit/backend/test_target_service.py index 68f1d479b7..879186b093 100644 --- a/tests/unit/backend/test_target_service.py +++ b/tests/unit/backend/test_target_service.py @@ -1008,6 +1008,57 @@ async def test_passes_probeable_modalities_only(self) -> None: # (c) typed field has the sorted, '+'-joined list assert result.non_probeable_input_modalities == ["function_call", "url"] + # (d) function_call and url appear ONLY in non-probeable combos + # (each in its own singleton, no probeable combo includes them). + assert result.non_probeable_only_types == ["function_call", "url"] + + async def test_non_probeable_only_types_excludes_types_confirmed_via_probeable_combo(self) -> None: + """ + Regression guard for the dialog cell-filter bug: when a target + declares both a probeable singleton like ``{text}`` AND a non-probeable + mixed combo like ``{text, function_call}``, ``text`` IS confirmed + (via the singleton) and must not appear in + ``non_probeable_only_types`` — otherwise the frontend would strip + ``text`` from the Input modalities cells and show ``— / —`` despite + it being probed and confirmed. + + ``non_probeable_input_modalities`` (the combo display list) still + contains the mixed combo so the "Not probed (no asset)" row can + surface it; the cell-filter logic uses the narrower + ``non_probeable_only_types`` set instead. + """ + from unittest.mock import AsyncMock + + service = TargetService() + fake_target = _fake_target_with_capabilities( + input_modalities=frozenset( + { + frozenset(["text"]), + frozenset(["text", "function_call"]), + frozenset(["image_path"]), + frozenset(["image_path", "url"]), + } + ) + ) + observed = _fake_observed_capabilities(declared=fake_target.capabilities) + with ( + patch.object(service, "get_target_object", return_value=fake_target), + patch( + "pyrit.backend.services.target_service.discover_target_capabilities_async", + new_callable=AsyncMock, + ) as mock_probe, + ): + mock_probe.return_value = observed + result = await service.validate_target_capabilities_async(target_registry_name="t1") + + assert result is not None + # Combo display list keeps the mixed combos (used by the "Not probed" row). + assert result.non_probeable_input_modalities == ["function_call+text", "image_path+url"] + # Cell-filter list contains only types NOT confirmed by any probeable combo. + # `text` is confirmed by {text}; `image_path` is confirmed by {image_path}. + # Only `function_call` and `url` are exclusively non-probeable. + assert result.non_probeable_only_types == ["function_call", "url"] + async def test_passes_empty_set_when_no_probeable_modalities(self) -> None: """ Declared modalities are all non-probeable. Method passes @@ -1044,6 +1095,8 @@ async def test_passes_empty_set_when_no_probeable_modalities(self) -> None: assert isinstance(passed, set) assert result is not None assert result.non_probeable_input_modalities == ["function_call", "url", "video_path"] + # With no probeable combos, every declared type is exclusively non-probeable. + assert result.non_probeable_only_types == ["function_call", "url", "video_path"] assert any("no packaged probe asset" in w for w in result.warnings) async def test_propagates_probe_exceptions(self) -> None: From d04d6add15d5f8d91ac4a7522c0065873169a4b2 Mon Sep 17 00:00:00 2001 From: Varun Joginpalli Date: Thu, 11 Jun 2026 22:07:23 +0000 Subject: [PATCH 15/17] refactor(frontend): move Validate button into capabilities column cluster Per Roman's feedback: putting Validate next to Set Active in the leftmost cell stacked two unrelated actions and crowded the row left edge. The button belongs next to the data it inspects. Changes: - New 'Validate' column inserted between 'Outputs' and 'Multi-turn', with a header tooltip explaining what the action does. - Each top-level row gets a subtle icon button (BeakerRegular) in the new column with aria-label='Validate capabilities for {target_registry_name}', wrapped in a Tooltip carrying the same description. - Leftmost cell now contains only Set Active / Active badge, restoring the row to a single-line action cell. - Updated 5 F5 tests to find buttons via the new aria-label regex. - Updated doc/gui/0_gui.md to describe the new column placement. No behavior change: the same dialog opens with the same payload; the disable-during-active-dialog and inner-target exclusion rules still hold. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- doc/gui/0_gui.md | 6 +- .../components/Config/TargetTable.styles.ts | 4 ++ .../components/Config/TargetTable.test.tsx | 14 ++-- .../src/components/Config/TargetTable.tsx | 71 +++++++++++-------- 4 files changed, 55 insertions(+), 40 deletions(-) diff --git a/doc/gui/0_gui.md b/doc/gui/0_gui.md index 11704bd623..e203557859 100644 --- a/doc/gui/0_gui.md +++ b/doc/gui/0_gui.md @@ -136,11 +136,11 @@ The Configuration view manages the targets available for attacks. #### Target Table -Lists all registered targets with their type, endpoint, and model name. Click "Set Active" to select a target for use in the Chat view. The active target is highlighted with an "Active" badge. Click "Validate" on any top-level row to probe a target's live capabilities and see a declared-vs-observed diff (see [Validating Targets](#validating-targets) below). +Lists all registered targets with their type, endpoint, and model name. Click "Set Active" to select a target for use in the Chat view. The active target is highlighted with an "Active" badge. The **Validate** column (with a beaker icon button) lets you probe a target's live capabilities and see a declared-vs-observed diff (see [Validating Targets](#validating-targets) below). #### Validating Targets -Each row in the target table has a **Validate** button that runs PyRIT's `discover_target_capabilities_async` engine against the selected target and opens a modal showing declared-vs-observed capability flags and input modalities. Use this when you want to confirm that a target actually accepts the request shapes its class declares (for example, when an Azure OpenAI gateway strips a feature, or when a multimodal class is pointed at a text-only deployment) before launching a long attack run. +The **Validate** column in the target table has a beaker icon button on every top-level row that runs PyRIT's `discover_target_capabilities_async` engine against the selected target and opens a modal showing declared-vs-observed capability flags and input modalities. The button is placed next to the capability columns (Inputs, Outputs, Multi-turn, …) so it sits with the data it inspects. Use this when you want to confirm that a target actually accepts the request shapes its class declares (for example, when an Azure OpenAI gateway strips a feature, or when a multimodal class is pointed at a text-only deployment) before launching a long attack run. The dialog: @@ -150,7 +150,7 @@ The dialog: - Reports declared input-modality combinations the engine has no packaged test asset for (e.g., `function_call`, `tool_call`, `reasoning`, `url`) in a separate "Not probed (no asset)" row rather than as false red mismatches. - Should NOT be run while an attack or scenario is actively using the same target — validation temporarily changes the target's runtime configuration during probing. -Only top-level registered targets get a Validate button; inner targets of composite wrappers (e.g., `RoundRobinTarget` children) are reachable only through the wrapper. +Only top-level registered targets have a Validate button; inner targets of composite wrappers (e.g., `RoundRobinTarget` children) are reachable only through the wrapper. #### Creating Targets diff --git a/frontend/src/components/Config/TargetTable.styles.ts b/frontend/src/components/Config/TargetTable.styles.ts index 23ce7d25c6..35eca0add9 100644 --- a/frontend/src/components/Config/TargetTable.styles.ts +++ b/frontend/src/components/Config/TargetTable.styles.ts @@ -38,6 +38,10 @@ export const useTargetTableStyles = makeStyles({ width: '160px', textAlign: 'center', }, + validateCell: { + width: '70px', + textAlign: 'center', + }, modalityRow: { display: 'inline-flex', alignItems: 'center', diff --git a/frontend/src/components/Config/TargetTable.test.tsx b/frontend/src/components/Config/TargetTable.test.tsx index 2dfe8adac5..7f7574a59b 100644 --- a/frontend/src/components/Config/TargetTable.test.tsx +++ b/frontend/src/components/Config/TargetTable.test.tsx @@ -415,7 +415,7 @@ describe('TargetTable', () => { , ) - const validateButtons = screen.getAllByRole('button', { name: /^Validate$/ }) + const validateButtons = screen.getAllByRole('button', { name: /^Validate capabilities for / }) // 3 sample targets, 1 button each (no active target → no extra active-row button) expect(validateButtons).toHaveLength(3) }) @@ -426,7 +426,7 @@ describe('TargetTable', () => { , ) - const validateButtons = screen.getAllByRole('button', { name: /^Validate$/ }) + const validateButtons = screen.getAllByRole('button', { name: /^Validate capabilities for / }) // 3 list rows + 1 active-row summary = 4 expect(validateButtons).toHaveLength(4) }) @@ -458,12 +458,12 @@ describe('TargetTable', () => { , ) // Before expanding: 1 top-level row → 1 Validate button - expect(screen.getAllByRole('button', { name: /^Validate$/ })).toHaveLength(1) + expect(screen.getAllByRole('button', { name: /^Validate capabilities for / })).toHaveLength(1) // Expand fireEvent.click(screen.getByLabelText('Expand inner targets')) expect(screen.getByText('https://a.openai.azure.com')).toBeInTheDocument() // After expanding: still only 1 Validate button (inner rows don't get one) - expect(screen.getAllByRole('button', { name: /^Validate$/ })).toHaveLength(1) + expect(screen.getAllByRole('button', { name: /^Validate capabilities for / })).toHaveLength(1) }) it('opens the validation dialog when a Validate button is clicked', async () => { @@ -474,7 +474,7 @@ describe('TargetTable', () => { , ) - const validateButtons = screen.getAllByRole('button', { name: /^Validate$/ }) + const validateButtons = screen.getAllByRole('button', { name: /^Validate capabilities for / }) fireEvent.click(validateButtons[0]) await waitFor(() => { expect(mockedApi.validateCapabilities).toHaveBeenCalledWith('openai_chat_gpt4') @@ -489,11 +489,11 @@ describe('TargetTable', () => { , ) - const validateButtons = screen.getAllByRole('button', { name: /^Validate$/ }) + const validateButtons = screen.getAllByRole('button', { name: /^Validate capabilities for / }) fireEvent.click(validateButtons[0]) await waitFor(() => { // The first row's Validate button is now disabled. - const stillButtons = screen.getAllByRole('button', { name: /^Validate$/ }) + const stillButtons = screen.getAllByRole('button', { name: /^Validate capabilities for / }) expect(stillButtons[0]).toBeDisabled() // The other rows' buttons remain enabled. expect(stillButtons[1]).not.toBeDisabled() diff --git a/frontend/src/components/Config/TargetTable.tsx b/frontend/src/components/Config/TargetTable.tsx index b5679505ba..f5046a0ed7 100644 --- a/frontend/src/components/Config/TargetTable.tsx +++ b/frontend/src/components/Config/TargetTable.tsx @@ -28,6 +28,7 @@ import { ArrowHookUpLeftRegular, ChevronRightRegular, ChevronDownRegular, + BeakerRegular, } from '@fluentui/react-icons' import type { TargetInstance } from '../../types' import { useTargetTableStyles } from './TargetTable.styles' @@ -73,6 +74,7 @@ const COLUMN_TOOLTIPS = { parameters: 'Target-specific configuration parameters (e.g., reasoning_effort, max_output_tokens)', inputs: 'Modalities the target accepts as input', outputs: 'Modalities the target can produce as output', + validate: 'Probe the target live and compare observed capabilities to the declared values shown in this row', } as const /** Composite icon: f(x) with a small return-arrow badge for function call outputs. */ @@ -285,17 +287,7 @@ export default function TargetTable({ targets, activeTarget, onSetActiveTarget } -
- }>Active - -
+ }>Active
@@ -325,6 +317,18 @@ export default function TargetTable({ targets, activeTarget, onSetActiveTarget } + + + - )} + {isActive(target) ? ( + }> + Active + + ) : ( -
+ )}
@@ -469,6 +468,18 @@ export default function TargetTable({ targets, activeTarget, onSetActiveTarget } + + +
)} - {error && !loading && ( + {displayError && !loading && ( - {error} + {displayError} )} - {result && !loading && !error && ( + {displayResult && !loading && !displayError && ( <> {(target.inner_targets ?? []).length > 0 && ( @@ -243,7 +257,7 @@ export default function ValidateCapabilitiesDialog({
- {result.non_probeable_input_modalities.length > 0 && ( + {displayResult.non_probeable_input_modalities.length > 0 && ( @@ -252,7 +266,7 @@ export default function ValidateCapabilitiesDialog({ - {result.non_probeable_input_modalities.join(', ')} + {displayResult.non_probeable_input_modalities.join(', ')} @@ -270,9 +284,9 @@ export default function ValidateCapabilitiesDialog({
- {result.warnings.length > 0 && ( + {displayResult.warnings.length > 0 && (
- {result.warnings.map((w, idx) => ( + {displayResult.warnings.map((w, idx) => ( }> {w}