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    /// Delete/unlink access to the filesystem.
32    ///
33    /// A distinct verb from [`Capability::FileWrite`] so a policy can allow
34    /// writes while denying deletes. Mirrors `aa_core::Capability::FileDelete`
35    /// so the gateway→canonical projection stays lossless. See AAASM-4103.
36    FileDelete,
37    /// Outbound network connections.
38    NetworkOutbound,
39    /// Inbound network connections.
40    NetworkInbound,
41    /// Execute commands in a terminal/shell.
42    TerminalExec,
43    /// Use a named MCP tool.
44    McpTool(String),
45    /// Use a named AI model.
46    Model(String),
47    /// Spawn child agents.
48    AgentSpawn,
49}
50
51/// Aggregates allow and deny capability sets for a given policy scope.
52#[derive(Debug, Clone, PartialEq, Eq, Default)]
53#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
54pub struct CapabilitySet {
55    /// Capabilities explicitly allowed.
56    pub allow: BTreeSet<Capability>,
57    /// Capabilities explicitly denied.
58    pub deny: BTreeSet<Capability>,
59}
60
61impl FromStr for Capability {
62    type Err = String;
63
64    fn from_str(s: &str) -> Result<Self, Self::Err> {
65        match s {
66            "file_read" => Ok(Capability::FileRead),
67            "file_write" => Ok(Capability::FileWrite),
68            "file_delete" => Ok(Capability::FileDelete),
69            "network_outbound" => Ok(Capability::NetworkOutbound),
70            "network_inbound" => Ok(Capability::NetworkInbound),
71            "terminal_exec" => Ok(Capability::TerminalExec),
72            "agent_spawn" => Ok(Capability::AgentSpawn),
73            _ => {
74                if let Some(name) = s.strip_prefix("mcp_tool:") {
75                    if name.is_empty() {
76                        return Err("mcp_tool: name must not be empty".to_string());
77                    }
78                    Ok(Capability::McpTool(name.to_string()))
79                } else if let Some(name) = s.strip_prefix("model:") {
80                    if name.is_empty() {
81                        return Err("model: name must not be empty".to_string());
82                    }
83                    Ok(Capability::Model(name.to_string()))
84                } else {
85                    Err(format!("unknown capability: '{s}'"))
86                }
87            }
88        }
89    }
90}
91
92impl fmt::Display for Capability {
93    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
94        match self {
95            Capability::FileRead => f.write_str("file_read"),
96            Capability::FileWrite => f.write_str("file_write"),
97            Capability::FileDelete => f.write_str("file_delete"),
98            Capability::NetworkOutbound => f.write_str("network_outbound"),
99            Capability::NetworkInbound => f.write_str("network_inbound"),
100            Capability::TerminalExec => f.write_str("terminal_exec"),
101            Capability::AgentSpawn => f.write_str("agent_spawn"),
102            Capability::McpTool(name) => write!(f, "mcp_tool:{name}"),
103            Capability::Model(name) => write!(f, "model:{name}"),
104        }
105    }
106}
107
108#[cfg(test)]
109mod tests {
110    use super::*;
111
112    #[test]
113    fn parses_each_simple_variant() {
114        assert_eq!("file_read".parse::<Capability>().unwrap(), Capability::FileRead);
115        assert_eq!("file_write".parse::<Capability>().unwrap(), Capability::FileWrite);
116        assert_eq!(
117            "network_outbound".parse::<Capability>().unwrap(),
118            Capability::NetworkOutbound
119        );
120        assert_eq!("terminal_exec".parse::<Capability>().unwrap(), Capability::TerminalExec);
121    }
122
123    #[test]
124    fn parses_parameterised_variants() {
125        assert_eq!(
126            "mcp_tool:git".parse::<Capability>().unwrap(),
127            Capability::McpTool("git".to_string())
128        );
129        assert_eq!(
130            "model:gpt-4".parse::<Capability>().unwrap(),
131            Capability::Model("gpt-4".to_string())
132        );
133    }
134
135    #[test]
136    fn rejects_empty_parameterised_name() {
137        assert!("mcp_tool:".parse::<Capability>().is_err());
138        assert!("model:".parse::<Capability>().is_err());
139    }
140
141    #[test]
142    fn rejects_unknown_capability() {
143        assert!("teleport".parse::<Capability>().is_err());
144    }
145
146    #[test]
147    fn display_round_trips() {
148        for cap in [
149            Capability::FileRead,
150            Capability::FileWrite,
151            Capability::NetworkOutbound,
152            Capability::NetworkInbound,
153            Capability::TerminalExec,
154            Capability::AgentSpawn,
155            Capability::McpTool("bash".to_string()),
156            Capability::Model("claude".to_string()),
157        ] {
158            let rendered = cap.to_string();
159            assert_eq!(rendered.parse::<Capability>().unwrap(), cap);
160        }
161    }
162}