diff --git a/nemoguardrails/http/retry.py b/nemoguardrails/http/retry.py index 929a23e586..1b42b8b561 100644 --- a/nemoguardrails/http/retry.py +++ b/nemoguardrails/http/retry.py @@ -127,7 +127,7 @@ def __init__( policy: RetryPolicy | None = None, *, sleep: Callable[[float], Awaitable[None]] = asyncio.sleep, - random_value: Callable[[], float] = random.random, + random_value: Callable[[], float] | None = None, now: Callable[[], datetime] | None = None, ): """Initialize a retrying client. @@ -143,7 +143,7 @@ def __init__( self._client = client self._policy = policy or RetryPolicy() self._sleep = sleep - self._random_value = random_value + self._random_value = random_value if random_value is not None else random.random self._now = now or (lambda: datetime.now(timezone.utc)) self._closed = False diff --git a/nemoguardrails/library/clavata/actions.py b/nemoguardrails/library/clavata/actions.py index 9b3ed44a68..6d01015203 100644 --- a/nemoguardrails/library/clavata/actions.py +++ b/nemoguardrails/library/clavata/actions.py @@ -251,6 +251,10 @@ async def clavata_check( try: labels = get_labels(clavata_config, labels=labels, rail=rail) except ClavataPluginValueError: + log.debug( + "No labels resolved for rail %r; falling back to whole-policy matching.", + rail, + ) labels = None result = await evaluate_with_policy(text, str(policy_id), clavata_config, http_client=http_client) diff --git a/nemoguardrails/library/clavata/flows.co b/nemoguardrails/library/clavata/flows.co index 19bc4f497e..9ffeba89a1 100644 --- a/nemoguardrails/library/clavata/flows.co +++ b/nemoguardrails/library/clavata/flows.co @@ -9,7 +9,7 @@ flow clavata check for $text $policy $labels="" if $is_match.is_blocked if $system.config.enable_rails_exceptions global $msg - $msg = "Interaction blocked by clavata check with policy={$policy} and text={$text}" + $msg = "Interaction blocked by clavata check with policy={$policy}" send ClavataPolicyMatchException(message=$msg) else bot refuse to respond diff --git a/nemoguardrails/library/clavata/flows.v1.co b/nemoguardrails/library/clavata/flows.v1.co index 20261423e7..3c5f1123b0 100644 --- a/nemoguardrails/library/clavata/flows.v1.co +++ b/nemoguardrails/library/clavata/flows.v1.co @@ -7,8 +7,7 @@ define flow clavata check input if $is_match.is_blocked if $config.enable_rails_exceptions - $msg = "Interaction blocked by clavata check with policy={$policy} and text={$text}" - create event ClavataPolicyMatchException(message=$msg) + create event ClavataPolicyMatchException(message="Interaction blocked by clavata check on input.") else bot refuse to respond stop @@ -21,8 +20,7 @@ define flow clavata check output if $is_match.is_blocked if $config.enable_rails_exceptions - $msg = "Interaction blocked by clavata check with policy={$policy} and text={$text}" - create event ClavataPolicyMatchException(message=$msg) + create event ClavataPolicyMatchException(message="Interaction blocked by clavata check on output.") else bot refuse to respond stop diff --git a/nemoguardrails/library/clavata/request.py b/nemoguardrails/library/clavata/request.py index 6164232726..2ebe67cba7 100644 --- a/nemoguardrails/library/clavata/request.py +++ b/nemoguardrails/library/clavata/request.py @@ -43,7 +43,6 @@ log = logging.getLogger(__name__) -_CLAVATA_API_KEY = os.environ.get("CLAVATA_API_KEY") _CLAVATA_RETRY_POLICY = RetryPolicy( max_attempts=3, retryable_methods=frozenset({"POST"}), @@ -64,7 +63,7 @@ def to_headers(self) -> Dict[str, str]: """ Converts the auth token into request headers. """ - api_key = self.api_key or _CLAVATA_API_KEY + api_key = self.api_key or os.environ.get("CLAVATA_API_KEY") if api_key is None: raise ClavataPluginConfigurationError( "CLAVATA_API_KEY environment variable is not set. " @@ -203,27 +202,26 @@ async def _make_request( ) if response.status_code != 200: - raise ClavataPluginAPIError( - f"Clavata call failed with status code {response.status_code}.\nDetails: {response.text}" - ) + raise ClavataPluginAPIError(f"Clavata call failed with status code {response.status_code}.") try: parsed_response = response.json() except HTTPResponseDecodeError as e: raise ClavataPluginValueError( - f"Failed to parse Clavata response as JSON. Status: {response.status_code}, " - f"Content: {response.text}" + f"Failed to parse Clavata response as JSON. Status: {response.status_code}" ) from e try: return response_model.model_validate(parsed_response) except ValidationError as e: - raise ClavataPluginValueError(f"Invalid response format from Clavata API. Details: {e}") from e + raise ClavataPluginValueError( + f"Invalid response format from Clavata API. Validation errors: {e.error_count()}" + ) from e except ClavataPluginError: raise except Exception as e: - raise ClavataPluginAPIError(f"Failed to make Clavata API request. Error: {e}") from e + raise ClavataPluginAPIError(f"Failed to make Clavata API request. Error: {type(e).__name__}") from e async def create_job(self, text: str, policy_id: str) -> Job: """ diff --git a/nemoguardrails/library/content_safety/flows.co b/nemoguardrails/library/content_safety/flows.co index c824325dd4..aa6f09e8a9 100644 --- a/nemoguardrails/library/content_safety/flows.co +++ b/nemoguardrails/library/content_safety/flows.co @@ -27,7 +27,7 @@ flow content safety check output $model if not $allowed if $system.config.enable_rails_exceptions - send ContentSafetyCheckOuputException(message="Output not allowed. The output was blocked by the 'content safety check output $model='{$model}'' flow.") + send ContentSafetyCheckOutputException(message="Output not allowed. The output was blocked by the 'content safety check output $model='{$model}'' flow.") else if $system.config.rails.config.content_safety.multilingual.enabled $lang_result = await DetectLanguageAction() diff --git a/nemoguardrails/library/content_safety/flows.v1.co b/nemoguardrails/library/content_safety/flows.v1.co index 019d0bbc90..8287cc36e3 100644 --- a/nemoguardrails/library/content_safety/flows.v1.co +++ b/nemoguardrails/library/content_safety/flows.v1.co @@ -9,7 +9,7 @@ define flow content safety check input if not $allowed if $config.enable_rails_exceptions - create event ContentSafetyCheckInputException(message="Input not allowed. The input was blocked by the 'content safety check input $model='{$model}'' flow.") + create event ContentSafetyCheckInputException(message="Input not allowed. The input was blocked by the 'content safety check input' flow.") else if $config.rails.config.content_safety.multilingual.enabled $lang_result = execute detect_language @@ -26,7 +26,7 @@ define flow content safety check output if not $allowed if $config.enable_rails_exceptions - create event ContentSafetyCheckOuputException(message="Output not allowed. The output was blocked by the 'content safety check output $model='{$model}'' flow.") + create event ContentSafetyCheckOutputException(message="Output not allowed. The output was blocked by the 'content safety check output' flow.") else if $config.rails.config.content_safety.multilingual.enabled $lang_result = execute detect_language diff --git a/nemoguardrails/library/content_safety/rail.py b/nemoguardrails/library/content_safety/rail.py index 678b4cc081..ce73f56057 100644 --- a/nemoguardrails/library/content_safety/rail.py +++ b/nemoguardrails/library/content_safety/rail.py @@ -17,6 +17,7 @@ ActionRef, Binding, ConfigSpecRef, + ModelRequirement, RailActions, RailConfigSchema, RailDirection, @@ -24,6 +25,7 @@ RailManifest, RailMetadata, RailPrivacy, + RailRequirements, RailSpec, RailSurface, ) @@ -79,6 +81,10 @@ bindings=(Binding.surface_param("model_name", "model"),), ), ), + requirements=RailRequirements( + models=(ModelRequirement(type="content_safety", required=True),), + extras=("multilingual",), + ), privacy=RailPrivacy(sends_user_text=True, sends_bot_text=True), ), ) diff --git a/nemoguardrails/library/f5/actions.py b/nemoguardrails/library/f5/actions.py index a0eb0b36fb..be2d7b7385 100644 --- a/nemoguardrails/library/f5/actions.py +++ b/nemoguardrails/library/f5/actions.py @@ -81,7 +81,6 @@ def _retrying_http_client(client: HTTPClient, f5_config: F5GuardrailsRailConfig) client, _retry_policy(f5_config), sleep=asyncio.sleep, - random_value=lambda: 1.0, ) diff --git a/nemoguardrails/library/injection_detection/actions.py b/nemoguardrails/library/injection_detection/actions.py index 54f6fc2b2e..e2daf61a74 100644 --- a/nemoguardrails/library/injection_detection/actions.py +++ b/nemoguardrails/library/injection_detection/actions.py @@ -59,8 +59,13 @@ def _injection_detection_outcome( action_option: str, original_text: str, ) -> RailOutcome: - metadata = dict(result) - metadata["action"] = action_option + # the checked text is deliberately excluded: outcome metadata reaches + # processing logs and tracing exporters + metadata = { + "is_injection": result["is_injection"], + "detections": result["detections"], + "action": action_option, + } if action_option == "reject" and result["is_injection"]: return RailOutcome.block(metadata=metadata) if result["text"] != original_text: @@ -193,7 +198,7 @@ def _load_rules( except yara.SyntaxError as e: msg = f"Failed to initialize injection detection due to configuration or YARA rule error: YARA compilation failed: {e}" log.error(msg) - return None + raise ValueError(msg) from e return rules diff --git a/nemoguardrails/library/regex/actions.py b/nemoguardrails/library/regex/actions.py index b32e2cc0e3..38b9184411 100644 --- a/nemoguardrails/library/regex/actions.py +++ b/nemoguardrails/library/regex/actions.py @@ -30,8 +30,9 @@ class RegexDetectionResult(TypedDict): def _regex_outcome(source: str, result: RegexDetectionResult) -> RailOutcome: - metadata = dict(result) - metadata["source"] = source + # the checked text is deliberately excluded: outcome metadata reaches + # processing logs and tracing exporters + metadata = {"is_match": result["is_match"], "detections": result["detections"], "source": source} if result["is_match"] and source == "retrieval": return RailOutcome.transform([(TransformTarget.RELEVANT_CHUNKS, "")], metadata=metadata) if result["is_match"]: diff --git a/tests/test_f5_guardrails.py b/tests/test_f5_guardrails.py index 89c9be5484..ca8a2a0006 100644 --- a/tests/test_f5_guardrails.py +++ b/tests/test_f5_guardrails.py @@ -678,8 +678,11 @@ async def test_f5_guardrails_429_exhausted_fail_closed(config_no_backoff, monkey @pytest.mark.asyncio async def test_f5_guardrails_429_no_retry_after_uses_backoff(monkeypatch): - """When Retry-After is missing, retry_backoff_seconds * 2**attempt is used.""" + """When Retry-After is missing, retry_backoff_seconds * 2**attempt is the jitter cap.""" monkeypatch.setenv("F5_GUARDRAILS_API_KEY", "test-key") + # backoff is full jitter, a uniform draw in [0, cap]; pin the draw to the + # cap so the delays below are deterministic + monkeypatch.setattr("nemoguardrails.http.retry.random.random", lambda: 1.0) cfg = RailsConfig.from_content( yaml_content=""" diff --git a/tests/test_injection_detection.py b/tests/test_injection_detection.py index beaeb2ee77..0f4b7db69e 100644 --- a/tests/test_injection_detection.py +++ b/tests/test_injection_detection.py @@ -90,15 +90,13 @@ def match(self, data=None, **kwargs): {"is_injection": False, "text": "normal", "detections": []}, "reject", "normal", - RailOutcome.allow(metadata={"is_injection": False, "text": "normal", "detections": [], "action": "reject"}), + RailOutcome.allow(metadata={"is_injection": False, "detections": [], "action": "reject"}), ), ( {"is_injection": True, "text": "normal", "detections": ["sqli"]}, "reject", "normal", - RailOutcome.block( - metadata={"is_injection": True, "text": "normal", "detections": ["sqli"], "action": "reject"} - ), + RailOutcome.block(metadata={"is_injection": True, "detections": ["sqli"], "action": "reject"}), ), ( {"is_injection": True, "text": "omitted", "detections": ["sqli"]}, @@ -106,7 +104,7 @@ def match(self, data=None, **kwargs): "normal", RailOutcome.transform( [(TransformTarget.BOT_MESSAGE, "omitted")], - metadata={"is_injection": True, "text": "omitted", "detections": ["sqli"], "action": "omit"}, + metadata={"is_injection": True, "detections": ["sqli"], "action": "omit"}, ), ), ], @@ -711,8 +709,8 @@ async def test_omit_action_with_exceptions_enabled(): @pytest.mark.asyncio -async def test_malformed_inline_yara_rule_fails_gracefully(caplog): - """Test that a malformed inline YARA rule leads to graceful failure (detection becomes no-op).""" +async def test_malformed_inline_yara_rule_fails_closed(caplog): + """Test that a malformed inline YARA rule fails closed rather than disabling detection.""" inline_rule_name = "malformed_rule" # this rule is malformed: missing { after rule name @@ -750,8 +748,10 @@ async def test_malformed_inline_yara_rule_fails_gracefully(caplog): result = await rails.generate_async(messages=[{"role": "user", "content": "trigger detection"}]) - # check that no exception was raised - assert result.get("role") != "exception", f"Expected no exception, but got {result}" + # a rule that cannot compile must not silently disable detection: the + # unchecked model output must never reach the caller + assert some_text_that_would_be_injection not in result["content"] + assert "internal error" in result["content"] # verify the error log was created with the expected content assert any( diff --git a/tests/test_regex_detection.py b/tests/test_regex_detection.py index f2c8df5d8a..0f9467d7b2 100644 --- a/tests/test_regex_detection.py +++ b/tests/test_regex_detection.py @@ -687,10 +687,8 @@ async def test_regex_action_accepts_extra_kwargs(): def test_regex_output_verdict_blocks_on_match(): from nemoguardrails.actions.rail_outcome import RailOutcome - matched = RailOutcome.block( - metadata={"is_match": True, "text": "fight club", "detections": ["\\bfight\\s+club\\b"]} - ) - no_match = RailOutcome.allow(metadata={"is_match": False, "text": "hello", "detections": []}) + matched = RailOutcome.block(metadata={"is_match": True, "detections": ["\\bfight\\s+club\\b"]}) + no_match = RailOutcome.allow(metadata={"is_match": False, "detections": []}) assert matched.is_blocked is True assert no_match.is_blocked is False