mirror of
https://github.com/BerriAI/litellm.git
synced 2026-08-28 05:25:59 +00:00
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.
This commit is contained in:
parent
35126212d5
commit
32095c5f52
2 changed files with 71 additions and 11 deletions
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue