Skip to main content

punch_types/
capability.rs

1use chrono::{DateTime, Utc};
2use serde::{Deserialize, Serialize};
3use uuid::Uuid;
4
5/// A capability that can be granted to a Fighter or Gorilla.
6///
7/// Capabilities follow a least-privilege model: agents only receive
8/// the permissions they need, scoped to specific patterns where applicable.
9#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
10#[serde(rename_all = "snake_case", tag = "type", content = "scope")]
11pub enum Capability {
12    /// Read files matching the given glob pattern.
13    FileRead(String),
14    /// Write files matching the given glob pattern.
15    FileWrite(String),
16    /// Run shell commands matching the given pattern.
17    ShellExec(String),
18    /// Make network requests to the given host/pattern.
19    Network(String),
20    /// Access the memory subsystem.
21    Memory,
22    /// Access the knowledge graph.
23    KnowledgeGraph,
24    /// Control a browser instance.
25    BrowserControl,
26    /// Spawn new agents.
27    AgentSpawn,
28    /// Send messages to other agents.
29    AgentMessage,
30    /// Create and manage scheduled tasks.
31    Schedule,
32    /// Publish events to the event bus.
33    EventPublish,
34    /// Source control operations (git).
35    SourceControl,
36    /// Container operations (docker).
37    Container,
38    /// Data manipulation (JSON, YAML, regex).
39    DataManipulation,
40    /// Code analysis (search, symbols).
41    CodeAnalysis,
42    /// Archive operations (create, extract, list tar.gz).
43    Archive,
44    /// Template rendering operations.
45    Template,
46    /// Cryptographic hash operations.
47    Crypto,
48    /// Invoke loaded WASM plugins (imported techniques).
49    PluginInvoke,
50    /// Delegate tasks to remote A2A agents.
51    A2ADelegate,
52    /// Access MCP servers matching the given name pattern (e.g., "*" for all, "github" for specific).
53    McpAccess(String),
54    /// Send proactive notifications to connected channels (Telegram, Slack, Discord, etc.).
55    ChannelNotify,
56    /// Self-configuration: modify own creed, heartbeats, and install skill packs.
57    SelfConfig,
58    /// System-level automation: screenshots, screen recording permission required.
59    SystemAutomation,
60    /// UI automation scoped to a specific app (accessibility tree interaction).
61    /// Use "*" to grant access to all apps.
62    UiAutomation(String),
63    /// Deep app integration scoped to a specific app (OCR, app-specific APIs).
64    /// Use "*" to grant access to all apps.
65    AppIntegration(String),
66}
67
68impl std::fmt::Display for Capability {
69    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
70        match self {
71            Self::FileRead(g) => write!(f, "file_read({})", g),
72            Self::FileWrite(g) => write!(f, "file_write({})", g),
73            Self::ShellExec(p) => write!(f, "shell_exec({})", p),
74            Self::Network(h) => write!(f, "network({})", h),
75            Self::Memory => write!(f, "memory"),
76            Self::KnowledgeGraph => write!(f, "knowledge_graph"),
77            Self::BrowserControl => write!(f, "browser_control"),
78            Self::AgentSpawn => write!(f, "agent_spawn"),
79            Self::AgentMessage => write!(f, "agent_message"),
80            Self::Schedule => write!(f, "schedule"),
81            Self::EventPublish => write!(f, "event_publish"),
82            Self::SourceControl => write!(f, "source_control"),
83            Self::Container => write!(f, "container"),
84            Self::DataManipulation => write!(f, "data_manipulation"),
85            Self::CodeAnalysis => write!(f, "code_analysis"),
86            Self::Archive => write!(f, "archive"),
87            Self::Template => write!(f, "template"),
88            Self::Crypto => write!(f, "crypto"),
89            Self::PluginInvoke => write!(f, "plugin_invoke"),
90            Self::A2ADelegate => write!(f, "a2a_delegate"),
91            Self::McpAccess(p) => write!(f, "mcp_access({})", p),
92            Self::ChannelNotify => write!(f, "channel_notify"),
93            Self::SelfConfig => write!(f, "self_config"),
94            Self::SystemAutomation => write!(f, "system_automation"),
95            Self::UiAutomation(app) => write!(f, "ui_automation({})", app),
96            Self::AppIntegration(app) => write!(f, "app_integration({})", app),
97        }
98    }
99}
100
101impl Capability {
102    /// Returns a full-access capability set with wildcard scopes.
103    ///
104    /// This is the default for user-facing fighters — they should be able to
105    /// use any tool the LLM decides is appropriate. Restrict capabilities only
106    /// when deploying sandboxed or multi-tenant agents.
107    pub fn full_access() -> Vec<Capability> {
108        vec![
109            Capability::FileRead("**".to_string()),
110            Capability::FileWrite("**".to_string()),
111            Capability::ShellExec("*".to_string()),
112            Capability::Network("*".to_string()),
113            Capability::Memory,
114            Capability::KnowledgeGraph,
115            Capability::BrowserControl,
116            Capability::AgentSpawn,
117            Capability::AgentMessage,
118            Capability::Schedule,
119            Capability::EventPublish,
120            Capability::SourceControl,
121            Capability::Container,
122            Capability::DataManipulation,
123            Capability::CodeAnalysis,
124            Capability::Archive,
125            Capability::Template,
126            Capability::Crypto,
127            Capability::PluginInvoke,
128            Capability::A2ADelegate,
129            Capability::McpAccess("*".to_string()),
130            Capability::ChannelNotify,
131            Capability::SelfConfig,
132            Capability::SystemAutomation,
133            Capability::UiAutomation("*".to_string()),
134            Capability::AppIntegration("*".to_string()),
135        ]
136    }
137}
138
139/// A record of a capability grant to an agent.
140#[derive(Debug, Clone, Serialize, Deserialize)]
141pub struct CapabilityGrant {
142    /// Unique identifier for this grant.
143    pub id: Uuid,
144    /// The capability that was granted.
145    pub capability: Capability,
146    /// Who or what granted this capability.
147    pub granted_by: String,
148    /// When the grant was issued.
149    pub granted_at: DateTime<Utc>,
150    /// Optional expiration time.
151    pub expires_at: Option<DateTime<Utc>>,
152}
153
154/// Check whether a granted capability satisfies a required capability.
155///
156/// Scope-less capabilities match by variant equality. For scoped capabilities,
157/// the granted pattern is matched against the required pattern using glob matching.
158pub fn capability_matches(granted: &Capability, required: &Capability) -> bool {
159    match (granted, required) {
160        (Capability::FileRead(granted_glob), Capability::FileRead(required_path)) => {
161            glob_matches(granted_glob, required_path)
162        }
163        (Capability::FileWrite(granted_glob), Capability::FileWrite(required_path)) => {
164            glob_matches(granted_glob, required_path)
165        }
166        (Capability::ShellExec(granted_pat), Capability::ShellExec(required_cmd)) => {
167            pattern_matches(granted_pat, required_cmd)
168        }
169        (Capability::Network(granted_host), Capability::Network(required_host)) => {
170            host_matches(granted_host, required_host)
171        }
172        (Capability::Memory, Capability::Memory) => true,
173        (Capability::KnowledgeGraph, Capability::KnowledgeGraph) => true,
174        (Capability::BrowserControl, Capability::BrowserControl) => true,
175        (Capability::AgentSpawn, Capability::AgentSpawn) => true,
176        (Capability::AgentMessage, Capability::AgentMessage) => true,
177        (Capability::Schedule, Capability::Schedule) => true,
178        (Capability::EventPublish, Capability::EventPublish) => true,
179        (Capability::SourceControl, Capability::SourceControl) => true,
180        (Capability::Container, Capability::Container) => true,
181        (Capability::DataManipulation, Capability::DataManipulation) => true,
182        (Capability::CodeAnalysis, Capability::CodeAnalysis) => true,
183        (Capability::Archive, Capability::Archive) => true,
184        (Capability::Template, Capability::Template) => true,
185        (Capability::Crypto, Capability::Crypto) => true,
186        (Capability::PluginInvoke, Capability::PluginInvoke) => true,
187        (Capability::A2ADelegate, Capability::A2ADelegate) => true,
188        (Capability::McpAccess(granted_pat), Capability::McpAccess(required_name)) => {
189            pattern_matches(granted_pat, required_name)
190        }
191        (Capability::ChannelNotify, Capability::ChannelNotify) => true,
192        (Capability::SelfConfig, Capability::SelfConfig) => true,
193        (Capability::SystemAutomation, Capability::SystemAutomation) => true,
194        (Capability::UiAutomation(granted_app), Capability::UiAutomation(required_app)) => {
195            pattern_matches(granted_app, required_app)
196        }
197        (Capability::AppIntegration(granted_app), Capability::AppIntegration(required_app)) => {
198            pattern_matches(granted_app, required_app)
199        }
200        _ => false,
201    }
202}
203
204/// Match a glob pattern against a path string.
205fn glob_matches(pattern: &str, path: &str) -> bool {
206    if pattern == "**" || pattern == "**/*" {
207        return true;
208    }
209    glob::Pattern::new(pattern)
210        .map(|p| p.matches(path))
211        .unwrap_or(false)
212}
213
214/// Match a command pattern against a required command.
215fn pattern_matches(pattern: &str, command: &str) -> bool {
216    if pattern == "*" {
217        return true;
218    }
219    glob::Pattern::new(pattern)
220        .map(|p| p.matches(command))
221        .unwrap_or(false)
222}
223
224/// Match a host pattern against a required host.
225fn host_matches(pattern: &str, host: &str) -> bool {
226    if pattern == "*" {
227        return true;
228    }
229    if let Some(suffix) = pattern.strip_prefix("*.") {
230        return host == suffix || host.ends_with(&format!(".{}", suffix));
231    }
232    pattern == host
233}
234
235#[cfg(test)]
236mod tests {
237    use super::*;
238
239    #[test]
240    fn test_glob_file_read() {
241        let granted = Capability::FileRead("src/**/*.rs".to_string());
242        let required = Capability::FileRead("src/main.rs".to_string());
243        assert!(capability_matches(&granted, &required));
244    }
245
246    #[test]
247    fn test_wildcard_grants_all() {
248        let granted = Capability::FileRead("**".to_string());
249        let required = Capability::FileRead("anything/at/all.txt".to_string());
250        assert!(capability_matches(&granted, &required));
251    }
252
253    #[test]
254    fn test_glob_no_match() {
255        let granted = Capability::FileRead("src/**/*.rs".to_string());
256        let required = Capability::FileRead("tests/data.json".to_string());
257        assert!(!capability_matches(&granted, &required));
258    }
259
260    #[test]
261    fn test_variant_mismatch() {
262        let granted = Capability::FileRead("**".to_string());
263        let required = Capability::FileWrite("foo.txt".to_string());
264        assert!(!capability_matches(&granted, &required));
265    }
266
267    #[test]
268    fn test_scopeless_capabilities() {
269        assert!(capability_matches(&Capability::Memory, &Capability::Memory));
270        assert!(!capability_matches(
271            &Capability::Memory,
272            &Capability::Schedule
273        ));
274    }
275
276    #[test]
277    fn test_wildcard_host() {
278        let granted = Capability::Network("*.example.com".to_string());
279        let required = Capability::Network("api.example.com".to_string());
280        assert!(capability_matches(&granted, &required));
281    }
282
283    #[test]
284    fn test_exact_host() {
285        let granted = Capability::Network("api.example.com".to_string());
286        let required = Capability::Network("api.example.com".to_string());
287        assert!(capability_matches(&granted, &required));
288
289        let other = Capability::Network("other.example.com".to_string());
290        assert!(!capability_matches(&granted, &other));
291    }
292
293    #[test]
294    fn test_capability_display_all_scoped() {
295        assert_eq!(
296            Capability::FileRead("src/*.rs".to_string()).to_string(),
297            "file_read(src/*.rs)"
298        );
299        assert_eq!(
300            Capability::FileWrite("out/**".to_string()).to_string(),
301            "file_write(out/**)"
302        );
303        assert_eq!(
304            Capability::ShellExec("ls*".to_string()).to_string(),
305            "shell_exec(ls*)"
306        );
307        assert_eq!(
308            Capability::Network("*.example.com".to_string()).to_string(),
309            "network(*.example.com)"
310        );
311    }
312
313    #[test]
314    fn test_mcp_access_wildcard() {
315        let granted = Capability::McpAccess("*".to_string());
316        let required = Capability::McpAccess("github".to_string());
317        assert!(capability_matches(&granted, &required));
318    }
319
320    #[test]
321    fn test_mcp_access_exact() {
322        let granted = Capability::McpAccess("github".to_string());
323        assert!(capability_matches(
324            &granted,
325            &Capability::McpAccess("github".to_string())
326        ));
327        assert!(!capability_matches(
328            &granted,
329            &Capability::McpAccess("slack".to_string())
330        ));
331    }
332
333    #[test]
334    fn test_mcp_access_display() {
335        assert_eq!(
336            Capability::McpAccess("github".to_string()).to_string(),
337            "mcp_access(github)"
338        );
339    }
340
341    #[test]
342    fn test_capability_display_all_scopeless() {
343        assert_eq!(Capability::Memory.to_string(), "memory");
344        assert_eq!(Capability::KnowledgeGraph.to_string(), "knowledge_graph");
345        assert_eq!(Capability::BrowserControl.to_string(), "browser_control");
346        assert_eq!(Capability::AgentSpawn.to_string(), "agent_spawn");
347        assert_eq!(Capability::AgentMessage.to_string(), "agent_message");
348        assert_eq!(Capability::Schedule.to_string(), "schedule");
349        assert_eq!(Capability::EventPublish.to_string(), "event_publish");
350        assert_eq!(Capability::SourceControl.to_string(), "source_control");
351        assert_eq!(Capability::Container.to_string(), "container");
352        assert_eq!(
353            Capability::DataManipulation.to_string(),
354            "data_manipulation"
355        );
356        assert_eq!(Capability::CodeAnalysis.to_string(), "code_analysis");
357        assert_eq!(Capability::Archive.to_string(), "archive");
358        assert_eq!(Capability::Template.to_string(), "template");
359        assert_eq!(Capability::Crypto.to_string(), "crypto");
360        assert_eq!(Capability::A2ADelegate.to_string(), "a2a_delegate");
361        assert_eq!(Capability::PluginInvoke.to_string(), "plugin_invoke");
362        assert_eq!(Capability::SelfConfig.to_string(), "self_config");
363        assert_eq!(
364            Capability::SystemAutomation.to_string(),
365            "system_automation"
366        );
367    }
368
369    #[test]
370    fn test_all_scopeless_capability_matches() {
371        let scopeless = vec![
372            Capability::Memory,
373            Capability::KnowledgeGraph,
374            Capability::BrowserControl,
375            Capability::AgentSpawn,
376            Capability::AgentMessage,
377            Capability::Schedule,
378            Capability::EventPublish,
379            Capability::SourceControl,
380            Capability::Container,
381            Capability::DataManipulation,
382            Capability::CodeAnalysis,
383            Capability::Archive,
384            Capability::Template,
385            Capability::Crypto,
386            Capability::A2ADelegate,
387            Capability::PluginInvoke,
388            Capability::SelfConfig,
389            Capability::SystemAutomation,
390        ];
391        for cap in &scopeless {
392            assert!(capability_matches(cap, cap), "{} should match itself", cap);
393        }
394        // Scoped MCP access should match itself
395        let mcp = Capability::McpAccess("test".to_string());
396        assert!(capability_matches(&mcp, &mcp));
397        // Cross-variant should not match
398        assert!(!capability_matches(
399            &Capability::Memory,
400            &Capability::Schedule
401        ));
402        assert!(!capability_matches(
403            &Capability::Archive,
404            &Capability::Template
405        ));
406    }
407
408    #[test]
409    fn test_capability_serde_roundtrip() {
410        let caps = vec![
411            Capability::FileRead("**/*.rs".to_string()),
412            Capability::FileWrite("out/**".to_string()),
413            Capability::ShellExec("*".to_string()),
414            Capability::Network("*.api.com".to_string()),
415            Capability::Memory,
416            Capability::BrowserControl,
417            Capability::Crypto,
418            Capability::McpAccess("*".to_string()),
419        ];
420        for cap in &caps {
421            let json = serde_json::to_string(cap).expect("serialize");
422            let deser: Capability = serde_json::from_str(&json).expect("deserialize");
423            assert_eq!(&deser, cap);
424        }
425    }
426
427    #[test]
428    fn test_glob_matches_star_star_slash_star() {
429        let granted = Capability::FileRead("**/*".to_string());
430        let required = Capability::FileRead("deep/nested/file.txt".to_string());
431        assert!(capability_matches(&granted, &required));
432    }
433
434    #[test]
435    fn test_shell_wildcard_grants_all() {
436        let granted = Capability::ShellExec("*".to_string());
437        let required = Capability::ShellExec("rm -rf /".to_string());
438        assert!(capability_matches(&granted, &required));
439    }
440
441    #[test]
442    fn test_network_wildcard_grants_all() {
443        let granted = Capability::Network("*".to_string());
444        let required = Capability::Network("any.host.com".to_string());
445        assert!(capability_matches(&granted, &required));
446    }
447
448    #[test]
449    fn test_subdomain_wildcard_host() {
450        let granted = Capability::Network("*.example.com".to_string());
451        // Direct match of the suffix
452        assert!(capability_matches(
453            &granted,
454            &Capability::Network("example.com".to_string())
455        ));
456        // Deep subdomain
457        assert!(capability_matches(
458            &granted,
459            &Capability::Network("deep.sub.example.com".to_string())
460        ));
461    }
462
463    #[test]
464    fn test_capability_grant_construction() {
465        let grant = CapabilityGrant {
466            id: Uuid::new_v4(),
467            capability: Capability::Memory,
468            granted_by: "admin".to_string(),
469            granted_at: chrono::Utc::now(),
470            expires_at: None,
471        };
472        assert_eq!(grant.granted_by, "admin");
473        assert!(grant.expires_at.is_none());
474    }
475
476    #[test]
477    fn test_ui_automation_scoped() {
478        let granted = Capability::UiAutomation("*".to_string());
479        let required = Capability::UiAutomation("Messages".to_string());
480        assert!(capability_matches(&granted, &required));
481
482        let specific = Capability::UiAutomation("Safari".to_string());
483        assert!(capability_matches(
484            &specific,
485            &Capability::UiAutomation("Safari".to_string())
486        ));
487        assert!(!capability_matches(
488            &specific,
489            &Capability::UiAutomation("Messages".to_string())
490        ));
491    }
492
493    #[test]
494    fn test_app_integration_scoped() {
495        let granted = Capability::AppIntegration("*".to_string());
496        let required = Capability::AppIntegration("Messages".to_string());
497        assert!(capability_matches(&granted, &required));
498
499        let specific = Capability::AppIntegration("Safari".to_string());
500        assert!(!capability_matches(
501            &specific,
502            &Capability::AppIntegration("Finder".to_string())
503        ));
504    }
505
506    #[test]
507    fn test_automation_display() {
508        assert_eq!(
509            Capability::UiAutomation("Safari".to_string()).to_string(),
510            "ui_automation(Safari)"
511        );
512        assert_eq!(
513            Capability::AppIntegration("*".to_string()).to_string(),
514            "app_integration(*)"
515        );
516    }
517
518    #[test]
519    fn test_automation_cross_variant_no_match() {
520        assert!(!capability_matches(
521            &Capability::SystemAutomation,
522            &Capability::UiAutomation("*".to_string())
523        ));
524        assert!(!capability_matches(
525            &Capability::UiAutomation("*".to_string()),
526            &Capability::AppIntegration("*".to_string())
527        ));
528    }
529
530    #[test]
531    fn test_capability_grant_with_expiry() {
532        let grant = CapabilityGrant {
533            id: Uuid::new_v4(),
534            capability: Capability::Network("*".to_string()),
535            granted_by: "system".to_string(),
536            granted_at: chrono::Utc::now(),
537            expires_at: Some(chrono::Utc::now()),
538        };
539        assert!(grant.expires_at.is_some());
540    }
541}