Skip to main content

oxicode_sdk/security/capability/
mod.rs

1//! Capability types — fine-grained permission definitions.
2//!
3//! Also provides the `types` sub-module (seL4-style CSpace, ResourceRef, Rights)
4//! and `resolve` sub-module (template-based CSpace resolution).
5
6pub mod resolve;
7pub mod types;
8
9use serde::{Deserialize, Serialize};
10use std::time::Duration;
11
12// ── Capability ─────────────────────────────────────────────────────────────
13
14/// A fine-grained permission that can be granted to an agent.
15///
16/// Each variant captures the specific resource and access level.
17/// The `Authorizer` checks these at tool execution time.
18#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
19#[serde(tag = "type", rename_all = "snake_case")]
20pub enum Capability {
21    // ── File system ──
22    /// Read files matching a glob pattern.
23    FileRead {
24        /// Glob pattern selecting the paths this capability permits reading (e.g. `src/**/*.rs`).
25        path_pattern: String,
26    },
27    /// Write or create files matching a glob pattern.
28    FileWrite {
29        /// Glob pattern selecting the paths this capability permits writing or creating.
30        path_pattern: String,
31    },
32    /// Edit existing files matching a glob pattern.
33    FileEdit {
34        /// Glob pattern selecting the paths this capability permits editing.
35        path_pattern: String,
36    },
37    /// List directory entries matching a glob pattern.
38    FileList {
39        /// Glob pattern selecting the paths this capability permits listing.
40        path_pattern: String,
41    },
42    /// Find files matching a glob pattern.
43    FileFind {
44        /// Glob pattern selecting the paths this capability permits finding.
45        path_pattern: String,
46    },
47
48    // ── Execution ──
49    /// Execute a restricted set of shell commands.
50    Bash {
51        /// Command names or [`StringPattern`] matchers the agent is permitted to invoke.
52        allowed_commands: Vec<StringPattern>,
53        /// Maximum wall-clock seconds a single command may run before being killed.
54        #[serde(default)]
55        timeout_secs: Option<u64>,
56    },
57
58    // ── Network ──
59    /// Make outbound network requests to a set of domains.
60    Network {
61        /// Domains the agent is permitted to contact (e.g. `api.example.com`).
62        allowed_domains: Vec<String>,
63    },
64    /// Browse web pages on a set of domains.
65    WebBrowse {
66        /// Domains the agent is permitted to browse.
67        allowed_domains: Vec<String>,
68    },
69
70    // ── Agent ──
71    /// Spawn child agents as subagents.
72    Subagent {
73        /// Maximum number of concurrent children this grant permits (`None` = unbounded).
74        max_children: Option<usize>,
75    },
76    /// Read from an inter-agent message bus channel.
77    BusRead {
78        /// Channel name the grant covers (`None` = all channels).
79        channel: Option<String>,
80    },
81    /// Write to an inter-agent message bus channel.
82    BusWrite {
83        /// Channel name the grant covers (`None` = all channels).
84        channel: Option<String>,
85    },
86
87    // ── Environment ──
88    /// Read environment variables.
89    EnvRead {
90        /// Names of environment variables the agent is permitted to read.
91        allowed_vars: Vec<String>,
92    },
93
94    // ── Meta ──
95    /// Invoke a named tool.
96    ToolUse {
97        /// Name of the tool this capability permits calling.
98        tool_name: String,
99    },
100    /// Access MCP server resources.
101    McpAccess {
102        /// Patterns matching the MCP resource URIs the agent may access.
103        resource_patterns: Vec<String>,
104    },
105}
106
107impl Capability {
108    /// Whether this capability satisfies (is at least as permissive as) `required`.
109    pub fn satisfies(&self, required: &Capability) -> bool {
110        match (self, required) {
111            // Same type: check pattern subset
112            (
113                Capability::FileRead { path_pattern: a },
114                Capability::FileRead { path_pattern: b },
115            )
116            | (
117                Capability::FileWrite { path_pattern: a },
118                Capability::FileWrite { path_pattern: b },
119            )
120            | (
121                Capability::FileEdit { path_pattern: a },
122                Capability::FileEdit { path_pattern: b },
123            )
124            | (
125                Capability::FileList { path_pattern: a },
126                Capability::FileList { path_pattern: b },
127            )
128            | (
129                Capability::FileFind { path_pattern: a },
130                Capability::FileFind { path_pattern: b },
131            ) => pattern_matches(a, b),
132
133            (
134                Capability::Bash {
135                    allowed_commands: a,
136                    ..
137                },
138                Capability::Bash {
139                    allowed_commands: b,
140                    ..
141                },
142            ) => {
143                // If `a` contains Wildcard, it satisfies any Bash request
144                if a.iter().any(|p| matches!(p, StringPattern::Wildcard)) {
145                    return true;
146                }
147                // Check that every required command is in `a`
148                b.iter()
149                    .all(|req| a.iter().any(|cap| string_pattern_matches(cap, req)))
150            }
151
152            (
153                Capability::Network { allowed_domains: a },
154                Capability::Network { allowed_domains: b },
155            )
156            | (
157                Capability::WebBrowse { allowed_domains: a },
158                Capability::WebBrowse { allowed_domains: b },
159            ) => domain_matches(a, b),
160
161            (
162                Capability::Subagent { max_children: a },
163                Capability::Subagent { max_children: b },
164            ) => match (a, b) {
165                (None, _) => true, // unlimited
166                (Some(a_max), Some(b_max)) => a_max >= b_max,
167                (Some(_), None) => false, // required is unlimited but granted is limited
168            },
169
170            (Capability::BusRead { channel: a }, Capability::BusRead { channel: b })
171            | (Capability::BusWrite { channel: a }, Capability::BusWrite { channel: b }) => {
172                match (a, b) {
173                    (None, _) => true, // all channels
174                    (Some(_), None) => false,
175                    (Some(a_ch), Some(b_ch)) => a_ch == b_ch,
176                }
177            }
178
179            (Capability::EnvRead { allowed_vars: a }, Capability::EnvRead { allowed_vars: b }) => {
180                b.iter().all(|req| a.iter().any(|cap| cap == req)) || a.contains(&"*".to_string())
181            }
182
183            (Capability::ToolUse { tool_name: a }, Capability::ToolUse { tool_name: b }) => {
184                a == b || a == "*"
185            }
186
187            (
188                Capability::McpAccess {
189                    resource_patterns: a,
190                },
191                Capability::McpAccess {
192                    resource_patterns: b,
193                },
194            ) => a.iter().any(|p| p == "*") || b.iter().all(|req| a.contains(req)),
195
196            _ => false,
197        }
198    }
199}
200
201// ── StringPattern ──────────────────────────────────────────────────────────
202
203/// Pattern for matching strings — either a literal or a wildcard.
204#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
205pub enum StringPattern {
206    /// Matches a single literal string exactly.
207    Literal(String),
208    /// Matches any string (the `*` wildcard).
209    Wildcard,
210}
211
212/// Check if a granted string pattern satisfies a required one.
213fn string_pattern_matches(granted: &StringPattern, required: &StringPattern) -> bool {
214    match (granted, required) {
215        (StringPattern::Wildcard, _) => true,
216        (StringPattern::Literal(a), StringPattern::Literal(b)) => a == b,
217        (StringPattern::Literal(_), StringPattern::Wildcard) => false,
218    }
219}
220
221/// Check if a glob-like path pattern matches a specific path.
222/// Supports `**` (recursive), `*` (single segment).
223fn pattern_matches(pattern: &str, path: &str) -> bool {
224    if pattern == "*" || pattern == "**" || pattern == "/**" {
225        return true;
226    }
227    if pattern == path {
228        return true;
229    }
230    // Simple suffix matching for patterns like "/workspace/**"
231    if let Some(prefix) = pattern.strip_suffix("/**") {
232        return path.starts_with(prefix)
233            || path.starts_with(&format!("{}/", prefix.trim_end_matches('/')));
234    }
235    if let Some(prefix) = pattern.strip_suffix("*") {
236        return path.starts_with(prefix);
237    }
238    false
239}
240
241/// Check if granted domains satisfy required domains.
242fn domain_matches(granted: &[String], required: &[String]) -> bool {
243    if granted.contains(&"*".to_string()) {
244        return true;
245    }
246    required.iter().all(|req| {
247        granted.iter().any(|g| {
248            if g == "*" {
249                return true;
250            }
251            if g == req {
252                return true;
253            }
254            // Subdomain matching: "*.example.com" matches "sub.example.com"
255            if let Some(suffix) = g.strip_prefix("*.") {
256                req.ends_with(&format!(".{}", suffix)) || req == suffix
257            } else {
258                false
259            }
260        })
261    })
262}
263
264// ── CapabilitySubject ──────────────────────────────────────────────────────
265
266/// The subject of a capability grant or check.
267#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
268pub enum CapabilitySubject {
269    /// A specific agent, identified by its id.
270    Agent(String),
271    /// A tool, identified by name.
272    Tool(String),
273    /// A group of agents, identified by name.
274    Group(String),
275}
276
277impl std::fmt::Display for CapabilitySubject {
278    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
279        match self {
280            CapabilitySubject::Agent(id) => write!(f, "agent:{}", id),
281            CapabilitySubject::Tool(name) => write!(f, "tool:{}", name),
282            CapabilitySubject::Group(name) => write!(f, "group:{}", name),
283        }
284    }
285}
286
287// ── CapabilitySet ──────────────────────────────────────────────────────────
288
289/// A set of capabilities with an optional expiration.
290#[derive(Debug, Clone, Serialize, Deserialize)]
291pub struct CapabilitySet {
292    capabilities: Vec<Capability>,
293    expires_at_ms: Option<u64>,
294}
295
296impl CapabilitySet {
297    /// Create a new set from capabilities.
298    pub fn new(capabilities: Vec<Capability>) -> Self {
299        Self {
300            capabilities,
301            expires_at_ms: None,
302        }
303    }
304
305    // ── Presets ──
306
307    /// All capabilities — for system agents.
308    pub fn all() -> Self {
309        Self::new(vec![
310            Capability::FileRead {
311                path_pattern: "/**".into(),
312            },
313            Capability::FileWrite {
314                path_pattern: "/**".into(),
315            },
316            Capability::FileEdit {
317                path_pattern: "/**".into(),
318            },
319            Capability::FileList {
320                path_pattern: "/**".into(),
321            },
322            Capability::FileFind {
323                path_pattern: "/**".into(),
324            },
325            Capability::Bash {
326                allowed_commands: vec![StringPattern::Wildcard],
327                timeout_secs: None,
328            },
329            Capability::Network {
330                allowed_domains: vec!["*".into()],
331            },
332            Capability::WebBrowse {
333                allowed_domains: vec!["*".into()],
334            },
335            Capability::Subagent { max_children: None },
336            Capability::BusRead { channel: None },
337            Capability::BusWrite { channel: None },
338            Capability::EnvRead {
339                allowed_vars: vec!["*".into()],
340            },
341            Capability::ToolUse {
342                tool_name: "*".into(),
343            },
344        ])
345    }
346
347    /// Read-only access — for research agents.
348    pub fn read_only(workspace: &str) -> Self {
349        let ws = workspace.to_string();
350        Self::new(vec![
351            Capability::FileRead {
352                path_pattern: format!("{}/**", ws),
353            },
354            Capability::FileList {
355                path_pattern: format!("{}/**", ws),
356            },
357            Capability::FileFind {
358                path_pattern: format!("{}/**", ws),
359            },
360            Capability::BusRead { channel: None },
361        ])
362    }
363
364    /// Standard coding agent permissions.
365    pub fn coding(workspace: &str) -> Self {
366        let ws = workspace.to_string();
367        Self::new(vec![
368            Capability::FileRead {
369                path_pattern: format!("{}/**", ws),
370            },
371            Capability::FileWrite {
372                path_pattern: format!("{}/**", ws),
373            },
374            Capability::FileEdit {
375                path_pattern: format!("{}/**", ws),
376            },
377            Capability::FileList {
378                path_pattern: format!("{}/**", ws),
379            },
380            Capability::FileFind {
381                path_pattern: format!("{}/**", ws),
382            },
383            Capability::Bash {
384                allowed_commands: vec![
385                    StringPattern::Literal("git".into()),
386                    StringPattern::Literal("cargo".into()),
387                    StringPattern::Literal("npm".into()),
388                    StringPattern::Literal("node".into()),
389                    StringPattern::Literal("python3".into()),
390                    StringPattern::Literal("ls".into()),
391                    StringPattern::Literal("cat".into()),
392                    StringPattern::Literal("grep".into()),
393                    StringPattern::Literal("rg".into()),
394                    StringPattern::Literal("find".into()),
395                    StringPattern::Literal("mkdir".into()),
396                    StringPattern::Literal("cp".into()),
397                    StringPattern::Literal("mv".into()),
398                ],
399                timeout_secs: Some(30),
400            },
401            Capability::Subagent {
402                max_children: Some(2),
403            },
404            Capability::BusRead { channel: None },
405        ])
406    }
407
408    /// Research agent — read + web browsing, no writes.
409    pub fn research(workspace: &str) -> Self {
410        let ws = workspace.to_string();
411        Self::new(vec![
412            Capability::FileRead {
413                path_pattern: format!("{}/**", ws),
414            },
415            Capability::FileList {
416                path_pattern: format!("{}/**", ws),
417            },
418            Capability::FileFind {
419                path_pattern: format!("{}/**", ws),
420            },
421            Capability::Network {
422                allowed_domains: vec!["*".into()],
423            },
424            Capability::WebBrowse {
425                allowed_domains: vec!["*".into()],
426            },
427            Capability::BusRead { channel: None },
428        ])
429    }
430
431    /// Browser agent — read + browse + output-only writes.
432    pub fn browser(workspace: &str) -> Self {
433        let ws = workspace.to_string();
434        Self::new(vec![
435            Capability::FileRead {
436                path_pattern: format!("{}/**", ws),
437            },
438            Capability::FileWrite {
439                path_pattern: format!("{}/output/**", ws),
440            },
441            Capability::Network {
442                allowed_domains: vec!["*".into()],
443            },
444            Capability::WebBrowse {
445                allowed_domains: vec!["*".into()],
446            },
447        ])
448    }
449
450    // ── Builder ──
451
452    /// Add a capability.
453    pub fn add(&mut self, cap: Capability) -> &mut Self {
454        self.capabilities.push(cap);
455        self
456    }
457
458    /// Set TTL for this capability set.
459    pub fn with_ttl(mut self, duration: Duration) -> Self {
460        let expires = std::time::SystemTime::now()
461            .duration_since(std::time::UNIX_EPOCH)
462            .map(|d| d.as_millis() as u64 + duration.as_millis() as u64)
463            .unwrap_or(u64::MAX);
464        self.expires_at_ms = Some(expires);
465        self
466    }
467
468    /// Check if the set has expired.
469    pub fn is_expired(&self) -> bool {
470        match self.expires_at_ms {
471            Some(expires) => {
472                let now = std::time::SystemTime::now()
473                    .duration_since(std::time::UNIX_EPOCH)
474                    .map(|d| d.as_millis() as u64)
475                    .unwrap_or(0);
476                now > expires
477            }
478            None => false,
479        }
480    }
481
482    /// Access the capabilities.
483    pub fn capabilities(&self) -> &[Capability] {
484        &self.capabilities
485    }
486
487    /// Check if any capability in this set satisfies `required`.
488    pub fn satisfies(&self, required: &Capability) -> bool {
489        self.capabilities.iter().any(|cap| cap.satisfies(required))
490    }
491}
492
493#[cfg(test)]
494mod tests {
495    use super::*;
496
497    #[test]
498    fn file_capability_satisfies() {
499        let cap = Capability::FileRead {
500            path_pattern: "/workspace/**".into(),
501        };
502        let req = Capability::FileRead {
503            path_pattern: "/workspace/src/main.rs".into(),
504        };
505        assert!(cap.satisfies(&req));
506
507        let denied = Capability::FileRead {
508            path_pattern: "/etc/passwd".into(),
509        };
510        assert!(!cap.satisfies(&denied));
511    }
512
513    #[test]
514    fn bash_wildcard_satisfies() {
515        let cap = Capability::Bash {
516            allowed_commands: vec![StringPattern::Wildcard],
517            timeout_secs: None,
518        };
519        let req = Capability::Bash {
520            allowed_commands: vec![StringPattern::Literal("rm".into())],
521            timeout_secs: None,
522        };
523        assert!(cap.satisfies(&req));
524    }
525
526    #[test]
527    fn domain_matching() {
528        let granted = vec!["*.example.com".to_string()];
529        let required = vec!["sub.example.com".to_string()];
530        assert!(domain_matches(&granted, &required));
531
532        let denied = vec!["other.com".to_string()];
533        assert!(!domain_matches(&granted, &denied));
534    }
535
536    #[test]
537    fn capability_set_coding_satisfies() {
538        let set = CapabilitySet::coding("/workspace");
539        assert!(set.satisfies(&Capability::FileRead {
540            path_pattern: "/workspace/src/main.rs".into()
541        }));
542        assert!(!set.satisfies(&Capability::FileWrite {
543            path_pattern: "/etc/passwd".into()
544        }));
545    }
546
547    #[test]
548    fn capability_set_read_only() {
549        let set = CapabilitySet::read_only("/ws");
550        assert!(set.satisfies(&Capability::FileRead {
551            path_pattern: "/ws/any".into()
552        }));
553        assert!(!set.satisfies(&Capability::FileWrite {
554            path_pattern: "/ws/any".into()
555        }));
556    }
557
558    #[test]
559    fn capability_set_all() {
560        let set = CapabilitySet::all();
561        assert!(set.satisfies(&Capability::FileRead {
562            path_pattern: "/anything".into()
563        }));
564        assert!(set.satisfies(&Capability::FileWrite {
565            path_pattern: "/anything".into()
566        }));
567        assert!(set.satisfies(&Capability::Bash {
568            allowed_commands: vec![StringPattern::Literal("anything".into())],
569            timeout_secs: None,
570        }));
571    }
572
573    #[test]
574    fn capability_set_with_ttl_not_expired() {
575        let set = CapabilitySet::coding("/ws").with_ttl(Duration::from_secs(3600));
576        assert!(!set.is_expired());
577    }
578
579    #[test]
580    fn capability_set_expired() {
581        let mut set = CapabilitySet::coding("/ws");
582        set.expires_at_ms = Some(1); // long ago
583        assert!(set.is_expired());
584    }
585
586    #[test]
587    fn capability_set_add() {
588        let mut set = CapabilitySet::new(vec![]);
589        set.add(Capability::FileRead {
590            path_pattern: "/ws".into(),
591        });
592        assert_eq!(set.capabilities().len(), 1);
593    }
594
595    #[test]
596    fn subject_display() {
597        assert_eq!(
598            CapabilitySubject::Agent("a1".into()).to_string(),
599            "agent:a1"
600        );
601        assert_eq!(
602            CapabilitySubject::Tool("read".into()).to_string(),
603            "tool:read"
604        );
605        assert_eq!(
606            CapabilitySubject::Group("coders".into()).to_string(),
607            "group:coders"
608        );
609    }
610}