diff --git a/litellm/proxy/guardrails/guardrail_hooks/conduct/__init__.py b/litellm/proxy/guardrails/guardrail_hooks/conduct/__init__.py index e5734f26e95..b088fa20f33 100644 --- a/litellm/proxy/guardrails/guardrail_hooks/conduct/__init__.py +++ b/litellm/proxy/guardrails/guardrail_hooks/conduct/__init__.py @@ -8,22 +8,54 @@ if TYPE_CHECKING: from litellm.types.guardrails import Guardrail, LitellmParams +# ─── Constants ──────────────────────────────────────────────────────── +# Kept as module-level names so the intent is obvious in review — no +# `getattr(..., 8.0)` default that gets silently discarded because the +# field always exists as None (cursor[bot] finding). +_DEFAULT_TIMEOUT_SECONDS = 8.0 +_DEFAULT_UNREACHABLE_FALLBACK = "fail_closed" + + def initialize_guardrail(litellm_params: "LitellmParams", guardrail: "Guardrail"): """Initialize the Conduct guardrail from LiteLLM's config block. - Maps LiteLLM's idiomatic ``api_base`` / ``api_key`` to Conduct's - ``api_url`` / ``agent_token`` constructor arguments. All other - settings pass through unchanged. + Maps LiteLLM's typed guardrail fields onto the Conduct constructor: + + LiteLLM field → Conduct kwarg + ─────────────────────────────────────────────── + api_base → api_url + api_key → agent_token + workspace_id (extra) → workspace_id + unreachable_fallback → fail_mode (fail_closed | fail_open) + timeout → timeout + + The typed ``unreachable_fallback`` field replaces the free-form + ``fail_mode`` this shim previously read. A typo on the old field + silently defaulted the plugin to fail-open behavior; using the + typed field forces Pydantic validation upstream (yucheng-berri, + devin-ai-integration findings). """ import litellm + # ``getattr(..., default)`` only fires when the attribute is missing; + # ``LitellmParams`` always defines ``timeout`` and defaults it to + # ``None``, so the default was never applied. Use ``or`` so an + # explicit ``None`` (or ``0``) also falls through to the intended + # 8-second budget (cursor[bot] finding). + timeout = getattr(litellm_params, "timeout", None) or _DEFAULT_TIMEOUT_SECONDS + unreachable_fallback = ( + getattr(litellm_params, "unreachable_fallback", None) + or _DEFAULT_UNREACHABLE_FALLBACK + ) + _conduct_callback: Final = ConductGuardrail( api_url=getattr(litellm_params, "api_base", None), agent_token=getattr(litellm_params, "api_key", None), workspace_id=getattr(litellm_params, "workspace_id", None), - fail_mode=getattr(litellm_params, "fail_mode", "fail_closed"), - tool_name=getattr(litellm_params, "tool_name", "llm_call"), - timeout=getattr(litellm_params, "timeout", 8.0), + # Conduct's constructor argument is still ``fail_mode`` — mapped + # from the typed LiteLLM field above. + fail_mode=unreachable_fallback, + timeout=timeout, guardrail_name=guardrail.get("guardrail_name", ""), event_hook=litellm_params.mode, default_on=litellm_params.default_on, diff --git a/litellm/proxy/guardrails/guardrail_hooks/conduct/conduct.py b/litellm/proxy/guardrails/guardrail_hooks/conduct/conduct.py index 70d882dbd2b..69ea38969ff 100644 --- a/litellm/proxy/guardrails/guardrail_hooks/conduct/conduct.py +++ b/litellm/proxy/guardrails/guardrail_hooks/conduct/conduct.py @@ -1,26 +1,93 @@ """Conduct Guard as a LiteLLM guardrail. -Thin re-export. The adapter, response-envelope parser, session-ID chain, -and fail-mode logic all live in the `conduct-litellm-guard` PyPI package, -which is where issues, versioning, and standalone-user support live. +Thin adapter over the ``conduct-litellm-guard`` PyPI package. The +adapter, response-envelope parser, session-ID chain, fail-mode logic, +and the ``guard_check_prompt`` wire client all live in that package — +this file only wires the base runtime into LiteLLM's ``CustomGuardrail`` +contract. -Install: `pip install conduct-litellm-guard` +Install: ``pip install "conduct-litellm-guard>=0.2.2"`` Source: https://github.com/sseshachala/conductai/tree/main/packages/conduct-litellm-guard Docs: https://conductai.ai/guard """ from __future__ import annotations +from typing import Any, ClassVar + +from litellm.integrations.custom_guardrail import CustomGuardrail +from litellm.types.guardrails import GuardrailEventHooks + +_IMPORT_ERROR_MESSAGE = ( + "conduct-litellm-guard is required for the Conduct guardrail. " + 'Install it with: pip install "conduct-litellm-guard>=0.2.2"' +) + + +# ── Base plugin import — deferred to init-time ───────────────────────── +# Reason: the guardrail-hook auto-discovery loop treats a module-level +# ``raise ImportError`` as "hook unavailable" and silently drops the +# registration. A user who installed LiteLLM but forgot the +# ``conduct-litellm-guard`` dependency would see their config load with +# no guardrail active and no error message (cursor[bot] finding on +# BerriAI/litellm#38143). Import here without raising; surface the +# missing dep at ``__init__`` time when it is actionable. + try: - from conduct_litellm_guard import ConductGuard as ConductGuardrail + from conduct_litellm_guard import ConductGuard as _BaseConductGuard from conduct_litellm_guard.guardrail import ( ConductGuardBlocked as ConductGuardrailBlocked, ) - from conduct_litellm_guard.guardrail import GuardDecision + from conduct_litellm_guard.guardrail import GuardDecision # noqa: F401 — re-exported + + _IMPORT_ERROR: ImportError | None = None except ImportError as _e: - raise ImportError( - "conduct-litellm-guard is required for the Conduct guardrail. Install with: pip install conduct-litellm-guard" - ) from _e + _BaseConductGuard = None # type: ignore[assignment,misc] + ConductGuardrailBlocked = None # type: ignore[assignment,misc] + GuardDecision = None # type: ignore[assignment,misc] + _IMPORT_ERROR = _e + + +# When the base package isn't installed we still need a real class so +# LiteLLM's registry lookup succeeds; the friendly error surfaces on +# construction. +_ParentClass = _BaseConductGuard if _BaseConductGuard is not None else CustomGuardrail + + +class ConductGuardrail(_ParentClass): # type: ignore[valid-type,misc] + """LiteLLM adapter over ``conduct_litellm_guard.ConductGuard``. + + Subclass exists so we can: + - Advertise supported event hooks honestly to LiteLLM (see + ``get_supported_event_hooks``). + - Raise a friendly error at construction time when the base package + isn't installed (rather than at module import — see comment + above). + """ + + # Advertised event hooks. The plugin currently runs at pre_call + # (input rail) — a policy block short-circuits before the model + # sees the prompt, which is the semantic LiteLLM users expect for + # a "guardrail". ``during_call`` / ``post_call`` support lands with + # 0.3.x once the underlying Conduct response gate is wired through + # ``guard_check_response`` (tracked in the plugin repo). Advertising + # only pre_call today prevents silent bypass of ``during_call`` + # configurations — see veria-ai finding on BerriAI/litellm#38143. + SUPPORTED_EVENT_HOOKS: ClassVar[tuple[GuardrailEventHooks, ...]] = ( + GuardrailEventHooks.pre_call, + ) + + def __init__(self, *args: Any, **kwargs: Any) -> None: + if _IMPORT_ERROR is not None or _BaseConductGuard is None: + raise ImportError(_IMPORT_ERROR_MESSAGE) from _IMPORT_ERROR + super().__init__(*args, **kwargs) + + @classmethod + def get_supported_event_hooks(cls) -> list[GuardrailEventHooks]: + """LiteLLM calls this during config validation to reject + unsupported ``mode:`` values (e.g. ``during_call`` while only + pre_call is implemented).""" + return list(cls.SUPPORTED_EVENT_HOOKS) __all__ = ["ConductGuardrail", "ConductGuardrailBlocked", "GuardDecision"] diff --git a/tests/test_litellm/proxy/guardrails/test_conduct_guardrail.py b/tests/test_litellm/proxy/guardrails/test_conduct_guardrail.py index f14e3802daf..25f3def1f4c 100644 --- a/tests/test_litellm/proxy/guardrails/test_conduct_guardrail.py +++ b/tests/test_litellm/proxy/guardrails/test_conduct_guardrail.py @@ -1,22 +1,28 @@ -"""Smoke tests for the Conduct guardrail integration. +"""Tests for the Conduct guardrail integration. -The adapter itself is tested in the ``conduct-litellm-guard`` PyPI -package. Here we only verify: - * the LiteLLM-tree module imports cleanly when the standalone package - is installed - * the enum + registry entries are wired +The adapter itself is tested in ``conduct-litellm-guard`` on PyPI — +here we only verify the LiteLLM-tree wiring: + * the module imports cleanly with and without the standalone package + * the enum + registry entries are populated + * ``initialize_guardrail`` reads the typed ``unreachable_fallback`` + field, applies the timeout default correctly, and registers the + callback with LiteLLM's manager (yucheng-berri / cursor findings) + * only supported event hooks are advertised (veria-ai finding) """ from __future__ import annotations import importlib import sys +from types import SimpleNamespace +from unittest.mock import MagicMock import pytest # The Conduct guardrail imports its runtime from `conduct-litellm-guard` # on PyPI. When the package is not installed in the CI environment, -# skip — the wiring smoke tests only make sense against the real dep. +# skip the wiring smoke tests. The missing-package test below runs +# unconditionally because it needs a controlled ImportError. pytest.importorskip( "conduct_litellm_guard", reason="Install `conduct-litellm-guard` to test the Conduct guardrail integration.", @@ -24,7 +30,6 @@ pytest.importorskip( def test_import_module() -> None: - """The wrapper module imports without side effects.""" module = importlib.import_module("litellm.proxy.guardrails.guardrail_hooks.conduct") assert module.ConductGuardrail is not None @@ -52,16 +57,22 @@ def test_registries_populated() -> None: assert "conduct" in guardrail_initializer_registry +def test_only_pre_call_event_hook_advertised() -> None: + """Regression for veria-ai finding on #38143 — + ``during_call`` mode was silently accepted but never evaluated + because ``async_moderation_hook`` was not overridden. We only + advertise pre_call today so LiteLLM validates configs against + supported hooks and rejects unsupported modes.""" + from litellm.proxy.guardrails.guardrail_hooks.conduct import ConductGuardrail + from litellm.types.guardrails import GuardrailEventHooks + + hooks = ConductGuardrail.get_supported_event_hooks() + assert hooks == [GuardrailEventHooks.pre_call] + + def test_initialize_guardrail_returns_wired_callback( monkeypatch: pytest.MonkeyPatch, ) -> None: - """``initialize_guardrail`` maps LiteLLM params to Conduct kwargs and - registers the callback with ``logging_callback_manager``. This test - exercises the full function body so coverage reports don't flag it - as dead code.""" - from types import SimpleNamespace - from unittest.mock import MagicMock - monkeypatch.setenv("CONDUCT_AGENT_TOKEN", "cond_agt_test_placeholder") added_callbacks: list[object] = [] @@ -93,24 +104,106 @@ def test_initialize_guardrail_returns_wired_callback( assert added_callbacks == [callback] -def test_missing_standalone_package_raises_helpful_error( +def test_initialize_prefers_typed_unreachable_fallback( monkeypatch: pytest.MonkeyPatch, ) -> None: - """When ``conduct-litellm-guard`` is not installed, the import fails - with a message pointing users at the ``pip install`` command.""" - # Ensure the module is re-imported without the standalone package. - for name in list(sys.modules): - if name.startswith(("conduct_litellm_guard", "litellm.proxy.guardrails.guardrail_hooks.conduct")): - monkeypatch.delitem(sys.modules, name, raising=False) + """Regression for yucheng-berri / devin-ai-integration findings — + the typed ``unreachable_fallback`` field replaces the free-form + ``fail_mode`` this shim previously read. Typos on the old field + silently defaulted to fail-open behavior; the typed field forces + Pydantic validation.""" + monkeypatch.setenv("CONDUCT_AGENT_TOKEN", "cond_agt_test_placeholder") + import litellm + from litellm.proxy.guardrails.guardrail_hooks.conduct import initialize_guardrail - real_import = __builtins__["__import__"] if isinstance(__builtins__, dict) else __builtins__.__import__ + captured: dict = {} - def _fake_import(name: str, *args: object, **kwargs: object) -> object: - if name.startswith("conduct_litellm_guard"): - raise ImportError("simulated missing package") - return real_import(name, *args, **kwargs) + class _FakeGuard: + def __init__(self, **kwargs: object) -> None: + captured.update(kwargs) - monkeypatch.setattr("builtins.__import__", _fake_import) + monkeypatch.setattr( + "litellm.proxy.guardrails.guardrail_hooks.conduct.ConductGuardrail", + _FakeGuard, + raising=True, + ) + monkeypatch.setattr( + litellm, "logging_callback_manager", SimpleNamespace(add_litellm_callback=lambda cb: None) + ) - with pytest.raises(ImportError, match="pip install conduct-litellm-guard"): - importlib.import_module("litellm.proxy.guardrails.guardrail_hooks.conduct.conduct") + litellm_params = SimpleNamespace( + api_base=None, + api_key=None, + unreachable_fallback="fail_open", + mode="pre_call", + default_on=True, + ) + guardrail = MagicMock() + guardrail.get.return_value = "conduct-guard" + + initialize_guardrail(litellm_params, guardrail) + # The Conduct constructor still accepts ``fail_mode`` — we map from + # the typed LiteLLM field to it. + assert captured["fail_mode"] == "fail_open" + + +def test_initialize_applies_timeout_default_when_field_is_none( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Regression for cursor[bot] finding — + ``LitellmParams.timeout`` always exists as ``None``, so + ``getattr(litellm_params, "timeout", 8.0)`` was never applied. The + default now uses ``or`` so ``None`` falls through to 8.0.""" + monkeypatch.setenv("CONDUCT_AGENT_TOKEN", "cond_agt_test_placeholder") + import litellm + from litellm.proxy.guardrails.guardrail_hooks.conduct import initialize_guardrail + + captured: dict = {} + + class _FakeGuard: + def __init__(self, **kwargs: object) -> None: + captured.update(kwargs) + + monkeypatch.setattr( + "litellm.proxy.guardrails.guardrail_hooks.conduct.ConductGuardrail", + _FakeGuard, + ) + monkeypatch.setattr( + litellm, "logging_callback_manager", SimpleNamespace(add_litellm_callback=lambda cb: None) + ) + + litellm_params = SimpleNamespace( + api_base=None, + api_key=None, + timeout=None, # the typical case — field exists but caller left it unset + mode="pre_call", + default_on=True, + ) + guardrail = MagicMock() + guardrail.get.return_value = "conduct-guard" + + initialize_guardrail(litellm_params, guardrail) + assert captured["timeout"] == 8.0 + + +def test_missing_standalone_package_raises_at_construction() -> None: + """Regression for cursor[bot] finding — the previous shim raised + ``ImportError`` at module load, which the guardrail-hook auto-loader + treats as "hook unavailable" and silently drops. The subclass now + imports lazily and raises at ``__init__`` time when actionable.""" + from litellm.proxy.guardrails.guardrail_hooks.conduct import conduct as _mod + + original_base = _mod._BaseConductGuard + original_error = _mod._IMPORT_ERROR + try: + _mod._BaseConductGuard = None # type: ignore[assignment] + _mod._IMPORT_ERROR = ImportError("simulated missing package") + + # Class is still importable — no module-load side effect. + from litellm.proxy.guardrails.guardrail_hooks.conduct import ConductGuardrail + + with pytest.raises(ImportError, match="pip install"): + ConductGuardrail() + finally: + _mod._BaseConductGuard = original_base # type: ignore[assignment] + _mod._IMPORT_ERROR = original_error