Skip to main content

oxicode_sdk/ports/fs/
access.rs

1//! Simple rule-based `AccessGate` — TOML allow/deny list per tool.
2//!
3//! Reads `<path>/access.toml` with this schema:
4//!
5//! ```toml
6//! [rules.bash]
7//! # Patterns are substring matches on `request.action` (the command line).
8//! deny = ["rm -rf /", "rm -rf ~", ":(){:|:&};"]    # catastrophic commands
9//! require_approval = ["sudo ", "apt ", "brew "]
10//!
11//! [rules.write]
12//! deny = ["/etc/", "/usr/"]
13//! require_approval = [".ssh/", ".aws/credentials"]
14//!
15//! [rules.edit]
16//! require_approval = [".git/"]
17//! ```
18//!
19//! Resolution order per request: `deny` → `require_approval` → `Allow`.
20//! Patterns are matched as substrings on `request.action`.
21
22use 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
30/// Rule-based gate. Pure sync, no I/O at request time.
31pub 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    /// Create a gate that allows everything.
60    pub fn permissive() -> Self {
61        Self {
62            rules: parking_lot::RwLock::new(Rules::default()),
63            path: None,
64        }
65    }
66
67    /// Load rules from a TOML file. If the file does not exist, the gate
68    /// is permissive (allows all).
69    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    /// Reload rules from disk. Replaces the current in-memory rules.
86    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        // First: old pattern denied.
210        let d1 = g.check(&req("bash", "old-pattern")).await.unwrap();
211        assert!(matches!(d1, AccessDecision::Deny { .. }));
212        // Update file.
213        fs::write(&p, "[rules.bash]\ndeny = [\"new-pattern\"]\n").unwrap();
214        g.reload().unwrap();
215        // Old pattern now allowed.
216        let d2 = g.check(&req("bash", "old-pattern")).await.unwrap();
217        assert_eq!(d2, AccessDecision::Allow);
218        // New pattern denied.
219        let d3 = g.check(&req("bash", "new-pattern")).await.unwrap();
220        assert!(matches!(d3, AccessDecision::Deny { .. }));
221    }
222}