1use serde::{Deserialize, Serialize};
7use std::collections::HashSet;
8
9#[derive(Debug, Clone, Serialize, Deserialize)]
13pub struct AgentCapabilities {
14 #[serde(default = "default_capability_id")]
16 pub capability_id: String,
17
18 #[serde(default)]
20 pub filesystem: FilesystemCapabilities,
21
22 #[serde(default)]
24 pub tools: ToolCapabilities,
25
26 #[serde(default)]
28 pub network: NetworkCapabilities,
29
30 #[serde(default)]
32 pub spawning: SpawningCapabilities,
33
34 #[serde(default)]
36 pub git: GitCapabilities,
37
38 #[serde(default)]
40 pub quotas: ResourceQuotas,
41}
42
43fn default_capability_id() -> String {
44 uuid::Uuid::new_v4().to_string()
45}
46
47impl Default for AgentCapabilities {
48 fn default() -> Self {
49 Self {
50 capability_id: default_capability_id(),
51 filesystem: FilesystemCapabilities::default(),
52 tools: ToolCapabilities::default(),
53 network: NetworkCapabilities::default(),
54 spawning: SpawningCapabilities::default(),
55 git: GitCapabilities::default(),
56 quotas: ResourceQuotas::default(),
57 }
58 }
59}
60
61impl AgentCapabilities {
62 pub fn allows_tool(&self, tool_name: &str) -> bool {
64 if self.tools.denied_tools.contains(tool_name) {
66 return false;
67 }
68
69 if let Some(ref allowed) = self.tools.allowed_tools {
71 return allowed.contains(tool_name);
72 }
73
74 let category = Self::categorize_tool(tool_name);
76 self.tools.allowed_categories.contains(&category)
77 }
78
79 pub fn requires_approval(&self, tool_name: &str) -> bool {
81 self.tools.always_approve.contains(tool_name)
82 }
83
84 pub fn categorize_tool(tool_name: &str) -> ToolCategory {
86 match tool_name {
87 "read_file" | "list_directory" | "search_files" => ToolCategory::FileRead,
89
90 "write_file" | "edit_file" | "patch_file" | "delete_file" | "create_directory" => {
92 ToolCategory::FileWrite
93 }
94
95 "search_code"
97 | "index_codebase"
98 | "query_codebase"
99 | "search_with_filters"
100 | "get_rag_statistics"
101 | "clear_rag_index"
102 | "search_git_history" => ToolCategory::Search,
103
104 name if name.starts_with("git_") => {
106 if name.contains("force")
107 || name.contains("reset")
108 || name.contains("rebase")
109 || name.contains("delete_branch")
110 {
111 ToolCategory::GitDestructive
112 } else {
113 ToolCategory::Git
114 }
115 }
116
117 "execute_command" => ToolCategory::Bash,
119
120 "fetch_url" | "web_search" | "web_browse" | "web_scrape" => ToolCategory::Web,
122
123 "execute_code" | "execute_script" => ToolCategory::CodeExecution,
125
126 "agent_spawn" | "agent_stop" | "agent_status" | "agent_list" | "agent_pool_stats"
128 | "agent_file_locks" => ToolCategory::AgentSpawn,
129
130 "plan_task" | "task_create" | "task_add_subtask" | "task_start" | "task_complete"
132 | "task_fail" | "task_list" | "task_get" => ToolCategory::Planning,
133
134 name if name.starts_with("mcp_") => ToolCategory::System,
136
137 "recall_context" | "search_tools" => ToolCategory::Search,
139
140 _ => ToolCategory::System,
142 }
143 }
144
145 pub fn allows_read(&self, path: &str) -> bool {
147 for denied in &self.filesystem.denied_paths {
149 if denied.matches(path) {
150 return false;
151 }
152 }
153
154 for allowed in &self.filesystem.read_paths {
156 if allowed.matches(path) {
157 return true;
158 }
159 }
160
161 false
162 }
163
164 pub fn allows_write(&self, path: &str) -> bool {
166 for denied in &self.filesystem.denied_paths {
168 if denied.matches(path) {
169 return false;
170 }
171 }
172
173 for allowed in &self.filesystem.write_paths {
175 if allowed.matches(path) {
176 return true;
177 }
178 }
179
180 false
181 }
182
183 pub fn allows_domain(&self, domain: &str) -> bool {
185 for denied in &self.network.denied_domains {
187 if Self::domain_matches(denied, domain) {
188 return false;
189 }
190 }
191
192 if self.network.allow_all {
194 return true;
195 }
196
197 for allowed in &self.network.allowed_domains {
199 if Self::domain_matches(allowed, domain) {
200 return true;
201 }
202 }
203
204 false
205 }
206
207 pub fn allows_git_op(&self, op: GitOperation) -> bool {
209 if op.is_destructive() && !self.git.can_destructive {
211 return false;
212 }
213
214 if op == GitOperation::ForcePush && !self.git.can_force_push {
216 return false;
217 }
218
219 self.git.allowed_ops.contains(&op)
220 }
221
222 pub fn can_spawn_agent(&self, current_children: u32, current_depth: u32) -> bool {
224 if !self.spawning.can_spawn {
225 return false;
226 }
227
228 if current_children >= self.spawning.max_children {
229 return false;
230 }
231
232 if current_depth >= self.spawning.max_depth {
233 return false;
234 }
235
236 true
237 }
238
239 fn domain_matches(pattern: &str, domain: &str) -> bool {
241 if pattern.starts_with("*.") {
242 let suffix = &pattern[1..]; domain.ends_with(suffix) || domain == &pattern[2..]
244 } else {
245 pattern == domain
246 }
247 }
248}
249
250#[derive(Debug, Clone, Copy, PartialEq, Eq)]
254pub enum CapabilityProfile {
255 ReadOnly,
257 StandardDev,
259 FullAccess,
261 Custom,
263}
264
265impl CapabilityProfile {
266 pub fn parse(s: &str) -> Option<Self> {
268 match s.to_lowercase().as_str() {
269 "read_only" | "readonly" | "read-only" => Some(Self::ReadOnly),
270 "standard_dev" | "standarddev" | "standard-dev" | "standard" => Some(Self::StandardDev),
271 "full_access" | "fullaccess" | "full-access" | "full" => Some(Self::FullAccess),
272 "custom" => Some(Self::Custom),
273 _ => None,
274 }
275 }
276
277 pub fn as_str(&self) -> &'static str {
279 match self {
280 Self::ReadOnly => "read_only",
281 Self::StandardDev => "standard_dev",
282 Self::FullAccess => "full_access",
283 Self::Custom => "custom",
284 }
285 }
286}
287
288impl AgentCapabilities {
289 pub fn read_only() -> Self {
299 Self {
300 capability_id: uuid::Uuid::new_v4().to_string(),
301 filesystem: FilesystemCapabilities {
302 read_paths: vec![PathPattern::new("**/*")],
303 write_paths: vec![],
304 denied_paths: vec![
305 PathPattern::new("**/.env*"),
306 PathPattern::new("**/*credentials*"),
307 PathPattern::new("**/*secret*"),
308 PathPattern::new("**/*.pem"),
309 PathPattern::new("**/*.key"),
310 ],
311 follow_symlinks: false,
312 access_hidden: false,
313 can_delete: false,
314 can_create_dirs: false,
315 max_write_size: None,
316 },
317 tools: ToolCapabilities {
318 allowed_categories: {
319 let mut cats = HashSet::new();
320 cats.insert(ToolCategory::FileRead);
321 cats.insert(ToolCategory::Search);
322 cats
323 },
324 denied_tools: HashSet::new(),
325 allowed_tools: None,
326 always_approve: HashSet::new(),
327 },
328 network: NetworkCapabilities::disabled(),
329 spawning: SpawningCapabilities::disabled(),
330 git: GitCapabilities::read_only(),
331 quotas: ResourceQuotas::conservative(),
332 }
333 }
334
335 pub fn standard_dev() -> Self {
345 Self {
346 capability_id: uuid::Uuid::new_v4().to_string(),
347 filesystem: FilesystemCapabilities {
348 read_paths: vec![PathPattern::new("**/*")],
349 write_paths: vec![
350 PathPattern::new("src/**"),
351 PathPattern::new("tests/**"),
352 PathPattern::new("docs/**"),
353 PathPattern::new("scripts/**"),
354 PathPattern::new("*.toml"),
355 PathPattern::new("*.json"),
356 PathPattern::new("*.yaml"),
357 PathPattern::new("*.yml"),
358 PathPattern::new("*.md"),
359 PathPattern::new("Makefile"),
360 PathPattern::new(".gitignore"),
361 ],
362 denied_paths: vec![
363 PathPattern::new("**/.env*"),
364 PathPattern::new("**/*credentials*"),
365 PathPattern::new("**/*secret*"),
366 PathPattern::new("**/node_modules/**"),
367 PathPattern::new("**/target/**"),
368 PathPattern::new("**/.git/**"),
369 ],
370 follow_symlinks: true,
371 access_hidden: true,
372 can_delete: true,
373 can_create_dirs: true,
374 max_write_size: Some(1024 * 1024), },
376 tools: ToolCapabilities {
377 allowed_categories: {
378 let mut cats = HashSet::new();
379 cats.insert(ToolCategory::FileRead);
380 cats.insert(ToolCategory::FileWrite);
381 cats.insert(ToolCategory::Search);
382 cats.insert(ToolCategory::Git);
383 cats.insert(ToolCategory::Planning);
384 cats.insert(ToolCategory::Web);
385 cats
386 },
387 denied_tools: {
388 let mut denied = HashSet::new();
389 denied.insert("execute_code".to_string());
390 denied
391 },
392 allowed_tools: None,
393 always_approve: {
394 let mut approve = HashSet::new();
395 approve.insert("delete_file".to_string());
396 approve.insert("execute_command".to_string());
397 approve
398 },
399 },
400 network: NetworkCapabilities {
401 allowed_domains: vec![
402 "github.com".to_string(),
403 "*.github.com".to_string(),
404 "docs.rs".to_string(),
405 "crates.io".to_string(),
406 "npmjs.com".to_string(),
407 "*.npmjs.com".to_string(),
408 "pypi.org".to_string(),
409 "stackoverflow.com".to_string(),
410 ],
411 denied_domains: vec![],
412 allow_all: false,
413 rate_limit: Some(60),
414 allow_api_calls: true,
415 max_response_size: Some(10 * 1024 * 1024), },
417 spawning: SpawningCapabilities {
418 can_spawn: true,
419 max_children: 3,
420 max_depth: 2,
421 can_elevate: false,
422 },
423 git: GitCapabilities::standard(),
424 quotas: ResourceQuotas::standard(),
425 }
426 }
427
428 pub fn full_access() -> Self {
437 Self {
438 capability_id: uuid::Uuid::new_v4().to_string(),
439 filesystem: FilesystemCapabilities::full(),
440 tools: ToolCapabilities::full(),
441 network: NetworkCapabilities::full(),
442 spawning: SpawningCapabilities::full(),
443 git: GitCapabilities::full(),
444 quotas: ResourceQuotas::generous(),
445 }
446 }
447
448 pub fn from_profile(profile: CapabilityProfile) -> Self {
450 match profile {
451 CapabilityProfile::ReadOnly => Self::read_only(),
452 CapabilityProfile::StandardDev => Self::standard_dev(),
453 CapabilityProfile::FullAccess => Self::full_access(),
454 CapabilityProfile::Custom => Self::default(),
455 }
456 }
457
458 pub fn derive_child(&self) -> Self {
462 let mut child = self.clone();
464 child.capability_id = uuid::Uuid::new_v4().to_string();
465
466 if child.spawning.max_depth > 0 {
468 child.spawning.max_depth -= 1;
469 }
470
471 child.spawning.can_elevate = false;
473
474 child
475 }
476
477 pub fn intersect(&self, other: &Self) -> Self {
479 Self {
480 capability_id: uuid::Uuid::new_v4().to_string(),
481 filesystem: FilesystemCapabilities {
482 read_paths: self
484 .filesystem
485 .read_paths
486 .iter()
487 .filter(|p| {
488 other
489 .filesystem
490 .read_paths
491 .iter()
492 .any(|op| op.pattern() == p.pattern())
493 })
494 .cloned()
495 .collect(),
496 write_paths: self
497 .filesystem
498 .write_paths
499 .iter()
500 .filter(|p| {
501 other
502 .filesystem
503 .write_paths
504 .iter()
505 .any(|op| op.pattern() == p.pattern())
506 })
507 .cloned()
508 .collect(),
509 denied_paths: {
511 let mut denied = self.filesystem.denied_paths.clone();
512 for p in &other.filesystem.denied_paths {
513 if !denied.iter().any(|dp| dp.pattern() == p.pattern()) {
514 denied.push(p.clone());
515 }
516 }
517 denied
518 },
519 follow_symlinks: self.filesystem.follow_symlinks
520 && other.filesystem.follow_symlinks,
521 access_hidden: self.filesystem.access_hidden && other.filesystem.access_hidden,
522 can_delete: self.filesystem.can_delete && other.filesystem.can_delete,
523 can_create_dirs: self.filesystem.can_create_dirs
524 && other.filesystem.can_create_dirs,
525 max_write_size: match (
526 self.filesystem.max_write_size,
527 other.filesystem.max_write_size,
528 ) {
529 (Some(a), Some(b)) => Some(a.min(b)),
530 (Some(a), None) => Some(a),
531 (None, Some(b)) => Some(b),
532 (None, None) => None,
533 },
534 },
535 tools: ToolCapabilities {
536 allowed_categories: self
538 .tools
539 .allowed_categories
540 .intersection(&other.tools.allowed_categories)
541 .cloned()
542 .collect(),
543 denied_tools: self
545 .tools
546 .denied_tools
547 .union(&other.tools.denied_tools)
548 .cloned()
549 .collect(),
550 allowed_tools: match (&self.tools.allowed_tools, &other.tools.allowed_tools) {
551 (Some(a), Some(b)) => Some(a.intersection(b).cloned().collect()),
552 (Some(a), None) => Some(a.clone()),
553 (None, Some(b)) => Some(b.clone()),
554 (None, None) => None,
555 },
556 always_approve: self
558 .tools
559 .always_approve
560 .union(&other.tools.always_approve)
561 .cloned()
562 .collect(),
563 },
564 network: NetworkCapabilities {
565 allowed_domains: self
566 .network
567 .allowed_domains
568 .iter()
569 .filter(|d| {
570 other.network.allowed_domains.contains(d) || other.network.allow_all
571 })
572 .cloned()
573 .collect(),
574 denied_domains: {
575 let mut denied = self.network.denied_domains.clone();
576 denied.extend(other.network.denied_domains.iter().cloned());
577 denied.sort();
578 denied.dedup();
579 denied
580 },
581 allow_all: self.network.allow_all && other.network.allow_all,
582 rate_limit: match (self.network.rate_limit, other.network.rate_limit) {
583 (Some(a), Some(b)) => Some(a.min(b)),
584 (Some(a), None) => Some(a),
585 (None, Some(b)) => Some(b),
586 (None, None) => None,
587 },
588 allow_api_calls: self.network.allow_api_calls && other.network.allow_api_calls,
589 max_response_size: match (
590 self.network.max_response_size,
591 other.network.max_response_size,
592 ) {
593 (Some(a), Some(b)) => Some(a.min(b)),
594 (Some(a), None) => Some(a),
595 (None, Some(b)) => Some(b),
596 (None, None) => None,
597 },
598 },
599 spawning: SpawningCapabilities {
600 can_spawn: self.spawning.can_spawn && other.spawning.can_spawn,
601 max_children: self.spawning.max_children.min(other.spawning.max_children),
602 max_depth: self.spawning.max_depth.min(other.spawning.max_depth),
603 can_elevate: self.spawning.can_elevate && other.spawning.can_elevate,
604 },
605 git: GitCapabilities {
606 allowed_ops: self
607 .git
608 .allowed_ops
609 .intersection(&other.git.allowed_ops)
610 .cloned()
611 .collect(),
612 protected_branches: {
613 let mut branches = self.git.protected_branches.clone();
614 branches.extend(other.git.protected_branches.iter().cloned());
615 branches.sort();
616 branches.dedup();
617 branches
618 },
619 can_force_push: self.git.can_force_push && other.git.can_force_push,
620 can_destructive: self.git.can_destructive && other.git.can_destructive,
621 require_pr_branches: {
622 let mut branches = self.git.require_pr_branches.clone();
623 branches.extend(other.git.require_pr_branches.iter().cloned());
624 branches.sort();
625 branches.dedup();
626 branches
627 },
628 },
629 quotas: ResourceQuotas {
630 max_execution_time: match (
631 self.quotas.max_execution_time,
632 other.quotas.max_execution_time,
633 ) {
634 (Some(a), Some(b)) => Some(a.min(b)),
635 (Some(a), None) => Some(a),
636 (None, Some(b)) => Some(b),
637 (None, None) => None,
638 },
639 max_memory: match (self.quotas.max_memory, other.quotas.max_memory) {
640 (Some(a), Some(b)) => Some(a.min(b)),
641 (Some(a), None) => Some(a),
642 (None, Some(b)) => Some(b),
643 (None, None) => None,
644 },
645 max_tokens: match (self.quotas.max_tokens, other.quotas.max_tokens) {
646 (Some(a), Some(b)) => Some(a.min(b)),
647 (Some(a), None) => Some(a),
648 (None, Some(b)) => Some(b),
649 (None, None) => None,
650 },
651 max_tool_calls: match (self.quotas.max_tool_calls, other.quotas.max_tool_calls) {
652 (Some(a), Some(b)) => Some(a.min(b)),
653 (Some(a), None) => Some(a),
654 (None, Some(b)) => Some(b),
655 (None, None) => None,
656 },
657 max_files_modified: match (
658 self.quotas.max_files_modified,
659 other.quotas.max_files_modified,
660 ) {
661 (Some(a), Some(b)) => Some(a.min(b)),
662 (Some(a), None) => Some(a),
663 (None, Some(b)) => Some(b),
664 (None, None) => None,
665 },
666 },
667 }
668 }
669}
670
671#[derive(Debug, Clone, Serialize, Deserialize)]
675pub struct FilesystemCapabilities {
676 #[serde(default = "default_read_paths")]
678 pub read_paths: Vec<PathPattern>,
679
680 #[serde(default)]
682 pub write_paths: Vec<PathPattern>,
683
684 #[serde(default = "default_denied_paths")]
686 pub denied_paths: Vec<PathPattern>,
687
688 #[serde(default = "default_true")]
690 pub follow_symlinks: bool,
691
692 #[serde(default = "default_true")]
694 pub access_hidden: bool,
695
696 #[serde(default)]
698 pub max_write_size: Option<u64>,
699
700 #[serde(default)]
702 pub can_delete: bool,
703
704 #[serde(default = "default_true")]
706 pub can_create_dirs: bool,
707}
708
709fn default_read_paths() -> Vec<PathPattern> {
710 vec![PathPattern::new("**/*")]
711}
712
713fn default_denied_paths() -> Vec<PathPattern> {
714 vec![
715 PathPattern::new("**/.env*"),
716 PathPattern::new("**/*credentials*"),
717 PathPattern::new("**/*secret*"),
718 ]
719}
720
721fn default_true() -> bool {
722 true
723}
724
725impl Default for FilesystemCapabilities {
726 fn default() -> Self {
727 Self {
728 read_paths: default_read_paths(),
729 write_paths: Vec::new(),
730 denied_paths: default_denied_paths(),
731 follow_symlinks: true,
732 access_hidden: true,
733 max_write_size: None,
734 can_delete: false,
735 can_create_dirs: true,
736 }
737 }
738}
739
740impl FilesystemCapabilities {
741 pub fn full() -> Self {
743 Self {
744 read_paths: vec![PathPattern::new("**/*")],
745 write_paths: vec![PathPattern::new("**/*")],
746 denied_paths: Vec::new(),
747 follow_symlinks: true,
748 access_hidden: true,
749 max_write_size: None,
750 can_delete: true,
751 can_create_dirs: true,
752 }
753 }
754}
755
756#[derive(Debug, Clone, Serialize, Deserialize)]
760pub struct ToolCapabilities {
761 #[serde(default = "default_allowed_categories")]
763 pub allowed_categories: HashSet<ToolCategory>,
764
765 #[serde(default)]
767 pub denied_tools: HashSet<String>,
768
769 #[serde(default)]
771 pub allowed_tools: Option<HashSet<String>>,
772
773 #[serde(default)]
775 pub always_approve: HashSet<String>,
776}
777
778fn default_allowed_categories() -> HashSet<ToolCategory> {
779 let mut set = HashSet::new();
780 set.insert(ToolCategory::FileRead);
781 set.insert(ToolCategory::Search);
782 set.insert(ToolCategory::Web);
783 set
784}
785
786impl Default for ToolCapabilities {
787 fn default() -> Self {
788 Self {
789 allowed_categories: default_allowed_categories(),
790 denied_tools: HashSet::new(),
791 allowed_tools: None,
792 always_approve: HashSet::new(),
793 }
794 }
795}
796
797impl ToolCapabilities {
798 pub fn full() -> Self {
800 let mut categories = HashSet::new();
801 categories.insert(ToolCategory::FileRead);
802 categories.insert(ToolCategory::FileWrite);
803 categories.insert(ToolCategory::Search);
804 categories.insert(ToolCategory::Git);
805 categories.insert(ToolCategory::GitDestructive);
806 categories.insert(ToolCategory::Bash);
807 categories.insert(ToolCategory::Web);
808 categories.insert(ToolCategory::CodeExecution);
809 categories.insert(ToolCategory::AgentSpawn);
810 categories.insert(ToolCategory::Planning);
811 categories.insert(ToolCategory::System);
812
813 Self {
814 allowed_categories: categories,
815 denied_tools: HashSet::new(),
816 allowed_tools: None,
817 always_approve: HashSet::new(),
818 }
819 }
820}
821
822#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
824pub enum ToolCategory {
825 FileRead,
827 FileWrite,
829 Search,
831 Git,
833 GitDestructive,
835 Bash,
837 Web,
839 CodeExecution,
841 AgentSpawn,
843 Planning,
845 System,
847}
848
849#[derive(Debug, Clone, Serialize, Deserialize)]
853pub struct NetworkCapabilities {
854 #[serde(default)]
856 pub allowed_domains: Vec<String>,
857
858 #[serde(default)]
860 pub denied_domains: Vec<String>,
861
862 #[serde(default)]
864 pub allow_all: bool,
865
866 #[serde(default)]
868 pub rate_limit: Option<u32>,
869
870 #[serde(default)]
872 pub allow_api_calls: bool,
873
874 #[serde(default)]
876 pub max_response_size: Option<u64>,
877}
878
879impl Default for NetworkCapabilities {
880 fn default() -> Self {
881 Self {
882 allowed_domains: Vec::new(),
883 denied_domains: Vec::new(),
884 allow_all: false,
885 rate_limit: Some(60),
886 allow_api_calls: false,
887 max_response_size: Some(10 * 1024 * 1024), }
889 }
890}
891
892impl NetworkCapabilities {
893 pub fn disabled() -> Self {
895 Self {
896 allowed_domains: Vec::new(),
897 denied_domains: Vec::new(),
898 allow_all: false,
899 rate_limit: Some(0),
900 allow_api_calls: false,
901 max_response_size: None,
902 }
903 }
904
905 pub fn full() -> Self {
907 Self {
908 allowed_domains: Vec::new(),
909 denied_domains: Vec::new(),
910 allow_all: true,
911 rate_limit: None,
912 allow_api_calls: true,
913 max_response_size: None,
914 }
915 }
916}
917
918#[derive(Debug, Clone, Serialize, Deserialize)]
922pub struct SpawningCapabilities {
923 #[serde(default)]
925 pub can_spawn: bool,
926
927 #[serde(default = "default_max_children")]
929 pub max_children: u32,
930
931 #[serde(default = "default_max_depth")]
933 pub max_depth: u32,
934
935 #[serde(default)]
937 pub can_elevate: bool,
938}
939
940fn default_max_children() -> u32 {
941 3
942}
943
944fn default_max_depth() -> u32 {
945 2
946}
947
948impl Default for SpawningCapabilities {
949 fn default() -> Self {
950 Self {
951 can_spawn: false,
952 max_children: 3,
953 max_depth: 2,
954 can_elevate: false,
955 }
956 }
957}
958
959impl SpawningCapabilities {
960 pub fn disabled() -> Self {
962 Self {
963 can_spawn: false,
964 max_children: 0,
965 max_depth: 0,
966 can_elevate: false,
967 }
968 }
969
970 pub fn full() -> Self {
972 Self {
973 can_spawn: true,
974 max_children: 10,
975 max_depth: 5,
976 can_elevate: true,
977 }
978 }
979}
980
981#[derive(Debug, Clone, Serialize, Deserialize)]
985pub struct GitCapabilities {
986 #[serde(default = "default_git_ops")]
988 pub allowed_ops: HashSet<GitOperation>,
989
990 #[serde(default)]
992 pub protected_branches: Vec<String>,
993
994 #[serde(default)]
996 pub can_force_push: bool,
997
998 #[serde(default)]
1000 pub can_destructive: bool,
1001
1002 #[serde(default)]
1004 pub require_pr_branches: Vec<String>,
1005}
1006
1007fn default_git_ops() -> HashSet<GitOperation> {
1008 let mut ops = HashSet::new();
1009 ops.insert(GitOperation::Status);
1010 ops.insert(GitOperation::Diff);
1011 ops.insert(GitOperation::Log);
1012 ops
1013}
1014
1015impl Default for GitCapabilities {
1016 fn default() -> Self {
1017 Self {
1018 allowed_ops: default_git_ops(),
1019 protected_branches: vec!["main".to_string(), "master".to_string()],
1020 can_force_push: false,
1021 can_destructive: false,
1022 require_pr_branches: Vec::new(),
1023 }
1024 }
1025}
1026
1027impl GitCapabilities {
1028 pub fn read_only() -> Self {
1030 let mut ops = HashSet::new();
1031 ops.insert(GitOperation::Status);
1032 ops.insert(GitOperation::Diff);
1033 ops.insert(GitOperation::Log);
1034 ops.insert(GitOperation::Fetch);
1035
1036 Self {
1037 allowed_ops: ops,
1038 protected_branches: vec!["main".to_string(), "master".to_string()],
1039 can_force_push: false,
1040 can_destructive: false,
1041 require_pr_branches: Vec::new(),
1042 }
1043 }
1044
1045 pub fn standard() -> Self {
1047 let mut ops = HashSet::new();
1048 ops.insert(GitOperation::Status);
1049 ops.insert(GitOperation::Diff);
1050 ops.insert(GitOperation::Log);
1051 ops.insert(GitOperation::Add);
1052 ops.insert(GitOperation::Commit);
1053 ops.insert(GitOperation::Push);
1054 ops.insert(GitOperation::Pull);
1055 ops.insert(GitOperation::Fetch);
1056 ops.insert(GitOperation::Branch);
1057 ops.insert(GitOperation::Checkout);
1058 ops.insert(GitOperation::Stash);
1059
1060 Self {
1061 allowed_ops: ops,
1062 protected_branches: vec!["main".to_string(), "master".to_string()],
1063 can_force_push: false,
1064 can_destructive: false,
1065 require_pr_branches: Vec::new(),
1066 }
1067 }
1068
1069 pub fn full() -> Self {
1071 let mut ops = HashSet::new();
1072 ops.insert(GitOperation::Status);
1073 ops.insert(GitOperation::Diff);
1074 ops.insert(GitOperation::Log);
1075 ops.insert(GitOperation::Add);
1076 ops.insert(GitOperation::Commit);
1077 ops.insert(GitOperation::Push);
1078 ops.insert(GitOperation::Pull);
1079 ops.insert(GitOperation::Fetch);
1080 ops.insert(GitOperation::Branch);
1081 ops.insert(GitOperation::Checkout);
1082 ops.insert(GitOperation::Merge);
1083 ops.insert(GitOperation::Rebase);
1084 ops.insert(GitOperation::Reset);
1085 ops.insert(GitOperation::Stash);
1086 ops.insert(GitOperation::Tag);
1087 ops.insert(GitOperation::ForcePush);
1088
1089 Self {
1090 allowed_ops: ops,
1091 protected_branches: Vec::new(),
1092 can_force_push: true,
1093 can_destructive: true,
1094 require_pr_branches: Vec::new(),
1095 }
1096 }
1097}
1098
1099#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
1101pub enum GitOperation {
1102 Status,
1104 Diff,
1106 Log,
1108 Add,
1110 Commit,
1112 Push,
1114 Pull,
1116 Fetch,
1118 Branch,
1120 Checkout,
1122 Merge,
1124 Rebase,
1126 Reset,
1128 Stash,
1130 Tag,
1132 ForcePush,
1134}
1135
1136impl GitOperation {
1137 pub fn is_destructive(&self) -> bool {
1139 matches!(
1140 self,
1141 GitOperation::Rebase
1142 | GitOperation::Reset
1143 | GitOperation::ForcePush
1144 | GitOperation::Merge
1145 )
1146 }
1147}
1148
1149#[derive(Debug, Clone, Serialize, Deserialize)]
1153pub struct ResourceQuotas {
1154 #[serde(default)]
1156 pub max_execution_time: Option<u64>,
1157
1158 #[serde(default)]
1160 pub max_memory: Option<u64>,
1161
1162 #[serde(default)]
1164 pub max_tokens: Option<u64>,
1165
1166 #[serde(default)]
1168 pub max_tool_calls: Option<u32>,
1169
1170 #[serde(default)]
1172 pub max_files_modified: Option<u32>,
1173}
1174
1175impl Default for ResourceQuotas {
1176 fn default() -> Self {
1177 Self {
1178 max_execution_time: Some(30 * 60), max_memory: None,
1180 max_tokens: Some(100_000),
1181 max_tool_calls: Some(500),
1182 max_files_modified: Some(50),
1183 }
1184 }
1185}
1186
1187impl ResourceQuotas {
1188 pub fn conservative() -> Self {
1190 Self {
1191 max_execution_time: Some(5 * 60), max_memory: Some(512 * 1024 * 1024), max_tokens: Some(10_000),
1194 max_tool_calls: Some(50),
1195 max_files_modified: Some(10),
1196 }
1197 }
1198
1199 pub fn standard() -> Self {
1201 Self::default()
1202 }
1203
1204 pub fn generous() -> Self {
1206 Self {
1207 max_execution_time: Some(2 * 60 * 60), max_memory: None,
1209 max_tokens: Some(500_000),
1210 max_tool_calls: Some(2000),
1211 max_files_modified: Some(200),
1212 }
1213 }
1214}
1215
1216#[derive(Debug, Clone, Serialize, Deserialize)]
1220#[serde(transparent)]
1221pub struct PathPattern {
1222 pattern: String,
1223}
1224
1225impl PathPattern {
1226 pub fn new(pattern: &str) -> Self {
1228 Self {
1229 pattern: pattern.to_string(),
1230 }
1231 }
1232
1233 pub fn glob(pattern: &str) -> Self {
1235 Self::new(pattern)
1236 }
1237
1238 #[cfg(feature = "native")]
1240 pub fn matches(&self, path: &str) -> bool {
1241 if let Ok(pattern) = glob::Pattern::new(&self.pattern) {
1243 pattern.matches(path) || pattern.matches_path(std::path::Path::new(path))
1244 } else {
1245 path.contains(&self.pattern)
1247 }
1248 }
1249
1250 #[cfg(not(feature = "native"))]
1252 pub fn matches(&self, path: &str) -> bool {
1253 path.contains(&self.pattern)
1254 }
1255
1256 pub fn pattern(&self) -> &str {
1258 &self.pattern
1259 }
1260}
1261
1262#[cfg(test)]
1265mod tests {
1266 use super::*;
1267
1268 #[test]
1269 fn test_path_pattern_matching() {
1270 let pattern = PathPattern::new("**/.env*");
1271 assert!(pattern.matches(".env"));
1272 assert!(pattern.matches(".env.local"));
1273 assert!(pattern.matches("config/.env"));
1274
1275 let pattern = PathPattern::new("src/**/*.rs");
1276 assert!(pattern.matches("src/main.rs"));
1277 assert!(pattern.matches("src/lib/mod.rs"));
1278 }
1279
1280 #[test]
1281 fn test_full_access_pattern() {
1282 let pattern = PathPattern::new("**/*");
1283 assert!(
1284 pattern.matches("index.html"),
1285 "**/* should match root files"
1286 );
1287 assert!(pattern.matches("./index.html"), "**/* should match ./file");
1288 assert!(
1289 pattern.matches("src/main.rs"),
1290 "**/* should match nested files"
1291 );
1292 }
1293
1294 #[test]
1295 fn test_tool_categorization() {
1296 assert_eq!(
1297 AgentCapabilities::categorize_tool("read_file"),
1298 ToolCategory::FileRead
1299 );
1300 assert_eq!(
1301 AgentCapabilities::categorize_tool("write_file"),
1302 ToolCategory::FileWrite
1303 );
1304 assert_eq!(
1305 AgentCapabilities::categorize_tool("git_status"),
1306 ToolCategory::Git
1307 );
1308 assert_eq!(
1309 AgentCapabilities::categorize_tool("git_force_push"),
1310 ToolCategory::GitDestructive
1311 );
1312 assert_eq!(
1313 AgentCapabilities::categorize_tool("execute_command"),
1314 ToolCategory::Bash
1315 );
1316 }
1317
1318 #[test]
1319 fn test_allows_tool() {
1320 let caps = AgentCapabilities::default();
1321
1322 assert!(caps.allows_tool("read_file"));
1324 assert!(caps.allows_tool("search_code"));
1325 assert!(!caps.allows_tool("write_file"));
1326 assert!(!caps.allows_tool("execute_command"));
1327 }
1328
1329 #[test]
1330 fn test_denied_tools() {
1331 let mut caps = AgentCapabilities::default();
1332 caps.tools.denied_tools.insert("read_file".to_string());
1333
1334 assert!(!caps.allows_tool("read_file"));
1336 assert!(caps.allows_tool("list_directory")); }
1338
1339 #[test]
1340 fn test_domain_matching() {
1341 let caps = AgentCapabilities {
1342 network: NetworkCapabilities {
1343 allowed_domains: vec!["github.com".to_string(), "*.github.com".to_string()],
1344 ..Default::default()
1345 },
1346 ..Default::default()
1347 };
1348
1349 assert!(caps.allows_domain("github.com"));
1350 assert!(caps.allows_domain("api.github.com"));
1351 assert!(caps.allows_domain("raw.github.com"));
1352 assert!(!caps.allows_domain("gitlab.com"));
1353 }
1354
1355 #[test]
1356 fn test_git_operations() {
1357 let caps = AgentCapabilities::default();
1358
1359 assert!(caps.allows_git_op(GitOperation::Status));
1361 assert!(caps.allows_git_op(GitOperation::Diff));
1362 assert!(!caps.allows_git_op(GitOperation::Push));
1363 assert!(!caps.allows_git_op(GitOperation::ForcePush));
1364 }
1365
1366 #[test]
1367 fn test_read_only_profile() {
1368 let caps = AgentCapabilities::read_only();
1369
1370 assert!(caps.allows_tool("read_file"));
1371 assert!(caps.allows_tool("search_code"));
1372 assert!(!caps.allows_tool("write_file"));
1373 assert!(!caps.allows_tool("execute_command"));
1374 assert!(!caps.allows_domain("github.com"));
1375 assert!(!caps.can_spawn_agent(0, 0));
1376 }
1377
1378 #[test]
1379 fn test_standard_dev_profile() {
1380 let caps = AgentCapabilities::standard_dev();
1381
1382 assert!(caps.allows_tool("read_file"));
1383 assert!(caps.allows_tool("write_file"));
1384 assert!(caps.allows_tool("git_status"));
1385 assert!(!caps.allows_tool("execute_code"));
1386 assert!(caps.requires_approval("delete_file"));
1387 assert!(caps.requires_approval("execute_command"));
1388 assert!(caps.allows_domain("github.com"));
1389 assert!(caps.allows_domain("api.github.com"));
1390 assert!(!caps.allows_domain("malware.com"));
1391 assert!(caps.can_spawn_agent(0, 0));
1392 assert!(caps.can_spawn_agent(2, 1));
1393 assert!(!caps.can_spawn_agent(3, 0));
1394 assert!(!caps.can_spawn_agent(0, 2));
1395 }
1396
1397 #[test]
1398 fn test_full_access_profile() {
1399 let caps = AgentCapabilities::full_access();
1400
1401 assert!(caps.allows_tool("read_file"));
1402 assert!(caps.allows_tool("write_file"));
1403 assert!(caps.allows_tool("execute_code"));
1404 assert!(caps.allows_tool("execute_command"));
1405 assert!(caps.allows_domain("any-domain.com"));
1406 assert!(caps.can_spawn_agent(9, 4));
1407 }
1408
1409 #[test]
1410 fn test_derive_child() {
1411 let parent = AgentCapabilities::standard_dev();
1412 let child = parent.derive_child();
1413
1414 assert_eq!(child.spawning.max_depth, parent.spawning.max_depth - 1);
1415 assert!(!child.spawning.can_elevate);
1416 assert_ne!(child.capability_id, parent.capability_id);
1417 }
1418
1419 #[test]
1420 fn test_capability_intersection() {
1421 let full = AgentCapabilities::full_access();
1422 let read_only = AgentCapabilities::read_only();
1423
1424 let intersected = full.intersect(&read_only);
1425
1426 assert!(intersected.allows_tool("read_file"));
1427 assert!(!intersected.allows_tool("write_file"));
1428 assert!(!intersected.can_spawn_agent(0, 0));
1429 }
1430
1431 #[test]
1432 fn test_profile_parsing() {
1433 assert_eq!(
1434 CapabilityProfile::parse("read_only"),
1435 Some(CapabilityProfile::ReadOnly)
1436 );
1437 assert_eq!(
1438 CapabilityProfile::parse("standard_dev"),
1439 Some(CapabilityProfile::StandardDev)
1440 );
1441 assert_eq!(
1442 CapabilityProfile::parse("full_access"),
1443 Some(CapabilityProfile::FullAccess)
1444 );
1445 assert_eq!(CapabilityProfile::parse("invalid"), None);
1446 }
1447}