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}
55
56impl std::fmt::Display for Capability {
57    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
58        match self {
59            Self::FileRead(g) => write!(f, "file_read({})", g),
60            Self::FileWrite(g) => write!(f, "file_write({})", g),
61            Self::ShellExec(p) => write!(f, "shell_exec({})", p),
62            Self::Network(h) => write!(f, "network({})", h),
63            Self::Memory => write!(f, "memory"),
64            Self::KnowledgeGraph => write!(f, "knowledge_graph"),
65            Self::BrowserControl => write!(f, "browser_control"),
66            Self::AgentSpawn => write!(f, "agent_spawn"),
67            Self::AgentMessage => write!(f, "agent_message"),
68            Self::Schedule => write!(f, "schedule"),
69            Self::EventPublish => write!(f, "event_publish"),
70            Self::SourceControl => write!(f, "source_control"),
71            Self::Container => write!(f, "container"),
72            Self::DataManipulation => write!(f, "data_manipulation"),
73            Self::CodeAnalysis => write!(f, "code_analysis"),
74            Self::Archive => write!(f, "archive"),
75            Self::Template => write!(f, "template"),
76            Self::Crypto => write!(f, "crypto"),
77            Self::PluginInvoke => write!(f, "plugin_invoke"),
78            Self::A2ADelegate => write!(f, "a2a_delegate"),
79            Self::McpAccess(p) => write!(f, "mcp_access({})", p),
80        }
81    }
82}
83
84/// A record of a capability grant to an agent.
85#[derive(Debug, Clone, Serialize, Deserialize)]
86pub struct CapabilityGrant {
87    /// Unique identifier for this grant.
88    pub id: Uuid,
89    /// The capability that was granted.
90    pub capability: Capability,
91    /// Who or what granted this capability.
92    pub granted_by: String,
93    /// When the grant was issued.
94    pub granted_at: DateTime<Utc>,
95    /// Optional expiration time.
96    pub expires_at: Option<DateTime<Utc>>,
97}
98
99/// Check whether a granted capability satisfies a required capability.
100///
101/// Scope-less capabilities match by variant equality. For scoped capabilities,
102/// the granted pattern is matched against the required pattern using glob matching.
103pub fn capability_matches(granted: &Capability, required: &Capability) -> bool {
104    match (granted, required) {
105        (Capability::FileRead(granted_glob), Capability::FileRead(required_path)) => {
106            glob_matches(granted_glob, required_path)
107        }
108        (Capability::FileWrite(granted_glob), Capability::FileWrite(required_path)) => {
109            glob_matches(granted_glob, required_path)
110        }
111        (Capability::ShellExec(granted_pat), Capability::ShellExec(required_cmd)) => {
112            pattern_matches(granted_pat, required_cmd)
113        }
114        (Capability::Network(granted_host), Capability::Network(required_host)) => {
115            host_matches(granted_host, required_host)
116        }
117        (Capability::Memory, Capability::Memory) => true,
118        (Capability::KnowledgeGraph, Capability::KnowledgeGraph) => true,
119        (Capability::BrowserControl, Capability::BrowserControl) => true,
120        (Capability::AgentSpawn, Capability::AgentSpawn) => true,
121        (Capability::AgentMessage, Capability::AgentMessage) => true,
122        (Capability::Schedule, Capability::Schedule) => true,
123        (Capability::EventPublish, Capability::EventPublish) => true,
124        (Capability::SourceControl, Capability::SourceControl) => true,
125        (Capability::Container, Capability::Container) => true,
126        (Capability::DataManipulation, Capability::DataManipulation) => true,
127        (Capability::CodeAnalysis, Capability::CodeAnalysis) => true,
128        (Capability::Archive, Capability::Archive) => true,
129        (Capability::Template, Capability::Template) => true,
130        (Capability::Crypto, Capability::Crypto) => true,
131        (Capability::PluginInvoke, Capability::PluginInvoke) => true,
132        (Capability::A2ADelegate, Capability::A2ADelegate) => true,
133        (Capability::McpAccess(granted_pat), Capability::McpAccess(required_name)) => {
134            pattern_matches(granted_pat, required_name)
135        }
136        _ => false,
137    }
138}
139
140/// Match a glob pattern against a path string.
141fn glob_matches(pattern: &str, path: &str) -> bool {
142    if pattern == "**" || pattern == "**/*" {
143        return true;
144    }
145    glob::Pattern::new(pattern)
146        .map(|p| p.matches(path))
147        .unwrap_or(false)
148}
149
150/// Match a command pattern against a required command.
151fn pattern_matches(pattern: &str, command: &str) -> bool {
152    if pattern == "*" {
153        return true;
154    }
155    glob::Pattern::new(pattern)
156        .map(|p| p.matches(command))
157        .unwrap_or(false)
158}
159
160/// Match a host pattern against a required host.
161fn host_matches(pattern: &str, host: &str) -> bool {
162    if pattern == "*" {
163        return true;
164    }
165    if let Some(suffix) = pattern.strip_prefix("*.") {
166        return host == suffix || host.ends_with(&format!(".{}", suffix));
167    }
168    pattern == host
169}
170
171#[cfg(test)]
172mod tests {
173    use super::*;
174
175    #[test]
176    fn test_glob_file_read() {
177        let granted = Capability::FileRead("src/**/*.rs".to_string());
178        let required = Capability::FileRead("src/main.rs".to_string());
179        assert!(capability_matches(&granted, &required));
180    }
181
182    #[test]
183    fn test_wildcard_grants_all() {
184        let granted = Capability::FileRead("**".to_string());
185        let required = Capability::FileRead("anything/at/all.txt".to_string());
186        assert!(capability_matches(&granted, &required));
187    }
188
189    #[test]
190    fn test_glob_no_match() {
191        let granted = Capability::FileRead("src/**/*.rs".to_string());
192        let required = Capability::FileRead("tests/data.json".to_string());
193        assert!(!capability_matches(&granted, &required));
194    }
195
196    #[test]
197    fn test_variant_mismatch() {
198        let granted = Capability::FileRead("**".to_string());
199        let required = Capability::FileWrite("foo.txt".to_string());
200        assert!(!capability_matches(&granted, &required));
201    }
202
203    #[test]
204    fn test_scopeless_capabilities() {
205        assert!(capability_matches(&Capability::Memory, &Capability::Memory));
206        assert!(!capability_matches(
207            &Capability::Memory,
208            &Capability::Schedule
209        ));
210    }
211
212    #[test]
213    fn test_wildcard_host() {
214        let granted = Capability::Network("*.example.com".to_string());
215        let required = Capability::Network("api.example.com".to_string());
216        assert!(capability_matches(&granted, &required));
217    }
218
219    #[test]
220    fn test_exact_host() {
221        let granted = Capability::Network("api.example.com".to_string());
222        let required = Capability::Network("api.example.com".to_string());
223        assert!(capability_matches(&granted, &required));
224
225        let other = Capability::Network("other.example.com".to_string());
226        assert!(!capability_matches(&granted, &other));
227    }
228
229    #[test]
230    fn test_capability_display_all_scoped() {
231        assert_eq!(
232            Capability::FileRead("src/*.rs".to_string()).to_string(),
233            "file_read(src/*.rs)"
234        );
235        assert_eq!(
236            Capability::FileWrite("out/**".to_string()).to_string(),
237            "file_write(out/**)"
238        );
239        assert_eq!(
240            Capability::ShellExec("ls*".to_string()).to_string(),
241            "shell_exec(ls*)"
242        );
243        assert_eq!(
244            Capability::Network("*.example.com".to_string()).to_string(),
245            "network(*.example.com)"
246        );
247    }
248
249    #[test]
250    fn test_mcp_access_wildcard() {
251        let granted = Capability::McpAccess("*".to_string());
252        let required = Capability::McpAccess("github".to_string());
253        assert!(capability_matches(&granted, &required));
254    }
255
256    #[test]
257    fn test_mcp_access_exact() {
258        let granted = Capability::McpAccess("github".to_string());
259        assert!(capability_matches(
260            &granted,
261            &Capability::McpAccess("github".to_string())
262        ));
263        assert!(!capability_matches(
264            &granted,
265            &Capability::McpAccess("slack".to_string())
266        ));
267    }
268
269    #[test]
270    fn test_mcp_access_display() {
271        assert_eq!(
272            Capability::McpAccess("github".to_string()).to_string(),
273            "mcp_access(github)"
274        );
275    }
276
277    #[test]
278    fn test_capability_display_all_scopeless() {
279        assert_eq!(Capability::Memory.to_string(), "memory");
280        assert_eq!(Capability::KnowledgeGraph.to_string(), "knowledge_graph");
281        assert_eq!(Capability::BrowserControl.to_string(), "browser_control");
282        assert_eq!(Capability::AgentSpawn.to_string(), "agent_spawn");
283        assert_eq!(Capability::AgentMessage.to_string(), "agent_message");
284        assert_eq!(Capability::Schedule.to_string(), "schedule");
285        assert_eq!(Capability::EventPublish.to_string(), "event_publish");
286        assert_eq!(Capability::SourceControl.to_string(), "source_control");
287        assert_eq!(Capability::Container.to_string(), "container");
288        assert_eq!(
289            Capability::DataManipulation.to_string(),
290            "data_manipulation"
291        );
292        assert_eq!(Capability::CodeAnalysis.to_string(), "code_analysis");
293        assert_eq!(Capability::Archive.to_string(), "archive");
294        assert_eq!(Capability::Template.to_string(), "template");
295        assert_eq!(Capability::Crypto.to_string(), "crypto");
296        assert_eq!(Capability::A2ADelegate.to_string(), "a2a_delegate");
297        assert_eq!(Capability::PluginInvoke.to_string(), "plugin_invoke");
298    }
299
300    #[test]
301    fn test_all_scopeless_capability_matches() {
302        let scopeless = vec![
303            Capability::Memory,
304            Capability::KnowledgeGraph,
305            Capability::BrowserControl,
306            Capability::AgentSpawn,
307            Capability::AgentMessage,
308            Capability::Schedule,
309            Capability::EventPublish,
310            Capability::SourceControl,
311            Capability::Container,
312            Capability::DataManipulation,
313            Capability::CodeAnalysis,
314            Capability::Archive,
315            Capability::Template,
316            Capability::Crypto,
317            Capability::A2ADelegate,
318            Capability::PluginInvoke,
319        ];
320        for cap in &scopeless {
321            assert!(capability_matches(cap, cap), "{} should match itself", cap);
322        }
323        // Scoped MCP access should match itself
324        let mcp = Capability::McpAccess("test".to_string());
325        assert!(capability_matches(&mcp, &mcp));
326        // Cross-variant should not match
327        assert!(!capability_matches(
328            &Capability::Memory,
329            &Capability::Schedule
330        ));
331        assert!(!capability_matches(
332            &Capability::Archive,
333            &Capability::Template
334        ));
335    }
336
337    #[test]
338    fn test_capability_serde_roundtrip() {
339        let caps = vec![
340            Capability::FileRead("**/*.rs".to_string()),
341            Capability::FileWrite("out/**".to_string()),
342            Capability::ShellExec("*".to_string()),
343            Capability::Network("*.api.com".to_string()),
344            Capability::Memory,
345            Capability::BrowserControl,
346            Capability::Crypto,
347            Capability::McpAccess("*".to_string()),
348        ];
349        for cap in &caps {
350            let json = serde_json::to_string(cap).expect("serialize");
351            let deser: Capability = serde_json::from_str(&json).expect("deserialize");
352            assert_eq!(&deser, cap);
353        }
354    }
355
356    #[test]
357    fn test_glob_matches_star_star_slash_star() {
358        let granted = Capability::FileRead("**/*".to_string());
359        let required = Capability::FileRead("deep/nested/file.txt".to_string());
360        assert!(capability_matches(&granted, &required));
361    }
362
363    #[test]
364    fn test_shell_wildcard_grants_all() {
365        let granted = Capability::ShellExec("*".to_string());
366        let required = Capability::ShellExec("rm -rf /".to_string());
367        assert!(capability_matches(&granted, &required));
368    }
369
370    #[test]
371    fn test_network_wildcard_grants_all() {
372        let granted = Capability::Network("*".to_string());
373        let required = Capability::Network("any.host.com".to_string());
374        assert!(capability_matches(&granted, &required));
375    }
376
377    #[test]
378    fn test_subdomain_wildcard_host() {
379        let granted = Capability::Network("*.example.com".to_string());
380        // Direct match of the suffix
381        assert!(capability_matches(
382            &granted,
383            &Capability::Network("example.com".to_string())
384        ));
385        // Deep subdomain
386        assert!(capability_matches(
387            &granted,
388            &Capability::Network("deep.sub.example.com".to_string())
389        ));
390    }
391
392    #[test]
393    fn test_capability_grant_construction() {
394        let grant = CapabilityGrant {
395            id: Uuid::new_v4(),
396            capability: Capability::Memory,
397            granted_by: "admin".to_string(),
398            granted_at: chrono::Utc::now(),
399            expires_at: None,
400        };
401        assert_eq!(grant.granted_by, "admin");
402        assert!(grant.expires_at.is_none());
403    }
404
405    #[test]
406    fn test_capability_grant_with_expiry() {
407        let grant = CapabilityGrant {
408            id: Uuid::new_v4(),
409            capability: Capability::Network("*".to_string()),
410            granted_by: "system".to_string(),
411            granted_at: chrono::Utc::now(),
412            expires_at: Some(chrono::Utc::now()),
413        };
414        assert!(grant.expires_at.is_some());
415    }
416}