Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions doc/code/framework.md
Original file line number Diff line number Diff line change
Expand Up @@ -396,6 +396,7 @@ See [message normalizers](./targets/11_message_normalizer) for capability behavi

- If you are creating a component with user input (e.g. via config, REST, or automatically) it should always use the registry
- If you are storing an instance of a component, it should always use the registry
- The registry accepts only explicitly supported external inputs, permits opaque Python objects only for in-process callers, and leaves component validation to constructors.

## [Setup](./setup/0_setup)

Expand Down
13 changes: 6 additions & 7 deletions doc/gui/0_gui.md
Original file line number Diff line number Diff line change
Expand Up @@ -588,9 +588,10 @@ at `GET /api/runtime`.
## Registry API Migration Notes

Use `/api/converters/types` and `/api/targets/types` for registry build metadata.
These endpoints return all constructor parameters from the registry, including
lists, unions, and component references. The temporary `/catalog` routes retain
their scalar-only filtering for the current UI.
These endpoints return the constructor parameters external callers can set,
including flat lists, unions with a supported alternative, and component
references, and leave out types external callers can't create. Registry metadata
keeps every parameter.
Create requests should supply an explicit registry `name`. Converter creation
returns the complete `ConverterInstance`; read its type from
`identifier.class_name`, not the old top-level `converter_type` field. Treat
Expand All @@ -607,10 +608,8 @@ allowlisted image, audio, and video extensions inline. Other files, including PD
SVG, HTML, text, and executables, download as `application/octet-stream` attachments.

**Temporary compatibility, scheduled for removal with the chat migration:**
the `/api/converters/catalog` and `/api/targets/catalog` routes project the same
registry metadata for the current UI. Create requests without a name receive a
generated `compat_...` name. New clients should not depend on these routes or
unnamed creation.
target create requests without a name receive a generated `compat_...` name.
New clients should supply an explicit name.

## Connection Health

Expand Down
13 changes: 8 additions & 5 deletions pyrit/backend/services/converter_service.py
Original file line number Diff line number Diff line change
Expand Up @@ -112,9 +112,11 @@ async def list_converter_types_async(self) -> ConverterTypeResponse:
"""
List all available converter types from the converter class registry.

Returns every constructible converter. Deciding which entries to surface
to a user is a presentation concern owned by the caller (e.g. the
frontend), not this service.
Returns every converter that external callers can build, with only the
parameters they may supply; converters that need a Python object for a
required parameter are left out. Deciding which entries to surface to a
user is a presentation concern owned by the caller (e.g. the frontend),
not this service.

Returns:
ConverterTypeResponse containing all available converter classes.
Expand All @@ -124,11 +126,12 @@ async def list_converter_types_async(self) -> ConverterTypeResponse:
converter_type=metadata.class_name,
supported_input_types=list(metadata.supported_input_types),
supported_output_types=list(metadata.supported_output_types),
parameters=list(metadata.parameters),
parameters=[parameter for parameter in metadata.parameters if parameter.is_external_input],
is_llm_based=metadata.is_llm_based,
description=metadata.class_description or None,
)
for metadata in self._registry.get_all_registered_class_metadata()
if all(parameter.is_external_input for parameter in metadata.parameters if parameter.required)
]

return ConverterTypeResponse(items=items)
Expand Down Expand Up @@ -197,7 +200,7 @@ async def create_converter_async(self, *, request: CreateConverterRequest) -> Co
try:
# Uploads may have yielded to another request that took the name.
self._registry.instances.validate_name_available(request.name)
converter_obj = self._registry.create_instance(request.type, **params)
converter_obj = self._registry.create_instance_from_external_input(request.type, params=params)
converter = self._build_instance_from_object(converter_id=request.name, converter_obj=converter_obj)
self._registry.instances.register(
converter_obj,
Expand Down
8 changes: 7 additions & 1 deletion pyrit/backend/services/scenario_run_service.py
Original file line number Diff line number Diff line change
Expand Up @@ -76,7 +76,7 @@
)
from pyrit.prompt_target import PromptTarget
from pyrit.registry import InitializerRegistry, ScenarioRegistry
from pyrit.registry.resolution import resolve_declared_params
from pyrit.registry.resolution import reject_non_external_params, resolve_declared_params
from pyrit.scenario import Scenario
from pyrit.scenario.core import override_default_adversarial_target

Expand Down Expand Up @@ -626,6 +626,12 @@ async def _prepare_run_async(self, *, request: RunScenarioRequest) -> _PreparedR
ValueError: If scenario, target, initializer, or technique cannot be found.
"""
scenario_class = self._configuration_resolver.resolve_scenario_class(scenario_name=request.scenario_name)
if request.scenario_params:
reject_non_external_params(
params=request.scenario_params,
declared=scenario_class.supported_parameters(),
owner=request.scenario_name,
)
await self._run_initializers_async(request=request)
objective_target = self._configuration_resolver.resolve_target(target_name=request.target_name)
adversarial_target = self._configuration_resolver.resolve_adversarial_target(
Expand Down
9 changes: 8 additions & 1 deletion pyrit/backend/services/scenario_service.py
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@
ScenarioRunSizeEstimateRequest,
)
from pyrit.registry import ScenarioMetadata, ScenarioRegistry
from pyrit.registry.resolution import reject_non_external_params
from pyrit.scenario.core import Scenario, override_default_adversarial_target
from pyrit.scenario.core.dataset_configuration import read_only_dataset_resolution

Expand Down Expand Up @@ -68,7 +69,7 @@ def _metadata_to_registered_scenario(
all_techniques=list(metadata.all_techniques),
technique_summaries=list(metadata.technique_summaries),
default_datasets=list(metadata.default_datasets),
supported_parameters=list(metadata.supported_parameters),
supported_parameters=[parameter for parameter in metadata.supported_parameters if parameter.is_external_input],
baseline_policy=metadata.baseline_policy,
include_baseline_by_default=metadata.include_baseline_by_default,
uses_default_adversarial_target=metadata.uses_default_adversarial_target,
Expand Down Expand Up @@ -193,6 +194,12 @@ async def estimate_scenario_run_size_async(
scenario_class = self._registry.get_class(scenario_name)
except KeyError:
return None
if request.scenario_params:
reject_non_external_params(
params=request.scenario_params,
declared=scenario_class.supported_parameters(),
owner=scenario_name,
)

estimate_key = self._build_configured_estimate_key(
scenario_name=scenario_name, scenario_class=scenario_class, request=request
Expand Down
11 changes: 8 additions & 3 deletions pyrit/backend/services/scorer_service.py
Original file line number Diff line number Diff line change
Expand Up @@ -36,21 +36,25 @@ def _build_instance(self, *, name: str, scorer: Any) -> ScorerInstance:

async def list_scorer_types_async(self) -> ScorerTypeResponse:
"""
List registered scorer class metadata without constructing scorers.
List the scorer types external callers can build, without constructing scorers.

Each entry lists only the parameters external callers may supply; types that
need a Python object for a required parameter are left out.

Returns:
ScorerTypeResponse: All registered scorer type metadata.
ScorerTypeResponse: Scorer type metadata for external callers.
"""

def list_types() -> ScorerTypeResponse:
items = [
ScorerTypeEntry(
scorer_type=metadata.class_name,
parameters=list(metadata.parameters),
parameters=[parameter for parameter in metadata.parameters if parameter.is_external_input],
is_llm_based=metadata.is_llm_based,
description=metadata.class_description or None,
)
for metadata in self._registry.get_all_registered_class_metadata()
if all(parameter.is_external_input for parameter in metadata.parameters if parameter.required)
]
return ScorerTypeResponse(items=items)

Expand Down Expand Up @@ -110,6 +114,7 @@ def create() -> ScorerInstance:
name=request.name,
type_name=request.type,
params=request.params,
external_input=True,
)
return self._build_instance(name=request.name, scorer=scorer)

Expand Down
22 changes: 14 additions & 8 deletions pyrit/backend/services/target_service.py
Original file line number Diff line number Diff line change
Expand Up @@ -188,9 +188,10 @@ async def list_target_types_async(self) -> TargetTypeResponse:
"""
List all available target types from the target class registry.

Returns every constructible target with its derived constructor
parameters and the auth modes it supports, all projected from the
registry's ``TargetMetadata``. Deciding which entries to surface to a
Returns every target that external callers can build, with the
constructor parameters they may supply and the auth modes it supports,
all projected from the registry's ``TargetMetadata``; targets that need a
Python object for a required parameter are left out. Deciding which entries to surface to a
user is a presentation concern owned by the caller (e.g. the frontend),
not this service.

Expand All @@ -201,14 +202,19 @@ async def list_target_types_async(self) -> TargetTypeResponse:
items: list[TargetTypeEntry] = [
TargetTypeEntry(
target_type=metadata.class_name,
parameters=self._project_target_parameters(
target_type=metadata.class_name,
parameters=metadata.parameters,
),
parameters=[
parameter
for parameter in self._project_target_parameters(
target_type=metadata.class_name,
parameters=metadata.parameters,
)
if parameter.is_external_input
],
supported_auth_modes=self._get_supported_auth_modes(metadata.supported_auth_modes),
description=metadata.class_description or None,
)
for metadata in metadata_items
if all(parameter.is_external_input for parameter in metadata.parameters if parameter.required)
]
return TargetTypeResponse(items=items)

Expand Down Expand Up @@ -265,7 +271,7 @@ async def create_target_async(self, *, request: CreateTargetRequest) -> TargetIn
# Remove this generated fallback after that UI sends an explicit name.
target_registry_name = request.name or f"compat_{uuid.uuid4().hex}"
self._registry.instances.validate_name_available(target_registry_name)
target_obj = self._registry.create_instance(request.type, **params)
target_obj = self._registry.create_instance_from_external_input(request.type, params=params)
target = self._build_instance_from_object(target_registry_name=target_registry_name, target_obj=target_obj)
self._registry.instances.register(target_obj, name=target_registry_name)
return target
Expand Down
3 changes: 1 addition & 2 deletions pyrit/converter/word_doc_converter.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
import hashlib
from dataclasses import dataclass
from io import BytesIO
from pathlib import Path # noqa: TC003 - registry annotation resolution
from typing import TYPE_CHECKING, Any

from docx import Document
Expand All @@ -16,8 +17,6 @@
from pyrit.memory import data_serializer_factory

if TYPE_CHECKING:
from pathlib import Path

from pyrit.memory import DataTypeSerializer
from pyrit.models import ComponentIdentifier, PromptDataType, SeedPrompt

Expand Down
57 changes: 57 additions & 0 deletions pyrit/models/parameter.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
import copy
import types
from abc import ABC, abstractmethod
from collections.abc import Collection, Sequence
from dataclasses import dataclass
from enum import Enum
from pathlib import Path
Expand Down Expand Up @@ -277,6 +278,36 @@ def is_string_coercible(self) -> bool:
return False
return _is_scalar_param_type(_unwrap_optional(self.param_type))

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The allowlist now accepts these inputs, but the catalog still describes their full Python annotations. The GUI therefore cannot configure several parameters that REST now accepts:

  • SATA's Collection[str] parameters serialize with is_list=False, so the converter form disables them.
  • font_size: int | tuple[int, int] remains disabled by the converter form's union-type check.
  • OpenAIVideoTarget.n_seconds is excluded by the target form's type check.

Please make the external catalog describe the supported external form, while keeping the complete Python contract in internal registry metadata. Reuse the existing descriptors: supported flat collections should use the existing list representation, including element choices for enums. Mixed unions should expose the supported external alternatives, not require clients to interpret opaque Python alternatives.

Main already represents list[SomeStringEnum] as list[str] with is_list=True and enum-value choices. Please keep the new collection support consistent with that contract.

Please also add coverage for serialized catalog metadata and the actual form submission path. The current creation tests send correctly typed values directly, so they cannot detect this mismatch. Check that the forms can supply font_size=24, n_seconds=8, and SATA word lists with the correct JSON shapes.

This should remain a small external-input projection, not recursive JSON conversion or component validation in the registry.

@property
def is_external_input(self) -> bool:
"""
Whether REST, CLI, and GUI callers may supply this parameter.

True for registry references, declared structured inputs, scalars (``Path`` and
``Path | str`` included), flat ``list`` / ``Collection`` / ``Sequence`` of non-path
scalars, and other unions with one of those as an alternative and no path
alternative. External callers supply that
alternative, as for ``api_key: str | Callable[...]`` or
``font_size: int | tuple[int, int]``; the other alternatives are for in-process
callers. Other parameters take Python objects from in-process callers only.

Returns:
bool: True when external callers may supply this parameter.
"""
if self.reference is not None or self.variants is not None:
return True
if self.opaque:
return False
param_type = _unwrap_optional(self.param_type)
if _is_scalar_param_type(param_type):
return True
if get_origin(param_type) in (Union, types.UnionType):
members = [member for member in get_args(param_type) if member is not type(None)]
return not any(_mentions_path(member) for member in members) and any(
_is_non_path_json_type(member) for member in members
)
return _is_non_path_json_type(param_type)

def is_reference_to(self, component_type: ComponentType) -> bool:
"""
Whether this parameter is a registry reference to the given component family.
Expand Down Expand Up @@ -411,6 +442,32 @@ def _is_scalar_param_type(annotation: Any) -> bool:
return _is_enum_type(annotation)


def _is_non_path_json_type(annotation: Any) -> bool:
"""
Return whether the annotation is a non-path scalar or a flat collection of one.

A flat collection is a ``list``, ``Collection``, or ``Sequence`` of a single non-path
scalar; external callers send it as a JSON array, which reaches the constructor as a list.

Returns:
bool: True for ``str``/``int``/``float``/``bool``/``Literal``/``Enum`` or a flat collection of them.
"""
if get_origin(annotation) in (list, Collection, Sequence):
type_args = get_args(annotation)
annotation = type_args[0] if len(type_args) == 1 else None
return _is_scalar_param_type(annotation) and annotation is not Path and not _is_path_or_str(annotation)


def _mentions_path(annotation: Any) -> bool:
"""
Return whether the annotation is ``Path`` or has ``Path`` among its type arguments.

Returns:
bool: True when a value of this type may be a local file path.
"""
return annotation is Path or any(_mentions_path(argument) for argument in get_args(annotation))


def _coerce_simple_value(*, param_name: str, annotation: Any, raw_value: Any) -> Any:
"""
Coerce ``raw_value`` to a scalar ``annotation`` — the shared coercion core.
Expand Down
4 changes: 1 addition & 3 deletions pyrit/prompt_target/a2a_target.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@
import math
import time
import uuid
from collections.abc import AsyncGenerator
from collections.abc import AsyncGenerator, Awaitable, Callable # noqa: TC003 - registry annotation resolution
from dataclasses import dataclass
from email.utils import parsedate_to_datetime
from typing import TYPE_CHECKING, Any, Literal, cast
Expand All @@ -31,8 +31,6 @@
from pyrit.prompt_target.common.utils import limit_requests_per_minute

if TYPE_CHECKING:
from collections.abc import Awaitable, Callable

from a2a.client import Client
from a2a.types import a2a_pb2

Expand Down
42 changes: 41 additions & 1 deletion pyrit/registry/registry.py
Original file line number Diff line number Diff line change
Expand Up @@ -720,6 +720,37 @@ def create_instance(self, name: str, **kwargs: object) -> T:
)
return cls(**resolved)

def create_instance_from_external_input(self, name: str, *, params: Mapping[str, object]) -> T:
"""
Build a configured instance from external input such as a REST or CLI request.

Unlike ``create_instance``, which serves in-process callers that may pass any
Python object, this accepts only parameters with ``Parameter.is_external_input``
and registry references given by name. Anything else is rejected before
construction; the constructor still validates the values it receives.

Args:
name (str): The catalog name to build.
params (Mapping[str, object]): Constructor arguments from the external caller.

Returns:
T: The constructed instance.

Raises:
KeyError: If the name is not registered.
ValueError: If an argument is not a valid constructor parameter or not an
external input, a registry reference cannot be resolved, or a value
cannot be coerced.
"""
cls = self.get_class(name)
resolved = resolve_constructor_args(
cls=cls,
raw_args=dict(params),
identifier_type=self._identifier_type(),
external_input=True,
)
return cls(**resolved)

def __contains__(self, name: str) -> bool:
"""
Check if a name is registered.
Expand Down Expand Up @@ -794,6 +825,7 @@ def create_named_instance(
type_name: str,
params: Mapping[str, object] | None = None,
registry_metadata: dict[str, Any] | None = None,
external_input: bool = False,
) -> InstanceT:
"""
Build and store a configured instance under an explicit name.
Expand All @@ -804,12 +836,20 @@ def create_named_instance(
params (Mapping[str, object] | None): Constructor arguments.
registry_metadata (dict[str, Any] | None): Per-entry metadata to store
with the instance.
external_input (bool): Whether ``params`` come from an external caller, in which
case the instance is built with ``create_instance_from_external_input``.
Defaults to False.

Returns:
InstanceT: The constructed and registered instance.
"""
self.instances.validate_name_available(name)
instance = self.create_instance(type_name, **dict(params) if params is not None else {})
args = dict(params) if params is not None else {}
instance = (
self.create_instance_from_external_input(type_name, params=args)
if external_input
else self.create_instance(type_name, **args)
)
self.instances.register(instance, name=name, metadata=registry_metadata)
return instance

Expand Down
Loading
Loading