Skip to main content

agentmux_srv/backend/
workdir_fs.rs

1// Copyright 2026, AgentMux Corp.
2// SPDX-License-Identifier: Apache-2.0
3
4//! The one way to write into an agent's working directory
5//! (docs/specs/SPEC_WORKDIR_SAFE_WRITES_2026_10_01.md).
6//!
7//! An agent's workdir isn't wholly AgentMux's: it can hold the user's own
8//! files, a cloned repo, and symlinks. Every write AgentMux makes into it —
9//! `CLAUDE.md` and the other instructions files, `.claude/*`, `.mcp.json`,
10//! skill files, manifests, backups — goes through [`Workdir`], which:
11//!
12//! - keeps the path inside the workdir, lexically (`safe_join_within_base`)
13//!   and through symlinked folders (`verify_no_symlink_escape`);
14//! - never writes through a symlinked file, dangling or not (a dangling link
15//!   passes the folder check, and `fs::write` would follow it);
16//! - replaces files atomically (temp file in the same folder, `sync_all`,
17//!   rename), so a crash or a full disk leaves the old file or the new one,
18//!   never half of each. A rename replaces a link *entry* rather than
19//!   following it, so a link appearing between the check and the write
20//!   still can't send the write outside.
21//!
22//! A refusal is an `io::ErrorKind::PermissionDenied` naming the path and
23//! the reason; callers keep their own policy (fail the launch, or log and
24//! go on). `scripts/check-workdir-writes.mjs` keeps raw filesystem writes
25//! out of the files that write into workdirs.
26
27use std::fs::File;
28use std::io::{self, Write as _};
29use std::path::{Path, PathBuf};
30
31/// How many times an atomic replace retries a rename that Windows refused
32/// (the target held open without delete-sharing), and how long it waits
33/// between tries.
34const RENAME_TRIES: u32 = 5;
35const RENAME_RETRY_DELAY: std::time::Duration = std::time::Duration::from_millis(40);
36
37/// An agent's working directory, resolved once.
38#[derive(Debug, Clone)]
39pub struct Workdir {
40    base: PathBuf,
41    canonical: PathBuf,
42}
43
44fn refused(path: &Path, why: impl std::fmt::Display) -> io::Error {
45    io::Error::new(io::ErrorKind::PermissionDenied, format!("refused to write {}: {why}", path.display()))
46}
47
48fn is_symlink(path: &Path) -> bool {
49    std::fs::symlink_metadata(path).is_ok_and(|m| m.file_type().is_symlink())
50}
51
52impl Workdir {
53    /// The workdir at `base`, which must already exist (callers create it).
54    pub fn open(base: &Path) -> io::Result<Workdir> {
55        let canonical = base.canonicalize()?;
56        Ok(Workdir { base: base.to_path_buf(), canonical })
57    }
58
59    /// The workdir's own path, as given to [`Workdir::open`].
60    pub fn base(&self) -> &Path {
61        &self.base
62    }
63
64    /// `rel` inside the workdir, or a refusal: lexically outside it, under a
65    /// folder that links out of it, or a symlink itself (dangling or not).
66    pub fn resolve(&self, rel: &str) -> io::Result<PathBuf> {
67        let path = crate::backend::base::safe_join_within_base(&self.base, rel)
68            .map_err(|e| refused(&self.base.join(rel), e))?;
69        crate::backend::base::verify_no_symlink_escape(&path, &self.canonical).map_err(|e| refused(&path, e))?;
70        if is_symlink(&path) {
71            return Err(refused(&path, "it is a symlink"));
72        }
73        Ok(path)
74    }
75
76    /// The parent folder of a resolved path, created inside the workdir.
77    fn ensure_parent(&self, path: &Path) -> io::Result<()> {
78        let Some(parent) = path.parent() else { return Ok(()) };
79        if parent.as_os_str().is_empty() || parent.exists() {
80            return Ok(());
81        }
82        std::fs::create_dir_all(parent)?;
83        // A folder that appeared as a link between the check and the create
84        // would only now be visible: check again before anything is written.
85        crate::backend::base::verify_no_symlink_escape(path, &self.canonical).map_err(|e| refused(path, e))
86    }
87
88    /// Replace `rel` with `bytes`, atomically. Parent folders are created
89    /// inside the workdir. `owner_only` makes the file 0600 on Unix (for
90    /// files holding keys, such as `.mcp.json`); otherwise a file being
91    /// replaced keeps its mode, as an in-place write would.
92    pub fn write(&self, rel: &str, bytes: &[u8], owner_only: bool) -> io::Result<()> {
93        let path = self.resolve(rel)?;
94        self.ensure_parent(&path)?;
95        #[cfg(unix)]
96        let keep_mode = if owner_only { None } else { std::fs::metadata(&path).ok().map(|m| m.permissions()) };
97        let tmp = temp_path(&path);
98        let mut opts = std::fs::OpenOptions::new();
99        opts.write(true).create_new(true);
100        #[cfg(unix)]
101        if owner_only {
102            use std::os::unix::fs::OpenOptionsExt as _;
103            opts.mode(0o600);
104        }
105        #[cfg(not(unix))]
106        let _ = owner_only;
107        let written = (|| {
108            let mut f = opts.open(&tmp)?;
109            #[cfg(unix)]
110            if let Some(perms) = keep_mode {
111                f.set_permissions(perms)?;
112            }
113            f.write_all(bytes)?;
114            f.sync_all()
115        })();
116        let result = written.and_then(|()| rename_with_retry(&tmp, &path));
117        if result.is_err() {
118            let _ = std::fs::remove_file(&tmp);
119        }
120        result
121    }
122
123    /// Create `rel` with `bytes` only if nothing is there: `Ok(true)` when
124    /// created, `Ok(false)` when it already existed. The file appears whole
125    /// or not at all, even across a crash: it is written and synced under a
126    /// temporary name, then hard-linked into place, which fails if `rel`
127    /// already exists (Codex on #4141 — a partial `.pre-adopt` backup would
128    /// otherwise block adoption forever).
129    pub fn create_new(&self, rel: &str, bytes: &[u8]) -> io::Result<bool> {
130        let path = self.resolve(rel)?;
131        self.ensure_parent(&path)?;
132        let tmp = temp_path(&path);
133        let staged = (|| {
134            let mut f = std::fs::OpenOptions::new().write(true).create_new(true).open(&tmp)?;
135            f.write_all(bytes)?;
136            f.sync_all()
137        })();
138        let linked = staged.and_then(|()| std::fs::hard_link(&tmp, &path));
139        let _ = std::fs::remove_file(&tmp);
140        match linked {
141            Ok(()) => return Ok(true),
142            Err(e) if e.kind() == io::ErrorKind::AlreadyExists => return Ok(false),
143            // A filesystem without hard links (FAT, some network shares):
144            // fall back to creating the file in place, removing it if the
145            // write fails.
146            Err(_) => {}
147        }
148        let mut f = match std::fs::OpenOptions::new().write(true).create_new(true).open(&path) {
149            Ok(f) => f,
150            Err(e) if e.kind() == io::ErrorKind::AlreadyExists => return Ok(false),
151            Err(e) => return Err(e),
152        };
153        if let Err(e) = f.write_all(bytes).and_then(|()| f.sync_all()) {
154            drop(f);
155            let _ = std::fs::remove_file(&path);
156            return Err(e);
157        }
158        Ok(true)
159    }
160
161    /// Append `bytes` to an existing `rel`: a true append, never a rewrite,
162    /// so an edit racing the caller's earlier read is kept.
163    pub fn append(&self, rel: &str, bytes: &[u8]) -> io::Result<()> {
164        let path = self.resolve(rel)?;
165        std::fs::OpenOptions::new().append(true).open(&path)?.write_all(bytes)
166    }
167
168    /// Remove `rel`. The path is checked like a write, so a link to outside
169    /// the workdir is refused rather than removed.
170    pub fn remove_file(&self, rel: &str) -> io::Result<()> {
171        let path = self.resolve(rel)?;
172        std::fs::remove_file(path)
173    }
174
175    /// Remove the empty folder `rel`.
176    pub fn remove_dir(&self, rel: &str) -> io::Result<()> {
177        let path = self.resolve(rel)?;
178        std::fs::remove_dir(path)
179    }
180
181    /// Open `rel` for read/write, creating it if absent and never truncating
182    /// it: for lock files.
183    pub fn open_lock_file(&self, rel: &str) -> io::Result<File> {
184        let path = self.resolve(rel)?;
185        self.ensure_parent(&path)?;
186        #[allow(clippy::suspicious_open_options)]
187        std::fs::OpenOptions::new().read(true).write(true).create(true).truncate(false).open(path)
188    }
189}
190
191/// A fresh temporary name beside `path`, so a rename or link stays on one
192/// filesystem.
193fn temp_path(path: &Path) -> PathBuf {
194    let file_name = path.file_name().and_then(|n| n.to_str()).unwrap_or("file");
195    path.with_file_name(format!(".{file_name}.{}.agentmux-tmp", uuid::Uuid::new_v4()))
196}
197
198/// `fs::rename`, retried briefly when Windows refuses to replace a file
199/// another process holds open; never falls back to a non-atomic write.
200fn rename_with_retry(from: &Path, to: &Path) -> io::Result<()> {
201    let mut tries = 0;
202    loop {
203        match std::fs::rename(from, to) {
204            Ok(()) => return Ok(()),
205            Err(e) if tries + 1 < RENAME_TRIES && e.kind() == io::ErrorKind::PermissionDenied => {
206                tries += 1;
207                std::thread::sleep(RENAME_RETRY_DELAY);
208            }
209            Err(e) => return Err(e),
210        }
211    }
212}
213
214#[cfg(test)]
215mod tests {
216    use super::*;
217
218    fn workdir() -> (tempfile::TempDir, Workdir) {
219        let dir = tempfile::tempdir().unwrap();
220        let wd = Workdir::open(dir.path()).unwrap();
221        (dir, wd)
222    }
223
224    /// `link` → `target`; false where the OS won't create links (Windows
225    /// without the privilege), in which case the caller skips.
226    fn symlink_file(target: &Path, link: &Path) -> bool {
227        #[cfg(unix)]
228        return std::os::unix::fs::symlink(target, link).is_ok();
229        #[cfg(windows)]
230        return std::os::windows::fs::symlink_file(target, link).is_ok();
231    }
232
233    fn symlink_dir(target: &Path, link: &Path) -> bool {
234        #[cfg(unix)]
235        return std::os::unix::fs::symlink(target, link).is_ok();
236        #[cfg(windows)]
237        return std::os::windows::fs::symlink_dir(target, link).is_ok();
238    }
239
240    fn is_refusal(r: io::Result<impl std::fmt::Debug>) -> bool {
241        matches!(r, Err(e) if e.kind() == io::ErrorKind::PermissionDenied)
242    }
243
244    #[test]
245    fn writes_create_parents_and_replace_whole() {
246        let (dir, wd) = workdir();
247        wd.write(".claude/skills/x/SKILL.md", b"one", false).unwrap();
248        wd.write(".claude/skills/x/SKILL.md", b"two", false).unwrap();
249        assert_eq!(std::fs::read(dir.path().join(".claude/skills/x/SKILL.md")).unwrap(), b"two");
250        let leftovers: Vec<_> = std::fs::read_dir(dir.path().join(".claude/skills/x"))
251            .unwrap()
252            .flatten()
253            .filter(|e| e.file_name().to_string_lossy().ends_with(".agentmux-tmp"))
254            .collect();
255        assert!(leftovers.is_empty(), "no temp file left behind");
256    }
257
258    #[cfg(unix)]
259    #[test]
260    fn owner_only_writes_are_0600() {
261        use std::os::unix::fs::PermissionsExt as _;
262        let (dir, wd) = workdir();
263        wd.write(".mcp.json", b"{}", true).unwrap();
264        let mode = std::fs::metadata(dir.path().join(".mcp.json")).unwrap().permissions().mode();
265        assert_eq!(mode & 0o777, 0o600);
266    }
267
268    /// ReAgent on #4141: a user's 0600 `CLAUDE.md` must not come back 0644.
269    #[cfg(unix)]
270    #[test]
271    fn a_replaced_file_keeps_its_mode() {
272        use std::os::unix::fs::PermissionsExt as _;
273        let (dir, wd) = workdir();
274        let path = dir.path().join("CLAUDE.md");
275        std::fs::write(&path, "old").unwrap();
276        std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o600)).unwrap();
277        wd.write("CLAUDE.md", b"new", false).unwrap();
278        assert_eq!(std::fs::metadata(&path).unwrap().permissions().mode() & 0o777, 0o600);
279        assert_eq!(std::fs::read_to_string(&path).unwrap(), "new");
280    }
281
282    #[test]
283    fn paths_outside_the_workdir_are_refused() {
284        let (_dir, wd) = workdir();
285        assert!(is_refusal(wd.write("../escape.md", b"x", false)));
286        assert!(is_refusal(wd.write("/etc/x", b"x", false)));
287        assert!(is_refusal(wd.write("", b"x", false)));
288    }
289
290    #[test]
291    fn a_symlinked_file_is_never_written_through_live_or_dangling() {
292        let (dir, wd) = workdir();
293        let outside = tempfile::tempdir().unwrap();
294        let target = outside.path().join("target.md");
295        std::fs::write(&target, b"theirs").unwrap();
296        if !symlink_file(&target, &dir.path().join("CLAUDE.md")) {
297            return;
298        }
299        assert!(is_refusal(wd.write("CLAUDE.md", b"ours", false)));
300        assert!(is_refusal(wd.append("CLAUDE.md", b"ours")));
301        assert_eq!(std::fs::read(&target).unwrap(), b"theirs");
302
303        let gone = outside.path().join("gone.md");
304        if symlink_file(&gone, &dir.path().join("AGENTS.md")) {
305            assert!(is_refusal(wd.write("AGENTS.md", b"ours", false)));
306            assert!(is_refusal(wd.create_new("AGENTS.md", b"ours")));
307            assert!(!gone.exists(), "nothing created at a dangling link's target");
308        }
309    }
310
311    #[test]
312    fn a_folder_linking_outside_is_refused() {
313        let (dir, wd) = workdir();
314        let outside = tempfile::tempdir().unwrap();
315        if !symlink_dir(outside.path(), &dir.path().join(".claude")) {
316            return;
317        }
318        assert!(is_refusal(wd.write(".claude/settings.json", b"{}", false)));
319        assert!(is_refusal(wd.open_lock_file(".claude/.lock")));
320        assert!(std::fs::read_dir(outside.path()).unwrap().next().is_none(), "nothing written outside");
321    }
322
323    #[test]
324    fn create_new_never_overwrites() {
325        let (dir, wd) = workdir();
326        assert!(wd.create_new(".claude/marker.json", b"first").unwrap());
327        assert!(!wd.create_new(".claude/marker.json", b"second").unwrap());
328        assert_eq!(std::fs::read(dir.path().join(".claude/marker.json")).unwrap(), b"first");
329        let leftovers: Vec<_> = std::fs::read_dir(dir.path().join(".claude"))
330            .unwrap()
331            .flatten()
332            .filter(|e| e.file_name().to_string_lossy().ends_with(".agentmux-tmp"))
333            .collect();
334        assert!(leftovers.is_empty(), "no temp file left behind");
335    }
336
337    #[test]
338    fn append_adds_to_the_end() {
339        let (dir, wd) = workdir();
340        std::fs::write(dir.path().join("CLAUDE.md"), b"mine\n").unwrap();
341        wd.append("CLAUDE.md", b"@import\n").unwrap();
342        assert_eq!(std::fs::read(dir.path().join("CLAUDE.md")).unwrap(), b"mine\n@import\n");
343        assert!(wd.append("missing.md", b"x").is_err(), "append never creates");
344    }
345
346    #[test]
347    fn removing_refuses_a_link_and_removes_a_file() {
348        let (dir, wd) = workdir();
349        wd.write(".claude/commands/a.md", b"x", false).unwrap();
350        wd.remove_file(".claude/commands/a.md").unwrap();
351        wd.remove_dir(".claude/commands").unwrap();
352        assert!(!dir.path().join(".claude/commands").exists());
353
354        let outside = tempfile::tempdir().unwrap();
355        let target = outside.path().join("keep.md");
356        std::fs::write(&target, b"keep").unwrap();
357        if symlink_file(&target, &dir.path().join("link.md")) {
358            assert!(is_refusal(wd.remove_file("link.md")));
359            assert!(target.exists());
360        }
361    }
362
363    #[test]
364    fn a_lock_file_is_created_and_never_truncated() {
365        let (dir, wd) = workdir();
366        std::fs::create_dir_all(dir.path().join(".claude")).unwrap();
367        std::fs::write(dir.path().join(".claude/.lock"), b"held").unwrap();
368        let _f = wd.open_lock_file(".claude/.lock").unwrap();
369        assert_eq!(std::fs::read(dir.path().join(".claude/.lock")).unwrap(), b"held");
370        let _g = wd.open_lock_file(".claude/new.lock").unwrap();
371        assert!(dir.path().join(".claude/new.lock").exists());
372    }
373}