1use chrono::{DateTime, Utc};
2use serde::{Deserialize, Serialize};
3use uuid::Uuid;
4
5#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
10#[serde(rename_all = "snake_case", tag = "type", content = "scope")]
11pub enum Capability {
12 FileRead(String),
14 FileWrite(String),
16 ShellExec(String),
18 Network(String),
20 Memory,
22 KnowledgeGraph,
24 BrowserControl,
26 AgentSpawn,
28 AgentMessage,
30 Schedule,
32 EventPublish,
34 SourceControl,
36 Container,
38 DataManipulation,
40 CodeAnalysis,
42 Archive,
44 Template,
46 Crypto,
48 PluginInvoke,
50 A2ADelegate,
52 McpAccess(String),
54}
55
56impl std::fmt::Display for Capability {
57 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
58 match self {
59 Self::FileRead(g) => write!(f, "file_read({})", g),
60 Self::FileWrite(g) => write!(f, "file_write({})", g),
61 Self::ShellExec(p) => write!(f, "shell_exec({})", p),
62 Self::Network(h) => write!(f, "network({})", h),
63 Self::Memory => write!(f, "memory"),
64 Self::KnowledgeGraph => write!(f, "knowledge_graph"),
65 Self::BrowserControl => write!(f, "browser_control"),
66 Self::AgentSpawn => write!(f, "agent_spawn"),
67 Self::AgentMessage => write!(f, "agent_message"),
68 Self::Schedule => write!(f, "schedule"),
69 Self::EventPublish => write!(f, "event_publish"),
70 Self::SourceControl => write!(f, "source_control"),
71 Self::Container => write!(f, "container"),
72 Self::DataManipulation => write!(f, "data_manipulation"),
73 Self::CodeAnalysis => write!(f, "code_analysis"),
74 Self::Archive => write!(f, "archive"),
75 Self::Template => write!(f, "template"),
76 Self::Crypto => write!(f, "crypto"),
77 Self::PluginInvoke => write!(f, "plugin_invoke"),
78 Self::A2ADelegate => write!(f, "a2a_delegate"),
79 Self::McpAccess(p) => write!(f, "mcp_access({})", p),
80 }
81 }
82}
83
84#[derive(Debug, Clone, Serialize, Deserialize)]
86pub struct CapabilityGrant {
87 pub id: Uuid,
89 pub capability: Capability,
91 pub granted_by: String,
93 pub granted_at: DateTime<Utc>,
95 pub expires_at: Option<DateTime<Utc>>,
97}
98
99pub fn capability_matches(granted: &Capability, required: &Capability) -> bool {
104 match (granted, required) {
105 (Capability::FileRead(granted_glob), Capability::FileRead(required_path)) => {
106 glob_matches(granted_glob, required_path)
107 }
108 (Capability::FileWrite(granted_glob), Capability::FileWrite(required_path)) => {
109 glob_matches(granted_glob, required_path)
110 }
111 (Capability::ShellExec(granted_pat), Capability::ShellExec(required_cmd)) => {
112 pattern_matches(granted_pat, required_cmd)
113 }
114 (Capability::Network(granted_host), Capability::Network(required_host)) => {
115 host_matches(granted_host, required_host)
116 }
117 (Capability::Memory, Capability::Memory) => true,
118 (Capability::KnowledgeGraph, Capability::KnowledgeGraph) => true,
119 (Capability::BrowserControl, Capability::BrowserControl) => true,
120 (Capability::AgentSpawn, Capability::AgentSpawn) => true,
121 (Capability::AgentMessage, Capability::AgentMessage) => true,
122 (Capability::Schedule, Capability::Schedule) => true,
123 (Capability::EventPublish, Capability::EventPublish) => true,
124 (Capability::SourceControl, Capability::SourceControl) => true,
125 (Capability::Container, Capability::Container) => true,
126 (Capability::DataManipulation, Capability::DataManipulation) => true,
127 (Capability::CodeAnalysis, Capability::CodeAnalysis) => true,
128 (Capability::Archive, Capability::Archive) => true,
129 (Capability::Template, Capability::Template) => true,
130 (Capability::Crypto, Capability::Crypto) => true,
131 (Capability::PluginInvoke, Capability::PluginInvoke) => true,
132 (Capability::A2ADelegate, Capability::A2ADelegate) => true,
133 (Capability::McpAccess(granted_pat), Capability::McpAccess(required_name)) => {
134 pattern_matches(granted_pat, required_name)
135 }
136 _ => false,
137 }
138}
139
140fn glob_matches(pattern: &str, path: &str) -> bool {
142 if pattern == "**" || pattern == "**/*" {
143 return true;
144 }
145 glob::Pattern::new(pattern)
146 .map(|p| p.matches(path))
147 .unwrap_or(false)
148}
149
150fn pattern_matches(pattern: &str, command: &str) -> bool {
152 if pattern == "*" {
153 return true;
154 }
155 glob::Pattern::new(pattern)
156 .map(|p| p.matches(command))
157 .unwrap_or(false)
158}
159
160fn host_matches(pattern: &str, host: &str) -> bool {
162 if pattern == "*" {
163 return true;
164 }
165 if let Some(suffix) = pattern.strip_prefix("*.") {
166 return host == suffix || host.ends_with(&format!(".{}", suffix));
167 }
168 pattern == host
169}
170
171#[cfg(test)]
172mod tests {
173 use super::*;
174
175 #[test]
176 fn test_glob_file_read() {
177 let granted = Capability::FileRead("src/**/*.rs".to_string());
178 let required = Capability::FileRead("src/main.rs".to_string());
179 assert!(capability_matches(&granted, &required));
180 }
181
182 #[test]
183 fn test_wildcard_grants_all() {
184 let granted = Capability::FileRead("**".to_string());
185 let required = Capability::FileRead("anything/at/all.txt".to_string());
186 assert!(capability_matches(&granted, &required));
187 }
188
189 #[test]
190 fn test_glob_no_match() {
191 let granted = Capability::FileRead("src/**/*.rs".to_string());
192 let required = Capability::FileRead("tests/data.json".to_string());
193 assert!(!capability_matches(&granted, &required));
194 }
195
196 #[test]
197 fn test_variant_mismatch() {
198 let granted = Capability::FileRead("**".to_string());
199 let required = Capability::FileWrite("foo.txt".to_string());
200 assert!(!capability_matches(&granted, &required));
201 }
202
203 #[test]
204 fn test_scopeless_capabilities() {
205 assert!(capability_matches(&Capability::Memory, &Capability::Memory));
206 assert!(!capability_matches(
207 &Capability::Memory,
208 &Capability::Schedule
209 ));
210 }
211
212 #[test]
213 fn test_wildcard_host() {
214 let granted = Capability::Network("*.example.com".to_string());
215 let required = Capability::Network("api.example.com".to_string());
216 assert!(capability_matches(&granted, &required));
217 }
218
219 #[test]
220 fn test_exact_host() {
221 let granted = Capability::Network("api.example.com".to_string());
222 let required = Capability::Network("api.example.com".to_string());
223 assert!(capability_matches(&granted, &required));
224
225 let other = Capability::Network("other.example.com".to_string());
226 assert!(!capability_matches(&granted, &other));
227 }
228
229 #[test]
230 fn test_capability_display_all_scoped() {
231 assert_eq!(
232 Capability::FileRead("src/*.rs".to_string()).to_string(),
233 "file_read(src/*.rs)"
234 );
235 assert_eq!(
236 Capability::FileWrite("out/**".to_string()).to_string(),
237 "file_write(out/**)"
238 );
239 assert_eq!(
240 Capability::ShellExec("ls*".to_string()).to_string(),
241 "shell_exec(ls*)"
242 );
243 assert_eq!(
244 Capability::Network("*.example.com".to_string()).to_string(),
245 "network(*.example.com)"
246 );
247 }
248
249 #[test]
250 fn test_mcp_access_wildcard() {
251 let granted = Capability::McpAccess("*".to_string());
252 let required = Capability::McpAccess("github".to_string());
253 assert!(capability_matches(&granted, &required));
254 }
255
256 #[test]
257 fn test_mcp_access_exact() {
258 let granted = Capability::McpAccess("github".to_string());
259 assert!(capability_matches(
260 &granted,
261 &Capability::McpAccess("github".to_string())
262 ));
263 assert!(!capability_matches(
264 &granted,
265 &Capability::McpAccess("slack".to_string())
266 ));
267 }
268
269 #[test]
270 fn test_mcp_access_display() {
271 assert_eq!(
272 Capability::McpAccess("github".to_string()).to_string(),
273 "mcp_access(github)"
274 );
275 }
276
277 #[test]
278 fn test_capability_display_all_scopeless() {
279 assert_eq!(Capability::Memory.to_string(), "memory");
280 assert_eq!(Capability::KnowledgeGraph.to_string(), "knowledge_graph");
281 assert_eq!(Capability::BrowserControl.to_string(), "browser_control");
282 assert_eq!(Capability::AgentSpawn.to_string(), "agent_spawn");
283 assert_eq!(Capability::AgentMessage.to_string(), "agent_message");
284 assert_eq!(Capability::Schedule.to_string(), "schedule");
285 assert_eq!(Capability::EventPublish.to_string(), "event_publish");
286 assert_eq!(Capability::SourceControl.to_string(), "source_control");
287 assert_eq!(Capability::Container.to_string(), "container");
288 assert_eq!(
289 Capability::DataManipulation.to_string(),
290 "data_manipulation"
291 );
292 assert_eq!(Capability::CodeAnalysis.to_string(), "code_analysis");
293 assert_eq!(Capability::Archive.to_string(), "archive");
294 assert_eq!(Capability::Template.to_string(), "template");
295 assert_eq!(Capability::Crypto.to_string(), "crypto");
296 assert_eq!(Capability::A2ADelegate.to_string(), "a2a_delegate");
297 assert_eq!(Capability::PluginInvoke.to_string(), "plugin_invoke");
298 }
299
300 #[test]
301 fn test_all_scopeless_capability_matches() {
302 let scopeless = vec![
303 Capability::Memory,
304 Capability::KnowledgeGraph,
305 Capability::BrowserControl,
306 Capability::AgentSpawn,
307 Capability::AgentMessage,
308 Capability::Schedule,
309 Capability::EventPublish,
310 Capability::SourceControl,
311 Capability::Container,
312 Capability::DataManipulation,
313 Capability::CodeAnalysis,
314 Capability::Archive,
315 Capability::Template,
316 Capability::Crypto,
317 Capability::A2ADelegate,
318 Capability::PluginInvoke,
319 ];
320 for cap in &scopeless {
321 assert!(capability_matches(cap, cap), "{} should match itself", cap);
322 }
323 let mcp = Capability::McpAccess("test".to_string());
325 assert!(capability_matches(&mcp, &mcp));
326 assert!(!capability_matches(
328 &Capability::Memory,
329 &Capability::Schedule
330 ));
331 assert!(!capability_matches(
332 &Capability::Archive,
333 &Capability::Template
334 ));
335 }
336
337 #[test]
338 fn test_capability_serde_roundtrip() {
339 let caps = vec![
340 Capability::FileRead("**/*.rs".to_string()),
341 Capability::FileWrite("out/**".to_string()),
342 Capability::ShellExec("*".to_string()),
343 Capability::Network("*.api.com".to_string()),
344 Capability::Memory,
345 Capability::BrowserControl,
346 Capability::Crypto,
347 Capability::McpAccess("*".to_string()),
348 ];
349 for cap in &caps {
350 let json = serde_json::to_string(cap).expect("serialize");
351 let deser: Capability = serde_json::from_str(&json).expect("deserialize");
352 assert_eq!(&deser, cap);
353 }
354 }
355
356 #[test]
357 fn test_glob_matches_star_star_slash_star() {
358 let granted = Capability::FileRead("**/*".to_string());
359 let required = Capability::FileRead("deep/nested/file.txt".to_string());
360 assert!(capability_matches(&granted, &required));
361 }
362
363 #[test]
364 fn test_shell_wildcard_grants_all() {
365 let granted = Capability::ShellExec("*".to_string());
366 let required = Capability::ShellExec("rm -rf /".to_string());
367 assert!(capability_matches(&granted, &required));
368 }
369
370 #[test]
371 fn test_network_wildcard_grants_all() {
372 let granted = Capability::Network("*".to_string());
373 let required = Capability::Network("any.host.com".to_string());
374 assert!(capability_matches(&granted, &required));
375 }
376
377 #[test]
378 fn test_subdomain_wildcard_host() {
379 let granted = Capability::Network("*.example.com".to_string());
380 assert!(capability_matches(
382 &granted,
383 &Capability::Network("example.com".to_string())
384 ));
385 assert!(capability_matches(
387 &granted,
388 &Capability::Network("deep.sub.example.com".to_string())
389 ));
390 }
391
392 #[test]
393 fn test_capability_grant_construction() {
394 let grant = CapabilityGrant {
395 id: Uuid::new_v4(),
396 capability: Capability::Memory,
397 granted_by: "admin".to_string(),
398 granted_at: chrono::Utc::now(),
399 expires_at: None,
400 };
401 assert_eq!(grant.granted_by, "admin");
402 assert!(grant.expires_at.is_none());
403 }
404
405 #[test]
406 fn test_capability_grant_with_expiry() {
407 let grant = CapabilityGrant {
408 id: Uuid::new_v4(),
409 capability: Capability::Network("*".to_string()),
410 granted_by: "system".to_string(),
411 granted_at: chrono::Utc::now(),
412 expires_at: Some(chrono::Utc::now()),
413 };
414 assert!(grant.expires_at.is_some());
415 }
416}