deepstrike_core/governance/
permission.rs1use compact_str::CompactString;
2
3use crate::types::message::ToolCall;
4use crate::types::policy::GovernanceVerdict;
5
6#[derive(Debug, Clone, PartialEq, Eq)]
8pub enum PermissionAction {
9 Allow,
10 Deny,
11 AskUser,
12}
13
14#[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
37pub 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_delegation(
72 &self,
73 requested_child_caps: &[crate::types::capability::Capability],
74 parent_caps: &[crate::types::capability::Capability],
75 ) -> Option<GovernanceVerdict> {
76 match crate::types::capability::caps_subset(requested_child_caps, parent_caps) {
77 Ok(()) => None,
78 Err(violations) => Some(GovernanceVerdict::Deny {
79 stage: "capability_delegation",
80 reason: format!(
81 "capability delegation would widen authority beyond the parent's: {}",
82 violations
83 .iter()
84 .map(|cap| cap.id.0.as_str())
85 .collect::<Vec<_>>()
86 .join(", ")
87 ),
88 }),
89 }
90 }
91
92 pub fn check(&self, call: &ToolCall) -> Option<GovernanceVerdict> {
93 for rule in &self.rules {
94 if rule.matches(&call.name) {
95 return match rule.action {
96 PermissionAction::Allow => None,
97 PermissionAction::Deny => Some(GovernanceVerdict::Deny {
98 stage: "permission",
99 reason: format!(
100 "tool '{}' denied by rule '{}'",
101 call.name, rule.tool_pattern
102 ),
103 }),
104 PermissionAction::AskUser => Some(GovernanceVerdict::AskUser {
105 reason: format!("tool '{}' requires user approval", call.name),
106 }),
107 };
108 }
109 }
110 match self.default {
111 PermissionAction::Allow => None,
112 PermissionAction::AskUser => Some(GovernanceVerdict::AskUser {
113 reason: format!("tool '{}' requires user approval", call.name),
114 }),
115 PermissionAction::Deny => Some(GovernanceVerdict::Deny {
116 stage: "permission",
117 reason: format!("tool '{}' denied by default policy", call.name),
118 }),
119 }
120 }
121}
122
123#[cfg(test)]
124mod tests {
125 use super::*;
126 use compact_str::CompactString;
127
128 fn test_call(name: &str) -> ToolCall {
129 ToolCall {
130 id: CompactString::new("call-1"),
131 name: CompactString::new(name),
132 arguments: serde_json::Value::Null,
133 }
134 }
135
136 #[test]
137 fn allow_by_default() {
138 let pm = PermissionManager::new(PermissionAction::Allow);
139 assert!(pm.check(&test_call("anything")).is_none());
140 }
141
142 #[test]
143 fn deny_by_pattern() {
144 let mut pm = PermissionManager::new(PermissionAction::Allow);
145 pm.add_rule(PermissionRule {
146 tool_pattern: "db.*".into(),
147 action: PermissionAction::Deny,
148 });
149 assert!(pm.check(&test_call("db.drop")).is_some());
150 assert!(pm.check(&test_call("file.read")).is_none());
151 }
152
153 fn cap(resource: &str, actions: &[&str]) -> crate::types::capability::Capability {
154 use crate::types::capability::*;
155 Capability {
156 id: CapabilityId("cap".into()),
157 kind: CapabilityKind::Tool,
158 resource: ResourceSelector(resource.into()),
159 actions: ActionSet(actions.iter().map(|a| (*a).into()).collect()),
160 constraints: ConstraintSet::default(),
161 lease: None,
162 delegatable: true,
163 issuer: Principal("issuer".into()),
164 }
165 }
166
167 #[test]
168 fn check_delegation_denies_a_child_requesting_wider_scope_than_the_parent_holds() {
169 let pm = PermissionManager::new(PermissionAction::Allow);
170 let parent_caps = vec![cap("/repo/src/**", &["read"])];
171 let requested_child_caps = vec![cap("/repo/**", &["read"])];
172
173 let verdict = pm.check_delegation(&requested_child_caps, &parent_caps);
174 assert!(matches!(verdict, Some(GovernanceVerdict::Deny { .. })));
175 }
176
177 #[test]
178 fn check_delegation_allows_a_legal_narrowing() {
179 let pm = PermissionManager::new(PermissionAction::Allow);
180 let parent_caps = vec![cap("/repo/src/**", &["read"])];
181 let requested_child_caps = vec![cap("/repo/src/utils/**", &["read"])];
182
183 assert!(
184 pm.check_delegation(&requested_child_caps, &parent_caps)
185 .is_none()
186 );
187 }
188
189 #[test]
190 fn check_delegation_does_not_affect_plain_tool_name_glob_checks() {
191 let mut pm = PermissionManager::new(PermissionAction::Allow);
194 pm.add_rule(PermissionRule {
195 tool_pattern: "read_*".into(),
196 action: PermissionAction::Allow,
197 });
198 pm.add_rule(PermissionRule {
199 tool_pattern: "*".into(),
200 action: PermissionAction::Deny,
201 });
202 assert!(pm.check(&test_call("read_file")).is_none());
203 assert!(pm.check(&test_call("write_file")).is_some());
204 }
205}