Skip to main content

aa_security/policy/
capability.rs

1//! Capability vocabulary for the canonical policy AST.
2//!
3//! This is the leaf-crate-owned capability model. It deliberately mirrors the
4//! `file_read` / `file_write` / `network_outbound` / `terminal_exec` / … wire
5//! vocabulary used by `policy-examples/*.yaml` so the canonical
6//! [`PolicyDocument`](super::PolicyDocument) parses the same on-disk contract
7//! the gateway already honours.
8//!
9//! It lives in `aa-security` (a leaf crate with no `aa-core` dependency)
10//! because `aa-core` itself depends on `aa-security`; defining the shared
11//! policy AST here is what lets BOTH the gateway rule engine and the
12//! (privilege-separated) eBPF loader depend on the exact same types without a
13//! dependency cycle. See AAASM-3606.
14
15use std::collections::BTreeSet;
16use std::fmt;
17use std::str::FromStr;
18
19/// A discrete action category a policy can allow or deny for an agent.
20///
21/// The string forms match the `capabilities.allow` / `capabilities.deny`
22/// entries in the policy YAML contract (`file_read`, `network_outbound`,
23/// `mcp_tool:<name>`, `model:<name>`, …).
24#[derive(Debug, Clone, PartialEq, Eq, Hash, PartialOrd, Ord)]
25#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
26pub enum Capability {
27    /// Read access to the filesystem.
28    FileRead,
29    /// Write access to the filesystem.
30    FileWrite,
31    /// Outbound network connections.
32    NetworkOutbound,
33    /// Inbound network connections.
34    NetworkInbound,
35    /// Execute commands in a terminal/shell.
36    TerminalExec,
37    /// Use a named MCP tool.
38    McpTool(String),
39    /// Use a named AI model.
40    Model(String),
41    /// Spawn child agents.
42    AgentSpawn,
43}
44
45/// Aggregates allow and deny capability sets for a given policy scope.
46#[derive(Debug, Clone, PartialEq, Eq, Default)]
47#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
48pub struct CapabilitySet {
49    /// Capabilities explicitly allowed.
50    pub allow: BTreeSet<Capability>,
51    /// Capabilities explicitly denied.
52    pub deny: BTreeSet<Capability>,
53}
54
55impl FromStr for Capability {
56    type Err = String;
57
58    fn from_str(s: &str) -> Result<Self, Self::Err> {
59        match s {
60            "file_read" => Ok(Capability::FileRead),
61            "file_write" => Ok(Capability::FileWrite),
62            "network_outbound" => Ok(Capability::NetworkOutbound),
63            "network_inbound" => Ok(Capability::NetworkInbound),
64            "terminal_exec" => Ok(Capability::TerminalExec),
65            "agent_spawn" => Ok(Capability::AgentSpawn),
66            _ => {
67                if let Some(name) = s.strip_prefix("mcp_tool:") {
68                    if name.is_empty() {
69                        return Err("mcp_tool: name must not be empty".to_string());
70                    }
71                    Ok(Capability::McpTool(name.to_string()))
72                } else if let Some(name) = s.strip_prefix("model:") {
73                    if name.is_empty() {
74                        return Err("model: name must not be empty".to_string());
75                    }
76                    Ok(Capability::Model(name.to_string()))
77                } else {
78                    Err(format!("unknown capability: '{s}'"))
79                }
80            }
81        }
82    }
83}
84
85impl fmt::Display for Capability {
86    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
87        match self {
88            Capability::FileRead => f.write_str("file_read"),
89            Capability::FileWrite => f.write_str("file_write"),
90            Capability::NetworkOutbound => f.write_str("network_outbound"),
91            Capability::NetworkInbound => f.write_str("network_inbound"),
92            Capability::TerminalExec => f.write_str("terminal_exec"),
93            Capability::AgentSpawn => f.write_str("agent_spawn"),
94            Capability::McpTool(name) => write!(f, "mcp_tool:{name}"),
95            Capability::Model(name) => write!(f, "model:{name}"),
96        }
97    }
98}
99
100#[cfg(test)]
101mod tests {
102    use super::*;
103
104    #[test]
105    fn parses_each_simple_variant() {
106        assert_eq!("file_read".parse::<Capability>().unwrap(), Capability::FileRead);
107        assert_eq!("file_write".parse::<Capability>().unwrap(), Capability::FileWrite);
108        assert_eq!(
109            "network_outbound".parse::<Capability>().unwrap(),
110            Capability::NetworkOutbound
111        );
112        assert_eq!("terminal_exec".parse::<Capability>().unwrap(), Capability::TerminalExec);
113    }
114
115    #[test]
116    fn parses_parameterised_variants() {
117        assert_eq!(
118            "mcp_tool:git".parse::<Capability>().unwrap(),
119            Capability::McpTool("git".to_string())
120        );
121        assert_eq!(
122            "model:gpt-4".parse::<Capability>().unwrap(),
123            Capability::Model("gpt-4".to_string())
124        );
125    }
126
127    #[test]
128    fn rejects_empty_parameterised_name() {
129        assert!("mcp_tool:".parse::<Capability>().is_err());
130        assert!("model:".parse::<Capability>().is_err());
131    }
132
133    #[test]
134    fn rejects_unknown_capability() {
135        assert!("teleport".parse::<Capability>().is_err());
136    }
137
138    #[test]
139    fn display_round_trips() {
140        for cap in [
141            Capability::FileRead,
142            Capability::FileWrite,
143            Capability::NetworkOutbound,
144            Capability::NetworkInbound,
145            Capability::TerminalExec,
146            Capability::AgentSpawn,
147            Capability::McpTool("bash".to_string()),
148            Capability::Model("claude".to_string()),
149        ] {
150            let rendered = cap.to_string();
151            assert_eq!(rendered.parse::<Capability>().unwrap(), cap);
152        }
153    }
154}