Repository navigation
fix(guardrails): check Anthropic-format request tools in tool_permission pre_call - #41011
meryemsakin wants to merge 8 commits into
Conversation
|
Codecov Report✅ All modified and coverable lines are covered by tests. 📢 Thoughts on this report? Let us know! |
0816a00 to
d62fc49
Compare
…ires Both checks are red on main. The schema.d.ts change matches BerriAI#40987 and the test reorder matches BerriAI#41011
…ion pre_call
The pre_call hook only collected tools shaped as {"type": "function", "function": {...}},
so flat tools from /v1/messages ({"name", "input_schema"}) and /v1/responses
({"type": "function", "name"}) were never checked and deny rules silently passed.
Reuse the shared anthropic_tool_names helper so every request tool name is checked,
and handle Anthropic tool_choice {"type": "tool"} in both block and rewrite modes.
Fixes BerriAI#40583
…ssion
Responses API named choices put the name at the top level ({"type": "function", "name": ...}),
so rewrite mode removed the denied tool but left a tool_choice pointing at it.
…ault_on gating Build the guardrail as an always-on pre_call guardrail instead of patching should_run_guardrail.
1aec6d9 to
c25cee4
Compare
|
Rebased on
No change to the fix itself or to its tests. I have updated the description accordingly, and the earlier CodeQL reply still applies: the helper is imported at module level by four sibling hooks on |
PR overviewAll previously flagged issues have been addressed. No open security concerns remain on this pull request. Security reviewNo open security issues remain on this pull request. Fixed/addressed: 1 · PR risk: 0/10 |
Anthropic's user-defined tools carry type "custom", so pre_call mapped that to "function" for tool_type rules. The Responses API has its own custom tool type, and mapping it let a custom tool slip past a tool_type deny rule when the default action is allow. Normalize only for the formats where custom means a plain function, and read tool names directly in the rewrite path, which never needed a type.
|
Merged current main. All 66 focused tests and full lint pass; reverting the fix fails all seven regression cases. @greptileai |
|
@mateo-berri @yucheng-berri could one of you review this when you have a moment? It's a follow-up to #35999: the Tool Permission pre-call hook doesn't check tools in Anthropic-format ( The fix is 2 files (+138/−23). The seven new regression cases fail without it, CI is green, and the description has live before/after requests. |
chrikrah
left a comment
There was a problem hiding this comment.
@meryemsakin I ran this at a21d5b4 and I would merge it. I reverted tool_permission.py to your base b94f5bd and kept your tests: 7 of the 66 fail, one per new case. The branch also merges into b41715b with no conflict, and neither touched file has changed on main since your base.
A probe against current main shows what the change closes: with default_action: deny and one allow rule, a flat Responses function delete_repo passes the pre-call hook today and gets a 400 on this branch. It also blocks an Anthropic server tool such as web_search_20250305 under a tool_type rule, which main lets through.
$ pytest tests/test_litellm/proxy/guardrails/guardrail_hooks/test_tool_permission.py -q
66 passed in 7.85s
# tool_permission.py reverted to b94f5bd, tests kept
7 failed, 59 passed in 6.76s
$ PYTHONPATH=<main b41715b> python probe_default_deny.py
default_action=deny, allow ^get_weather$ | tool type=function -> PASSED THROUGH
$ PYTHONPATH=<this branch> python probe_default_deny.py
default_action=deny, allow ^get_weather$ | tool type=function -> blocked 400: Tool 'delete_repo' denied by default action
$ PYTHONPATH=<main b41715b> python probe_nameless.py
anthropic_messages rule tool_type=^web_search_20250305$ tool type=web_search_20250305 -> PASSED THROUGH
$ PYTHONPATH=<this branch> python probe_nameless.py
anthropic_messages rule tool_type=^web_search_20250305$ tool type=web_search_20250305 -> blocked 400: Tool 'web_search' denied by rule 'deny_type'
# Python 3.13; not run: the proxy end to end
If an end-to-end test through the proxy would help before merge, I can add one against this branch this week.
|
Thanks @chrikrah, I really appreciate the thorough review and additional probes! An end-to-end test through the proxy would be helpful if you have time. I’ll resolve the conflict with main and rerun the tests. |
chrikrah
left a comment
There was a problem hiding this comment.
@meryemsakin here is the proxy run you asked for: through a running proxy, a ^mcp__.*$ deny rule blocks the tool on all three endpoints with your change, and only on /v1/chat/completions without it.
# ./run_e2e.sh <tree> <port>, once per tree: main be44817, then main + a21d5b4 (merged, tree 4d15c2f)
# each run: python -m litellm.proxy.proxy_cli --config e2e_config.yaml (tool_permission pre_call,
# deny ^mcp__.*$, default allow; mock_response models), then ./e2e_requests.sh, one curl per row; Python 3.13
# rows from the two runs side by side:
main be44817 main + a21d5b4
/v1/chat/completions mcp__github__delete_repo 400 400 denied by rule 'deny_mcp'
/v1/messages mcp__github__delete_repo 200 400 denied by rule 'deny_mcp'
/v1/responses mcp__github__delete_repo 200 400 denied by rule 'deny_mcp'
/v1/messages get_weather 200 200
/v1/responses get_weather 200 200
# main + a21d5b4 with tool_permission.py from main: the main column again
$ pytest tests/unit/proxy/guardrails/guardrail_hooks/test_tool_permission.py -q # main + a21d5b4
66 passed, 1 warning in 3.94s
# same file, tool_permission.py from main: 7 failed, 59 passed, 1 warning in 4.52s
git merge of a21d5b4 into be44817 completes with no conflict here: #44003 moved your test file to tests/unit/proxy/guardrails/, and the merge follows the rename.
Want me to extend tests/e2e/guardrails/test_tool_permission_guardrail_e2e.py so its exercised_on covers messages and responses, not only chat_completions? I can push it to your branch or post it here today. cc @yucheng-berri, who wrote that test.
|
Thanks @chrikrah, this is exactly the end-to-end check I was hoping for. The results match the live replay in the description: the deny rule only held on /v1/chat/completions before, and now holds on all three endpoints. Yes, please extend Could you open a PR against my branch ( Thanks also for confirming the branch still merges cleanly into current main, including the #44003 rename. It turns out there was no conflict to resolve. |
chrikrah
left a comment
There was a problem hiding this comment.
@meryemsakin, here is the e2e diff. It adds the block and allow cases for /v1/messages and /v1/responses, with the tools declared flat the way those endpoints take them, and widens exercised_on on both registry rows. It applies with git apply on a21d5b4, the head of this branch. It does not apply on main at 512692d: #44963 added @meta(Subject(...)) to both existing tests in that file, so after you merge main the four new cases want the same tags.
$ pytest tests/e2e/guardrails/test_tool_permission_guardrail_e2e.py --reruns 0 -v
# proxy from a21d5b4, local Postgres, gemini-2.5-flash as a mock deployment (mock_tool_calls: get_weather)
test_pre_call_blocks_tool_outside_the_allow_list PASSED
test_pre_call_allows_permitted_tool PASSED
test_pre_call_blocks_flat_tool_outside_the_allow_list[messages] PASSED
test_pre_call_blocks_flat_tool_outside_the_allow_list[responses] PASSED
test_pre_call_allows_permitted_flat_tool[messages] PASSED
test_pre_call_allows_permitted_flat_tool[responses] PASSED
6 passed in 90.76s
# same run, proxy with tool_permission.py from main 10444df
FAILED ...test_pre_call_blocks_flat_tool_outside_the_allow_list[messages]
FAILED ...test_pre_call_blocks_flat_tool_outside_the_allow_list[responses]
2 failed, 4 passed in 331.66s
# AssertionError: tool_permission let a tool outside the allow-list through /messages; got 200
$ basedpyright tests/e2e # basedpyright 1.39.7
0 errors, 0 warnings, 0 notes
# not run: a real Gemini model, so on the allow side the tool call comes from the mock
diff --git a/tests/e2e/coverage_registry/guardrail.yaml b/tests/e2e/coverage_registry/guardrail.yaml
index 920a288..fb774cf 100644
--- a/tests/e2e/coverage_registry/guardrail.yaml
+++ b/tests/e2e/coverage_registry/guardrail.yaml
@@ -24,8 +24,8 @@
- {id: guardrail.ibm_guardrails.post_call.blocks, module: guardrail, tier: P1, hook_point: post_call, assertions: [blocks], exercised_on: [chat_completions], source: "guardrail_hooks/ibm_guardrails/ibm_detector.py", rationale: "Output policy validation"}
- {id: guardrail.semantic_guard.pre_call.blocks, module: guardrail, tier: P1, hook_point: pre_call, assertions: [blocks], exercised_on: [chat_completions], source: "guardrail_hooks/semantic_guard", rationale: "Semantic policy compliance"}
- {id: guardrail.block_code_execution.pre_call.blocks, module: guardrail, tier: P1, hook_point: pre_call, assertions: [blocks], exercised_on: [chat_completions], source: "guardrail_hooks/block_code_execution", rationale: "Code-injection prevention"}
-- {id: guardrail.tool_permission.pre_call.allows, module: guardrail, tier: P1, hook_point: pre_call, assertions: [allows], exercised_on: [chat_completions], source: "guardrail_hooks/tool_permission.py", rationale: "Grant allowed tools"}
-- {id: guardrail.tool_permission.pre_call.blocks, module: guardrail, tier: P1, hook_point: pre_call, assertions: [blocks], exercised_on: [chat_completions], source: "guardrail_hooks/tool_permission.py", rationale: "Block unauthorized tools"}
+- {id: guardrail.tool_permission.pre_call.allows, module: guardrail, tier: P1, hook_point: pre_call, assertions: [allows], exercised_on: [chat_completions, messages, responses], source: "guardrail_hooks/tool_permission.py", rationale: "Grant allowed tools"}
+- {id: guardrail.tool_permission.pre_call.blocks, module: guardrail, tier: P1, hook_point: pre_call, assertions: [blocks], exercised_on: [chat_completions, messages, responses], source: "guardrail_hooks/tool_permission.py", rationale: "Block unauthorized tools"}
- {id: guardrail.microsoft_purview.pre_call.blocks, module: guardrail, tier: P1, hook_point: pre_call, assertions: [blocks], exercised_on: [chat_completions], source: "guardrail_hooks/microsoft_purview/purview_dlp.py", rationale: "DLP sensitive-data disclosure"}
- {id: guardrail.headroom.pre_call.blocks, module: guardrail, tier: P1, hook_point: pre_call, assertions: [blocks], exercised_on: [chat_completions], source: "guardrail_hooks/headroom/headroom.py", rationale: "Anomaly detection threshold"}
- {id: guardrail.generic_guardrail_api.pre_call.blocks, module: guardrail, tier: P1, hook_point: pre_call, assertions: [blocks], exercised_on: [chat_completions], source: "guardrail_hooks/generic_guardrail_api/generic_guardrail_api.py", rationale: "Vendor-agnostic custom API"}
diff --git a/tests/e2e/guardrails/test_tool_permission_guardrail_e2e.py b/tests/e2e/guardrails/test_tool_permission_guardrail_e2e.py
index 8d1047e..6730e0e 100644
--- a/tests/e2e/guardrails/test_tool_permission_guardrail_e2e.py
+++ b/tests/e2e/guardrails/test_tool_permission_guardrail_e2e.py
@@ -19,9 +19,10 @@ verdict comes from the proxy itself.
from __future__ import annotations
-from typing import Final
+from typing import Final, Literal
import pytest
+from pydantic import BaseModel
from e2e_config import unique_marker
from e2e_http import StreamingResponse, UnknownApiError
@@ -30,10 +31,22 @@ from guardrails_client import (
ToolPermissionParamsBody,
ToolPermissionRuleBody,
poll_until_blocked,
+ poll_until_blocked_stream,
poll_until_guardrail_applied,
)
from lifecycle import ResourceManager
-from models import ChatResponse, ChatTool, ChatToolFunction
+from models import (
+ AnthropicCustomTool,
+ AnthropicMessagesBody,
+ AnthropicMessagesResponse,
+ AnthropicToolChoice,
+ ChatMessage,
+ ChatResponse,
+ ChatTool,
+ ChatToolFunction,
+ JsonSchemaProperty,
+ ToolInputSchema,
+)
pytestmark = pytest.mark.e2e
@@ -62,6 +75,37 @@ DENIED_TOOL: Final = ChatTool(
TOOL_PROMPT: Final = "What is the weather in Paris right now?"
+#: /v1/messages and /v1/responses declare a tool flat, with `name` at the top
+#: level instead of under `function`. The same two tools in those layouts.
+type FlatToolEndpoint = Literal["messages", "responses"]
+ALLOWED_TOOL_SCHEMA: Final = ToolInputSchema(properties={"city": JsonSchemaProperty(type="string")}, required=["city"])
+DENIED_TOOL_SCHEMA: Final = ToolInputSchema()
+
+
+class _ResponsesFunctionTool(BaseModel):
+ type: Literal["function"] = "function"
+ name: str
+ description: str | None = None
+ parameters: ToolInputSchema
+
+
+class _ResponsesToolBody(BaseModel):
+ model: str
+ input: str
+ tools: list[_ResponsesFunctionTool]
+ tool_choice: Literal["required"] | None = None
+ guardrails: list[str]
+ max_output_tokens: int = 128
+
+
+class _ResponsesOutputItem(BaseModel):
+ type: str
+ name: str | None = None
+
+
+class _ResponsesToolOutput(BaseModel):
+ output: list[_ResponsesOutputItem]
+
def _register_tool_permission(client: GuardrailsClient, resources: ResourceManager, *, name: str) -> None:
"""Allow-list exactly one tool: everything else falls to `default_action=deny`
@@ -99,6 +143,59 @@ def _tool_call_names(response: ChatResponse) -> tuple[str, ...]:
)
+def _send_flat_tool(
+ client: GuardrailsClient,
+ key: str,
+ endpoint: FlatToolEndpoint,
+ *,
+ guardrail: str,
+ tool: ChatTool,
+ schema: ToolInputSchema,
+ require_tool: bool = False,
+) -> StreamingResponse:
+ """Declare `tool` the way the endpoint's own clients do: an Anthropic custom
+ tool on /v1/messages, a flat function tool on /v1/responses."""
+ name: Final = tool.function.name
+ match endpoint:
+ case "messages":
+ return client.proxy.transport.send(
+ "/v1/messages",
+ headers=client.proxy.transport.bearer(key),
+ json=AnthropicMessagesBody(
+ model=MODEL,
+ messages=[ChatMessage(role="user", content=TOOL_PROMPT)],
+ max_tokens=128,
+ tools=[
+ AnthropicCustomTool(name=name, description=tool.function.description or "", input_schema=schema)
+ ],
+ tool_choice=AnthropicToolChoice(type="any") if require_tool else None,
+ guardrails=[guardrail],
+ ),
+ )
+ case "responses":
+ return client.proxy.transport.send(
+ "/v1/responses",
+ headers=client.proxy.transport.bearer(key),
+ json=_ResponsesToolBody(
+ model=MODEL,
+ input=TOOL_PROMPT,
+ tools=[_ResponsesFunctionTool(name=name, description=tool.function.description, parameters=schema)],
+ tool_choice="required" if require_tool else None,
+ guardrails=[guardrail],
+ ),
+ )
+
+
+def _flat_tool_call_names(endpoint: FlatToolEndpoint, body: str) -> tuple[str, ...]:
+ match endpoint:
+ case "messages":
+ content: Final = AnthropicMessagesResponse.model_validate_json(body).content or []
+ return tuple(block.name for block in content if block.type == "tool_use" and block.name)
+ case "responses":
+ output: Final = _ResponsesToolOutput.model_validate_json(body).output
+ return tuple(item.name for item in output if item.type == "function_call" and item.name)
+
+
class TestToolPermissionPreCall:
@pytest.mark.covers("guardrail.tool_permission.pre_call.blocks", exercised_on=["chat_completions"])
def test_pre_call_blocks_tool_outside_the_allow_list(
@@ -169,3 +266,92 @@ class TestToolPermissionPreCall:
assert called == (ALLOWED_TOOL.function.name,), (
f"the served call must carry one tool call for the permitted tool, got {called!r}: {outcome.body[:400]}"
)
+
+ @pytest.mark.parametrize(
+ "endpoint",
+ [
+ pytest.param(
+ "messages",
+ marks=pytest.mark.covers("guardrail.tool_permission.pre_call.blocks", exercised_on=["messages"]),
+ ),
+ pytest.param(
+ "responses",
+ marks=pytest.mark.covers("guardrail.tool_permission.pre_call.blocks", exercised_on=["responses"]),
+ ),
+ ],
+ )
+ def test_pre_call_blocks_flat_tool_outside_the_allow_list(
+ self,
+ client: GuardrailsClient,
+ resources: ResourceManager,
+ scoped_key: str,
+ endpoint: FlatToolEndpoint,
+ ) -> None:
+ """The block half on the two routes whose tools carry `name` at the top
+ level. A guardrail that only reads `function.name` sees no tool there and
+ lets the denied one through to the model with a 200."""
+ name = f"e2e-toolperm-block-{endpoint}-{unique_marker()}"
+ _register_tool_permission(client, resources, name=name)
+
+ outcome = poll_until_blocked_stream(
+ lambda: _send_flat_tool(
+ client, scoped_key, endpoint, guardrail=name, tool=DENIED_TOOL, schema=DENIED_TOOL_SCHEMA
+ )
+ )
+
+ assert outcome.status_code == 400, (
+ f"tool_permission let a tool outside the allow-list through /{endpoint}; "
+ f"got {outcome.status_code}: {outcome.body[:400]}"
+ )
+ assert DENIED_TOOL.function.name in outcome.body, (
+ f"the block must name the denied tool so the caller can fix the request; got: {outcome.body[:400]}"
+ )
+
+ @pytest.mark.parametrize(
+ "endpoint",
+ [
+ pytest.param(
+ "messages",
+ marks=pytest.mark.covers("guardrail.tool_permission.pre_call.allows", exercised_on=["messages"]),
+ ),
+ pytest.param(
+ "responses",
+ marks=pytest.mark.covers("guardrail.tool_permission.pre_call.allows", exercised_on=["responses"]),
+ ),
+ ],
+ )
+ def test_pre_call_allows_permitted_flat_tool(
+ self,
+ client: GuardrailsClient,
+ resources: ResourceManager,
+ scoped_key: str,
+ endpoint: FlatToolEndpoint,
+ ) -> None:
+ """The allow half on the same two routes: the permitted tool, declared
+ flat, is served, the guardrail is named in the applied-guardrails
+ header, and the model's tool call comes back for that tool."""
+ name = f"e2e-toolperm-allow-{endpoint}-{unique_marker()}"
+ _register_tool_permission(client, resources, name=name)
+
+ outcome = poll_until_guardrail_applied(
+ lambda: _send_flat_tool(
+ client,
+ scoped_key,
+ endpoint,
+ guardrail=name,
+ tool=ALLOWED_TOOL,
+ schema=ALLOWED_TOOL_SCHEMA,
+ require_tool=True,
+ ),
+ name,
+ )
+
+ assert outcome.ok, f"the permitted tool must be served, got {outcome.status_code}: {outcome.body[:400]}"
+ applied = _applied_guardrails(outcome)
+ assert name in applied, (
+ f"the allowed call must carry x-litellm-applied-guardrails naming the guardrail; got {applied!r}"
+ )
+ called = _flat_tool_call_names(endpoint, outcome.body)
+ assert called == (ALLOWED_TOOL.function.name,), (
+ f"the served call must carry one tool call for the permitted tool, got {called!r}: {outcome.body[:400]}"
+ )Could you apply it to this branch and rerun the focused tests?
Add blocked and permitted flat-tool cases through the running proxy, with endpoint coverage and metadata for the current e2e harness Require a guardrail policy verdict when checking denied tools so an unrelated provider error cannot satisfy the regression test Co-authored-by: Christopher Krah <christopher.krah@plexito.de>
|
Thanks @chrikrah, I really appreciate the time and care you put into these tests. I’ve applied the diff and credited you in the commit. |
TLDR
Problem this solves:
How it solves it:
Intentional product change: flat-format tools now follow configured permission rules, so previously bypassed denied tools return HTTP 400
User Flow
Before: a user can send a denied MCP tool through the Anthropic endpoint
^mcp__.*$toolstool_useblockAfter: the same request is rejected before reaching the model
^mcp__.*$toolsFlat function definitions on http://localhost:4000/v1/responses receive the same checks
Relevant issues
Addresses the Tool Permission portion of #40583
Pre-Submission checklist
Please complete all items before asking a LiteLLM maintainer to review your PR
Current validation
Current tip is
c55c340437. Mergedmainat7ff5010674before applying the e2e diff from Christopher Krah. The four new cases check blocked and allowed flat tool definitions on/v1/messagesand/v1/responses. Their metadata matches the current harness, the registry records both endpoints, and the Responses bodies use frozen shared modelsAll 66 focused unit tests pass. The six cases in
tests/e2e/guardrails/test_tool_permission_guardrail_e2e.pypass on a local proxy and isolated PostgreSQL in 91.36 seconds with--reruns 0. E2e and harness basedpyright reports zero errors. Ruff, formatting,git diff --check, and the strict coverage collector passmake lint BASE_REF=upstream/main LINT_JOBS=2passes, including the strict-rule, type-discipline, test-quality, basedpyright, import and format gates. No limits were raised. Against unmodifiedmainat7ff5010674, the retained PR unit tests report seven failures and 59 passes. The same six e2e cases against that main tree report exactly two failures and four passes. Only the new Messages and Responses block cases fail, with HTTP 200 instead of the required 400Required CI and fresh Greptile, Veria and Bugbot reviews of the updated tip remain pending
The current e2e validation uses a local proxy and database with a mocked provider. The permission checks run in the proxy, but allowed tool calls come from the mock. This does not establish behavior against a real Gemini model
Screenshots / Proof of Fix
Historical evidence only, captured on 2026-09-28 at the commits below. It has not been rerun against the updated tip. Fresh live-provider proof remains pending
Live replay on 2026-09-28: a local proxy calling real Ollama 0.34.4 on a Colab Tesla T4, with
qwen3.8:27b(Q4_K_M, digestaaee06c39dcf) and a 4096-token context. No mocked provider responsesStart the proxy on each commit with
python litellm/proxy/proxy_cli.py --config guardrail-live.yaml --host 127.0.0.1 --port 4010. Port 11439 is an SSH tunnel to Ollama's port 11434Outputs below show the HTTP status and selected response fields; generated IDs and unrelated metadata are omitted
Before (b94f5bd)
/v1/messages, denied tool
{ "content": [ { "type": "text", "text": "" }, { "type": "tool_use", "name": "mcp__my_server__lookup", "input": { "city": "Istanbul" } } ], "stop_reason": "tool_use", "usage": { "input_tokens": 283, "output_tokens": 32 } }/v1/messages, allowed tool
{ "content": [ { "type": "text", "text": "" }, { "type": "tool_use", "name": "get_weather", "input": { "city": "Istanbul" } } ], "stop_reason": "tool_use", "usage": { "input_tokens": 278, "output_tokens": 27 } }/v1/responses, denied tool
{ "output": [ { "type": "message", "status": "completed", "role": "assistant", "content": [ { "type": "output_text", "text": "", "annotations": [] } ], "phase": null }, { "arguments": "{\"city\": \"Istanbul\"}", "name": "mcp__my_server__lookup", "type": "function_call", "namespace": null, "status": "completed" } ], "status": "completed", "usage": { "input_tokens": 283, "input_tokens_details": null, "output_tokens": 32, "output_tokens_details": null, "total_tokens": 315, "cost": null } }/v1/responses, allowed tool
{ "output": [ { "type": "message", "status": "completed", "role": "assistant", "content": [ { "type": "output_text", "text": "", "annotations": [] } ], "phase": null }, { "arguments": "{\"city\": \"Istanbul\"}", "name": "get_weather", "type": "function_call", "namespace": null, "status": "completed" } ], "status": "completed", "usage": { "input_tokens": 278, "input_tokens_details": null, "output_tokens": 27, "output_tokens_details": null, "total_tokens": 305, "cost": null } }/v1/chat/completions, denied tool
{ "error": { "message": "Violated guardrail policy", "type": "invalid_request_error", "param": null, "code": "400", "provider_specific_fields": { "error": "Violated guardrail policy", "detection_message": "Tool 'mcp__my_server__lookup' denied by rule 'deny_mcp_tools'", "guardrail_name": "block-mcp", "guardrail_mode": "pre_call" } } }/v1/chat/completions, allowed tool
{ "choices": [ { "finish_reason": "tool_calls", "index": 0, "message": { "content": "", "role": "assistant", "tool_calls": [ { "function": { "arguments": "{\"city\": \"Istanbul\"}", "name": "get_weather" }, "id": "call_q1vfu33p", "type": "function" } ] } } ], "usage": { "completion_tokens": 27, "prompt_tokens": 278, "total_tokens": 305 } }After (a21d5b4)
/v1/messages, denied tool
{ "type": "error", "error": { "type": "invalid_request_error", "message": "Violated guardrail policy", "provider_specific_fields": { "error": "Violated guardrail policy", "detection_message": "Tool 'mcp__my_server__lookup' denied by rule 'deny_mcp_tools'", "guardrail_name": "block-mcp", "guardrail_mode": "pre_call" } } }/v1/messages, allowed tool
{ "content": [ { "type": "text", "text": "" }, { "type": "tool_use", "name": "get_weather", "input": { "city": "Istanbul" } } ], "stop_reason": "tool_use", "usage": { "input_tokens": 278, "output_tokens": 27 } }/v1/responses, denied tool
{ "error": { "message": "Violated guardrail policy", "type": "invalid_request_error", "param": null, "code": "400", "provider_specific_fields": { "error": "Violated guardrail policy", "detection_message": "Tool 'mcp__my_server__lookup' denied by rule 'deny_mcp_tools'", "guardrail_name": "block-mcp", "guardrail_mode": "pre_call" } } }/v1/responses, allowed tool
{ "output": [ { "type": "message", "status": "completed", "role": "assistant", "content": [ { "type": "output_text", "text": "", "annotations": [] } ], "phase": null }, { "arguments": "{\"city\": \"Istanbul\"}", "name": "get_weather", "type": "function_call", "namespace": null, "status": "completed" } ], "status": "completed", "usage": { "input_tokens": 278, "input_tokens_details": null, "output_tokens": 27, "output_tokens_details": null, "total_tokens": 305, "cost": null } }/v1/chat/completions, denied tool
{ "error": { "message": "Violated guardrail policy", "type": "invalid_request_error", "param": null, "code": "400", "provider_specific_fields": { "error": "Violated guardrail policy", "detection_message": "Tool 'mcp__my_server__lookup' denied by rule 'deny_mcp_tools'", "guardrail_name": "block-mcp", "guardrail_mode": "pre_call" } } }/v1/chat/completions, allowed tool
{ "choices": [ { "finish_reason": "tool_calls", "index": 0, "message": { "content": "", "role": "assistant", "tool_calls": [ { "function": { "arguments": "{\"city\": \"Istanbul\"}", "name": "get_weather" }, "id": "call_kfiidnlh", "type": "function" } ] } } ], "usage": { "completion_tokens": 27, "prompt_tokens": 278, "total_tokens": 305 } }Type
Bug Fix and Test
Caveats
Severe
default_action: deny, unlisted tools receive HTTP 400Low
QA runbook
Use a local proxy at
http://localhost:4000backed by Postgres, withgeneral_settings.master_key,general_settings.store_model_in_db: true, and agemini-2.5-flashdeployment backed bygemini/gemini-2.5-flash. Real-provider reproduction needsGEMINI_API_KEYin the proxy environment. SetLITELLM_MASTER_KEYandLITELLM_PROXY_URL=http://localhost:4000for the harness. Seetests/e2e/CONTRIBUTING.mdfor stack setupFor each case, mirror
scoped_keyandGuardrailsClient.registerwith fresh resources. POSThttp://localhost:4000/key/generateusing the master-key bearer token and{"models":[],"user_id":"e2e-test-user"}, then retain the returnedkey. POSThttp://localhost:4000/guardrailsusing the master key and the body below, replacingNAMEwith a unique case name. Retainguardrail_idand allow the guardrail to propagate before checking responses{ "guardrail": { "guardrail_name": "NAME", "litellm_params": { "guardrail": "tool_permission", "mode": "pre_call", "default_on": false, "default_action": "deny", "on_disallowed_action": "block", "rules": [ {"id": "allow-get-weather", "tool_name": "get_weather", "decision": "allow"} ] } } }Send inference requests with the generated-key bearer token and
Content-Type: application/json. Each body sets"model":"gemini-2.5-flash"and"guardrails":["NAME"], using the promptWhat is the weather in Paris right now?. The allowed tool isget_weather, described asGet the current weather for a city, with{"type":"object","properties":{"city":{"type":"string"}},"required":["city"]}. The denied tool isdelete_customer_database, described asPermanently delete the customer database, with{"type":"object","properties":{}}After each case, DELETE
http://localhost:4000/guardrails/{guardrail_id}using the master key, and POSThttp://localhost:4000/key/deletewith{"keys":["GENERATED_KEY"]}using the master key. The harness registers both cleanups throughresourcestests/e2e/guardrails/test_tool_permission_guardrail_e2e.py::TestToolPermissionPreCall::test_pre_call_blocks_tool_outside_the_allow_listproves Chat Completions rejects a denied nested tool/key/generateand/guardrailsas abovehttp://localhost:4000/chat/completionswith"messages":[{"role":"user","content":"What is the weather in Paris right now?"}],"max_tokens":128, and"tools":[{"type":"function","function":{"name":"delete_customer_database","description":"Permanently delete the customer database","parameters":{"type":"object","properties":{}}}}]delete_customer_databaseand a guardrail verdict in the error bodyguardrailsfrom the same request and expecting HTTP 200tests/e2e/guardrails/test_tool_permission_guardrail_e2e.py::TestToolPermissionPreCall::test_pre_call_allows_permitted_toolproves Chat Completions serves a permitted tool and reports the guardrail/key/generateand/guardrailsas abovehttp://localhost:4000/chat/completionswith the samemessages,"max_tokens":128,"tool_choice":"required", and"tools":[{"type":"function","function":{"name":"get_weather","description":"Get the current weather for a city","parameters":{"type":"object","properties":{"city":{"type":"string"}},"required":["city"]}}}]choices[].message.tool_calls[].function.nameequal toget_weather, andNAMEinx-litellm-applied-guardrailsguardrails, expecting HTTP 200 withNAMEabsent from the applied-guardrails headertests/e2e/guardrails/test_tool_permission_guardrail_e2e.py::TestToolPermissionPreCall::test_pre_call_blocks_flat_tool_outside_the_allow_list[messages]proves Messages rejects a denied flat tool/key/generateand/guardrailsas abovehttp://localhost:4000/v1/messageswith the samemessages,"max_tokens":128, and"tools":[{"name":"delete_customer_database","description":"Permanently delete the customer database","input_schema":{"type":"object","properties":{},"required":[]}}]delete_customer_databaseandViolated guardrail policyin the error bodyguardrailsfrom the same request and expecting HTTP 200tests/e2e/guardrails/test_tool_permission_guardrail_e2e.py::TestToolPermissionPreCall::test_pre_call_blocks_flat_tool_outside_the_allow_list[responses]proves Responses rejects a denied flat function tool/key/generateand/guardrailsas abovehttp://localhost:4000/v1/responseswith"input":"What is the weather in Paris right now?","max_output_tokens":128, and"tools":[{"type":"function","name":"delete_customer_database","description":"Permanently delete the customer database","parameters":{"type":"object","properties":{},"required":[]}}]delete_customer_databaseandViolated guardrail policyin the error bodyguardrailsfrom the same request and expecting HTTP 200tests/e2e/guardrails/test_tool_permission_guardrail_e2e.py::TestToolPermissionPreCall::test_pre_call_allows_permitted_flat_tool[messages]proves Messages serves a permitted flat tool and reports the guardrail/key/generateand/guardrailsas abovehttp://localhost:4000/v1/messageswith the samemessages,"max_tokens":128,"tool_choice":{"type":"any"}, and"tools":[{"name":"get_weather","description":"Get the current weather for a city","input_schema":{"type":"object","properties":{"city":{"type":"string"}},"required":["city"]}}]content[]block with"type":"tool_use"and"name":"get_weather", andNAMEinx-litellm-applied-guardrailsguardrails, expecting HTTP 200 withNAMEabsent from the applied-guardrails headertests/e2e/guardrails/test_tool_permission_guardrail_e2e.py::TestToolPermissionPreCall::test_pre_call_allows_permitted_flat_tool[responses]proves Responses serves a permitted flat function tool and reports the guardrail/key/generateand/guardrailsas abovehttp://localhost:4000/v1/responseswith"input":"What is the weather in Paris right now?","max_output_tokens":128,"tool_choice":"required", and"tools":[{"type":"function","name":"get_weather","description":"Get the current weather for a city","parameters":{"type":"object","properties":{"city":{"type":"string"}},"required":["city"]}}]output[]item with"type":"function_call"and"name":"get_weather", andNAMEinx-litellm-applied-guardrailsguardrails, expecting HTTP 200 withNAMEabsent from the applied-guardrails headerFinal Attestation