Skip to main content

oxicode_sdk/ports/fs/
capability.rs

1//! Rule-based `CapabilityResolver` — TOML config maps subjects to tool lists.
2
3use parking_lot::RwLock;
4use std::collections::BTreeMap;
5use std::future::Future;
6use std::path::PathBuf;
7use std::pin::Pin;
8
9use crate::SdkError;
10use crate::ports::CapabilityResolver;
11
12/// Resolves visible tools per subject from a TOML file:
13///
14/// ```toml
15/// [subjects]
16/// "agent-1" = ["read", "grep", "ls"]
17/// "agent-2" = ["read", "write", "edit", "bash"]
18/// "agent-*" = ["read"]   # default — wildcard suffix
19/// ```
20pub struct TomlCapabilityResolver {
21    path: Option<PathBuf>,
22    subjects: RwLock<BTreeMap<String, Vec<String>>>,
23}
24
25impl std::fmt::Debug for TomlCapabilityResolver {
26    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
27        f.debug_struct("TomlCapabilityResolver")
28            .field("path", &self.path)
29            .finish()
30    }
31}
32
33#[derive(Debug, Default, serde::Deserialize)]
34struct RulesFile {
35    #[serde(default)]
36    subjects: BTreeMap<String, Vec<String>>,
37}
38
39impl TomlCapabilityResolver {
40    /// Create a resolver that allows no tools.
41    pub fn empty() -> Self {
42        Self {
43            path: None,
44            subjects: RwLock::new(BTreeMap::new()),
45        }
46    }
47
48    /// Load rules from a TOML file. Missing file = empty rules.
49    pub fn from_file(path: impl Into<PathBuf>) -> Self {
50        let path = path.into();
51        let subjects = if path.exists() {
52            std::fs::read_to_string(&path)
53                .ok()
54                .and_then(|s| toml::from_str::<RulesFile>(&s).ok())
55                .map(|f| f.subjects)
56                .unwrap_or_default()
57        } else {
58            BTreeMap::new()
59        };
60        Self {
61            path: Some(path),
62            subjects: RwLock::new(subjects),
63        }
64    }
65
66    /// Re-read the TOML file and replace the in-memory rules. No-op (returns `Ok(())`) if this resolver was not created from a file.
67    pub fn reload(&self) -> std::io::Result<()> {
68        let Some(path) = &self.path else {
69            return Ok(());
70        };
71        let text = std::fs::read_to_string(path)?;
72        let parsed: RulesFile = toml::from_str(&text)
73            .map_err(|e| std::io::Error::new(std::io::ErrorKind::InvalidData, e.to_string()))?;
74        *self.subjects.write() = parsed.subjects;
75        Ok(())
76    }
77}
78
79impl CapabilityResolver for TomlCapabilityResolver {
80    fn visible_tools(
81        &self,
82        subject: &str,
83    ) -> Pin<Box<dyn Future<Output = Result<Vec<String>, SdkError>> + Send + '_>> {
84        let result = self.resolve_sync(subject);
85        Box::pin(async move { Ok(result) })
86    }
87}
88
89impl TomlCapabilityResolver {
90    fn resolve_sync(&self, subject: &str) -> Vec<String> {
91        let g = self.subjects.read();
92        // Exact match first.
93        if let Some(list) = g.get(subject) {
94            return list.clone();
95        }
96        // Wildcard suffix: keys ending with `*` are defaults.
97        let mut best: Option<&Vec<String>> = None;
98        for (key, list) in g.iter() {
99            if let Some(prefix) = key.strip_suffix('*')
100                && subject.starts_with(prefix)
101                && (best.is_none() || prefix.len() > key.trim_end_matches('*').len())
102            {
103                best = Some(list);
104            }
105        }
106        best.cloned().unwrap_or_default()
107    }
108}
109
110#[cfg(test)]
111mod tests {
112    use super::*;
113    use std::fs;
114    use tempfile::TempDir;
115
116    #[tokio::test]
117    async fn empty_resolver_returns_nothing() {
118        let r = TomlCapabilityResolver::empty();
119        assert!(r.visible_tools("anyone").await.unwrap().is_empty());
120    }
121
122    #[tokio::test]
123    async fn exact_match() {
124        let tmp = TempDir::new().unwrap();
125        let p = tmp.path().join("caps.toml");
126        fs::write(
127            &p,
128            r#"[subjects]
129"agent-1" = ["read", "write"]
130"#,
131        )
132        .unwrap();
133        let r = TomlCapabilityResolver::from_file(&p);
134        let v = r.visible_tools("agent-1").await.unwrap();
135        assert_eq!(v, vec!["read", "write"]);
136    }
137
138    #[tokio::test]
139    async fn wildcard_default() {
140        let tmp = TempDir::new().unwrap();
141        let p = tmp.path().join("caps.toml");
142        fs::write(
143            &p,
144            r#"[subjects]
145"agent-*" = ["read"]
146"#,
147        )
148        .unwrap();
149        let r = TomlCapabilityResolver::from_file(&p);
150        let v = r.visible_tools("agent-anything").await.unwrap();
151        assert_eq!(v, vec!["read"]);
152    }
153}