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 ChannelNotify,
56 SelfConfig,
58 SystemAutomation,
60 UiAutomation(String),
63 AppIntegration(String),
66}
67
68impl std::fmt::Display for Capability {
69 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
70 match self {
71 Self::FileRead(g) => write!(f, "file_read({})", g),
72 Self::FileWrite(g) => write!(f, "file_write({})", g),
73 Self::ShellExec(p) => write!(f, "shell_exec({})", p),
74 Self::Network(h) => write!(f, "network({})", h),
75 Self::Memory => write!(f, "memory"),
76 Self::KnowledgeGraph => write!(f, "knowledge_graph"),
77 Self::BrowserControl => write!(f, "browser_control"),
78 Self::AgentSpawn => write!(f, "agent_spawn"),
79 Self::AgentMessage => write!(f, "agent_message"),
80 Self::Schedule => write!(f, "schedule"),
81 Self::EventPublish => write!(f, "event_publish"),
82 Self::SourceControl => write!(f, "source_control"),
83 Self::Container => write!(f, "container"),
84 Self::DataManipulation => write!(f, "data_manipulation"),
85 Self::CodeAnalysis => write!(f, "code_analysis"),
86 Self::Archive => write!(f, "archive"),
87 Self::Template => write!(f, "template"),
88 Self::Crypto => write!(f, "crypto"),
89 Self::PluginInvoke => write!(f, "plugin_invoke"),
90 Self::A2ADelegate => write!(f, "a2a_delegate"),
91 Self::McpAccess(p) => write!(f, "mcp_access({})", p),
92 Self::ChannelNotify => write!(f, "channel_notify"),
93 Self::SelfConfig => write!(f, "self_config"),
94 Self::SystemAutomation => write!(f, "system_automation"),
95 Self::UiAutomation(app) => write!(f, "ui_automation({})", app),
96 Self::AppIntegration(app) => write!(f, "app_integration({})", app),
97 }
98 }
99}
100
101impl Capability {
102 pub fn full_access() -> Vec<Capability> {
108 vec![
109 Capability::FileRead("**".to_string()),
110 Capability::FileWrite("**".to_string()),
111 Capability::ShellExec("*".to_string()),
112 Capability::Network("*".to_string()),
113 Capability::Memory,
114 Capability::KnowledgeGraph,
115 Capability::BrowserControl,
116 Capability::AgentSpawn,
117 Capability::AgentMessage,
118 Capability::Schedule,
119 Capability::EventPublish,
120 Capability::SourceControl,
121 Capability::Container,
122 Capability::DataManipulation,
123 Capability::CodeAnalysis,
124 Capability::Archive,
125 Capability::Template,
126 Capability::Crypto,
127 Capability::PluginInvoke,
128 Capability::A2ADelegate,
129 Capability::McpAccess("*".to_string()),
130 Capability::ChannelNotify,
131 Capability::SelfConfig,
132 Capability::SystemAutomation,
133 Capability::UiAutomation("*".to_string()),
134 Capability::AppIntegration("*".to_string()),
135 ]
136 }
137}
138
139#[derive(Debug, Clone, Serialize, Deserialize)]
141pub struct CapabilityGrant {
142 pub id: Uuid,
144 pub capability: Capability,
146 pub granted_by: String,
148 pub granted_at: DateTime<Utc>,
150 pub expires_at: Option<DateTime<Utc>>,
152}
153
154pub fn capability_matches(granted: &Capability, required: &Capability) -> bool {
159 match (granted, required) {
160 (Capability::FileRead(granted_glob), Capability::FileRead(required_path)) => {
161 glob_matches(granted_glob, required_path)
162 }
163 (Capability::FileWrite(granted_glob), Capability::FileWrite(required_path)) => {
164 glob_matches(granted_glob, required_path)
165 }
166 (Capability::ShellExec(granted_pat), Capability::ShellExec(required_cmd)) => {
167 pattern_matches(granted_pat, required_cmd)
168 }
169 (Capability::Network(granted_host), Capability::Network(required_host)) => {
170 host_matches(granted_host, required_host)
171 }
172 (Capability::Memory, Capability::Memory) => true,
173 (Capability::KnowledgeGraph, Capability::KnowledgeGraph) => true,
174 (Capability::BrowserControl, Capability::BrowserControl) => true,
175 (Capability::AgentSpawn, Capability::AgentSpawn) => true,
176 (Capability::AgentMessage, Capability::AgentMessage) => true,
177 (Capability::Schedule, Capability::Schedule) => true,
178 (Capability::EventPublish, Capability::EventPublish) => true,
179 (Capability::SourceControl, Capability::SourceControl) => true,
180 (Capability::Container, Capability::Container) => true,
181 (Capability::DataManipulation, Capability::DataManipulation) => true,
182 (Capability::CodeAnalysis, Capability::CodeAnalysis) => true,
183 (Capability::Archive, Capability::Archive) => true,
184 (Capability::Template, Capability::Template) => true,
185 (Capability::Crypto, Capability::Crypto) => true,
186 (Capability::PluginInvoke, Capability::PluginInvoke) => true,
187 (Capability::A2ADelegate, Capability::A2ADelegate) => true,
188 (Capability::McpAccess(granted_pat), Capability::McpAccess(required_name)) => {
189 pattern_matches(granted_pat, required_name)
190 }
191 (Capability::ChannelNotify, Capability::ChannelNotify) => true,
192 (Capability::SelfConfig, Capability::SelfConfig) => true,
193 (Capability::SystemAutomation, Capability::SystemAutomation) => true,
194 (Capability::UiAutomation(granted_app), Capability::UiAutomation(required_app)) => {
195 pattern_matches(granted_app, required_app)
196 }
197 (Capability::AppIntegration(granted_app), Capability::AppIntegration(required_app)) => {
198 pattern_matches(granted_app, required_app)
199 }
200 _ => false,
201 }
202}
203
204fn glob_matches(pattern: &str, path: &str) -> bool {
206 if pattern == "**" || pattern == "**/*" {
207 return true;
208 }
209 glob::Pattern::new(pattern)
210 .map(|p| p.matches(path))
211 .unwrap_or(false)
212}
213
214fn pattern_matches(pattern: &str, command: &str) -> bool {
216 if pattern == "*" {
217 return true;
218 }
219 glob::Pattern::new(pattern)
220 .map(|p| p.matches(command))
221 .unwrap_or(false)
222}
223
224fn host_matches(pattern: &str, host: &str) -> bool {
226 if pattern == "*" {
227 return true;
228 }
229 if let Some(suffix) = pattern.strip_prefix("*.") {
230 return host == suffix || host.ends_with(&format!(".{}", suffix));
231 }
232 pattern == host
233}
234
235#[cfg(test)]
236mod tests {
237 use super::*;
238
239 #[test]
240 fn test_glob_file_read() {
241 let granted = Capability::FileRead("src/**/*.rs".to_string());
242 let required = Capability::FileRead("src/main.rs".to_string());
243 assert!(capability_matches(&granted, &required));
244 }
245
246 #[test]
247 fn test_wildcard_grants_all() {
248 let granted = Capability::FileRead("**".to_string());
249 let required = Capability::FileRead("anything/at/all.txt".to_string());
250 assert!(capability_matches(&granted, &required));
251 }
252
253 #[test]
254 fn test_glob_no_match() {
255 let granted = Capability::FileRead("src/**/*.rs".to_string());
256 let required = Capability::FileRead("tests/data.json".to_string());
257 assert!(!capability_matches(&granted, &required));
258 }
259
260 #[test]
261 fn test_variant_mismatch() {
262 let granted = Capability::FileRead("**".to_string());
263 let required = Capability::FileWrite("foo.txt".to_string());
264 assert!(!capability_matches(&granted, &required));
265 }
266
267 #[test]
268 fn test_scopeless_capabilities() {
269 assert!(capability_matches(&Capability::Memory, &Capability::Memory));
270 assert!(!capability_matches(
271 &Capability::Memory,
272 &Capability::Schedule
273 ));
274 }
275
276 #[test]
277 fn test_wildcard_host() {
278 let granted = Capability::Network("*.example.com".to_string());
279 let required = Capability::Network("api.example.com".to_string());
280 assert!(capability_matches(&granted, &required));
281 }
282
283 #[test]
284 fn test_exact_host() {
285 let granted = Capability::Network("api.example.com".to_string());
286 let required = Capability::Network("api.example.com".to_string());
287 assert!(capability_matches(&granted, &required));
288
289 let other = Capability::Network("other.example.com".to_string());
290 assert!(!capability_matches(&granted, &other));
291 }
292
293 #[test]
294 fn test_capability_display_all_scoped() {
295 assert_eq!(
296 Capability::FileRead("src/*.rs".to_string()).to_string(),
297 "file_read(src/*.rs)"
298 );
299 assert_eq!(
300 Capability::FileWrite("out/**".to_string()).to_string(),
301 "file_write(out/**)"
302 );
303 assert_eq!(
304 Capability::ShellExec("ls*".to_string()).to_string(),
305 "shell_exec(ls*)"
306 );
307 assert_eq!(
308 Capability::Network("*.example.com".to_string()).to_string(),
309 "network(*.example.com)"
310 );
311 }
312
313 #[test]
314 fn test_mcp_access_wildcard() {
315 let granted = Capability::McpAccess("*".to_string());
316 let required = Capability::McpAccess("github".to_string());
317 assert!(capability_matches(&granted, &required));
318 }
319
320 #[test]
321 fn test_mcp_access_exact() {
322 let granted = Capability::McpAccess("github".to_string());
323 assert!(capability_matches(
324 &granted,
325 &Capability::McpAccess("github".to_string())
326 ));
327 assert!(!capability_matches(
328 &granted,
329 &Capability::McpAccess("slack".to_string())
330 ));
331 }
332
333 #[test]
334 fn test_mcp_access_display() {
335 assert_eq!(
336 Capability::McpAccess("github".to_string()).to_string(),
337 "mcp_access(github)"
338 );
339 }
340
341 #[test]
342 fn test_capability_display_all_scopeless() {
343 assert_eq!(Capability::Memory.to_string(), "memory");
344 assert_eq!(Capability::KnowledgeGraph.to_string(), "knowledge_graph");
345 assert_eq!(Capability::BrowserControl.to_string(), "browser_control");
346 assert_eq!(Capability::AgentSpawn.to_string(), "agent_spawn");
347 assert_eq!(Capability::AgentMessage.to_string(), "agent_message");
348 assert_eq!(Capability::Schedule.to_string(), "schedule");
349 assert_eq!(Capability::EventPublish.to_string(), "event_publish");
350 assert_eq!(Capability::SourceControl.to_string(), "source_control");
351 assert_eq!(Capability::Container.to_string(), "container");
352 assert_eq!(
353 Capability::DataManipulation.to_string(),
354 "data_manipulation"
355 );
356 assert_eq!(Capability::CodeAnalysis.to_string(), "code_analysis");
357 assert_eq!(Capability::Archive.to_string(), "archive");
358 assert_eq!(Capability::Template.to_string(), "template");
359 assert_eq!(Capability::Crypto.to_string(), "crypto");
360 assert_eq!(Capability::A2ADelegate.to_string(), "a2a_delegate");
361 assert_eq!(Capability::PluginInvoke.to_string(), "plugin_invoke");
362 assert_eq!(Capability::SelfConfig.to_string(), "self_config");
363 assert_eq!(
364 Capability::SystemAutomation.to_string(),
365 "system_automation"
366 );
367 }
368
369 #[test]
370 fn test_all_scopeless_capability_matches() {
371 let scopeless = vec![
372 Capability::Memory,
373 Capability::KnowledgeGraph,
374 Capability::BrowserControl,
375 Capability::AgentSpawn,
376 Capability::AgentMessage,
377 Capability::Schedule,
378 Capability::EventPublish,
379 Capability::SourceControl,
380 Capability::Container,
381 Capability::DataManipulation,
382 Capability::CodeAnalysis,
383 Capability::Archive,
384 Capability::Template,
385 Capability::Crypto,
386 Capability::A2ADelegate,
387 Capability::PluginInvoke,
388 Capability::SelfConfig,
389 Capability::SystemAutomation,
390 ];
391 for cap in &scopeless {
392 assert!(capability_matches(cap, cap), "{} should match itself", cap);
393 }
394 let mcp = Capability::McpAccess("test".to_string());
396 assert!(capability_matches(&mcp, &mcp));
397 assert!(!capability_matches(
399 &Capability::Memory,
400 &Capability::Schedule
401 ));
402 assert!(!capability_matches(
403 &Capability::Archive,
404 &Capability::Template
405 ));
406 }
407
408 #[test]
409 fn test_capability_serde_roundtrip() {
410 let caps = vec![
411 Capability::FileRead("**/*.rs".to_string()),
412 Capability::FileWrite("out/**".to_string()),
413 Capability::ShellExec("*".to_string()),
414 Capability::Network("*.api.com".to_string()),
415 Capability::Memory,
416 Capability::BrowserControl,
417 Capability::Crypto,
418 Capability::McpAccess("*".to_string()),
419 ];
420 for cap in &caps {
421 let json = serde_json::to_string(cap).expect("serialize");
422 let deser: Capability = serde_json::from_str(&json).expect("deserialize");
423 assert_eq!(&deser, cap);
424 }
425 }
426
427 #[test]
428 fn test_glob_matches_star_star_slash_star() {
429 let granted = Capability::FileRead("**/*".to_string());
430 let required = Capability::FileRead("deep/nested/file.txt".to_string());
431 assert!(capability_matches(&granted, &required));
432 }
433
434 #[test]
435 fn test_shell_wildcard_grants_all() {
436 let granted = Capability::ShellExec("*".to_string());
437 let required = Capability::ShellExec("rm -rf /".to_string());
438 assert!(capability_matches(&granted, &required));
439 }
440
441 #[test]
442 fn test_network_wildcard_grants_all() {
443 let granted = Capability::Network("*".to_string());
444 let required = Capability::Network("any.host.com".to_string());
445 assert!(capability_matches(&granted, &required));
446 }
447
448 #[test]
449 fn test_subdomain_wildcard_host() {
450 let granted = Capability::Network("*.example.com".to_string());
451 assert!(capability_matches(
453 &granted,
454 &Capability::Network("example.com".to_string())
455 ));
456 assert!(capability_matches(
458 &granted,
459 &Capability::Network("deep.sub.example.com".to_string())
460 ));
461 }
462
463 #[test]
464 fn test_capability_grant_construction() {
465 let grant = CapabilityGrant {
466 id: Uuid::new_v4(),
467 capability: Capability::Memory,
468 granted_by: "admin".to_string(),
469 granted_at: chrono::Utc::now(),
470 expires_at: None,
471 };
472 assert_eq!(grant.granted_by, "admin");
473 assert!(grant.expires_at.is_none());
474 }
475
476 #[test]
477 fn test_ui_automation_scoped() {
478 let granted = Capability::UiAutomation("*".to_string());
479 let required = Capability::UiAutomation("Messages".to_string());
480 assert!(capability_matches(&granted, &required));
481
482 let specific = Capability::UiAutomation("Safari".to_string());
483 assert!(capability_matches(
484 &specific,
485 &Capability::UiAutomation("Safari".to_string())
486 ));
487 assert!(!capability_matches(
488 &specific,
489 &Capability::UiAutomation("Messages".to_string())
490 ));
491 }
492
493 #[test]
494 fn test_app_integration_scoped() {
495 let granted = Capability::AppIntegration("*".to_string());
496 let required = Capability::AppIntegration("Messages".to_string());
497 assert!(capability_matches(&granted, &required));
498
499 let specific = Capability::AppIntegration("Safari".to_string());
500 assert!(!capability_matches(
501 &specific,
502 &Capability::AppIntegration("Finder".to_string())
503 ));
504 }
505
506 #[test]
507 fn test_automation_display() {
508 assert_eq!(
509 Capability::UiAutomation("Safari".to_string()).to_string(),
510 "ui_automation(Safari)"
511 );
512 assert_eq!(
513 Capability::AppIntegration("*".to_string()).to_string(),
514 "app_integration(*)"
515 );
516 }
517
518 #[test]
519 fn test_automation_cross_variant_no_match() {
520 assert!(!capability_matches(
521 &Capability::SystemAutomation,
522 &Capability::UiAutomation("*".to_string())
523 ));
524 assert!(!capability_matches(
525 &Capability::UiAutomation("*".to_string()),
526 &Capability::AppIntegration("*".to_string())
527 ));
528 }
529
530 #[test]
531 fn test_capability_grant_with_expiry() {
532 let grant = CapabilityGrant {
533 id: Uuid::new_v4(),
534 capability: Capability::Network("*".to_string()),
535 granted_by: "system".to_string(),
536 granted_at: chrono::Utc::now(),
537 expires_at: Some(chrono::Utc::now()),
538 };
539 assert!(grant.expires_at.is_some());
540 }
541}