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