fix: normalize case for tool permission guardrail fields to prevent validation errors (#18662)

This fixes a critical issue where capitalized values in tool_permission guardrail
configurations (e.g., "Deny" instead of "deny") caused Pydantic validation errors
during proxy startup, leading to repeated initialization failures and latency issues.

Problem:
- Users could save guardrails with capitalized values through UI/API
- Data was written to database without validation (e.g., default_action: "Deny")
- On proxy startup, loading from DB triggered strict Pydantic validation
- ValidationError caused guardrail initialization to fail in a retry loop
- This resulted in startup delays and repeated error logging

Root Cause:
- Write path had no case normalization
- Read path enforced strict lowercase Literal validation
- Asymmetry between write and read caused latent data corruption

Solution:
Added field validators to normalize case before Pydantic validation:

1. ToolPermissionRule.decision ("allow"/"deny")
   - Normalizes decision field in rules array

2. ToolPermissionGuardrailConfigModel.default_action ("allow"/"deny")
   - Normalizes default fallback action

3. ToolPermissionGuardrailConfigModel.on_disallowed_action ("block"/"rewrite")
   - Normalizes disallowed tool behavior

4. ToolPermissionGuardrail.__init__ normalization
   - Defensive normalization for direct instantiation
   - Ensures normalization regardless of code path

Impact:
- Prevents validation errors during guardrail initialization
- Eliminates startup retry loops and latency issues
- Handles existing database records with capitalized values
- Accepts case-insensitive input from all sources (UI, API, direct calls)
- Fully backward compatible with existing lowercase configurations

Testing:
- Added 3 comprehensive tests for case-insensitive handling
- All 27 existing tests still pass
- Tests verify normalization across all affected fields

Files Changed:
- litellm/types/proxy/guardrails/guardrail_hooks/tool_permission.py
  Added @field_validator decorators for case normalization
- litellm/proxy/guardrails/guardrail_hooks/tool_permission.py
  Added runtime normalization in __init__ method
- tests/test_litellm/proxy/guardrails/guardrail_hooks/test_tool_permission.py
  Added case-insensitive validation tests
This commit is contained in:
Alexsander Hamir 2026-01-05 11:24:19 -08:00 committed by GitHub
parent 3b847e0d9d
commit 0b0a9abd90
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
3 changed files with 97 additions and 2 deletions

View file

@ -108,8 +108,9 @@ class ToolPermissionGuardrail(CustomGuardrail):
if compiled_patterns:
self._compiled_rule_patterns[rule.id] = compiled_patterns
self.default_action = default_action
self.on_disallowed_action = on_disallowed_action
# Normalize to lowercase for case-insensitive handling
self.default_action = default_action.lower() if isinstance(default_action, str) else default_action
self.on_disallowed_action = on_disallowed_action.lower() if isinstance(on_disallowed_action, str) else on_disallowed_action
verbose_proxy_logger.debug(
"Tool Permission Guardrail initialized with %d rules, default_action: %s",

View file

@ -40,6 +40,14 @@ class ToolPermissionRule(BaseModel):
return stripped
return value
@field_validator("decision", mode="before")
@classmethod
def normalize_decision(cls, v):
"""Normalize decision to lowercase to handle case-insensitive input."""
if isinstance(v, str):
return v.lower()
return v
@model_validator(mode="after")
def _ensure_target_present(self):
if self.tool_name is None and self.tool_type is None:
@ -87,6 +95,22 @@ class ToolPermissionGuardrailConfigModel(GuardrailConfigModel):
description="Choose whether disallowed tools block the request or get rewritten out of the payload",
)
@field_validator("default_action", mode="before")
@classmethod
def normalize_default_action(cls, v):
"""Normalize default_action to lowercase to handle case-insensitive input."""
if isinstance(v, str):
return v.lower()
return v
@field_validator("on_disallowed_action", mode="before")
@classmethod
def normalize_on_disallowed_action(cls, v):
"""Normalize on_disallowed_action to lowercase to handle case-insensitive input."""
if isinstance(v, str):
return v.lower()
return v
@staticmethod
def ui_friendly_name() -> str:
return "LiteLLM Tool Permission Guardrail"

View file

@ -558,3 +558,73 @@ class TestToolPermissionGuardrailIntegration:
assert is_allowed is True
assert rule_id is None
assert "default" in (message or "")
def test_case_insensitive_default_action(self):
"""Test that default_action accepts capitalized values and normalizes them"""
# Test capitalized 'Deny'
guardrail = ToolPermissionGuardrail(
guardrail_name="test-case-insensitive",
rules=[],
default_action="Deny", # Should be normalized to 'deny'
)
assert guardrail.default_action == "deny"
# Test capitalized 'Allow'
guardrail2 = ToolPermissionGuardrail(
guardrail_name="test-case-insensitive2",
rules=[],
default_action="Allow", # Should be normalized to 'allow'
)
assert guardrail2.default_action == "allow"
# Test uppercase 'DENY'
guardrail3 = ToolPermissionGuardrail(
guardrail_name="test-case-insensitive3",
rules=[],
default_action="DENY", # Should be normalized to 'deny'
)
assert guardrail3.default_action == "deny"
def test_case_insensitive_on_disallowed_action(self):
"""Test that on_disallowed_action accepts capitalized values and normalizes them"""
# Test capitalized 'Block'
guardrail = ToolPermissionGuardrail(
guardrail_name="test-on-disallowed",
rules=[],
default_action="deny",
on_disallowed_action="Block", # Should be normalized to 'block'
)
assert guardrail.on_disallowed_action == "block"
# Test capitalized 'Rewrite'
guardrail2 = ToolPermissionGuardrail(
guardrail_name="test-on-disallowed2",
rules=[],
default_action="deny",
on_disallowed_action="Rewrite", # Should be normalized to 'rewrite'
)
assert guardrail2.on_disallowed_action == "rewrite"
def test_case_insensitive_decision_in_rules(self):
"""Test that decision field in rules accepts capitalized values and normalizes them"""
guardrail = ToolPermissionGuardrail(
guardrail_name="test-decision-case",
rules=[
{"id": "allow_bash", "tool_name": r"^Bash$", "decision": "Allow"}, # Capitalized
{"id": "deny_read", "tool_name": r"^Read$", "decision": "DENY"}, # Uppercase
],
default_action="deny",
)
# Verify rules are normalized
assert guardrail.rules[0].decision == "allow"
assert guardrail.rules[1].decision == "deny"
# Verify functionality still works
is_allowed, rule_id, _ = guardrail._check_tool_permission("Bash")
assert is_allowed is True
assert rule_id == "allow_bash"
is_allowed, rule_id, _ = guardrail._check_tool_permission("Read")
assert is_allowed is False
assert rule_id == "deny_read"