Skip to main content

deepstrike_core/governance/
permission.rs

1use compact_str::CompactString;
2
3use crate::types::message::ToolCall;
4use crate::types::policy::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 default_action(&self) -> &PermissionAction {
56        &self.default
57    }
58
59    pub fn check(&self, call: &ToolCall) -> Option<GovernanceVerdict> {
60        for rule in &self.rules {
61            if rule.matches(&call.name) {
62                return match rule.action {
63                    PermissionAction::Allow => None,
64                    PermissionAction::Deny => Some(GovernanceVerdict::Deny {
65                        stage: "permission",
66                        reason: format!(
67                            "tool '{}' denied by rule '{}'",
68                            call.name, rule.tool_pattern
69                        ),
70                    }),
71                    PermissionAction::AskUser => Some(GovernanceVerdict::AskUser {
72                        reason: format!("tool '{}' requires user approval", call.name),
73                    }),
74                };
75            }
76        }
77        match self.default {
78            PermissionAction::Allow => None,
79            PermissionAction::AskUser => Some(GovernanceVerdict::AskUser {
80                reason: format!("tool '{}' requires user approval", call.name),
81            }),
82            PermissionAction::Deny => Some(GovernanceVerdict::Deny {
83                stage: "permission",
84                reason: format!("tool '{}' denied by default policy", call.name),
85            }),
86        }
87    }
88}
89
90#[cfg(test)]
91mod tests {
92    use super::*;
93    use compact_str::CompactString;
94
95    fn test_call(name: &str) -> ToolCall {
96        ToolCall {
97            id: CompactString::new("call-1"),
98            name: CompactString::new(name),
99            arguments: serde_json::Value::Null,
100        }
101    }
102
103
104    #[test]
105    fn allow_by_default() {
106        let pm = PermissionManager::new(PermissionAction::Allow);
107        assert!(pm.check(&test_call("anything")).is_none());
108    }
109
110    #[test]
111    fn deny_by_pattern() {
112        let mut pm = PermissionManager::new(PermissionAction::Allow);
113        pm.add_rule(PermissionRule {
114            tool_pattern: "db.*".into(),
115            action: PermissionAction::Deny,
116        });
117        assert!(pm.check(&test_call("db.drop")).is_some());
118        assert!(pm.check(&test_call("file.read")).is_none());
119    }
120}