Skip to main content

aa_core/
capability.rs

1//! Per-level capability types for fine-grained agent permission control.
2//!
3//! A [`Capability`] represents a discrete action category that policy can allow
4//! or deny. A [`CapabilitySet`] aggregates allow and deny sets for a given scope.
5
6use alloc::collections::BTreeSet;
7use alloc::string::{String, ToString};
8use core::str::FromStr;
9
10/// A discrete action category that policy can allow or deny for an agent.
11#[derive(Debug, Clone, PartialEq, Eq, Hash, PartialOrd, Ord)]
12#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
13pub enum Capability {
14    /// Read access to the filesystem.
15    FileRead,
16    /// Write access to the filesystem.
17    FileWrite,
18    /// Outbound network connections.
19    NetworkOutbound,
20    /// Inbound network connections.
21    NetworkInbound,
22    /// Execute commands in a terminal/shell.
23    TerminalExec,
24    /// Use a named MCP tool.
25    McpTool(String),
26    /// Use a named AI model.
27    Model(String),
28    /// Spawn child agents.
29    AgentSpawn,
30}
31
32/// Aggregates allow and deny capability sets for a given policy scope.
33#[derive(Debug, Clone, PartialEq, Default)]
34#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
35pub struct CapabilitySet {
36    /// Capabilities explicitly allowed.
37    pub allow: BTreeSet<Capability>,
38    /// Capabilities explicitly denied.
39    pub deny: BTreeSet<Capability>,
40}
41
42impl FromStr for Capability {
43    type Err = String;
44
45    fn from_str(s: &str) -> Result<Self, Self::Err> {
46        match s {
47            "file_read" => Ok(Capability::FileRead),
48            "file_write" => Ok(Capability::FileWrite),
49            "network_outbound" => Ok(Capability::NetworkOutbound),
50            "network_inbound" => Ok(Capability::NetworkInbound),
51            "terminal_exec" => Ok(Capability::TerminalExec),
52            "agent_spawn" => Ok(Capability::AgentSpawn),
53            _ => {
54                if let Some(name) = s.strip_prefix("mcp_tool:") {
55                    if name.is_empty() {
56                        return Err("mcp_tool: name must not be empty".to_string());
57                    }
58                    Ok(Capability::McpTool(name.to_string()))
59                } else if let Some(name) = s.strip_prefix("model:") {
60                    if name.is_empty() {
61                        return Err("model: name must not be empty".to_string());
62                    }
63                    Ok(Capability::Model(name.to_string()))
64                } else {
65                    Err(alloc::format!("unknown capability: '{s}'"))
66                }
67            }
68        }
69    }
70}
71
72impl core::fmt::Display for Capability {
73    fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
74        match self {
75            Capability::FileRead => f.write_str("file_read"),
76            Capability::FileWrite => f.write_str("file_write"),
77            Capability::NetworkOutbound => f.write_str("network_outbound"),
78            Capability::NetworkInbound => f.write_str("network_inbound"),
79            Capability::TerminalExec => f.write_str("terminal_exec"),
80            Capability::AgentSpawn => f.write_str("agent_spawn"),
81            Capability::McpTool(name) => write!(f, "mcp_tool:{name}"),
82            Capability::Model(name) => write!(f, "model:{name}"),
83        }
84    }
85}
86
87/// Merge a parent [`CapabilitySet`] with a child [`CapabilitySet`] using
88/// parent-deny-wins semantics.
89///
90/// Rules:
91/// - `deny` = union of both deny sets; parent deny always wins over child allow.
92/// - `allow`:
93///   - Both empty → empty (no allow-list restriction).
94///   - Parent empty, child non-empty → `child.allow` minus merged deny.
95///   - Parent non-empty, child empty → `parent.allow` minus merged deny.
96///   - Both non-empty → intersection of `parent.allow` and `child.allow`, minus merged deny.
97///
98/// Requires the `alloc` feature.
99#[cfg(feature = "alloc")]
100pub fn merge_capabilities(parent: &CapabilitySet, child: &CapabilitySet) -> CapabilitySet {
101    // deny = union of both deny sets
102    let deny: BTreeSet<Capability> = parent.deny.union(&child.deny).cloned().collect();
103
104    let allow: BTreeSet<Capability> = match (parent.allow.is_empty(), child.allow.is_empty()) {
105        // Both empty → no allow-list restriction
106        (true, true) => BTreeSet::new(),
107        // Parent empty, child non-empty → use child.allow
108        (true, false) => child.allow.difference(&deny).cloned().collect(),
109        // Parent non-empty, child empty → use parent.allow
110        (false, true) => parent.allow.difference(&deny).cloned().collect(),
111        // Both non-empty → intersection, then subtract deny
112        (false, false) => parent
113            .allow
114            .intersection(&child.allow)
115            .filter(|c| !deny.contains(c))
116            .cloned()
117            .collect(),
118    };
119
120    CapabilitySet { allow, deny }
121}
122
123/// Map a [`crate::GovernanceAction`] to the [`Capability`] it exercises,
124/// or `None` if the action does not map to a known capability.
125///
126/// Requires the `alloc` feature.
127#[cfg(feature = "alloc")]
128pub fn action_to_capability(action: &crate::GovernanceAction) -> Option<Capability> {
129    use crate::policy::FileMode;
130    use crate::GovernanceAction;
131
132    // NOTE: Capability::AgentSpawn, NetworkInbound, and Model variants have no
133    // corresponding GovernanceAction yet. When new action variants land, add
134    // mappings here to avoid silent policy bypasses.
135    match action {
136        GovernanceAction::ToolCall { name, .. } => Some(Capability::McpTool(name.clone())),
137        GovernanceAction::ToolResult { tool_name, .. } => Some(Capability::McpTool(tool_name.clone())),
138        GovernanceAction::FileAccess {
139            mode: FileMode::Read, ..
140        } => Some(Capability::FileRead),
141        GovernanceAction::FileAccess {
142            mode: FileMode::Write | FileMode::Append | FileMode::Delete,
143            ..
144        } => Some(Capability::FileWrite),
145        GovernanceAction::NetworkRequest { .. } => Some(Capability::NetworkOutbound),
146        GovernanceAction::ProcessExec { .. } => Some(Capability::TerminalExec),
147        GovernanceAction::SendMessage { .. } => None,
148    }
149}
150
151/// Per-scope contribution to an effective permission set.
152///
153/// Carries the `allow` and `deny` capabilities a single policy document declares
154/// at one scope along the cascade chain. The `scope` field is a wire-format
155/// label such as `"global"`, `"org:acme"`, `"team:platform"`, or
156/// `"agent:<uuid>"`; the gateway populates it from the policy's own
157/// `PolicyScope` so the renderer can show provenance without depending on the
158/// gateway's enum.
159#[cfg(feature = "alloc")]
160#[derive(Debug, Clone, PartialEq, Default)]
161#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
162pub struct PermissionSource {
163    /// Wire-format scope label (e.g. `"global"`, `"team:platform"`).
164    pub scope: String,
165    /// Capabilities this scope explicitly allows.
166    pub allow: BTreeSet<Capability>,
167    /// Capabilities this scope explicitly denies.
168    pub deny: BTreeSet<Capability>,
169}
170
171/// Effective capability set for a single agent, with cascade provenance.
172///
173/// `merged` is the result of folding `merge_capabilities` left-to-right over
174/// every policy document that applies to the agent (Global → Org → Team →
175/// Agent → Tool). `sources` records each contributing scope's individual
176/// `allow`/`deny`, in cascade order, so consumers (CLI, dashboard) can show
177/// *where* each capability decision originates.
178///
179/// `sources` may be empty if no policy in the cascade declares a
180/// `capabilities` block, in which case `merged` is also empty (no allow-list
181/// restriction).
182#[cfg(feature = "alloc")]
183#[derive(Debug, Clone, PartialEq, Default)]
184#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
185pub struct EffectivePermissions {
186    /// Merged result after most-restrictive-wins cascade.
187    pub merged: CapabilitySet,
188    /// Per-scope contribution, in cascade order (broadest → narrowest).
189    pub sources: alloc::vec::Vec<PermissionSource>,
190}
191
192#[cfg(test)]
193mod tests {
194    use super::*;
195    use alloc::collections::BTreeSet;
196
197    #[test]
198    fn capability_variants_are_distinct() {
199        assert_ne!(Capability::FileRead, Capability::FileWrite);
200        assert_ne!(
201            Capability::McpTool("a".to_string()),
202            Capability::McpTool("b".to_string())
203        );
204    }
205
206    #[test]
207    fn mcp_tool_same_name_eq() {
208        assert_eq!(
209            Capability::McpTool("bash".to_string()),
210            Capability::McpTool("bash".to_string())
211        );
212    }
213
214    #[test]
215    fn capability_hashable_in_set() {
216        let mut set: BTreeSet<Capability> = BTreeSet::new();
217        set.insert(Capability::FileRead);
218        set.insert(Capability::FileWrite);
219        set.insert(Capability::McpTool("bash".to_string()));
220        assert_eq!(set.len(), 3);
221    }
222
223    #[test]
224    fn capability_set_default_is_empty() {
225        let cs = CapabilitySet::default();
226        assert!(cs.allow.is_empty());
227        assert!(cs.deny.is_empty());
228    }
229
230    #[test]
231    fn capability_from_str_file_read() {
232        assert_eq!("file_read".parse::<Capability>().unwrap(), Capability::FileRead);
233    }
234
235    #[test]
236    fn capability_from_str_file_write() {
237        assert_eq!("file_write".parse::<Capability>().unwrap(), Capability::FileWrite);
238    }
239
240    #[test]
241    fn capability_from_str_network_outbound() {
242        assert_eq!(
243            "network_outbound".parse::<Capability>().unwrap(),
244            Capability::NetworkOutbound
245        );
246    }
247
248    #[test]
249    fn capability_from_str_network_inbound() {
250        assert_eq!(
251            "network_inbound".parse::<Capability>().unwrap(),
252            Capability::NetworkInbound
253        );
254    }
255
256    #[test]
257    fn capability_from_str_terminal_exec() {
258        assert_eq!("terminal_exec".parse::<Capability>().unwrap(), Capability::TerminalExec);
259    }
260
261    #[test]
262    fn capability_from_str_mcp_tool() {
263        assert_eq!(
264            "mcp_tool:bash".parse::<Capability>().unwrap(),
265            Capability::McpTool("bash".to_string())
266        );
267    }
268
269    #[test]
270    fn capability_from_str_model() {
271        assert_eq!(
272            "model:gpt-4o".parse::<Capability>().unwrap(),
273            Capability::Model("gpt-4o".to_string())
274        );
275    }
276
277    #[test]
278    fn capability_from_str_agent_spawn() {
279        assert_eq!("agent_spawn".parse::<Capability>().unwrap(), Capability::AgentSpawn);
280    }
281
282    #[test]
283    fn capability_from_str_unknown_returns_err() {
284        assert!("unknown_cap".parse::<Capability>().is_err());
285    }
286
287    #[test]
288    fn capability_from_str_mcp_tool_empty_name_returns_err() {
289        assert!("mcp_tool:".parse::<Capability>().is_err());
290    }
291
292    #[test]
293    fn capability_from_str_model_empty_name_returns_err() {
294        assert!("model:".parse::<Capability>().is_err());
295    }
296
297    #[test]
298    fn capability_display_round_trips_simple_variant() {
299        let cap = Capability::FileRead;
300        assert_eq!(cap.to_string().parse::<Capability>().unwrap(), cap);
301    }
302
303    #[test]
304    fn capability_display_round_trips_mcp_tool() {
305        let cap = Capability::McpTool("bash".to_string());
306        assert_eq!(cap.to_string().parse::<Capability>().unwrap(), cap);
307    }
308
309    // ------------------------------------------------------------------
310    // merge_capabilities tests
311    // ------------------------------------------------------------------
312
313    fn cap_set(allow: &[Capability], deny: &[Capability]) -> CapabilitySet {
314        CapabilitySet {
315            allow: allow.iter().cloned().collect(),
316            deny: deny.iter().cloned().collect(),
317        }
318    }
319
320    #[test]
321    fn merge_empty_parent_with_child_deny() {
322        let parent = CapabilitySet::default();
323        let child = cap_set(&[], &[Capability::FileWrite]);
324        let result = super::merge_capabilities(&parent, &child);
325        assert!(result.deny.contains(&Capability::FileWrite));
326        assert!(result.allow.is_empty());
327    }
328
329    #[test]
330    fn merge_parent_deny_wins_over_child_allow() {
331        let parent = cap_set(&[], &[Capability::NetworkOutbound]);
332        let child = cap_set(&[Capability::FileRead, Capability::NetworkOutbound], &[]);
333        let result = super::merge_capabilities(&parent, &child);
334        assert!(result.allow.contains(&Capability::FileRead));
335        assert!(!result.allow.contains(&Capability::NetworkOutbound));
336    }
337
338    #[test]
339    fn merge_deny_is_union() {
340        let parent = cap_set(&[], &[Capability::FileWrite]);
341        let child = cap_set(&[], &[Capability::TerminalExec]);
342        let result = super::merge_capabilities(&parent, &child);
343        assert!(result.deny.contains(&Capability::FileWrite));
344        assert!(result.deny.contains(&Capability::TerminalExec));
345    }
346
347    #[test]
348    fn merge_both_allow_nonempty_takes_intersection() {
349        let parent = cap_set(&[Capability::FileRead, Capability::FileWrite], &[]);
350        let child = cap_set(&[Capability::FileRead, Capability::NetworkOutbound], &[]);
351        let result = super::merge_capabilities(&parent, &child);
352        assert_eq!(
353            result.allow,
354            [Capability::FileRead].iter().cloned().collect::<BTreeSet<_>>()
355        );
356    }
357
358    #[test]
359    fn merge_parent_allow_nonempty_child_allow_empty_uses_parent() {
360        let parent = cap_set(&[Capability::FileRead], &[]);
361        let child = cap_set(&[], &[]);
362        let result = super::merge_capabilities(&parent, &child);
363        assert_eq!(
364            result.allow,
365            [Capability::FileRead].iter().cloned().collect::<BTreeSet<_>>()
366        );
367    }
368
369    #[test]
370    fn merge_parent_allow_empty_child_allow_nonempty_uses_child() {
371        let parent = cap_set(&[], &[]);
372        let child = cap_set(&[Capability::FileRead], &[]);
373        let result = super::merge_capabilities(&parent, &child);
374        assert_eq!(
375            result.allow,
376            [Capability::FileRead].iter().cloned().collect::<BTreeSet<_>>()
377        );
378    }
379
380    #[test]
381    fn merge_parent_deny_overrides_intersection_allow() {
382        let parent = cap_set(&[Capability::FileRead, Capability::FileWrite], &[Capability::FileRead]);
383        let child = cap_set(&[Capability::FileRead], &[]);
384        let result = super::merge_capabilities(&parent, &child);
385        assert!(
386            result.allow.is_empty(),
387            "FileRead was denied by parent, should be absent from allow"
388        );
389        assert!(result.deny.contains(&Capability::FileRead));
390    }
391
392    #[test]
393    fn merge_both_empty_returns_empty() {
394        let parent = CapabilitySet::default();
395        let child = CapabilitySet::default();
396        let result = super::merge_capabilities(&parent, &child);
397        assert_eq!(result, CapabilitySet::default());
398    }
399
400    // ------------------------------------------------------------------
401    // action_to_capability tests
402    // ------------------------------------------------------------------
403
404    #[test]
405    fn action_to_capability_tool_call() {
406        let action = crate::GovernanceAction::ToolCall {
407            name: "bash".to_string(),
408            args: "{}".to_string(),
409        };
410        assert_eq!(
411            super::action_to_capability(&action),
412            Some(Capability::McpTool("bash".to_string()))
413        );
414    }
415
416    #[test]
417    fn action_to_capability_file_read() {
418        let action = crate::GovernanceAction::FileAccess {
419            path: "/tmp/f".to_string(),
420            mode: crate::policy::FileMode::Read,
421        };
422        assert_eq!(super::action_to_capability(&action), Some(Capability::FileRead));
423    }
424
425    #[test]
426    fn action_to_capability_file_write() {
427        let action = crate::GovernanceAction::FileAccess {
428            path: "/tmp/f".to_string(),
429            mode: crate::policy::FileMode::Write,
430        };
431        assert_eq!(super::action_to_capability(&action), Some(Capability::FileWrite));
432    }
433
434    #[test]
435    fn action_to_capability_file_append_is_file_write() {
436        let action = crate::GovernanceAction::FileAccess {
437            path: "/tmp/f".to_string(),
438            mode: crate::policy::FileMode::Append,
439        };
440        assert_eq!(super::action_to_capability(&action), Some(Capability::FileWrite));
441    }
442
443    #[test]
444    fn action_to_capability_file_delete_is_file_write() {
445        let action = crate::GovernanceAction::FileAccess {
446            path: "/tmp/f".to_string(),
447            mode: crate::policy::FileMode::Delete,
448        };
449        assert_eq!(super::action_to_capability(&action), Some(Capability::FileWrite));
450    }
451
452    #[test]
453    fn action_to_capability_network_request() {
454        let action = crate::GovernanceAction::NetworkRequest {
455            url: "https://example.com".to_string(),
456            method: "GET".to_string(),
457        };
458        assert_eq!(super::action_to_capability(&action), Some(Capability::NetworkOutbound));
459    }
460
461    #[test]
462    fn action_to_capability_process_exec() {
463        let action = crate::GovernanceAction::ProcessExec {
464            command: "ls".to_string(),
465        };
466        assert_eq!(super::action_to_capability(&action), Some(Capability::TerminalExec));
467    }
468}