From e2164d7b6bc520983db6d988a18b490303ef8d30 Mon Sep 17 00:00:00 2001 From: Copilot <223556219+Copilot@users.noreply.github.com> Date: Tue, 21 Jul 2026 21:33:28 -0700 Subject: [PATCH] FIX: Reject non-object adversarial JSON replies Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: cf88d9a1-35b5-4edf-8bd3-c9d5fc82c174 --- .../adversarial_conversation_manager.py | 9 +++++-- .../test_adversarial_conversation_manager.py | 27 +++++++++++++++++++ 2 files changed, 34 insertions(+), 2 deletions(-) diff --git a/pyrit/executor/attack/component/adversarial_conversation_manager.py b/pyrit/executor/attack/component/adversarial_conversation_manager.py index 94a281c65a..a8f766ac16 100644 --- a/pyrit/executor/attack/component/adversarial_conversation_manager.py +++ b/pyrit/executor/attack/component/adversarial_conversation_manager.py @@ -235,8 +235,8 @@ def _parse_adversarial_reply(response_text: str, *, schema: JsonSchemaDefinition AdversarialReply: The parsed message and reasoning fields. Raises: - InvalidJsonException: If the reply is not valid JSON, is missing a required key, carries a - key the schema forbids, or omits ``next_message``. + InvalidJsonException: If the reply is not valid JSON, does not decode to an object, is + missing a required key, carries a key the schema forbids, or omits ``next_message``. """ cleaned = remove_markdown_json(response_text) try: @@ -244,6 +244,11 @@ def _parse_adversarial_reply(response_text: str, *, schema: JsonSchemaDefinition except json.JSONDecodeError as e: raise InvalidJsonException(message=f"Invalid JSON encountered: {cleaned}") from e + if not isinstance(parsed, dict): + raise InvalidJsonException( + message=(f"Adversarial-chat reply must be a JSON object, but decoded to {type(parsed).__name__}: {cleaned}") + ) + normalized = {_camel_to_snake(key): value for key, value in parsed.items()} if schema is not None: diff --git a/tests/unit/executor/attack/component/test_adversarial_conversation_manager.py b/tests/unit/executor/attack/component/test_adversarial_conversation_manager.py index 29cd8943a7..edddd5c34e 100644 --- a/tests/unit/executor/attack/component/test_adversarial_conversation_manager.py +++ b/tests/unit/executor/attack/component/test_adversarial_conversation_manager.py @@ -171,6 +171,16 @@ def test_parse_reply_invalid_json_raises(): _parse_adversarial_reply("not json at all", schema=SCHEMA) +@pytest.mark.parametrize( + "response_text", + ["[]", "null", '"text"', "42"], + ids=["array", "null", "string", "number"], +) +def test_parse_reply_non_object_json_raises(response_text: str) -> None: + with pytest.raises(InvalidJsonException, match="must be a JSON object"): + _parse_adversarial_reply(response_text, schema=SCHEMA) + + def test_parse_reply_missing_key_raises(): with pytest.raises(InvalidJsonException, match="Missing required keys"): _parse_adversarial_reply('{"next_message": "hi", "rationale": "r"}', schema=SCHEMA) @@ -539,6 +549,23 @@ async def test_invalid_reply_raises(self): with pytest.raises(InvalidJsonException): await manager.get_next_message_async(turn_index=1, last_response=_response_message()) + async def test_non_object_reply_retries_then_succeeds(self) -> None: + normalizer = _normalizer(None) + normalizer.send_prompt_async.side_effect = [ + Message.from_prompt(prompt="[]", role="assistant"), + Message.from_prompt(prompt=VALID_JSON, role="assistant"), + ] + manager = _manager( + adversarial_system_prompt=_system_prompt(schema=SCHEMA), + prompt_normalizer=normalizer, + ) + + turn = await manager.get_next_message_async(turn_index=1, last_response=_response_message()) + + assert turn.reply is not None + assert turn.reply.next_message == "hello target" + assert normalizer.send_prompt_async.call_count == 2 + # --- get_next_message_async: bypass path -------------------------------------