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