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// This consumes the central permit instead of opening a second approval channel.
75pub async fn authorize_write(
76    ctx: &crate::tool::ToolCtx,
77    target: &Path,
78    operation: &str,
79    allow_permit: bool,
80) -> Result<bool, crate::error::RuntimeError> {
81    let Err(error) = ctx.fs_access.check_write(target) else {
82        return Ok(false);
83    };
84    let blocked = |detail: &str| {
85        Err(crate::error::RuntimeError::ToolFailed(format!(
86            "{operation}({}): {error}{detail}",
87            target.display()
88        )))
89    };
90    if !allow_permit {
91        return blocked("");
92    }
93    let Some(permit) = ctx.invocation_authorization() else {
94        return blocked(" — no central authorization for this call");
95    };
96    if !permit.covers(operation, target) {
97        return blocked(" — central authorization does not cover this target");
98    }
99    Ok(true)
100}
101
102#[derive(Debug, Error)]
103pub enum FsAccessError {
104    #[error("read-only fs access: refusing to write {}", path.display())]
105    ReadOnlyBlocked { path: PathBuf },
106    #[error(
107        "workspace-write fs access: refusing to write {} outside workspace root {}",
108        path.display(),
109        workspace.display()
110    )]
111    OutsideWorkspace { path: PathBuf, workspace: PathBuf },
112}
113
114// Decide whether `target` may be written under the given mode. `workspace`
115// is the current project root — `None` means we don't have one, in which
116// case workspace-write falls back to allowing writes only inside the
117// system temp dir so at least tests and scratch work still function.
118pub fn check_write(
119    target: &Path,
120    workspace: Option<&Path>,
121    mode: FsAccessMode,
122) -> Result<(), FsAccessError> {
123    match mode {
124        FsAccessMode::DangerFullAccess => Ok(()),
125        FsAccessMode::ReadOnly => Err(FsAccessError::ReadOnlyBlocked {
126            path: target.to_path_buf(),
127        }),
128        FsAccessMode::WorkspaceWrite => {
129            let canonical = canonicalize_stable(target);
130            if is_temp_path(&canonical) {
131                return Ok(());
132            }
133            let ws = match workspace {
134                Some(ws) => canonicalize_stable(ws),
135                None => {
136                    return Err(FsAccessError::OutsideWorkspace {
137                        path: canonical,
138                        workspace: PathBuf::from("<none>"),
139                    });
140                }
141            };
142            if canonical.starts_with(&ws) {
143                Ok(())
144            } else {
145                Err(FsAccessError::OutsideWorkspace {
146                    path: canonical,
147                    workspace: ws,
148                })
149            }
150        }
151    }
152}
153
154pub(crate) fn is_temp_path(path: &Path) -> bool {
155    let canonical = canonicalize_stable(path);
156    if canonical.starts_with(canonicalize_stable(&std::env::temp_dir())) {
157        return true;
158    }
159    #[cfg(unix)]
160    if canonical.starts_with(canonicalize_stable(Path::new("/tmp"))) {
161        return true;
162    }
163    false
164}
165
166// canonicalize() only works for existing paths, but we're often asked
167// about paths that will be created. Walk up to the nearest existing
168// ancestor, canonicalize it (this resolves macOS /var → /private/var
169// symlinks), then re-attach the missing tail. That way "workspace" and
170// "target" get the same symlink-resolved prefix and starts_with works.
171pub(crate) fn canonicalize_stable(p: &Path) -> PathBuf {
172    let absolute = if p.is_absolute() {
173        p.to_path_buf()
174    } else {
175        match std::env::current_dir() {
176            Ok(cwd) => cwd.join(p),
177            Err(_) => return p.to_path_buf(),
178        }
179    };
180    let mut ancestor = absolute.as_path();
181    let mut suffix: Vec<&std::ffi::OsStr> = Vec::new();
182    let root = loop {
183        if let Ok(real) = ancestor.canonicalize() {
184            break real;
185        }
186        match (ancestor.parent(), ancestor.file_name()) {
187            (Some(parent), Some(name)) => {
188                suffix.push(name);
189                ancestor = parent;
190            }
191            _ => return absolute,
192        }
193    };
194    let mut out = root;
195    for name in suffix.into_iter().rev() {
196        out.push(name);
197    }
198    out
199}
200
201#[cfg(test)]
202mod tests {
203    use super::*;
204    use tempfile::TempDir;
205
206    fn authorized_ctx(tool: &str, target: &Path) -> crate::tool::ToolCtx {
207        let permit = crate::permission::InvocationAuthorization::new(
208            crate::permission::PermissionRequestId::now(),
209            "call-1",
210            tool,
211            crate::permission::ResourceProvenance::none()
212                .with_path(&crate::tool::ToolCtx::default(), target)
213                .unwrap(),
214            crate::permission::ExecutionBoundary::Sandboxed,
215        );
216        crate::tool::ToolCtx::default()
217            .with_fs_access(FsAccessPolicy {
218                mode: FsAccessMode::ReadOnly,
219                workspace: None,
220            })
221            .authorized_for(permit)
222    }
223
224    #[tokio::test]
225    async fn central_permit_allows_exact_target_without_pending_approval() {
226        let target = PathBuf::from("/etc/atman-permit-test");
227        let approval = std::sync::Arc::new(crate::session::ApprovalRegistry::new());
228        let mut ctx = authorized_ctx("fs.write", &target).with_approval(approval.clone());
229        ctx.flow_run_id = Some(crate::event::FlowRunId::now());
230
231        assert!(
232            authorize_write(&ctx, &target, "fs.write", true)
233                .await
234                .unwrap()
235        );
236        assert!(approval.list_pending().is_empty());
237    }
238
239    #[tokio::test]
240    async fn central_permit_rejects_tool_and_target_mismatches() {
241        let target = PathBuf::from("/etc/atman-permit-test");
242        let ctx = authorized_ctx("fs.write", &target);
243
244        assert!(
245            authorize_write(&ctx, &target, "fs.edit", true)
246                .await
247                .is_err()
248        );
249        assert!(
250            authorize_write(&ctx, Path::new("/etc/atman-other-target"), "fs.write", true,)
251                .await
252                .is_err()
253        );
254    }
255
256    #[tokio::test]
257    async fn per_call_contexts_do_not_share_permits() {
258        let first = PathBuf::from("/etc/atman-first");
259        let second = PathBuf::from("/etc/atman-second");
260        let first_ctx = authorized_ctx("fs.write", &first);
261        let second_ctx = authorized_ctx("fs.write", &second);
262
263        assert!(
264            authorize_write(&first_ctx, &first, "fs.write", true)
265                .await
266                .is_ok()
267        );
268        assert!(
269            authorize_write(&first_ctx, &second, "fs.write", true)
270                .await
271                .is_err()
272        );
273        assert!(
274            authorize_write(&second_ctx, &second, "fs.write", true)
275                .await
276                .is_ok()
277        );
278        assert!(
279            authorize_write(&second_ctx, &first, "fs.write", true)
280                .await
281                .is_err()
282        );
283    }
284
285    #[test]
286    fn parse_canonical_forms() {
287        use std::str::FromStr;
288        assert_eq!(
289            FsAccessMode::from_str("read-only").unwrap(),
290            FsAccessMode::ReadOnly
291        );
292        assert_eq!(
293            FsAccessMode::from_str("workspace-write").unwrap(),
294            FsAccessMode::WorkspaceWrite
295        );
296        assert_eq!(
297            FsAccessMode::from_str("danger-full-access").unwrap(),
298            FsAccessMode::DangerFullAccess
299        );
300    }
301
302    #[test]
303    fn parse_aliases() {
304        use std::str::FromStr;
305        assert_eq!(
306            FsAccessMode::from_str("ro").unwrap(),
307            FsAccessMode::ReadOnly
308        );
309        assert_eq!(
310            FsAccessMode::from_str("ws").unwrap(),
311            FsAccessMode::WorkspaceWrite
312        );
313        assert_eq!(
314            FsAccessMode::from_str("danger").unwrap(),
315            FsAccessMode::DangerFullAccess
316        );
317    }
318
319    #[test]
320    fn parse_rejects_unknown() {
321        use std::str::FromStr;
322        let err = FsAccessMode::from_str("chaos").unwrap_err();
323        assert!(err.contains("chaos"));
324    }
325
326    #[test]
327    fn default_is_workspace_write() {
328        assert_eq!(FsAccessMode::default(), FsAccessMode::WorkspaceWrite);
329    }
330
331    #[test]
332    fn round_trip_as_str_and_from_str() {
333        use std::str::FromStr;
334        for mode in [
335            FsAccessMode::ReadOnly,
336            FsAccessMode::WorkspaceWrite,
337            FsAccessMode::DangerFullAccess,
338        ] {
339            assert_eq!(FsAccessMode::from_str(mode.as_str()).unwrap(), mode);
340        }
341    }
342
343    #[test]
344    fn read_only_blocks_every_write() {
345        let ws = TempDir::new().unwrap();
346        let target = ws.path().join("inside.txt");
347        let err = check_write(&target, Some(ws.path()), FsAccessMode::ReadOnly).unwrap_err();
348        assert!(matches!(err, FsAccessError::ReadOnlyBlocked { .. }));
349    }
350
351    #[test]
352    fn danger_full_access_permits_arbitrary_paths() {
353        let target = std::env::temp_dir().join("some/absurd/deep/path.txt");
354        assert!(check_write(&target, None, FsAccessMode::DangerFullAccess).is_ok());
355        assert!(
356            check_write(
357                Path::new("/etc/passwd"),
358                None,
359                FsAccessMode::DangerFullAccess
360            )
361            .is_ok()
362        );
363    }
364
365    #[test]
366    fn workspace_write_allows_paths_inside_workspace() {
367        let ws = TempDir::new().unwrap();
368        let nested = ws.path().join("sub/dir");
369        std::fs::create_dir_all(&nested).unwrap();
370        let target = nested.join("a.txt");
371        assert!(check_write(&target, Some(ws.path()), FsAccessMode::WorkspaceWrite).is_ok());
372    }
373
374    #[test]
375    fn workspace_write_allows_paths_inside_tempdir() {
376        let ws = TempDir::new().unwrap();
377        let scratch = TempDir::new().unwrap();
378        let target = scratch.path().join("scratch.txt");
379        assert!(check_write(&target, Some(ws.path()), FsAccessMode::WorkspaceWrite).is_ok());
380    }
381
382    #[cfg(unix)]
383    #[test]
384    fn workspace_write_allows_platform_tmp_root() {
385        let ws = TempDir::new().unwrap();
386        let target = Path::new("/tmp").join("atman-scratch.txt");
387        assert!(check_write(&target, Some(ws.path()), FsAccessMode::WorkspaceWrite).is_ok());
388    }
389
390    #[test]
391    fn workspace_write_blocks_paths_outside_workspace_and_tempdir() {
392        let ws = TempDir::new().unwrap();
393        let outside = TempDir::new().unwrap();
394        let outside_target = outside.path().join("../../etc/passwd");
395        // canonicalize will resolve the .. so we craft a clearly-outside path
396        // by using an absolute path anchored elsewhere.
397        let bad = PathBuf::from("/etc/passwd");
398        let err = check_write(&bad, Some(ws.path()), FsAccessMode::WorkspaceWrite).unwrap_err();
399        assert!(matches!(err, FsAccessError::OutsideWorkspace { .. }));
400        drop(outside_target);
401    }
402
403    #[test]
404    fn workspace_write_without_workspace_only_allows_tempdir() {
405        let scratch = TempDir::new().unwrap();
406        assert!(
407            check_write(
408                &scratch.path().join("f.txt"),
409                None,
410                FsAccessMode::WorkspaceWrite,
411            )
412            .is_ok()
413        );
414        let err =
415            check_write(Path::new("/etc/passwd"), None, FsAccessMode::WorkspaceWrite).unwrap_err();
416        assert!(matches!(err, FsAccessError::OutsideWorkspace { .. }));
417    }
418
419    #[test]
420    fn nonexistent_target_still_checked_against_workspace() {
421        let ws = TempDir::new().unwrap();
422        // File doesn't exist yet — canonicalize will fail — but the parent
423        // directory chain is inside the workspace so the write must succeed.
424        let target = ws.path().join("does/not/exist/yet.txt");
425        assert!(check_write(&target, Some(ws.path()), FsAccessMode::WorkspaceWrite).is_ok());
426    }
427
428    #[test]
429    fn error_messages_reference_the_offending_paths() {
430        let ws = TempDir::new().unwrap();
431        let err = check_write(
432            Path::new("/etc/passwd"),
433            Some(ws.path()),
434            FsAccessMode::WorkspaceWrite,
435        )
436        .unwrap_err();
437        let msg = err.to_string();
438        assert!(msg.contains("/etc/passwd"));
439        assert!(msg.contains(&ws.path().display().to_string()));
440    }
441}