Skip to main content

bob_adapters/
approval_static.rs

1//! Static approval adapter for tool-call guardrails.
2
3use bob_core::{
4    ToolError, normalize_tool_list,
5    ports::ApprovalPort,
6    tools_match,
7    types::{ApprovalContext, ApprovalDecision, ToolCall},
8};
9
10/// Approval behavior mode.
11#[derive(Debug, Clone, Copy, PartialEq, Eq)]
12pub enum StaticApprovalMode {
13    AllowAll,
14    DenyAll,
15}
16
17/// Runtime static approval policy.
18#[derive(Debug, Clone)]
19pub struct StaticApprovalPort {
20    mode: StaticApprovalMode,
21    deny_tools: Vec<String>,
22}
23
24impl StaticApprovalPort {
25    #[must_use]
26    pub fn new(mode: StaticApprovalMode, deny_tools: Vec<String>) -> Self {
27        Self { mode, deny_tools: normalize_tool_list(deny_tools.iter().map(String::as_str)) }
28    }
29}
30
31#[async_trait::async_trait]
32impl ApprovalPort for StaticApprovalPort {
33    async fn approve_tool_call(
34        &self,
35        call: &ToolCall,
36        _context: &ApprovalContext,
37    ) -> Result<ApprovalDecision, ToolError> {
38        if self.mode == StaticApprovalMode::DenyAll {
39            return Ok(ApprovalDecision::Denied {
40                reason: "tool calls are disabled by approval policy".to_string(),
41            });
42        }
43        if self.deny_tools.iter().any(|tool| tools_match(tool, &call.name)) {
44            return Ok(ApprovalDecision::Denied {
45                reason: format!("tool '{}' denied by approval policy", call.name),
46            });
47        }
48        Ok(ApprovalDecision::Approved)
49    }
50}
51
52#[cfg(test)]
53mod tests {
54    use super::*;
55
56    fn make_tool_call(name: &str) -> ToolCall {
57        ToolCall { name: name.to_string(), arguments: serde_json::json!({}) }
58    }
59
60    fn test_context() -> ApprovalContext {
61        ApprovalContext { session_id: "s1".to_string(), turn_step: 1, selected_skills: Vec::new() }
62    }
63
64    #[tokio::test]
65    async fn deny_all_rejects_everything() {
66        let approval = StaticApprovalPort::new(StaticApprovalMode::DenyAll, vec![]);
67        let decision =
68            approval.approve_tool_call(&make_tool_call("local/read_file"), &test_context()).await;
69        assert!(decision.is_ok());
70        assert!(matches!(decision.ok(), Some(ApprovalDecision::Denied { .. })));
71    }
72
73    #[tokio::test]
74    async fn deny_tools_rejects_listed_tools() {
75        let approval =
76            StaticApprovalPort::new(StaticApprovalMode::AllowAll, vec!["local/shell_exec".into()]);
77        let decision =
78            approval.approve_tool_call(&make_tool_call("local/shell_exec"), &test_context()).await;
79        assert!(decision.is_ok());
80        assert!(matches!(decision.ok(), Some(ApprovalDecision::Denied { .. })));
81    }
82
83    #[tokio::test]
84    async fn allow_mode_allows_unlisted_tools() {
85        let approval =
86            StaticApprovalPort::new(StaticApprovalMode::AllowAll, vec!["local/shell_exec".into()]);
87        let decision =
88            approval.approve_tool_call(&make_tool_call("local/read_file"), &test_context()).await;
89        assert!(decision.is_ok());
90        assert!(matches!(decision.ok(), Some(ApprovalDecision::Approved)));
91    }
92}