astrid_workspace/
boundaries.rs1use globset::{Glob, GlobMatcher};
4use serde::{Deserialize, Serialize};
5use std::path::{Path, PathBuf};
6use tracing::{debug, warn};
7
8use crate::config::{EscapePolicy, WorkspaceConfig, WorkspaceMode};
9
10#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
12#[serde(rename_all = "snake_case")]
13pub enum PathCheck {
14 Allowed,
16 AutoAllowed,
18 NeverAllowed,
20 RequiresApproval,
22}
23
24impl PathCheck {
25 #[must_use]
27 pub fn is_allowed(&self) -> bool {
28 matches!(self, Self::Allowed | Self::AutoAllowed)
29 }
30
31 #[must_use]
33 pub fn needs_approval(&self) -> bool {
34 matches!(self, Self::RequiresApproval)
35 }
36
37 #[must_use]
39 pub fn is_blocked(&self) -> bool {
40 matches!(self, Self::NeverAllowed)
41 }
42}
43
44#[derive(Debug)]
48pub struct WorkspaceBoundary {
49 config: WorkspaceConfig,
50 compiled_matchers: Vec<GlobMatcher>,
52}
53
54impl Clone for WorkspaceBoundary {
55 fn clone(&self) -> Self {
56 Self::new(self.config.clone())
58 }
59}
60
61impl WorkspaceBoundary {
62 #[must_use]
66 pub fn new(config: WorkspaceConfig) -> Self {
67 let compiled_matchers = config
68 .auto_allow
69 .patterns
70 .iter()
71 .filter_map(|pattern| match Glob::new(pattern) {
72 Ok(glob) => Some(glob.compile_matcher()),
73 Err(e) => {
74 warn!(pattern = %pattern, error = %e, "Failed to compile glob pattern");
75 None
76 },
77 })
78 .collect();
79
80 Self {
81 config,
82 compiled_matchers,
83 }
84 }
85
86 #[must_use]
88 pub fn config(&self) -> &WorkspaceConfig {
89 &self.config
90 }
91
92 #[must_use]
94 pub fn root(&self) -> &Path {
95 &self.config.root
96 }
97
98 #[must_use]
100 pub fn is_in_workspace(&self, path: &Path) -> bool {
101 let expanded = self.expand_path(path);
102 expanded.starts_with(&self.config.root)
103 }
104
105 #[must_use]
107 pub fn is_auto_allowed(&self, path: &Path) -> bool {
108 let expanded = self.expand_path(path);
109
110 for allowed in &self.config.auto_allow.read {
112 if expanded.starts_with(allowed) {
113 return true;
114 }
115 }
116
117 for allowed in &self.config.auto_allow.write {
119 if expanded.starts_with(allowed) {
120 return true;
121 }
122 }
123
124 for matcher in &self.compiled_matchers {
126 if matcher.is_match(&expanded) {
127 return true;
128 }
129 }
130
131 false
132 }
133
134 #[must_use]
136 pub fn is_never_allowed(&self, path: &Path) -> bool {
137 let expanded = self.expand_path(path);
138
139 for blocked in &self.config.never_allow {
140 let blocked_expanded = blocked.canonicalize().unwrap_or_else(|_| blocked.clone());
142 if expanded.starts_with(&blocked_expanded) {
143 return true;
144 }
145 if expanded.starts_with(blocked) {
147 return true;
148 }
149 }
150
151 false
152 }
153
154 #[must_use]
156 pub fn check(&self, path: &Path) -> PathCheck {
157 let expanded = self.expand_path(path);
158
159 debug!(
160 path = %path.display(),
161 expanded = %expanded.display(),
162 "Checking path against workspace"
163 );
164
165 if self.is_never_allowed(&expanded) {
167 return PathCheck::NeverAllowed;
168 }
169
170 if self.is_in_workspace(&expanded) {
172 return PathCheck::Allowed;
173 }
174
175 if self.is_auto_allowed(&expanded) {
177 return PathCheck::AutoAllowed;
178 }
179
180 match self.config.mode {
182 WorkspaceMode::Autonomous => PathCheck::Allowed,
183 WorkspaceMode::Guided | WorkspaceMode::Safe => match self.config.escape_policy {
184 EscapePolicy::Allow => PathCheck::AutoAllowed,
185 EscapePolicy::Deny => PathCheck::NeverAllowed,
186 EscapePolicy::Ask => PathCheck::RequiresApproval,
187 },
188 }
189 }
190
191 #[must_use]
195 pub fn expand_path(&self, path: &Path) -> PathBuf {
196 path.canonicalize().unwrap_or_else(|_| {
198 if path.is_absolute() {
200 path.to_path_buf()
201 } else {
202 self.config.root.join(path)
203 }
204 })
205 }
206
207 #[must_use]
209 pub fn check_all(&self, paths: &[&Path]) -> PathCheck {
210 let mut result = PathCheck::Allowed;
211
212 for path in paths {
213 let check = self.check(path);
214 match check {
215 PathCheck::NeverAllowed => return PathCheck::NeverAllowed,
216 PathCheck::RequiresApproval => result = PathCheck::RequiresApproval,
217 PathCheck::AutoAllowed if result == PathCheck::Allowed => {
218 result = PathCheck::AutoAllowed;
219 },
220 _ => {},
221 }
222 }
223
224 result
225 }
226}
227
228#[cfg(test)]
229mod tests {
230 use super::*;
231 use tempfile::TempDir;
232
233 #[test]
234 fn test_path_check_helpers() {
235 assert!(PathCheck::Allowed.is_allowed());
236 assert!(PathCheck::AutoAllowed.is_allowed());
237 assert!(!PathCheck::NeverAllowed.is_allowed());
238 assert!(!PathCheck::RequiresApproval.is_allowed());
239
240 assert!(PathCheck::RequiresApproval.needs_approval());
241 assert!(!PathCheck::Allowed.needs_approval());
242
243 assert!(PathCheck::NeverAllowed.is_blocked());
244 assert!(!PathCheck::Allowed.is_blocked());
245 }
246
247 #[test]
248 fn test_workspace_boundary_in_workspace() {
249 let temp_dir = TempDir::new().unwrap();
250 let config = WorkspaceConfig::new(temp_dir.path());
251 let boundary = WorkspaceBoundary::new(config);
252
253 let in_workspace = temp_dir.path().join("src/main.rs");
254 assert!(boundary.is_in_workspace(&in_workspace));
255
256 let outside = PathBuf::from("/tmp/other");
257 assert!(!boundary.is_in_workspace(&outside));
258 }
259
260 #[test]
261 fn test_workspace_boundary_never_allowed() {
262 let config = WorkspaceConfig::new("/home/user/project").never_allow("/etc");
263 let boundary = WorkspaceBoundary::new(config);
264
265 assert!(boundary.is_never_allowed(Path::new("/etc/passwd")));
266 assert_eq!(
267 boundary.check(Path::new("/etc/passwd")),
268 PathCheck::NeverAllowed
269 );
270 }
271
272 #[test]
273 fn test_workspace_boundary_auto_allowed() {
274 let config = WorkspaceConfig::new("/home/user/project").allow_read("/usr/share/doc");
275 let boundary = WorkspaceBoundary::new(config);
276
277 assert!(boundary.is_auto_allowed(Path::new("/usr/share/doc/readme.txt")));
278 }
279
280 #[test]
281 fn test_workspace_boundary_autonomous_mode() {
282 let config =
283 WorkspaceConfig::new("/home/user/project").with_mode(WorkspaceMode::Autonomous);
284 let boundary = WorkspaceBoundary::new(config);
285
286 assert_eq!(
288 boundary.check(Path::new("/tmp/random/file")),
289 PathCheck::Allowed
290 );
291 }
292}