From eba10185a026480bdd701151145f5d1ce8225a6b Mon Sep 17 00:00:00 2001 From: Acacian Date: Thu, 29 Oct 2026 01:08:05 +0900 Subject: [PATCH 1/2] fix(guardrails): extract Responses namespace and tool_search_output tools for allowlist checks extract_request_tool_names for /v1/responses read only flat function, custom and mcp tools. Codex groups MCP tools in a `namespace` tool, and with tool_search the deferred tools arrive only in an input[] tool_search_output, so a key/team allowed_tools list never saw them and let them through. Open namespace tools into their members and add the tools a tool_search_output in input[] loads, reusing the helpers the chat-path replay already uses. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01Pd88xuMVHt4chTrc8ZzAzv --- .../transformation.py | 11 +++ .../guardrail_translation/handler.py | 7 +- .../proxy/test_tools_allowlist_enforcement.py | 84 +++++++++++++++++-- 3 files changed, 94 insertions(+), 8 deletions(-) diff --git a/litellm/completion_extras/litellm_responses_transformation/transformation.py b/litellm/completion_extras/litellm_responses_transformation/transformation.py index bb59b4617af..1453a86d31d 100644 --- a/litellm/completion_extras/litellm_responses_transformation/transformation.py +++ b/litellm/completion_extras/litellm_responses_transformation/transformation.py @@ -411,6 +411,17 @@ def replayed_tool_search_tool_names(request: object) -> tuple[str, ...]: return tuple(chain.from_iterable(_tool_definition_name(tool) for tool in replayed_tool_search_tools(request))) +def with_namespace_members(tools: Sequence[object]) -> tuple[object, ...]: + """A Responses ``namespace`` tool stands for its member tools, as in a replayed search.""" + return tuple(chain.from_iterable(_namespace_members(tool) for tool in tools)) + + +def input_tool_search_tools(request: object) -> tuple[object, ...]: + """The tools a ``tool_search_output`` item in a Responses ``input`` loads.""" + items: Final = _tool_search_items_from_output_items(_json_field(request, "input")) + return with_namespace_members(tuple(chain.from_iterable(_loaded_tools(item) for item in items))) + + def _loads_any_tool(message: object, names: frozenset[str]) -> bool: loaded_names: Final = chain.from_iterable(_tool_definition_name(tool) for tool in _message_loaded_tools(message)) return any(name in names for name in loaded_names) diff --git a/litellm/llms/openai/responses/guardrail_translation/handler.py b/litellm/llms/openai/responses/guardrail_translation/handler.py index 559daf767c2..4ebe0f066ac 100644 --- a/litellm/llms/openai/responses/guardrail_translation/handler.py +++ b/litellm/llms/openai/responses/guardrail_translation/handler.py @@ -45,7 +45,9 @@ from litellm._logging import verbose_proxy_logger from litellm.completion_extras.litellm_responses_transformation.transformation import ( LiteLLMResponsesTransformationHandler, OpenAiResponsesToChatCompletionStreamIterator, + input_tool_search_tools, tool_call_dict_from_output_item, + with_namespace_members, ) from litellm.litellm_core_utils.core_helpers import has_attributes from litellm.llms.base_llm.guardrail_translation.base_translation import ( @@ -929,9 +931,10 @@ class OpenAIResponsesHandler(BaseTranslation): def extract_request_tool_names(self, data: Mapping[str, object]) -> list[str]: """Extract tool names from Responses API request (tools[].name for function - and custom, tools[].server_label for mcp).""" + and custom, tools[].server_label for mcp), opening namespace tools into their + members and adding the tools a tool_search_output in input[] loads.""" names: Final[list[str]] = [] - for tool in list_field(data, "tools"): + for tool in (*with_namespace_members(list_field(data, "tools")), *input_tool_search_tools(data)): if not isinstance(tool, dict): continue if tool.get("type") in ("function", "custom") and tool.get("name"): diff --git a/tests/unit/proxy/test_tools_allowlist_enforcement.py b/tests/unit/proxy/test_tools_allowlist_enforcement.py index e306f7be4f8..5822c19002d 100644 --- a/tests/unit/proxy/test_tools_allowlist_enforcement.py +++ b/tests/unit/proxy/test_tools_allowlist_enforcement.py @@ -58,9 +58,7 @@ class TestExtractRequestToolNames: {"type": "function", "name": "get_current_weather", "description": "x"}, ] } - assert extract_request_tool_names("/v1/responses", data) == [ - "get_current_weather" - ] + assert extract_request_tool_names("/v1/responses", data) == ["get_current_weather"] def test_openai_responses_mcp_tools(self): data = { @@ -86,6 +84,42 @@ class TestExtractRequestToolNames: "get_current_weather", ] + def test_openai_responses_namespace_tools(self): + """Codex sends MCP tools grouped in a namespace; the allowlist must see each member.""" + data = { + "tools": [ + {"type": "function", "name": "exec_command"}, + { + "type": "namespace", + "name": "mcp__mock", + "tools": [{"type": "function", "name": "search_issues"}, {"type": "function", "name": "get_issue"}], + }, + ] + } + assert extract_request_tool_names("/v1/responses", data) == ["exec_command", "search_issues", "get_issue"] + + def test_openai_responses_tool_search_output_in_input(self): + """With tool_search, deferred MCP tools arrive only in input[], never in tools[].""" + data = { + "tools": [{"type": "tool_search"}], + "input": [ + {"role": "user", "content": "find the issue"}, + {"type": "tool_search_call", "call_id": "ts_1", "execution": "client"}, + { + "type": "tool_search_output", + "call_id": "ts_1", + "tools": [ + { + "type": "namespace", + "name": "mcp__mock", + "tools": [{"type": "function", "name": "search_issues", "defer_loading": True}], + } + ], + }, + ], + } + assert extract_request_tool_names("/v1/responses", data) == ["search_issues"] + def test_anthropic_tools(self): data = {"tools": [{"name": "get_weather"}, {"name": "run_sql"}]} assert extract_request_tool_names("/v1/messages", data) == [ @@ -129,9 +163,7 @@ class TestExtractRequestToolNames: }, ] } - assert extract_request_tool_names("/generate_content", data) == [ - "schedule_meeting" - ] + assert extract_request_tool_names("/generate_content", data) == ["schedule_meeting"] def test_mcp_call_tool_name(self): data = {"name": "my_tool", "arguments": {}} @@ -242,6 +274,46 @@ class TestCheckToolsAllowlist: assert exc_info.value.type == ProxyErrorTypes.tool_access_denied assert "restricted_tool" in str(exc_info.value.message) + @pytest.mark.parametrize( + "body", + [ + { + "tools": [ + {"type": "namespace", "name": "mcp__mock", "tools": [{"type": "function", "name": "run_shell"}]} + ] + }, + { + "tools": [{"type": "tool_search"}], + "input": [ + { + "type": "tool_search_output", + "call_id": "ts_1", + "tools": [ + { + "type": "namespace", + "name": "mcp__mock", + "tools": [{"type": "function", "name": "run_shell", "defer_loading": True}], + } + ], + } + ], + }, + ], + ids=["namespace_in_tools", "tool_search_output_in_input"], + ) + @pytest.mark.asyncio + async def test_disallowed_namespace_member_raises_on_responses_route(self, body): + token = _token(metadata={"allowed_tools": ["get_weather"]}) + 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 "run_shell" in str(exc_info.value.message) + @pytest.mark.parametrize( "loaded_tool", [ From c00cf1823cf9625d2f9d6764cb5cf1e1ac7eab48 Mon Sep 17 00:00:00 2001 From: Acacian Date: Fri, 30 Oct 2026 06:26:48 +0900 Subject: [PATCH 2/2] fix(guardrails): extract tools from Responses additional_tools items for allowlist checks Codex on newer models leaves tools[] empty and sends every tool inside an additional_tools input item, so extract_request_tool_names returned no names and the key/team allowed_tools check was skipped for the whole request. Read those items too, through the same parser hoist_additional_tools uses. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01Pd88xuMVHt4chTrc8ZzAzv --- .../guardrail_translation/handler.py | 9 ++-- litellm/responses/additional_tools.py | 13 +++++- .../proxy/test_tools_allowlist_enforcement.py | 45 +++++++++++++++++-- 3 files changed, 58 insertions(+), 9 deletions(-) diff --git a/litellm/llms/openai/responses/guardrail_translation/handler.py b/litellm/llms/openai/responses/guardrail_translation/handler.py index 4ebe0f066ac..8611f556c17 100644 --- a/litellm/llms/openai/responses/guardrail_translation/handler.py +++ b/litellm/llms/openai/responses/guardrail_translation/handler.py @@ -73,6 +73,7 @@ from litellm.llms.openai.responses.guardrail_translation.tool_merge import ( append_returned_tools, merge_guardrailed_tools, ) +from litellm.responses.additional_tools import additional_tools_in_input from litellm.responses.litellm_completion_transformation.compaction_items import ( compaction_summary, decode_compaction_item, @@ -931,10 +932,12 @@ class OpenAIResponsesHandler(BaseTranslation): def extract_request_tool_names(self, data: Mapping[str, object]) -> list[str]: """Extract tool names from Responses API request (tools[].name for function - and custom, tools[].server_label for mcp), opening namespace tools into their - members and adding the tools a tool_search_output in input[] loads.""" + and custom, tools[].server_label for mcp), including the tools additional_tools + items in input[] carry, opening namespace tools into their members and adding + the tools a tool_search_output in input[] loads.""" names: Final[list[str]] = [] - for tool in (*with_namespace_members(list_field(data, "tools")), *input_tool_search_tools(data)): + request_tools: Final = (*list_field(data, "tools"), *additional_tools_in_input(data.get("input"))) + for tool in (*with_namespace_members(request_tools), *input_tool_search_tools(data)): if not isinstance(tool, dict): continue if tool.get("type") in ("function", "custom") and tool.get("name"): diff --git a/litellm/responses/additional_tools.py b/litellm/responses/additional_tools.py index 9c7a3a5f72e..9373ca334c7 100644 --- a/litellm/responses/additional_tools.py +++ b/litellm/responses/additional_tools.py @@ -1,11 +1,11 @@ import json from collections.abc import Iterator, Mapping, Sequence from dataclasses import dataclass -from itertools import groupby +from itertools import chain, groupby from types import MappingProxyType from typing import Final, cast # noqa: TID251 # validating the openai tool union strips vendor keys from raw tools -from pydantic import ValidationError +from pydantic import TypeAdapter, ValidationError from litellm._logging import verbose_logger from litellm.types.llms.base import LiteLLMBaseModel @@ -29,6 +29,9 @@ class HoistedAdditionalTools: hoisted: tuple[ALL_RESPONSES_API_TOOL_PARAMS, ...] +_INPUT_ITEMS: Final = TypeAdapter(tuple[object, ...]) + + def _is_additional_tools_item(item: object) -> bool: try: return _InputItemType.model_validate(item).type == ADDITIONAL_TOOLS_INPUT_ITEM_TYPE @@ -86,6 +89,12 @@ def _unique_tools(tools: Sequence[Mapping[str, object]]) -> tuple[Mapping[str, o return tuple(_merged(copies_by_identity[identity]) for identity in first_seen_order) +def additional_tools_in_input(input: object) -> tuple[ALL_RESPONSES_API_TOOL_PARAMS, ...]: + """The tools that ``additional_tools`` input items carry, in input order.""" + items: Final = _INPUT_ITEMS.validate_python(input) if isinstance(input, list) else () + return tuple(chain.from_iterable(_tools_of_item(item) for item in items if _is_additional_tools_item(item))) + + def hoist_additional_tools( input: str | ResponseInputParam, tools: Sequence[ALL_RESPONSES_API_TOOL_PARAMS] | None, diff --git a/tests/unit/proxy/test_tools_allowlist_enforcement.py b/tests/unit/proxy/test_tools_allowlist_enforcement.py index 5822c19002d..2bc18ec6cf8 100644 --- a/tests/unit/proxy/test_tools_allowlist_enforcement.py +++ b/tests/unit/proxy/test_tools_allowlist_enforcement.py @@ -58,7 +58,9 @@ class TestExtractRequestToolNames: {"type": "function", "name": "get_current_weather", "description": "x"}, ] } - assert extract_request_tool_names("/v1/responses", data) == ["get_current_weather"] + assert extract_request_tool_names("/v1/responses", data) == [ + "get_current_weather" + ] def test_openai_responses_mcp_tools(self): data = { @@ -120,6 +122,30 @@ class TestExtractRequestToolNames: } assert extract_request_tool_names("/v1/responses", data) == ["search_issues"] + def test_openai_responses_additional_tools_in_input(self): + """Codex on newer models leaves tools[] empty and sends every tool in an additional_tools input item.""" + data = { + "input": [ + { + "type": "additional_tools", + "role": "developer", + "tools": [ + { + "type": "namespace", + "name": "functions", + "tools": [{"type": "custom", "name": "exec"}, {"type": "function", "name": "wait"}], + }, + {"type": "function", "name": "search_issues"}, + ], + }, + {"role": "user", "content": "hi"}, + ], + } + assert extract_request_tool_names("/v1/responses", data) == ["exec", "wait", "search_issues"] + + def test_openai_responses_string_input_has_no_additional_tools(self): + assert extract_request_tool_names("/v1/responses", {"input": "hi"}) == [] + def test_anthropic_tools(self): data = {"tools": [{"name": "get_weather"}, {"name": "run_sql"}]} assert extract_request_tool_names("/v1/messages", data) == [ @@ -163,7 +189,9 @@ class TestExtractRequestToolNames: }, ] } - assert extract_request_tool_names("/generate_content", data) == ["schedule_meeting"] + assert extract_request_tool_names("/generate_content", data) == [ + "schedule_meeting" + ] def test_mcp_call_tool_name(self): data = {"name": "my_tool", "arguments": {}} @@ -298,11 +326,20 @@ class TestCheckToolsAllowlist: } ], }, + { + "input": [ + { + "type": "additional_tools", + "role": "developer", + "tools": [{"type": "function", "name": "run_shell"}], + } + ] + }, ], - ids=["namespace_in_tools", "tool_search_output_in_input"], + ids=["namespace_in_tools", "tool_search_output_in_input", "additional_tools_in_input"], ) @pytest.mark.asyncio - async def test_disallowed_namespace_member_raises_on_responses_route(self, body): + async def test_disallowed_tool_outside_top_level_tools_raises_on_responses_route(self, body): token = _token(metadata={"allowed_tools": ["get_weather"]}) with pytest.raises(ProxyException) as exc_info: await check_tools_allowlist(