aa_security/policy/
capability.rs1use std::collections::BTreeSet;
16use std::fmt;
17use std::str::FromStr;
18
19#[derive(Debug, Clone, PartialEq, Eq, Hash, PartialOrd, Ord)]
25#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
26pub enum Capability {
27 FileRead,
29 FileWrite,
31 FileDelete,
37 NetworkOutbound,
39 NetworkInbound,
41 TerminalExec,
43 McpTool(String),
45 Model(String),
47 AgentSpawn,
49}
50
51#[derive(Debug, Clone, PartialEq, Eq, Default)]
53#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
54pub struct CapabilitySet {
55 pub allow: BTreeSet<Capability>,
57 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}