diff --git a/litellm/policy_templates_backup.json b/litellm/policy_templates_backup.json index 34c8d2d16a6..0798f345bb5 100644 --- a/litellm/policy_templates_backup.json +++ b/litellm/policy_templates_backup.json @@ -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" diff --git a/litellm/proxy/common_utils/path_utils.py b/litellm/proxy/common_utils/path_utils.py index 7e71310bfb6..3494a4c3fa0 100644 --- a/litellm/proxy/common_utils/path_utils.py +++ b/litellm/proxy/common_utils/path_utils.py @@ -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. diff --git a/litellm/proxy/guardrails/content_filter_data/__init__.py b/litellm/proxy/guardrails/content_filter_data/__init__.py new file mode 100644 index 00000000000..18820bfb7f9 --- /dev/null +++ b/litellm/proxy/guardrails/content_filter_data/__init__.py @@ -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 ``.yaml`` or ``.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) diff --git a/litellm/proxy/guardrails/guardrail_hooks/litellm_content_filter/categories/age_discrimination.yaml b/litellm/proxy/guardrails/content_filter_data/categories/age_discrimination.yaml similarity index 100% rename from litellm/proxy/guardrails/guardrail_hooks/litellm_content_filter/categories/age_discrimination.yaml rename to litellm/proxy/guardrails/content_filter_data/categories/age_discrimination.yaml diff --git a/litellm/proxy/guardrails/guardrail_hooks/litellm_content_filter/categories/bias_gender.yaml b/litellm/proxy/guardrails/content_filter_data/categories/bias_gender.yaml similarity index 100% rename from litellm/proxy/guardrails/guardrail_hooks/litellm_content_filter/categories/bias_gender.yaml rename to litellm/proxy/guardrails/content_filter_data/categories/bias_gender.yaml diff --git a/litellm/proxy/guardrails/guardrail_hooks/litellm_content_filter/categories/bias_racial.yaml b/litellm/proxy/guardrails/content_filter_data/categories/bias_racial.yaml similarity index 100% rename from litellm/proxy/guardrails/guardrail_hooks/litellm_content_filter/categories/bias_racial.yaml rename to litellm/proxy/guardrails/content_filter_data/categories/bias_racial.yaml diff --git a/litellm/proxy/guardrails/guardrail_hooks/litellm_content_filter/categories/bias_religious.yaml b/litellm/proxy/guardrails/content_filter_data/categories/bias_religious.yaml similarity index 100% rename from litellm/proxy/guardrails/guardrail_hooks/litellm_content_filter/categories/bias_religious.yaml rename to litellm/proxy/guardrails/content_filter_data/categories/bias_religious.yaml diff --git a/litellm/proxy/guardrails/guardrail_hooks/litellm_content_filter/categories/bias_sexual_orientation.yaml b/litellm/proxy/guardrails/content_filter_data/categories/bias_sexual_orientation.yaml similarity index 100% rename from litellm/proxy/guardrails/guardrail_hooks/litellm_content_filter/categories/bias_sexual_orientation.yaml rename to litellm/proxy/guardrails/content_filter_data/categories/bias_sexual_orientation.yaml diff --git a/litellm/proxy/guardrails/guardrail_hooks/litellm_content_filter/categories/claims_fraud_coaching.yaml b/litellm/proxy/guardrails/content_filter_data/categories/claims_fraud_coaching.yaml similarity index 100% rename from litellm/proxy/guardrails/guardrail_hooks/litellm_content_filter/categories/claims_fraud_coaching.yaml rename to litellm/proxy/guardrails/content_filter_data/categories/claims_fraud_coaching.yaml diff --git a/litellm/proxy/guardrails/guardrail_hooks/litellm_content_filter/categories/claims_medical_advice.yaml b/litellm/proxy/guardrails/content_filter_data/categories/claims_medical_advice.yaml similarity index 100% rename from litellm/proxy/guardrails/guardrail_hooks/litellm_content_filter/categories/claims_medical_advice.yaml rename to litellm/proxy/guardrails/content_filter_data/categories/claims_medical_advice.yaml diff --git a/litellm/proxy/guardrails/guardrail_hooks/litellm_content_filter/categories/claims_phi_disclosure.yaml b/litellm/proxy/guardrails/content_filter_data/categories/claims_phi_disclosure.yaml similarity index 100% rename from litellm/proxy/guardrails/guardrail_hooks/litellm_content_filter/categories/claims_phi_disclosure.yaml rename to litellm/proxy/guardrails/content_filter_data/categories/claims_phi_disclosure.yaml diff --git a/litellm/proxy/guardrails/guardrail_hooks/litellm_content_filter/categories/claims_prior_auth_gaming.yaml b/litellm/proxy/guardrails/content_filter_data/categories/claims_prior_auth_gaming.yaml similarity index 100% rename from litellm/proxy/guardrails/guardrail_hooks/litellm_content_filter/categories/claims_prior_auth_gaming.yaml rename to litellm/proxy/guardrails/content_filter_data/categories/claims_prior_auth_gaming.yaml diff --git a/litellm/proxy/guardrails/guardrail_hooks/litellm_content_filter/categories/claims_system_override.yaml b/litellm/proxy/guardrails/content_filter_data/categories/claims_system_override.yaml similarity index 100% rename from litellm/proxy/guardrails/guardrail_hooks/litellm_content_filter/categories/claims_system_override.yaml rename to litellm/proxy/guardrails/content_filter_data/categories/claims_system_override.yaml diff --git a/litellm/proxy/guardrails/guardrail_hooks/litellm_content_filter/categories/denied_financial_advice.yaml b/litellm/proxy/guardrails/content_filter_data/categories/denied_financial_advice.yaml similarity index 100% rename from litellm/proxy/guardrails/guardrail_hooks/litellm_content_filter/categories/denied_financial_advice.yaml rename to litellm/proxy/guardrails/content_filter_data/categories/denied_financial_advice.yaml diff --git a/litellm/proxy/guardrails/guardrail_hooks/litellm_content_filter/categories/denied_insults.yaml b/litellm/proxy/guardrails/content_filter_data/categories/denied_insults.yaml similarity index 100% rename from litellm/proxy/guardrails/guardrail_hooks/litellm_content_filter/categories/denied_insults.yaml rename to litellm/proxy/guardrails/content_filter_data/categories/denied_insults.yaml diff --git a/litellm/proxy/guardrails/guardrail_hooks/litellm_content_filter/categories/denied_legal_advice.yaml b/litellm/proxy/guardrails/content_filter_data/categories/denied_legal_advice.yaml similarity index 100% rename from litellm/proxy/guardrails/guardrail_hooks/litellm_content_filter/categories/denied_legal_advice.yaml rename to litellm/proxy/guardrails/content_filter_data/categories/denied_legal_advice.yaml diff --git a/litellm/proxy/guardrails/guardrail_hooks/litellm_content_filter/categories/denied_medical_advice.yaml b/litellm/proxy/guardrails/content_filter_data/categories/denied_medical_advice.yaml similarity index 100% rename from litellm/proxy/guardrails/guardrail_hooks/litellm_content_filter/categories/denied_medical_advice.yaml rename to litellm/proxy/guardrails/content_filter_data/categories/denied_medical_advice.yaml diff --git a/litellm/proxy/guardrails/guardrail_hooks/litellm_content_filter/categories/disability.yaml b/litellm/proxy/guardrails/content_filter_data/categories/disability.yaml similarity index 100% rename from litellm/proxy/guardrails/guardrail_hooks/litellm_content_filter/categories/disability.yaml rename to litellm/proxy/guardrails/content_filter_data/categories/disability.yaml diff --git a/litellm/proxy/guardrails/guardrail_hooks/litellm_content_filter/categories/gender_sexual_orientation.yaml b/litellm/proxy/guardrails/content_filter_data/categories/gender_sexual_orientation.yaml similarity index 100% rename from litellm/proxy/guardrails/guardrail_hooks/litellm_content_filter/categories/gender_sexual_orientation.yaml rename to litellm/proxy/guardrails/content_filter_data/categories/gender_sexual_orientation.yaml diff --git a/litellm/proxy/guardrails/guardrail_hooks/litellm_content_filter/categories/harm_toxic_abuse.json b/litellm/proxy/guardrails/content_filter_data/categories/harm_toxic_abuse.json similarity index 100% rename from litellm/proxy/guardrails/guardrail_hooks/litellm_content_filter/categories/harm_toxic_abuse.json rename to litellm/proxy/guardrails/content_filter_data/categories/harm_toxic_abuse.json diff --git a/litellm/proxy/guardrails/guardrail_hooks/litellm_content_filter/categories/harm_toxic_abuse_au.json b/litellm/proxy/guardrails/content_filter_data/categories/harm_toxic_abuse_au.json similarity index 100% rename from litellm/proxy/guardrails/guardrail_hooks/litellm_content_filter/categories/harm_toxic_abuse_au.json rename to litellm/proxy/guardrails/content_filter_data/categories/harm_toxic_abuse_au.json diff --git a/litellm/proxy/guardrails/guardrail_hooks/litellm_content_filter/categories/harm_toxic_abuse_de.json b/litellm/proxy/guardrails/content_filter_data/categories/harm_toxic_abuse_de.json similarity index 100% rename from litellm/proxy/guardrails/guardrail_hooks/litellm_content_filter/categories/harm_toxic_abuse_de.json rename to litellm/proxy/guardrails/content_filter_data/categories/harm_toxic_abuse_de.json diff --git a/litellm/proxy/guardrails/guardrail_hooks/litellm_content_filter/categories/harm_toxic_abuse_es.json b/litellm/proxy/guardrails/content_filter_data/categories/harm_toxic_abuse_es.json similarity index 100% rename from litellm/proxy/guardrails/guardrail_hooks/litellm_content_filter/categories/harm_toxic_abuse_es.json rename to litellm/proxy/guardrails/content_filter_data/categories/harm_toxic_abuse_es.json diff --git a/litellm/proxy/guardrails/guardrail_hooks/litellm_content_filter/categories/harm_toxic_abuse_fr.json b/litellm/proxy/guardrails/content_filter_data/categories/harm_toxic_abuse_fr.json similarity index 100% rename from litellm/proxy/guardrails/guardrail_hooks/litellm_content_filter/categories/harm_toxic_abuse_fr.json rename to litellm/proxy/guardrails/content_filter_data/categories/harm_toxic_abuse_fr.json diff --git a/litellm/proxy/guardrails/guardrail_hooks/litellm_content_filter/categories/harmful_child_safety.yaml b/litellm/proxy/guardrails/content_filter_data/categories/harmful_child_safety.yaml similarity index 100% rename from litellm/proxy/guardrails/guardrail_hooks/litellm_content_filter/categories/harmful_child_safety.yaml rename to litellm/proxy/guardrails/content_filter_data/categories/harmful_child_safety.yaml diff --git a/litellm/proxy/guardrails/guardrail_hooks/litellm_content_filter/categories/harmful_illegal_weapons.yaml b/litellm/proxy/guardrails/content_filter_data/categories/harmful_illegal_weapons.yaml similarity index 100% rename from litellm/proxy/guardrails/guardrail_hooks/litellm_content_filter/categories/harmful_illegal_weapons.yaml rename to litellm/proxy/guardrails/content_filter_data/categories/harmful_illegal_weapons.yaml diff --git a/litellm/proxy/guardrails/guardrail_hooks/litellm_content_filter/categories/harmful_self_harm.yaml b/litellm/proxy/guardrails/content_filter_data/categories/harmful_self_harm.yaml similarity index 100% rename from litellm/proxy/guardrails/guardrail_hooks/litellm_content_filter/categories/harmful_self_harm.yaml rename to litellm/proxy/guardrails/content_filter_data/categories/harmful_self_harm.yaml diff --git a/litellm/proxy/guardrails/guardrail_hooks/litellm_content_filter/categories/harmful_violence.yaml b/litellm/proxy/guardrails/content_filter_data/categories/harmful_violence.yaml similarity index 100% rename from litellm/proxy/guardrails/guardrail_hooks/litellm_content_filter/categories/harmful_violence.yaml rename to litellm/proxy/guardrails/content_filter_data/categories/harmful_violence.yaml diff --git a/litellm/proxy/guardrails/guardrail_hooks/litellm_content_filter/categories/military_status.yaml b/litellm/proxy/guardrails/content_filter_data/categories/military_status.yaml similarity index 100% rename from litellm/proxy/guardrails/guardrail_hooks/litellm_content_filter/categories/military_status.yaml rename to litellm/proxy/guardrails/content_filter_data/categories/military_status.yaml diff --git a/litellm/proxy/guardrails/guardrail_hooks/litellm_content_filter/categories/prompt_injection_data_exfiltration.yaml b/litellm/proxy/guardrails/content_filter_data/categories/prompt_injection_data_exfiltration.yaml similarity index 100% rename from litellm/proxy/guardrails/guardrail_hooks/litellm_content_filter/categories/prompt_injection_data_exfiltration.yaml rename to litellm/proxy/guardrails/content_filter_data/categories/prompt_injection_data_exfiltration.yaml diff --git a/litellm/proxy/guardrails/guardrail_hooks/litellm_content_filter/categories/prompt_injection_jailbreak.yaml b/litellm/proxy/guardrails/content_filter_data/categories/prompt_injection_jailbreak.yaml similarity index 100% rename from litellm/proxy/guardrails/guardrail_hooks/litellm_content_filter/categories/prompt_injection_jailbreak.yaml rename to litellm/proxy/guardrails/content_filter_data/categories/prompt_injection_jailbreak.yaml diff --git a/litellm/proxy/guardrails/guardrail_hooks/litellm_content_filter/categories/prompt_injection_malicious_code.yaml b/litellm/proxy/guardrails/content_filter_data/categories/prompt_injection_malicious_code.yaml similarity index 100% rename from litellm/proxy/guardrails/guardrail_hooks/litellm_content_filter/categories/prompt_injection_malicious_code.yaml rename to litellm/proxy/guardrails/content_filter_data/categories/prompt_injection_malicious_code.yaml diff --git a/litellm/proxy/guardrails/guardrail_hooks/litellm_content_filter/categories/prompt_injection_sql.yaml b/litellm/proxy/guardrails/content_filter_data/categories/prompt_injection_sql.yaml similarity index 100% rename from litellm/proxy/guardrails/guardrail_hooks/litellm_content_filter/categories/prompt_injection_sql.yaml rename to litellm/proxy/guardrails/content_filter_data/categories/prompt_injection_sql.yaml diff --git a/litellm/proxy/guardrails/guardrail_hooks/litellm_content_filter/categories/prompt_injection_system_prompt.yaml b/litellm/proxy/guardrails/content_filter_data/categories/prompt_injection_system_prompt.yaml similarity index 100% rename from litellm/proxy/guardrails/guardrail_hooks/litellm_content_filter/categories/prompt_injection_system_prompt.yaml rename to litellm/proxy/guardrails/content_filter_data/categories/prompt_injection_system_prompt.yaml diff --git a/litellm/proxy/guardrails/guardrail_hooks/litellm_content_filter/categories/religion.yaml b/litellm/proxy/guardrails/content_filter_data/categories/religion.yaml similarity index 100% rename from litellm/proxy/guardrails/guardrail_hooks/litellm_content_filter/categories/religion.yaml rename to litellm/proxy/guardrails/content_filter_data/categories/religion.yaml diff --git a/litellm/proxy/guardrails/guardrail_hooks/litellm_content_filter/policy_templates/airline_brand_protection.yaml b/litellm/proxy/guardrails/content_filter_data/policy_templates/airline_brand_protection.yaml similarity index 100% rename from litellm/proxy/guardrails/guardrail_hooks/litellm_content_filter/policy_templates/airline_brand_protection.yaml rename to litellm/proxy/guardrails/content_filter_data/policy_templates/airline_brand_protection.yaml diff --git a/litellm/proxy/guardrails/guardrail_hooks/litellm_content_filter/policy_templates/aviation_safety_topics.yaml b/litellm/proxy/guardrails/content_filter_data/policy_templates/aviation_safety_topics.yaml similarity index 100% rename from litellm/proxy/guardrails/guardrail_hooks/litellm_content_filter/policy_templates/aviation_safety_topics.yaml rename to litellm/proxy/guardrails/content_filter_data/policy_templates/aviation_safety_topics.yaml diff --git a/litellm/proxy/guardrails/guardrail_hooks/litellm_content_filter/policy_templates/eu_ai_act_art5_biometric_profiling.yaml b/litellm/proxy/guardrails/content_filter_data/policy_templates/eu_ai_act_art5_biometric_profiling.yaml similarity index 100% rename from litellm/proxy/guardrails/guardrail_hooks/litellm_content_filter/policy_templates/eu_ai_act_art5_biometric_profiling.yaml rename to litellm/proxy/guardrails/content_filter_data/policy_templates/eu_ai_act_art5_biometric_profiling.yaml diff --git a/litellm/proxy/guardrails/guardrail_hooks/litellm_content_filter/policy_templates/eu_ai_act_art5_biometric_profiling_fr.yaml b/litellm/proxy/guardrails/content_filter_data/policy_templates/eu_ai_act_art5_biometric_profiling_fr.yaml similarity index 100% rename from litellm/proxy/guardrails/guardrail_hooks/litellm_content_filter/policy_templates/eu_ai_act_art5_biometric_profiling_fr.yaml rename to litellm/proxy/guardrails/content_filter_data/policy_templates/eu_ai_act_art5_biometric_profiling_fr.yaml diff --git a/litellm/proxy/guardrails/guardrail_hooks/litellm_content_filter/policy_templates/eu_ai_act_art5_emotion_recognition.yaml b/litellm/proxy/guardrails/content_filter_data/policy_templates/eu_ai_act_art5_emotion_recognition.yaml similarity index 100% rename from litellm/proxy/guardrails/guardrail_hooks/litellm_content_filter/policy_templates/eu_ai_act_art5_emotion_recognition.yaml rename to litellm/proxy/guardrails/content_filter_data/policy_templates/eu_ai_act_art5_emotion_recognition.yaml diff --git a/litellm/proxy/guardrails/guardrail_hooks/litellm_content_filter/policy_templates/eu_ai_act_art5_emotion_recognition_fr.yaml b/litellm/proxy/guardrails/content_filter_data/policy_templates/eu_ai_act_art5_emotion_recognition_fr.yaml similarity index 100% rename from litellm/proxy/guardrails/guardrail_hooks/litellm_content_filter/policy_templates/eu_ai_act_art5_emotion_recognition_fr.yaml rename to litellm/proxy/guardrails/content_filter_data/policy_templates/eu_ai_act_art5_emotion_recognition_fr.yaml diff --git a/litellm/proxy/guardrails/guardrail_hooks/litellm_content_filter/policy_templates/eu_ai_act_art5_manipulation.yaml b/litellm/proxy/guardrails/content_filter_data/policy_templates/eu_ai_act_art5_manipulation.yaml similarity index 100% rename from litellm/proxy/guardrails/guardrail_hooks/litellm_content_filter/policy_templates/eu_ai_act_art5_manipulation.yaml rename to litellm/proxy/guardrails/content_filter_data/policy_templates/eu_ai_act_art5_manipulation.yaml diff --git a/litellm/proxy/guardrails/guardrail_hooks/litellm_content_filter/policy_templates/eu_ai_act_art5_manipulation_fr.yaml b/litellm/proxy/guardrails/content_filter_data/policy_templates/eu_ai_act_art5_manipulation_fr.yaml similarity index 100% rename from litellm/proxy/guardrails/guardrail_hooks/litellm_content_filter/policy_templates/eu_ai_act_art5_manipulation_fr.yaml rename to litellm/proxy/guardrails/content_filter_data/policy_templates/eu_ai_act_art5_manipulation_fr.yaml diff --git a/litellm/proxy/guardrails/guardrail_hooks/litellm_content_filter/policy_templates/eu_ai_act_art5_social_scoring.yaml b/litellm/proxy/guardrails/content_filter_data/policy_templates/eu_ai_act_art5_social_scoring.yaml similarity index 100% rename from litellm/proxy/guardrails/guardrail_hooks/litellm_content_filter/policy_templates/eu_ai_act_art5_social_scoring.yaml rename to litellm/proxy/guardrails/content_filter_data/policy_templates/eu_ai_act_art5_social_scoring.yaml diff --git a/litellm/proxy/guardrails/guardrail_hooks/litellm_content_filter/policy_templates/eu_ai_act_art5_social_scoring_fr.yaml b/litellm/proxy/guardrails/content_filter_data/policy_templates/eu_ai_act_art5_social_scoring_fr.yaml similarity index 100% rename from litellm/proxy/guardrails/guardrail_hooks/litellm_content_filter/policy_templates/eu_ai_act_art5_social_scoring_fr.yaml rename to litellm/proxy/guardrails/content_filter_data/policy_templates/eu_ai_act_art5_social_scoring_fr.yaml diff --git a/litellm/proxy/guardrails/guardrail_hooks/litellm_content_filter/policy_templates/eu_ai_act_art5_vulnerability.yaml b/litellm/proxy/guardrails/content_filter_data/policy_templates/eu_ai_act_art5_vulnerability.yaml similarity index 100% rename from litellm/proxy/guardrails/guardrail_hooks/litellm_content_filter/policy_templates/eu_ai_act_art5_vulnerability.yaml rename to litellm/proxy/guardrails/content_filter_data/policy_templates/eu_ai_act_art5_vulnerability.yaml diff --git a/litellm/proxy/guardrails/guardrail_hooks/litellm_content_filter/policy_templates/eu_ai_act_art5_vulnerability_fr.yaml b/litellm/proxy/guardrails/content_filter_data/policy_templates/eu_ai_act_art5_vulnerability_fr.yaml similarity index 100% rename from litellm/proxy/guardrails/guardrail_hooks/litellm_content_filter/policy_templates/eu_ai_act_art5_vulnerability_fr.yaml rename to litellm/proxy/guardrails/content_filter_data/policy_templates/eu_ai_act_art5_vulnerability_fr.yaml diff --git a/litellm/proxy/guardrails/guardrail_hooks/litellm_content_filter/policy_templates/eu_ai_act_article5.yaml b/litellm/proxy/guardrails/content_filter_data/policy_templates/eu_ai_act_article5.yaml similarity index 100% rename from litellm/proxy/guardrails/guardrail_hooks/litellm_content_filter/policy_templates/eu_ai_act_article5.yaml rename to litellm/proxy/guardrails/content_filter_data/policy_templates/eu_ai_act_article5.yaml diff --git a/litellm/proxy/guardrails/guardrail_hooks/litellm_content_filter/policy_templates/eu_ai_act_article5_fr.yaml b/litellm/proxy/guardrails/content_filter_data/policy_templates/eu_ai_act_article5_fr.yaml similarity index 100% rename from litellm/proxy/guardrails/guardrail_hooks/litellm_content_filter/policy_templates/eu_ai_act_article5_fr.yaml rename to litellm/proxy/guardrails/content_filter_data/policy_templates/eu_ai_act_article5_fr.yaml diff --git a/litellm/proxy/guardrails/guardrail_hooks/litellm_content_filter/policy_templates/prompt_injection.yaml b/litellm/proxy/guardrails/content_filter_data/policy_templates/prompt_injection.yaml similarity index 100% rename from litellm/proxy/guardrails/guardrail_hooks/litellm_content_filter/policy_templates/prompt_injection.yaml rename to litellm/proxy/guardrails/content_filter_data/policy_templates/prompt_injection.yaml diff --git a/litellm/proxy/guardrails/guardrail_hooks/litellm_content_filter/policy_templates/sg_mas_data_governance.yaml b/litellm/proxy/guardrails/content_filter_data/policy_templates/sg_mas_data_governance.yaml similarity index 100% rename from litellm/proxy/guardrails/guardrail_hooks/litellm_content_filter/policy_templates/sg_mas_data_governance.yaml rename to litellm/proxy/guardrails/content_filter_data/policy_templates/sg_mas_data_governance.yaml diff --git a/litellm/proxy/guardrails/guardrail_hooks/litellm_content_filter/policy_templates/sg_mas_fairness_bias.yaml b/litellm/proxy/guardrails/content_filter_data/policy_templates/sg_mas_fairness_bias.yaml similarity index 100% rename from litellm/proxy/guardrails/guardrail_hooks/litellm_content_filter/policy_templates/sg_mas_fairness_bias.yaml rename to litellm/proxy/guardrails/content_filter_data/policy_templates/sg_mas_fairness_bias.yaml diff --git a/litellm/proxy/guardrails/guardrail_hooks/litellm_content_filter/policy_templates/sg_mas_human_oversight.yaml b/litellm/proxy/guardrails/content_filter_data/policy_templates/sg_mas_human_oversight.yaml similarity index 100% rename from litellm/proxy/guardrails/guardrail_hooks/litellm_content_filter/policy_templates/sg_mas_human_oversight.yaml rename to litellm/proxy/guardrails/content_filter_data/policy_templates/sg_mas_human_oversight.yaml diff --git a/litellm/proxy/guardrails/guardrail_hooks/litellm_content_filter/policy_templates/sg_mas_model_security.yaml b/litellm/proxy/guardrails/content_filter_data/policy_templates/sg_mas_model_security.yaml similarity index 100% rename from litellm/proxy/guardrails/guardrail_hooks/litellm_content_filter/policy_templates/sg_mas_model_security.yaml rename to litellm/proxy/guardrails/content_filter_data/policy_templates/sg_mas_model_security.yaml diff --git a/litellm/proxy/guardrails/guardrail_hooks/litellm_content_filter/policy_templates/sg_mas_transparency_explainability.yaml b/litellm/proxy/guardrails/content_filter_data/policy_templates/sg_mas_transparency_explainability.yaml similarity index 100% rename from litellm/proxy/guardrails/guardrail_hooks/litellm_content_filter/policy_templates/sg_mas_transparency_explainability.yaml rename to litellm/proxy/guardrails/content_filter_data/policy_templates/sg_mas_transparency_explainability.yaml diff --git a/litellm/proxy/guardrails/guardrail_hooks/litellm_content_filter/policy_templates/sg_pdpa_data_transfer.yaml b/litellm/proxy/guardrails/content_filter_data/policy_templates/sg_pdpa_data_transfer.yaml similarity index 100% rename from litellm/proxy/guardrails/guardrail_hooks/litellm_content_filter/policy_templates/sg_pdpa_data_transfer.yaml rename to litellm/proxy/guardrails/content_filter_data/policy_templates/sg_pdpa_data_transfer.yaml diff --git a/litellm/proxy/guardrails/guardrail_hooks/litellm_content_filter/policy_templates/sg_pdpa_do_not_call.yaml b/litellm/proxy/guardrails/content_filter_data/policy_templates/sg_pdpa_do_not_call.yaml similarity index 100% rename from litellm/proxy/guardrails/guardrail_hooks/litellm_content_filter/policy_templates/sg_pdpa_do_not_call.yaml rename to litellm/proxy/guardrails/content_filter_data/policy_templates/sg_pdpa_do_not_call.yaml diff --git a/litellm/proxy/guardrails/guardrail_hooks/litellm_content_filter/policy_templates/sg_pdpa_personal_identifiers.yaml b/litellm/proxy/guardrails/content_filter_data/policy_templates/sg_pdpa_personal_identifiers.yaml similarity index 100% rename from litellm/proxy/guardrails/guardrail_hooks/litellm_content_filter/policy_templates/sg_pdpa_personal_identifiers.yaml rename to litellm/proxy/guardrails/content_filter_data/policy_templates/sg_pdpa_personal_identifiers.yaml diff --git a/litellm/proxy/guardrails/guardrail_hooks/litellm_content_filter/policy_templates/sg_pdpa_profiling_automated_decisions.yaml b/litellm/proxy/guardrails/content_filter_data/policy_templates/sg_pdpa_profiling_automated_decisions.yaml similarity index 100% rename from litellm/proxy/guardrails/guardrail_hooks/litellm_content_filter/policy_templates/sg_pdpa_profiling_automated_decisions.yaml rename to litellm/proxy/guardrails/content_filter_data/policy_templates/sg_pdpa_profiling_automated_decisions.yaml diff --git a/litellm/proxy/guardrails/guardrail_hooks/litellm_content_filter/policy_templates/sg_pdpa_sensitive_data.yaml b/litellm/proxy/guardrails/content_filter_data/policy_templates/sg_pdpa_sensitive_data.yaml similarity index 100% rename from litellm/proxy/guardrails/guardrail_hooks/litellm_content_filter/policy_templates/sg_pdpa_sensitive_data.yaml rename to litellm/proxy/guardrails/content_filter_data/policy_templates/sg_pdpa_sensitive_data.yaml diff --git a/litellm/proxy/guardrails/guardrail_hooks/litellm_content_filter/policy_templates/sql_injection.yaml b/litellm/proxy/guardrails/content_filter_data/policy_templates/sql_injection.yaml similarity index 100% rename from litellm/proxy/guardrails/guardrail_hooks/litellm_content_filter/policy_templates/sql_injection.yaml rename to litellm/proxy/guardrails/content_filter_data/policy_templates/sql_injection.yaml diff --git a/litellm/proxy/guardrails/guardrail_hooks/litellm_content_filter/policy_templates/uae_anti_discrimination.yaml b/litellm/proxy/guardrails/content_filter_data/policy_templates/uae_anti_discrimination.yaml similarity index 100% rename from litellm/proxy/guardrails/guardrail_hooks/litellm_content_filter/policy_templates/uae_anti_discrimination.yaml rename to litellm/proxy/guardrails/content_filter_data/policy_templates/uae_anti_discrimination.yaml diff --git a/litellm/proxy/guardrails/guardrail_hooks/litellm_content_filter/policy_templates/uae_cultural_sensitivity.yaml b/litellm/proxy/guardrails/content_filter_data/policy_templates/uae_cultural_sensitivity.yaml similarity index 100% rename from litellm/proxy/guardrails/guardrail_hooks/litellm_content_filter/policy_templates/uae_cultural_sensitivity.yaml rename to litellm/proxy/guardrails/content_filter_data/policy_templates/uae_cultural_sensitivity.yaml diff --git a/litellm/proxy/guardrails/guardrail_endpoints.py b/litellm/proxy/guardrails/guardrail_endpoints.py index 6053ab26726..acad9403ed4 100644 --- a/litellm/proxy/guardrails/guardrail_endpoints.py +++ b/litellm/proxy/guardrails/guardrail_endpoints.py @@ -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 diff --git a/litellm/proxy/guardrails/guardrail_hooks/litellm_content_filter/content_filter.py b/litellm/proxy/guardrails/guardrail_hooks/litellm_content_filter/content_filter.py index 092e8eaafa1..405fd779d24 100644 --- a/litellm/proxy/guardrails/guardrail_hooks/litellm_content_filter/content_filter.py +++ b/litellm/proxy/guardrails/guardrail_hooks/litellm_content_filter/content_filter.py @@ -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/`` or ``categories/`` 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 diff --git a/litellm/proxy/guardrails/guardrail_hooks/litellm_content_filter/patterns.py b/litellm/proxy/guardrails/guardrail_hooks/litellm_content_filter/patterns.py index 6c23813affd..9d051eb90d6 100644 --- a/litellm/proxy/guardrails/guardrail_hooks/litellm_content_filter/patterns.py +++ b/litellm/proxy/guardrails/guardrail_hooks/litellm_content_filter/patterns.py @@ -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 diff --git a/policy_templates.json b/policy_templates.json index c9591dd7a4a..51eb6da8ed6 100644 --- a/policy_templates.json +++ b/policy_templates.json @@ -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" diff --git a/pyproject.toml b/pyproject.toml index 77a1a3fdb75..a81c75c2e0b 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -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", diff --git a/tests/guardrails_tests/test_eu_ai_act_article5.py b/tests/guardrails_tests/test_eu_ai_act_article5.py index d17e56c7450..a2cf1324cbb 100644 --- a/tests/guardrails_tests/test_eu_ai_act_article5.py +++ b/tests/guardrails_tests/test_eu_ai_act_article5.py @@ -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 = [ diff --git a/tests/guardrails_tests/test_eu_ai_act_french_3_scenarios.py b/tests/guardrails_tests/test_eu_ai_act_french_3_scenarios.py index cfc59030076..d17fcc1a0d1 100644 --- a/tests/guardrails_tests/test_eu_ai_act_french_3_scenarios.py +++ b/tests/guardrails_tests/test_eu_ai_act_french_3_scenarios.py @@ -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 = [ diff --git a/tests/guardrails_tests/test_semantic_guard.py b/tests/guardrails_tests/test_semantic_guard.py index 92c55507568..141e5e1cf7c 100644 --- a/tests/guardrails_tests/test_semantic_guard.py +++ b/tests/guardrails_tests/test_semantic_guard.py @@ -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( diff --git a/tests/guardrails_tests/test_sg_mas_ai_guardrails.py b/tests/guardrails_tests/test_sg_mas_ai_guardrails.py index 385fee93ab4..e8f3e4ed409 100644 --- a/tests/guardrails_tests/test_sg_mas_ai_guardrails.py +++ b/tests/guardrails_tests/test_sg_mas_ai_guardrails.py @@ -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: diff --git a/tests/guardrails_tests/test_sg_pdpa_guardrails.py b/tests/guardrails_tests/test_sg_pdpa_guardrails.py index 1e8b8a48b85..3ca7073fd1b 100644 --- a/tests/guardrails_tests/test_sg_pdpa_guardrails.py +++ b/tests/guardrails_tests/test_sg_pdpa_guardrails.py @@ -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: diff --git a/tests/test_litellm/proxy/common_utils/test_path_utils.py b/tests/test_litellm/proxy/common_utils/test_path_utils.py index 8936d910777..8cf1ef6467b 100644 --- a/tests/test_litellm/proxy/common_utils/test_path_utils.py +++ b/tests/test_litellm/proxy/common_utils/test_path_utils.py @@ -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") diff --git a/tests/test_litellm/proxy/guardrails/test_content_filter_path_traversal.py b/tests/test_litellm/proxy/guardrails/test_content_filter_path_traversal.py index 2d19fe7fe73..b796c2d3a6d 100644 --- a/tests/test_litellm/proxy/guardrails/test_content_filter_path_traversal.py +++ b/tests/test_litellm/proxy/guardrails/test_content_filter_path_traversal.py @@ -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/" 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/" 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 diff --git a/tests/test_litellm/proxy/guardrails/test_guardrail_endpoints.py b/tests/test_litellm/proxy/guardrails/test_guardrail_endpoints.py index 508736fb78e..4339febb0e3 100644 --- a/tests/test_litellm/proxy/guardrails/test_guardrail_endpoints.py +++ b/tests/test_litellm/proxy/guardrails/test_guardrail_endpoints.py @@ -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" diff --git a/tests/windows_tests/check_windows_wheel_install.py b/tests/windows_tests/check_windows_wheel_install.py index d0b448f35f6..a6c2e7f2984 100644 --- a/tests/windows_tests/check_windows_wheel_install.py +++ b/tests/windows_tests/check_windows_wheel_install.py @@ -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" ) diff --git a/tests/windows_tests/test_check_windows_wheel_install.py b/tests/windows_tests/test_check_windows_wheel_install.py index 204bcb2f5e2..7af369b0a2f 100644 --- a/tests/windows_tests/test_check_windows_wheel_install.py +++ b/tests/windows_tests/test_check_windows_wheel_install.py @@ -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