1use std::path::{Path, PathBuf};
2
3use serde::{Deserialize, Serialize};
4use thiserror::Error;
5
6#[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#[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
74pub 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
114pub 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
166pub(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 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 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}