mirror of
https://github.com/BerriAI/litellm.git
synced 2026-10-02 02:11:58 +00:00
fix(packaging): keep wheel paths under Windows MAX_PATH for Store Python (#43903)
* fix(packaging): keep wheel paths under Windows MAX_PATH for Store Python pip install litellm fails on Microsoft Store Python because its user site-packages is already 134 chars plus the profile name, and the content filter guardrail ships YAML five directories deep under litellm/proxy/guardrails/guardrail_hooks/litellm_content_filter/. The existing wheel guard assumed a 100-char install prefix, so it never saw it. Move categories/ and policy_templates/ to litellm/proxy/guardrails/content_filter_data/ and drop the benchmark fixtures from the wheel. Old category_file paths keep resolving because the resolver only keys on the trailing categories/<file> or policy_templates/<file> suffix. Derive the guard's worst-case prefix from the Store Python site-packages path with a 15-char profile name (149), fail files at 260 and directories at 248 (CreateDirectoryW), and fix the off-by-one that let a 260-char path through. Fixes #43851 * ci: run the Windows wheel install guard on pull requests The two Windows jobs live in CircleCI, which never runs on pull requests, so nothing installs the wheel on Windows before merge. Add a GitHub Actions job on windows-latest that builds the wheel and runs the guard. Two things make the run deterministic instead of image dependent. The job turns the LongPathsEnabled registry key off first, because runner images ship with it on and python.exe is long-path aware, so a 300-char path would install fine. The guard installs with pip instead of uv, because uv writes files from Rust, which switches to extended-length paths on its own and can never hit MAX_PATH. * fix(guardrails): keep the old content filter package dir as a category search root Deployments that copied their own category YAML into guardrail_hooks/litellm_content_filter/ before the data move would have had that file rejected by the new directory jail and missing from by-name loads, inherit_from lookups, the UI category listing and the category YAML endpoint. Every lookup now searches the bundled data dir first and the old package dir second, with the bundled copy winning on a name clash. * fix(guardrails): resolve category files through safe_join By-name category lookups and the suffix search in the category_file resolver now go through safe_join, so a name or suffix that would escape its data root never reaches the filesystem. The LITELLM_CONTENT_FILTER_ALLOW_EXTERNAL_PATHS opt-out keeps its unjailed search. Clears the two CodeQL path-injection findings on the new lookup code. * fix(guardrails): keep symlinked category files loadable by name By-name category lookups resolved symlinks through safe_join, so a category file symlinked into the categories folder from elsewhere stopped loading. Those lookups now only reject names that leave the folder lexically and return the link untouched, matching how by-name loads behaved before the data move. The category_file resolver keeps its realpath jail as before. * fix(guardrails): keep the category viewer inside the category folders GET /guardrails/ui/category_yaml/{name} hands raw file contents to any valid key, and on main it refused a symlink whose target left the categories folder. The previous commit let by-name lookups follow symlinks again, which also let the viewer read whatever a symlink in a legacy categories folder pointed at. The viewer now checks the found file's real path against every categories folder it searches and answers 400 as before, while the guardrail's own by-name loads keep following symlinks The roots come in through a FastAPI dependency so the check is testable against a temp folder, and the content filter's realpath containment moves to path_utils.is_within so both surfaces share it. The test that patched os.path.commonpath covered a branch that no longer exists and goes with it * ci: drop the Windows wheel install job from pull requests The job took about 13 minutes on every PR to guard an edge case. The guard still runs its path-length check on Linux in base_sdk_install and on Windows in the CircleCI windows_release_wheel job.
This commit is contained in:
parent
629c2b5808
commit
f39c811d34
78 changed files with 623 additions and 345 deletions
|
|
@ -1128,7 +1128,7 @@
|
|||
"categories": [
|
||||
{
|
||||
"category": "eu_ai_act_art5_manipulation",
|
||||
"category_file": "litellm/proxy/guardrails/guardrail_hooks/litellm_content_filter/policy_templates/eu_ai_act_art5_manipulation.yaml",
|
||||
"category_file": "litellm/proxy/guardrails/content_filter_data/policy_templates/eu_ai_act_art5_manipulation.yaml",
|
||||
"enabled": true,
|
||||
"action": "BLOCK",
|
||||
"severity_threshold": "medium"
|
||||
|
|
@ -1147,7 +1147,7 @@
|
|||
"categories": [
|
||||
{
|
||||
"category": "eu_ai_act_art5_vulnerability",
|
||||
"category_file": "litellm/proxy/guardrails/guardrail_hooks/litellm_content_filter/policy_templates/eu_ai_act_art5_vulnerability.yaml",
|
||||
"category_file": "litellm/proxy/guardrails/content_filter_data/policy_templates/eu_ai_act_art5_vulnerability.yaml",
|
||||
"enabled": true,
|
||||
"action": "BLOCK",
|
||||
"severity_threshold": "medium"
|
||||
|
|
@ -1166,7 +1166,7 @@
|
|||
"categories": [
|
||||
{
|
||||
"category": "eu_ai_act_art5_social_scoring",
|
||||
"category_file": "litellm/proxy/guardrails/guardrail_hooks/litellm_content_filter/policy_templates/eu_ai_act_art5_social_scoring.yaml",
|
||||
"category_file": "litellm/proxy/guardrails/content_filter_data/policy_templates/eu_ai_act_art5_social_scoring.yaml",
|
||||
"enabled": true,
|
||||
"action": "BLOCK",
|
||||
"severity_threshold": "medium"
|
||||
|
|
@ -1185,7 +1185,7 @@
|
|||
"categories": [
|
||||
{
|
||||
"category": "eu_ai_act_art5_emotion_recognition",
|
||||
"category_file": "litellm/proxy/guardrails/guardrail_hooks/litellm_content_filter/policy_templates/eu_ai_act_art5_emotion_recognition.yaml",
|
||||
"category_file": "litellm/proxy/guardrails/content_filter_data/policy_templates/eu_ai_act_art5_emotion_recognition.yaml",
|
||||
"enabled": true,
|
||||
"action": "BLOCK",
|
||||
"severity_threshold": "medium"
|
||||
|
|
@ -1204,7 +1204,7 @@
|
|||
"categories": [
|
||||
{
|
||||
"category": "eu_ai_act_art5_biometric_profiling",
|
||||
"category_file": "litellm/proxy/guardrails/guardrail_hooks/litellm_content_filter/policy_templates/eu_ai_act_art5_biometric_profiling.yaml",
|
||||
"category_file": "litellm/proxy/guardrails/content_filter_data/policy_templates/eu_ai_act_art5_biometric_profiling.yaml",
|
||||
"enabled": true,
|
||||
"action": "BLOCK",
|
||||
"severity_threshold": "medium"
|
||||
|
|
@ -1223,7 +1223,7 @@
|
|||
"categories": [
|
||||
{
|
||||
"category": "eu_ai_act_art5_manipulation_fr",
|
||||
"category_file": "litellm/proxy/guardrails/guardrail_hooks/litellm_content_filter/policy_templates/eu_ai_act_art5_manipulation_fr.yaml",
|
||||
"category_file": "litellm/proxy/guardrails/content_filter_data/policy_templates/eu_ai_act_art5_manipulation_fr.yaml",
|
||||
"enabled": true,
|
||||
"action": "BLOCK",
|
||||
"severity_threshold": "medium"
|
||||
|
|
@ -1242,7 +1242,7 @@
|
|||
"categories": [
|
||||
{
|
||||
"category": "eu_ai_act_art5_vulnerability_fr",
|
||||
"category_file": "litellm/proxy/guardrails/guardrail_hooks/litellm_content_filter/policy_templates/eu_ai_act_art5_vulnerability_fr.yaml",
|
||||
"category_file": "litellm/proxy/guardrails/content_filter_data/policy_templates/eu_ai_act_art5_vulnerability_fr.yaml",
|
||||
"enabled": true,
|
||||
"action": "BLOCK",
|
||||
"severity_threshold": "medium"
|
||||
|
|
@ -1261,7 +1261,7 @@
|
|||
"categories": [
|
||||
{
|
||||
"category": "eu_ai_act_art5_social_scoring_fr",
|
||||
"category_file": "litellm/proxy/guardrails/guardrail_hooks/litellm_content_filter/policy_templates/eu_ai_act_art5_social_scoring_fr.yaml",
|
||||
"category_file": "litellm/proxy/guardrails/content_filter_data/policy_templates/eu_ai_act_art5_social_scoring_fr.yaml",
|
||||
"enabled": true,
|
||||
"action": "BLOCK",
|
||||
"severity_threshold": "medium"
|
||||
|
|
@ -1280,7 +1280,7 @@
|
|||
"categories": [
|
||||
{
|
||||
"category": "eu_ai_act_art5_emotion_recognition_fr",
|
||||
"category_file": "litellm/proxy/guardrails/guardrail_hooks/litellm_content_filter/policy_templates/eu_ai_act_art5_emotion_recognition_fr.yaml",
|
||||
"category_file": "litellm/proxy/guardrails/content_filter_data/policy_templates/eu_ai_act_art5_emotion_recognition_fr.yaml",
|
||||
"enabled": true,
|
||||
"action": "BLOCK",
|
||||
"severity_threshold": "medium"
|
||||
|
|
@ -1299,7 +1299,7 @@
|
|||
"categories": [
|
||||
{
|
||||
"category": "eu_ai_act_art5_biometric_profiling_fr",
|
||||
"category_file": "litellm/proxy/guardrails/guardrail_hooks/litellm_content_filter/policy_templates/eu_ai_act_art5_biometric_profiling_fr.yaml",
|
||||
"category_file": "litellm/proxy/guardrails/content_filter_data/policy_templates/eu_ai_act_art5_biometric_profiling_fr.yaml",
|
||||
"enabled": true,
|
||||
"action": "BLOCK",
|
||||
"severity_threshold": "medium"
|
||||
|
|
@ -1673,7 +1673,7 @@
|
|||
"categories": [
|
||||
{
|
||||
"category": "aviation_safety_topics",
|
||||
"category_file": "litellm/proxy/guardrails/guardrail_hooks/litellm_content_filter/policy_templates/aviation_safety_topics.yaml",
|
||||
"category_file": "litellm/proxy/guardrails/content_filter_data/policy_templates/aviation_safety_topics.yaml",
|
||||
"enabled": true,
|
||||
"action": "BLOCK",
|
||||
"severity_threshold": "medium"
|
||||
|
|
@ -1692,7 +1692,7 @@
|
|||
"categories": [
|
||||
{
|
||||
"category": "airline_brand_protection",
|
||||
"category_file": "litellm/proxy/guardrails/guardrail_hooks/litellm_content_filter/policy_templates/airline_brand_protection.yaml",
|
||||
"category_file": "litellm/proxy/guardrails/content_filter_data/policy_templates/airline_brand_protection.yaml",
|
||||
"enabled": true,
|
||||
"action": "BLOCK",
|
||||
"severity_threshold": "medium"
|
||||
|
|
@ -1864,7 +1864,7 @@
|
|||
"categories": [
|
||||
{
|
||||
"category": "airline_off_topic_restriction",
|
||||
"category_file": "litellm/proxy/guardrails/guardrail_hooks/litellm_content_filter/policy_templates/airline_off_topic_restriction.yaml",
|
||||
"category_file": "litellm/proxy/guardrails/content_filter_data/policy_templates/airline_off_topic_restriction.yaml",
|
||||
"enabled": true,
|
||||
"action": "BLOCK",
|
||||
"severity_threshold": "medium"
|
||||
|
|
@ -1962,7 +1962,7 @@
|
|||
"categories": [
|
||||
{
|
||||
"category": "uae_cultural_sensitivity",
|
||||
"category_file": "litellm/proxy/guardrails/guardrail_hooks/litellm_content_filter/policy_templates/uae_cultural_sensitivity.yaml",
|
||||
"category_file": "litellm/proxy/guardrails/content_filter_data/policy_templates/uae_cultural_sensitivity.yaml",
|
||||
"enabled": true,
|
||||
"action": "BLOCK",
|
||||
"severity_threshold": "medium"
|
||||
|
|
@ -1981,7 +1981,7 @@
|
|||
"categories": [
|
||||
{
|
||||
"category": "uae_anti_discrimination",
|
||||
"category_file": "litellm/proxy/guardrails/guardrail_hooks/litellm_content_filter/policy_templates/uae_anti_discrimination.yaml",
|
||||
"category_file": "litellm/proxy/guardrails/content_filter_data/policy_templates/uae_anti_discrimination.yaml",
|
||||
"enabled": true,
|
||||
"action": "BLOCK",
|
||||
"severity_threshold": "medium"
|
||||
|
|
@ -2575,7 +2575,7 @@
|
|||
"categories": [
|
||||
{
|
||||
"category": "sg_pdpa_personal_identifiers",
|
||||
"category_file": "litellm/proxy/guardrails/guardrail_hooks/litellm_content_filter/policy_templates/sg_pdpa_personal_identifiers.yaml",
|
||||
"category_file": "litellm/proxy/guardrails/content_filter_data/policy_templates/sg_pdpa_personal_identifiers.yaml",
|
||||
"enabled": true,
|
||||
"action": "BLOCK",
|
||||
"severity_threshold": "medium"
|
||||
|
|
@ -2594,7 +2594,7 @@
|
|||
"categories": [
|
||||
{
|
||||
"category": "sg_pdpa_sensitive_data",
|
||||
"category_file": "litellm/proxy/guardrails/guardrail_hooks/litellm_content_filter/policy_templates/sg_pdpa_sensitive_data.yaml",
|
||||
"category_file": "litellm/proxy/guardrails/content_filter_data/policy_templates/sg_pdpa_sensitive_data.yaml",
|
||||
"enabled": true,
|
||||
"action": "BLOCK",
|
||||
"severity_threshold": "medium"
|
||||
|
|
@ -2613,7 +2613,7 @@
|
|||
"categories": [
|
||||
{
|
||||
"category": "sg_pdpa_do_not_call",
|
||||
"category_file": "litellm/proxy/guardrails/guardrail_hooks/litellm_content_filter/policy_templates/sg_pdpa_do_not_call.yaml",
|
||||
"category_file": "litellm/proxy/guardrails/content_filter_data/policy_templates/sg_pdpa_do_not_call.yaml",
|
||||
"enabled": true,
|
||||
"action": "BLOCK",
|
||||
"severity_threshold": "medium"
|
||||
|
|
@ -2632,7 +2632,7 @@
|
|||
"categories": [
|
||||
{
|
||||
"category": "sg_pdpa_data_transfer",
|
||||
"category_file": "litellm/proxy/guardrails/guardrail_hooks/litellm_content_filter/policy_templates/sg_pdpa_data_transfer.yaml",
|
||||
"category_file": "litellm/proxy/guardrails/content_filter_data/policy_templates/sg_pdpa_data_transfer.yaml",
|
||||
"enabled": true,
|
||||
"action": "BLOCK",
|
||||
"severity_threshold": "medium"
|
||||
|
|
@ -2651,7 +2651,7 @@
|
|||
"categories": [
|
||||
{
|
||||
"category": "sg_pdpa_profiling_automated_decisions",
|
||||
"category_file": "litellm/proxy/guardrails/guardrail_hooks/litellm_content_filter/policy_templates/sg_pdpa_profiling_automated_decisions.yaml",
|
||||
"category_file": "litellm/proxy/guardrails/content_filter_data/policy_templates/sg_pdpa_profiling_automated_decisions.yaml",
|
||||
"enabled": true,
|
||||
"action": "BLOCK",
|
||||
"severity_threshold": "medium"
|
||||
|
|
@ -2710,7 +2710,7 @@
|
|||
"categories": [
|
||||
{
|
||||
"category": "sg_mas_fairness_bias",
|
||||
"category_file": "litellm/proxy/guardrails/guardrail_hooks/litellm_content_filter/policy_templates/sg_mas_fairness_bias.yaml",
|
||||
"category_file": "litellm/proxy/guardrails/content_filter_data/policy_templates/sg_mas_fairness_bias.yaml",
|
||||
"enabled": true,
|
||||
"action": "BLOCK",
|
||||
"severity_threshold": "medium"
|
||||
|
|
@ -2729,7 +2729,7 @@
|
|||
"categories": [
|
||||
{
|
||||
"category": "sg_mas_transparency_explainability",
|
||||
"category_file": "litellm/proxy/guardrails/guardrail_hooks/litellm_content_filter/policy_templates/sg_mas_transparency_explainability.yaml",
|
||||
"category_file": "litellm/proxy/guardrails/content_filter_data/policy_templates/sg_mas_transparency_explainability.yaml",
|
||||
"enabled": true,
|
||||
"action": "BLOCK",
|
||||
"severity_threshold": "medium"
|
||||
|
|
@ -2748,7 +2748,7 @@
|
|||
"categories": [
|
||||
{
|
||||
"category": "sg_mas_human_oversight",
|
||||
"category_file": "litellm/proxy/guardrails/guardrail_hooks/litellm_content_filter/policy_templates/sg_mas_human_oversight.yaml",
|
||||
"category_file": "litellm/proxy/guardrails/content_filter_data/policy_templates/sg_mas_human_oversight.yaml",
|
||||
"enabled": true,
|
||||
"action": "BLOCK",
|
||||
"severity_threshold": "medium"
|
||||
|
|
@ -2767,7 +2767,7 @@
|
|||
"categories": [
|
||||
{
|
||||
"category": "sg_mas_data_governance",
|
||||
"category_file": "litellm/proxy/guardrails/guardrail_hooks/litellm_content_filter/policy_templates/sg_mas_data_governance.yaml",
|
||||
"category_file": "litellm/proxy/guardrails/content_filter_data/policy_templates/sg_mas_data_governance.yaml",
|
||||
"enabled": true,
|
||||
"action": "BLOCK",
|
||||
"severity_threshold": "medium"
|
||||
|
|
@ -2786,7 +2786,7 @@
|
|||
"categories": [
|
||||
{
|
||||
"category": "sg_mas_model_security",
|
||||
"category_file": "litellm/proxy/guardrails/guardrail_hooks/litellm_content_filter/policy_templates/sg_mas_model_security.yaml",
|
||||
"category_file": "litellm/proxy/guardrails/content_filter_data/policy_templates/sg_mas_model_security.yaml",
|
||||
"enabled": true,
|
||||
"action": "BLOCK",
|
||||
"severity_threshold": "medium"
|
||||
|
|
@ -2841,7 +2841,7 @@
|
|||
"categories": [
|
||||
{
|
||||
"category": "claims_fraud_coaching",
|
||||
"category_file": "litellm/proxy/guardrails/guardrail_hooks/litellm_content_filter/categories/claims_fraud_coaching.yaml",
|
||||
"category_file": "litellm/proxy/guardrails/content_filter_data/categories/claims_fraud_coaching.yaml",
|
||||
"enabled": true,
|
||||
"action": "BLOCK",
|
||||
"severity_threshold": "medium"
|
||||
|
|
@ -2860,7 +2860,7 @@
|
|||
"categories": [
|
||||
{
|
||||
"category": "claims_phi_disclosure",
|
||||
"category_file": "litellm/proxy/guardrails/guardrail_hooks/litellm_content_filter/categories/claims_phi_disclosure.yaml",
|
||||
"category_file": "litellm/proxy/guardrails/content_filter_data/categories/claims_phi_disclosure.yaml",
|
||||
"enabled": true,
|
||||
"action": "BLOCK",
|
||||
"severity_threshold": "medium"
|
||||
|
|
@ -2879,7 +2879,7 @@
|
|||
"categories": [
|
||||
{
|
||||
"category": "claims_prior_auth_gaming",
|
||||
"category_file": "litellm/proxy/guardrails/guardrail_hooks/litellm_content_filter/categories/claims_prior_auth_gaming.yaml",
|
||||
"category_file": "litellm/proxy/guardrails/content_filter_data/categories/claims_prior_auth_gaming.yaml",
|
||||
"enabled": true,
|
||||
"action": "BLOCK",
|
||||
"severity_threshold": "medium"
|
||||
|
|
@ -2898,7 +2898,7 @@
|
|||
"categories": [
|
||||
{
|
||||
"category": "claims_system_override",
|
||||
"category_file": "litellm/proxy/guardrails/guardrail_hooks/litellm_content_filter/categories/claims_system_override.yaml",
|
||||
"category_file": "litellm/proxy/guardrails/content_filter_data/categories/claims_system_override.yaml",
|
||||
"enabled": true,
|
||||
"action": "BLOCK",
|
||||
"severity_threshold": "medium"
|
||||
|
|
@ -2917,7 +2917,7 @@
|
|||
"categories": [
|
||||
{
|
||||
"category": "claims_medical_advice",
|
||||
"category_file": "litellm/proxy/guardrails/guardrail_hooks/litellm_content_filter/categories/claims_medical_advice.yaml",
|
||||
"category_file": "litellm/proxy/guardrails/content_filter_data/categories/claims_medical_advice.yaml",
|
||||
"enabled": true,
|
||||
"action": "BLOCK",
|
||||
"severity_threshold": "medium"
|
||||
|
|
|
|||
|
|
@ -38,6 +38,38 @@ def safe_join(base_dir: str, *parts: str) -> str:
|
|||
return resolved
|
||||
|
||||
|
||||
def try_safe_join(base_dir: str, *parts: str) -> str | None:
|
||||
"""safe_join, with None instead of ValueError when the path escapes base_dir."""
|
||||
try:
|
||||
return safe_join(base_dir, *parts)
|
||||
except ValueError:
|
||||
return None
|
||||
|
||||
|
||||
def is_within(path: str, base_dir: str) -> bool:
|
||||
"""True when path, with symlinks resolved, is base_dir or sits inside it."""
|
||||
base: Final = os.path.realpath(base_dir)
|
||||
resolved: Final = os.path.realpath(path)
|
||||
return resolved.startswith(base + os.sep) or resolved == base
|
||||
|
||||
|
||||
def join_within(base_dir: str, *parts: str) -> str | None:
|
||||
"""Join without following symlinks; None when the joined path leaves base_dir.
|
||||
|
||||
Only the supplied components are checked (``..`` and absolute parts are
|
||||
rejected), so a symlink stored inside base_dir that points elsewhere is
|
||||
still returned. Use safe_join when the target itself must stay inside.
|
||||
"""
|
||||
for part in parts:
|
||||
if "\x00" in part:
|
||||
return None
|
||||
base: Final = os.path.normpath(os.path.abspath(base_dir))
|
||||
joined: Final = os.path.normpath(os.path.join(base, *parts))
|
||||
if not joined.startswith(base + os.sep):
|
||||
return None
|
||||
return joined
|
||||
|
||||
|
||||
def safe_filename(filename: str) -> str:
|
||||
"""
|
||||
Extract a safe filename from a user-supplied path.
|
||||
|
|
|
|||
39
litellm/proxy/guardrails/content_filter_data/__init__.py
Normal file
39
litellm/proxy/guardrails/content_filter_data/__init__.py
Normal file
|
|
@ -0,0 +1,39 @@
|
|||
"""Category and policy-template YAML for the content filter guardrail.
|
||||
|
||||
Kept out of ``guardrail_hooks/litellm_content_filter/`` so the packaged paths
|
||||
stay under the Windows MAX_PATH budget enforced by
|
||||
``tests/windows_tests/check_windows_wheel_install.py``. That package directory
|
||||
stays a search root so files a deployment copied there before the move keep
|
||||
loading.
|
||||
"""
|
||||
|
||||
import itertools
|
||||
import os
|
||||
from typing import Final
|
||||
|
||||
from litellm.proxy.common_utils.path_utils import join_within
|
||||
|
||||
DATA_DIR: Final = os.path.dirname(os.path.abspath(__file__))
|
||||
CATEGORIES_DIR: Final = os.path.join(DATA_DIR, "categories")
|
||||
POLICY_TEMPLATES_DIR: Final = os.path.join(DATA_DIR, "policy_templates")
|
||||
LEGACY_DATA_DIR: Final = os.path.join(os.path.dirname(DATA_DIR), "guardrail_hooks", "litellm_content_filter")
|
||||
DATA_ROOTS: Final = (DATA_DIR, LEGACY_DATA_DIR)
|
||||
|
||||
|
||||
def category_dirs(roots: tuple[str, ...] = DATA_ROOTS) -> tuple[str, ...]:
|
||||
"""Every ``categories/`` folder that exists under the roots, bundled first."""
|
||||
return tuple(d for d in (os.path.join(root, "categories") for root in roots) if os.path.isdir(d))
|
||||
|
||||
|
||||
def find_category_file(category_name: str, roots: tuple[str, ...] = DATA_ROOTS) -> str | None:
|
||||
"""First ``<name>.yaml`` or ``<name>.json`` across the category folders, or None.
|
||||
|
||||
A name that would escape its folder (``../x``) never matches. A symlink
|
||||
stored in the folder is returned as is, wherever it points, as before the
|
||||
data move.
|
||||
"""
|
||||
candidates: Final = (
|
||||
join_within(d, f"{category_name}{ext}")
|
||||
for d, ext in itertools.product(category_dirs(roots), (".yaml", ".json"))
|
||||
)
|
||||
return next((c for c in candidates if c is not None and os.path.isfile(c)), None)
|
||||
|
|
@ -21,7 +21,8 @@ from litellm.integrations.custom_guardrail import CustomGuardrail
|
|||
from litellm.litellm_core_utils.safe_json_dumps import safe_dumps
|
||||
from litellm.proxy._types import LitellmUserRoles, UserAPIKeyAuth
|
||||
from litellm.proxy.auth.user_api_key_auth import user_api_key_auth
|
||||
from litellm.proxy.common_utils.path_utils import safe_join
|
||||
from litellm.proxy.common_utils.path_utils import is_within, safe_join
|
||||
from litellm.proxy.guardrails.content_filter_data import CATEGORIES_DIR, DATA_ROOTS, category_dirs, find_category_file
|
||||
from litellm.proxy.guardrails.guardrail_hooks.custom_code.bounded_execution import (
|
||||
ExecutionTimeoutError,
|
||||
await_with_timeout,
|
||||
|
|
@ -1440,12 +1441,16 @@ async def get_guardrail_ui_settings():
|
|||
)
|
||||
|
||||
|
||||
def content_filter_data_roots() -> tuple[str, ...]:
|
||||
return DATA_ROOTS
|
||||
|
||||
|
||||
@router.get(
|
||||
"/guardrails/ui/category_yaml/{category_name}",
|
||||
tags=["Guardrails"],
|
||||
dependencies=[Depends(user_api_key_auth)],
|
||||
)
|
||||
async def get_category_yaml(category_name: str):
|
||||
async def get_category_yaml(category_name: str, roots: tuple[str, ...] = Depends(content_filter_data_roots)):
|
||||
"""
|
||||
Get the YAML or JSON content for a specific content filter category.
|
||||
|
||||
|
|
@ -1455,35 +1460,20 @@ async def get_category_yaml(category_name: str):
|
|||
Returns:
|
||||
The raw YAML or JSON content of the category file with file type indicator
|
||||
"""
|
||||
# Get the categories directory path
|
||||
categories_dir: Final = os.path.join(
|
||||
os.path.dirname(__file__),
|
||||
"guardrail_hooks",
|
||||
"litellm_content_filter",
|
||||
"categories",
|
||||
)
|
||||
|
||||
# Try to find the file with either .yaml or .json extension
|
||||
try:
|
||||
yaml_path: Final = safe_join(categories_dir, f"{category_name}.yaml")
|
||||
json_path: Final = safe_join(categories_dir, f"{category_name}.json")
|
||||
safe_join(CATEGORIES_DIR, f"{category_name}.yaml")
|
||||
except ValueError:
|
||||
raise HTTPException(status_code=400, detail="Invalid category name")
|
||||
|
||||
category_file_path = None
|
||||
file_type = None
|
||||
|
||||
if os.path.exists(yaml_path):
|
||||
category_file_path = yaml_path
|
||||
file_type = "yaml"
|
||||
elif os.path.exists(json_path):
|
||||
category_file_path = json_path
|
||||
file_type = "json"
|
||||
else:
|
||||
category_file_path: Final = find_category_file(category_name, roots)
|
||||
if category_file_path is None:
|
||||
raise HTTPException(
|
||||
status_code=404,
|
||||
detail=f"Category file not found: {category_name} (tried .yaml and .json)",
|
||||
)
|
||||
if not any(is_within(category_file_path, category_dir) for category_dir in category_dirs(roots)):
|
||||
raise HTTPException(status_code=400, detail="Invalid category name")
|
||||
file_type: Final = "yaml" if category_file_path.endswith(".yaml") else "json"
|
||||
|
||||
try:
|
||||
# Read and return the raw content
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@ to detect and block/mask sensitive content.
|
|||
"""
|
||||
|
||||
import asyncio
|
||||
import itertools
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
|
|
@ -28,6 +29,13 @@ from litellm.constants import (
|
|||
)
|
||||
from litellm.integrations.custom_guardrail import CustomGuardrail
|
||||
from litellm.proxy._types import UserAPIKeyAuth
|
||||
from litellm.proxy.common_utils.path_utils import is_within, try_safe_join
|
||||
from litellm.proxy.guardrails.content_filter_data import (
|
||||
CATEGORIES_DIR,
|
||||
DATA_DIR,
|
||||
DATA_ROOTS,
|
||||
find_category_file,
|
||||
)
|
||||
from litellm.types.utils import (
|
||||
CallTypes,
|
||||
Function,
|
||||
|
|
@ -365,21 +373,14 @@ class ContentFilterGuardrail(CustomGuardrail):
|
|||
}
|
||||
|
||||
@staticmethod
|
||||
def _assert_within_categories_dir(path: str, categories_dir: str) -> None:
|
||||
"""Raise ValueError if path escapes the categories directory."""
|
||||
resolved: Final = os.path.realpath(path)
|
||||
allowed: Final = os.path.realpath(categories_dir)
|
||||
try:
|
||||
common: Final = os.path.commonpath([resolved, allowed])
|
||||
except ValueError:
|
||||
# commonpath() raises ValueError on Windows when paths span different drives
|
||||
raise ValueError(f"Category file path '{path}' is outside the allowed categories directory")
|
||||
if common != allowed:
|
||||
def _assert_within_data_roots(path: str, roots: tuple[str, ...]) -> None:
|
||||
"""Raise ValueError unless path sits inside one of the category data roots."""
|
||||
if not any(is_within(path, root) for root in roots):
|
||||
raise ValueError(
|
||||
f"Category file path '{path}' is outside the allowed categories directory '{categories_dir}'"
|
||||
f"Category file path '{path}' is outside the allowed categories directory ({', '.join(roots)})"
|
||||
)
|
||||
|
||||
def _resolve_category_file_path(self, file_path: str) -> str:
|
||||
def _resolve_category_file_path(self, file_path: str, roots: tuple[str, ...] = DATA_ROOTS) -> str:
|
||||
"""
|
||||
Resolve a category file path that may be relative.
|
||||
|
||||
|
|
@ -387,13 +388,16 @@ class ContentFilterGuardrail(CustomGuardrail):
|
|||
relative paths like "litellm/proxy/.../policy_templates/file.yaml".
|
||||
These only work when the CWD is the project root. In production
|
||||
(Docker, installed packages, etc.) the CWD is different, so the
|
||||
file isn't found.
|
||||
file isn't found. Paths recorded before the data moved out of the
|
||||
guardrail package still resolve because only the trailing
|
||||
``policy_templates/<file>`` or ``categories/<file>`` suffix has to match,
|
||||
and the old package directory stays a search root for files a
|
||||
deployment copied there itself.
|
||||
|
||||
Resolution order:
|
||||
1. Return as-is if absolute or already exists (jailed to module dir).
|
||||
2. Try joining the full path relative to this module's directory (jailed).
|
||||
3. Progressively strip leading path components and try each suffix
|
||||
relative to this module's directory (jailed).
|
||||
1. Return as-is if absolute or already exists (jailed to the roots).
|
||||
2. Try the full path, then progressively shorter suffixes, under each
|
||||
root in turn (jailed).
|
||||
|
||||
The directory jail can be disabled for deployments that legitimately
|
||||
store category files outside the package (e.g. mounted volumes) by
|
||||
|
|
@ -404,54 +408,49 @@ class ContentFilterGuardrail(CustomGuardrail):
|
|||
|
||||
Args:
|
||||
file_path: The file path to resolve (absolute or relative).
|
||||
roots: Directories a category file may live under, bundled first.
|
||||
|
||||
Returns:
|
||||
The resolved absolute-ish path, or the original path if
|
||||
resolution fails (caller should check existence).
|
||||
|
||||
Raises:
|
||||
ValueError: If the resolved path escapes the module directory
|
||||
ValueError: If the resolved path escapes every root
|
||||
and ``LITELLM_CONTENT_FILTER_ALLOW_EXTERNAL_PATHS`` is not set.
|
||||
"""
|
||||
module_dir: Final = os.path.dirname(__file__)
|
||||
allow_external: Final = os.environ.get("LITELLM_CONTENT_FILTER_ALLOW_EXTERNAL_PATHS", "").lower() == "true"
|
||||
|
||||
if os.path.isabs(file_path) or os.path.exists(file_path):
|
||||
if not allow_external:
|
||||
self._assert_within_categories_dir(file_path, module_dir)
|
||||
else:
|
||||
if allow_external:
|
||||
verbose_proxy_logger.warning(
|
||||
"LITELLM_CONTENT_FILTER_ALLOW_EXTERNAL_PATHS is set — "
|
||||
"skipping directory jail for category_file '%s'",
|
||||
file_path,
|
||||
)
|
||||
return file_path
|
||||
self._assert_within_data_roots(file_path, roots)
|
||||
return file_path
|
||||
|
||||
# Try the full relative path joined to the module directory
|
||||
candidate = os.path.join(module_dir, file_path)
|
||||
if os.path.exists(candidate):
|
||||
if not allow_external:
|
||||
self._assert_within_categories_dir(candidate, module_dir)
|
||||
return candidate
|
||||
|
||||
# Progressively strip leading components to find a matching suffix
|
||||
parts: Final = file_path.split("/")
|
||||
for i in range(1, len(parts)):
|
||||
suffix = os.path.join(*parts[i:])
|
||||
candidate = os.path.join(module_dir, suffix)
|
||||
if os.path.exists(candidate):
|
||||
if not allow_external:
|
||||
self._assert_within_categories_dir(candidate, module_dir)
|
||||
return candidate
|
||||
suffixes: Final = tuple(os.path.join(*parts[i:]) for i in range(len(parts)))
|
||||
search: Final = tuple(itertools.product(suffixes, roots))
|
||||
if allow_external:
|
||||
unjailed: Final = (os.path.join(root, suffix) for suffix, root in search)
|
||||
return next((c for c in unjailed if os.path.exists(c)), file_path)
|
||||
|
||||
# File not found via any resolution strategy — jail the module-relative
|
||||
# path anyway to reject traversal attempts (e.g. "../../../../etc/passwd")
|
||||
# regardless of CWD or whether the target file exists.
|
||||
if not allow_external:
|
||||
self._assert_within_categories_dir(os.path.join(module_dir, file_path), module_dir)
|
||||
jailed: Final = (try_safe_join(root, suffix) for suffix, root in search)
|
||||
found: Final = next((c for c in jailed if c is not None and os.path.exists(c)), None)
|
||||
if found is not None:
|
||||
return found
|
||||
|
||||
# Nothing matched: jail the data-relative path anyway so "../../etc/passwd" is
|
||||
# rejected regardless of CWD or whether the target exists.
|
||||
self._assert_within_data_roots(os.path.join(DATA_DIR, file_path), roots)
|
||||
return file_path
|
||||
|
||||
def _load_categories(self, categories: list[ContentFilterCategoryConfig]) -> None:
|
||||
def _load_categories(
|
||||
self, categories: list[ContentFilterCategoryConfig], roots: tuple[str, ...] = DATA_ROOTS
|
||||
) -> None:
|
||||
"""
|
||||
Load content categories from configuration.
|
||||
|
||||
|
|
@ -462,9 +461,8 @@ class ContentFilterGuardrail(CustomGuardrail):
|
|||
action: "BLOCK"
|
||||
severity_threshold: "medium"
|
||||
category_file: "/path/to/custom_file.yaml" # optional override
|
||||
roots: Directories a category file may live under, bundled first.
|
||||
"""
|
||||
categories_dir: Final = os.path.join(os.path.dirname(__file__), "categories")
|
||||
|
||||
for cat_config in categories:
|
||||
view = self._category_config_view(cat_config)
|
||||
category_name = view["category"]
|
||||
|
|
@ -491,22 +489,16 @@ class ContentFilterGuardrail(CustomGuardrail):
|
|||
# Load category file (custom or default)
|
||||
if custom_file:
|
||||
try:
|
||||
category_file_path = self._resolve_category_file_path(custom_file)
|
||||
category_file_path = self._resolve_category_file_path(custom_file, roots)
|
||||
except ValueError as e:
|
||||
verbose_proxy_logger.warning(
|
||||
"Category %s: invalid category_file path, skipping. %s", category_name, e
|
||||
)
|
||||
continue
|
||||
else:
|
||||
# Try .yaml first, then .json (e.g. harm_toxic_abuse.json)
|
||||
yaml_path = os.path.join(categories_dir, f"{category_name}.yaml")
|
||||
json_path = os.path.join(categories_dir, f"{category_name}.json")
|
||||
if os.path.exists(yaml_path):
|
||||
category_file_path = yaml_path
|
||||
elif os.path.exists(json_path):
|
||||
category_file_path = json_path
|
||||
else:
|
||||
category_file_path = yaml_path # will trigger "not found" below
|
||||
category_file_path = find_category_file(category_name, roots) or os.path.join(
|
||||
CATEGORIES_DIR, f"{category_name}.yaml"
|
||||
)
|
||||
|
||||
if not os.path.exists(category_file_path):
|
||||
verbose_proxy_logger.warning("Category file not found: %s, skipping", category_file_path)
|
||||
|
|
@ -528,7 +520,7 @@ class ContentFilterGuardrail(CustomGuardrail):
|
|||
category_config_obj,
|
||||
category_action,
|
||||
severity_threshold,
|
||||
categories_dir,
|
||||
roots,
|
||||
)
|
||||
|
||||
# Add always_block_keywords if present
|
||||
|
|
@ -572,7 +564,7 @@ class ContentFilterGuardrail(CustomGuardrail):
|
|||
category_config_obj: CategoryConfig,
|
||||
category_action: ContentFilterAction,
|
||||
severity_threshold: str,
|
||||
categories_dir: str,
|
||||
roots: tuple[str, ...],
|
||||
) -> None:
|
||||
"""
|
||||
Load a conditional category that uses identifier_words + block_words.
|
||||
|
|
@ -583,7 +575,7 @@ class ContentFilterGuardrail(CustomGuardrail):
|
|||
category_config_obj: CategoryConfig object with identifier_words
|
||||
category_action: Action to take when match is found
|
||||
severity_threshold: Minimum severity threshold
|
||||
categories_dir: Directory containing category files
|
||||
roots: Directories the inherited category file may live under
|
||||
"""
|
||||
try:
|
||||
block_words: Final[list[str]] = []
|
||||
|
|
@ -593,24 +585,14 @@ class ContentFilterGuardrail(CustomGuardrail):
|
|||
if inherit_from:
|
||||
# Remove .json or .yaml extension if included
|
||||
inherit_base: Final = inherit_from.replace(".json", "").replace(".yaml", "")
|
||||
|
||||
# Find the inherited category file
|
||||
inherit_yaml_path: Final = os.path.join(categories_dir, f"{inherit_base}.yaml")
|
||||
inherit_json_path: Final = os.path.join(categories_dir, f"{inherit_base}.json")
|
||||
|
||||
inherit_file_path = None
|
||||
if os.path.exists(inherit_yaml_path):
|
||||
inherit_file_path = inherit_yaml_path
|
||||
elif os.path.exists(inherit_json_path):
|
||||
inherit_file_path = inherit_json_path
|
||||
else:
|
||||
inherit_file_path: Final = find_category_file(inherit_base, roots)
|
||||
if inherit_file_path is None:
|
||||
verbose_proxy_logger.warning(
|
||||
"Category %s: inherit_from '%s' file not found at %s",
|
||||
"Category %s: inherit_from '%s' file not found under %s",
|
||||
category_name,
|
||||
inherit_from,
|
||||
categories_dir,
|
||||
", ".join(roots),
|
||||
)
|
||||
verbose_proxy_logger.debug("Tried paths: %s, %s", inherit_yaml_path, inherit_json_path)
|
||||
|
||||
if inherit_file_path:
|
||||
# Load the inherited category
|
||||
|
|
|
|||
|
|
@ -8,10 +8,13 @@ sensitive information like SSNs, credit cards, API keys, etc.
|
|||
import json
|
||||
import os
|
||||
import re
|
||||
from collections.abc import Iterator
|
||||
from enum import Enum
|
||||
from re import Pattern
|
||||
from typing import Any, Final
|
||||
|
||||
from litellm.proxy.guardrails.content_filter_data import DATA_ROOTS, category_dirs
|
||||
|
||||
|
||||
def _load_patterns_from_json() -> dict:
|
||||
"""Load pattern definitions from patterns.json file"""
|
||||
|
|
@ -124,74 +127,64 @@ def get_pattern_metadata() -> list[dict[str, str]]:
|
|||
]
|
||||
|
||||
|
||||
def get_available_content_categories() -> list[dict[str, str]]:
|
||||
def _category_entry(categories_dir: str, filename: str) -> dict[str, str] | None:
|
||||
import yaml
|
||||
|
||||
category_file_path: Final = os.path.join(categories_dir, filename)
|
||||
if filename.endswith((".yaml", ".yml")):
|
||||
try:
|
||||
with open(category_file_path, "r") as f:
|
||||
category_data = yaml.safe_load(f)
|
||||
except Exception as e:
|
||||
from litellm._logging import verbose_proxy_logger
|
||||
|
||||
verbose_proxy_logger.warning("Failed to load category file %s: %s", filename, e)
|
||||
return None
|
||||
if not category_data or "category_name" not in category_data:
|
||||
return None
|
||||
return {
|
||||
"name": category_data["category_name"],
|
||||
"display_name": category_data.get("display_name")
|
||||
or category_data["category_name"].replace("_", " ").title(),
|
||||
"description": category_data.get("description", ""),
|
||||
"default_action": category_data.get("default_action", "BLOCK"),
|
||||
}
|
||||
if filename.endswith(".json"):
|
||||
category_name: Final = os.path.splitext(filename)[0]
|
||||
if category_name == "harm_toxic_abuse":
|
||||
return {
|
||||
"name": category_name,
|
||||
"display_name": "Harmful Toxic Abuse",
|
||||
"description": "Detects harmful, toxic, or abusive language and content",
|
||||
"default_action": "BLOCK",
|
||||
}
|
||||
display_name: Final = category_name.replace("_", " ").title()
|
||||
return {
|
||||
"name": category_name,
|
||||
"display_name": display_name,
|
||||
"description": f"Content category: {display_name}",
|
||||
"default_action": "BLOCK",
|
||||
}
|
||||
return None
|
||||
|
||||
|
||||
def get_available_content_categories(roots: tuple[str, ...] = DATA_ROOTS) -> list[dict[str, str]]:
|
||||
"""
|
||||
Return available content categories for UI display.
|
||||
|
||||
Includes categories defined in .yaml/.yml files and in .json files
|
||||
(e.g. harm_toxic_abuse.json).
|
||||
(e.g. harm_toxic_abuse.json) under every data root, bundled first. A
|
||||
name that appears under several roots is listed once, from the first root.
|
||||
|
||||
Returns:
|
||||
List of dictionaries containing category name, display_name, and description
|
||||
"""
|
||||
import yaml
|
||||
entries: Final = tuple(e for e in (_category_entry(d, f) for d, f in _category_files(roots)) if e is not None)
|
||||
first_per_name: Final = {e["name"]: e for e in reversed(entries)}
|
||||
return sorted(first_per_name.values(), key=lambda x: x["name"])
|
||||
|
||||
categories_dir: Final = os.path.join(os.path.dirname(__file__), "categories")
|
||||
available_categories: Final = []
|
||||
|
||||
if not os.path.exists(categories_dir):
|
||||
return []
|
||||
|
||||
# Scan the categories directory for YAML files
|
||||
for filename in os.listdir(categories_dir):
|
||||
if filename.endswith(".yaml") or filename.endswith(".yml"):
|
||||
category_file_path = os.path.join(categories_dir, filename)
|
||||
try:
|
||||
with open(category_file_path, "r") as f:
|
||||
category_data = yaml.safe_load(f)
|
||||
|
||||
if category_data and "category_name" in category_data:
|
||||
# Use explicit display_name if provided, otherwise auto-generate from category_name
|
||||
display_name = category_data.get("display_name") or (
|
||||
category_data["category_name"].replace("_", " ").title()
|
||||
)
|
||||
|
||||
available_categories.append(
|
||||
{
|
||||
"name": category_data["category_name"],
|
||||
"display_name": display_name,
|
||||
"description": category_data.get("description", ""),
|
||||
"default_action": category_data.get("default_action", "BLOCK"),
|
||||
}
|
||||
)
|
||||
except Exception as e:
|
||||
# Skip files that can't be loaded but log the error for debugging
|
||||
from litellm._logging import verbose_proxy_logger
|
||||
|
||||
verbose_proxy_logger.warning("Failed to load category file %s: %s", filename, e)
|
||||
continue
|
||||
elif filename.endswith(".json"):
|
||||
# JSON category files (e.g. harm_toxic_abuse.json) - no YAML header, use filename
|
||||
category_name = os.path.splitext(filename)[0]
|
||||
try:
|
||||
if category_name == "harm_toxic_abuse":
|
||||
display_name = "Harmful Toxic Abuse"
|
||||
description = "Detects harmful, toxic, or abusive language and content"
|
||||
else:
|
||||
display_name = category_name.replace("_", " ").title()
|
||||
description = f"Content category: {display_name}"
|
||||
available_categories.append(
|
||||
{
|
||||
"name": category_name,
|
||||
"display_name": display_name,
|
||||
"description": description,
|
||||
"default_action": "BLOCK",
|
||||
}
|
||||
)
|
||||
except Exception:
|
||||
continue
|
||||
|
||||
# Sort by name for consistent ordering
|
||||
available_categories.sort(key=lambda x: x["name"])
|
||||
|
||||
return available_categories
|
||||
def _category_files(roots: tuple[str, ...]) -> Iterator[tuple[str, str]]:
|
||||
for categories_dir in category_dirs(roots):
|
||||
for filename in sorted(os.listdir(categories_dir)):
|
||||
yield categories_dir, filename
|
||||
|
|
|
|||
|
|
@ -1086,7 +1086,7 @@
|
|||
"categories": [
|
||||
{
|
||||
"category": "eu_ai_act_art5_manipulation",
|
||||
"category_file": "litellm/proxy/guardrails/guardrail_hooks/litellm_content_filter/policy_templates/eu_ai_act_art5_manipulation.yaml",
|
||||
"category_file": "litellm/proxy/guardrails/content_filter_data/policy_templates/eu_ai_act_art5_manipulation.yaml",
|
||||
"enabled": true,
|
||||
"action": "BLOCK",
|
||||
"severity_threshold": "medium"
|
||||
|
|
@ -1105,7 +1105,7 @@
|
|||
"categories": [
|
||||
{
|
||||
"category": "eu_ai_act_art5_vulnerability",
|
||||
"category_file": "litellm/proxy/guardrails/guardrail_hooks/litellm_content_filter/policy_templates/eu_ai_act_art5_vulnerability.yaml",
|
||||
"category_file": "litellm/proxy/guardrails/content_filter_data/policy_templates/eu_ai_act_art5_vulnerability.yaml",
|
||||
"enabled": true,
|
||||
"action": "BLOCK",
|
||||
"severity_threshold": "medium"
|
||||
|
|
@ -1124,7 +1124,7 @@
|
|||
"categories": [
|
||||
{
|
||||
"category": "eu_ai_act_art5_social_scoring",
|
||||
"category_file": "litellm/proxy/guardrails/guardrail_hooks/litellm_content_filter/policy_templates/eu_ai_act_art5_social_scoring.yaml",
|
||||
"category_file": "litellm/proxy/guardrails/content_filter_data/policy_templates/eu_ai_act_art5_social_scoring.yaml",
|
||||
"enabled": true,
|
||||
"action": "BLOCK",
|
||||
"severity_threshold": "medium"
|
||||
|
|
@ -1143,7 +1143,7 @@
|
|||
"categories": [
|
||||
{
|
||||
"category": "eu_ai_act_art5_emotion_recognition",
|
||||
"category_file": "litellm/proxy/guardrails/guardrail_hooks/litellm_content_filter/policy_templates/eu_ai_act_art5_emotion_recognition.yaml",
|
||||
"category_file": "litellm/proxy/guardrails/content_filter_data/policy_templates/eu_ai_act_art5_emotion_recognition.yaml",
|
||||
"enabled": true,
|
||||
"action": "BLOCK",
|
||||
"severity_threshold": "medium"
|
||||
|
|
@ -1162,7 +1162,7 @@
|
|||
"categories": [
|
||||
{
|
||||
"category": "eu_ai_act_art5_biometric_profiling",
|
||||
"category_file": "litellm/proxy/guardrails/guardrail_hooks/litellm_content_filter/policy_templates/eu_ai_act_art5_biometric_profiling.yaml",
|
||||
"category_file": "litellm/proxy/guardrails/content_filter_data/policy_templates/eu_ai_act_art5_biometric_profiling.yaml",
|
||||
"enabled": true,
|
||||
"action": "BLOCK",
|
||||
"severity_threshold": "medium"
|
||||
|
|
@ -1181,7 +1181,7 @@
|
|||
"categories": [
|
||||
{
|
||||
"category": "eu_ai_act_art5_manipulation_fr",
|
||||
"category_file": "litellm/proxy/guardrails/guardrail_hooks/litellm_content_filter/policy_templates/eu_ai_act_art5_manipulation_fr.yaml",
|
||||
"category_file": "litellm/proxy/guardrails/content_filter_data/policy_templates/eu_ai_act_art5_manipulation_fr.yaml",
|
||||
"enabled": true,
|
||||
"action": "BLOCK",
|
||||
"severity_threshold": "medium"
|
||||
|
|
@ -1200,7 +1200,7 @@
|
|||
"categories": [
|
||||
{
|
||||
"category": "eu_ai_act_art5_vulnerability_fr",
|
||||
"category_file": "litellm/proxy/guardrails/guardrail_hooks/litellm_content_filter/policy_templates/eu_ai_act_art5_vulnerability_fr.yaml",
|
||||
"category_file": "litellm/proxy/guardrails/content_filter_data/policy_templates/eu_ai_act_art5_vulnerability_fr.yaml",
|
||||
"enabled": true,
|
||||
"action": "BLOCK",
|
||||
"severity_threshold": "medium"
|
||||
|
|
@ -1219,7 +1219,7 @@
|
|||
"categories": [
|
||||
{
|
||||
"category": "eu_ai_act_art5_social_scoring_fr",
|
||||
"category_file": "litellm/proxy/guardrails/guardrail_hooks/litellm_content_filter/policy_templates/eu_ai_act_art5_social_scoring_fr.yaml",
|
||||
"category_file": "litellm/proxy/guardrails/content_filter_data/policy_templates/eu_ai_act_art5_social_scoring_fr.yaml",
|
||||
"enabled": true,
|
||||
"action": "BLOCK",
|
||||
"severity_threshold": "medium"
|
||||
|
|
@ -1238,7 +1238,7 @@
|
|||
"categories": [
|
||||
{
|
||||
"category": "eu_ai_act_art5_emotion_recognition_fr",
|
||||
"category_file": "litellm/proxy/guardrails/guardrail_hooks/litellm_content_filter/policy_templates/eu_ai_act_art5_emotion_recognition_fr.yaml",
|
||||
"category_file": "litellm/proxy/guardrails/content_filter_data/policy_templates/eu_ai_act_art5_emotion_recognition_fr.yaml",
|
||||
"enabled": true,
|
||||
"action": "BLOCK",
|
||||
"severity_threshold": "medium"
|
||||
|
|
@ -1257,7 +1257,7 @@
|
|||
"categories": [
|
||||
{
|
||||
"category": "eu_ai_act_art5_biometric_profiling_fr",
|
||||
"category_file": "litellm/proxy/guardrails/guardrail_hooks/litellm_content_filter/policy_templates/eu_ai_act_art5_biometric_profiling_fr.yaml",
|
||||
"category_file": "litellm/proxy/guardrails/content_filter_data/policy_templates/eu_ai_act_art5_biometric_profiling_fr.yaml",
|
||||
"enabled": true,
|
||||
"action": "BLOCK",
|
||||
"severity_threshold": "medium"
|
||||
|
|
@ -1614,7 +1614,7 @@
|
|||
"categories": [
|
||||
{
|
||||
"category": "aviation_safety_topics",
|
||||
"category_file": "litellm/proxy/guardrails/guardrail_hooks/litellm_content_filter/policy_templates/aviation_safety_topics.yaml",
|
||||
"category_file": "litellm/proxy/guardrails/content_filter_data/policy_templates/aviation_safety_topics.yaml",
|
||||
"enabled": true,
|
||||
"action": "BLOCK",
|
||||
"severity_threshold": "medium"
|
||||
|
|
@ -1633,7 +1633,7 @@
|
|||
"categories": [
|
||||
{
|
||||
"category": "airline_brand_protection",
|
||||
"category_file": "litellm/proxy/guardrails/guardrail_hooks/litellm_content_filter/policy_templates/airline_brand_protection.yaml",
|
||||
"category_file": "litellm/proxy/guardrails/content_filter_data/policy_templates/airline_brand_protection.yaml",
|
||||
"enabled": true,
|
||||
"action": "BLOCK",
|
||||
"severity_threshold": "medium"
|
||||
|
|
@ -1851,7 +1851,7 @@
|
|||
"categories": [
|
||||
{
|
||||
"category": "uae_cultural_sensitivity",
|
||||
"category_file": "litellm/proxy/guardrails/guardrail_hooks/litellm_content_filter/policy_templates/uae_cultural_sensitivity.yaml",
|
||||
"category_file": "litellm/proxy/guardrails/content_filter_data/policy_templates/uae_cultural_sensitivity.yaml",
|
||||
"enabled": true,
|
||||
"action": "BLOCK",
|
||||
"severity_threshold": "medium"
|
||||
|
|
@ -1870,7 +1870,7 @@
|
|||
"categories": [
|
||||
{
|
||||
"category": "uae_anti_discrimination",
|
||||
"category_file": "litellm/proxy/guardrails/guardrail_hooks/litellm_content_filter/policy_templates/uae_anti_discrimination.yaml",
|
||||
"category_file": "litellm/proxy/guardrails/content_filter_data/policy_templates/uae_anti_discrimination.yaml",
|
||||
"enabled": true,
|
||||
"action": "BLOCK",
|
||||
"severity_threshold": "medium"
|
||||
|
|
@ -2134,7 +2134,7 @@
|
|||
"categories": [
|
||||
{
|
||||
"category": "sg_pdpa_personal_identifiers",
|
||||
"category_file": "litellm/proxy/guardrails/guardrail_hooks/litellm_content_filter/policy_templates/sg_pdpa_personal_identifiers.yaml",
|
||||
"category_file": "litellm/proxy/guardrails/content_filter_data/policy_templates/sg_pdpa_personal_identifiers.yaml",
|
||||
"enabled": true,
|
||||
"action": "BLOCK",
|
||||
"severity_threshold": "medium"
|
||||
|
|
@ -2153,7 +2153,7 @@
|
|||
"categories": [
|
||||
{
|
||||
"category": "sg_pdpa_sensitive_data",
|
||||
"category_file": "litellm/proxy/guardrails/guardrail_hooks/litellm_content_filter/policy_templates/sg_pdpa_sensitive_data.yaml",
|
||||
"category_file": "litellm/proxy/guardrails/content_filter_data/policy_templates/sg_pdpa_sensitive_data.yaml",
|
||||
"enabled": true,
|
||||
"action": "BLOCK",
|
||||
"severity_threshold": "medium"
|
||||
|
|
@ -2172,7 +2172,7 @@
|
|||
"categories": [
|
||||
{
|
||||
"category": "sg_pdpa_do_not_call",
|
||||
"category_file": "litellm/proxy/guardrails/guardrail_hooks/litellm_content_filter/policy_templates/sg_pdpa_do_not_call.yaml",
|
||||
"category_file": "litellm/proxy/guardrails/content_filter_data/policy_templates/sg_pdpa_do_not_call.yaml",
|
||||
"enabled": true,
|
||||
"action": "BLOCK",
|
||||
"severity_threshold": "medium"
|
||||
|
|
@ -2191,7 +2191,7 @@
|
|||
"categories": [
|
||||
{
|
||||
"category": "sg_pdpa_data_transfer",
|
||||
"category_file": "litellm/proxy/guardrails/guardrail_hooks/litellm_content_filter/policy_templates/sg_pdpa_data_transfer.yaml",
|
||||
"category_file": "litellm/proxy/guardrails/content_filter_data/policy_templates/sg_pdpa_data_transfer.yaml",
|
||||
"enabled": true,
|
||||
"action": "BLOCK",
|
||||
"severity_threshold": "medium"
|
||||
|
|
@ -2210,7 +2210,7 @@
|
|||
"categories": [
|
||||
{
|
||||
"category": "sg_pdpa_profiling_automated_decisions",
|
||||
"category_file": "litellm/proxy/guardrails/guardrail_hooks/litellm_content_filter/policy_templates/sg_pdpa_profiling_automated_decisions.yaml",
|
||||
"category_file": "litellm/proxy/guardrails/content_filter_data/policy_templates/sg_pdpa_profiling_automated_decisions.yaml",
|
||||
"enabled": true,
|
||||
"action": "BLOCK",
|
||||
"severity_threshold": "medium"
|
||||
|
|
@ -2269,7 +2269,7 @@
|
|||
"categories": [
|
||||
{
|
||||
"category": "sg_mas_fairness_bias",
|
||||
"category_file": "litellm/proxy/guardrails/guardrail_hooks/litellm_content_filter/policy_templates/sg_mas_fairness_bias.yaml",
|
||||
"category_file": "litellm/proxy/guardrails/content_filter_data/policy_templates/sg_mas_fairness_bias.yaml",
|
||||
"enabled": true,
|
||||
"action": "BLOCK",
|
||||
"severity_threshold": "medium"
|
||||
|
|
@ -2288,7 +2288,7 @@
|
|||
"categories": [
|
||||
{
|
||||
"category": "sg_mas_transparency_explainability",
|
||||
"category_file": "litellm/proxy/guardrails/guardrail_hooks/litellm_content_filter/policy_templates/sg_mas_transparency_explainability.yaml",
|
||||
"category_file": "litellm/proxy/guardrails/content_filter_data/policy_templates/sg_mas_transparency_explainability.yaml",
|
||||
"enabled": true,
|
||||
"action": "BLOCK",
|
||||
"severity_threshold": "medium"
|
||||
|
|
@ -2307,7 +2307,7 @@
|
|||
"categories": [
|
||||
{
|
||||
"category": "sg_mas_human_oversight",
|
||||
"category_file": "litellm/proxy/guardrails/guardrail_hooks/litellm_content_filter/policy_templates/sg_mas_human_oversight.yaml",
|
||||
"category_file": "litellm/proxy/guardrails/content_filter_data/policy_templates/sg_mas_human_oversight.yaml",
|
||||
"enabled": true,
|
||||
"action": "BLOCK",
|
||||
"severity_threshold": "medium"
|
||||
|
|
@ -2326,7 +2326,7 @@
|
|||
"categories": [
|
||||
{
|
||||
"category": "sg_mas_data_governance",
|
||||
"category_file": "litellm/proxy/guardrails/guardrail_hooks/litellm_content_filter/policy_templates/sg_mas_data_governance.yaml",
|
||||
"category_file": "litellm/proxy/guardrails/content_filter_data/policy_templates/sg_mas_data_governance.yaml",
|
||||
"enabled": true,
|
||||
"action": "BLOCK",
|
||||
"severity_threshold": "medium"
|
||||
|
|
@ -2345,7 +2345,7 @@
|
|||
"categories": [
|
||||
{
|
||||
"category": "sg_mas_model_security",
|
||||
"category_file": "litellm/proxy/guardrails/guardrail_hooks/litellm_content_filter/policy_templates/sg_mas_model_security.yaml",
|
||||
"category_file": "litellm/proxy/guardrails/content_filter_data/policy_templates/sg_mas_model_security.yaml",
|
||||
"enabled": true,
|
||||
"action": "BLOCK",
|
||||
"severity_threshold": "medium"
|
||||
|
|
@ -2400,7 +2400,7 @@
|
|||
"categories": [
|
||||
{
|
||||
"category": "claims_fraud_coaching",
|
||||
"category_file": "litellm/proxy/guardrails/guardrail_hooks/litellm_content_filter/categories/claims_fraud_coaching.yaml",
|
||||
"category_file": "litellm/proxy/guardrails/content_filter_data/categories/claims_fraud_coaching.yaml",
|
||||
"enabled": true,
|
||||
"action": "BLOCK",
|
||||
"severity_threshold": "medium"
|
||||
|
|
@ -2419,7 +2419,7 @@
|
|||
"categories": [
|
||||
{
|
||||
"category": "claims_phi_disclosure",
|
||||
"category_file": "litellm/proxy/guardrails/guardrail_hooks/litellm_content_filter/categories/claims_phi_disclosure.yaml",
|
||||
"category_file": "litellm/proxy/guardrails/content_filter_data/categories/claims_phi_disclosure.yaml",
|
||||
"enabled": true,
|
||||
"action": "BLOCK",
|
||||
"severity_threshold": "medium"
|
||||
|
|
@ -2438,7 +2438,7 @@
|
|||
"categories": [
|
||||
{
|
||||
"category": "claims_prior_auth_gaming",
|
||||
"category_file": "litellm/proxy/guardrails/guardrail_hooks/litellm_content_filter/categories/claims_prior_auth_gaming.yaml",
|
||||
"category_file": "litellm/proxy/guardrails/content_filter_data/categories/claims_prior_auth_gaming.yaml",
|
||||
"enabled": true,
|
||||
"action": "BLOCK",
|
||||
"severity_threshold": "medium"
|
||||
|
|
@ -2457,7 +2457,7 @@
|
|||
"categories": [
|
||||
{
|
||||
"category": "claims_system_override",
|
||||
"category_file": "litellm/proxy/guardrails/guardrail_hooks/litellm_content_filter/categories/claims_system_override.yaml",
|
||||
"category_file": "litellm/proxy/guardrails/content_filter_data/categories/claims_system_override.yaml",
|
||||
"enabled": true,
|
||||
"action": "BLOCK",
|
||||
"severity_threshold": "medium"
|
||||
|
|
@ -2476,7 +2476,7 @@
|
|||
"categories": [
|
||||
{
|
||||
"category": "claims_medical_advice",
|
||||
"category_file": "litellm/proxy/guardrails/guardrail_hooks/litellm_content_filter/categories/claims_medical_advice.yaml",
|
||||
"category_file": "litellm/proxy/guardrails/content_filter_data/categories/claims_medical_advice.yaml",
|
||||
"enabled": true,
|
||||
"action": "BLOCK",
|
||||
"severity_threshold": "medium"
|
||||
|
|
|
|||
|
|
@ -323,6 +323,8 @@ include = [
|
|||
exclude = [
|
||||
"litellm/proxy/enterprise",
|
||||
"litellm/proxy/enterprise/**",
|
||||
"litellm/proxy/guardrails/guardrail_hooks/litellm_content_filter/guardrail_benchmarks",
|
||||
"litellm/proxy/guardrails/guardrail_hooks/litellm_content_filter/guardrail_benchmarks/**",
|
||||
"**/__pycache__",
|
||||
"**/__pycache__/**",
|
||||
"**/.pytest_cache",
|
||||
|
|
|
|||
|
|
@ -12,6 +12,7 @@ import os
|
|||
import pytest
|
||||
|
||||
import litellm
|
||||
from litellm.proxy.guardrails.content_filter_data import POLICY_TEMPLATES_DIR
|
||||
from litellm.proxy.guardrails.guardrail_hooks.litellm_content_filter.content_filter import (
|
||||
ContentFilterGuardrail,
|
||||
)
|
||||
|
|
@ -161,14 +162,7 @@ def content_filter_guardrail():
|
|||
|
||||
# Get absolute path to the policy template
|
||||
|
||||
content_filter_dir = os.path.join(
|
||||
os.path.dirname(__file__),
|
||||
"../../litellm/proxy/guardrails/guardrail_hooks/litellm_content_filter",
|
||||
)
|
||||
policy_template_path = os.path.join(
|
||||
content_filter_dir, "policy_templates/eu_ai_act_article5.yaml"
|
||||
)
|
||||
policy_template_path = os.path.abspath(policy_template_path)
|
||||
policy_template_path = os.path.join(POLICY_TEMPLATES_DIR, "eu_ai_act_article5.yaml")
|
||||
|
||||
# Load the EU AI Act Article 5 policy template
|
||||
categories = [
|
||||
|
|
|
|||
|
|
@ -11,6 +11,7 @@ import os
|
|||
import pytest
|
||||
|
||||
import litellm
|
||||
from litellm.proxy.guardrails.content_filter_data import POLICY_TEMPLATES_DIR
|
||||
from litellm.proxy.guardrails.guardrail_hooks.litellm_content_filter.content_filter import (
|
||||
ContentFilterGuardrail,
|
||||
)
|
||||
|
|
@ -25,14 +26,7 @@ def content_filter_guardrail():
|
|||
"""Initialize content filter guardrail with EU AI Act Article 5 French template."""
|
||||
|
||||
# Get absolute path to the French policy template
|
||||
content_filter_dir = os.path.join(
|
||||
os.path.dirname(__file__),
|
||||
"../../litellm/proxy/guardrails/guardrail_hooks/litellm_content_filter",
|
||||
)
|
||||
policy_template_path = os.path.join(
|
||||
content_filter_dir, "policy_templates/eu_ai_act_article5_fr.yaml"
|
||||
)
|
||||
policy_template_path = os.path.abspath(policy_template_path)
|
||||
policy_template_path = os.path.join(POLICY_TEMPLATES_DIR, "eu_ai_act_article5_fr.yaml")
|
||||
|
||||
# Load the EU AI Act Article 5 French policy template
|
||||
categories = [
|
||||
|
|
|
|||
|
|
@ -10,6 +10,8 @@ from unittest.mock import MagicMock
|
|||
import pytest
|
||||
from fastapi import HTTPException
|
||||
|
||||
from litellm.proxy.guardrails.content_filter_data import POLICY_TEMPLATES_DIR
|
||||
|
||||
|
||||
class TestRouteLoader:
|
||||
"""Tests for SemanticGuardRouteLoader — YAML loading and route building."""
|
||||
|
|
@ -244,13 +246,7 @@ class TestContentFilterSqlInjectionTemplate:
|
|||
ContentFilterCategoryConfig,
|
||||
)
|
||||
|
||||
content_filter_dir = os.path.join(
|
||||
os.path.dirname(__file__),
|
||||
"../../litellm/proxy/guardrails/guardrail_hooks/litellm_content_filter",
|
||||
)
|
||||
policy_template_path = os.path.abspath(
|
||||
os.path.join(content_filter_dir, "policy_templates/sql_injection.yaml")
|
||||
)
|
||||
policy_template_path = os.path.join(POLICY_TEMPLATES_DIR, "sql_injection.yaml")
|
||||
|
||||
categories = [
|
||||
ContentFilterCategoryConfig(
|
||||
|
|
@ -496,13 +492,7 @@ class TestContentFilterPromptInjectionTemplate:
|
|||
ContentFilterCategoryConfig,
|
||||
)
|
||||
|
||||
content_filter_dir = os.path.join(
|
||||
os.path.dirname(__file__),
|
||||
"../../litellm/proxy/guardrails/guardrail_hooks/litellm_content_filter",
|
||||
)
|
||||
policy_template_path = os.path.abspath(
|
||||
os.path.join(content_filter_dir, "policy_templates/prompt_injection.yaml")
|
||||
)
|
||||
policy_template_path = os.path.join(POLICY_TEMPLATES_DIR, "prompt_injection.yaml")
|
||||
|
||||
categories = [
|
||||
ContentFilterCategoryConfig(
|
||||
|
|
|
|||
|
|
@ -14,6 +14,7 @@ import os
|
|||
import pytest
|
||||
|
||||
import litellm
|
||||
from litellm.proxy.guardrails.content_filter_data import POLICY_TEMPLATES_DIR
|
||||
from litellm.proxy.guardrails.guardrail_hooks.litellm_content_filter.content_filter import (
|
||||
ContentFilterGuardrail,
|
||||
)
|
||||
|
|
@ -24,13 +25,7 @@ from litellm.types.proxy.guardrails.guardrail_hooks.litellm_content_filter impor
|
|||
|
||||
# ── helpers ──────────────────────────────────────────────────────────────
|
||||
|
||||
POLICY_DIR = os.path.abspath(
|
||||
os.path.join(
|
||||
os.path.dirname(__file__),
|
||||
"../../litellm/proxy/guardrails/guardrail_hooks/"
|
||||
"litellm_content_filter/policy_templates",
|
||||
)
|
||||
)
|
||||
POLICY_DIR = POLICY_TEMPLATES_DIR
|
||||
|
||||
|
||||
def _make_guardrail(yaml_filename: str, category_name: str) -> ContentFilterGuardrail:
|
||||
|
|
|
|||
|
|
@ -19,6 +19,7 @@ import os
|
|||
import pytest
|
||||
|
||||
import litellm
|
||||
from litellm.proxy.guardrails.content_filter_data import POLICY_TEMPLATES_DIR
|
||||
from litellm.proxy.guardrails.guardrail_hooks.litellm_content_filter.content_filter import (
|
||||
ContentFilterGuardrail,
|
||||
)
|
||||
|
|
@ -29,13 +30,7 @@ from litellm.types.proxy.guardrails.guardrail_hooks.litellm_content_filter impor
|
|||
|
||||
# ── helpers ──────────────────────────────────────────────────────────────
|
||||
|
||||
POLICY_DIR = os.path.abspath(
|
||||
os.path.join(
|
||||
os.path.dirname(__file__),
|
||||
"../../litellm/proxy/guardrails/guardrail_hooks/"
|
||||
"litellm_content_filter/policy_templates",
|
||||
)
|
||||
)
|
||||
POLICY_DIR = POLICY_TEMPLATES_DIR
|
||||
|
||||
|
||||
def _make_guardrail(yaml_filename: str, category_name: str) -> ContentFilterGuardrail:
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@ import os
|
|||
|
||||
import pytest
|
||||
|
||||
from litellm.proxy.common_utils.path_utils import safe_filename, safe_join
|
||||
from litellm.proxy.common_utils.path_utils import is_within, join_within, safe_filename, safe_join, try_safe_join
|
||||
|
||||
|
||||
class TestSafeJoin:
|
||||
|
|
@ -42,5 +42,47 @@ class TestSafeFilename:
|
|||
safe_filename("..")
|
||||
|
||||
def test_empty_rejected(self):
|
||||
with pytest.raises(ValueError, match='Empty or unsafe filename'):
|
||||
with pytest.raises(ValueError, match="Empty or unsafe filename"):
|
||||
safe_filename("")
|
||||
|
||||
|
||||
def test_try_safe_join_returns_none_instead_of_raising(tmp_path):
|
||||
inside = try_safe_join(str(tmp_path), "categories", "x.yaml")
|
||||
assert inside is not None and inside.startswith(os.path.realpath(str(tmp_path)))
|
||||
assert try_safe_join(str(tmp_path), "..", "escaped.yaml") is None
|
||||
assert try_safe_join(str(tmp_path), "bad\x00name") is None
|
||||
|
||||
|
||||
def test_is_within_resolves_symlinks_before_checking(tmp_path):
|
||||
outside = tmp_path / "outside.yaml"
|
||||
outside.write_text("x")
|
||||
folder = tmp_path / "folder"
|
||||
folder.mkdir()
|
||||
(folder / "inside.yaml").write_text("x")
|
||||
(folder / "out_link.yaml").symlink_to(outside)
|
||||
(folder / "in_link.yaml").symlink_to(folder / "inside.yaml")
|
||||
|
||||
assert is_within(str(folder / "inside.yaml"), str(folder))
|
||||
assert is_within(str(folder / "in_link.yaml"), str(folder))
|
||||
assert is_within(str(folder), str(folder))
|
||||
assert not is_within(str(folder / "out_link.yaml"), str(folder))
|
||||
assert not is_within(str(folder / ".." / "outside.yaml"), str(folder))
|
||||
assert not is_within(str(tmp_path / "folder_sibling.yaml"), str(folder))
|
||||
|
||||
|
||||
def test_join_within_keeps_symlinks_but_rejects_traversal(tmp_path):
|
||||
outside = tmp_path / "outside.yaml"
|
||||
outside.write_text("x")
|
||||
folder = tmp_path / "folder"
|
||||
folder.mkdir()
|
||||
(folder / "link.yaml").symlink_to(outside)
|
||||
|
||||
kept = join_within(str(folder), "link.yaml")
|
||||
assert kept == os.path.join(os.path.normpath(os.path.abspath(str(folder))), "link.yaml")
|
||||
assert os.path.islink(kept)
|
||||
assert join_within(str(folder), "..", "outside.yaml") is None
|
||||
assert join_within(str(folder), "sub", "..", "..", "outside.yaml") is None
|
||||
assert join_within(str(folder), str(outside)) is None
|
||||
assert join_within(str(folder), "bad\x00name") is None
|
||||
with pytest.raises(ValueError, match="escapes base directory"):
|
||||
safe_join(str(folder), "link.yaml")
|
||||
|
|
|
|||
|
|
@ -1,7 +1,19 @@
|
|||
import os
|
||||
import pathlib
|
||||
import re
|
||||
from unittest.mock import patch
|
||||
|
||||
import pytest
|
||||
|
||||
import litellm
|
||||
from litellm.proxy.guardrails.content_filter_data import (
|
||||
CATEGORIES_DIR,
|
||||
DATA_DIR,
|
||||
LEGACY_DATA_DIR as INSTALLED_LEGACY_DATA_DIR,
|
||||
)
|
||||
|
||||
LEGACY_DATA_DIR = "litellm/proxy/guardrails/guardrail_hooks/litellm_content_filter"
|
||||
|
||||
|
||||
class TestContentFilterPathTraversal:
|
||||
"""Tests that _resolve_category_file_path rejects path traversal."""
|
||||
|
|
@ -25,21 +37,36 @@ class TestContentFilterPathTraversal:
|
|||
|
||||
def test_valid_category_file_inside_categories_dir_allowed(self):
|
||||
guardrail = self._get_guardrail()
|
||||
categories_dir = os.path.join(
|
||||
os.path.dirname(
|
||||
__import__(
|
||||
"litellm.proxy.guardrails.guardrail_hooks.litellm_content_filter.content_filter",
|
||||
fromlist=["content_filter"],
|
||||
).__file__
|
||||
),
|
||||
"categories",
|
||||
)
|
||||
valid_file = os.path.join(categories_dir, "harmful_self_harm.yaml")
|
||||
valid_file = os.path.join(CATEGORIES_DIR, "harmful_self_harm.yaml")
|
||||
if not os.path.exists(valid_file):
|
||||
pytest.skip("harmful_self_harm.yaml not present in this environment")
|
||||
result = guardrail._resolve_category_file_path(valid_file)
|
||||
assert result == valid_file
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"legacy_path",
|
||||
[
|
||||
f"{LEGACY_DATA_DIR}/policy_templates/eu_ai_act_article5.yaml",
|
||||
f"{LEGACY_DATA_DIR}/categories/harmful_self_harm.yaml",
|
||||
],
|
||||
)
|
||||
def test_paths_recorded_before_the_data_move_still_resolve(self, legacy_path, monkeypatch, tmp_path):
|
||||
"""Policies saved by older releases point at the old package-internal folders."""
|
||||
monkeypatch.chdir(tmp_path)
|
||||
resolved = self._get_guardrail()._resolve_category_file_path(legacy_path)
|
||||
assert os.path.isfile(resolved)
|
||||
assert os.path.realpath(resolved) == os.path.realpath(os.path.join(DATA_DIR, *legacy_path.split("/")[-2:]))
|
||||
|
||||
def test_every_category_file_published_in_policy_templates_resolves(self, monkeypatch, tmp_path):
|
||||
"""The proxy fetches policy_templates.json from main, so every path in it must exist in the package."""
|
||||
monkeypatch.chdir(tmp_path)
|
||||
published = os.path.join(os.path.dirname(os.path.dirname(litellm.__file__)), "policy_templates.json")
|
||||
category_files = re.findall(r'"category_file":\s*"([^"]+)"', open(published).read())
|
||||
assert category_files
|
||||
guardrail = self._get_guardrail()
|
||||
missing = [p for p in category_files if not os.path.isfile(guardrail._resolve_category_file_path(p))]
|
||||
assert missing == []
|
||||
|
||||
def test_invalid_category_name_skipped(self):
|
||||
from litellm.proxy.guardrails.guardrail_hooks.litellm_content_filter.content_filter import (
|
||||
ContentFilterGuardrail,
|
||||
|
|
@ -66,31 +93,18 @@ class TestContentFilterPathTraversal:
|
|||
guardrail.category_keywords = {}
|
||||
guardrail.always_block_category_keywords = {}
|
||||
guardrail.conditional_categories = {}
|
||||
guardrail._load_categories(
|
||||
[{"category": "foo/../../etc/passwd", "enabled": True}]
|
||||
)
|
||||
guardrail._load_categories([{"category": "foo/../../etc/passwd", "enabled": True}])
|
||||
assert "foo/../../etc/passwd" not in guardrail.loaded_categories
|
||||
|
||||
def test_assert_within_categories_dir_blocks_parent_traversal(self):
|
||||
def test_assert_within_data_roots_blocks_parent_traversal(self):
|
||||
from litellm.proxy.guardrails.guardrail_hooks.litellm_content_filter.content_filter import (
|
||||
ContentFilterGuardrail,
|
||||
)
|
||||
|
||||
categories_dir = os.path.join(
|
||||
os.path.dirname(
|
||||
__import__(
|
||||
"litellm.proxy.guardrails.guardrail_hooks.litellm_content_filter.content_filter",
|
||||
fromlist=["content_filter"],
|
||||
).__file__
|
||||
),
|
||||
"categories",
|
||||
)
|
||||
with pytest.raises(ValueError, match="outside the allowed categories"):
|
||||
ContentFilterGuardrail._assert_within_categories_dir(
|
||||
"/etc/passwd", categories_dir
|
||||
)
|
||||
ContentFilterGuardrail._assert_within_data_roots("/etc/passwd", (CATEGORIES_DIR,))
|
||||
|
||||
def test_assert_within_categories_dir_allows_valid_file(self, tmp_path):
|
||||
def test_assert_within_data_roots_allows_valid_file(self, tmp_path):
|
||||
from litellm.proxy.guardrails.guardrail_hooks.litellm_content_filter.content_filter import (
|
||||
ContentFilterGuardrail,
|
||||
)
|
||||
|
|
@ -98,40 +112,13 @@ class TestContentFilterPathTraversal:
|
|||
categories_dir = str(tmp_path)
|
||||
valid_file = str(tmp_path / "test.yaml")
|
||||
# Should not raise
|
||||
ContentFilterGuardrail._assert_within_categories_dir(valid_file, categories_dir)
|
||||
|
||||
def test_assert_within_categories_dir_commonpath_raises_valueerror(self, tmp_path):
|
||||
"""Cover the except-ValueError branch (Windows cross-drive paths)."""
|
||||
from litellm.proxy.guardrails.guardrail_hooks.litellm_content_filter.content_filter import (
|
||||
ContentFilterGuardrail,
|
||||
)
|
||||
|
||||
categories_dir = str(tmp_path)
|
||||
valid_file = str(tmp_path / "test.yaml")
|
||||
with patch(
|
||||
"os.path.commonpath", side_effect=ValueError("Paths on different drives")
|
||||
):
|
||||
with pytest.raises(
|
||||
ValueError, match="outside the allowed categories directory"
|
||||
):
|
||||
ContentFilterGuardrail._assert_within_categories_dir(
|
||||
valid_file, categories_dir
|
||||
)
|
||||
ContentFilterGuardrail._assert_within_data_roots(valid_file, (categories_dir,))
|
||||
|
||||
def test_resolve_category_file_path_direct_join_hit(self):
|
||||
"""Cover the first-join-attempt success branch (lines 383-384)."""
|
||||
guardrail = self._get_guardrail()
|
||||
# "categories/<file>" joined directly to module_dir resolves to an existing file.
|
||||
categories_dir = os.path.join(
|
||||
os.path.dirname(
|
||||
__import__(
|
||||
"litellm.proxy.guardrails.guardrail_hooks.litellm_content_filter.content_filter",
|
||||
fromlist=["content_filter"],
|
||||
).__file__
|
||||
),
|
||||
"categories",
|
||||
)
|
||||
yaml_files = [f for f in os.listdir(categories_dir) if f.endswith(".yaml")]
|
||||
# "categories/<file>" joined directly to the data dir resolves to an existing file.
|
||||
yaml_files = [f for f in os.listdir(CATEGORIES_DIR) if f.endswith(".yaml")]
|
||||
if not yaml_files:
|
||||
pytest.skip("No category YAML files present in this environment")
|
||||
relative_path = os.path.join("categories", yaml_files[0])
|
||||
|
|
@ -141,16 +128,7 @@ class TestContentFilterPathTraversal:
|
|||
def test_resolve_category_file_path_component_strip_hit(self):
|
||||
"""Cover the component-stripping loop success branch (lines 392-393)."""
|
||||
guardrail = self._get_guardrail()
|
||||
categories_dir = os.path.join(
|
||||
os.path.dirname(
|
||||
__import__(
|
||||
"litellm.proxy.guardrails.guardrail_hooks.litellm_content_filter.content_filter",
|
||||
fromlist=["content_filter"],
|
||||
).__file__
|
||||
),
|
||||
"categories",
|
||||
)
|
||||
yaml_files = [f for f in os.listdir(categories_dir) if f.endswith(".yaml")]
|
||||
yaml_files = [f for f in os.listdir(CATEGORIES_DIR) if f.endswith(".yaml")]
|
||||
if not yaml_files:
|
||||
pytest.skip("No category YAML files present in this environment")
|
||||
# Prefix with a fake leading component so the first-join attempt misses,
|
||||
|
|
@ -195,9 +173,7 @@ class TestContentFilterPathTraversal:
|
|||
external_file = tmp_path / "external_categories.yaml"
|
||||
external_file.write_text("category_name: test\n")
|
||||
|
||||
with patch.dict(
|
||||
_os.environ, {"LITELLM_CONTENT_FILTER_ALLOW_EXTERNAL_PATHS": "true"}
|
||||
):
|
||||
with patch.dict(_os.environ, {"LITELLM_CONTENT_FILTER_ALLOW_EXTERNAL_PATHS": "true"}):
|
||||
# Should return the path without raising ValueError.
|
||||
result = guardrail._resolve_category_file_path(str(external_file))
|
||||
assert result == str(external_file)
|
||||
|
|
@ -211,3 +187,149 @@ class TestContentFilterPathTraversal:
|
|||
_os.environ.pop("LITELLM_CONTENT_FILTER_ALLOW_EXTERNAL_PATHS", None)
|
||||
with pytest.raises(ValueError, match="outside the allowed categories"):
|
||||
guardrail._resolve_category_file_path("/etc/passwd")
|
||||
|
||||
|
||||
def _fresh_guardrail():
|
||||
from litellm.proxy.guardrails.guardrail_hooks.litellm_content_filter.content_filter import (
|
||||
ContentFilterGuardrail,
|
||||
)
|
||||
|
||||
guardrail = ContentFilterGuardrail.__new__(ContentFilterGuardrail)
|
||||
guardrail.loaded_categories = {}
|
||||
guardrail.severity_threshold = "medium"
|
||||
guardrail.category_keywords = {}
|
||||
guardrail.always_block_category_keywords = {}
|
||||
guardrail.conditional_categories = {}
|
||||
return guardrail
|
||||
|
||||
|
||||
CUSTOM_CATEGORY_YAML = """category_name: custom_legacy
|
||||
display_name: Custom Legacy
|
||||
description: copied into the old package folder by a deployment
|
||||
default_action: BLOCK
|
||||
keywords:
|
||||
- keyword: legacycopyword
|
||||
severity: high
|
||||
"""
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def legacy_root(tmp_path):
|
||||
"""A stand-in for the pre-move package dir with a deployment's own category file inside."""
|
||||
root = tmp_path / "litellm_content_filter"
|
||||
(root / "categories").mkdir(parents=True)
|
||||
(root / "categories" / "custom_legacy.yaml").write_text(CUSTOM_CATEGORY_YAML)
|
||||
return str(root)
|
||||
|
||||
|
||||
class TestLegacyPackageRootStaysSearchable:
|
||||
"""Files a deployment copied into the old guardrail package dir must keep working after the move."""
|
||||
|
||||
def test_installed_legacy_root_is_the_old_package_dir(self):
|
||||
assert INSTALLED_LEGACY_DATA_DIR.endswith(os.path.join("guardrail_hooks", "litellm_content_filter"))
|
||||
assert os.path.isdir(INSTALLED_LEGACY_DATA_DIR)
|
||||
|
||||
def test_custom_category_file_under_legacy_root_resolves(self, legacy_root):
|
||||
roots = (DATA_DIR, legacy_root)
|
||||
custom = os.path.join(legacy_root, "categories", "custom_legacy.yaml")
|
||||
assert _fresh_guardrail()._resolve_category_file_path(custom, roots) == custom
|
||||
|
||||
def test_custom_category_file_relative_to_legacy_root_resolves(self, legacy_root, monkeypatch, tmp_path):
|
||||
monkeypatch.chdir(tmp_path)
|
||||
resolved = _fresh_guardrail()._resolve_category_file_path(
|
||||
"categories/custom_legacy.yaml", (DATA_DIR, legacy_root)
|
||||
)
|
||||
assert os.path.realpath(resolved) == os.path.realpath(
|
||||
os.path.join(legacy_root, "categories", "custom_legacy.yaml")
|
||||
)
|
||||
|
||||
def test_bundled_root_wins_when_both_roots_hold_the_name(self, legacy_root):
|
||||
resolved = _fresh_guardrail()._resolve_category_file_path(
|
||||
"categories/harmful_self_harm.yaml", (DATA_DIR, legacy_root)
|
||||
)
|
||||
assert os.path.realpath(resolved) == os.path.realpath(os.path.join(CATEGORIES_DIR, "harmful_self_harm.yaml"))
|
||||
|
||||
def test_custom_category_loads_by_name_from_legacy_root(self, legacy_root):
|
||||
guardrail = _fresh_guardrail()
|
||||
guardrail._load_categories([{"category": "custom_legacy", "enabled": True}], (DATA_DIR, legacy_root))
|
||||
assert "custom_legacy" in guardrail.loaded_categories
|
||||
assert "legacycopyword" in guardrail.category_keywords
|
||||
|
||||
def test_custom_category_loads_via_category_file_under_legacy_root(self, legacy_root):
|
||||
guardrail = _fresh_guardrail()
|
||||
guardrail._load_categories(
|
||||
[
|
||||
{
|
||||
"category": "custom_legacy",
|
||||
"enabled": True,
|
||||
"category_file": os.path.join(legacy_root, "categories", "custom_legacy.yaml"),
|
||||
}
|
||||
],
|
||||
(DATA_DIR, legacy_root),
|
||||
)
|
||||
assert "custom_legacy" in guardrail.loaded_categories
|
||||
|
||||
def test_traversal_still_rejected_with_two_roots(self, legacy_root):
|
||||
with pytest.raises(ValueError, match="outside the allowed categories"):
|
||||
_fresh_guardrail()._resolve_category_file_path("../../../../etc/passwd", (DATA_DIR, legacy_root))
|
||||
|
||||
def test_file_outside_every_root_rejected(self, legacy_root, tmp_path):
|
||||
outside = tmp_path / "elsewhere.yaml"
|
||||
outside.write_text(CUSTOM_CATEGORY_YAML)
|
||||
with pytest.raises(ValueError, match="outside the allowed categories"):
|
||||
_fresh_guardrail()._resolve_category_file_path(str(outside), (DATA_DIR, legacy_root))
|
||||
|
||||
def test_ui_listing_includes_legacy_root_and_lists_each_name_once(self, legacy_root):
|
||||
from litellm.proxy.guardrails.guardrail_hooks.litellm_content_filter.patterns import (
|
||||
get_available_content_categories,
|
||||
)
|
||||
|
||||
listed = get_available_content_categories((DATA_DIR, legacy_root))
|
||||
names = [c["name"] for c in listed]
|
||||
assert "custom_legacy" in names
|
||||
assert "harmful_self_harm" in names
|
||||
assert len(names) == len(set(names))
|
||||
assert names == sorted(names)
|
||||
|
||||
def test_ui_listing_prefers_bundled_copy_on_name_clash(self, legacy_root):
|
||||
from litellm.proxy.guardrails.guardrail_hooks.litellm_content_filter.patterns import (
|
||||
get_available_content_categories,
|
||||
)
|
||||
|
||||
clash = CUSTOM_CATEGORY_YAML.replace("custom_legacy", "harmful_self_harm").replace(
|
||||
"Custom Legacy", "Shadowed Copy"
|
||||
)
|
||||
(pathlib.Path(legacy_root) / "categories" / "harmful_self_harm.yaml").write_text(clash)
|
||||
listed = {c["name"]: c for c in get_available_content_categories((DATA_DIR, legacy_root))}
|
||||
assert listed["harmful_self_harm"]["display_name"] != "Shadowed Copy"
|
||||
|
||||
def test_find_category_file_falls_through_to_legacy_root(self, legacy_root):
|
||||
from litellm.proxy.guardrails.content_filter_data import find_category_file
|
||||
|
||||
roots = (DATA_DIR, legacy_root)
|
||||
custom = find_category_file("custom_legacy", roots)
|
||||
bundled = find_category_file("harmful_self_harm", roots)
|
||||
assert custom is not None and os.path.samefile(
|
||||
custom, os.path.join(legacy_root, "categories", "custom_legacy.yaml")
|
||||
)
|
||||
assert bundled is not None and os.path.samefile(bundled, os.path.join(CATEGORIES_DIR, "harmful_self_harm.yaml"))
|
||||
assert find_category_file("no_such_category_anywhere", roots) is None
|
||||
|
||||
def test_find_category_file_never_escapes_a_category_folder(self, legacy_root, tmp_path):
|
||||
from litellm.proxy.guardrails.content_filter_data import find_category_file
|
||||
|
||||
(tmp_path / "escaped.yaml").write_text(CUSTOM_CATEGORY_YAML)
|
||||
assert find_category_file("../../escaped", (DATA_DIR, legacy_root)) is None
|
||||
|
||||
def test_symlinked_category_in_the_folder_still_loads_by_name(self, legacy_root, tmp_path):
|
||||
"""A category file symlinked into the folder from elsewhere loaded before the move and must keep loading."""
|
||||
target = tmp_path / "elsewhere" / "linked_cat.yaml"
|
||||
target.parent.mkdir()
|
||||
target.write_text(CUSTOM_CATEGORY_YAML.replace("custom_legacy", "linked_cat"))
|
||||
link = pathlib.Path(legacy_root) / "categories" / "linked_cat.yaml"
|
||||
link.symlink_to(target)
|
||||
|
||||
guardrail = _fresh_guardrail()
|
||||
guardrail._load_categories([{"category": "linked_cat", "enabled": True}], (DATA_DIR, legacy_root))
|
||||
assert "linked_cat" in guardrail.loaded_categories
|
||||
assert "legacycopyword" in guardrail.category_keywords
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@ from typing import Dict, List, Optional
|
|||
from unittest.mock import AsyncMock
|
||||
|
||||
import pytest
|
||||
import yaml
|
||||
|
||||
|
||||
from fastapi import HTTPException
|
||||
|
|
@ -20,6 +21,7 @@ from litellm.proxy.guardrails.guardrail_endpoints import (
|
|||
approve_guardrail_submission,
|
||||
create_guardrail,
|
||||
delete_guardrail,
|
||||
get_category_yaml,
|
||||
get_guardrail_info,
|
||||
get_guardrail_submission,
|
||||
get_guardrail_ui_settings,
|
||||
|
|
@ -30,6 +32,7 @@ from litellm.proxy.guardrails.guardrail_endpoints import (
|
|||
reject_guardrail_submission,
|
||||
update_guardrail,
|
||||
)
|
||||
from litellm.proxy.guardrails.content_filter_data import DATA_ROOTS
|
||||
from litellm.proxy.guardrails.guardrail_endpoints import (
|
||||
test_custom_code_guardrail as run_custom_code_test_endpoint,
|
||||
)
|
||||
|
|
@ -2670,3 +2673,58 @@ async def test_test_custom_code_endpoint_reports_a_system_exit_as_an_execution_e
|
|||
assert response.error == "Execution error: SystemExit: bye"
|
||||
assert response.error_type == "execution"
|
||||
assert time.monotonic() - started < 2.0
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_category_yaml_returns_bundled_category_and_its_file_type():
|
||||
result = await get_category_yaml("harmful_self_harm", roots=DATA_ROOTS)
|
||||
assert result["category_name"] == "harmful_self_harm"
|
||||
assert result["file_type"] == "yaml"
|
||||
assert yaml.safe_load(result["yaml_content"])["category_name"] == "harmful_self_harm"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_category_yaml_reports_json_file_type():
|
||||
result = await get_category_yaml("harm_toxic_abuse", roots=DATA_ROOTS)
|
||||
assert result["file_type"] == "json"
|
||||
json.loads(result["yaml_content"])
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_category_yaml_rejects_traversal_with_400():
|
||||
with pytest.raises(HTTPException) as exc:
|
||||
await get_category_yaml("../../etc/passwd", roots=DATA_ROOTS)
|
||||
assert exc.value.status_code == 400
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_category_yaml_unknown_category_is_404():
|
||||
with pytest.raises(HTTPException) as exc:
|
||||
await get_category_yaml("no_such_category_anywhere", roots=DATA_ROOTS)
|
||||
assert exc.value.status_code == 404
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_category_yaml_refuses_a_symlink_pointing_outside_the_category_folders(tmp_path):
|
||||
secret = tmp_path / "secret.txt"
|
||||
secret.write_text("db_password: hunter2\n")
|
||||
categories = tmp_path / "legacy" / "categories"
|
||||
categories.mkdir(parents=True)
|
||||
(categories / "escape.yaml").symlink_to(secret)
|
||||
|
||||
with pytest.raises(HTTPException) as exc:
|
||||
await get_category_yaml("escape", roots=(*DATA_ROOTS, str(tmp_path / "legacy")))
|
||||
assert exc.value.status_code == 400
|
||||
assert "hunter2" not in str(exc.value.detail)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_category_yaml_serves_a_symlink_that_stays_inside_a_category_folder(tmp_path):
|
||||
categories = tmp_path / "legacy" / "categories"
|
||||
categories.mkdir(parents=True)
|
||||
(categories / "real.yaml").write_text('category_name: "real"\nkeywords: []\n')
|
||||
(categories / "alias.yaml").symlink_to(categories / "real.yaml")
|
||||
|
||||
result = await get_category_yaml("alias", roots=(*DATA_ROOTS, str(tmp_path / "legacy")))
|
||||
assert result["file_type"] == "yaml"
|
||||
assert yaml.safe_load(result["yaml_content"])["category_name"] == "real"
|
||||
|
|
|
|||
|
|
@ -1,6 +1,17 @@
|
|||
"""Reproduce a default-Windows ``pip install litellm`` to catch the 260-char
|
||||
MAX_PATH regression that content-filter benchmark fixtures keep reintroducing
|
||||
(#21941, #22039, #29536). Run after ``uv build --wheel --out-dir dist``.
|
||||
MAX_PATH regression that content-filter fixtures keep reintroducing
|
||||
(#21941, #22039, #29536, #43851). Run after ``uv build --wheel --out-dir dist``.
|
||||
|
||||
pip writes every wheel entry verbatim under ``site-packages``, so an entry
|
||||
busts the limit when ``site-packages`` prefix + entry reaches MAX_PATH (260,
|
||||
which counts the terminating NUL, so 259 visible characters), and its parent
|
||||
directory busts ``CreateDirectoryW`` at 248. Microsoft Store Python has the
|
||||
deepest common ``site-packages``: 134 characters plus the profile folder name
|
||||
(learn.microsoft.com/en-us/windows/win32/fileio/maximum-file-path-limitation
|
||||
and the Store install layout, checked 2026-09-30).
|
||||
|
||||
The install must go through pip, not uv: uv writes files from Rust, which
|
||||
switches to extended-length paths on its own and never hits MAX_PATH.
|
||||
"""
|
||||
|
||||
import glob
|
||||
|
|
@ -10,15 +21,26 @@ import sys
|
|||
import zipfile
|
||||
|
||||
MAX_PATH = 260
|
||||
# Worst-case Windows site-packages prefix: long profile name + roaming AppData venv.
|
||||
WORST_CASE_PREFIX = 100
|
||||
MAX_DIRECTORY_PATH = 248
|
||||
STORE_PYTHON_SITE_PACKAGES = (
|
||||
"C:\\Users\\{profile}\\AppData\\Local\\Packages\\PythonSoftwareFoundation.Python.3.12_qbz5n2kfra8p0"
|
||||
"\\LocalCache\\local-packages\\Python312\\site-packages\\"
|
||||
)
|
||||
WORST_CASE_PREFIX = len(STORE_PYTHON_SITE_PACKAGES.format(profile="x" * 15))
|
||||
|
||||
|
||||
def overlong_install_paths(wheel, prefix_len=WORST_CASE_PREFIX, max_path=MAX_PATH):
|
||||
def busts_windows_limits(entry, prefix_len=WORST_CASE_PREFIX):
|
||||
return (
|
||||
prefix_len + len(entry) >= MAX_PATH
|
||||
or prefix_len + len(os.path.dirname(entry)) >= MAX_DIRECTORY_PATH
|
||||
)
|
||||
|
||||
|
||||
def overlong_install_paths(wheel, prefix_len=WORST_CASE_PREFIX):
|
||||
with zipfile.ZipFile(wheel) as zf:
|
||||
names = zf.namelist()
|
||||
return sorted(
|
||||
(n for n in names if prefix_len + len(n) > max_path), key=len, reverse=True
|
||||
(n for n in names if busts_windows_limits(n, prefix_len)), key=len, reverse=True
|
||||
)
|
||||
|
||||
|
||||
|
|
@ -46,7 +68,7 @@ def main(argv):
|
|||
if offenders:
|
||||
print(
|
||||
f"::error::{len(offenders)} packaged path(s) bust the Windows MAX_PATH limit "
|
||||
f"at a {WORST_CASE_PREFIX}-char install prefix:"
|
||||
f"at a {WORST_CASE_PREFIX}-char install prefix (Store Python, 15-char profile name):"
|
||||
)
|
||||
for n in offenders[:15]:
|
||||
print(f" on-disk {WORST_CASE_PREFIX + len(n):4} {n}")
|
||||
|
|
@ -57,10 +79,10 @@ def main(argv):
|
|||
|
||||
venv = _deep_venv_dir()
|
||||
os.makedirs(os.path.dirname(venv), exist_ok=True)
|
||||
if _run(["uv", "venv", venv]) != 0:
|
||||
if _run([sys.executable, "-m", "venv", venv]) != 0:
|
||||
return 1
|
||||
python = os.path.join(venv, "Scripts", "python.exe")
|
||||
if _run(["uv", "pip", "install", "--python", python, wheel]) != 0:
|
||||
if _run([python, "-m", "pip", "install", wheel]) != 0:
|
||||
print(
|
||||
f"::error::installing {os.path.basename(wheel)} into a deep prefix failed"
|
||||
)
|
||||
|
|
|
|||
|
|
@ -1,12 +1,18 @@
|
|||
import zipfile
|
||||
|
||||
import pytest
|
||||
|
||||
from check_windows_wheel_install import (
|
||||
MAX_DIRECTORY_PATH,
|
||||
MAX_PATH,
|
||||
WORST_CASE_PREFIX,
|
||||
main,
|
||||
overlong_install_paths,
|
||||
)
|
||||
|
||||
FILE_BUDGET = MAX_PATH - WORST_CASE_PREFIX - 1
|
||||
DIRECTORY_BUDGET = MAX_DIRECTORY_PATH - WORST_CASE_PREFIX - 1
|
||||
|
||||
|
||||
def _wheel(tmp_path, *entry_names):
|
||||
path = tmp_path / "pkg.whl"
|
||||
|
|
@ -17,20 +23,42 @@ def _wheel(tmp_path, *entry_names):
|
|||
|
||||
|
||||
def test_flags_entry_one_char_over_budget(tmp_path):
|
||||
busts = "a" * (MAX_PATH - WORST_CASE_PREFIX + 1)
|
||||
busts = "a" * (FILE_BUDGET + 1)
|
||||
assert overlong_install_paths(_wheel(tmp_path, busts)) == [busts]
|
||||
|
||||
|
||||
def test_allows_entry_exactly_at_budget(tmp_path):
|
||||
at_limit = "a" * (MAX_PATH - WORST_CASE_PREFIX)
|
||||
at_limit = "a" * FILE_BUDGET
|
||||
assert (
|
||||
overlong_install_paths(_wheel(tmp_path, at_limit, "litellm/__init__.py")) == []
|
||||
)
|
||||
|
||||
|
||||
def test_flags_directory_one_char_over_create_directory_limit(tmp_path):
|
||||
busts = "d" * (DIRECTORY_BUDGET + 1) + "/f"
|
||||
assert overlong_install_paths(_wheel(tmp_path, busts)) == [busts]
|
||||
|
||||
|
||||
def test_allows_directory_exactly_at_create_directory_limit(tmp_path):
|
||||
at_limit = "d" * DIRECTORY_BUDGET + "/f"
|
||||
assert overlong_install_paths(_wheel(tmp_path, at_limit)) == []
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"entry",
|
||||
[
|
||||
"litellm/proxy/guardrails/guardrail_hooks/litellm_content_filter/guardrail_benchmarks/evals/block_disability_discrimination.jsonl",
|
||||
"litellm/proxy/guardrails/guardrail_hooks/litellm_content_filter/policy_templates/sg_pdpa_profiling_automated_decisions.yaml",
|
||||
],
|
||||
)
|
||||
def test_flags_the_paths_that_overflowed_store_python(tmp_path, entry):
|
||||
"""Both shipped in v1.103.1 and broke pip install under Microsoft Store Python (#43851)."""
|
||||
assert overlong_install_paths(_wheel(tmp_path, entry)) == [entry]
|
||||
|
||||
|
||||
def test_orders_offenders_longest_first(tmp_path):
|
||||
longer = "a" * (MAX_PATH - WORST_CASE_PREFIX + 5)
|
||||
shorter = "b" * (MAX_PATH - WORST_CASE_PREFIX + 1)
|
||||
longer = "a" * (FILE_BUDGET + 5)
|
||||
shorter = "b" * (FILE_BUDGET + 1)
|
||||
assert overlong_install_paths(_wheel(tmp_path, shorter, longer)) == [
|
||||
longer,
|
||||
shorter,
|
||||
|
|
@ -53,6 +81,6 @@ def test_lengths_only_passes_without_installing(tmp_path, monkeypatch):
|
|||
|
||||
|
||||
def test_lengths_only_fails_on_an_overlong_path(tmp_path, monkeypatch):
|
||||
_dist_with(tmp_path, "a" * (MAX_PATH - WORST_CASE_PREFIX + 1))
|
||||
_dist_with(tmp_path, "a" * (FILE_BUDGET + 1))
|
||||
monkeypatch.chdir(tmp_path)
|
||||
assert main(["--lengths-only"]) == 1
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue