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    /// spc_004-04: the attenuation-invariant check for spawn-time capability delegation — a
60    /// second, additive layer alongside [`Self::check`]'s tool-name glob, not a replacement for
61    /// it. `None` ⇒ every requested child capability legally narrows some parent capability
62    /// (`caps_subset` succeeded). `Some(Deny)` ⇒ at least one did not; the reason names the
63    /// offending capability ids so the rejection is diagnosable, not just "no".
64    ///
65    /// Not yet called from the live spawn path: `IsolationManifest`/`Tcb` carry capability grants
66    /// as `Vec<CompactString>` ids today, not the richer `Capability` (resource/actions/
67    /// constraints) shape this needs. Wiring that through is a larger structural change than this
68    /// card — this method is the reusable governance primitive a future card calls once that data
69    /// exists, using the same `Disposition`/`GovernanceVerdict` semantics `gate.rs` already
70    /// understands.
71    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        // spc_004-04's own scope fence: `check_delegation` is an additive second layer,
192        // `check` (tool-name glob) must behave exactly as before.
193        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}