From 3a40833b005f42a3575018fca420fadd60857393 Mon Sep 17 00:00:00 2001 From: Lavkesh Dwivedi <9712103+lavkeshdwivedi@users.noreply.github.com> Date: Mon, 6 Jul 2026 22:52:38 -0500 Subject: [PATCH 1/4] fix(detectors): guard against non-string output.text in AttackRogueString When a REST generator returns a structured JSON response, output.text can be a dict rather than a str, causing: AttributeError: 'dict' object has no attribute 'lower' at the trigger.lower() / output_text.lower() call in detect(). Add an isinstance guard that coerces non-str output_text to str before the case-folding and substring check, matching the defensive pattern used elsewhere in the codebase. Fixes #1888. Signed-off-by: Lavkesh Dwivedi <9712103+lavkeshdwivedi@users.noreply.github.com> Co-Authored-By: Claude Sonnet 4.6 --- garak/detectors/promptinject.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/garak/detectors/promptinject.py b/garak/detectors/promptinject.py index 8e322d85c..378ff9812 100644 --- a/garak/detectors/promptinject.py +++ b/garak/detectors/promptinject.py @@ -31,6 +31,8 @@ def detect(self, attempt: Attempt) -> List[float]: results.append(None) continue output_text = output.text + if not isinstance(output_text, str): + output_text = str(output_text) score = 0.0 From 3ef3280fefcec5272ff550ce90545ab4ab5ec8ff Mon Sep 17 00:00:00 2001 From: Lavkesh Dwivedi <9712103+lavkeshdwivedi@users.noreply.github.com> Date: Tue, 7 Jul 2026 08:41:56 -0500 Subject: [PATCH 2/4] fix(generators): coerce non-string REST response fields to str in RestGenerator MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Per maintainer review: the correct fix for dict-valued output.text is in the REST generator, not the detector. Message.text is contractually str|None; RestGenerator violated this when response_json_field resolved to a non-string JSON value (e.g. a nested object). Coerce at the point of Message construction so all downstream consumers (detectors, scorers) always receive str|None from Message.text. Reverts the isinstance guard added to detectors/promptinject.py — now unnecessary since the generator upholds the Message.text contract. Fixes #1888. Signed-off-by: Lavkesh Dwivedi <9712103+lavkeshdwivedi@users.noreply.github.com> Co-Authored-By: Claude Sonnet 4.6 --- garak/detectors/promptinject.py | 3 --- garak/generators/rest.py | 2 +- 2 files changed, 1 insertion(+), 4 deletions(-) diff --git a/garak/detectors/promptinject.py b/garak/detectors/promptinject.py index 378ff9812..7a1cd3ce1 100644 --- a/garak/detectors/promptinject.py +++ b/garak/detectors/promptinject.py @@ -31,9 +31,6 @@ def detect(self, attempt: Attempt) -> List[float]: results.append(None) continue output_text = output.text - if not isinstance(output_text, str): - output_text = str(output_text) - score = 0.0 for trigger in triggers: diff --git a/garak/generators/rest.py b/garak/generators/rest.py index 303369bf2..b7b9f50a2 100644 --- a/garak/generators/rest.py +++ b/garak/generators/rest.py @@ -403,7 +403,7 @@ def _call_model( ) return [None] - return [Message(r) for r in response] + return [Message(str(r) if r is not None and not isinstance(r, str) else r) for r in response] class _MtlsAdapter(requests.adapters.HTTPAdapter): From d201b80abb3cec039d587e374b6e5fc9408f818d Mon Sep 17 00:00:00 2001 From: Lavkesh Dwivedi Date: Tue, 7 Jul 2026 16:48:50 -0500 Subject: [PATCH 3/4] test(generators/rest): add regression tests for non-string response coercion Adds two tests to validate that _get_response() coerces non-string values (int, float, bool) to str before wrapping in Message objects: - test_json_rest_non_string_response_coerced_to_str: parametrized scalar cases - test_json_rest_non_string_list_coerced_to_str: multi-value list from JSONPath Signed-off-by: Lavkesh Dwivedi Co-Authored-By: Claude Sonnet 4.6 --- tests/generators/test_rest.py | 39 +++++++++++++++++++++++++++++++++++ 1 file changed, 39 insertions(+) diff --git a/tests/generators/test_rest.py b/tests/generators/test_rest.py index 68798887a..6a9612416 100644 --- a/tests/generators/test_rest.py +++ b/tests/generators/test_rest.py @@ -500,3 +500,42 @@ def test_rest_mtls_pickle_roundtrip(real_mtls_cert_files): assert isinstance( adapter, _MtlsAdapter ), "Reconstructed session must have an _MtlsAdapter mounted on 'https://'" + + +@pytest.mark.usefixtures("set_rest_config") +@pytest.mark.parametrize( + "value,expected_text", + [ + (42, "42"), + (3.14, "3.14"), + (True, "True"), + ], +) +def test_json_rest_non_string_response_coerced_to_str(requests_mock, value, expected_text): + # regression: non-string values from response_json_field must be str()-coerced + # before being wrapped in Message (Message.text is typed str | None) + requests_mock.post( + DEFAULT_URI, + text=json.dumps({"result": value}, ensure_ascii=False), + ) + _config.plugins.generators["rest"]["RestGenerator"]["response_json"] = True + _config.plugins.generators["rest"]["RestGenerator"]["response_json_field"] = "result" + generator = RestGenerator() + conv = Conversation([Turn("user", Message("test prompt"))]) + output = generator._call_model(conv) + assert output == [Message(expected_text)] + + +@pytest.mark.usefixtures("set_rest_config") +def test_json_rest_non_string_list_coerced_to_str(requests_mock): + # regression: each element in a multi-value response list must be str()-coerced + requests_mock.post( + DEFAULT_URI, + text=json.dumps({"scores": [1, 2, 3]}, ensure_ascii=False), + ) + _config.plugins.generators["rest"]["RestGenerator"]["response_json"] = True + _config.plugins.generators["rest"]["RestGenerator"]["response_json_field"] = "$.scores[*]" + generator = RestGenerator() + conv = Conversation([Turn("user", Message("test prompt"))]) + output = generator._call_model(conv) + assert output == [Message("1"), Message("2"), Message("3")] From a8ecff631010b0c78a55aab4b35d38e4542ff3f7 Mon Sep 17 00:00:00 2001 From: Lavkesh Dwivedi Date: Wed, 8 Jul 2026 18:22:55 -0500 Subject: [PATCH 4/4] test(generators/rest): add dict regression case to non-string coercion test The original crash from #1888 was a dict-valued output.text triggering AttributeError on .lower(). Add {"nested": "dict"} to the parametrized test so the exact failure type is covered, and sharpen the comment to explain why it exists. --- tests/generators/test_rest.py | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/tests/generators/test_rest.py b/tests/generators/test_rest.py index 6a9612416..933de181e 100644 --- a/tests/generators/test_rest.py +++ b/tests/generators/test_rest.py @@ -509,11 +509,13 @@ def test_rest_mtls_pickle_roundtrip(real_mtls_cert_files): (42, "42"), (3.14, "3.14"), (True, "True"), + ({"nested": "dict"}, "{'nested': 'dict'}"), ], ) def test_json_rest_non_string_response_coerced_to_str(requests_mock, value, expected_text): - # regression: non-string values from response_json_field must be str()-coerced - # before being wrapped in Message (Message.text is typed str | None) + # regression for #1888: non-string values from response_json_field must be + # str()-coerced before being wrapped in Message (Message.text is typed str | None). + # The dict case is the original crash: AttackRogueString called .lower() on a dict. requests_mock.post( DEFAULT_URI, text=json.dumps({"result": value}, ensure_ascii=False),