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