fix(deepkeep): honour empty-list replacements in _build_return_inputs

When DeepKeep returns GUARDRAIL_INTERVENED with an intentional empty
replacement (e.g. texts:[], tool_calls:[]) the previous truthiness check
treated [] as absent and forwarded the original content downstream —
a guardrail bypass for any case where the firewall wants to fully clear
a field.

Fix: replace all response_json.get(field) truthiness checks with
'is not None' comparisons so that an empty list is respected as a
deliberate replacement. Applies to texts, images, tools, tool_calls,
and the original-input fallback guards.

Adds test_apply_guardrail_honours_empty_list_replacements.
This commit is contained in:
Yaniv Israel 2026-06-18 17:04:24 +03:00
parent e9780752ea
commit 46e090224f
2 changed files with 65 additions and 8 deletions

View file

@ -248,23 +248,29 @@ class DeepKeepGuardrail(CustomGuardrail):
tool_calls: Any | None,
structured_messages: Any | None,
) -> GenericGuardrailAPIInputs:
"""Merge original inputs with any guardrail-modified values from the API response."""
"""Merge original inputs with any guardrail-modified values from the API response.
Presence is checked with ``is not None`` (not truthiness) so that an
intentional empty-list replacement such as ``texts: []`` or
``tool_calls: []`` is honoured and forwarded downstream rather than
silently discarded in favour of the original content.
"""
return_inputs = GenericGuardrailAPIInputs(texts=texts)
if response_json.get("texts"):
if response_json.get("texts") is not None:
return_inputs["texts"] = response_json["texts"]
if response_json.get("images"):
if response_json.get("images") is not None:
return_inputs["images"] = response_json["images"]
elif images:
elif images is not None:
return_inputs["images"] = images
if response_json.get("tools") is not None:
return_inputs["tools"] = response_json["tools"]
elif tools:
elif tools is not None:
return_inputs["tools"] = tools
if response_json.get("tool_calls"):
if response_json.get("tool_calls") is not None:
return_inputs["tool_calls"] = response_json["tool_calls"]
elif tool_calls:
elif tool_calls is not None:
return_inputs["tool_calls"] = tool_calls
if structured_messages:
if structured_messages is not None:
return_inputs["structured_messages"] = structured_messages
return return_inputs

View file

@ -584,6 +584,57 @@ class TestDeepKeepGuardrail:
assert result["tools"] != original_tools
assert result["tool_calls"] != original_tool_calls
@pytest.mark.asyncio
async def test_apply_guardrail_honours_empty_list_replacements(self):
"""Empty-list replacements from the API must clear the field, not fall back to originals."""
guardrail = DeepKeepGuardrail(
api_key="test-key",
api_base="https://test.deepkeep.ai",
firewall_id="fw-123",
guardrail_name="test",
event_hook="pre_call",
)
mock_response = Response(
status_code=200,
json={
"action": "GUARDRAIL_INTERVENED",
"blocked_reason": None,
# DeepKeep clears all content entirely
"texts": [],
"images": [],
"tools": [],
"tool_calls": [],
},
request=Request(
"POST",
"https://test.deepkeep.ai/v3/openai/beta/litellm_basic_guardrail_api",
),
)
with patch.object(
guardrail.async_handler,
"post",
new_callable=AsyncMock,
return_value=mock_response,
):
result = await guardrail.apply_guardrail(
inputs={
"texts": ["sensitive content that should be cleared"],
"tools": [{"type": "function", "function": {"name": "leak_data"}}],
"tool_calls": [{"id": "call_1", "type": "function"}],
"images": ["data:image/png;base64,abc"],
},
request_data={"metadata": {}},
input_type="request",
)
# Empty-list replacements must be used — not the original non-empty values
assert result["texts"] == []
assert result.get("images") == []
assert result.get("tools") == []
assert result.get("tool_calls") == []
@pytest.mark.asyncio
async def test_firewall_id_in_payload(self):
"""should include firewall_id in additional_provider_specific_params."""