Skip to main content

brainwires_permissions/
types.rs

1//! Core permission system types
2//!
3//! This module defines the capability-based permission model for agents,
4//! including filesystem, tool, network, spawning, git, and quota capabilities.
5
6use serde::{Deserialize, Serialize};
7use std::collections::HashSet;
8
9// ── Capability Types ─────────────────────────────────────────────────
10
11/// Agent capabilities - explicit permissions granted to an agent
12#[derive(Debug, Clone, Serialize, Deserialize)]
13pub struct AgentCapabilities {
14    /// Unique capability set ID for auditing
15    #[serde(default = "default_capability_id")]
16    pub capability_id: String,
17
18    /// File system capabilities
19    #[serde(default)]
20    pub filesystem: FilesystemCapabilities,
21
22    /// Tool execution capabilities
23    #[serde(default)]
24    pub tools: ToolCapabilities,
25
26    /// Network capabilities
27    #[serde(default)]
28    pub network: NetworkCapabilities,
29
30    /// Agent spawning capabilities
31    #[serde(default)]
32    pub spawning: SpawningCapabilities,
33
34    /// Git operation capabilities
35    #[serde(default)]
36    pub git: GitCapabilities,
37
38    /// Resource quota limits
39    #[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    /// Check if a tool is allowed by the current capabilities
63    pub fn allows_tool(&self, tool_name: &str) -> bool {
64        // Check explicit deny list first
65        if self.tools.denied_tools.contains(tool_name) {
66            return false;
67        }
68
69        // Check explicit allow list if specified
70        if let Some(ref allowed) = self.tools.allowed_tools {
71            return allowed.contains(tool_name);
72        }
73
74        // Fall back to category-based check
75        let category = Self::categorize_tool(tool_name);
76        self.tools.allowed_categories.contains(&category)
77    }
78
79    /// Check if a tool requires explicit approval
80    pub fn requires_approval(&self, tool_name: &str) -> bool {
81        self.tools.always_approve.contains(tool_name)
82    }
83
84    /// Categorize a tool by name into a ToolCategory
85    pub fn categorize_tool(tool_name: &str) -> ToolCategory {
86        match tool_name {
87            // File read operations
88            "read_file" | "list_directory" | "search_files" => ToolCategory::FileRead,
89
90            // File write operations
91            "write_file" | "edit_file" | "patch_file" | "delete_file" | "create_directory" => {
92                ToolCategory::FileWrite
93            }
94
95            // Search operations
96            "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            // Git operations - check for destructive operations first
105            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            // Bash/shell operations
118            "execute_command" => ToolCategory::Bash,
119
120            // Web operations
121            "fetch_url" | "web_search" | "web_browse" | "web_scrape" => ToolCategory::Web,
122
123            // Code execution
124            "execute_code" | "execute_script" => ToolCategory::CodeExecution,
125
126            // Agent operations
127            "agent_spawn" | "agent_stop" | "agent_status" | "agent_list" | "agent_pool_stats"
128            | "agent_file_locks" => ToolCategory::AgentSpawn,
129
130            // Planning/task operations
131            "plan_task" | "task_create" | "task_add_subtask" | "task_start" | "task_complete"
132            | "task_fail" | "task_list" | "task_get" => ToolCategory::Planning,
133
134            // MCP tools
135            name if name.starts_with("mcp_") => ToolCategory::System,
136
137            // Context operations
138            "recall_context" | "search_tools" => ToolCategory::Search,
139
140            // Default to System for unknown tools
141            _ => ToolCategory::System,
142        }
143    }
144
145    /// Check if a file path is allowed for reading
146    pub fn allows_read(&self, path: &str) -> bool {
147        // Check denied paths first
148        for denied in &self.filesystem.denied_paths {
149            if denied.matches(path) {
150                return false;
151            }
152        }
153
154        // Check if any read path matches
155        for allowed in &self.filesystem.read_paths {
156            if allowed.matches(path) {
157                return true;
158            }
159        }
160
161        false
162    }
163
164    /// Check if a file path is allowed for writing
165    pub fn allows_write(&self, path: &str) -> bool {
166        // Check denied paths first
167        for denied in &self.filesystem.denied_paths {
168            if denied.matches(path) {
169                return false;
170            }
171        }
172
173        // Check if any write path matches
174        for allowed in &self.filesystem.write_paths {
175            if allowed.matches(path) {
176                return true;
177            }
178        }
179
180        false
181    }
182
183    /// Check if a domain is allowed for network access
184    pub fn allows_domain(&self, domain: &str) -> bool {
185        // Check denied domains first
186        for denied in &self.network.denied_domains {
187            if Self::domain_matches(denied, domain) {
188                return false;
189            }
190        }
191
192        // If allow_all is set, allow everything not denied
193        if self.network.allow_all {
194            return true;
195        }
196
197        // Check allowed domains
198        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    /// Check if a git operation is allowed
208    pub fn allows_git_op(&self, op: GitOperation) -> bool {
209        // Check for destructive operations
210        if op.is_destructive() && !self.git.can_destructive {
211            return false;
212        }
213
214        // Check force push
215        if op == GitOperation::ForcePush && !self.git.can_force_push {
216            return false;
217        }
218
219        self.git.allowed_ops.contains(&op)
220    }
221
222    /// Check if spawning agents is allowed
223    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    /// Simple domain matching with wildcard support
240    fn domain_matches(pattern: &str, domain: &str) -> bool {
241        if pattern.starts_with("*.") {
242            let suffix = &pattern[1..]; // Keep the dot
243            domain.ends_with(suffix) || domain == &pattern[2..]
244        } else {
245            pattern == domain
246        }
247    }
248}
249
250// ── Capability Profiles ──────────────────────────────────────────────
251
252/// Capability profile names
253#[derive(Debug, Clone, Copy, PartialEq, Eq)]
254pub enum CapabilityProfile {
255    /// Read-only exploration - safe for untrusted agents
256    ReadOnly,
257    /// Standard development - balanced safety and utility
258    StandardDev,
259    /// Full access - for trusted orchestrators
260    FullAccess,
261    /// Custom profile loaded from config
262    Custom,
263}
264
265impl CapabilityProfile {
266    /// Parse from string
267    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    /// Convert to string
278    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    /// Read-only exploration - safe for untrusted agents
290    ///
291    /// This profile allows:
292    /// - Reading all files (except secrets)
293    /// - Search operations
294    /// - Read-only git operations
295    /// - No network access
296    /// - No spawning
297    /// - Conservative quotas
298    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    /// Standard development - balanced safety and utility
336    ///
337    /// This profile allows:
338    /// - Reading all files (except secrets)
339    /// - Writing to src/, tests/, docs/
340    /// - File read/write, search, git, planning tools
341    /// - Network access to common dev domains
342    /// - Limited agent spawning
343    /// - Standard quotas
344    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), // 1MB
375            },
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), // 10MB
416            },
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    /// Full access - for trusted orchestrators
429    ///
430    /// This profile allows:
431    /// - Full filesystem access
432    /// - All tools including bash and code execution
433    /// - Full network access
434    /// - Full spawning capabilities
435    /// - Generous quotas
436    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    /// Create capabilities from a profile name
449    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    /// Create a child capability set that is a subset of the parent
459    ///
460    /// Child capabilities can never exceed parent capabilities.
461    pub fn derive_child(&self) -> Self {
462        // Child inherits parent capabilities but with reduced spawning depth
463        let mut child = self.clone();
464        child.capability_id = uuid::Uuid::new_v4().to_string();
465
466        // Reduce spawning depth
467        if child.spawning.max_depth > 0 {
468            child.spawning.max_depth -= 1;
469        }
470
471        // Disable elevation for children
472        child.spawning.can_elevate = false;
473
474        child
475    }
476
477    /// Merge capabilities, taking the more restrictive option for each field
478    pub fn intersect(&self, other: &Self) -> Self {
479        Self {
480            capability_id: uuid::Uuid::new_v4().to_string(),
481            filesystem: FilesystemCapabilities {
482                // Intersection of allowed paths
483                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                // Union of denied paths (more restrictive)
510                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                // Intersection of allowed categories
537                allowed_categories: self
538                    .tools
539                    .allowed_categories
540                    .intersection(&other.tools.allowed_categories)
541                    .cloned()
542                    .collect(),
543                // Union of denied tools
544                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                // Union of tools requiring approval
557                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// ── Filesystem Capabilities ──────────────────────────────────────────
672
673/// File system capabilities
674#[derive(Debug, Clone, Serialize, Deserialize)]
675pub struct FilesystemCapabilities {
676    /// Allowed read paths (glob patterns)
677    #[serde(default = "default_read_paths")]
678    pub read_paths: Vec<PathPattern>,
679
680    /// Allowed write paths (glob patterns)
681    #[serde(default)]
682    pub write_paths: Vec<PathPattern>,
683
684    /// Denied paths (override allows)
685    #[serde(default = "default_denied_paths")]
686    pub denied_paths: Vec<PathPattern>,
687
688    /// Can follow symlinks outside allowed paths
689    #[serde(default = "default_true")]
690    pub follow_symlinks: bool,
691
692    /// Can access hidden files (dotfiles)
693    #[serde(default = "default_true")]
694    pub access_hidden: bool,
695
696    /// Maximum file size for write operations (bytes)
697    #[serde(default)]
698    pub max_write_size: Option<u64>,
699
700    /// Can delete files
701    #[serde(default)]
702    pub can_delete: bool,
703
704    /// Can create directories
705    #[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    /// Create full access filesystem capabilities
742    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// ── Tool Capabilities ────────────────────────────────────────────────
757
758/// Tool execution capabilities
759#[derive(Debug, Clone, Serialize, Deserialize)]
760pub struct ToolCapabilities {
761    /// Tool categories allowed
762    #[serde(default = "default_allowed_categories")]
763    pub allowed_categories: HashSet<ToolCategory>,
764
765    /// Specific tools denied (overrides category allows)
766    #[serde(default)]
767    pub denied_tools: HashSet<String>,
768
769    /// Specific tools allowed (if not using categories)
770    #[serde(default)]
771    pub allowed_tools: Option<HashSet<String>>,
772
773    /// Require approval for these tools regardless of trust
774    #[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    /// Create full access tool capabilities
799    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/// Tool categories for permission grouping
823#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
824pub enum ToolCategory {
825    /// Read file operations: read_file, list_directory, search_files
826    FileRead,
827    /// Write file operations: write_file, edit_file, patch_file, delete_file
828    FileWrite,
829    /// Search operations: search_code, semantic search, RAG
830    Search,
831    /// Git operations: status, diff, log, add, commit, push, pull
832    Git,
833    /// Destructive git operations: force push, hard reset, rebase
834    GitDestructive,
835    /// Shell command execution
836    Bash,
837    /// Web operations: fetch_url, web_search, web_scrape
838    Web,
839    /// Code execution in sandboxed environment
840    CodeExecution,
841    /// Agent spawning and management
842    AgentSpawn,
843    /// Planning and task management
844    Planning,
845    /// System-level operations
846    System,
847}
848
849// ── Network Capabilities ─────────────────────────────────────────────
850
851/// Network capabilities
852#[derive(Debug, Clone, Serialize, Deserialize)]
853pub struct NetworkCapabilities {
854    /// Allowed domains (supports wildcards like *.github.com)
855    #[serde(default)]
856    pub allowed_domains: Vec<String>,
857
858    /// Denied domains (override allows)
859    #[serde(default)]
860    pub denied_domains: Vec<String>,
861
862    /// Allow all domains (use with caution)
863    #[serde(default)]
864    pub allow_all: bool,
865
866    /// Rate limit (requests per minute)
867    #[serde(default)]
868    pub rate_limit: Option<u32>,
869
870    /// Can make external API calls
871    #[serde(default)]
872    pub allow_api_calls: bool,
873
874    /// Maximum response size to process (bytes)
875    #[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), // 10MB
888        }
889    }
890}
891
892impl NetworkCapabilities {
893    /// Create disabled network capabilities
894    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    /// Create full network capabilities
906    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// ── Spawning Capabilities ────────────────────────────────────────────
919
920/// Agent spawning capabilities
921#[derive(Debug, Clone, Serialize, Deserialize)]
922pub struct SpawningCapabilities {
923    /// Can spawn child agents
924    #[serde(default)]
925    pub can_spawn: bool,
926
927    /// Maximum concurrent child agents
928    #[serde(default = "default_max_children")]
929    pub max_children: u32,
930
931    /// Maximum depth of agent hierarchy
932    #[serde(default = "default_max_depth")]
933    pub max_depth: u32,
934
935    /// Can spawn agents with elevated privileges (requires approval)
936    #[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    /// Create disabled spawning capabilities
961    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    /// Create full spawning capabilities
971    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// ── Git Capabilities ─────────────────────────────────────────────────
982
983/// Git operation capabilities
984#[derive(Debug, Clone, Serialize, Deserialize)]
985pub struct GitCapabilities {
986    /// Allowed operations
987    #[serde(default = "default_git_ops")]
988    pub allowed_ops: HashSet<GitOperation>,
989
990    /// Protected branches (cannot push directly)
991    #[serde(default)]
992    pub protected_branches: Vec<String>,
993
994    /// Can force push (dangerous)
995    #[serde(default)]
996    pub can_force_push: bool,
997
998    /// Can perform destructive operations
999    #[serde(default)]
1000    pub can_destructive: bool,
1001
1002    /// Require PR for these branches
1003    #[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    /// Create read-only git capabilities
1029    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    /// Create standard git capabilities
1046    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    /// Create full git capabilities
1070    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/// Git operations
1100#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
1101pub enum GitOperation {
1102    /// View working tree status.
1103    Status,
1104    /// Show changes between commits.
1105    Diff,
1106    /// View commit history.
1107    Log,
1108    /// Stage changes.
1109    Add,
1110    /// Create a commit.
1111    Commit,
1112    /// Push to remote.
1113    Push,
1114    /// Pull from remote.
1115    Pull,
1116    /// Fetch from remote.
1117    Fetch,
1118    /// Branch operations.
1119    Branch,
1120    /// Switch branches.
1121    Checkout,
1122    /// Merge branches.
1123    Merge,
1124    /// Rebase commits.
1125    Rebase,
1126    /// Reset to a previous state.
1127    Reset,
1128    /// Stash changes.
1129    Stash,
1130    /// Tag a commit.
1131    Tag,
1132    /// Force push to remote.
1133    ForcePush,
1134}
1135
1136impl GitOperation {
1137    /// Check if this operation is destructive
1138    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// ── Resource Quotas ──────────────────────────────────────────────────
1150
1151/// Resource quota limits
1152#[derive(Debug, Clone, Serialize, Deserialize)]
1153pub struct ResourceQuotas {
1154    /// Maximum execution time (seconds)
1155    #[serde(default)]
1156    pub max_execution_time: Option<u64>,
1157
1158    /// Maximum memory usage (bytes)
1159    #[serde(default)]
1160    pub max_memory: Option<u64>,
1161
1162    /// Maximum API tokens consumed
1163    #[serde(default)]
1164    pub max_tokens: Option<u64>,
1165
1166    /// Maximum tool calls per session
1167    #[serde(default)]
1168    pub max_tool_calls: Option<u32>,
1169
1170    /// Maximum files modified per session
1171    #[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), // 30 minutes
1179            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    /// Create conservative quotas
1189    pub fn conservative() -> Self {
1190        Self {
1191            max_execution_time: Some(5 * 60),    // 5 minutes
1192            max_memory: Some(512 * 1024 * 1024), // 512MB
1193            max_tokens: Some(10_000),
1194            max_tool_calls: Some(50),
1195            max_files_modified: Some(10),
1196        }
1197    }
1198
1199    /// Create standard quotas
1200    pub fn standard() -> Self {
1201        Self::default()
1202    }
1203
1204    /// Create generous quotas
1205    pub fn generous() -> Self {
1206        Self {
1207            max_execution_time: Some(2 * 60 * 60), // 2 hours
1208            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// ── Path Pattern ─────────────────────────────────────────────────────
1217
1218/// Path pattern for glob matching
1219#[derive(Debug, Clone, Serialize, Deserialize)]
1220#[serde(transparent)]
1221pub struct PathPattern {
1222    pattern: String,
1223}
1224
1225impl PathPattern {
1226    /// Create a new path pattern
1227    pub fn new(pattern: &str) -> Self {
1228        Self {
1229            pattern: pattern.to_string(),
1230        }
1231    }
1232
1233    /// Create a glob pattern
1234    pub fn glob(pattern: &str) -> Self {
1235        Self::new(pattern)
1236    }
1237
1238    /// Check if a path matches this pattern
1239    #[cfg(feature = "native")]
1240    pub fn matches(&self, path: &str) -> bool {
1241        // Use glob matching
1242        if let Ok(pattern) = glob::Pattern::new(&self.pattern) {
1243            pattern.matches(path) || pattern.matches_path(std::path::Path::new(path))
1244        } else {
1245            // Fall back to simple string matching if pattern is invalid
1246            path.contains(&self.pattern)
1247        }
1248    }
1249
1250    /// Check if a path matches this pattern (simple string matching for WASM)
1251    #[cfg(not(feature = "native"))]
1252    pub fn matches(&self, path: &str) -> bool {
1253        path.contains(&self.pattern)
1254    }
1255
1256    /// Get the pattern string
1257    pub fn pattern(&self) -> &str {
1258        &self.pattern
1259    }
1260}
1261
1262// ── Tests ────────────────────────────────────────────────────────────
1263
1264#[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        // Default only allows FileRead, Search, and Web
1323        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        // Even though FileRead is allowed, this specific tool is denied
1335        assert!(!caps.allows_tool("read_file"));
1336        assert!(caps.allows_tool("list_directory")); // Other FileRead tools still work
1337    }
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        // Default allows read-only git ops
1360        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}