fix(deepkeep): apply guardrail tool/tool_call redactions from API response

When DeepKeep returns GUARDRAIL_INTERVENED with redacted tools or
tool_calls, the previous code ignored those redactions and forwarded
the original (potentially sensitive) values to the model — a guardrail
bypass for content embedded in tool schemas or function arguments.

Fix: prefer response_json["tools"] / response_json["tool_calls"] when
present, falling back to the originals only when the guardrail did not
return replacements — consistent with the existing pattern for texts and
images.

Refactor _build_return_inputs() into a private static helper to keep
apply_guardrail() under the PLR0915 statement limit (50).

Adds test_apply_guardrail_applies_tool_redactions_from_response to
assert that redacted tool payloads from the API response are used.
This commit is contained in:
Yaniv Israel 2026-06-15 22:04:52 +03:00
parent 36f95d8d9f
commit 82c6b2f181
2 changed files with 93 additions and 15 deletions

View file

@ -238,6 +238,36 @@ class DeepKeepGuardrail(CustomGuardrail):
verbose_proxy_logger.error("DeepKeep guardrail API error: %s", str(error))
raise DeepKeepGuardrailAPIError(f"DeepKeep guardrail API failed: {str(error)}")
@staticmethod
def _build_return_inputs(
*,
response_json: Dict[str, Any],
texts: list,
images: Optional[Any],
tools: Optional[Any],
tool_calls: Optional[Any],
structured_messages: Optional[Any],
) -> GenericGuardrailAPIInputs:
"""Merge original inputs with any guardrail-modified values from the API response."""
return_inputs = GenericGuardrailAPIInputs(texts=texts)
if response_json.get("texts"):
return_inputs["texts"] = response_json["texts"]
if response_json.get("images"):
return_inputs["images"] = response_json["images"]
elif images:
return_inputs["images"] = images
if response_json.get("tools"):
return_inputs["tools"] = response_json["tools"]
elif tools:
return_inputs["tools"] = tools
if response_json.get("tool_calls"):
return_inputs["tool_calls"] = response_json["tool_calls"]
elif tool_calls:
return_inputs["tool_calls"] = tool_calls
if structured_messages:
return_inputs["structured_messages"] = structured_messages
return return_inputs
@log_guardrail_information
async def apply_guardrail(
self,
@ -336,21 +366,14 @@ class DeepKeepGuardrail(CustomGuardrail):
should_wrap_with_default_message=False,
)
# Build return inputs – apply any modifications from GUARDRAIL_INTERVENED
return_inputs = GenericGuardrailAPIInputs(texts=texts)
if response_json.get("texts"):
return_inputs["texts"] = response_json["texts"]
if response_json.get("images"):
return_inputs["images"] = response_json["images"]
elif images:
return_inputs["images"] = images
if tools:
return_inputs["tools"] = tools
if tool_calls:
return_inputs["tool_calls"] = tool_calls
if structured_messages:
return_inputs["structured_messages"] = structured_messages
return return_inputs
return self._build_return_inputs(
response_json=response_json,
texts=texts,
images=images,
tools=tools,
tool_calls=tool_calls,
structured_messages=structured_messages,
)
except GuardrailRaisedException:
raise

View file

@ -529,6 +529,61 @@ class TestDeepKeepGuardrail:
assert result["tool_calls"] == sample_tool_calls
assert result["structured_messages"] == sample_structured
@pytest.mark.asyncio
async def test_apply_guardrail_applies_tool_redactions_from_response(self):
"""should use redacted tools/tool_calls from response when GUARDRAIL_INTERVENED returns them."""
guardrail = DeepKeepGuardrail(
api_key="test-key",
api_base="https://test.deepkeep.ai",
firewall_id="fw-123",
guardrail_name="test",
event_hook="pre_call",
)
redacted_tools = [{"type": "function", "function": {"name": "get_data", "description": "[REDACTED]"}}]
redacted_tool_calls = [{"id": "call_1", "type": "function", "function": {"name": "get_data", "arguments": "{}"}}]
mock_response = Response(
status_code=200,
json={
"action": "GUARDRAIL_INTERVENED",
"blocked_reason": None,
"texts": None,
"images": None,
"tools": redacted_tools,
"tool_calls": redacted_tool_calls,
},
request=Request(
"POST",
"https://test.deepkeep.ai/v3/openai/beta/litellm_basic_guardrail_api",
),
)
original_tools = [{"type": "function", "function": {"name": "get_data", "description": "sensitive info"}}]
original_tool_calls = [{"id": "call_1", "type": "function", "function": {"name": "get_data", "arguments": '{"secret": "value"}'}}]
with patch.object(
guardrail.async_handler,
"post",
new_callable=AsyncMock,
return_value=mock_response,
):
result = await guardrail.apply_guardrail(
inputs={
"texts": ["run the tool"],
"tools": original_tools,
"tool_calls": original_tool_calls,
},
request_data={"metadata": {}},
input_type="request",
)
# Redacted versions from the API response must be used, not the originals
assert result["tools"] == redacted_tools
assert result["tool_calls"] == redacted_tool_calls
assert result["tools"] != original_tools
assert result["tool_calls"] != original_tool_calls
@pytest.mark.asyncio
async def test_firewall_id_in_payload(self):
"""should include firewall_id in additional_provider_specific_params."""