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:
longwind48 2026-08-28 11:13:28 +08:00
parent 35126212d5
commit 32095c5f52
2 changed files with 71 additions and 11 deletions

View file

@ -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,

View file

@ -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(