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
2 changes: 2 additions & 0 deletions pyrit/prompt_target/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@
UnsupportedCapabilityBehavior,
)
from pyrit.prompt_target.common.target_configuration import TargetConfiguration
from pyrit.prompt_target.common.target_requirements import TargetRequirements
from pyrit.prompt_target.common.utils import limit_requests_per_minute
from pyrit.prompt_target.gandalf_target import GandalfLevel, GandalfTarget
from pyrit.prompt_target.http_target.http_target import HTTPTarget
Expand Down Expand Up @@ -77,6 +78,7 @@
"RealtimeTarget",
"TargetCapabilities",
"TargetConfiguration",
"TargetRequirements",
"UnsupportedCapabilityBehavior",
"TextTarget",
"WebSocketCopilotTarget",
Expand Down
54 changes: 54 additions & 0 deletions pyrit/prompt_target/common/target_requirements.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
# Copyright (c) Microsoft Corporation.
# Licensed under the MIT license.

from __future__ import annotations

from dataclasses import dataclass, field
from typing import TYPE_CHECKING

if TYPE_CHECKING:
from pyrit.prompt_target.common.target_capabilities import CapabilityName
from pyrit.prompt_target.common.target_configuration import TargetConfiguration


@dataclass(frozen=True)
class TargetRequirements:
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.

Nit: I'm not sure I understand the point of this dataclass. What does this do that a validate method attached to TargetConfiguration or TargetCapabilities doesn't? Attaching TargetCapabilities to a consumer object is weird but this class seems like it could be reduced to a private attribute for consumers (_REQUIRED_CAPABILITIES: frozenset) while the validation method is moved elsewhere. Non-blocking comment since this works but worth documenting

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

yeah so the main motivation for the class itself is to have a descriptive interface so users (converters, scorers, attacks) can have a REQUIRED_CAPABILITIES member variable of the type TargetRequirements. I prefer having the two classes bc they are two separate concepts--what a target can support vs what a user of the target requires so I think it's more straightforward. The other place I could see a validation function (that would reduce duplication of the validation func) is in the TargetConfiguration class but it would be a TargetConfiguration instance verifying that another TargetConfiguration can support it vs a TargetConfiguration instance verifying that it meets TargetRequirements. wdyt ? is there a more natural place to put the validation function?

"""
Declarative description of what a consumer (attack, converter, scorer)
requires from a target.

Consumers define their requirements once and validate them against a
``TargetConfiguration`` at construction time. This replaces ad-hoc
``isinstance`` checks and scattered capability branching.
"""

# The set of capabilities the consumer requires.
required_capabilities: frozenset[CapabilityName] = field(default_factory=frozenset)

def validate(self, *, configuration: TargetConfiguration) -> None:
"""
Validate that the target configuration can satisfy all requirements.

Iterates over every required capability and delegates to
``TargetConfiguration.ensure_can_handle``, which checks native support
first and then consults the handling policy. All violations are
collected and reported in a single ``ValueError``.

Args:
configuration (TargetConfiguration): The target configuration to validate against.

Raises:
ValueError: If any required capability is missing and the policy
does not allow adaptation.
"""
errors: list[str] = []
for capability in sorted(self.required_capabilities, key=lambda c: c.value):
try:
configuration.ensure_can_handle(capability=capability)
except ValueError as exc:
errors.append(str(exc))
if errors:
raise ValueError(
f"Target does not satisfy {len(errors)} required capability(ies):\n"
+ "\n".join(f" - {e}" for e in errors)
)
131 changes: 131 additions & 0 deletions tests/unit/target/test_target_requirements.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,131 @@
# Copyright (c) Microsoft Corporation.
# Licensed under the MIT license.

import pytest

from pyrit.prompt_target.common.target_capabilities import (
CapabilityHandlingPolicy,
CapabilityName,
TargetCapabilities,
UnsupportedCapabilityBehavior,
)
from pyrit.prompt_target.common.target_configuration import TargetConfiguration
from pyrit.prompt_target.common.target_requirements import TargetRequirements


@pytest.fixture
def adapt_all_policy():
return CapabilityHandlingPolicy(
behaviors={
CapabilityName.SYSTEM_PROMPT: UnsupportedCapabilityBehavior.ADAPT,
CapabilityName.MULTI_TURN: UnsupportedCapabilityBehavior.ADAPT,
CapabilityName.JSON_SCHEMA: UnsupportedCapabilityBehavior.RAISE,
CapabilityName.JSON_OUTPUT: UnsupportedCapabilityBehavior.RAISE,
CapabilityName.MULTI_MESSAGE_PIECES: UnsupportedCapabilityBehavior.RAISE,
CapabilityName.EDITABLE_HISTORY: UnsupportedCapabilityBehavior.RAISE,
}
)


# ---------------------------------------------------------------------------
# Construction
# ---------------------------------------------------------------------------


def test_init_default_has_empty_capabilities():
reqs = TargetRequirements()
assert reqs.required_capabilities == frozenset()


def test_init_with_capabilities():
reqs = TargetRequirements(
required_capabilities=frozenset({CapabilityName.MULTI_TURN, CapabilityName.SYSTEM_PROMPT})
)
assert CapabilityName.MULTI_TURN in reqs.required_capabilities
assert CapabilityName.SYSTEM_PROMPT in reqs.required_capabilities


# ---------------------------------------------------------------------------
# validate — all pass
# ---------------------------------------------------------------------------


def test_validate_passes_when_target_supports_all_natively():
caps = TargetCapabilities(supports_multi_turn=True, supports_system_prompt=True)
config = TargetConfiguration(capabilities=caps)
reqs = TargetRequirements(
required_capabilities=frozenset({CapabilityName.MULTI_TURN, CapabilityName.SYSTEM_PROMPT})
)
reqs.validate(configuration=config)


def test_validate_passes_when_policy_is_adapt(adapt_all_policy):
caps = TargetCapabilities(supports_multi_turn=False, supports_system_prompt=False)
config = TargetConfiguration(capabilities=caps, policy=adapt_all_policy)
reqs = TargetRequirements(
required_capabilities=frozenset({CapabilityName.MULTI_TURN, CapabilityName.SYSTEM_PROMPT})
)
reqs.validate(configuration=config)


def test_validate_passes_with_empty_requirements():
caps = TargetCapabilities(supports_multi_turn=True, supports_system_prompt=True)
config = TargetConfiguration(capabilities=caps)
reqs = TargetRequirements()
reqs.validate(configuration=config)


# ---------------------------------------------------------------------------
# validate — failures
# ---------------------------------------------------------------------------


def test_validate_raises_when_capability_missing_and_no_policy():
# EDITABLE_HISTORY has no normalizer and no handling policy — validate raises.
caps = TargetCapabilities(supports_editable_history=False, supports_multi_turn=True, supports_system_prompt=True)
config = TargetConfiguration(capabilities=caps)
reqs = TargetRequirements(required_capabilities=frozenset({CapabilityName.EDITABLE_HISTORY}))
with pytest.raises(ValueError, match="supports_editable_history"):
reqs.validate(configuration=config)


def test_validate_raises_when_capability_missing_and_policy_raise(adapt_all_policy):
# json_output is missing and the policy is RAISE — validate raises.
caps = TargetCapabilities(supports_multi_turn=False, supports_system_prompt=False, supports_json_output=False)
config = TargetConfiguration(capabilities=caps, policy=adapt_all_policy)
reqs = TargetRequirements(required_capabilities=frozenset({CapabilityName.JSON_OUTPUT}))
with pytest.raises(ValueError, match="supports_json_output"):
reqs.validate(configuration=config)


def test_validate_collects_all_unsatisfied_capabilities(adapt_all_policy):
"""When multiple capabilities are missing, validate reports all violations."""
caps = TargetCapabilities(
supports_multi_turn=False,
supports_system_prompt=False,
supports_json_output=False,
supports_editable_history=False,
)
config = TargetConfiguration(capabilities=caps, policy=adapt_all_policy)
# json_output => RAISE, editable_history => no policy (raises)
reqs = TargetRequirements(
required_capabilities=frozenset({CapabilityName.JSON_OUTPUT, CapabilityName.EDITABLE_HISTORY})
)
with pytest.raises(ValueError, match="2 required capability") as exc_info:
reqs.validate(configuration=config)
assert "supports_json_output" in str(exc_info.value)
assert "supports_editable_history" in str(exc_info.value)


def test_validate_mixed_adapt_and_raise(adapt_all_policy):
"""One capability adapts but another raises — validate should raise."""
caps = TargetCapabilities(supports_multi_turn=False, supports_system_prompt=False, supports_json_output=False)
config = TargetConfiguration(capabilities=caps, policy=adapt_all_policy)
# multi_turn and system_prompt => ADAPT (OK), json_output => RAISE (fail)
reqs = TargetRequirements(
required_capabilities=frozenset(
{CapabilityName.MULTI_TURN, CapabilityName.SYSTEM_PROMPT, CapabilityName.JSON_OUTPUT}
)
)
with pytest.raises(ValueError, match="supports_json_output"):
reqs.validate(configuration=config)
Loading