Skip to main content

deepstrike_core/governance/
permission.rs

1use compact_str::CompactString;
2
3use crate::types::message::ToolCall;
4use crate::types::policy::{CallerContext, GovernanceVerdict};
5
6/// Permission action for a tool.
7#[derive(Debug, Clone, PartialEq, Eq)]
8pub enum PermissionAction {
9    Allow,
10    Deny,
11    AskUser,
12}
13
14/// A permission rule matching tool names by glob pattern.
15#[derive(Debug, Clone)]
16pub struct PermissionRule {
17    pub tool_pattern: CompactString,
18    pub action: PermissionAction,
19}
20
21impl PermissionRule {
22    fn matches(&self, tool_name: &str) -> bool {
23        let p = self.tool_pattern.as_str();
24        if p == "*" {
25            return true;
26        }
27        if let Some(prefix) = p.strip_suffix('*') {
28            return tool_name.starts_with(prefix);
29        }
30        if let Some(suffix) = p.strip_prefix('*') {
31            return tool_name.ends_with(suffix);
32        }
33        p == tool_name
34    }
35}
36
37/// Permission manager — evaluates rules in order, first match wins.
38pub struct PermissionManager {
39    rules: Vec<PermissionRule>,
40    default: PermissionAction,
41}
42
43impl PermissionManager {
44    pub fn new(default: PermissionAction) -> Self {
45        Self {
46            rules: Vec::new(),
47            default,
48        }
49    }
50
51    pub fn add_rule(&mut self, rule: PermissionRule) {
52        self.rules.push(rule);
53    }
54
55    pub fn rule_count(&self) -> usize {
56        self.rules.len()
57    }
58
59    pub fn default_action(&self) -> &PermissionAction {
60        &self.default
61    }
62
63    pub fn check(&self, call: &ToolCall, _caller: &CallerContext) -> Option<GovernanceVerdict> {
64        for rule in &self.rules {
65            if rule.matches(&call.name) {
66                return match rule.action {
67                    PermissionAction::Allow => None,
68                    PermissionAction::Deny => Some(GovernanceVerdict::Deny {
69                        stage: "permission",
70                        reason: format!(
71                            "tool '{}' denied by rule '{}'",
72                            call.name, rule.tool_pattern
73                        ),
74                    }),
75                    PermissionAction::AskUser => Some(GovernanceVerdict::AskUser {
76                        reason: format!("tool '{}' requires user approval", call.name),
77                    }),
78                };
79            }
80        }
81        match self.default {
82            PermissionAction::Allow => None,
83            PermissionAction::AskUser => Some(GovernanceVerdict::AskUser {
84                reason: format!("tool '{}' requires user approval", call.name),
85            }),
86            PermissionAction::Deny => Some(GovernanceVerdict::Deny {
87                stage: "permission",
88                reason: format!("tool '{}' denied by default policy", call.name),
89            }),
90        }
91    }
92}
93
94#[cfg(test)]
95mod tests {
96    use super::*;
97    use compact_str::CompactString;
98
99    fn test_call(name: &str) -> ToolCall {
100        ToolCall {
101            id: CompactString::new("call-1"),
102            name: CompactString::new(name),
103            arguments: serde_json::Value::Null,
104        }
105    }
106
107    fn test_caller() -> CallerContext {
108        CallerContext {
109            agent_id: "test".into(),
110            session_id: "s1".into(),
111            is_sub_agent: false,
112            parent_session_id: None,
113        }
114    }
115
116    #[test]
117    fn allow_by_default() {
118        let pm = PermissionManager::new(PermissionAction::Allow);
119        assert!(pm.check(&test_call("anything"), &test_caller()).is_none());
120    }
121
122    #[test]
123    fn deny_by_pattern() {
124        let mut pm = PermissionManager::new(PermissionAction::Allow);
125        pm.add_rule(PermissionRule {
126            tool_pattern: "db.*".into(),
127            action: PermissionAction::Deny,
128        });
129        assert!(pm.check(&test_call("db.drop"), &test_caller()).is_some());
130        assert!(pm.check(&test_call("file.read"), &test_caller()).is_none());
131    }
132}