oxicode_sdk/ports/fs/
capability.rs1use 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
12pub 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 pub fn empty() -> Self {
42 Self {
43 path: None,
44 subjects: RwLock::new(BTreeMap::new()),
45 }
46 }
47
48 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 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 if let Some(list) = g.get(subject) {
94 return list.clone();
95 }
96 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}