Skip to main content

heartbit_core/agent/
guardrail.rs

1//! `Guardrail` trait and `GuardAction` — base types for all guardrail implementations.
2
3use std::future::Future;
4use std::pin::Pin;
5
6use crate::error::Error;
7use crate::llm::types::{CompletionRequest, CompletionResponse, ToolCall};
8use crate::tool::ToolOutput;
9
10/// Action returned by guardrail hooks that can deny operations.
11#[derive(Debug, Clone, PartialEq)]
12pub enum GuardAction {
13    /// Allow the operation to proceed.
14    Allow,
15    /// Deny the operation with a reason.
16    Deny {
17        /// Human-readable reason recorded in audit + events.
18        reason: String,
19    },
20    /// Log the concern but allow the operation to proceed.
21    ///
22    /// The agent loop treats `Warn` like `Allow` but emits
23    /// `AgentEvent::GuardrailWarned` and an audit record. This enables
24    /// monitoring mode (shadow enforcement) without blocking production.
25    Warn {
26        /// Human-readable reason recorded in audit + events.
27        reason: String,
28    },
29    /// Immediately terminate the agent run. Used for critical detections
30    /// (e.g., CSAM, active exploitation) where the agent loop must stop
31    /// without further processing. The agent emits `KillSwitchActivated`
32    /// and returns `Error::KillSwitch`.
33    Kill {
34        /// Human-readable reason recorded in audit + events.
35        reason: String,
36    },
37}
38
39impl GuardAction {
40    /// Create a `Deny` action with the given reason.
41    pub fn deny(reason: impl Into<String>) -> Self {
42        GuardAction::Deny {
43            reason: reason.into(),
44        }
45    }
46
47    /// Create a `Warn` action with the given reason.
48    pub fn warn(reason: impl Into<String>) -> Self {
49        GuardAction::Warn {
50            reason: reason.into(),
51        }
52    }
53
54    /// Create a `Kill` action with the given reason.
55    pub fn kill(reason: impl Into<String>) -> Self {
56        GuardAction::Kill {
57            reason: reason.into(),
58        }
59    }
60
61    /// Returns `true` if this action blocks the operation (`Deny` or `Kill`).
62    pub fn is_denied(&self) -> bool {
63        matches!(self, GuardAction::Deny { .. } | GuardAction::Kill { .. })
64    }
65
66    /// Returns `true` if this action terminates the agent run (`Kill`).
67    pub fn is_killed(&self) -> bool {
68        matches!(self, GuardAction::Kill { .. })
69    }
70}
71
72/// Interceptor hooks for LLM calls and tool executions.
73///
74/// All methods have default no-op implementations so guardrails only need to
75/// override the hooks they care about. Methods use `Pin<Box<dyn Future>>` for
76/// dyn-compatibility (same pattern as the `Tool` trait).
77///
78/// Multiple guardrails are registered via `Vec<Arc<dyn Guardrail>>` — first
79/// `Deny` wins.
80///
81/// # Example
82///
83/// A trivial guardrail that denies any LLM response containing a forbidden
84/// substring:
85///
86/// ```rust
87/// use std::future::Future;
88/// use std::pin::Pin;
89/// use heartbit_core::{GuardAction, Guardrail};
90/// use heartbit_core::llm::types::CompletionResponse;
91///
92/// struct NoSecrets;
93///
94/// impl Guardrail for NoSecrets {
95///     fn name(&self) -> &str { "no-secrets" }
96///
97///     fn post_llm(
98///         &self,
99///         response: &mut CompletionResponse,
100///     ) -> Pin<Box<dyn Future<Output = Result<GuardAction, heartbit_core::Error>> + Send + '_>> {
101///         let leaked = response
102///             .content
103///             .iter()
104///             .any(|block| matches!(block, heartbit_core::llm::types::ContentBlock::Text { text }
105///                 if text.contains("sk-")));
106///         Box::pin(async move {
107///             Ok(if leaked {
108///                 GuardAction::deny("response contained an API key prefix")
109///             } else {
110///                 GuardAction::Allow
111///             })
112///         })
113///     }
114/// }
115/// ```
116pub trait Guardrail: Send + Sync {
117    /// Human-readable name for this guardrail, used in events and audit.
118    /// Override to attribute which guardrail fired in logs.
119    fn name(&self) -> &str {
120        "unnamed"
121    }
122
123    /// Called before each LLM call. Can mutate the request (e.g., inject safety
124    /// instructions, redact content). `Err` aborts the run.
125    fn pre_llm(
126        &self,
127        _request: &mut CompletionRequest,
128    ) -> Pin<Box<dyn Future<Output = Result<(), Error>> + Send + '_>> {
129        Box::pin(async { Ok(()) })
130    }
131
132    /// Called after each LLM response. Can inspect *and mutate* the response
133    /// (e.g. redact PII in `ContentBlock::Text` blocks before it reaches the
134    /// caller, audit log, or downstream tools).
135    ///
136    /// `Deny` discards the response and injects the denial reason as a user
137    /// message (consumes a turn). `Warn` lets the (possibly mutated) response
138    /// flow through but raises an audit signal. `Err` aborts the run.
139    ///
140    /// **Mutations must run synchronously inside this method body** — the
141    /// returned future's lifetime is tied to `&self`, not to `response`, so
142    /// it cannot capture `&mut response`. Apply any changes to
143    /// `response.content` before constructing the `Box::pin(async move { … })`.
144    /// This is also what lets `GuardrailChain` pipe each guardrail's mutations
145    /// through before any future is awaited.
146    fn post_llm(
147        &self,
148        _response: &mut CompletionResponse,
149    ) -> Pin<Box<dyn Future<Output = Result<GuardAction, Error>> + Send + '_>> {
150        Box::pin(async { Ok(GuardAction::Allow) })
151    }
152
153    /// Called before each tool execution. Can deny individual tool calls.
154    /// `Deny` returns a `ToolResult::error` for that call. `Err` aborts the run.
155    fn pre_tool(
156        &self,
157        _call: &ToolCall,
158    ) -> Pin<Box<dyn Future<Output = Result<GuardAction, Error>> + Send + '_>> {
159        Box::pin(async { Ok(GuardAction::Allow) })
160    }
161
162    /// Called after each tool execution (after truncation). Can mutate the
163    /// output (e.g., redact sensitive data). `Err` converts to a tool error
164    /// (consistent with tool execution errors — the agent loop continues).
165    fn post_tool(
166        &self,
167        _call: &ToolCall,
168        _output: &mut ToolOutput,
169    ) -> Pin<Box<dyn Future<Output = Result<(), Error>> + Send + '_>> {
170        Box::pin(async { Ok(()) })
171    }
172
173    /// Called by the agent loop before each guardrail evaluation to provide
174    /// the current turn number. Stateful guardrails (e.g., `BehavioralMonitorGuardrail`)
175    /// can override this to track turn context.
176    fn set_turn(&self, _turn: usize) {}
177}
178
179#[cfg(test)]
180mod tests {
181    use super::*;
182
183    #[test]
184    fn guard_action_deny_constructor() {
185        let action = GuardAction::deny("PII detected");
186        match action {
187            GuardAction::Deny { reason } => assert_eq!(reason, "PII detected"),
188            _ => panic!("expected Deny"),
189        }
190    }
191
192    #[test]
193    fn guard_action_warn_constructor() {
194        let action = GuardAction::warn("suspicious pattern");
195        match action {
196            GuardAction::Warn { reason } => assert_eq!(reason, "suspicious pattern"),
197            _ => panic!("expected Warn"),
198        }
199    }
200
201    #[test]
202    fn guard_action_is_denied() {
203        assert!(GuardAction::deny("blocked").is_denied());
204        assert!(GuardAction::kill("critical").is_denied());
205        assert!(!GuardAction::Allow.is_denied());
206        assert!(!GuardAction::warn("suspicious").is_denied());
207    }
208
209    #[test]
210    fn guard_action_kill_constructor() {
211        let action = GuardAction::kill("CSAM detected");
212        match action {
213            GuardAction::Kill { reason } => assert_eq!(reason, "CSAM detected"),
214            _ => panic!("expected Kill"),
215        }
216    }
217
218    #[test]
219    fn guard_action_is_killed() {
220        assert!(GuardAction::kill("critical").is_killed());
221        assert!(!GuardAction::deny("blocked").is_killed());
222        assert!(!GuardAction::Allow.is_killed());
223        assert!(!GuardAction::warn("suspicious").is_killed());
224    }
225
226    #[test]
227    fn guardrail_default_name() {
228        struct MyGuardrail;
229        impl Guardrail for MyGuardrail {}
230        let g = MyGuardrail;
231        assert_eq!(g.name(), "unnamed");
232    }
233
234    #[test]
235    fn guardrail_custom_name() {
236        struct NamedGuardrail;
237        impl Guardrail for NamedGuardrail {
238            fn name(&self) -> &str {
239                "pii_detector"
240            }
241        }
242        let g = NamedGuardrail;
243        assert_eq!(g.name(), "pii_detector");
244    }
245
246    #[tokio::test]
247    async fn default_guardrail_allows_everything() {
248        struct NoOpGuardrail;
249        impl Guardrail for NoOpGuardrail {}
250
251        let g = NoOpGuardrail;
252
253        let mut request = CompletionRequest {
254            system: "sys".into(),
255            messages: vec![],
256            tools: vec![],
257            max_tokens: 1024,
258            tool_choice: None,
259            reasoning_effort: None,
260        };
261        g.pre_llm(&mut request).await.unwrap();
262
263        let mut response = CompletionResponse {
264            content: vec![],
265            stop_reason: crate::llm::types::StopReason::EndTurn,
266            reasoning: None,
267            usage: crate::llm::types::TokenUsage::default(),
268            model: None,
269        };
270        let action = g.post_llm(&mut response).await.unwrap();
271        assert!(matches!(action, GuardAction::Allow));
272
273        let call = ToolCall {
274            id: "c1".into(),
275            name: "test".into(),
276            input: serde_json::json!({}),
277        };
278        let action = g.pre_tool(&call).await.unwrap();
279        assert!(matches!(action, GuardAction::Allow));
280
281        let mut output = ToolOutput::success("result".to_string());
282        g.post_tool(&call, &mut output).await.unwrap();
283        assert_eq!(output.content, "result");
284    }
285
286    #[tokio::test]
287    async fn post_tool_can_mutate_output() {
288        struct RedactGuardrail;
289        impl Guardrail for RedactGuardrail {
290            fn post_tool(
291                &self,
292                _call: &ToolCall,
293                output: &mut ToolOutput,
294            ) -> Pin<Box<dyn std::future::Future<Output = Result<(), Error>> + Send + '_>>
295            {
296                // Mutation is synchronous; the future just returns Ok(())
297                output.content = output.content.replace("secret", "[REDACTED]");
298                Box::pin(async { Ok(()) })
299            }
300        }
301
302        let g = RedactGuardrail;
303        let call = ToolCall {
304            id: "c1".into(),
305            name: "test".into(),
306            input: serde_json::json!({}),
307        };
308        let mut output = ToolOutput::success("the secret is 42".to_string());
309        g.post_tool(&call, &mut output).await.unwrap();
310        assert_eq!(output.content, "the [REDACTED] is 42");
311    }
312
313    #[tokio::test]
314    async fn pre_tool_deny_returns_deny_action() {
315        struct BlockBashGuardrail;
316        impl Guardrail for BlockBashGuardrail {
317            fn pre_tool(
318                &self,
319                call: &ToolCall,
320            ) -> Pin<Box<dyn std::future::Future<Output = Result<GuardAction, Error>> + Send + '_>>
321            {
322                let name = call.name.clone();
323                Box::pin(async move {
324                    if name == "bash" {
325                        Ok(GuardAction::deny("bash tool is disabled"))
326                    } else {
327                        Ok(GuardAction::Allow)
328                    }
329                })
330            }
331        }
332
333        let g = BlockBashGuardrail;
334        let bash_call = ToolCall {
335            id: "c1".into(),
336            name: "bash".into(),
337            input: serde_json::json!({"command": "rm -rf /"}),
338        };
339        let action = g.pre_tool(&bash_call).await.unwrap();
340        assert!(
341            matches!(action, GuardAction::Deny { reason } if reason == "bash tool is disabled")
342        );
343
344        let read_call = ToolCall {
345            id: "c2".into(),
346            name: "read".into(),
347            input: serde_json::json!({"path": "/tmp/test.txt"}),
348        };
349        let action = g.pre_tool(&read_call).await.unwrap();
350        assert!(matches!(action, GuardAction::Allow));
351    }
352}