oxicode_sdk/ports/fs/
access.rs1use serde::Deserialize;
23use std::future::Future;
24use std::path::PathBuf;
25use std::pin::Pin;
26
27use crate::SdkError;
28use crate::ports::{AccessDecision, AccessGate, ToolCallRequest};
29
30pub struct SimpleAccessGate {
32 rules: parking_lot::RwLock<Rules>,
33 path: Option<PathBuf>,
34}
35
36impl std::fmt::Debug for SimpleAccessGate {
37 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
38 f.debug_struct("SimpleAccessGate")
39 .field("path", &self.path)
40 .finish()
41 }
42}
43
44#[derive(Debug, Default, Clone, Deserialize)]
45struct Rules {
46 #[serde(default)]
47 rules: std::collections::BTreeMap<String, ToolRule>,
48}
49
50#[derive(Debug, Default, Clone, Deserialize)]
51struct ToolRule {
52 #[serde(default)]
53 deny: Vec<String>,
54 #[serde(default)]
55 require_approval: Vec<String>,
56}
57
58impl SimpleAccessGate {
59 pub fn permissive() -> Self {
61 Self {
62 rules: parking_lot::RwLock::new(Rules::default()),
63 path: None,
64 }
65 }
66
67 pub fn from_file(path: impl Into<PathBuf>) -> Self {
70 let path = path.into();
71 let rules = if path.exists() {
72 std::fs::read_to_string(&path)
73 .ok()
74 .and_then(|s| toml::from_str(&s).ok())
75 .unwrap_or_default()
76 } else {
77 Rules::default()
78 };
79 Self {
80 rules: parking_lot::RwLock::new(rules),
81 path: Some(path),
82 }
83 }
84
85 pub fn reload(&self) -> std::io::Result<()> {
87 let Some(path) = &self.path else {
88 return Ok(());
89 };
90 let text = std::fs::read_to_string(path)?;
91 let parsed: Rules = toml::from_str(&text)
92 .map_err(|e| std::io::Error::new(std::io::ErrorKind::InvalidData, e.to_string()))?;
93 *self.rules.write() = parsed;
94 Ok(())
95 }
96}
97
98impl AccessGate for SimpleAccessGate {
99 fn check(
100 &self,
101 request: &ToolCallRequest,
102 ) -> Pin<Box<dyn Future<Output = Result<AccessDecision, SdkError>> + Send + '_>> {
103 let decision = self.check_sync(request);
104 Box::pin(async move { Ok(decision) })
105 }
106}
107
108impl SimpleAccessGate {
109 fn check_sync(&self, request: &ToolCallRequest) -> AccessDecision {
110 let rules = self.rules.read();
111 let Some(rule) = rules.rules.get(&request.tool) else {
112 return AccessDecision::Allow;
113 };
114 for pat in &rule.deny {
115 if request.action.contains(pat) {
116 return AccessDecision::Deny {
117 reason: format!("matches deny pattern: {pat}"),
118 };
119 }
120 }
121 for pat in &rule.require_approval {
122 if request.action.contains(pat) {
123 return AccessDecision::RequireApproval {
124 reason: format!("matches approval pattern: {pat}"),
125 };
126 }
127 }
128 AccessDecision::Allow
129 }
130}
131
132#[cfg(test)]
133mod tests {
134 use super::*;
135 use std::fs;
136 use tempfile::TempDir;
137
138 fn req(tool: &str, action: &str) -> ToolCallRequest {
139 ToolCallRequest {
140 tool: tool.into(),
141 action: action.into(),
142 cwd: std::path::PathBuf::from("/tmp"),
143 subject: "test".into(),
144 }
145 }
146
147 #[tokio::test]
148 async fn permissive_allows_all() {
149 let g = SimpleAccessGate::permissive();
150 let d = g.check(&req("bash", "ls -la")).await.unwrap();
151 assert_eq!(d, AccessDecision::Allow);
152 }
153
154 #[tokio::test]
155 async fn deny_pattern_blocks() {
156 let tmp = TempDir::new().unwrap();
157 let p = tmp.path().join("access.toml");
158 fs::write(
159 &p,
160 r#"[rules.bash]
161deny = ["rm -rf /"]
162"#,
163 )
164 .unwrap();
165 let g = SimpleAccessGate::from_file(&p);
166 let d = g.check(&req("bash", "rm -rf /")).await.unwrap();
167 assert!(matches!(d, AccessDecision::Deny { .. }));
168 }
169
170 #[tokio::test]
171 async fn approval_pattern_pauses() {
172 let tmp = TempDir::new().unwrap();
173 let p = tmp.path().join("access.toml");
174 fs::write(
175 &p,
176 r#"[rules.bash]
177require_approval = ["sudo "]
178"#,
179 )
180 .unwrap();
181 let g = SimpleAccessGate::from_file(&p);
182 let d = g.check(&req("bash", "sudo apt update")).await.unwrap();
183 assert!(matches!(d, AccessDecision::RequireApproval { .. }));
184 }
185
186 #[tokio::test]
187 async fn unmatched_action_allows() {
188 let tmp = TempDir::new().unwrap();
189 let p = tmp.path().join("access.toml");
190 fs::write(
191 &p,
192 r#"[rules.bash]
193deny = ["rm -rf /"]
194require_approval = ["sudo "]
195"#,
196 )
197 .unwrap();
198 let g = SimpleAccessGate::from_file(&p);
199 let d = g.check(&req("bash", "ls -la")).await.unwrap();
200 assert_eq!(d, AccessDecision::Allow);
201 }
202
203 #[tokio::test]
204 async fn reload_picks_up_changes() {
205 let tmp = TempDir::new().unwrap();
206 let p = tmp.path().join("access.toml");
207 fs::write(&p, "[rules.bash]\ndeny = [\"old-pattern\"]\n").unwrap();
208 let g = SimpleAccessGate::from_file(&p);
209 let d1 = g.check(&req("bash", "old-pattern")).await.unwrap();
211 assert!(matches!(d1, AccessDecision::Deny { .. }));
212 fs::write(&p, "[rules.bash]\ndeny = [\"new-pattern\"]\n").unwrap();
214 g.reload().unwrap();
215 let d2 = g.check(&req("bash", "old-pattern")).await.unwrap();
217 assert_eq!(d2, AccessDecision::Allow);
218 let d3 = g.check(&req("bash", "new-pattern")).await.unwrap();
220 assert!(matches!(d3, AccessDecision::Deny { .. }));
221 }
222}