mirror of
https://github.com/BerriAI/litellm.git
synced 2026-09-12 23:01:41 +00:00
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:
parent
3b847e0d9d
commit
0b0a9abd90
3 changed files with 97 additions and 2 deletions
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue