Skip to content

Commit 3471ea6

Browse files
authored
Merge branch 'main' into romanlutz-landing-raccoon-peek
2 parents 324df76 + 1dc36a8 commit 3471ea6

18 files changed

Lines changed: 617 additions & 132 deletions

.pyrit_conf_example

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,7 @@ memory_db_type: sqlite
4646
# - scorer
4747
initializers:
4848
- name: simple
49+
- name: scenario_technique
4950
- name: load_default_datasets
5051
- name: target
5152
args:

doc/code/converters/1_text_to_text_converters.ipynb

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -696,6 +696,11 @@
696696
"decomposition_converter = DecompositionConverter(converter_target=attack_llm)\n",
697697
"print(\"Decomposition:\", await decomposition_converter.convert_async(prompt=prompt)) # type: ignore\n",
698698
"\n",
699+
"# With use_word_game=True, each noun phrase is also replaced by an innocuous codeword, with the\n",
700+
"# mapping established in the same prompt\n",
701+
"decomposition_word_game = DecompositionConverter(converter_target=attack_llm, use_word_game=True)\n",
702+
"print(\"Decomposition (word-game):\", await decomposition_word_game.convert_async(prompt=prompt)) # type: ignore\n",
703+
"\n",
699704
"# Denylist detection\n",
700705
"denylist_converter = DenylistConverter(converter_target=attack_llm)\n",
701706
"print(\"Denylist Check:\", await denylist_converter.convert_async(prompt=prompt)) # type: ignore\n",

doc/code/converters/1_text_to_text_converters.py

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -313,6 +313,11 @@
313313
decomposition_converter = DecompositionConverter(converter_target=attack_llm)
314314
print("Decomposition:", await decomposition_converter.convert_async(prompt=prompt)) # type: ignore
315315

316+
# With use_word_game=True, each noun phrase is also replaced by an innocuous codeword, with the
317+
# mapping established in the same prompt
318+
decomposition_word_game = DecompositionConverter(converter_target=attack_llm, use_word_game=True)
319+
print("Decomposition (word-game):", await decomposition_word_game.convert_async(prompt=prompt)) # type: ignore
320+
316321
# Denylist detection
317322
denylist_converter = DenylistConverter(converter_target=attack_llm)
318323
print("Denylist Check:", await denylist_converter.convert_async(prompt=prompt)) # type: ignore

pyproject.toml

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -209,6 +209,23 @@ include = ["pyrit/prompt_target/hugging_face/**"]
209209
[tool.ty.overrides.rules]
210210
invalid-argument-type = "ignore"
211211

212+
# Unit tests intentionally exercise runtime validation failures, mutable test doubles,
213+
# and optional fields after fixtures have populated them. Keep library code strict while
214+
# avoiding hundreds of per-assertion ignores in tests.
215+
[[tool.ty.overrides]]
216+
include = ["tests/unit/**"]
217+
[tool.ty.overrides.rules]
218+
call-non-callable = "ignore"
219+
invalid-assignment = "ignore"
220+
invalid-await = "ignore"
221+
invalid-parameter-default = "ignore"
222+
invalid-type-arguments = "ignore"
223+
invalid-yield = "ignore"
224+
missing-argument = "ignore"
225+
not-subscriptable = "ignore"
226+
unknown-argument = "ignore"
227+
unsupported-operator = "ignore"
228+
212229
[tool.uv]
213230
constraint-dependencies = [
214231
"aiohttp>=3.13.4",

pyrit/backend/models/__init__.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77
Pydantic models for API requests and responses.
88
"""
99

10+
from pyrit.backend.models._media import DEFAULT_MEDIA_EXTENSIONS
1011
from pyrit.backend.models.attacks import (
1112
AddMessageRequest,
1213
AddMessageResponse,
@@ -66,6 +67,8 @@
6667
)
6768

6869
__all__ = [
70+
# Media
71+
"DEFAULT_MEDIA_EXTENSIONS",
6972
# Attacks
7073
"AddMessageRequest",
7174
"AddMessageResponse",

pyrit/backend/models/_media.py

Lines changed: 10 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -29,8 +29,15 @@
2929
"binary_path": "file",
3030
}
3131

32-
# Fallback extension per prefix when the value carries no usable suffix.
33-
_DEFAULT_EXTENSIONS = {"image": ".png", "audio": ".wav", "video": ".mp4", "file": ".bin"}
32+
# Default file extension per media data type, used when a value carries no usable
33+
# suffix and no MIME metadata is available. Centralized here so the backend response
34+
# models and the attack/converter services share a single source of truth.
35+
DEFAULT_MEDIA_EXTENSIONS: dict[str, str] = {
36+
"image_path": ".png",
37+
"audio_path": ".wav",
38+
"video_path": ".mp4",
39+
"binary_path": ".bin",
40+
}
3441

3542

3643
def infer_mime_type(*, value: str | None, data_type: PromptDataType) -> str | None:
@@ -79,6 +86,6 @@ def build_filename(*, data_type: str, sha256: str | None, value: str | None) ->
7986
ext = Path(source).suffix
8087

8188
if not ext:
82-
ext = _DEFAULT_EXTENSIONS.get(prefix, ".bin")
89+
ext = DEFAULT_MEDIA_EXTENSIONS.get(data_type, ".bin")
8390

8491
return f"{prefix}_{short_hash}{ext}"

pyrit/backend/services/attack_service.py

Lines changed: 13 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,7 @@
3131
request_piece_to_pyrit_message_piece,
3232
request_to_pyrit_message,
3333
)
34+
from pyrit.backend.models import DEFAULT_MEDIA_EXTENSIONS
3435
from pyrit.backend.models.attacks import (
3536
AddMessageRequest,
3637
AddMessageResponse,
@@ -935,22 +936,26 @@ async def _persist_base64_pieces_async(request: AddMessageRequest) -> None:
935936
except (OSError, ValueError):
936937
pass
937938

938-
# Derive file extension from the MIME type sent by the frontend
939-
ext = None
940-
if piece.mime_type:
941-
ext = mimetypes.guess_extension(piece.mime_type, strict=False)
942-
if not ext:
943-
ext = ".bin"
944-
945939
# Strip data URI prefix if present (e.g. "data:image/png;base64,...")
946940
# The backend itself returns data URIs from pyrit_messages_to_dto_async,
947941
# so the client may echo them back.
948942
value = piece.original_value
943+
data_uri_mime_type = None
949944
if value.startswith("data:"):
950945
# Format: data:<mime>;base64,<payload>
951-
_, _, payload = value.partition(",")
946+
header, _, payload = value.partition(",")
947+
data_uri_mime_type = header.split(":", 1)[1].split(";", 1)[0] if ":" in header else None
952948
value = payload
953949

950+
# Derive file extension from MIME metadata, then fall back to data_type.
951+
ext = None
952+
if piece.mime_type:
953+
ext = mimetypes.guess_extension(piece.mime_type, strict=False)
954+
if not ext and data_uri_mime_type:
955+
ext = mimetypes.guess_extension(data_uri_mime_type, strict=False)
956+
if not ext:
957+
ext = DEFAULT_MEDIA_EXTENSIONS.get(piece.data_type, ".bin")
958+
954959
serializer = data_serializer_factory(
955960
category="prompt-memory-entries",
956961
data_type=cast("PromptDataType", piece.data_type),

pyrit/backend/services/converter_service.py

Lines changed: 4 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -19,10 +19,11 @@
1919
import uuid
2020
from functools import lru_cache
2121
from pathlib import Path
22-
from typing import Any, ClassVar, Literal, Union, get_args, get_origin
22+
from typing import Any, Literal, Union, get_args, get_origin
2323
from urllib.parse import parse_qs, urlparse
2424

2525
from pyrit.backend.mappers.converter_mappers import converter_object_to_instance
26+
from pyrit.backend.models import DEFAULT_MEDIA_EXTENSIONS
2627
from pyrit.backend.models.converters import (
2728
ConverterCatalogEntry,
2829
ConverterCatalogResponse,
@@ -79,13 +80,6 @@ class ConverterService:
7980
API metadata is derived from the converter objects.
8081
"""
8182

82-
_DATA_TYPE_EXTENSION: ClassVar[dict[str, str]] = {
83-
"image_path": ".png",
84-
"audio_path": ".wav",
85-
"video_path": ".mp4",
86-
"binary_path": ".bin",
87-
}
88-
8983
def __init__(self) -> None:
9084
"""Initialize the converter service."""
9185
self._registry = ConverterRegistry.get_registry_singleton()
@@ -254,7 +248,7 @@ async def preview_conversion_async(self, *, request: ConverterPreviewRequest) ->
254248
elif original_value.startswith("data:"):
255249
_, _, value = original_value.partition(",")
256250

257-
ext = self._DATA_TYPE_EXTENSION.get(str(data_type), ".bin")
251+
ext = DEFAULT_MEDIA_EXTENSIONS.get(str(data_type), ".bin")
258252

259253
serializer = data_serializer_factory(
260254
category="prompt-memory-entries",
@@ -268,7 +262,7 @@ async def preview_conversion_async(self, *, request: ConverterPreviewRequest) ->
268262
pass
269263
else:
270264
# Treat as raw base64
271-
ext = self._DATA_TYPE_EXTENSION.get(str(data_type), ".bin")
265+
ext = DEFAULT_MEDIA_EXTENSIONS.get(str(data_type), ".bin")
272266

273267
serializer = data_serializer_factory(
274268
category="prompt-memory-entries",

pyrit/cli/api_client.py

Lines changed: 35 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -153,7 +153,7 @@ async def register_initializer_async(self, *, name: str, script_content: str) ->
153153
json={"name": name, "script_content": script_content},
154154
)
155155
if resp.status_code == 403:
156-
detail = resp.json().get("detail", "Custom initializer operations are disabled on the server.")
156+
detail = self._response_detail(resp) or "Custom initializer operations are disabled on the server."
157157
raise ServerNotAvailableError(detail)
158158
self._raise_for_status(resp)
159159
return resp.json()
@@ -308,6 +308,39 @@ async def _get_json_async(self, *, path: str, params: dict[str, Any] | None = No
308308
self._raise_for_status(resp)
309309
return resp.json()
310310

311+
@staticmethod
312+
def _response_detail(resp: Any) -> str | None:
313+
"""
314+
Extract a user-facing error detail from a response body.
315+
316+
Prefer FastAPI-style JSON ``detail`` values, then fall back to a plain
317+
text response body. Non-string mock/proxy attributes are ignored so
318+
callers can still use their default error messages.
319+
320+
Returns:
321+
str | None: Extracted detail text, or ``None`` if the body has no
322+
usable error detail.
323+
"""
324+
try:
325+
payload = resp.json()
326+
except Exception:
327+
payload = None
328+
if isinstance(payload, dict):
329+
detail_value = payload.get("detail")
330+
if isinstance(detail_value, str) and detail_value.strip():
331+
return detail_value
332+
if detail_value is not None:
333+
return str(detail_value)
334+
335+
text = getattr(resp, "text", "")
336+
if isinstance(text, bytes):
337+
text = text.decode(errors="replace")
338+
if isinstance(text, str):
339+
text = text.strip()
340+
if text:
341+
return text
342+
return None
343+
311344
@staticmethod
312345
def _raise_for_status(resp: Any) -> None:
313346
"""
@@ -327,22 +360,7 @@ def _raise_for_status(resp: Any) -> None:
327360
try:
328361
resp.raise_for_status()
329362
except httpx.HTTPStatusError as exc:
330-
detail: str | None = None
331-
try:
332-
payload = resp.json()
333-
except Exception:
334-
payload = None
335-
if isinstance(payload, dict):
336-
detail_value = payload.get("detail")
337-
if isinstance(detail_value, str) and detail_value.strip():
338-
detail = detail_value
339-
elif detail_value is not None:
340-
detail = str(detail_value)
341-
if detail is None:
342-
text = getattr(resp, "text", "") or ""
343-
text = text.strip()
344-
if text:
345-
detail = text
363+
detail = PyRITApiClient._response_detail(resp)
346364
if detail is None:
347365
raise
348366
message = f"{exc}: {detail}"

0 commit comments

Comments
 (0)