1use alloc::collections::BTreeSet;
7use alloc::string::{String, ToString};
8use core::str::FromStr;
9
10#[derive(Debug, Clone, PartialEq, Eq, Hash, PartialOrd, Ord)]
12#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
13pub enum Capability {
14 FileRead,
16 FileWrite,
18 FileDelete,
23 NetworkOutbound,
25 NetworkInbound,
27 TerminalExec,
29 McpTool(String),
31 Model(String),
33 AgentSpawn,
35}
36
37#[derive(Debug, Clone, PartialEq, Default)]
39#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
40pub struct CapabilitySet {
41 pub allow: BTreeSet<Capability>,
43 pub deny: BTreeSet<Capability>,
45 #[cfg_attr(feature = "serde", serde(default))]
58 pub allow_restricted: bool,
59}
60
61impl CapabilitySet {
62 #[must_use]
70 pub fn allow_is_restricted(&self) -> bool {
71 self.allow_restricted || !self.allow.is_empty()
72 }
73}
74
75impl Capability {
76 #[must_use]
90 pub fn is_enforceable(&self) -> bool {
91 !matches!(
92 self,
93 Capability::Model(_) | Capability::NetworkInbound | Capability::AgentSpawn
94 )
95 }
96}
97
98impl FromStr for Capability {
99 type Err = String;
100
101 fn from_str(s: &str) -> Result<Self, Self::Err> {
102 match s {
103 "file_read" => Ok(Capability::FileRead),
104 "file_write" => Ok(Capability::FileWrite),
105 "file_delete" => Ok(Capability::FileDelete),
106 "network_outbound" => Ok(Capability::NetworkOutbound),
107 "network_inbound" => Ok(Capability::NetworkInbound),
108 "terminal_exec" => Ok(Capability::TerminalExec),
109 "agent_spawn" => Ok(Capability::AgentSpawn),
110 _ => {
111 if let Some(name) = s.strip_prefix("mcp_tool:") {
112 if name.is_empty() {
113 return Err("mcp_tool: name must not be empty".to_string());
114 }
115 Ok(Capability::McpTool(name.to_string()))
116 } else if let Some(name) = s.strip_prefix("model:") {
117 if name.is_empty() {
118 return Err("model: name must not be empty".to_string());
119 }
120 Ok(Capability::Model(name.to_string()))
121 } else {
122 Err(alloc::format!("unknown capability: '{s}'"))
123 }
124 }
125 }
126 }
127}
128
129impl core::fmt::Display for Capability {
130 fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
131 match self {
132 Capability::FileRead => f.write_str("file_read"),
133 Capability::FileWrite => f.write_str("file_write"),
134 Capability::FileDelete => f.write_str("file_delete"),
135 Capability::NetworkOutbound => f.write_str("network_outbound"),
136 Capability::NetworkInbound => f.write_str("network_inbound"),
137 Capability::TerminalExec => f.write_str("terminal_exec"),
138 Capability::AgentSpawn => f.write_str("agent_spawn"),
139 Capability::McpTool(name) => write!(f, "mcp_tool:{name}"),
140 Capability::Model(name) => write!(f, "model:{name}"),
141 }
142 }
143}
144
145#[cfg(feature = "alloc")]
162pub fn merge_capabilities(parent: &CapabilitySet, child: &CapabilitySet) -> CapabilitySet {
163 let deny: BTreeSet<Capability> = parent.deny.union(&child.deny).cloned().collect();
165
166 let allow: BTreeSet<Capability> = match (parent.allow.is_empty(), child.allow.is_empty()) {
167 (true, true) => BTreeSet::new(),
169 (true, false) => child.allow.difference(&deny).cloned().collect(),
171 (false, true) => parent.allow.difference(&deny).cloned().collect(),
173 (false, false) => parent
175 .allow
176 .intersection(&child.allow)
177 .filter(|c| !deny.contains(c))
178 .cloned()
179 .collect(),
180 };
181
182 let allow_restricted =
185 parent.allow_restricted || child.allow_restricted || !parent.allow.is_empty() || !child.allow.is_empty();
186
187 CapabilitySet {
188 allow,
189 deny,
190 allow_restricted,
191 }
192}
193
194#[cfg(feature = "alloc")]
199pub fn action_to_capability(action: &crate::GovernanceAction) -> Option<Capability> {
200 use crate::policy::FileMode;
201 use crate::GovernanceAction;
202
203 match action {
210 GovernanceAction::ToolCall { name, .. } => Some(Capability::McpTool(name.clone())),
211 GovernanceAction::ToolResult { tool_name, .. } => Some(Capability::McpTool(tool_name.clone())),
212 GovernanceAction::FileAccess {
213 mode: FileMode::Read, ..
214 } => Some(Capability::FileRead),
215 GovernanceAction::FileAccess {
216 mode: FileMode::Write | FileMode::Append,
217 ..
218 } => Some(Capability::FileWrite),
219 GovernanceAction::FileAccess {
225 mode: FileMode::Delete, ..
226 } => Some(Capability::FileDelete),
227 GovernanceAction::NetworkRequest { .. } => Some(Capability::NetworkOutbound),
228 GovernanceAction::ProcessExec { .. } => Some(Capability::TerminalExec),
229 GovernanceAction::SendMessage { .. } => None,
230 }
231}
232
233#[cfg(feature = "alloc")]
245pub fn capability_is_denied(deny: &BTreeSet<Capability>, cap: &Capability) -> bool {
246 if deny.contains(cap) {
247 return true;
248 }
249 matches!(cap, Capability::FileDelete) && deny.contains(&Capability::FileWrite)
251}
252
253#[cfg(feature = "alloc")]
262#[derive(Debug, Clone, PartialEq, Default)]
263#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
264pub struct PermissionSource {
265 pub scope: String,
267 pub allow: BTreeSet<Capability>,
269 pub deny: BTreeSet<Capability>,
271}
272
273#[cfg(feature = "alloc")]
285#[derive(Debug, Clone, PartialEq, Default)]
286#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
287pub struct EffectivePermissions {
288 pub merged: CapabilitySet,
290 pub sources: alloc::vec::Vec<PermissionSource>,
292}
293
294#[cfg(test)]
295mod tests {
296 use super::*;
297 use alloc::collections::BTreeSet;
298
299 #[test]
300 fn capability_variants_are_distinct() {
301 assert_ne!(Capability::FileRead, Capability::FileWrite);
302 assert_ne!(
303 Capability::McpTool("a".to_string()),
304 Capability::McpTool("b".to_string())
305 );
306 }
307
308 #[test]
309 fn mcp_tool_same_name_eq() {
310 assert_eq!(
311 Capability::McpTool("bash".to_string()),
312 Capability::McpTool("bash".to_string())
313 );
314 }
315
316 #[test]
317 fn capability_hashable_in_set() {
318 let mut set: BTreeSet<Capability> = BTreeSet::new();
319 set.insert(Capability::FileRead);
320 set.insert(Capability::FileWrite);
321 set.insert(Capability::McpTool("bash".to_string()));
322 assert_eq!(set.len(), 3);
323 }
324
325 #[test]
326 fn capability_set_default_is_empty() {
327 let cs = CapabilitySet::default();
328 assert!(cs.allow.is_empty());
329 assert!(cs.deny.is_empty());
330 }
331
332 #[test]
333 fn capability_from_str_file_read() {
334 assert_eq!("file_read".parse::<Capability>().unwrap(), Capability::FileRead);
335 }
336
337 #[test]
338 fn capability_from_str_file_write() {
339 assert_eq!("file_write".parse::<Capability>().unwrap(), Capability::FileWrite);
340 }
341
342 #[test]
343 fn capability_from_str_file_delete() {
344 assert_eq!("file_delete".parse::<Capability>().unwrap(), Capability::FileDelete);
345 }
346
347 #[test]
348 fn capability_file_delete_display_round_trips() {
349 let cap = Capability::FileDelete;
350 assert_eq!(cap.to_string(), "file_delete");
351 assert_eq!(cap.to_string().parse::<Capability>().unwrap(), cap);
352 }
353
354 #[test]
355 fn capability_file_delete_distinct_from_file_write() {
356 assert_ne!(Capability::FileDelete, Capability::FileWrite);
357 }
358
359 #[test]
360 fn capability_from_str_network_outbound() {
361 assert_eq!(
362 "network_outbound".parse::<Capability>().unwrap(),
363 Capability::NetworkOutbound
364 );
365 }
366
367 #[test]
368 fn capability_from_str_network_inbound() {
369 assert_eq!(
370 "network_inbound".parse::<Capability>().unwrap(),
371 Capability::NetworkInbound
372 );
373 }
374
375 #[test]
376 fn capability_from_str_terminal_exec() {
377 assert_eq!("terminal_exec".parse::<Capability>().unwrap(), Capability::TerminalExec);
378 }
379
380 #[test]
381 fn capability_from_str_mcp_tool() {
382 assert_eq!(
383 "mcp_tool:bash".parse::<Capability>().unwrap(),
384 Capability::McpTool("bash".to_string())
385 );
386 }
387
388 #[test]
389 fn capability_from_str_model() {
390 assert_eq!(
391 "model:gpt-4o".parse::<Capability>().unwrap(),
392 Capability::Model("gpt-4o".to_string())
393 );
394 }
395
396 #[test]
397 fn capability_from_str_agent_spawn() {
398 assert_eq!("agent_spawn".parse::<Capability>().unwrap(), Capability::AgentSpawn);
399 }
400
401 #[test]
402 fn capability_from_str_unknown_returns_err() {
403 assert!("unknown_cap".parse::<Capability>().is_err());
404 }
405
406 #[test]
407 fn capability_from_str_mcp_tool_empty_name_returns_err() {
408 assert!("mcp_tool:".parse::<Capability>().is_err());
409 }
410
411 #[test]
412 fn capability_from_str_model_empty_name_returns_err() {
413 assert!("model:".parse::<Capability>().is_err());
414 }
415
416 #[test]
417 fn capability_display_round_trips_simple_variant() {
418 let cap = Capability::FileRead;
419 assert_eq!(cap.to_string().parse::<Capability>().unwrap(), cap);
420 }
421
422 #[test]
423 fn capability_display_round_trips_mcp_tool() {
424 let cap = Capability::McpTool("bash".to_string());
425 assert_eq!(cap.to_string().parse::<Capability>().unwrap(), cap);
426 }
427
428 fn cap_set(allow: &[Capability], deny: &[Capability]) -> CapabilitySet {
433 CapabilitySet {
434 allow: allow.iter().cloned().collect(),
435 deny: deny.iter().cloned().collect(),
436 allow_restricted: false,
437 }
438 }
439
440 #[test]
441 fn merge_empty_parent_with_child_deny() {
442 let parent = CapabilitySet::default();
443 let child = cap_set(&[], &[Capability::FileWrite]);
444 let result = super::merge_capabilities(&parent, &child);
445 assert!(result.deny.contains(&Capability::FileWrite));
446 assert!(result.allow.is_empty());
447 }
448
449 #[test]
450 fn merge_parent_deny_wins_over_child_allow() {
451 let parent = cap_set(&[], &[Capability::NetworkOutbound]);
452 let child = cap_set(&[Capability::FileRead, Capability::NetworkOutbound], &[]);
453 let result = super::merge_capabilities(&parent, &child);
454 assert!(result.allow.contains(&Capability::FileRead));
455 assert!(!result.allow.contains(&Capability::NetworkOutbound));
456 }
457
458 #[test]
459 fn merge_deny_is_union() {
460 let parent = cap_set(&[], &[Capability::FileWrite]);
461 let child = cap_set(&[], &[Capability::TerminalExec]);
462 let result = super::merge_capabilities(&parent, &child);
463 assert!(result.deny.contains(&Capability::FileWrite));
464 assert!(result.deny.contains(&Capability::TerminalExec));
465 }
466
467 #[test]
468 fn merge_both_allow_nonempty_takes_intersection() {
469 let parent = cap_set(&[Capability::FileRead, Capability::FileWrite], &[]);
470 let child = cap_set(&[Capability::FileRead, Capability::NetworkOutbound], &[]);
471 let result = super::merge_capabilities(&parent, &child);
472 assert_eq!(
473 result.allow,
474 [Capability::FileRead].iter().cloned().collect::<BTreeSet<_>>()
475 );
476 }
477
478 #[test]
479 fn merge_parent_allow_nonempty_child_allow_empty_uses_parent() {
480 let parent = cap_set(&[Capability::FileRead], &[]);
481 let child = cap_set(&[], &[]);
482 let result = super::merge_capabilities(&parent, &child);
483 assert_eq!(
484 result.allow,
485 [Capability::FileRead].iter().cloned().collect::<BTreeSet<_>>()
486 );
487 }
488
489 #[test]
490 fn merge_parent_allow_empty_child_allow_nonempty_uses_child() {
491 let parent = cap_set(&[], &[]);
492 let child = cap_set(&[Capability::FileRead], &[]);
493 let result = super::merge_capabilities(&parent, &child);
494 assert_eq!(
495 result.allow,
496 [Capability::FileRead].iter().cloned().collect::<BTreeSet<_>>()
497 );
498 }
499
500 #[test]
501 fn merge_parent_deny_overrides_intersection_allow() {
502 let parent = cap_set(&[Capability::FileRead, Capability::FileWrite], &[Capability::FileRead]);
503 let child = cap_set(&[Capability::FileRead], &[]);
504 let result = super::merge_capabilities(&parent, &child);
505 assert!(
506 result.allow.is_empty(),
507 "FileRead was denied by parent, should be absent from allow"
508 );
509 assert!(result.deny.contains(&Capability::FileRead));
510 }
511
512 #[test]
513 fn merge_both_empty_returns_empty() {
514 let parent = CapabilitySet::default();
515 let child = CapabilitySet::default();
516 let result = super::merge_capabilities(&parent, &child);
517 assert_eq!(result, CapabilitySet::default());
518 }
519
520 #[test]
525 fn action_to_capability_tool_call() {
526 let action = crate::GovernanceAction::ToolCall {
527 name: "bash".to_string(),
528 args: "{}".to_string(),
529 };
530 assert_eq!(
531 super::action_to_capability(&action),
532 Some(Capability::McpTool("bash".to_string()))
533 );
534 }
535
536 #[test]
537 fn action_to_capability_file_read() {
538 let action = crate::GovernanceAction::FileAccess {
539 path: "/tmp/f".to_string(),
540 mode: crate::policy::FileMode::Read,
541 };
542 assert_eq!(super::action_to_capability(&action), Some(Capability::FileRead));
543 }
544
545 #[test]
546 fn action_to_capability_file_write() {
547 let action = crate::GovernanceAction::FileAccess {
548 path: "/tmp/f".to_string(),
549 mode: crate::policy::FileMode::Write,
550 };
551 assert_eq!(super::action_to_capability(&action), Some(Capability::FileWrite));
552 }
553
554 #[test]
555 fn action_to_capability_file_append_is_file_write() {
556 let action = crate::GovernanceAction::FileAccess {
557 path: "/tmp/f".to_string(),
558 mode: crate::policy::FileMode::Append,
559 };
560 assert_eq!(super::action_to_capability(&action), Some(Capability::FileWrite));
561 }
562
563 #[test]
564 fn action_to_capability_file_delete_is_file_delete() {
565 let action = crate::GovernanceAction::FileAccess {
567 path: "/tmp/f".to_string(),
568 mode: crate::policy::FileMode::Delete,
569 };
570 assert_eq!(super::action_to_capability(&action), Some(Capability::FileDelete));
571 }
572
573 #[test]
574 fn action_to_capability_network_request() {
575 let action = crate::GovernanceAction::NetworkRequest {
576 url: "https://example.com".to_string(),
577 method: "GET".to_string(),
578 };
579 assert_eq!(super::action_to_capability(&action), Some(Capability::NetworkOutbound));
580 }
581
582 #[test]
587 fn is_enforceable_false_for_inert_capabilities() {
588 assert!(!Capability::NetworkInbound.is_enforceable());
591 assert!(!Capability::AgentSpawn.is_enforceable());
592 assert!(!Capability::Model("gpt-4o".to_string()).is_enforceable());
593 }
594
595 #[test]
596 fn is_enforceable_true_for_action_backed_capabilities() {
597 assert!(Capability::FileRead.is_enforceable());
598 assert!(Capability::FileWrite.is_enforceable());
599 assert!(Capability::NetworkOutbound.is_enforceable());
600 assert!(Capability::TerminalExec.is_enforceable());
601 assert!(Capability::McpTool("bash".to_string()).is_enforceable());
602 }
603
604 #[test]
605 fn action_to_capability_only_yields_enforceable_capabilities() {
606 let actions = [
609 crate::GovernanceAction::ToolCall {
610 name: "bash".to_string(),
611 args: "{}".to_string(),
612 },
613 crate::GovernanceAction::FileAccess {
614 path: "/tmp/f".to_string(),
615 mode: crate::policy::FileMode::Read,
616 },
617 crate::GovernanceAction::FileAccess {
618 path: "/tmp/f".to_string(),
619 mode: crate::policy::FileMode::Write,
620 },
621 crate::GovernanceAction::NetworkRequest {
622 url: "https://example.com".to_string(),
623 method: "GET".to_string(),
624 },
625 crate::GovernanceAction::ProcessExec {
626 command: "ls".to_string(),
627 },
628 ];
629 for action in &actions {
630 if let Some(cap) = super::action_to_capability(action) {
631 assert!(
632 cap.is_enforceable(),
633 "action {action:?} mapped to inert capability {cap:?}"
634 );
635 }
636 }
637 }
638
639 #[test]
640 fn action_to_capability_process_exec() {
641 let action = crate::GovernanceAction::ProcessExec {
642 command: "ls".to_string(),
643 };
644 assert_eq!(super::action_to_capability(&action), Some(Capability::TerminalExec));
645 }
646
647 fn deny_set(caps: &[Capability]) -> BTreeSet<Capability> {
652 caps.iter().cloned().collect()
653 }
654
655 #[test]
656 fn capability_is_denied_direct_match() {
657 let deny = deny_set(&[Capability::FileDelete]);
658 assert!(super::capability_is_denied(&deny, &Capability::FileDelete));
659 }
660
661 #[test]
662 fn capability_is_denied_empty_set_is_false() {
663 let deny = deny_set(&[]);
664 assert!(!super::capability_is_denied(&deny, &Capability::FileDelete));
665 }
666
667 #[test]
668 fn capability_is_denied_file_write_deny_also_denies_delete() {
669 let deny = deny_set(&[Capability::FileWrite]);
671 assert!(super::capability_is_denied(&deny, &Capability::FileDelete));
672 }
673
674 #[test]
675 fn capability_is_denied_file_delete_deny_does_not_deny_write() {
676 let deny = deny_set(&[Capability::FileDelete]);
678 assert!(!super::capability_is_denied(&deny, &Capability::FileWrite));
679 }
680
681 #[test]
682 fn capability_is_denied_unrelated_deny_is_false() {
683 let deny = deny_set(&[Capability::NetworkOutbound]);
684 assert!(!super::capability_is_denied(&deny, &Capability::FileDelete));
685 }
686
687 #[test]
692 fn allow_is_restricted_true_when_allow_non_empty() {
693 let cs = cap_set(&[Capability::FileRead], &[]);
695 assert!(cs.allow_is_restricted());
696 }
697
698 #[test]
699 fn allow_is_restricted_false_when_no_allow_declared() {
700 let cs = cap_set(&[], &[Capability::TerminalExec]);
702 assert!(!cs.allow_is_restricted());
703 }
704
705 #[test]
706 fn allow_is_restricted_true_when_flag_set_but_allow_empty() {
707 let cs = CapabilitySet {
709 allow: BTreeSet::new(),
710 deny: BTreeSet::new(),
711 allow_restricted: true,
712 };
713 assert!(cs.allow_is_restricted());
714 }
715
716 #[test]
717 fn merge_disjoint_allow_lists_stays_restricted_with_empty_allow() {
718 let parent = cap_set(&[Capability::FileRead], &[]);
722 let child = cap_set(&[Capability::FileWrite], &[]);
723 let result = super::merge_capabilities(&parent, &child);
724 assert!(result.allow.is_empty(), "disjoint allow-lists intersect to empty");
725 assert!(result.allow_restricted, "restriction must persist across the collapse");
726 assert!(
727 result.allow_is_restricted(),
728 "guard must treat the collapsed set as restricted (deny-all)"
729 );
730 }
731
732 #[test]
733 fn merge_single_tier_allow_is_restricted() {
734 let parent = CapabilitySet::default();
736 let child = cap_set(&[Capability::FileRead], &[]);
737 let result = super::merge_capabilities(&parent, &child);
738 assert!(result.allow.contains(&Capability::FileRead));
739 assert!(result.allow_restricted);
740 }
741
742 #[test]
743 fn merge_no_allow_declared_is_unrestricted() {
744 let parent = cap_set(&[], &[Capability::FileWrite]);
746 let child = cap_set(&[], &[Capability::TerminalExec]);
747 let result = super::merge_capabilities(&parent, &child);
748 assert!(result.allow.is_empty());
749 assert!(!result.allow_restricted);
750 assert!(!result.allow_is_restricted());
751 }
752}