Skip to content

fix(guardrails): check Anthropic-format request tools in tool_permission pre_call - #41011

Open
meryemsakin wants to merge 8 commits into
BerriAI:mainfrom
meryemsakin:fix-tool-permission-anthropic-request-tools
Open

meryemsakin wants to merge 8 commits into
BerriAI:mainfrom
meryemsakin:fix-tool-permission-anthropic-request-tools

Conversation

@meryemsakin

@meryemsakin meryemsakin commented Sep 13, 2026 •

Copy link
Copy Markdown

TLDR

Problem this solves:

  • Tool Permission misses tools in Anthropic and Responses requests
  • Users can bypass deny rules with those request formats

How it solves it:

  • Read nested and flat tool names with the shared helper
  • Check named tool choices and remove denied tools during rewriting
  • Preserve Responses custom types when matching tool-type rules
  • Test flat tools on Messages and Responses, blocked and allowed

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

  1. The admin enables Tool Permission before requests, denying names matching ^mcp__.*$
  2. The user sends POST http://localhost:4000/v1/messages with an MCP tool in tools
  3. The request reaches the model, which can return HTTP 200 with a tool_use block
  4. The user can bypass the configured deny rule through this endpoint

After: the same request is rejected before reaching the model

  1. The admin enables Tool Permission before requests, denying names matching ^mcp__.*$
  2. The user sends POST http://localhost:4000/v1/messages with an MCP tool in tools
  3. The proxy returns HTTP 400 identifying the denied tool and rule
  4. The user cannot offer that denied tool to the model through this endpoint

Flat 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

  • I have added meaningful tests
  • The handful of test files covering my change pass locally
  • My PR passes all required CI/CD checks
  • My PR's scope is as isolated as possible, it only solves one problem
  • I have received a Greptile Confidence Score of at least 4/5 on the current tip before requesting a maintainer review

Current validation

Current tip is c55c340437. Merged main at 7ff5010674 before applying the e2e diff from Christopher Krah. The four new cases check blocked and allowed flat tool definitions on /v1/messages and /v1/responses. Their metadata matches the current harness, the registry records both endpoints, and the Responses bodies use frozen shared models

All 66 focused unit tests pass. The six cases in tests/e2e/guardrails/test_tool_permission_guardrail_e2e.py pass 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 pass

make lint BASE_REF=upstream/main LINT_JOBS=2 passes, including the strict-rule, type-discipline, test-quality, basedpyright, import and format gates. No limits were raised. Against unmodified main at 7ff5010674, 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 400

Required 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, digest aaee06c39dcf) and a 4096-token context. No mocked provider responses

Start 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 11434

model_list:
  - model_name: local-qa
    litellm_params:
      model: ollama_chat/qwen3.8:27b
      api_base: http://127.0.0.1:11439
      think: false
      num_ctx: 4096

guardrails:
  - guardrail_name: block-mcp
    litellm_params:
      guardrail: tool_permission
      mode: pre_call
      default_on: true
      rules:
        - id: deny_mcp_tools
          tool_name: ^mcp__.*$
          decision: deny
      default_action: allow
      on_disallowed_action: block

general_settings:
  master_key: sk-local-pr-qa

Outputs below show the HTTP status and selected response fields; generated IDs and unrelated metadata are omitted

Before (b94f5bd)

/v1/messages, denied tool

  1. Command:
    curl -sS --max-time 300 -w '\nHTTP %{http_code}\n' http://127.0.0.1:4010/v1/messages -H 'Authorization: Bearer sk-local-pr-qa' -H 'Content-Type: application/json' -d '{"model":"local-qa","max_tokens":256,"temperature":0,"messages":[{"role":"user","content":"Use the tool to look up Istanbul."}],"tools":[{"name":"mcp__my_server__lookup","description":"Look up the weather in a city","input_schema":{"type":"object","properties":{"city":{"type":"string"}},"required":["city"]}}]}'
  2. Observed output, selected fields:
    {
      "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
      }
    }
    HTTP 200

/v1/messages, allowed tool

  1. Command:
    curl -sS --max-time 300 -w '\nHTTP %{http_code}\n' http://127.0.0.1:4010/v1/messages -H 'Authorization: Bearer sk-local-pr-qa' -H 'Content-Type: application/json' -d '{"model":"local-qa","max_tokens":256,"temperature":0,"messages":[{"role":"user","content":"Use the tool to look up Istanbul."}],"tools":[{"name":"get_weather","description":"Look up the weather in a city","input_schema":{"type":"object","properties":{"city":{"type":"string"}},"required":["city"]}}]}'
  2. Observed output, selected fields:
    {
      "content": [
        {
          "type": "text",
          "text": ""
        },
        {
          "type": "tool_use",
          "name": "get_weather",
          "input": {
            "city": "Istanbul"
          }
        }
      ],
      "stop_reason": "tool_use",
      "usage": {
        "input_tokens": 278,
        "output_tokens": 27
      }
    }
    HTTP 200

/v1/responses, denied tool

  1. Command:
    curl -sS --max-time 300 -w '\nHTTP %{http_code}\n' http://127.0.0.1:4010/v1/responses -H 'Authorization: Bearer sk-local-pr-qa' -H 'Content-Type: application/json' -d '{"model":"local-qa","temperature":0,"input":"Use the tool to look up Istanbul.","max_output_tokens":256,"tools":[{"type":"function","name":"mcp__my_server__lookup","description":"Look up the weather in a city","parameters":{"type":"object","properties":{"city":{"type":"string"}},"required":["city"]}}]}'
  2. Observed output, selected fields:
    {
      "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
      }
    }
    HTTP 200

/v1/responses, allowed tool

  1. Command:
    curl -sS --max-time 300 -w '\nHTTP %{http_code}\n' http://127.0.0.1:4010/v1/responses -H 'Authorization: Bearer sk-local-pr-qa' -H 'Content-Type: application/json' -d '{"model":"local-qa","temperature":0,"input":"Use the tool to look up Istanbul.","max_output_tokens":256,"tools":[{"type":"function","name":"get_weather","description":"Look up the weather in a city","parameters":{"type":"object","properties":{"city":{"type":"string"}},"required":["city"]}}]}'
  2. Observed output, selected fields:
    {
      "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
      }
    }
    HTTP 200

/v1/chat/completions, denied tool

  1. Command:
    curl -sS --max-time 300 -w '\nHTTP %{http_code}\n' http://127.0.0.1:4010/v1/chat/completions -H 'Authorization: Bearer sk-local-pr-qa' -H 'Content-Type: application/json' -d '{"model":"local-qa","max_tokens":256,"temperature":0,"messages":[{"role":"user","content":"Use the tool to look up Istanbul."}],"tools":[{"type":"function","function":{"name":"mcp__my_server__lookup","description":"Look up the weather in a city","parameters":{"type":"object","properties":{"city":{"type":"string"}},"required":["city"]}}}]}'
  2. Observed output, selected fields:
    {
      "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"
        }
      }
    }
    HTTP 400

/v1/chat/completions, allowed tool

  1. Command:
    curl -sS --max-time 300 -w '\nHTTP %{http_code}\n' http://127.0.0.1:4010/v1/chat/completions -H 'Authorization: Bearer sk-local-pr-qa' -H 'Content-Type: application/json' -d '{"model":"local-qa","max_tokens":256,"temperature":0,"messages":[{"role":"user","content":"Use the tool to look up Istanbul."}],"tools":[{"type":"function","function":{"name":"get_weather","description":"Look up the weather in a city","parameters":{"type":"object","properties":{"city":{"type":"string"}},"required":["city"]}}}]}'
  2. Observed output, selected fields:
    {
      "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
      }
    }
    HTTP 200

After (a21d5b4)

/v1/messages, denied tool

  1. Command:
    curl -sS --max-time 300 -w '\nHTTP %{http_code}\n' http://127.0.0.1:4010/v1/messages -H 'Authorization: Bearer sk-local-pr-qa' -H 'Content-Type: application/json' -d '{"model":"local-qa","max_tokens":256,"temperature":0,"messages":[{"role":"user","content":"Use the tool to look up Istanbul."}],"tools":[{"name":"mcp__my_server__lookup","description":"Look up the weather in a city","input_schema":{"type":"object","properties":{"city":{"type":"string"}},"required":["city"]}}]}'
  2. Observed output, selected fields:
    {
      "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"
        }
      }
    }
    HTTP 400

/v1/messages, allowed tool

  1. Command:
    curl -sS --max-time 300 -w '\nHTTP %{http_code}\n' http://127.0.0.1:4010/v1/messages -H 'Authorization: Bearer sk-local-pr-qa' -H 'Content-Type: application/json' -d '{"model":"local-qa","max_tokens":256,"temperature":0,"messages":[{"role":"user","content":"Use the tool to look up Istanbul."}],"tools":[{"name":"get_weather","description":"Look up the weather in a city","input_schema":{"type":"object","properties":{"city":{"type":"string"}},"required":["city"]}}]}'
  2. Observed output, selected fields:
    {
      "content": [
        {
          "type": "text",
          "text": ""
        },
        {
          "type": "tool_use",
          "name": "get_weather",
          "input": {
            "city": "Istanbul"
          }
        }
      ],
      "stop_reason": "tool_use",
      "usage": {
        "input_tokens": 278,
        "output_tokens": 27
      }
    }
    HTTP 200

/v1/responses, denied tool

  1. Command:
    curl -sS --max-time 300 -w '\nHTTP %{http_code}\n' http://127.0.0.1:4010/v1/responses -H 'Authorization: Bearer sk-local-pr-qa' -H 'Content-Type: application/json' -d '{"model":"local-qa","temperature":0,"input":"Use the tool to look up Istanbul.","max_output_tokens":256,"tools":[{"type":"function","name":"mcp__my_server__lookup","description":"Look up the weather in a city","parameters":{"type":"object","properties":{"city":{"type":"string"}},"required":["city"]}}]}'
  2. Observed output, selected fields:
    {
      "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"
        }
      }
    }
    HTTP 400

/v1/responses, allowed tool

  1. Command:
    curl -sS --max-time 300 -w '\nHTTP %{http_code}\n' http://127.0.0.1:4010/v1/responses -H 'Authorization: Bearer sk-local-pr-qa' -H 'Content-Type: application/json' -d '{"model":"local-qa","temperature":0,"input":"Use the tool to look up Istanbul.","max_output_tokens":256,"tools":[{"type":"function","name":"get_weather","description":"Look up the weather in a city","parameters":{"type":"object","properties":{"city":{"type":"string"}},"required":["city"]}}]}'
  2. Observed output, selected fields:
    {
      "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
      }
    }
    HTTP 200

/v1/chat/completions, denied tool

  1. Command:
    curl -sS --max-time 300 -w '\nHTTP %{http_code}\n' http://127.0.0.1:4010/v1/chat/completions -H 'Authorization: Bearer sk-local-pr-qa' -H 'Content-Type: application/json' -d '{"model":"local-qa","max_tokens":256,"temperature":0,"messages":[{"role":"user","content":"Use the tool to look up Istanbul."}],"tools":[{"type":"function","function":{"name":"mcp__my_server__lookup","description":"Look up the weather in a city","parameters":{"type":"object","properties":{"city":{"type":"string"}},"required":["city"]}}}]}'
  2. Observed output, selected fields:
    {
      "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"
        }
      }
    }
    HTTP 400

/v1/chat/completions, allowed tool

  1. Command:
    curl -sS --max-time 300 -w '\nHTTP %{http_code}\n' http://127.0.0.1:4010/v1/chat/completions -H 'Authorization: Bearer sk-local-pr-qa' -H 'Content-Type: application/json' -d '{"model":"local-qa","max_tokens":256,"temperature":0,"messages":[{"role":"user","content":"Use the tool to look up Istanbul."}],"tools":[{"type":"function","function":{"name":"get_weather","description":"Look up the weather in a city","parameters":{"type":"object","properties":{"city":{"type":"string"}},"required":["city"]}}}]}'
  2. Observed output, selected fields:
    {
      "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
      }
    }
    HTTP 200

Type

Bug Fix and Test

Caveats

Severe

  • Previously unchecked flat-format tools now undergo permission checks
    • With default_action: deny, unlisted tools receive HTTP 400
    • Named Anthropic server tools are checked too

Low

  • Interactive Claude Code screenshots remain unavailable (desktop permission denied)
  • Rewriting denied named tool choices has only unit-test validation
  • The separate custom-code sandbox issue is outside this PR

QA runbook

Use a local proxy at http://localhost:4000 backed by Postgres, with general_settings.master_key, general_settings.store_model_in_db: true, and a gemini-2.5-flash deployment backed by gemini/gemini-2.5-flash. Real-provider reproduction needs GEMINI_API_KEY in the proxy environment. Set LITELLM_MASTER_KEY and LITELLM_PROXY_URL=http://localhost:4000 for the harness. See tests/e2e/CONTRIBUTING.md for stack setup

For each case, mirror scoped_key and GuardrailsClient.register with fresh resources. POST http://localhost:4000/key/generate using the master-key bearer token and {"models":[],"user_id":"e2e-test-user"}, then retain the returned key. POST http://localhost:4000/guardrails using the master key and the body below, replacing NAME with a unique case name. Retain guardrail_id and 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 prompt What is the weather in Paris right now?. The allowed tool is get_weather, described as Get the current weather for a city, with {"type":"object","properties":{"city":{"type":"string"}},"required":["city"]}. The denied tool is delete_customer_database, described as Permanently delete the customer database, with {"type":"object","properties":{}}

After each case, DELETE http://localhost:4000/guardrails/{guardrail_id} using the master key, and POST http://localhost:4000/key/delete with {"keys":["GENERATED_KEY"]} using the master key. The harness registers both cleanups through resources

  • tests/e2e/guardrails/test_tool_permission_guardrail_e2e.py::TestToolPermissionPreCall::test_pre_call_blocks_tool_outside_the_allow_list proves Chat Completions rejects a denied nested tool

    • Create the fresh key and guardrail through /key/generate and /guardrails as above
    • POST http://localhost:4000/chat/completions with "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":{}}}}]
    • Expect HTTP 400 with delete_customer_database and a guardrail verdict in the error body
    • Sanity check by removing guardrails from the same request and expecting HTTP 200
  • tests/e2e/guardrails/test_tool_permission_guardrail_e2e.py::TestToolPermissionPreCall::test_pre_call_allows_permitted_tool proves Chat Completions serves a permitted tool and reports the guardrail

    • Create the fresh key and guardrail through /key/generate and /guardrails as above
    • POST http://localhost:4000/chat/completions with the same messages, "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"]}}}]
    • Expect HTTP 200, exactly one choices[].message.tool_calls[].function.name equal to get_weather, and NAME in x-litellm-applied-guardrails
    • Sanity check by removing guardrails, expecting HTTP 200 with NAME absent from the applied-guardrails header
  • tests/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

    • Create the fresh key and guardrail through /key/generate and /guardrails as above
    • POST http://localhost:4000/v1/messages with the same messages, "max_tokens":128, and "tools":[{"name":"delete_customer_database","description":"Permanently delete the customer database","input_schema":{"type":"object","properties":{},"required":[]}}]
    • Expect HTTP 400 with delete_customer_database and Violated guardrail policy in the error body
    • Sanity check by removing guardrails from the same request and expecting HTTP 200
  • tests/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

    • Create the fresh key and guardrail through /key/generate and /guardrails as above
    • POST http://localhost:4000/v1/responses with "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":[]}}]
    • Expect HTTP 400 with delete_customer_database and Violated guardrail policy in the error body
    • Sanity check by removing guardrails from the same request and expecting HTTP 200
  • tests/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

    • Create the fresh key and guardrail through /key/generate and /guardrails as above
    • POST http://localhost:4000/v1/messages with the same messages, "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"]}}]
    • Expect HTTP 200, exactly one content[] block with "type":"tool_use" and "name":"get_weather", and NAME in x-litellm-applied-guardrails
    • Sanity check by removing guardrails, expecting HTTP 200 with NAME absent from the applied-guardrails header
  • tests/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

    • Create the fresh key and guardrail through /key/generate and /guardrails as above
    • POST http://localhost:4000/v1/responses with "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"]}}]
    • Expect HTTP 200, exactly one output[] item with "type":"function_call" and "name":"get_weather", and NAME in x-litellm-applied-guardrails
    • Sanity check by removing guardrails, expecting HTTP 200 with NAME absent from the applied-guardrails header

Final Attestation

  • The tests check the right things, including the edge cases, and regressions in the respective real-world customer use-cases are not possible after this PR

@codspeed

codspeed Bot commented Sep 13, 2026 •

Copy link
Copy Markdown
Contributor

Merging this PR will not alter performance

✅ 31 untouched benchmarks


Comparing meryemsakin:fix-tool-permission-anthropic-request-tools (c55c340) with main (7ff5010)

Open in CodSpeed

@greptile-apps

greptile-apps Bot commented Sep 13, 2026 •

Copy link
Copy Markdown
Contributor

RetriggerConfidence Score: 5/5

[Medium risk] Updates tool permission checks for Anthropic API format.

The PR appears safe to merge based on this review.

Summary

The PR checks flat Anthropic and Responses request tools before a model call, applies permission rules to named tool choices, and removes denied tools in rewrite mode. Focused tests cover blocking, tool-type matching, and rewriting.

Reviews (8) · Last reviewed commit: "chore: merge main to run current require..."

Comment thread litellm/proxy/guardrails/guardrail_hooks/tool_permission.py Outdated
Comment thread litellm/proxy/guardrails/guardrail_hooks/tool_permission.py Fixed
@codecov

codecov Bot commented Sep 13, 2026 •

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.

📢 Thoughts on this report? Let us know!

@meryemsakin

Copy link
Copy Markdown
Author

@greptileai

Comment thread tests/test_litellm/proxy/guardrails/guardrail_hooks/test_tool_permission.py Outdated
@meryemsakin

Copy link
Copy Markdown
Author

@greptileai

Comment thread tests/test_litellm/proxy/guardrails/guardrail_hooks/test_tool_permission.py Outdated
@meryemsakin

Copy link
Copy Markdown
Author

@greptileai

@meryemsakin
meryemsakin force-pushed the fix-tool-permission-anthropic-request-tools branch from 0816a00 to d62fc49 Compare September 13, 2026 20:50
@meryemsakin

Copy link
Copy Markdown
Author

@greptileai

Comment thread tests/proxy_behavior/auth/test_auth_object_prefetch.py Outdated
@meryemsakin

Copy link
Copy Markdown
Author

@greptileai

crognlie added a commit to crognlie/litellm that referenced this pull request Sep 13, 2026
…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.
@meryemsakin
meryemsakin force-pushed the fix-tool-permission-anthropic-request-tools branch from 1aec6d9 to c25cee4 Compare September 15, 2026 13:39
@meryemsakin

Copy link
Copy Markdown
Author

Rebased on main and dropped the two commits that were not part of this fix, so the PR is now just the guardrail change and its tests (2 files, +99/-21).

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 main, and its module-level import closure never reaches the root package.

Comment thread litellm/proxy/guardrails/guardrail_hooks/tool_permission.py Outdated
@veria-ai

veria-ai Bot commented Sep 15, 2026 •

Copy link
Copy Markdown
Contributor

PR overview

All previously flagged issues have been addressed. No open security concerns remain on this pull request.

Security review

No 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.
@meryemsakin

Copy link
Copy Markdown
Author

@greptileai

@meryemsakin

Copy link
Copy Markdown
Author

Merged current main. All 66 focused tests and full lint pass; reverting the fix fails all seven regression cases. @greptileai

@meryemsakin

Copy link
Copy Markdown
Author

@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 (/v1/messages) or Responses requests, so a deny rule such as ^mcp__.*$ can be bypassed by sending the tool through those endpoints.

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 chrikrah left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@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.

@meryemsakin

Copy link
Copy Markdown
Author

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 chrikrah left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@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.

@meryemsakin

Copy link
Copy Markdown
Author

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 tests/e2e/guardrails/test_tool_permission_guardrail_e2e.py so exercised_on covers messages and responses. That is the regression this PR is meant to prevent, so I think it belongs here rather than in a follow-up.

Could you open a PR against my branch (meryemsakin:fix-tool-permission-anthropic-request-tools) or post the diff here? I'll merge your PR or apply the diff with a Co-authored-by trailer, then rerun the focused tests and lint.

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 chrikrah left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@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?

meryemsakin and others added 2 commits October 10, 2026 14:08
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>
@meryemsakin

Copy link
Copy Markdown
Author

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.

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants