Skip to main content

embacle/
guardrail.rs

1// ABOUTME: Pluggable guardrail middleware for pre-request and post-response validation
2// ABOUTME: Includes content length, topic filter, and basic PII detection guardrails
3//
4// SPDX-License-Identifier: Apache-2.0
5// Copyright (c) 2026 dravr.ai
6
7//! # Guardrail Middleware
8//!
9//! [`GuardrailProvider`] wraps an inner `Box<dyn LlmProvider>` and runs
10//! pre-request and post-response checks via pluggable [`Guardrail`] trait
11//! implementations. If any guardrail rejects the request or response, a
12//! [`RunnerError`] with `ErrorKind::Guardrail` is returned.
13//!
14//! ## Built-in Guardrails
15//!
16//! - [`ContentLengthGuardrail`] — rejects oversized messages
17//! - [`TopicFilterGuardrail`] — blocks messages containing specified patterns
18//! - [`PiiScrubGuardrail`] — detects basic email/phone patterns (not production-grade)
19//!
20//! ## Limitations
21//!
22//! - `complete_stream()` runs pre-request checks but skips post-response checks,
23//!   since the full content is not available upfront.
24
25use std::fmt;
26
27use async_trait::async_trait;
28use tracing::warn;
29
30use crate::types::{
31    ChatRequest, ChatResponse, ChatStream, LlmCapabilities, LlmProvider, RunnerError,
32};
33
34/// A guardrail violation with details about which guardrail and why
35#[derive(Debug, Clone)]
36pub struct GuardrailViolation {
37    /// Name of the guardrail that triggered
38    pub guardrail_name: String,
39    /// Explanation of why the check failed
40    pub reason: String,
41}
42
43impl fmt::Display for GuardrailViolation {
44    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
45        write!(f, "{}: {}", self.guardrail_name, self.reason)
46    }
47}
48
49impl From<GuardrailViolation> for RunnerError {
50    fn from(violation: GuardrailViolation) -> Self {
51        Self::guardrail(violation.to_string())
52    }
53}
54
55/// Trait for synchronous pre/post request validation.
56///
57/// Guardrails run fast, in-memory checks. They must be `Send + Sync`
58/// to work with async providers, but the checks themselves are synchronous.
59pub trait Guardrail: Send + Sync {
60    /// Human-readable name for this guardrail
61    fn name(&self) -> &str;
62
63    /// Validate a request before it reaches the provider.
64    ///
65    /// Return `Ok(())` to allow the request, or `Err(GuardrailViolation)` to reject it.
66    fn check_request(&self, request: &ChatRequest) -> Result<(), GuardrailViolation>;
67
68    /// Validate a response after it comes back from the provider.
69    ///
70    /// Return `Ok(())` to allow the response, or `Err(GuardrailViolation)` to reject it.
71    fn check_response(
72        &self,
73        request: &ChatRequest,
74        response: &ChatResponse,
75    ) -> Result<(), GuardrailViolation>;
76}
77
78/// Provider wrapper that applies guardrail checks before and after LLM calls.
79pub struct GuardrailProvider {
80    inner: Box<dyn LlmProvider>,
81    guardrails: Vec<Box<dyn Guardrail>>,
82}
83
84impl GuardrailProvider {
85    /// Wrap a provider with guardrail validation
86    pub fn new(inner: Box<dyn LlmProvider>, guardrails: Vec<Box<dyn Guardrail>>) -> Self {
87        Self { inner, guardrails }
88    }
89
90    /// Run all pre-request guardrail checks
91    fn check_all_requests(&self, request: &ChatRequest) -> Result<(), RunnerError> {
92        for guardrail in &self.guardrails {
93            guardrail.check_request(request).map_err(|violation| {
94                warn!(
95                    guardrail = guardrail.name(),
96                    reason = %violation.reason,
97                    "guardrail: pre-request check failed"
98                );
99                RunnerError::from(violation)
100            })?;
101        }
102        Ok(())
103    }
104
105    /// Run all post-response guardrail checks
106    fn check_all_responses(
107        &self,
108        request: &ChatRequest,
109        response: &ChatResponse,
110    ) -> Result<(), RunnerError> {
111        for guardrail in &self.guardrails {
112            guardrail
113                .check_response(request, response)
114                .map_err(|violation| {
115                    warn!(
116                        guardrail = guardrail.name(),
117                        reason = %violation.reason,
118                        "guardrail: post-response check failed"
119                    );
120                    RunnerError::from(violation)
121                })?;
122        }
123        Ok(())
124    }
125}
126
127#[async_trait]
128impl LlmProvider for GuardrailProvider {
129    fn name(&self) -> &'static str {
130        self.inner.name()
131    }
132
133    fn display_name(&self) -> &str {
134        self.inner.display_name()
135    }
136
137    fn capabilities(&self) -> LlmCapabilities {
138        self.inner.capabilities()
139    }
140
141    fn default_model(&self) -> &str {
142        self.inner.default_model()
143    }
144
145    fn available_models(&self) -> &[String] {
146        self.inner.available_models()
147    }
148
149    async fn complete(&self, request: &ChatRequest) -> Result<ChatResponse, RunnerError> {
150        self.check_all_requests(request)?;
151        let response = self.inner.complete(request).await?;
152        self.check_all_responses(request, &response)?;
153        Ok(response)
154    }
155
156    /// Pre-request guardrails run; post-response checks are skipped (documented limitation)
157    async fn complete_stream(&self, request: &ChatRequest) -> Result<ChatStream, RunnerError> {
158        self.check_all_requests(request)?;
159        self.inner.complete_stream(request).await
160    }
161
162    async fn health_check(&self) -> Result<bool, RunnerError> {
163        self.inner.health_check().await
164    }
165}
166
167// ============================================================================
168// Built-in Guardrails
169// ============================================================================
170
171/// Rejects messages that exceed configured character limits.
172pub struct ContentLengthGuardrail {
173    /// Maximum characters allowed in a single message
174    pub max_message_chars: usize,
175    /// Maximum total characters across all messages in a request
176    pub max_total_chars: usize,
177}
178
179impl Guardrail for ContentLengthGuardrail {
180    fn name(&self) -> &str {
181        "content_length"
182    }
183
184    fn check_request(&self, request: &ChatRequest) -> Result<(), GuardrailViolation> {
185        let mut total = 0;
186        for msg in &request.messages {
187            if msg.content.len() > self.max_message_chars {
188                return Err(GuardrailViolation {
189                    guardrail_name: self.name().to_owned(),
190                    reason: format!(
191                        "message exceeds max length ({} > {} chars)",
192                        msg.content.len(),
193                        self.max_message_chars
194                    ),
195                });
196            }
197            total += msg.content.len();
198        }
199        if total > self.max_total_chars {
200            return Err(GuardrailViolation {
201                guardrail_name: self.name().to_owned(),
202                reason: format!(
203                    "total content exceeds max length ({total} > {} chars)",
204                    self.max_total_chars
205                ),
206            });
207        }
208        Ok(())
209    }
210
211    fn check_response(
212        &self,
213        _request: &ChatRequest,
214        _response: &ChatResponse,
215    ) -> Result<(), GuardrailViolation> {
216        Ok(())
217    }
218}
219
220/// Blocks messages containing specified topic patterns (case-insensitive substring match).
221pub struct TopicFilterGuardrail {
222    /// Patterns to block (matched case-insensitively as substrings)
223    pub blocked_patterns: Vec<String>,
224}
225
226impl Guardrail for TopicFilterGuardrail {
227    fn name(&self) -> &str {
228        "topic_filter"
229    }
230
231    fn check_request(&self, request: &ChatRequest) -> Result<(), GuardrailViolation> {
232        for msg in &request.messages {
233            let content_lower = msg.content.to_lowercase();
234            for pattern in &self.blocked_patterns {
235                if content_lower.contains(&pattern.to_lowercase()) {
236                    return Err(GuardrailViolation {
237                        guardrail_name: self.name().to_owned(),
238                        reason: format!("blocked topic detected: \"{pattern}\""),
239                    });
240                }
241            }
242        }
243        Ok(())
244    }
245
246    fn check_response(
247        &self,
248        _request: &ChatRequest,
249        response: &ChatResponse,
250    ) -> Result<(), GuardrailViolation> {
251        let content_lower = response.content.to_lowercase();
252        for pattern in &self.blocked_patterns {
253            if content_lower.contains(&pattern.to_lowercase()) {
254                return Err(GuardrailViolation {
255                    guardrail_name: self.name().to_owned(),
256                    reason: format!("blocked topic detected in response: \"{pattern}\""),
257                });
258            }
259        }
260        Ok(())
261    }
262}
263
264/// Detects basic email and phone number patterns in messages.
265///
266/// This uses hand-rolled character scanning, not regex. It catches common
267/// patterns but is not intended for production-grade PII detection.
268pub struct PiiScrubGuardrail {
269    /// Whether to check for email-like patterns (contains `@` with surrounding word chars)
270    pub check_email: bool,
271    /// Whether to check for phone-like patterns (sequences of 7+ digits with optional separators)
272    pub check_phone: bool,
273}
274
275impl PiiScrubGuardrail {
276    /// Scan text for email-like patterns: word chars, then @, then word chars with dots
277    fn contains_email(text: &str) -> bool {
278        let chars: Vec<char> = text.chars().collect();
279        for (i, &c) in chars.iter().enumerate() {
280            if c == '@' {
281                // Check for word chars before @
282                let has_before = i > 0 && (chars[i - 1].is_alphanumeric() || chars[i - 1] == '.');
283                // Check for word chars after @
284                let has_after =
285                    i + 1 < chars.len() && (chars[i + 1].is_alphanumeric() || chars[i + 1] == '.');
286                if has_before && has_after {
287                    return true;
288                }
289            }
290        }
291        false
292    }
293
294    /// Scan text for phone-like patterns: 7 or more digits, possibly separated by
295    /// dashes, spaces, dots, or parentheses
296    fn contains_phone(text: &str) -> bool {
297        let mut digit_count = 0;
298        let mut in_sequence = false;
299        for c in text.chars() {
300            if c.is_ascii_digit() {
301                digit_count += 1;
302                in_sequence = true;
303            } else if in_sequence && (c == '-' || c == ' ' || c == '.' || c == '(' || c == ')') {
304                // Allow separators within a phone sequence
305            } else {
306                if digit_count >= 7 {
307                    return true;
308                }
309                digit_count = 0;
310                in_sequence = false;
311            }
312        }
313        digit_count >= 7
314    }
315}
316
317impl Guardrail for PiiScrubGuardrail {
318    fn name(&self) -> &str {
319        "pii_scrub"
320    }
321
322    fn check_request(&self, request: &ChatRequest) -> Result<(), GuardrailViolation> {
323        for msg in &request.messages {
324            if self.check_email && Self::contains_email(&msg.content) {
325                return Err(GuardrailViolation {
326                    guardrail_name: self.name().to_owned(),
327                    reason: "email address detected in request".to_owned(),
328                });
329            }
330            if self.check_phone && Self::contains_phone(&msg.content) {
331                return Err(GuardrailViolation {
332                    guardrail_name: self.name().to_owned(),
333                    reason: "phone number detected in request".to_owned(),
334                });
335            }
336        }
337        Ok(())
338    }
339
340    fn check_response(
341        &self,
342        _request: &ChatRequest,
343        response: &ChatResponse,
344    ) -> Result<(), GuardrailViolation> {
345        if self.check_email && Self::contains_email(&response.content) {
346            return Err(GuardrailViolation {
347                guardrail_name: self.name().to_owned(),
348                reason: "email address detected in response".to_owned(),
349            });
350        }
351        if self.check_phone && Self::contains_phone(&response.content) {
352            return Err(GuardrailViolation {
353                guardrail_name: self.name().to_owned(),
354                reason: "phone number detected in response".to_owned(),
355            });
356        }
357        Ok(())
358    }
359}
360
361#[cfg(test)]
362mod tests {
363    use super::*;
364    use crate::types::{
365        ChatMessage, ChatRequest, ChatResponse, ChatStream, ErrorKind, LlmCapabilities,
366        LlmProvider, RunnerError,
367    };
368    use async_trait::async_trait;
369    use std::sync::atomic::{AtomicU32, Ordering};
370    use std::sync::Mutex;
371
372    struct TestProvider {
373        responses: Mutex<Vec<Result<ChatResponse, RunnerError>>>,
374        call_count: AtomicU32,
375    }
376
377    impl TestProvider {
378        fn ok(content: &str) -> Self {
379            Self {
380                responses: Mutex::new(vec![Ok(ChatResponse {
381                    content: content.to_owned(),
382                    model: "test-model".to_owned(),
383                    usage: None,
384                    finish_reason: Some("stop".to_owned()),
385                    warnings: None,
386                    tool_calls: None,
387                })]),
388                call_count: AtomicU32::new(0),
389            }
390        }
391    }
392
393    #[async_trait]
394    impl LlmProvider for TestProvider {
395        fn name(&self) -> &'static str {
396            "test"
397        }
398        fn display_name(&self) -> &str {
399            "Test Provider"
400        }
401        fn capabilities(&self) -> LlmCapabilities {
402            LlmCapabilities::text_only()
403        }
404        fn default_model(&self) -> &'static str {
405            "test-model"
406        }
407        fn available_models(&self) -> &[String] {
408            &[]
409        }
410        async fn complete(&self, _request: &ChatRequest) -> Result<ChatResponse, RunnerError> {
411            self.call_count.fetch_add(1, Ordering::SeqCst);
412            let mut responses = self.responses.lock().expect("test lock"); // Safe: test assertion
413            if responses.is_empty() {
414                Ok(ChatResponse {
415                    content: "default".to_owned(),
416                    model: "test-model".to_owned(),
417                    usage: None,
418                    finish_reason: Some("stop".to_owned()),
419                    warnings: None,
420                    tool_calls: None,
421                })
422            } else {
423                responses.remove(0)
424            }
425        }
426        async fn complete_stream(&self, _request: &ChatRequest) -> Result<ChatStream, RunnerError> {
427            Err(RunnerError::internal("streaming not supported in test"))
428        }
429        async fn health_check(&self) -> Result<bool, RunnerError> {
430            Ok(true)
431        }
432    }
433
434    // ========================================================================
435    // ContentLengthGuardrail tests
436    // ========================================================================
437
438    #[test]
439    fn content_length_rejects_long_message() {
440        let guard = ContentLengthGuardrail {
441            max_message_chars: 10,
442            max_total_chars: 1000,
443        };
444        let request = ChatRequest::new(vec![ChatMessage::user("a".repeat(11))]);
445        let result = guard.check_request(&request);
446        assert!(result.is_err());
447        assert!(result.unwrap_err().reason.contains("max length"));
448    }
449
450    #[test]
451    fn content_length_rejects_total_overflow() {
452        let guard = ContentLengthGuardrail {
453            max_message_chars: 100,
454            max_total_chars: 15,
455        };
456        let request = ChatRequest::new(vec![
457            ChatMessage::user("abcdefgh"), // 8
458            ChatMessage::user("ijklmnop"), // 8 -> total 16 > 15
459        ]);
460        let result = guard.check_request(&request);
461        assert!(result.is_err());
462        assert!(result.unwrap_err().reason.contains("total content"));
463    }
464
465    #[test]
466    fn content_length_accepts_within_limits() {
467        let guard = ContentLengthGuardrail {
468            max_message_chars: 100,
469            max_total_chars: 1000,
470        };
471        let request = ChatRequest::new(vec![ChatMessage::user("hello")]);
472        assert!(guard.check_request(&request).is_ok());
473    }
474
475    #[test]
476    fn content_length_response_passthrough() {
477        let guard = ContentLengthGuardrail {
478            max_message_chars: 5,
479            max_total_chars: 5,
480        };
481        let request = ChatRequest::new(vec![ChatMessage::user("hi")]);
482        let response = ChatResponse {
483            content: "a very long response that exceeds limits".to_owned(),
484            model: "m".to_owned(),
485            usage: None,
486            finish_reason: None,
487            warnings: None,
488            tool_calls: None,
489        };
490        // ContentLengthGuardrail does not check responses
491        assert!(guard.check_response(&request, &response).is_ok());
492    }
493
494    // ========================================================================
495    // TopicFilterGuardrail tests
496    // ========================================================================
497
498    #[test]
499    fn topic_filter_blocks_request() {
500        let guard = TopicFilterGuardrail {
501            blocked_patterns: vec!["forbidden".to_owned()],
502        };
503        let request = ChatRequest::new(vec![ChatMessage::user("this is forbidden content")]);
504        let result = guard.check_request(&request);
505        assert!(result.is_err());
506        assert!(result.unwrap_err().reason.contains("forbidden"));
507    }
508
509    #[test]
510    fn topic_filter_case_insensitive() {
511        let guard = TopicFilterGuardrail {
512            blocked_patterns: vec!["SECRET".to_owned()],
513        };
514        let request = ChatRequest::new(vec![ChatMessage::user("tell me the secret")]);
515        assert!(guard.check_request(&request).is_err());
516    }
517
518    #[test]
519    fn topic_filter_allows_clean_content() {
520        let guard = TopicFilterGuardrail {
521            blocked_patterns: vec!["forbidden".to_owned()],
522        };
523        let request = ChatRequest::new(vec![ChatMessage::user("hello world")]);
524        assert!(guard.check_request(&request).is_ok());
525    }
526
527    #[test]
528    fn topic_filter_blocks_response() {
529        let guard = TopicFilterGuardrail {
530            blocked_patterns: vec!["classified".to_owned()],
531        };
532        let request = ChatRequest::new(vec![ChatMessage::user("tell me")]);
533        let response = ChatResponse {
534            content: "this information is classified".to_owned(),
535            model: "m".to_owned(),
536            usage: None,
537            finish_reason: None,
538            warnings: None,
539            tool_calls: None,
540        };
541        assert!(guard.check_response(&request, &response).is_err());
542    }
543
544    #[test]
545    fn topic_filter_multiple_patterns() {
546        let guard = TopicFilterGuardrail {
547            blocked_patterns: vec!["alpha".to_owned(), "beta".to_owned()],
548        };
549        let request = ChatRequest::new(vec![ChatMessage::user("the beta release")]);
550        assert!(guard.check_request(&request).is_err());
551    }
552
553    // ========================================================================
554    // PiiScrubGuardrail tests
555    // ========================================================================
556
557    #[test]
558    fn pii_detects_email() {
559        let guard = PiiScrubGuardrail {
560            check_email: true,
561            check_phone: false,
562        };
563        let request = ChatRequest::new(vec![ChatMessage::user("contact me at user@example.com")]);
564        assert!(guard.check_request(&request).is_err());
565    }
566
567    #[test]
568    fn pii_detects_phone() {
569        let guard = PiiScrubGuardrail {
570            check_email: false,
571            check_phone: true,
572        };
573        let request = ChatRequest::new(vec![ChatMessage::user("call me at 555-123-4567 please")]);
574        assert!(guard.check_request(&request).is_err());
575    }
576
577    #[test]
578    fn pii_allows_clean_content() {
579        let guard = PiiScrubGuardrail {
580            check_email: true,
581            check_phone: true,
582        };
583        let request = ChatRequest::new(vec![ChatMessage::user("hello world 42")]);
584        assert!(guard.check_request(&request).is_ok());
585    }
586
587    #[test]
588    fn pii_detects_email_in_response() {
589        let guard = PiiScrubGuardrail {
590            check_email: true,
591            check_phone: false,
592        };
593        let request = ChatRequest::new(vec![ChatMessage::user("hi")]);
594        let response = ChatResponse {
595            content: "reach out to admin@corp.io".to_owned(),
596            model: "m".to_owned(),
597            usage: None,
598            finish_reason: None,
599            warnings: None,
600            tool_calls: None,
601        };
602        assert!(guard.check_response(&request, &response).is_err());
603    }
604
605    #[test]
606    fn pii_phone_without_separators() {
607        let guard = PiiScrubGuardrail {
608            check_email: false,
609            check_phone: true,
610        };
611        let request = ChatRequest::new(vec![ChatMessage::user("number is 5551234567")]);
612        assert!(guard.check_request(&request).is_err());
613    }
614
615    // ========================================================================
616    // GuardrailProvider integration tests
617    // ========================================================================
618
619    #[tokio::test]
620    async fn pre_request_rejection_prevents_inner_call() {
621        let provider = TestProvider::ok("should not reach");
622        let guard = TopicFilterGuardrail {
623            blocked_patterns: vec!["blocked".to_owned()],
624        };
625        let guarded = GuardrailProvider::new(Box::new(provider), vec![Box::new(guard)]);
626        let request = ChatRequest::new(vec![ChatMessage::user("this is blocked")]);
627
628        let err = guarded.complete(&request).await.unwrap_err();
629        assert_eq!(err.kind, ErrorKind::Guardrail);
630        assert!(err.message.contains("blocked"));
631    }
632
633    #[tokio::test]
634    async fn post_response_rejection() {
635        let provider = TestProvider::ok("this contains classified info");
636        let guard = TopicFilterGuardrail {
637            blocked_patterns: vec!["classified".to_owned()],
638        };
639        let guarded = GuardrailProvider::new(Box::new(provider), vec![Box::new(guard)]);
640        let request = ChatRequest::new(vec![ChatMessage::user("tell me something")]);
641
642        let err = guarded.complete(&request).await.unwrap_err();
643        assert_eq!(err.kind, ErrorKind::Guardrail);
644        assert!(err.message.contains("classified"));
645    }
646
647    #[tokio::test]
648    async fn multiple_guardrails() {
649        let provider = TestProvider::ok("safe response");
650        let length_guard = ContentLengthGuardrail {
651            max_message_chars: 5,
652            max_total_chars: 100,
653        };
654        let topic_guard = TopicFilterGuardrail {
655            blocked_patterns: vec!["forbidden".to_owned()],
656        };
657        let guarded = GuardrailProvider::new(
658            Box::new(provider),
659            vec![Box::new(length_guard), Box::new(topic_guard)],
660        );
661
662        // Length guardrail triggers first
663        let request = ChatRequest::new(vec![ChatMessage::user("this is too long")]);
664        let err = guarded.complete(&request).await.unwrap_err();
665        assert_eq!(err.kind, ErrorKind::Guardrail);
666        assert!(err.message.contains("content_length"));
667    }
668
669    #[tokio::test]
670    async fn empty_guardrails_passthrough() {
671        let provider = TestProvider::ok("hello");
672        let guarded = GuardrailProvider::new(Box::new(provider), vec![]);
673        let request = ChatRequest::new(vec![ChatMessage::user("hi")]);
674
675        let response = guarded.complete(&request).await.expect("should pass"); // Safe: test assertion
676        assert_eq!(response.content, "hello");
677    }
678
679    #[tokio::test]
680    async fn delegates_trait_methods() {
681        let provider = TestProvider::ok("hello");
682        let guarded = GuardrailProvider::new(Box::new(provider), vec![]);
683
684        assert_eq!(guarded.name(), "test");
685        assert_eq!(guarded.display_name(), "Test Provider");
686        assert_eq!(guarded.default_model(), "test-model");
687        assert!(guarded.capabilities().supports_streaming());
688
689        let healthy = guarded.health_check().await.expect("health check"); // Safe: test assertion
690        assert!(healthy);
691    }
692}