From ab912c963dcac93f5849c5958a65aa7b910ba234 Mon Sep 17 00:00:00 2001 From: mateo-berri <277851410+mateo-berri@users.noreply.github.com> Date: Mon, 20 Jul 2026 20:33:25 -0700 Subject: [PATCH] fix(guardrails): extract tool names from additional_tools input items on Responses API --- .../guardrail_translation/handler.py | 31 ++++++---- .../proxy/test_tools_allowlist_enforcement.py | 56 +++++++++++++++++++ 2 files changed, 77 insertions(+), 10 deletions(-) diff --git a/litellm/llms/openai/responses/guardrail_translation/handler.py b/litellm/llms/openai/responses/guardrail_translation/handler.py index d90703d1544..996fe8df463 100644 --- a/litellm/llms/openai/responses/guardrail_translation/handler.py +++ b/litellm/llms/openai/responses/guardrail_translation/handler.py @@ -198,16 +198,27 @@ class OpenAIResponsesHandler(BaseTranslation): def extract_request_tool_names(self, data: dict) -> List[str]: """Extract tool names from Responses API request (tools[].name for function - and custom, tools[].server_label for mcp).""" - names: List[str] = [] - for tool in data.get("tools") or []: - if not isinstance(tool, dict): - continue - if tool.get("type") in ("function", "custom") and tool.get("name"): - names.append(str(tool["name"])) - elif tool.get("type") == "mcp" and tool.get("server_label"): - names.append(str(tool["server_label"])) - return names + and custom, tools[].server_label for mcp), including tools nested in + additional_tools input items (Codex CLI ships its tool definitions there).""" + input_items = data.get("input") + additional_tools = ( + tool + for item in (input_items if isinstance(input_items, list) else ()) + if isinstance(item, dict) and item.get("type") == "additional_tools" + for tool in (item.get("tools") or ()) + ) + names = (self._responses_tool_name(tool) for tool in (*(data.get("tools") or ()), *additional_tools)) + return [name for name in names if name] + + @staticmethod + def _responses_tool_name(tool: object) -> str | None: + if not isinstance(tool, dict): + return None + if tool.get("type") in ("function", "custom") and tool.get("name"): + return str(tool["name"]) + if tool.get("type") == "mcp" and tool.get("server_label"): + return str(tool["server_label"]) + return None def _extract_and_transform_tools( self, diff --git a/tests/test_litellm/proxy/test_tools_allowlist_enforcement.py b/tests/test_litellm/proxy/test_tools_allowlist_enforcement.py index 31f5fbf606b..7f160c8d75f 100644 --- a/tests/test_litellm/proxy/test_tools_allowlist_enforcement.py +++ b/tests/test_litellm/proxy/test_tools_allowlist_enforcement.py @@ -86,6 +86,40 @@ class TestExtractRequestToolNames: "get_current_weather", ] + def test_openai_responses_additional_tools_input_items(self): + """Codex CLI 0.143+ ships tool definitions inside an additional_tools + input item instead of (or alongside) top-level tools; those nested tools + must be extracted too or a restricted key could smuggle a disallowed + tool past allowlist enforcement (VERIA finding on PR #33228).""" + data = { + "tools": [{"type": "function", "name": "get_current_weather"}], + "input": [ + {"role": "user", "content": "hi"}, + { + "type": "additional_tools", + "role": "developer", + "tools": [ + {"type": "custom", "name": "exec", "description": "x"}, + {"type": "mcp", "server_label": "dmcp", "server_url": "http://x"}, + ], + }, + ], + } + assert extract_request_tool_names("/v1/responses", data) == [ + "get_current_weather", + "exec", + "dmcp", + ] + + def test_openai_responses_string_input_ignored(self): + data = { + "tools": [{"type": "function", "name": "get_current_weather"}], + "input": "hi", + } + assert extract_request_tool_names("/v1/responses", data) == [ + "get_current_weather" + ] + def test_anthropic_tools(self): data = {"tools": [{"name": "get_weather"}, {"name": "run_sql"}]} assert extract_request_tool_names("/v1/messages", data) == [ @@ -173,6 +207,28 @@ class TestCheckToolsAllowlist: assert exc_info.value.type == ProxyErrorTypes.tool_access_denied assert "restricted_tool" in str(exc_info.value.message) + @pytest.mark.asyncio + async def test_disallowed_additional_tools_input_item_raises_on_responses_route(self): + token = _token(metadata={"allowed_tools": ["other_tool"]}) + body = { + "input": [ + { + "type": "additional_tools", + "role": "developer", + "tools": [{"type": "custom", "name": "exec"}], + }, + ], + } + with pytest.raises(ProxyException) as exc_info: + await check_tools_allowlist( + request_body=body, + valid_token=token, + team_object=None, + route="/v1/responses", + ) + assert exc_info.value.type == ProxyErrorTypes.tool_access_denied + assert "exec" in str(exc_info.value.message) + @pytest.mark.asyncio async def test_team_allowlist_used_when_key_empty(self): token = _token(