From 0b0a9abd9098d3045ff1218470bb9df97b26d5b1 Mon Sep 17 00:00:00 2001 From: Alexsander Hamir Date: Mon, 5 Jan 2026 11:24:19 -0800 Subject: [PATCH] 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 --- .../guardrail_hooks/tool_permission.py | 5 +- .../guardrail_hooks/tool_permission.py | 24 +++++++ .../guardrail_hooks/test_tool_permission.py | 70 +++++++++++++++++++ 3 files changed, 97 insertions(+), 2 deletions(-) diff --git a/litellm/proxy/guardrails/guardrail_hooks/tool_permission.py b/litellm/proxy/guardrails/guardrail_hooks/tool_permission.py index 64753d9fa85..bec76acc50e 100644 --- a/litellm/proxy/guardrails/guardrail_hooks/tool_permission.py +++ b/litellm/proxy/guardrails/guardrail_hooks/tool_permission.py @@ -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", diff --git a/litellm/types/proxy/guardrails/guardrail_hooks/tool_permission.py b/litellm/types/proxy/guardrails/guardrail_hooks/tool_permission.py index 2ed1f3d2e3a..b47e40196e0 100644 --- a/litellm/types/proxy/guardrails/guardrail_hooks/tool_permission.py +++ b/litellm/types/proxy/guardrails/guardrail_hooks/tool_permission.py @@ -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" diff --git a/tests/test_litellm/proxy/guardrails/guardrail_hooks/test_tool_permission.py b/tests/test_litellm/proxy/guardrails/guardrail_hooks/test_tool_permission.py index a7fd1c64955..0588515cff3 100644 --- a/tests/test_litellm/proxy/guardrails/guardrail_hooks/test_tool_permission.py +++ b/tests/test_litellm/proxy/guardrails/guardrail_hooks/test_tool_permission.py @@ -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"