heartbit_core/agent/
guardrail.rs1use 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#[derive(Debug, Clone, PartialEq)]
12pub enum GuardAction {
13 Allow,
15 Deny {
17 reason: String,
19 },
20 Warn {
26 reason: String,
28 },
29 Kill {
34 reason: String,
36 },
37}
38
39impl GuardAction {
40 pub fn deny(reason: impl Into<String>) -> Self {
42 GuardAction::Deny {
43 reason: reason.into(),
44 }
45 }
46
47 pub fn warn(reason: impl Into<String>) -> Self {
49 GuardAction::Warn {
50 reason: reason.into(),
51 }
52 }
53
54 pub fn kill(reason: impl Into<String>) -> Self {
56 GuardAction::Kill {
57 reason: reason.into(),
58 }
59 }
60
61 pub fn is_denied(&self) -> bool {
63 matches!(self, GuardAction::Deny { .. } | GuardAction::Kill { .. })
64 }
65
66 pub fn is_killed(&self) -> bool {
68 matches!(self, GuardAction::Kill { .. })
69 }
70}
71
72pub trait Guardrail: Send + Sync {
117 fn name(&self) -> &str {
120 "unnamed"
121 }
122
123 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 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 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 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 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 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}