From 942e6cb3cd4c1e3a3cbb4ab1571e122a2230a593 Mon Sep 17 00:00:00 2001 From: mateo-berri <277851410+mateo-berri@users.noreply.github.com> Date: Sat, 5 Sep 2026 16:12:43 -0700 Subject: [PATCH] fix(file_search): scope emulated file_search to the request's vector stores The emulated file_search handler searched whatever vector_store_id the model returned, so a model steered to an id outside the request's file_search tool reached a store the per-key vector store permission check never saw. An id outside the request's stores now falls back to those stores; an id that is one of them still narrows the search to it. --- .../responses/file_search/emulated_handler.py | 2 +- .../llms/test_file_search_responses.py | 110 +++++++++++++++++- 2 files changed, 110 insertions(+), 2 deletions(-) diff --git a/litellm/responses/file_search/emulated_handler.py b/litellm/responses/file_search/emulated_handler.py index 0418f0c5e14..686d5c37fd0 100644 --- a/litellm/responses/file_search/emulated_handler.py +++ b/litellm/responses/file_search/emulated_handler.py @@ -459,7 +459,7 @@ async def _execute_file_search_tool_calls( queries_from_call = _resolve_queries_from_args(args, input) vs_id_arg = args.get("vector_store_id") - vs_ids_for_call = [cast(str, vs_id_arg)] if vs_id_arg else all_vs_ids # cast-ok: model-supplied, as today + vs_ids_for_call = [cast(str, vs_id_arg)] if vs_id_arg in all_vs_ids else all_vs_ids # cast-ok: request id queries, results = await _run_vector_searches( queries=queries_from_call, diff --git a/tests/test_litellm/llms/test_file_search_responses.py b/tests/test_litellm/llms/test_file_search_responses.py index 887f14ce80e..5ecca664379 100644 --- a/tests/test_litellm/llms/test_file_search_responses.py +++ b/tests/test_litellm/llms/test_file_search_responses.py @@ -8,7 +8,7 @@ Coverage: E1-E4 file_search guard in responses/main.py F1-F6 ManagedFiles hook access control G1-G3 get_vector_store_ids_from_file_search_tools() - H1-H14 emulated_handler unit tests + H1-H17 emulated_handler unit tests """ import base64 @@ -936,3 +936,111 @@ class TestEmulatedFileSearchHandler: f"Sub-call {i} must run with is_internal_call=True to suppress " "billing callbacks in wrapper_async" ) + + @pytest.mark.asyncio + async def test_H16_model_chosen_id_outside_request_is_not_searched(self): + """Security regression: a vector_store_id the model returns that was not in the + request's file_search tool must never be searched. Per-key authorization only sees + request ids, so honoring an off-schema id leaks stores the key cannot access. + The handler must fall back to the request's own stores instead.""" + from litellm.responses.file_search.emulated_handler import ( + aresponses_with_emulated_file_search, + ) + + first_resp = MagicMock() + first_resp.output = [ + { + "type": "function_call", + "name": "litellm_file_search", + "call_id": "call_leak", + "arguments": '{"queries": ["launch codeword"], "vector_store_id": "vs_unauthorized"}', + } + ] + first_resp.id = "resp_leak" + first_resp.created_at = 1700000000 + first_resp.model = "claude-3-5-sonnet" + first_resp.usage = None + + final_resp = self._make_mock_responses_api_response(text="done") + + search_result = MagicMock() + search_result.file_id = "file-allowed" + search_result.filename = "allowed.txt" + search_result.score = 0.9 + search_result.content = [{"type": "text", "text": "allowed context"}] + mock_search_response = MagicMock() + mock_search_response.data = [search_result] + + mock_asearch = AsyncMock(return_value=mock_search_response) + with ( + patch.object( + import_module("litellm.responses.file_search.emulated_handler"), + "_call_aresponses", + new=AsyncMock(side_effect=[first_resp, final_resp]), + ), + patch("litellm.vector_stores.main.asearch", new=mock_asearch), # test-quality-ok: asserts store searched + ): + await aresponses_with_emulated_file_search( + input="What is the launch codeword?", + model="anthropic/claude-3-5-sonnet", + tools=[{"type": "file_search", "vector_store_ids": ["vs_allowed"]}], + ) + + searched_ids = [c.kwargs["vector_store_id"] for c in mock_asearch.call_args_list] + assert searched_ids, "Expected the vector store to be searched at least once" + assert "vs_unauthorized" not in searched_ids, ( + "Handler searched the off-schema store the model picked; per-key auth never saw it" + ) + assert set(searched_ids) == {"vs_allowed"} + + @pytest.mark.asyncio + async def test_H17_model_chosen_id_within_request_narrows_search(self): + """A vector_store_id the model returns that IS one of the request's stores is honored: + only that store is searched, not every store in the request.""" + from litellm.responses.file_search.emulated_handler import ( + aresponses_with_emulated_file_search, + ) + + first_resp = MagicMock() + first_resp.output = [ + { + "type": "function_call", + "name": "litellm_file_search", + "call_id": "call_narrow", + "arguments": '{"queries": ["q"], "vector_store_id": "vs_two"}', + } + ] + first_resp.id = "resp_narrow" + first_resp.created_at = 1700000000 + first_resp.model = "claude-3-5-sonnet" + first_resp.usage = None + + final_resp = self._make_mock_responses_api_response(text="done") + + search_result = MagicMock() + search_result.file_id = "file-two" + search_result.filename = "two.txt" + search_result.score = 0.9 + search_result.content = [{"type": "text", "text": "context"}] + mock_search_response = MagicMock() + mock_search_response.data = [search_result] + + mock_asearch = AsyncMock(return_value=mock_search_response) + with ( + patch.object( + import_module("litellm.responses.file_search.emulated_handler"), + "_call_aresponses", + new=AsyncMock(side_effect=[first_resp, final_resp]), + ), + patch("litellm.vector_stores.main.asearch", new=mock_asearch), # test-quality-ok: asserts store searched + ): + await aresponses_with_emulated_file_search( + input="q", + model="anthropic/claude-3-5-sonnet", + tools=[{"type": "file_search", "vector_store_ids": ["vs_one", "vs_two"]}], + ) + + searched_ids = [c.kwargs["vector_store_id"] for c in mock_asearch.call_args_list] + assert set(searched_ids) == {"vs_two"}, ( + "A request-listed id the model picks should narrow the search to that store only" + )