diff --git a/litellm/llms/openai/responses/guardrail_translation/handler.py b/litellm/llms/openai/responses/guardrail_translation/handler.py index c119e490ecd..145ca05d6e5 100644 --- a/litellm/llms/openai/responses/guardrail_translation/handler.py +++ b/litellm/llms/openai/responses/guardrail_translation/handler.py @@ -233,8 +233,28 @@ class OpenAIResponsesHandler(BaseTranslation): return str(server_label) if server_label else None return str(tool_type) if tool_type else None + @staticmethod + def _tools_nested_in_input_item(item: object) -> tuple[object, ...]: + """Tools declared inside an ``additional_tools`` input item. Codex's responses-lite wire + mode ships tool definitions there instead of in top-level ``tools``, and providers hoist + them back out before dispatch, so reading only ``tools`` would miss them.""" + if not isinstance(item, dict) or item.get("type") != "additional_tools": + return () + tools: Final = item.get("tools") + return tuple(tools) if isinstance(tools, list) else () + def extract_request_tool_names(self, data: dict) -> list[str]: - return [name for tool in data.get("tools") or [] if (name := self._request_tool_name(tool)) is not None] + input_items: Final = data.get("input") + nested: Final = ( + tuple(tool for item in input_items for tool in self._tools_nested_in_input_item(item)) + if isinstance(input_items, list) + else () + ) + return [ + name + for tool in (*(data.get("tools") or []), *nested) + 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 431cc4dc3cd..85358417494 100644 --- a/tests/test_litellm/proxy/test_tools_allowlist_enforcement.py +++ b/tests/test_litellm/proxy/test_tools_allowlist_enforcement.py @@ -105,6 +105,41 @@ class TestExtractRequestToolNames: "dmcp", ] + def test_openai_responses_tools_nested_in_additional_tools_input_item(self): + """Codex's responses-lite wire mode declares tools inside an `additional_tools` input + item, and providers hoist them into top-level `tools` before dispatch. Reading only + `tools` would let a restricted key smuggle any tool through `input` + (VERIA finding on PR #37995).""" + data = { + "input": [ + {"type": "message", "role": "user", "content": "hi"}, + { + "type": "additional_tools", + "role": "developer", + "tools": [{"type": "web_search"}, {"type": "function", "name": "run_sql"}], + }, + ], + "tools": [{"type": "function", "name": "declared_up_front"}], + } + assert extract_request_tool_names("/v1/responses", data) == [ + "declared_up_front", + "web_search", + "run_sql", + ] + + def test_openai_responses_malformed_additional_tools_yields_no_name(self): + """An `additional_tools` item with a missing or non-list `tools` slot, and a plain string + input, must not raise on the auth hot path.""" + data = { + "input": [ + {"type": "additional_tools"}, + {"type": "additional_tools", "tools": "not-a-list"}, + "junk", + ] + } + assert extract_request_tool_names("/v1/responses", data) == [] + assert extract_request_tool_names("/v1/responses", {"input": "plain string"}) == [] + 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.""" @@ -280,6 +315,24 @@ class TestCheckToolsAllowlist: ) assert body["tools"] == tools + @pytest.mark.asyncio + async def test_disallowed_tool_nested_in_input_raises_on_responses_route(self): + token = _token(metadata={"allowed_tools": ["run_sql"]}) + body = { + "input": [ + {"type": "additional_tools", "role": "developer", "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_team_allowlist_used_when_key_empty(self): token = _token(