1pub mod resolve;
7pub mod types;
8
9use serde::{Deserialize, Serialize};
10use std::time::Duration;
11
12#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
19#[serde(tag = "type", rename_all = "snake_case")]
20pub enum Capability {
21 FileRead {
24 path_pattern: String,
26 },
27 FileWrite {
29 path_pattern: String,
31 },
32 FileEdit {
34 path_pattern: String,
36 },
37 FileList {
39 path_pattern: String,
41 },
42 FileFind {
44 path_pattern: String,
46 },
47
48 Bash {
51 allowed_commands: Vec<StringPattern>,
53 #[serde(default)]
55 timeout_secs: Option<u64>,
56 },
57
58 Network {
61 allowed_domains: Vec<String>,
63 },
64 WebBrowse {
66 allowed_domains: Vec<String>,
68 },
69
70 Subagent {
73 max_children: Option<usize>,
75 },
76 BusRead {
78 channel: Option<String>,
80 },
81 BusWrite {
83 channel: Option<String>,
85 },
86
87 EnvRead {
90 allowed_vars: Vec<String>,
92 },
93
94 ToolUse {
97 tool_name: String,
99 },
100 McpAccess {
102 resource_patterns: Vec<String>,
104 },
105}
106
107impl Capability {
108 pub fn satisfies(&self, required: &Capability) -> bool {
110 match (self, required) {
111 (
113 Capability::FileRead { path_pattern: a },
114 Capability::FileRead { path_pattern: b },
115 )
116 | (
117 Capability::FileWrite { path_pattern: a },
118 Capability::FileWrite { path_pattern: b },
119 )
120 | (
121 Capability::FileEdit { path_pattern: a },
122 Capability::FileEdit { path_pattern: b },
123 )
124 | (
125 Capability::FileList { path_pattern: a },
126 Capability::FileList { path_pattern: b },
127 )
128 | (
129 Capability::FileFind { path_pattern: a },
130 Capability::FileFind { path_pattern: b },
131 ) => pattern_matches(a, b),
132
133 (
134 Capability::Bash {
135 allowed_commands: a,
136 ..
137 },
138 Capability::Bash {
139 allowed_commands: b,
140 ..
141 },
142 ) => {
143 if a.iter().any(|p| matches!(p, StringPattern::Wildcard)) {
145 return true;
146 }
147 b.iter()
149 .all(|req| a.iter().any(|cap| string_pattern_matches(cap, req)))
150 }
151
152 (
153 Capability::Network { allowed_domains: a },
154 Capability::Network { allowed_domains: b },
155 )
156 | (
157 Capability::WebBrowse { allowed_domains: a },
158 Capability::WebBrowse { allowed_domains: b },
159 ) => domain_matches(a, b),
160
161 (
162 Capability::Subagent { max_children: a },
163 Capability::Subagent { max_children: b },
164 ) => match (a, b) {
165 (None, _) => true, (Some(a_max), Some(b_max)) => a_max >= b_max,
167 (Some(_), None) => false, },
169
170 (Capability::BusRead { channel: a }, Capability::BusRead { channel: b })
171 | (Capability::BusWrite { channel: a }, Capability::BusWrite { channel: b }) => {
172 match (a, b) {
173 (None, _) => true, (Some(_), None) => false,
175 (Some(a_ch), Some(b_ch)) => a_ch == b_ch,
176 }
177 }
178
179 (Capability::EnvRead { allowed_vars: a }, Capability::EnvRead { allowed_vars: b }) => {
180 b.iter().all(|req| a.iter().any(|cap| cap == req)) || a.contains(&"*".to_string())
181 }
182
183 (Capability::ToolUse { tool_name: a }, Capability::ToolUse { tool_name: b }) => {
184 a == b || a == "*"
185 }
186
187 (
188 Capability::McpAccess {
189 resource_patterns: a,
190 },
191 Capability::McpAccess {
192 resource_patterns: b,
193 },
194 ) => a.iter().any(|p| p == "*") || b.iter().all(|req| a.contains(req)),
195
196 _ => false,
197 }
198 }
199}
200
201#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
205pub enum StringPattern {
206 Literal(String),
208 Wildcard,
210}
211
212fn string_pattern_matches(granted: &StringPattern, required: &StringPattern) -> bool {
214 match (granted, required) {
215 (StringPattern::Wildcard, _) => true,
216 (StringPattern::Literal(a), StringPattern::Literal(b)) => a == b,
217 (StringPattern::Literal(_), StringPattern::Wildcard) => false,
218 }
219}
220
221fn pattern_matches(pattern: &str, path: &str) -> bool {
224 if pattern == "*" || pattern == "**" || pattern == "/**" {
225 return true;
226 }
227 if pattern == path {
228 return true;
229 }
230 if let Some(prefix) = pattern.strip_suffix("/**") {
232 return path.starts_with(prefix)
233 || path.starts_with(&format!("{}/", prefix.trim_end_matches('/')));
234 }
235 if let Some(prefix) = pattern.strip_suffix("*") {
236 return path.starts_with(prefix);
237 }
238 false
239}
240
241fn domain_matches(granted: &[String], required: &[String]) -> bool {
243 if granted.contains(&"*".to_string()) {
244 return true;
245 }
246 required.iter().all(|req| {
247 granted.iter().any(|g| {
248 if g == "*" {
249 return true;
250 }
251 if g == req {
252 return true;
253 }
254 if let Some(suffix) = g.strip_prefix("*.") {
256 req.ends_with(&format!(".{}", suffix)) || req == suffix
257 } else {
258 false
259 }
260 })
261 })
262}
263
264#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
268pub enum CapabilitySubject {
269 Agent(String),
271 Tool(String),
273 Group(String),
275}
276
277impl std::fmt::Display for CapabilitySubject {
278 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
279 match self {
280 CapabilitySubject::Agent(id) => write!(f, "agent:{}", id),
281 CapabilitySubject::Tool(name) => write!(f, "tool:{}", name),
282 CapabilitySubject::Group(name) => write!(f, "group:{}", name),
283 }
284 }
285}
286
287#[derive(Debug, Clone, Serialize, Deserialize)]
291pub struct CapabilitySet {
292 capabilities: Vec<Capability>,
293 expires_at_ms: Option<u64>,
294}
295
296impl CapabilitySet {
297 pub fn new(capabilities: Vec<Capability>) -> Self {
299 Self {
300 capabilities,
301 expires_at_ms: None,
302 }
303 }
304
305 pub fn all() -> Self {
309 Self::new(vec![
310 Capability::FileRead {
311 path_pattern: "/**".into(),
312 },
313 Capability::FileWrite {
314 path_pattern: "/**".into(),
315 },
316 Capability::FileEdit {
317 path_pattern: "/**".into(),
318 },
319 Capability::FileList {
320 path_pattern: "/**".into(),
321 },
322 Capability::FileFind {
323 path_pattern: "/**".into(),
324 },
325 Capability::Bash {
326 allowed_commands: vec![StringPattern::Wildcard],
327 timeout_secs: None,
328 },
329 Capability::Network {
330 allowed_domains: vec!["*".into()],
331 },
332 Capability::WebBrowse {
333 allowed_domains: vec!["*".into()],
334 },
335 Capability::Subagent { max_children: None },
336 Capability::BusRead { channel: None },
337 Capability::BusWrite { channel: None },
338 Capability::EnvRead {
339 allowed_vars: vec!["*".into()],
340 },
341 Capability::ToolUse {
342 tool_name: "*".into(),
343 },
344 ])
345 }
346
347 pub fn read_only(workspace: &str) -> Self {
349 let ws = workspace.to_string();
350 Self::new(vec![
351 Capability::FileRead {
352 path_pattern: format!("{}/**", ws),
353 },
354 Capability::FileList {
355 path_pattern: format!("{}/**", ws),
356 },
357 Capability::FileFind {
358 path_pattern: format!("{}/**", ws),
359 },
360 Capability::BusRead { channel: None },
361 ])
362 }
363
364 pub fn coding(workspace: &str) -> Self {
366 let ws = workspace.to_string();
367 Self::new(vec![
368 Capability::FileRead {
369 path_pattern: format!("{}/**", ws),
370 },
371 Capability::FileWrite {
372 path_pattern: format!("{}/**", ws),
373 },
374 Capability::FileEdit {
375 path_pattern: format!("{}/**", ws),
376 },
377 Capability::FileList {
378 path_pattern: format!("{}/**", ws),
379 },
380 Capability::FileFind {
381 path_pattern: format!("{}/**", ws),
382 },
383 Capability::Bash {
384 allowed_commands: vec![
385 StringPattern::Literal("git".into()),
386 StringPattern::Literal("cargo".into()),
387 StringPattern::Literal("npm".into()),
388 StringPattern::Literal("node".into()),
389 StringPattern::Literal("python3".into()),
390 StringPattern::Literal("ls".into()),
391 StringPattern::Literal("cat".into()),
392 StringPattern::Literal("grep".into()),
393 StringPattern::Literal("rg".into()),
394 StringPattern::Literal("find".into()),
395 StringPattern::Literal("mkdir".into()),
396 StringPattern::Literal("cp".into()),
397 StringPattern::Literal("mv".into()),
398 ],
399 timeout_secs: Some(30),
400 },
401 Capability::Subagent {
402 max_children: Some(2),
403 },
404 Capability::BusRead { channel: None },
405 ])
406 }
407
408 pub fn research(workspace: &str) -> Self {
410 let ws = workspace.to_string();
411 Self::new(vec![
412 Capability::FileRead {
413 path_pattern: format!("{}/**", ws),
414 },
415 Capability::FileList {
416 path_pattern: format!("{}/**", ws),
417 },
418 Capability::FileFind {
419 path_pattern: format!("{}/**", ws),
420 },
421 Capability::Network {
422 allowed_domains: vec!["*".into()],
423 },
424 Capability::WebBrowse {
425 allowed_domains: vec!["*".into()],
426 },
427 Capability::BusRead { channel: None },
428 ])
429 }
430
431 pub fn browser(workspace: &str) -> Self {
433 let ws = workspace.to_string();
434 Self::new(vec![
435 Capability::FileRead {
436 path_pattern: format!("{}/**", ws),
437 },
438 Capability::FileWrite {
439 path_pattern: format!("{}/output/**", ws),
440 },
441 Capability::Network {
442 allowed_domains: vec!["*".into()],
443 },
444 Capability::WebBrowse {
445 allowed_domains: vec!["*".into()],
446 },
447 ])
448 }
449
450 pub fn add(&mut self, cap: Capability) -> &mut Self {
454 self.capabilities.push(cap);
455 self
456 }
457
458 pub fn with_ttl(mut self, duration: Duration) -> Self {
460 let expires = std::time::SystemTime::now()
461 .duration_since(std::time::UNIX_EPOCH)
462 .map(|d| d.as_millis() as u64 + duration.as_millis() as u64)
463 .unwrap_or(u64::MAX);
464 self.expires_at_ms = Some(expires);
465 self
466 }
467
468 pub fn is_expired(&self) -> bool {
470 match self.expires_at_ms {
471 Some(expires) => {
472 let now = std::time::SystemTime::now()
473 .duration_since(std::time::UNIX_EPOCH)
474 .map(|d| d.as_millis() as u64)
475 .unwrap_or(0);
476 now > expires
477 }
478 None => false,
479 }
480 }
481
482 pub fn capabilities(&self) -> &[Capability] {
484 &self.capabilities
485 }
486
487 pub fn satisfies(&self, required: &Capability) -> bool {
489 self.capabilities.iter().any(|cap| cap.satisfies(required))
490 }
491}
492
493#[cfg(test)]
494mod tests {
495 use super::*;
496
497 #[test]
498 fn file_capability_satisfies() {
499 let cap = Capability::FileRead {
500 path_pattern: "/workspace/**".into(),
501 };
502 let req = Capability::FileRead {
503 path_pattern: "/workspace/src/main.rs".into(),
504 };
505 assert!(cap.satisfies(&req));
506
507 let denied = Capability::FileRead {
508 path_pattern: "/etc/passwd".into(),
509 };
510 assert!(!cap.satisfies(&denied));
511 }
512
513 #[test]
514 fn bash_wildcard_satisfies() {
515 let cap = Capability::Bash {
516 allowed_commands: vec![StringPattern::Wildcard],
517 timeout_secs: None,
518 };
519 let req = Capability::Bash {
520 allowed_commands: vec![StringPattern::Literal("rm".into())],
521 timeout_secs: None,
522 };
523 assert!(cap.satisfies(&req));
524 }
525
526 #[test]
527 fn domain_matching() {
528 let granted = vec!["*.example.com".to_string()];
529 let required = vec!["sub.example.com".to_string()];
530 assert!(domain_matches(&granted, &required));
531
532 let denied = vec!["other.com".to_string()];
533 assert!(!domain_matches(&granted, &denied));
534 }
535
536 #[test]
537 fn capability_set_coding_satisfies() {
538 let set = CapabilitySet::coding("/workspace");
539 assert!(set.satisfies(&Capability::FileRead {
540 path_pattern: "/workspace/src/main.rs".into()
541 }));
542 assert!(!set.satisfies(&Capability::FileWrite {
543 path_pattern: "/etc/passwd".into()
544 }));
545 }
546
547 #[test]
548 fn capability_set_read_only() {
549 let set = CapabilitySet::read_only("/ws");
550 assert!(set.satisfies(&Capability::FileRead {
551 path_pattern: "/ws/any".into()
552 }));
553 assert!(!set.satisfies(&Capability::FileWrite {
554 path_pattern: "/ws/any".into()
555 }));
556 }
557
558 #[test]
559 fn capability_set_all() {
560 let set = CapabilitySet::all();
561 assert!(set.satisfies(&Capability::FileRead {
562 path_pattern: "/anything".into()
563 }));
564 assert!(set.satisfies(&Capability::FileWrite {
565 path_pattern: "/anything".into()
566 }));
567 assert!(set.satisfies(&Capability::Bash {
568 allowed_commands: vec![StringPattern::Literal("anything".into())],
569 timeout_secs: None,
570 }));
571 }
572
573 #[test]
574 fn capability_set_with_ttl_not_expired() {
575 let set = CapabilitySet::coding("/ws").with_ttl(Duration::from_secs(3600));
576 assert!(!set.is_expired());
577 }
578
579 #[test]
580 fn capability_set_expired() {
581 let mut set = CapabilitySet::coding("/ws");
582 set.expires_at_ms = Some(1); assert!(set.is_expired());
584 }
585
586 #[test]
587 fn capability_set_add() {
588 let mut set = CapabilitySet::new(vec![]);
589 set.add(Capability::FileRead {
590 path_pattern: "/ws".into(),
591 });
592 assert_eq!(set.capabilities().len(), 1);
593 }
594
595 #[test]
596 fn subject_display() {
597 assert_eq!(
598 CapabilitySubject::Agent("a1".into()).to_string(),
599 "agent:a1"
600 );
601 assert_eq!(
602 CapabilitySubject::Tool("read".into()).to_string(),
603 "tool:read"
604 );
605 assert_eq!(
606 CapabilitySubject::Group("coders".into()).to_string(),
607 "group:coders"
608 );
609 }
610}