Skip to main content

atman_runtime/
fs_access.rs

1use std::path::{Path, PathBuf};
2
3use serde::{Deserialize, Serialize};
4use thiserror::Error;
5
6// FsAccessMode is the file-system tier of the sandbox policy. It's
7// separate from atman-daemon's Seatbelt SandboxConfig (which decides
8// whether `shell.exec` runs inside sandbox-exec) — that policy asks
9// "do we wrap the shell?", this policy asks "which paths can atman's
10// own file tools touch?".
11#[derive(Debug, Clone, Copy, Default, Serialize, Deserialize, PartialEq, Eq)]
12#[serde(rename_all = "kebab-case")]
13pub enum FsAccessMode {
14    ReadOnly,
15    #[default]
16    WorkspaceWrite,
17    DangerFullAccess,
18}
19
20impl FsAccessMode {
21    pub fn as_str(self) -> &'static str {
22        match self {
23            Self::ReadOnly => "read-only",
24            Self::WorkspaceWrite => "workspace-write",
25            Self::DangerFullAccess => "danger-full-access",
26        }
27    }
28}
29
30impl std::str::FromStr for FsAccessMode {
31    type Err = String;
32
33    fn from_str(s: &str) -> Result<Self, Self::Err> {
34        match s {
35            "read-only" | "readonly" | "ro" => Ok(Self::ReadOnly),
36            "workspace-write" | "workspace" | "ws" => Ok(Self::WorkspaceWrite),
37            "danger-full-access" | "full-access" | "danger" => Ok(Self::DangerFullAccess),
38            other => Err(format!(
39                "unknown fs access mode: {other}. Expected one of: read-only, workspace-write, danger-full-access"
40            )),
41        }
42    }
43}
44
45// Bundle mode + workspace so ToolCtx carries one thing, not two. Default
46// is workspace-write with no workspace — writes will fall back to
47// tempdir-only, which is the safe posture when running without a project.
48#[derive(Debug, Clone, Default)]
49pub struct FsAccessPolicy {
50    pub mode: FsAccessMode,
51    pub workspace: Option<PathBuf>,
52}
53
54impl FsAccessPolicy {
55    pub fn danger_full_access() -> Self {
56        Self {
57            mode: FsAccessMode::DangerFullAccess,
58            workspace: None,
59        }
60    }
61
62    pub fn workspace_write(workspace: PathBuf) -> Self {
63        Self {
64            mode: FsAccessMode::WorkspaceWrite,
65            workspace: Some(workspace),
66        }
67    }
68
69    pub fn check_write(&self, target: &Path) -> Result<(), FsAccessError> {
70        check_write(target, self.workspace.as_deref(), self.mode)
71    }
72}
73
74#[derive(Debug, Error)]
75pub enum FsAccessError {
76    #[error("read-only fs access: refusing to write {}", path.display())]
77    ReadOnlyBlocked { path: PathBuf },
78    #[error(
79        "workspace-write fs access: refusing to write {} outside workspace root {}",
80        path.display(),
81        workspace.display()
82    )]
83    OutsideWorkspace { path: PathBuf, workspace: PathBuf },
84}
85
86// Decide whether `target` may be written under the given mode. `workspace`
87// is the current project root — `None` means we don't have one, in which
88// case workspace-write falls back to allowing writes only inside the
89// system temp dir so at least tests and scratch work still function.
90pub fn check_write(
91    target: &Path,
92    workspace: Option<&Path>,
93    mode: FsAccessMode,
94) -> Result<(), FsAccessError> {
95    match mode {
96        FsAccessMode::DangerFullAccess => Ok(()),
97        FsAccessMode::ReadOnly => Err(FsAccessError::ReadOnlyBlocked {
98            path: target.to_path_buf(),
99        }),
100        FsAccessMode::WorkspaceWrite => {
101            let canonical = canonicalize_stable(target);
102            let temp = canonicalize_stable(&std::env::temp_dir());
103            if canonical.starts_with(&temp) {
104                return Ok(());
105            }
106            let ws = match workspace {
107                Some(ws) => canonicalize_stable(ws),
108                None => {
109                    return Err(FsAccessError::OutsideWorkspace {
110                        path: canonical,
111                        workspace: PathBuf::from("<none>"),
112                    });
113                }
114            };
115            if canonical.starts_with(&ws) {
116                Ok(())
117            } else {
118                Err(FsAccessError::OutsideWorkspace {
119                    path: canonical,
120                    workspace: ws,
121                })
122            }
123        }
124    }
125}
126
127// canonicalize() only works for existing paths, but we're often asked
128// about paths that will be created. Walk up to the nearest existing
129// ancestor, canonicalize it (this resolves macOS /var → /private/var
130// symlinks), then re-attach the missing tail. That way "workspace" and
131// "target" get the same symlink-resolved prefix and starts_with works.
132fn canonicalize_stable(p: &Path) -> PathBuf {
133    let absolute = if p.is_absolute() {
134        p.to_path_buf()
135    } else {
136        match std::env::current_dir() {
137            Ok(cwd) => cwd.join(p),
138            Err(_) => return p.to_path_buf(),
139        }
140    };
141    let mut ancestor = absolute.as_path();
142    let mut suffix: Vec<&std::ffi::OsStr> = Vec::new();
143    let root = loop {
144        if let Ok(real) = ancestor.canonicalize() {
145            break real;
146        }
147        match (ancestor.parent(), ancestor.file_name()) {
148            (Some(parent), Some(name)) => {
149                suffix.push(name);
150                ancestor = parent;
151            }
152            _ => return absolute,
153        }
154    };
155    let mut out = root;
156    for name in suffix.into_iter().rev() {
157        out.push(name);
158    }
159    out
160}
161
162#[cfg(test)]
163mod tests {
164    use super::*;
165    use tempfile::TempDir;
166
167    #[test]
168    fn parse_canonical_forms() {
169        use std::str::FromStr;
170        assert_eq!(
171            FsAccessMode::from_str("read-only").unwrap(),
172            FsAccessMode::ReadOnly
173        );
174        assert_eq!(
175            FsAccessMode::from_str("workspace-write").unwrap(),
176            FsAccessMode::WorkspaceWrite
177        );
178        assert_eq!(
179            FsAccessMode::from_str("danger-full-access").unwrap(),
180            FsAccessMode::DangerFullAccess
181        );
182    }
183
184    #[test]
185    fn parse_aliases() {
186        use std::str::FromStr;
187        assert_eq!(
188            FsAccessMode::from_str("ro").unwrap(),
189            FsAccessMode::ReadOnly
190        );
191        assert_eq!(
192            FsAccessMode::from_str("ws").unwrap(),
193            FsAccessMode::WorkspaceWrite
194        );
195        assert_eq!(
196            FsAccessMode::from_str("danger").unwrap(),
197            FsAccessMode::DangerFullAccess
198        );
199    }
200
201    #[test]
202    fn parse_rejects_unknown() {
203        use std::str::FromStr;
204        let err = FsAccessMode::from_str("chaos").unwrap_err();
205        assert!(err.contains("chaos"));
206    }
207
208    #[test]
209    fn default_is_workspace_write() {
210        assert_eq!(FsAccessMode::default(), FsAccessMode::WorkspaceWrite);
211    }
212
213    #[test]
214    fn round_trip_as_str_and_from_str() {
215        use std::str::FromStr;
216        for mode in [
217            FsAccessMode::ReadOnly,
218            FsAccessMode::WorkspaceWrite,
219            FsAccessMode::DangerFullAccess,
220        ] {
221            assert_eq!(FsAccessMode::from_str(mode.as_str()).unwrap(), mode);
222        }
223    }
224
225    #[test]
226    fn read_only_blocks_every_write() {
227        let ws = TempDir::new().unwrap();
228        let target = ws.path().join("inside.txt");
229        let err = check_write(&target, Some(ws.path()), FsAccessMode::ReadOnly).unwrap_err();
230        assert!(matches!(err, FsAccessError::ReadOnlyBlocked { .. }));
231    }
232
233    #[test]
234    fn danger_full_access_permits_arbitrary_paths() {
235        let target = std::env::temp_dir().join("some/absurd/deep/path.txt");
236        assert!(check_write(&target, None, FsAccessMode::DangerFullAccess).is_ok());
237        assert!(
238            check_write(
239                Path::new("/etc/passwd"),
240                None,
241                FsAccessMode::DangerFullAccess
242            )
243            .is_ok()
244        );
245    }
246
247    #[test]
248    fn workspace_write_allows_paths_inside_workspace() {
249        let ws = TempDir::new().unwrap();
250        let nested = ws.path().join("sub/dir");
251        std::fs::create_dir_all(&nested).unwrap();
252        let target = nested.join("a.txt");
253        assert!(check_write(&target, Some(ws.path()), FsAccessMode::WorkspaceWrite).is_ok());
254    }
255
256    #[test]
257    fn workspace_write_allows_paths_inside_tempdir() {
258        let ws = TempDir::new().unwrap();
259        let scratch = TempDir::new().unwrap();
260        let target = scratch.path().join("scratch.txt");
261        assert!(check_write(&target, Some(ws.path()), FsAccessMode::WorkspaceWrite).is_ok());
262    }
263
264    #[test]
265    fn workspace_write_blocks_paths_outside_workspace_and_tempdir() {
266        let ws = TempDir::new().unwrap();
267        let outside = TempDir::new().unwrap();
268        let outside_target = outside.path().join("../../etc/passwd");
269        // canonicalize will resolve the .. so we craft a clearly-outside path
270        // by using an absolute path anchored elsewhere.
271        let bad = PathBuf::from("/etc/passwd");
272        let err = check_write(&bad, Some(ws.path()), FsAccessMode::WorkspaceWrite).unwrap_err();
273        assert!(matches!(err, FsAccessError::OutsideWorkspace { .. }));
274        drop(outside_target);
275    }
276
277    #[test]
278    fn workspace_write_without_workspace_only_allows_tempdir() {
279        let scratch = TempDir::new().unwrap();
280        assert!(
281            check_write(
282                &scratch.path().join("f.txt"),
283                None,
284                FsAccessMode::WorkspaceWrite,
285            )
286            .is_ok()
287        );
288        let err =
289            check_write(Path::new("/etc/passwd"), None, FsAccessMode::WorkspaceWrite).unwrap_err();
290        assert!(matches!(err, FsAccessError::OutsideWorkspace { .. }));
291    }
292
293    #[test]
294    fn nonexistent_target_still_checked_against_workspace() {
295        let ws = TempDir::new().unwrap();
296        // File doesn't exist yet — canonicalize will fail — but the parent
297        // directory chain is inside the workspace so the write must succeed.
298        let target = ws.path().join("does/not/exist/yet.txt");
299        assert!(check_write(&target, Some(ws.path()), FsAccessMode::WorkspaceWrite).is_ok());
300    }
301
302    #[test]
303    fn error_messages_reference_the_offending_paths() {
304        let ws = TempDir::new().unwrap();
305        let err = check_write(
306            Path::new("/etc/passwd"),
307            Some(ws.path()),
308            FsAccessMode::WorkspaceWrite,
309        )
310        .unwrap_err();
311        let msg = err.to_string();
312        assert!(msg.contains("/etc/passwd"));
313        assert!(msg.contains(&ws.path().display().to_string()));
314    }
315}