Skip to content

Commit 98966c2

Browse files
romanlutzCopilot
andauthored
FIX: unblock main CI — Test GUI (PyPI), Crescendo parser, CoCoNot empty prompt (#1862)
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
1 parent bed0e15 commit 98966c2

6 files changed

Lines changed: 179 additions & 7 deletions

File tree

docker/Dockerfile

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -46,11 +46,20 @@ COPY --chown=vscode:vscode frontend/ /app/frontend/
4646
COPY --chown=vscode:vscode build_scripts/ /app/build_scripts/
4747
COPY --chown=vscode:vscode doc/ /app/doc/
4848

49-
# Install PyRIT and create build info (combined to ensure dependencies are available)
49+
# Install PyRIT and create build info (combined to ensure dependencies are available).
50+
# For PYRIT_SOURCE=pypi we also delete the local pyrit/ + packaging files copied
51+
# above so they don't shadow the installed wheel: WORKDIR is /app, so otherwise
52+
# `python -m pyrit.*` would import the local source — which is how the
53+
# missing-alembic crash on Test GUI (PyPI) happens (local source uses alembic
54+
# but PyPI <=0.13.0 doesn't depend on it). The rm mirrors the COPY block above
55+
# (lines 43-47) one-to-one, except /app/doc which is intentionally retained
56+
# because the later RUN block copies it into /app/notebooks/ for Jupyter mode.
5057
# Note: We use 'uv pip' because the devcontainer creates venv with uv (no pip by default)
5158
RUN if [ "$PYRIT_SOURCE" = "pypi" ]; then \
5259
echo "Installing PyRIT from PyPI version: $PYRIT_VERSION"; \
5360
uv pip install --python /opt/venv/bin/python pyrit[speech,opencv,fairness_bias,fastapi,playwright]==$PYRIT_VERSION; \
61+
echo "Removing local source so the installed PyPI package isn't shadowed"; \
62+
rm -rf /app/pyrit /app/frontend /app/build_scripts /app/pyproject.toml /app/MANIFEST.in /app/README.md /app/LICENSE; \
5463
elif [ "$PYRIT_SOURCE" = "local" ]; then \
5564
echo "Installing PyRIT from local source"; \
5665
uv pip install --python /opt/venv/bin/python -e .[speech,opencv,fairness_bias,fastapi,playwright]; \

docker/start.sh

Lines changed: 17 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -80,7 +80,23 @@ elif [ "$PYRIT_MODE" = "gui" ]; then
8080
fi
8181
} >"$RUNTIME_CONFIG"
8282

83-
exec python -m pyrit.backend.pyrit_backend \
83+
# Pick the launcher module. PR #1753 moved the launcher from
84+
# ``pyrit.cli.pyrit_backend`` to ``pyrit.backend.pyrit_backend``. The PyPI
85+
# docker_build CI job pins to whatever's currently published (0.13.0 at
86+
# time of writing), which still uses the old path, so fall back to it when
87+
# the new module isn't present. Once a release containing the new layout
88+
# ships, this fallback is dead code and can be removed.
89+
if python -c "import pyrit.backend.pyrit_backend" >/dev/null 2>&1; then
90+
BACKEND_MODULE="pyrit.backend.pyrit_backend"
91+
elif python -c "import pyrit.cli.pyrit_backend" >/dev/null 2>&1; then
92+
echo "Using legacy pyrit.cli.pyrit_backend launcher (PyRIT <= 0.13.0)"
93+
BACKEND_MODULE="pyrit.cli.pyrit_backend"
94+
else
95+
echo "ERROR: cannot find pyrit backend launcher module" >&2
96+
exit 1
97+
fi
98+
99+
exec python -m "$BACKEND_MODULE" \
84100
--host 0.0.0.0 \
85101
--port 8000 \
86102
--config-file "$RUNTIME_CONFIG"

pyrit/datasets/seed_datasets/remote/coconot_dataset.py

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -151,6 +151,16 @@ async def fetch_dataset_async(self, *, cache: bool = True) -> SeedDataset:
151151
category = row.get("category")
152152
if wanted_categories is not None and category not in wanted_categories:
153153
continue
154+
# The upstream HF dataset contains a small number of rows with an
155+
# empty ``prompt`` (observed in original.train under the wildchats
156+
# subcategory). SeedObjective enforces value != "" downstream, so
157+
# skip them here to keep the loader resilient to upstream drift.
158+
if not (row.get("prompt") or "").strip():
159+
logger.warning(
160+
f"Skipping CoCoNot row with empty prompt "
161+
f"(id={row.get('id')!r}, category={category!r}, split={split!r})"
162+
)
163+
continue
154164
seeds.append(self._row_to_seed(row=row, split=split, source_url=source_url))
155165

156166
if not seeds:

pyrit/executor/attack/multi_turn/crescendo.py

Lines changed: 28 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55

66
import json
77
import logging
8+
import re
89
from dataclasses import dataclass
910
from pathlib import Path
1011
from typing import TYPE_CHECKING, Any, Optional, Union, cast
@@ -568,6 +569,12 @@ def _parse_adversarial_response(self, response_text: str) -> str:
568569
"""
569570
Parse and validate the JSON response from the adversarial chat.
570571
572+
camelCase keys are normalized to snake_case before validation. The
573+
Crescendo system prompts specify a snake_case JSON schema, but some
574+
backends drift to camelCase (``generatedQuestion`` instead of
575+
``generated_question``); accepting both prevents the attack from
576+
burning all its retries on a casing mismatch.
577+
571578
Args:
572579
response_text (str): The response text to parse.
573580
@@ -582,25 +589,41 @@ def _parse_adversarial_response(self, response_text: str) -> str:
582589
try:
583590
parsed_output = json.loads(response_text)
584591

585-
# Check for required keys
586-
missing_keys = expected_keys - set(parsed_output.keys())
592+
normalized_output = {self._camel_to_snake(key): value for key, value in parsed_output.items()}
593+
594+
missing_keys = expected_keys - set(normalized_output.keys())
587595
if missing_keys:
588596
raise InvalidJsonException(
589597
message=f"Missing required keys {missing_keys} in JSON response: {response_text}"
590598
)
591599

592-
# Check for unexpected keys
593-
extra_keys = set(parsed_output.keys()) - expected_keys
600+
extra_keys = set(normalized_output.keys()) - expected_keys
594601
if extra_keys:
595602
raise InvalidJsonException(
596603
message=f"Unexpected keys {extra_keys} found in JSON response: {response_text}"
597604
)
598605

599-
return str(parsed_output["generated_question"])
606+
return str(normalized_output["generated_question"])
600607

601608
except json.JSONDecodeError as e:
602609
raise InvalidJsonException(message=f"Invalid JSON encountered: {response_text}") from e
603610

611+
@staticmethod
612+
def _camel_to_snake(name: str) -> str:
613+
"""
614+
Convert a ``camelCase`` or ``PascalCase`` identifier to ``snake_case``.
615+
616+
Existing snake_case identifiers are returned unchanged.
617+
618+
Args:
619+
name (str): The identifier to convert.
620+
621+
Returns:
622+
str: The snake_case form of ``name``.
623+
"""
624+
intermediate = re.sub(r"(.)([A-Z][a-z]+)", r"\1_\2", name)
625+
return re.sub(r"([a-z0-9])([A-Z])", r"\1_\2", intermediate).lower()
626+
604627
async def _send_prompt_to_objective_target_async(
605628
self,
606629
*,

tests/unit/datasets/test_coconot_dataset.py

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -213,6 +213,53 @@ def test_invalid_split_raises(self) -> None:
213213
with pytest.raises(ValueError, match="Expected CoCoNotSplit"):
214214
_CoCoNotRefusalDataset(splits=[CoCoNotCategory.SAFETY]) # type: ignore[ty:invalid-argument-type]
215215

216+
async def test_rows_with_empty_prompts_are_skipped(self) -> None:
217+
"""Upstream rows with empty/whitespace ``prompt`` are dropped, not turned into empty seeds.
218+
219+
Regression test for the end_to_end ``test_fetch_dataset[_CoCoNotRefusalDataset]``
220+
failure, where an empty-prompt row in ``original.train`` (wildchats subcategory)
221+
produced a SeedObjective with ``value=""`` and tripped the loader-wide
222+
``seed.value`` invariant.
223+
"""
224+
loader = _CoCoNotRefusalDataset(splits=[CoCoNotSplit.TRAIN])
225+
rows_with_empty = [
226+
{
227+
"id": "ok",
228+
"prompt": "real prompt",
229+
"response": "",
230+
"category": "Indeterminate requests",
231+
"subcategory": "fine",
232+
},
233+
{
234+
"id": "empty",
235+
"prompt": "",
236+
"response": "",
237+
"category": "Indeterminate requests",
238+
"subcategory": "wildchats",
239+
},
240+
{
241+
"id": "whitespace",
242+
"prompt": " ",
243+
"response": "",
244+
"category": "Indeterminate requests",
245+
"subcategory": "wildchats",
246+
},
247+
{
248+
"id": "missing",
249+
"response": "",
250+
"category": "Indeterminate requests",
251+
"subcategory": "wildchats",
252+
},
253+
]
254+
with patch.object(loader, "_fetch_from_huggingface", new=AsyncMock(return_value=rows_with_empty)):
255+
dataset = await loader.fetch_dataset_async()
256+
257+
assert len(dataset.seeds) == 1
258+
kept = dataset.seeds[0]
259+
assert kept.value == "real prompt"
260+
assert kept.metadata is not None
261+
assert kept.metadata["id"] == "ok"
262+
216263

217264
class TestCoCoNotContrastDataset:
218265
"""Tests for the CoCoNot contrast (over-refusal) sibling (`contrast.test`)."""

tests/unit/executor/attack/multi_turn/test_crescendo.py

Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -931,6 +931,73 @@ async def test_parse_adversarial_response_with_various_inputs(
931931
result = attack._parse_adversarial_response(response_json)
932932
assert isinstance(result, str)
933933

934+
@pytest.mark.parametrize(
935+
"raw,expected",
936+
[
937+
("generated_question", "generated_question"),
938+
("generatedQuestion", "generated_question"),
939+
("GeneratedQuestion", "generated_question"),
940+
("rationaleBehindJailbreak", "rationale_behind_jailbreak"),
941+
("lastResponseSummary", "last_response_summary"),
942+
("", ""),
943+
],
944+
)
945+
def test_camel_to_snake_handles_common_cases(self, raw: str, expected: str) -> None:
946+
"""``_camel_to_snake`` normalizes camelCase / PascalCase and leaves snake_case alone."""
947+
assert CrescendoAttack._camel_to_snake(raw) == expected
948+
949+
def test_parse_adversarial_response_accepts_camel_case_keys(
950+
self,
951+
mock_objective_target: MagicMock,
952+
mock_adversarial_chat: MagicMock,
953+
) -> None:
954+
"""camelCase keys are normalized to snake_case so well-formed JSON with the wrong casing still parses.
955+
956+
Regression test for the Azure DevOps Integration Tests failure on
957+
``4_sequential_attack.ipynb``, where the adversarial model returned
958+
``generatedQuestion`` / ``rationaleBehindJailbreak`` /
959+
``lastResponseSummary`` for three retries straight and the strict
960+
snake_case-only parser tore down the run.
961+
"""
962+
attack = CrescendoTestHelper.create_attack(
963+
objective_target=mock_objective_target,
964+
adversarial_chat=mock_adversarial_chat,
965+
)
966+
camel_case_response = (
967+
'{"generatedQuestion": "Attack question", '
968+
'"lastResponseSummary": "Summary text", '
969+
'"rationaleBehindJailbreak": "Why this works"}'
970+
)
971+
972+
result = attack._parse_adversarial_response(camel_case_response)
973+
974+
assert result == "Attack question"
975+
976+
def test_parse_adversarial_response_mixed_casing_still_validates_extras(
977+
self,
978+
mock_objective_target: MagicMock,
979+
mock_adversarial_chat: MagicMock,
980+
) -> None:
981+
"""Extra keys remain rejected even after camelCase normalization.
982+
983+
``unexpectedKey`` normalizes to ``unexpected_key`` (still not in the
984+
expected set), so the strict extra-key check continues to fire — we
985+
only loosen casing, not the schema.
986+
"""
987+
attack = CrescendoTestHelper.create_attack(
988+
objective_target=mock_objective_target,
989+
adversarial_chat=mock_adversarial_chat,
990+
)
991+
response_with_extra = (
992+
'{"generatedQuestion": "Attack", '
993+
'"lastResponseSummary": "Summary", '
994+
'"rationaleBehindJailbreak": "Rationale", '
995+
'"unexpectedKey": "value"}'
996+
)
997+
998+
with pytest.raises(InvalidJsonException, match="Unexpected keys"):
999+
attack._parse_adversarial_response(response_with_extra)
1000+
9341001
async def test_custom_message_is_sent_to_target(
9351002
self,
9361003
mock_objective_target: MagicMock,

0 commit comments

Comments
 (0)