From 32095c5f52423e53d46f93fe49a63577fc613179 Mon Sep 17 00:00:00 2001 From: longwind48 Date: Fri, 28 Aug 2026 11:13:28 +0800 Subject: [PATCH] fix(guardrails): enforce allowlists on built-in responses tools Built-in server-side tools such as web_search carry no name of their own, only a type, so the Responses tool-name extractor returned nothing for them. Both consumers of that extractor, the key/team allowed_tools check in auth and ToolPolicyGuardrail, are name-based, so a key with a restrictive allowlist or a default-deny tool policy could still send web_search, reach the internet, and bill for it. Surface built-in tools under their type, matching what the Anthropic messages path already does with its flat tools[].name. A missing function or custom name still yields nothing, so "function" can never stand in for the real tool. --- .../guardrail_translation/handler.py | 29 ++++++---- .../proxy/test_tools_allowlist_enforcement.py | 53 +++++++++++++++++++ 2 files changed, 71 insertions(+), 11 deletions(-) diff --git a/litellm/llms/openai/responses/guardrail_translation/handler.py b/litellm/llms/openai/responses/guardrail_translation/handler.py index 7c5d8ac99ad..c119e490ecd 100644 --- a/litellm/llms/openai/responses/guardrail_translation/handler.py +++ b/litellm/llms/openai/responses/guardrail_translation/handler.py @@ -216,18 +216,25 @@ class OpenAIResponsesHandler(BaseTranslation): return data + @staticmethod + def _request_tool_name(tool: object) -> str | None: + """The name a Responses request tool acts under: ``name`` for function and custom, + ``server_label`` for mcp, and the bare ``type`` for built-in server-side tools + (web_search, code_interpreter, ...) that carry no name of their own. Those bill and + reach the internet, so allowlist checks must see them under some name.""" + if not isinstance(tool, dict): + return None + tool_type: Final = tool.get("type") + if tool_type in ("function", "custom"): + name: Final = tool.get("name") + return str(name) if name else None + if tool_type == "mcp": + server_label: Final = tool.get("server_label") + return str(server_label) if server_label else None + return str(tool_type) if tool_type else None + 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: Final[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 + return [name for tool in data.get("tools") or [] if (name := self._request_tool_name(tool)) is not 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 3e9c7c14b95..431cc4dc3cd 100644 --- a/tests/test_litellm/proxy/test_tools_allowlist_enforcement.py +++ b/tests/test_litellm/proxy/test_tools_allowlist_enforcement.py @@ -86,6 +86,31 @@ class TestExtractRequestToolNames: "get_current_weather", ] + def test_openai_responses_builtin_tools(self): + """Built-in server-side tools carry no name of their own, so they act under their + type; without that a restricted key could still reach the internet and bill for + web_search while its allowlist named nothing of the sort (VERIA finding on PR #37995).""" + data = { + "tools": [ + {"type": "web_search"}, + {"type": "code_interpreter", "container": {"type": "auto"}}, + {"type": "function", "name": "get_current_weather"}, + {"type": "mcp", "server_label": "dmcp", "server_url": "http://x"}, + ] + } + assert extract_request_tool_names("/v1/responses", data) == [ + "web_search", + "code_interpreter", + "get_current_weather", + "dmcp", + ] + + def test_openai_responses_unnamed_tool_yields_no_name(self): + """A function or custom tool missing its name must not fall back to the bare type: + that would let "function" satisfy an allowlist that never granted the real tool.""" + data = {"tools": [{"type": "function"}, {"type": "custom", "name": ""}, {"type": "mcp"}, "junk"]} + assert extract_request_tool_names("/v1/responses", data) == [] + def test_anthropic_tools(self): data = {"tools": [{"name": "get_weather"}, {"name": "run_sql"}]} assert extract_request_tool_names("/v1/messages", data) == [ @@ -227,6 +252,34 @@ 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_builtin_web_search_raises_on_responses_route(self): + token = _token(metadata={"allowed_tools": ["run_sql"]}) + body = {"tools": [{"type": "web_search"}]} + 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 "web_search" in str(exc_info.value.message) + + @pytest.mark.asyncio + async def test_allowlisted_builtin_web_search_passes_on_responses_route(self): + token = _token(metadata={"allowed_tools": ["web_search", "run_sql"]}) + tools = [{"type": "web_search"}, {"type": "function", "name": "run_sql"}] + body = {"tools": tools} + assert extract_request_tool_names("/v1/responses", body) == ["web_search", "run_sql"] + await check_tools_allowlist( + request_body=body, + valid_token=token, + team_object=None, + route="/v1/responses", + ) + assert body["tools"] == tools + @pytest.mark.asyncio async def test_team_allowlist_used_when_key_empty(self): token = _token(