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 1/3] 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" + ) From 55a6132b3163104628b6198d214c3f81db1fea52 Mon Sep 17 00:00:00 2001 From: mateo-berri <277851410+mateo-berri@users.noreply.github.com> Date: Sat, 5 Sep 2026 16:31:24 -0700 Subject: [PATCH 2/3] fix(file_search): log when a model-picked vector_store_id is dropped Emulated file_search now warns when the model returns a vector_store_id that is not one of the request's stores, naming the dropped id and the stores that were searched instead. H16 asserts the warning is emitted exactly once. --- litellm/responses/file_search/emulated_handler.py | 7 +++++++ tests/test_litellm/llms/test_file_search_responses.py | 8 +++++++- 2 files changed, 14 insertions(+), 1 deletion(-) diff --git a/litellm/responses/file_search/emulated_handler.py b/litellm/responses/file_search/emulated_handler.py index 686d5c37fd0..77539efd090 100644 --- a/litellm/responses/file_search/emulated_handler.py +++ b/litellm/responses/file_search/emulated_handler.py @@ -459,6 +459,13 @@ async def _execute_file_search_tool_calls( queries_from_call = _resolve_queries_from_args(args, input) vs_id_arg = args.get("vector_store_id") + if vs_id_arg is not None and vs_id_arg not in all_vs_ids: + verbose_logger.warning( + "file_search emulated: model picked vector_store_id='%s' outside the request's vector_store_ids %s; " + "searching the request's stores instead", + vs_id_arg, + all_vs_ids, + ) 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( diff --git a/tests/test_litellm/llms/test_file_search_responses.py b/tests/test_litellm/llms/test_file_search_responses.py index 5ecca664379..0339f07d414 100644 --- a/tests/test_litellm/llms/test_file_search_responses.py +++ b/tests/test_litellm/llms/test_file_search_responses.py @@ -12,6 +12,7 @@ Coverage: """ import base64 +import logging from typing import Any, Dict, List, Optional from importlib import import_module from unittest.mock import AsyncMock, MagicMock, patch @@ -938,7 +939,7 @@ class TestEmulatedFileSearchHandler: ) @pytest.mark.asyncio - async def test_H16_model_chosen_id_outside_request_is_not_searched(self): + async def test_H16_model_chosen_id_outside_request_is_not_searched(self, caplog): """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. @@ -979,6 +980,7 @@ class TestEmulatedFileSearchHandler: new=AsyncMock(side_effect=[first_resp, final_resp]), ), patch("litellm.vector_stores.main.asearch", new=mock_asearch), # test-quality-ok: asserts store searched + caplog.at_level(logging.WARNING, logger="LiteLLM"), ): await aresponses_with_emulated_file_search( input="What is the launch codeword?", @@ -992,6 +994,10 @@ class TestEmulatedFileSearchHandler: "Handler searched the off-schema store the model picked; per-key auth never saw it" ) assert set(searched_ids) == {"vs_allowed"} + dropped_id_warnings = [r for r in caplog.records if "vs_unauthorized" in r.getMessage()] + assert len(dropped_id_warnings) == 1, "Expected one warning naming the dropped model-picked id" + assert dropped_id_warnings[0].levelno == logging.WARNING + assert "vs_allowed" in dropped_id_warnings[0].getMessage() @pytest.mark.asyncio async def test_H17_model_chosen_id_within_request_narrows_search(self): From 8bf03c10fdc5f2fe183a06d893ab008a5cc922a0 Mon Sep 17 00:00:00 2001 From: mateo-berri <277851410+mateo-berri@users.noreply.github.com> Date: Sat, 5 Sep 2026 16:44:54 -0700 Subject: [PATCH 3/3] fix(file_search): escape the dropped vector_store_id in the warning Format the model-picked id with %r so control characters in it cannot break the log line. The regression test for the unlisted id keeps to generic scoping wording --- .../responses/file_search/emulated_handler.py | 2 +- .../llms/test_file_search_responses.py | 18 +++++++----------- 2 files changed, 8 insertions(+), 12 deletions(-) diff --git a/litellm/responses/file_search/emulated_handler.py b/litellm/responses/file_search/emulated_handler.py index 77539efd090..aacef9c2198 100644 --- a/litellm/responses/file_search/emulated_handler.py +++ b/litellm/responses/file_search/emulated_handler.py @@ -461,7 +461,7 @@ async def _execute_file_search_tool_calls( vs_id_arg = args.get("vector_store_id") if vs_id_arg is not None and vs_id_arg not in all_vs_ids: verbose_logger.warning( - "file_search emulated: model picked vector_store_id='%s' outside the request's vector_store_ids %s; " + "file_search emulated: model picked vector_store_id=%r outside the request's vector_store_ids %s; " "searching the request's stores instead", vs_id_arg, all_vs_ids, diff --git a/tests/test_litellm/llms/test_file_search_responses.py b/tests/test_litellm/llms/test_file_search_responses.py index 0339f07d414..90c60fd20e8 100644 --- a/tests/test_litellm/llms/test_file_search_responses.py +++ b/tests/test_litellm/llms/test_file_search_responses.py @@ -940,10 +940,8 @@ class TestEmulatedFileSearchHandler: @pytest.mark.asyncio async def test_H16_model_chosen_id_outside_request_is_not_searched(self, caplog): - """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.""" + """A vector_store_id the model returns that the request did not list is never + searched; the request's own stores are searched instead, with a warning.""" from litellm.responses.file_search.emulated_handler import ( aresponses_with_emulated_file_search, ) @@ -953,11 +951,11 @@ class TestEmulatedFileSearchHandler: { "type": "function_call", "name": "litellm_file_search", - "call_id": "call_leak", - "arguments": '{"queries": ["launch codeword"], "vector_store_id": "vs_unauthorized"}', + "call_id": "call_unlisted", + "arguments": '{"queries": ["launch codeword"], "vector_store_id": "vs_unlisted"}', } ] - first_resp.id = "resp_leak" + first_resp.id = "resp_unlisted" first_resp.created_at = 1700000000 first_resp.model = "claude-3-5-sonnet" first_resp.usage = None @@ -990,11 +988,9 @@ class TestEmulatedFileSearchHandler: 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 "vs_unlisted" not in searched_ids, "Handler searched a store the request did not list" assert set(searched_ids) == {"vs_allowed"} - dropped_id_warnings = [r for r in caplog.records if "vs_unauthorized" in r.getMessage()] + dropped_id_warnings = [r for r in caplog.records if "vs_unlisted" in r.getMessage()] assert len(dropped_id_warnings) == 1, "Expected one warning naming the dropped model-picked id" assert dropped_id_warnings[0].levelno == logging.WARNING assert "vs_allowed" in dropped_id_warnings[0].getMessage()