Skip to main content

astrid_workspace/
boundaries.rs

1//! Workspace boundary checking.
2
3use 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/// Result of checking a path against workspace boundaries.
11#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
12#[serde(rename_all = "snake_case")]
13pub enum PathCheck {
14    /// Path is within the workspace, allowed.
15    Allowed,
16    /// Path is auto-allowed (outside workspace but configured).
17    AutoAllowed,
18    /// Path is never allowed (protected system path).
19    NeverAllowed,
20    /// Path requires user approval.
21    RequiresApproval,
22}
23
24impl PathCheck {
25    /// Check if the path is allowed (directly or auto).
26    #[must_use]
27    pub fn is_allowed(&self) -> bool {
28        matches!(self, Self::Allowed | Self::AutoAllowed)
29    }
30
31    /// Check if the path requires approval.
32    #[must_use]
33    pub fn needs_approval(&self) -> bool {
34        matches!(self, Self::RequiresApproval)
35    }
36
37    /// Check if the path is never allowed.
38    #[must_use]
39    pub fn is_blocked(&self) -> bool {
40        matches!(self, Self::NeverAllowed)
41    }
42}
43
44/// Workspace boundary checker.
45///
46/// Pre-compiles glob patterns for efficient matching.
47#[derive(Debug)]
48pub struct WorkspaceBoundary {
49    config: WorkspaceConfig,
50    /// Pre-compiled glob matchers for auto-allow patterns.
51    compiled_matchers: Vec<GlobMatcher>,
52}
53
54impl Clone for WorkspaceBoundary {
55    fn clone(&self) -> Self {
56        // Re-compile matchers when cloning
57        Self::new(self.config.clone())
58    }
59}
60
61impl WorkspaceBoundary {
62    /// Create a new workspace boundary checker.
63    ///
64    /// Pre-compiles all glob patterns in the configuration.
65    #[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    /// Get the workspace configuration.
87    #[must_use]
88    pub fn config(&self) -> &WorkspaceConfig {
89        &self.config
90    }
91
92    /// Get the workspace root.
93    #[must_use]
94    pub fn root(&self) -> &Path {
95        &self.config.root
96    }
97
98    /// Check if a path is within the workspace.
99    #[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    /// Check if a path is auto-allowed.
106    #[must_use]
107    pub fn is_auto_allowed(&self, path: &Path) -> bool {
108        let expanded = self.expand_path(path);
109
110        // Check read paths
111        for allowed in &self.config.auto_allow.read {
112            if expanded.starts_with(allowed) {
113                return true;
114            }
115        }
116
117        // Check write paths
118        for allowed in &self.config.auto_allow.write {
119            if expanded.starts_with(allowed) {
120                return true;
121            }
122        }
123
124        // Check pre-compiled glob patterns
125        for matcher in &self.compiled_matchers {
126            if matcher.is_match(&expanded) {
127                return true;
128            }
129        }
130
131        false
132    }
133
134    /// Check if a path is never allowed.
135    #[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            // Canonicalize the blocked path too (handles symlinks like /etc -> /private/etc on macOS)
141            let blocked_expanded = blocked.canonicalize().unwrap_or_else(|_| blocked.clone());
142            if expanded.starts_with(&blocked_expanded) {
143                return true;
144            }
145            // Also check without canonicalization for non-existent paths
146            if expanded.starts_with(blocked) {
147                return true;
148            }
149        }
150
151        false
152    }
153
154    /// Check a path against the workspace boundaries.
155    #[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        // Check never-allowed first
166        if self.is_never_allowed(&expanded) {
167            return PathCheck::NeverAllowed;
168        }
169
170        // Check if in workspace
171        if self.is_in_workspace(&expanded) {
172            return PathCheck::Allowed;
173        }
174
175        // Check auto-allowed
176        if self.is_auto_allowed(&expanded) {
177            return PathCheck::AutoAllowed;
178        }
179
180        // Check mode
181        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    /// Expand a path to its canonical form.
192    ///
193    /// This resolves `.`, `..`, and symlinks if the path exists.
194    #[must_use]
195    pub fn expand_path(&self, path: &Path) -> PathBuf {
196        // Try to canonicalize, fall back to the original path
197        path.canonicalize().unwrap_or_else(|_| {
198            // If the path doesn't exist, try to normalize it manually
199            if path.is_absolute() {
200                path.to_path_buf()
201            } else {
202                self.config.root.join(path)
203            }
204        })
205    }
206
207    /// Check multiple paths and return the most restrictive result.
208    #[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        // In autonomous mode, everything except never-allowed is allowed
287        assert_eq!(
288            boundary.check(Path::new("/tmp/random/file")),
289            PathCheck::Allowed
290        );
291    }
292}