Skip to main content

fluers_runtime/
local_env.rs

1//! The real local-filesystem `SessionEnv`.
2//!
3//! Tools run against a real directory on disk via `tokio::fs` +
4//! `tokio::process`. **Confinement is fd-anchored**: every read, write, search,
5//! and exec cwd is resolved off a single held root fd via `openat`
6//! per-component walks with `O_NOFOLLOW` + an authoritative `fstat` on the
7//! opened leaf fd. There is no canonicalize-then-contain step in any data path,
8//! so a symlink/hardlink swapped between the containment check and the operation
9//! cannot redirect a read (exfil) or a write/exec (data loss).
10//!
11//! See `SECURITY.md`: this is *not* an OS-level sandbox (no chroot/landlock/
12//! UID separation). The fd-anchoring closes the TOCTOU class the path-based
13//! `resolve()` had; it does not turn this into a security boundary against a
14//! determined adversary until OS isolation lands.
15
16use std::ffi::OsStr;
17use std::os::fd::{AsFd, BorrowedFd, OwnedFd};
18use std::path::{Component, Path, PathBuf};
19
20use async_trait::async_trait;
21use rustix::fs::{fstat, ftruncate, mkdirat, open, openat, Dir, FileType, Mode, OFlags};
22use rustix::io::Errno;
23use tokio::io::{AsyncReadExt, AsyncWriteExt};
24use tokio::process::Command;
25use tokio_util::sync::CancellationToken;
26
27// `fcntl(F_GETPATH)` is apple-only; it backs `fd_real_path` on macOS.
28#[cfg(target_os = "macos")]
29use rustix::fs::getpath;
30// `/proc/self/fd/N` readlink needs the raw fd int on Linux.
31#[cfg(target_os = "linux")]
32use std::os::fd::AsRawFd;
33
34use crate::env::{Limits, SessionEnv, ShellResult};
35use crate::error::{RuntimeError, RuntimeResult};
36
37/// POSIX `st_mode` masks (stable, platform-independent) for the regular-file
38/// check — avoids pulling `libc` just for `S_ISREG`.
39const ST_MODE_TYPE_MASK: u32 = 0o170_000; // S_IFMT
40const ST_MODE_REGULAR: u32 = 0o100_000; // S_IFREG
41
42/// A `SessionEnv` backed by a real local directory.
43pub struct LocalSessionEnv {
44    /// Held fd over the canonical root: the anchor for every fd-anchored walk.
45    /// Opened once at construction with `O_DIRECTORY | O_NOFOLLOW | O_CLOEXEC`,
46    /// so root-path re-resolution never re-enters any data hot path. Because
47    /// the root is pinned by fd (not path), renaming/symlinking the root *path*
48    /// after construction cannot redirect a subsequent operation. `OwnedFd` is
49    /// `Send + Sync` on Unix.
50    root_fd: OwnedFd,
51    #[allow(dead_code)]
52    limits: Limits,
53}
54
55impl LocalSessionEnv {
56    /// Create an env rooted at `root`. The directory is canonicalized; if it
57    /// does not exist it is created. An fd is held over the canonical root for
58    /// the lifetime of the env.
59    pub async fn new(root: impl Into<PathBuf>, limits: Limits) -> RuntimeResult<Self> {
60        let root = root.into();
61        tokio::fs::create_dir_all(&root)
62            .await
63            .map_err(RuntimeError::Io)?;
64        let canon = tokio::fs::canonicalize(&root)
65            .await
66            .map_err(RuntimeError::Io)?;
67        // Hold an fd over the canonical root. Opened with O_NOFOLLOW (reject a
68        // root swapped to a symlink since construction) + O_DIRECTORY +
69        // O_CLOEXEC. From here on, no operation re-resolves the root *path* —
70        // they all anchor off this fd.
71        let root_flags = OFlags::RDONLY | OFlags::DIRECTORY | OFlags::NOFOLLOW | OFlags::CLOEXEC;
72        let root_fd = open(&canon, root_flags, Mode::empty())
73            .map_err(|e| RuntimeError::Io(std::io::Error::from(e)))?;
74        Ok(Self { root_fd, limits })
75    }
76
77    /// Validate a model-supplied relative path and return its `Normal`
78    /// components (skipping `.`). Rejects absolute paths and any `..`
79    /// component up front — the fd walk itself then enforces containment, so
80    /// there is no canonicalize-then-contain step anywhere in the data path.
81    fn normal_components<'a>(&self, rel: &'a Path) -> RuntimeResult<Vec<&'a OsStr>> {
82        if rel.is_absolute() {
83            return Err(RuntimeError::Sandbox(format!(
84                "absolute paths are not allowed: `{}`",
85                rel.display()
86            )));
87        }
88        if rel.components().any(|c| matches!(c, Component::ParentDir)) {
89            return Err(RuntimeError::Sandbox(format!(
90                "`..` is not allowed in paths: `{}`",
91                rel.display()
92            )));
93        }
94        Ok(rel
95            .components()
96            .filter_map(|c| match c {
97                Component::Normal(name) => Some(name),
98                // `CurDir` (".") is skipped; `ParentDir`/absolute are
99                // pre-rejected above.
100                _ => None,
101            })
102            .collect())
103    }
104
105    /// Open `rel` for reading via an fd-anchored walk from the held root fd
106    /// (B-Swift Phase C1a / #4). Closes the path-based TOCTOU at the daemon
107    /// read: every component is opened with `O_NOFOLLOW` (symlink → `ELOOP`),
108    /// and the leaf is `fstat`'d on the SAME fd we hand back for reading — so a
109    /// symlink/hardlink swap between confinement and the read cannot exfiltrate.
110    /// Mirrors the Swift `readFdAnchored`.
111    ///
112    /// Returns the opened regular-file `File` and its size in bytes (the size is
113    /// authoritative — taken off the open fd, not the path).
114    fn open_anchored_read(&self, rel: &Path) -> RuntimeResult<(std::fs::File, u64)> {
115        let names = self.normal_components(rel)?;
116        if names.is_empty() {
117            return Err(RuntimeError::Sandbox(format!(
118                "read path has no components: `{}`",
119                rel.display()
120            )));
121        }
122
123        let oflag = OFlags::RDONLY | OFlags::NOFOLLOW | OFlags::CLOEXEC;
124        // Walk: hold every opened fd in `chain` so intermediates stay alive
125        // until the next level is opened; the last element is the leaf.
126        let mut chain: Vec<OwnedFd> = Vec::new();
127        for name in names {
128            let dir = match chain.last() {
129                Some(f) => f.as_fd(),
130                None => self.root_fd.as_fd(),
131            };
132            let fd = match openat(dir, name, oflag, Mode::empty()) {
133                Ok(fd) => fd,
134                Err(Errno::LOOP) => {
135                    return Err(RuntimeError::Sandbox(format!(
136                        "symlinks are not allowed in read paths: `{}`",
137                        rel.display()
138                    )));
139                }
140                Err(e) => return Err(RuntimeError::Io(std::io::Error::from(e))),
141            };
142            chain.push(fd);
143        }
144        let leaf_owned = chain
145            .pop()
146            .ok_or_else(|| RuntimeError::Sandbox("read path has no components".to_string()))?;
147        // Remaining `chain` (intermediates) drops here → their fds close.
148
149        // Authoritative leaf check: fstat the OPENED fd (not the path).
150        let stat =
151            fstat(leaf_owned.as_fd()).map_err(|e| RuntimeError::Io(std::io::Error::from(e)))?;
152        if (stat.st_mode as u32 & ST_MODE_TYPE_MASK) != ST_MODE_REGULAR {
153            return Err(RuntimeError::Sandbox(format!(
154                "not a regular file: `{}`",
155                rel.display()
156            )));
157        }
158        if stat.st_nlink > 1 {
159            // Hardlink exfil (`ln secret in_root; read in_root/link`) — mirrors
160            // the Swift-side C2/#3 reject. Authoritative here: fstat off the
161            // open fd, not the path.
162            return Err(RuntimeError::Sandbox(format!(
163                "multiple hard links — can't safely confine: `{}`",
164                rel.display()
165            )));
166        }
167        let size = stat.st_size.max(0) as u64;
168        Ok((std::fs::File::from(leaf_owned), size))
169    }
170
171    /// Open an existing directory `rel` via an fd-anchored walk from the held
172    /// root fd (B-Swift Phase C1b). Used to pin an exec `cwd` by fd (passed to
173    /// the child as `/dev/fd/N`). Every component is opened with
174    /// `O_DIRECTORY | O_NOFOLLOW`, so a symlinked intermediate dir → `ELOOP`
175    /// → reject (never followed).
176    fn open_anchored_dir(&self, rel: &Path) -> RuntimeResult<OwnedFd> {
177        let names = self.normal_components(rel)?;
178        let oflag = OFlags::RDONLY | OFlags::DIRECTORY | OFlags::NOFOLLOW | OFlags::CLOEXEC;
179        // Open "." relative to the held root → an independent owned starting fd,
180        // so we never borrow `root_fd` across the walk.
181        let mut cur = openat(self.root_fd.as_fd(), ".", oflag, Mode::empty())
182            .map_err(|e| RuntimeError::Io(std::io::Error::from(e)))?;
183        for name in names {
184            let next = match openat(cur.as_fd(), name, oflag, Mode::empty()) {
185                Ok(fd) => fd,
186                Err(Errno::LOOP) => {
187                    return Err(RuntimeError::Sandbox(format!(
188                        "symlinked directories are not allowed: `{}`",
189                        rel.display()
190                    )));
191                }
192                Err(e) => return Err(RuntimeError::Io(std::io::Error::from(e))),
193            };
194            cur = next;
195        }
196        Ok(cur)
197    }
198
199    /// Derive the real on-disk path of an already-open directory fd — macOS
200    /// `fcntl(F_GETPATH)`, Linux `/proc/self/fd/N`. The path comes from the
201    /// *inode* the fd names, NOT from any model-supplied input string, so a
202    /// symlink swap on the input path between the fd-anchored open and the
203    /// spawn/search can't redirect the operation. (`/dev/fd/N` as a `cwd` is
204    /// Linux-only — macOS fdescfs rejects `chdir` to it with `ENOTDIR`, so the
205    /// inode path is the portable fd-anchored handle.) A post-open *move* of the
206    /// directory is a residual race outside the threat model: this is not an OS
207    /// sandbox, and moving the dir requires write access under the confined root.
208    fn fd_real_path(fd: BorrowedFd<'_>) -> RuntimeResult<PathBuf> {
209        #[cfg(target_os = "macos")]
210        {
211            use std::os::unix::ffi::OsStrExt;
212            let c = getpath(fd).map_err(|e| RuntimeError::Io(std::io::Error::from(e)))?;
213            Ok(PathBuf::from(OsStr::from_bytes(c.to_bytes())))
214        }
215        #[cfg(target_os = "linux")]
216        {
217            let raw = fd.as_raw_fd();
218            std::fs::read_link(format!("/proc/self/fd/{raw}")).map_err(RuntimeError::Io)
219        }
220        #[cfg(not(any(target_os = "macos", target_os = "linux")))]
221        {
222            let _ = fd;
223            Err(RuntimeError::Sandbox(
224                "fd-derived directory path is unsupported on this platform".into(),
225            ))
226        }
227    }
228
229    /// Resolve a grep search path to its real INODE path, fd-anchored from the
230    /// held root fd. Every component is opened `O_NOFOLLOW`; a symlink anywhere
231    /// in the path (including a symlinked dir passed explicitly) is rejected
232    /// outright — `rg --no-follow` would otherwise follow an explicit
233    /// symlinked-dir argument and leak its contents. The returned path is the
234    /// inode's path (from `fd_real_path`), so a swap on the input can't redirect
235    /// the search. Handles directory and file leaf targets; `.`/empty → root.
236    fn search_path_inode(&self, p: &str) -> RuntimeResult<PathBuf> {
237        let names = self.normal_components(Path::new(p))?;
238        if names.is_empty() {
239            // `.` or empty path → the root.
240            return Self::fd_real_path(self.root_fd.as_fd());
241        }
242        let dir_oflag = OFlags::RDONLY | OFlags::DIRECTORY | OFlags::NOFOLLOW | OFlags::CLOEXEC;
243        let file_oflag = OFlags::RDONLY | OFlags::NOFOLLOW | OFlags::CLOEXEC;
244        let (parents, last) = names.split_at(names.len() - 1);
245        let mut parent = openat(self.root_fd.as_fd(), ".", dir_oflag, Mode::empty())
246            .map_err(|e| RuntimeError::Io(std::io::Error::from(e)))?;
247        for name in parents.iter().copied() {
248            parent = match openat(parent.as_fd(), name, dir_oflag, Mode::empty()) {
249                Ok(fd) => fd,
250                Err(Errno::LOOP) => {
251                    return Err(RuntimeError::Sandbox(format!(
252                        "symlinked search path is not allowed: `{p}`"
253                    )))
254                }
255                Err(e) => return Err(RuntimeError::Io(std::io::Error::from(e))),
256            };
257        }
258        let last_name = last[0];
259        // Leaf: try dir, fall back to file (a file grep target). `O_NOFOLLOW`
260        // in both means a symlink leaf → `ELOOP` → reject.
261        let leaf_fd = match openat(parent.as_fd(), last_name, dir_oflag, Mode::empty()) {
262            Ok(fd) => fd,
263            Err(Errno::NOTDIR) => {
264                match openat(parent.as_fd(), last_name, file_oflag, Mode::empty()) {
265                    Ok(fd) => fd,
266                    Err(Errno::LOOP) => {
267                        return Err(RuntimeError::Sandbox(format!(
268                            "symlinked search path is not allowed: `{p}`"
269                        )))
270                    }
271                    Err(e) => return Err(RuntimeError::Io(std::io::Error::from(e))),
272                }
273            }
274            Err(Errno::LOOP) => {
275                return Err(RuntimeError::Sandbox(format!(
276                    "symlinked search path is not allowed: `{p}`"
277                )))
278            }
279            Err(e) => return Err(RuntimeError::Io(std::io::Error::from(e))),
280        };
281        Self::fd_real_path(leaf_fd.as_fd())
282    }
283
284    /// Open `rel` for writing via an fd-anchored walk from the held root fd
285    /// (B-Swift Phase C1b — the critical counterpart of `open_anchored_read`).
286    ///
287    /// Invariants:
288    /// - Parent dirs are created with a `mkdirat` walk from the root fd (each
289    ///   level opened `O_NOFOLLOW`); `mkdirat` does not follow a symlink at the
290    ///   target name, and the follow-up `openat(O_DIRECTORY|O_NOFOLLOW)` rejects
291    ///   a symlinked intermediate outright.
292    /// - The leaf is opened `WRONLY | CREATE | NOFOLLOW` — `O_NOFOLLOW` rejects
293    ///   a symlink leaf outright (`ELOOP`). Critically, `O_TRUNC` is **not**
294    ///   passed: truncation is deferred to `ftruncate` *after* the hardlink
295    ///   check, so a write through a hardlink can never mutate before the
296    ///   confinement decision.
297    /// - The opened leaf fd is `fstat`'d (authoritative): non-regular files are
298    ///   rejected, and `st_nlink > 1` is rejected — a write through a hardlink
299    ///   mutates every name in the set (silent cross-target data loss).
300    /// - The caller truncates + writes off the SAME fd.
301    fn open_anchored_write(&self, rel: &Path) -> RuntimeResult<OwnedFd> {
302        let names = self.normal_components(rel)?;
303        let (parents, leaf) = names.split_at(names.len().saturating_sub(1));
304        let leaf_name = leaf.first().copied().ok_or_else(|| {
305            RuntimeError::Sandbox(format!("write path has no file name: `{}`", rel.display()))
306        })?;
307
308        let dir_oflag = OFlags::RDONLY | OFlags::DIRECTORY | OFlags::NOFOLLOW | OFlags::CLOEXEC;
309        // mkdirat default mode mirrors std's `create_dir` (0o777 & !umask);
310        // files below use 0o666 & !umask (std's `fs::write` default).
311        let dir_mode = Mode::RWXU | Mode::RWXG | Mode::RWXO;
312        let file_mode = Mode::RUSR | Mode::WUSR | Mode::RGRP | Mode::WGRP | Mode::ROTH | Mode::WOTH;
313
314        let mut parent = openat(self.root_fd.as_fd(), ".", dir_oflag, Mode::empty())
315            .map_err(|e| RuntimeError::Io(std::io::Error::from(e)))?;
316        for name in parents.iter().copied() {
317            let next = match openat(parent.as_fd(), name, dir_oflag, Mode::empty()) {
318                Ok(fd) => fd,
319                Err(Errno::NOENT) => {
320                    // Create the missing intermediate dir. `mkdirat` does NOT
321                    // follow a symlink at `name` (it would fail EEXIST); the
322                    // reopen below re-establishes the fd-anchored position.
323                    // EEXIST from mkdirat means another writer created it
324                    // concurrently — that's safe; just reopen it.
325                    if let Err(e) = mkdirat(parent.as_fd(), name, dir_mode) {
326                        if e != Errno::EXIST {
327                            return Err(RuntimeError::Io(std::io::Error::from(e)));
328                        }
329                    }
330                    match openat(parent.as_fd(), name, dir_oflag, Mode::empty()) {
331                        Ok(fd) => fd,
332                        Err(Errno::LOOP) => {
333                            return Err(RuntimeError::Sandbox(format!(
334                                "symlinked directories are not allowed: `{}`",
335                                rel.display()
336                            )));
337                        }
338                        Err(e) => return Err(RuntimeError::Io(std::io::Error::from(e))),
339                    }
340                }
341                Err(Errno::LOOP) => {
342                    return Err(RuntimeError::Sandbox(format!(
343                        "symlinked directories are not allowed: `{}`",
344                        rel.display()
345                    )));
346                }
347                Err(e) => return Err(RuntimeError::Io(std::io::Error::from(e))),
348            };
349            parent = next;
350        }
351
352        // Leaf: CREATE + NOFOLLOW, but deliberately NO TRUNC — truncate after
353        // the nlink check so a hardlink can't be mutated pre-decision.
354        let leaf_oflag = OFlags::WRONLY | OFlags::CREATE | OFlags::NOFOLLOW | OFlags::CLOEXEC;
355        let leaf_fd = match openat(parent.as_fd(), leaf_name, leaf_oflag, file_mode) {
356            Ok(fd) => fd,
357            Err(Errno::LOOP) => {
358                return Err(RuntimeError::Sandbox(format!(
359                    "symlink leaf is not allowed: `{}`",
360                    rel.display()
361                )));
362            }
363            Err(e) => return Err(RuntimeError::Io(std::io::Error::from(e))),
364        };
365
366        // Authoritative confinement checks off the OPEN fd (not the path).
367        let stat = fstat(leaf_fd.as_fd()).map_err(|e| RuntimeError::Io(std::io::Error::from(e)))?;
368        if (stat.st_mode as u32 & ST_MODE_TYPE_MASK) != ST_MODE_REGULAR {
369            return Err(RuntimeError::Sandbox(format!(
370                "not a regular file: `{}`",
371                rel.display()
372            )));
373        }
374        if stat.st_nlink > 1 {
375            // A write through a hardlink mutates every name in the set — reject,
376            // mirroring the read-side decision.
377            return Err(RuntimeError::Sandbox(format!(
378                "multiple hard links — can't safely confine: `{}`",
379                rel.display()
380            )));
381        }
382        Ok(leaf_fd)
383    }
384}
385
386#[async_trait]
387impl SessionEnv for LocalSessionEnv {
388    async fn read_file(
389        &self,
390        path: &Path,
391        max_lines: usize,
392        max_bytes: usize,
393    ) -> RuntimeResult<String> {
394        // B-Swift Phase C1a / #4: fd-anchored open + read from the SAME fd
395        // (closes the check-then-use TOCTOU the path-based read had).
396        //
397        // Bounded read (0.5.2): the output is capped at `max_bytes` anyway
398        // (apply_read_limits truncates beyond it), so reading the whole file
399        // first would OOM on a multi-GB file. Read at most `max_bytes` and
400        // trim any partial UTF-8 char at the cut. Memory is thus bounded by
401        // `max_bytes`, independent of the on-disk size.
402        let (file, _size) = self.open_anchored_read(path)?;
403        let (raw, truncated_at_cap) = read_bounded_string(file, max_bytes).await?;
404        let mut out = apply_read_limits(raw, max_lines, max_bytes);
405        // If the bounded read cut the file short (file > max_bytes) and
406        // apply_read_limits didn't itself add a truncation marker, surface that
407        // the content was capped — preserves the original oversized-file
408        // indicator that the unbounded read had.
409        if truncated_at_cap && !out.contains("[... truncated") {
410            out.push_str(&format!("\n[... truncated at {max_bytes} bytes ...]"));
411        }
412        Ok(out)
413    }
414
415    async fn read_file_full(&self, path: &Path, max_bytes: usize) -> RuntimeResult<String> {
416        // B-Swift Phase C1a / #4: size + read off the SAME open fd. The old
417        // path-based metadata check raced the read; now the size gate is
418        // authoritative (fstat off the open fd) and the read uses that fd.
419        let (file, size) = self.open_anchored_read(path)?;
420        let size = size as usize;
421        if size > max_bytes {
422            return Err(RuntimeError::FileTooLarge {
423                path: path.display().to_string(),
424                size,
425                max: max_bytes,
426            });
427        }
428        let mut file = tokio::fs::File::from_std(file);
429        let mut raw = String::new();
430        file.read_to_string(&mut raw)
431            .await
432            .map_err(RuntimeError::Io)?;
433        Ok(raw)
434    }
435
436    async fn write_file(&self, path: &Path, content: &str) -> RuntimeResult<()> {
437        // B-Swift Phase C1b: fd-anchored write. Open the leaf off the held root
438        // fd (mkdirat-walking parents), fstat for hardlink confinement, THEN
439        // truncate + write off the SAME fd. No path re-resolution in any step.
440        let leaf_fd = self.open_anchored_write(path)?;
441        // Truncate AFTER the nlink check (the open deliberately omitted O_TRUNC).
442        ftruncate(&leaf_fd, 0).map_err(|e| RuntimeError::Io(std::io::Error::from(e)))?;
443        let mut file = tokio::fs::File::from_std(std::fs::File::from(leaf_fd));
444        file.write_all(content.as_bytes())
445            .await
446            .map_err(RuntimeError::Io)?;
447        // Flush before returning: a subsequent `fstat` (e.g. a size-gated
448        // `read_file_full`) must observe the full new size. `write_all`'s await
449        // dispatches the pwrite on the blocking pool, but tokio `File`'s close is
450        // deferred on drop — without this barrier the size was intermittently
451        // not yet visible to a following `fstat` under parallel load (a rare
452        // flake that returned a stale/short size). `flush` completes the pending
453        // async write without an `fsync` (no durability/perf cost vs `sync_all`).
454        file.flush().await.map_err(RuntimeError::Io)?;
455        Ok(())
456    }
457
458    async fn exec(
459        &self,
460        command: &str,
461        cwd: &Path,
462        timeout_ms: Option<u64>,
463        cancel: &CancellationToken,
464    ) -> RuntimeResult<ShellResult> {
465        // The cwd is opened fd-anchored (`openat(O_DIRECTORY|O_NOFOLLOW)` per
466        // component from the held root fd), so a symlinked cwd dir is rejected
467        // outright. The child then chdirs to the *inode's* real path — derived
468        // from the open fd via `fd_real_path`, not from the input string — so a
469        // symlink swap on the cwd path between open and spawn can't redirect it.
470        // (`/dev/fd/N` would be the pure-inode handle, but macOS fdescfs rejects
471        // `chdir` to it; the inode path is the portable form.) `cwd_fd` is held
472        // in scope through `spawn()` so the inode it names stays valid.
473        let cwd_fd = self.open_anchored_dir(cwd)?;
474        let cwd_path = Self::fd_real_path(cwd_fd.as_fd())?;
475
476        // `kill_on_drop(true)`: on timeout/cancel the in-flight `wait_with_output`
477        // future (which owns the child) is dropped, and its `Drop` sends SIGKILL —
478        // so a still-running child is never orphaned.
479        //
480        // `env_clear()` + allowlist: model-run shells must NOT inherit the
481        // parent's full environment — that includes provider API keys
482        // (`OPENAI_API_KEY`, `ANTHROPIC_API_KEY`, `FLUERS_SERVER_TOKEN`, …),
483        // which a prompt-injected model could exfiltrate via `env | grep KEY`.
484        // Only a safe, minimal allowlist (needed for commands to function) is
485        // re-set.
486        let child = Command::new("sh")
487            .arg("-c")
488            .arg(command)
489            .current_dir(&cwd_path)
490            .env_clear()
491            .envs(safe_exec_env())
492            .stdout(std::process::Stdio::piped())
493            .stderr(std::process::Stdio::piped())
494            .kill_on_drop(true)
495            .spawn()
496            .map_err(RuntimeError::Io)?;
497        // `cwd_fd` stays live until end of scope (spawn has run by now).
498
499        let timeout_fut = match timeout_ms {
500            Some(ms) => Box::pin(tokio::time::sleep(std::time::Duration::from_millis(ms)))
501                as std::pin::Pin<Box<dyn std::future::Future<Output = ()> + Send>>,
502            None => Box::pin(std::future::pending()),
503        };
504        let cancel_fut = cancel.cancelled();
505
506        // `wait_with_output` drains stdout AND stderr concurrently while it waits.
507        // The old `child.wait()` did not read the pipes, so a child emitting more
508        // than the OS pipe buffer (~64 KB) blocked on a full pipe while `wait()`
509        // blocked on the child — a deadlock that only broke on timeout (output
510        // lost, misreported as a 124), or hung forever with no timeout set.
511        tokio::select! {
512            _ = timeout_fut => {
513                // `child` (moved into the dropped `wait_with_output` future) is
514                // SIGKILLed via `kill_on_drop`. Return the 124-shaped result.
515                Ok(ShellResult {
516                    exit_code: 124,
517                    stdout: String::new(),
518                    stderr: format!("command timed out after {}ms", timeout_ms.unwrap_or(0)),
519                })
520            }
521            _ = cancel_fut => {
522                Err(RuntimeError::Sandbox("command cancelled".into()))
523            }
524            output = child.wait_with_output() => {
525                let output = output.map_err(RuntimeError::Io)?;
526                Ok(ShellResult {
527                    exit_code: output.status.code().unwrap_or(-1),
528                    stdout: String::from_utf8_lossy(&output.stdout).into_owned(),
529                    stderr: String::from_utf8_lossy(&output.stderr).into_owned(),
530                })
531            }
532        }
533    }
534
535    async fn glob(&self, pattern: &str, limit: usize) -> RuntimeResult<Vec<String>> {
536        // Containment: reject absolute patterns and `..` so the model can't
537        // list files outside the root (e.g. `../../*` or `/etc/*`).
538        validate_search_pattern(pattern)?;
539        // Split into a base dir (must exist) + a single-segment filename pattern.
540        // As in the original matcher, the filename pattern is applied at every
541        // depth under the base (the descent is what changed: it is now
542        // fd-anchored and never enters a symlinked directory).
543        let pat_path = Path::new(pattern);
544        let base_rel = pat_path.parent().unwrap_or_else(|| Path::new(""));
545        let fname = pat_path.file_name().and_then(|s| s.to_str()).unwrap_or("*");
546        // Results are reported relative to the ROOT, but the walk starts at the
547        // base dir — so seed the descent with the base's own path relative to
548        // root (e.g. `sub/*.txt` → base prefix `sub`, so `sub/nested.txt` is
549        // reported, not `nested.txt`).
550        let base_prefix = self
551            .normal_components(base_rel)?
552            .iter()
553            .map(|s| s.to_string_lossy().into_owned())
554            .collect::<Vec<_>>()
555            .join("/");
556        // A missing/symlinked base yields no matches (preserves the original
557        // "no results" behavior for non-existent bases after validation).
558        let base_fd = match self.open_anchored_dir(base_rel) {
559            Ok(fd) => fd,
560            Err(_) => return Ok(Vec::new()),
561        };
562        let dir = match Dir::new(base_fd) {
563            Ok(d) => d,
564            Err(_) => return Ok(Vec::new()),
565        };
566        let mut results: Vec<String> = Vec::new();
567        walk_glob_fd(dir, fname, &base_prefix, &mut results, limit)?;
568        results.sort();
569        // De-dup (a `**`/depth-recursion can surface the same relative path).
570        results.dedup();
571        Ok(results)
572    }
573
574    async fn grep(
575        &self,
576        pattern: &str,
577        paths: &[&str],
578        max_matches: usize,
579    ) -> RuntimeResult<Vec<String>> {
580        // Containment: validate each search path's SHAPE (reject absolute/`..`
581        // so the model can't reach outside the root), then resolve it fd-anchored
582        // to its real INODE path. This is essential: `rg --no-follow` still
583        // follows a symlinked dir passed EXPLICITLY as a search path, so passing
584        // the input string would leak through `linkdir -> outside`. Resolving to
585        // the inode path (and rejecting symlinks outright at `openat(NO_FOLLOW)`)
586        // closes that — the search runs against the real confined dir/file.
587        let root_path = Self::fd_real_path(self.root_fd.as_fd())?;
588        let mut validated: Vec<String> = Vec::new();
589        if paths.is_empty() {
590            validated.push(shell_quote(&root_path.to_string_lossy()));
591        } else {
592            for p in paths {
593                validate_search_pattern(p)?;
594                let inode = self.search_path_inode(p)?;
595                validated.push(shell_quote(&inode.to_string_lossy()));
596            }
597        }
598        let search = validated.join(" ");
599        // The process cwd is the root's inode path too (belt-and-suspenders);
600        // `rg --no-follow` / the `find -P` fallback never follow symlinks.
601        // `kill_on_drop(true)` + a bounded timeout ensure a grep against a hung
602        // filesystem (stuck NFS, adversarial tree) can neither block this future
603        // forever nor orphan the child. The trait gives no cancel token/timeout,
604        // so a fixed ceiling is applied here.
605        const GREP_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(30);
606        let child = Command::new("sh")
607            .arg("-c")
608            .arg(format!(
609                "rg -n --no-follow -- {pat} {search} 2>/dev/null \
610                 || find -P {search} -type f -exec grep -Hn -- {pat} {{}} + 2>/dev/null",
611                pat = shell_quote(pattern),
612            ))
613            .current_dir(&root_path)
614            .stdout(std::process::Stdio::piped())
615            .stderr(std::process::Stdio::piped())
616            .kill_on_drop(true)
617            .spawn()
618            .map_err(RuntimeError::Io)?;
619        let rg = match tokio::time::timeout(GREP_TIMEOUT, child.wait_with_output()).await {
620            Ok(res) => res.map_err(RuntimeError::Io)?,
621            // On timeout the `wait_with_output` future is dropped; `kill_on_drop`
622            // SIGKILLs the child. Surface an empty result rather than hanging.
623            Err(_) => return Ok(Vec::new()),
624        };
625        let out = String::from_utf8_lossy(&rg.stdout);
626        // Search paths are absolute inode paths (see above), so `rg`/`grep` emit
627        // absolute paths — strip the root's inode prefix so results stay
628        // root-relative (as they did pre-fd-anchoring) and don't leak the host
629        // temp/root path to the model.
630        let root_prefix = format!("{}/", root_path.to_string_lossy());
631        Ok(out
632            .lines()
633            .map(|l| {
634                l.strip_prefix(root_prefix.as_str())
635                    .unwrap_or(l)
636                    .to_string()
637            })
638            .take(max_matches)
639            .collect())
640    }
641}
642
643/// Truncate `raw` to `max_lines` and `max_bytes`, whichever binds first.
644fn apply_read_limits(raw: String, max_lines: usize, max_bytes: usize) -> String {
645    let mut bytes_left = max_bytes;
646    let mut out = String::new();
647    let mut truncated = false;
648    for (i, line) in raw.split_inclusive('\n').enumerate() {
649        if i >= max_lines {
650            out.push_str(&format!("\n[... truncated at {max_lines} lines ...]"));
651            truncated = true;
652            break;
653        }
654        if bytes_left < line.len() {
655            // Take as many whole bytes as fit on a UTF-8 boundary.
656            let take = line
657                .char_indices()
658                .map(|(i, _)| i)
659                .find(|&pos| pos > bytes_left)
660                .unwrap_or(line.len());
661            out.push_str(line.get(..take).unwrap_or(line));
662            out.push_str(&format!("\n[... truncated at {max_bytes} bytes ...]"));
663            truncated = true;
664            break;
665        }
666        out.push_str(line);
667        bytes_left -= line.len();
668    }
669    if truncated {
670        out
671    } else {
672        raw
673    }
674}
675
676/// Read at most `max_bytes` bytes from `file` into a `String`, trimming any
677/// partial UTF-8 char at the read boundary.
678///
679/// Bounds memory at `max_bytes` so a multi-GB file cannot OOM the truncating
680/// `read_file` path — the output is already capped at `max_bytes` by
681/// [`apply_read_limits`], so reading more than that is pure waste. If the
682/// `max_bytes` boundary splits a multibyte char, the partial trailing bytes are
683/// trimmed to the last valid char boundary. A genuinely invalid-UTF-8 file that
684/// fits within `max_bytes` still errors (mirrors the prior `read_to_string`).
685///
686/// Returns the decoded prefix and a flag set when the file was larger than
687/// `max_bytes` (i.e. the read hit the cap) so the caller can surface a
688/// truncation marker.
689async fn read_bounded_string(
690    file: std::fs::File,
691    max_bytes: usize,
692) -> RuntimeResult<(String, bool)> {
693    let file = tokio::fs::File::from_std(file);
694    let mut buf: Vec<u8> = Vec::with_capacity(max_bytes.min(8 * 1024));
695    file.take(max_bytes as u64)
696        .read_to_end(&mut buf)
697        .await
698        .map_err(RuntimeError::Io)?;
699    // `read_full`: we read the whole file (didn't hit the cap) → any UTF-8
700    // error is genuine and should surface, not be silently trimmed.
701    let read_full = buf.len() < max_bytes;
702    let truncated_at_cap = !read_full;
703    match std::str::from_utf8(&buf) {
704        Ok(s) => Ok((s.to_string(), truncated_at_cap)),
705        Err(e) => {
706            let vu = e.valid_up_to();
707            if read_full {
708                Err(RuntimeError::Io(std::io::Error::new(
709                    std::io::ErrorKind::InvalidData,
710                    "stream did not contain valid UTF-8",
711                )))
712            } else {
713                // Hit the cap: trim the truncated multibyte suffix. `vu` is, by
714                // definition, a valid char boundary, so `&buf[..vu]` is valid.
715                Ok((
716                    std::str::from_utf8(&buf[..vu])
717                        .map(str::to_string)
718                        .unwrap_or_default(),
719                    truncated_at_cap,
720                ))
721            }
722        }
723    }
724}
725
726/// fd-anchored recursive glob descent. `dir` is an already-opened directory
727/// (opened `O_NOFOLLOW` by the caller). The single-segment filename pattern
728/// `fname_pat` (supporting `*`/`?`) is matched against every entry at every
729/// depth under `dir`. Recursion into a subdirectory happens ONLY via
730/// `openat(O_DIRECTORY | O_NOFOLLOW)` — that gate authoritatively refuses a
731/// symlinked directory, so a symlink can never lead the walk out of the root.
732/// `rel_prefix` is the path of `dir` relative to the session root ("" at the
733/// base); results are accumulated as root-relative strings.
734fn walk_glob_fd(
735    mut dir: Dir,
736    fname_pat: &str,
737    rel_prefix: &str,
738    out: &mut Vec<String>,
739    limit: usize,
740) -> RuntimeResult<()> {
741    // Phase 1: drain entries into an owned vec. This ends the mutable borrow of
742    // `dir` so phase 2 can take an immutable borrow for `dir.fd()` (needed to
743    // openat children). `.`/`..` are skipped.
744    let mut entries: Vec<(String, FileType)> = Vec::new();
745    for res in &mut dir {
746        match res {
747            Ok(e) => {
748                let name = e.file_name().to_string_lossy().into_owned();
749                if name == "." || name == ".." {
750                    continue;
751                }
752                entries.push((name, e.file_type()));
753            }
754            Err(e) => return Err(RuntimeError::Io(std::io::Error::from(e))),
755        }
756    }
757    if out.len() >= limit {
758        return Ok(());
759    }
760    // The parent fd for recursion (immutable borrow — no conflict with the
761    // finished iterator).
762    let parent_fd = dir
763        .fd()
764        .map_err(|e| RuntimeError::Io(std::io::Error::from(e)))?;
765    for (name, ftype) in entries {
766        if out.len() >= limit {
767            return Ok(());
768        }
769        let rel = if rel_prefix.is_empty() {
770            name.clone()
771        } else {
772            format!("{rel_prefix}/{name}")
773        };
774        if matches_glob(&name, fname_pat) {
775            out.push(rel.clone());
776        }
777        // `is_dir()` is only a *hint* to attempt recursion; the authoritative
778        // gate is the `openat(O_DIRECTORY | O_NOFOLLOW)` below — even if d_type
779        // lies, a symlinked dir cannot be entered.
780        if ftype.is_dir() {
781            if let Ok(child_fd) = openat(
782                parent_fd,
783                name.as_str(),
784                OFlags::RDONLY | OFlags::DIRECTORY | OFlags::NOFOLLOW | OFlags::CLOEXEC,
785                Mode::empty(),
786            ) {
787                if let Ok(child_dir) = Dir::new(child_fd) {
788                    walk_glob_fd(child_dir, fname_pat, &rel, out, limit)?;
789                }
790            }
791            // openat/Dir failure (symlink, ENOTDIR, race, …) → skip, don't error.
792        }
793    }
794    Ok(())
795}
796
797/// Single-segment glob (`*`/`?`) matcher. `**` is treated as `*` here.
798fn matches_glob(name: &str, pat: &str) -> bool {
799    let name_b = name.as_bytes();
800    let pat_b = pat.as_bytes();
801    matches_at(name_b, pat_b, 0, 0)
802}
803
804fn matches_at(n: &[u8], p: &[u8], mut ni: usize, mut pi: usize) -> bool {
805    let mut star: Option<(usize, usize)> = None;
806    while ni < n.len() {
807        if pi < p.len() && (p[pi] == b'?' || p[pi] == b'*') {
808            if p[pi] == b'*' {
809                star = Some((pi, ni));
810                pi += 1;
811                continue;
812            }
813            pi += 1;
814            ni += 1;
815        } else if pi < p.len() && p[pi] == n[ni] {
816            pi += 1;
817            ni += 1;
818        } else if let Some((sp, sn)) = star {
819            pi = sp + 1;
820            ni = sn + 1;
821            star = Some((sp, sn + 1));
822        } else {
823            return false;
824        }
825    }
826    while pi < p.len() && p[pi] == b'*' {
827        pi += 1;
828    }
829    pi == p.len()
830}
831
832/// Validate a glob/grep search pattern/path is contained: reject absolute
833/// paths and `..` components so the model can't reach outside the root.
834///
835/// Patterns may legitimately contain `*`/`?` (glob) — only path-structure
836/// escapes are rejected.
837fn validate_search_pattern(input: &str) -> RuntimeResult<()> {
838    // Reject absolute paths.
839    if input.starts_with('/') || input.starts_with('\\') {
840        return Err(RuntimeError::Sandbox(format!(
841            "absolute paths are not allowed: `{input}`"
842        )));
843    }
844    // Reject any `..` path component. Walk segments, ignoring glob wildcards.
845    for seg in input.split('/') {
846        if seg == ".." {
847            return Err(RuntimeError::Sandbox(format!(
848                "`..` is not allowed in search paths: `{input}`"
849            )));
850        }
851    }
852    Ok(())
853}
854
855/// Quote a string for safe inclusion in a `sh -c` command.
856fn shell_quote(s: &str) -> String {
857    format!("'{}'", s.replace('\'', "'\\''"))
858}
859
860/// The minimal, safe environment re-applied to a model-run shell after
861/// [`std::process::Command::env_clear`]. Carries only what commands need to
862/// function — NOT provider API keys, tokens, or other secrets the parent holds.
863/// Locale/timezone are passed through (set by the user's login shell) so command
864/// output formatting matches the user's session. Keys are owned to avoid
865/// per-call leaking.
866fn safe_exec_env() -> Vec<(String, std::ffi::OsString)> {
867    let mut out: Vec<(String, std::ffi::OsString)> = Vec::new();
868    // Essentials for commands to run and find binaries.
869    for name in ["PATH", "HOME", "USER", "LOGNAME", "SHELL", "TMPDIR"] {
870        if let Some(v) = std::env::var_os(name) {
871            out.push((name.to_string(), v));
872        }
873    }
874    // Locale/timezone (formatting only — no secrets). Use exact names plus the
875    // `LC_` category prefix; a broad `LANG` prefix match would also pass secrets
876    // like `LANGCHAIN_API_KEY`/`LANGFUSE_SECRET_KEY`, defeating `env_clear()`.
877    for (k, v) in std::env::vars_os() {
878        let key = k.to_string_lossy().into_owned();
879        if matches!(key.as_str(), "TZ" | "LANG" | "LANGUAGE") || key.starts_with("LC_") {
880            out.push((key, v));
881        }
882    }
883    out
884}
885
886#[cfg(test)]
887mod tests {
888    //! Local sandbox path-containment and tool tests against a temp dir.
889
890    use super::*;
891
892    #[tokio::test]
893    async fn read_file_within_root_works() {
894        let dir = tempfile::tempdir().unwrap();
895        let env = LocalSessionEnv::new(dir.path(), Limits::default())
896            .await
897            .unwrap();
898        tokio::fs::write(dir.path().join("hello.txt"), "hi there\n")
899            .await
900            .unwrap();
901        let got = env
902            .read_file(Path::new("hello.txt"), 100, 1024)
903            .await
904            .unwrap();
905        assert_eq!(got, "hi there\n");
906    }
907
908    #[tokio::test]
909    async fn read_file_rejects_absolute_path() {
910        let dir = tempfile::tempdir().unwrap();
911        let env = LocalSessionEnv::new(dir.path(), Limits::default())
912            .await
913            .unwrap();
914        let res = env.read_file(Path::new("/etc/passwd"), 100, 1024).await;
915        assert!(res.is_err(), "absolute paths must be rejected");
916    }
917
918    #[tokio::test]
919    async fn read_file_rejects_parent_dir() {
920        let dir = tempfile::tempdir().unwrap();
921        let env = LocalSessionEnv::new(dir.path(), Limits::default())
922            .await
923            .unwrap();
924        let res = env.read_file(Path::new("../escape.txt"), 100, 1024).await;
925        assert!(res.is_err(), "`..` must be rejected");
926    }
927
928    #[tokio::test]
929    async fn read_file_full_returns_complete_content_without_truncation() {
930        let dir = tempfile::tempdir().unwrap();
931        let env = LocalSessionEnv::new(dir.path(), Limits::default())
932            .await
933            .unwrap();
934        // 10 lines of 60 bytes each = 600 bytes, well under the default cap,
935        // but above the *truncating* read's line/byte interplay. Ensure the
936        // full-read path returns the whole file verbatim, with no marker.
937        let body = (0..10)
938            .map(|i| format!("line number {i:02} with some padding text\n"))
939            .collect::<String>();
940        tokio::fs::write(dir.path().join("big.txt"), &body)
941            .await
942            .unwrap();
943        let got = env
944            .read_file_full(Path::new("big.txt"), 1024)
945            .await
946            .unwrap();
947        assert_eq!(got, body);
948        assert!(!got.contains("[... truncated"));
949    }
950
951    #[tokio::test]
952    async fn read_file_full_rejects_absolute_path() {
953        let dir = tempfile::tempdir().unwrap();
954        let env = LocalSessionEnv::new(dir.path(), Limits::default())
955            .await
956            .unwrap();
957        let res = env.read_file_full(Path::new("/etc/passwd"), 1024).await;
958        assert!(res.is_err(), "absolute paths must be rejected");
959    }
960
961    #[tokio::test]
962    async fn read_file_full_rejects_parent_dir() {
963        let dir = tempfile::tempdir().unwrap();
964        let env = LocalSessionEnv::new(dir.path(), Limits::default())
965            .await
966            .unwrap();
967        let res = env.read_file_full(Path::new("../escape.txt"), 1024).await;
968        assert!(res.is_err(), "`..` must be rejected");
969    }
970
971    #[tokio::test]
972    async fn read_file_full_errors_when_too_large_not_truncated() {
973        let dir = tempfile::tempdir().unwrap();
974        let env = LocalSessionEnv::new(dir.path(), Limits::default())
975            .await
976            .unwrap();
977        // 100 bytes, cap at 50 -> must ERROR (FileTooLarge), never return a
978        // truncated prefix (the whole point vs `read_file`).
979        tokio::fs::write(dir.path().join("over.txt"), &"a".repeat(100))
980            .await
981            .unwrap();
982        let res = env.read_file_full(Path::new("over.txt"), 50).await;
983        assert!(res.is_err(), "oversized file must error, not truncate");
984        match res {
985            Err(RuntimeError::FileTooLarge { size, max, .. }) => {
986                assert_eq!(size, 100);
987                assert_eq!(max, 50);
988            }
989            other => panic!("expected FileTooLarge, got {other:?}"),
990        }
991    }
992
993    #[tokio::test]
994    async fn write_then_read_roundtrips() {
995        let dir = tempfile::tempdir().unwrap();
996        let env = LocalSessionEnv::new(dir.path(), Limits::default())
997            .await
998            .unwrap();
999        env.write_file(Path::new("sub/nested/file.txt"), "deep content")
1000            .await
1001            .unwrap();
1002        let got = env
1003            .read_file(Path::new("sub/nested/file.txt"), 100, 1024)
1004            .await
1005            .unwrap();
1006        assert_eq!(got, "deep content");
1007    }
1008
1009    #[tokio::test]
1010    async fn read_file_bounded_read_does_not_oom_on_large_file() {
1011        // Regression for 0.5.2 bounded read: a file far larger than `max_bytes`
1012        // must be read bounded (not fully buffered) and truncated, without
1013        // erroring or OOMing. The old `read_to_string` of the whole file would
1014        // allocate the entire multi-MB body.
1015        let dir = tempfile::tempdir().unwrap();
1016        let env = LocalSessionEnv::new(dir.path(), Limits::default())
1017            .await
1018            .unwrap();
1019        // 100 KB of ASCII, capped at 64 bytes. Only the first ~64 bytes are
1020        // returned (plus a truncation marker); nothing else is held in memory.
1021        let body = "a".repeat(100 * 1024);
1022        tokio::fs::write(dir.path().join("big.txt"), &body)
1023            .await
1024            .unwrap();
1025        let got = env
1026            .read_file(Path::new("big.txt"), 10_000, 64)
1027            .await
1028            .unwrap();
1029        assert!(
1030            got.contains("[... truncated at 64 bytes"),
1031            "expected a byte-cap truncation marker: {got:?}"
1032        );
1033        assert!(got.len() < 128, "output must be bounded near max_bytes");
1034    }
1035
1036    #[tokio::test]
1037    async fn read_file_bounded_read_trims_multibyte_boundary() {
1038        // A multibyte char straddling the `max_bytes` cut must be trimmed to a
1039        // valid char boundary — no panic, no invalid UTF-8 in the output.
1040        let dir = tempfile::tempdir().unwrap();
1041        let env = LocalSessionEnv::new(dir.path(), Limits::default())
1042            .await
1043            .unwrap();
1044        // Each `é` is 2 bytes (U+00E9, UTF-8 C3 A9). 10 of them = 20 bytes.
1045        // Capping at 11 bytes splits the 6th char; the trim drops its trailing
1046        // byte so the result is 5 chars (10 bytes).
1047        let body = "é".repeat(10);
1048        tokio::fs::write(dir.path().join("accent.txt"), body.as_bytes())
1049            .await
1050            .unwrap();
1051        let got = env
1052            .read_file(Path::new("accent.txt"), 10_000, 11)
1053            .await
1054            .unwrap();
1055        // The bounded prefix must be valid UTF-8 and contain only whole chars.
1056        assert!(
1057            got.starts_with("ééééé"),
1058            "trimmed prefix should be whole chars"
1059        );
1060    }
1061
1062    #[tokio::test]
1063    async fn exec_runs_shell_command() {
1064        let dir = tempfile::tempdir().unwrap();
1065        let env = LocalSessionEnv::new(dir.path(), Limits::default())
1066            .await
1067            .unwrap();
1068        let res = env
1069            .exec(
1070                "echo hello",
1071                Path::new("."),
1072                None,
1073                &CancellationToken::new(),
1074            )
1075            .await
1076            .unwrap();
1077        assert_eq!(res.exit_code, 0);
1078        assert_eq!(res.stdout.trim(), "hello");
1079    }
1080
1081    #[tokio::test]
1082    async fn exec_does_not_leak_parent_env_secrets() {
1083        // The model-run shell must NOT inherit provider keys / tokens from the
1084        // parent. We set a distinctive secret in the parent env, run `env` in the
1085        // child, and assert the secret is absent (env_clear + allowlist).
1086        let dir = tempfile::tempdir().unwrap();
1087        let env = LocalSessionEnv::new(dir.path(), Limits::default())
1088            .await
1089            .unwrap();
1090        std::env::set_var("FLUERS_TEST_SECRET", "leak-me-if-you-can");
1091        let res = env
1092            .exec("env", Path::new("."), None, &CancellationToken::new())
1093            .await
1094            .unwrap();
1095        assert_eq!(res.exit_code, 0, "env should run");
1096        assert!(
1097            !res.stdout.contains("FLUERS_TEST_SECRET"),
1098            "parent env secret must not leak into the model-run shell"
1099        );
1100        assert!(
1101            !res.stdout.contains("leak-me-if-you-can"),
1102            "the secret value must not appear in the child env"
1103        );
1104        std::env::remove_var("FLUERS_TEST_SECRET");
1105    }
1106
1107    #[tokio::test]
1108    async fn exec_does_not_leak_lang_prefixed_secrets() {
1109        // Regression: the locale allowlist once used `starts_with("LANG")`, which
1110        // also passed secrets like `LANGCHAIN_API_KEY` into the child. The
1111        // allowlist must match locale names exactly, not by `LANG` prefix.
1112        let dir = tempfile::tempdir().unwrap();
1113        let env = LocalSessionEnv::new(dir.path(), Limits::default())
1114            .await
1115            .unwrap();
1116        std::env::set_var("LANGCHAIN_API_KEY", "lang-prefixed-secret");
1117        let res = env
1118            .exec("env", Path::new("."), None, &CancellationToken::new())
1119            .await
1120            .unwrap();
1121        std::env::remove_var("LANGCHAIN_API_KEY");
1122        assert!(
1123            !res.stdout.contains("LANGCHAIN_API_KEY"),
1124            "a LANG-prefixed secret must not leak into the model-run shell"
1125        );
1126        assert!(
1127            !res.stdout.contains("lang-prefixed-secret"),
1128            "the LANG-prefixed secret value must not appear in the child env"
1129        );
1130    }
1131
1132    #[tokio::test]
1133    async fn exec_timeout_returns_124() {
1134        let dir = tempfile::tempdir().unwrap();
1135        let env = LocalSessionEnv::new(dir.path(), Limits::default())
1136            .await
1137            .unwrap();
1138        let res = env
1139            .exec(
1140                "sleep 5",
1141                Path::new("."),
1142                Some(200),
1143                &CancellationToken::new(),
1144            )
1145            .await
1146            .unwrap();
1147        assert_eq!(res.exit_code, 124, "timeout must yield exit 124");
1148    }
1149
1150    #[test]
1151    fn glob_matcher_basics() {
1152        assert!(matches_glob("foo.txt", "*.txt"));
1153        assert!(matches_glob("foo.txt", "foo.*"));
1154        assert!(!matches_glob("foo.txt", "*.md"));
1155        assert!(matches_glob("a", "?"));
1156    }
1157
1158    #[test]
1159    fn read_limit_truncates() {
1160        let got = apply_read_limits("a\nb\nc\nd\n".into(), 2, 1024);
1161        assert!(got.contains("a"));
1162        assert!(got.contains("b"));
1163        assert!(got.contains("truncated"));
1164    }
1165
1166    #[tokio::test]
1167    async fn glob_rejects_absolute_pattern() {
1168        let dir = tempfile::tempdir().unwrap();
1169        let env = LocalSessionEnv::new(dir.path(), Limits::default())
1170            .await
1171            .unwrap();
1172        let res = env.glob("/etc/*", 10).await;
1173        assert!(res.is_err(), "absolute glob patterns must be rejected");
1174    }
1175
1176    #[tokio::test]
1177    async fn glob_rejects_parent_dir_pattern() {
1178        let dir = tempfile::tempdir().unwrap();
1179        let env = LocalSessionEnv::new(dir.path(), Limits::default())
1180            .await
1181            .unwrap();
1182        let res = env.glob("../**/*", 10).await;
1183        assert!(res.is_err(), "`..` in glob patterns must be rejected");
1184    }
1185
1186    #[tokio::test]
1187    async fn grep_rejects_absolute_path() {
1188        let dir = tempfile::tempdir().unwrap();
1189        let env = LocalSessionEnv::new(dir.path(), Limits::default())
1190            .await
1191            .unwrap();
1192        let res = env.grep("foo", &["/etc/passwd"], 10).await;
1193        assert!(res.is_err(), "absolute grep paths must be rejected");
1194    }
1195
1196    #[tokio::test]
1197    async fn grep_rejects_parent_dir_path() {
1198        let dir = tempfile::tempdir().unwrap();
1199        let env = LocalSessionEnv::new(dir.path(), Limits::default())
1200            .await
1201            .unwrap();
1202        let res = env.grep("foo", &["../.env"], 10).await;
1203        assert!(res.is_err(), "`..` grep paths must be rejected");
1204    }
1205
1206    // ── B-Swift Phase C1a / #4: fd-anchored read TOCTOU / hardlink coverage ──
1207    // These prove the fix: the OLD path-based `read_to_string(resolved)` followed
1208    // symlinks (leaking the target) and ignored `st_nlink`, so each of these
1209    // would have SUCCEEDED (exfiltrated the secret) before the fix.
1210
1211    /// Write a secret to a file OUTSIDE the env root (a sibling temp dir) and
1212    /// return both the held `TempDir` (keep alive for the test) and its path.
1213    #[cfg(unix)]
1214    fn outside_secret(body: &str) -> (tempfile::TempDir, PathBuf) {
1215        use std::io::Write;
1216        let dir = tempfile::tempdir().unwrap();
1217        let path = dir.path().join("secret.txt");
1218        let mut f = std::fs::File::create(&path).unwrap();
1219        f.write_all(body.as_bytes()).unwrap();
1220        (dir, path)
1221    }
1222
1223    #[cfg(unix)]
1224    #[tokio::test]
1225    async fn read_file_rejects_symlink_leaf_even_when_target_inside_root() {
1226        use std::os::unix::fs::symlink;
1227        let dir = tempfile::tempdir().unwrap();
1228        let env = LocalSessionEnv::new(dir.path(), Limits::default())
1229            .await
1230            .unwrap();
1231        tokio::fs::write(dir.path().join("inside.txt"), "ok\n")
1232            .await
1233            .unwrap();
1234        symlink("inside.txt", dir.path().join("link.txt")).unwrap();
1235        let res = env.read_file(Path::new("link.txt"), 100, 1024).await;
1236        assert!(
1237            res.is_err(),
1238            "a symlink leaf must be rejected even if its target is inside the root"
1239        );
1240    }
1241
1242    #[cfg(unix)]
1243    #[tokio::test]
1244    async fn read_file_rejects_symlink_leaf_to_outside_root() {
1245        // Exfil via symlink: link.txt -> /outside/secret. The OLD read followed
1246        // it and leaked "TOPSECRET"; the anchored `openat(O_NOFOLLOW)` rejects
1247        // the symlink leaf outright.
1248        use std::os::unix::fs::symlink;
1249        let dir = tempfile::tempdir().unwrap();
1250        let env = LocalSessionEnv::new(dir.path(), Limits::default())
1251            .await
1252            .unwrap();
1253        let (_outside, secret) = outside_secret("TOPSECRET");
1254        symlink(&secret, dir.path().join("link.txt")).unwrap();
1255        let res = env.read_file(Path::new("link.txt"), 100, 1024).await;
1256        assert!(
1257            res.is_err(),
1258            "a symlink to outside the root must be rejected"
1259        );
1260        if let Ok(s) = res {
1261            assert!(!s.contains("TOPSECRET"), "the secret must not leak");
1262        }
1263    }
1264
1265    #[cfg(unix)]
1266    #[tokio::test]
1267    async fn read_file_rejects_intermediate_symlink_dir() {
1268        // Exfil via a symlinked intermediate dir: linkdir -> realdir; reading
1269        // `linkdir/file.txt` must reject at the `linkdir` component (per-component
1270        // `openat(O_NOFOLLOW)`).
1271        use std::os::unix::fs::symlink;
1272        let dir = tempfile::tempdir().unwrap();
1273        let env = LocalSessionEnv::new(dir.path(), Limits::default())
1274            .await
1275            .unwrap();
1276        tokio::fs::create_dir_all(dir.path().join("realdir"))
1277            .await
1278            .unwrap();
1279        tokio::fs::write(dir.path().join("realdir/file.txt"), "ok\n")
1280            .await
1281            .unwrap();
1282        symlink("realdir", dir.path().join("linkdir")).unwrap();
1283        let res = env
1284            .read_file(Path::new("linkdir/file.txt"), 100, 1024)
1285            .await;
1286        assert!(
1287            res.is_err(),
1288            "a symlinked intermediate dir must be rejected"
1289        );
1290    }
1291
1292    #[cfg(unix)]
1293    #[tokio::test]
1294    async fn read_file_rejects_hardlink_to_outside_secret() {
1295        // Hardlink exfil: `ln /outside/secret root/link.txt`. The file is regular
1296        // and inside the root, but `st_nlink > 1` → reject (mirrors the Swift
1297        // C2/#3 decision; authoritative here via post-open `fstat`).
1298        let dir = tempfile::tempdir().unwrap();
1299        let env = LocalSessionEnv::new(dir.path(), Limits::default())
1300            .await
1301            .unwrap();
1302        let (_outside, secret) = outside_secret("TOPSECRET");
1303        std::fs::hard_link(&secret, dir.path().join("link.txt")).unwrap();
1304        let res = env.read_file(Path::new("link.txt"), 100, 1024).await;
1305        assert!(res.is_err(), "a hardlink (st_nlink > 1) must be rejected");
1306        if let Ok(s) = res {
1307            assert!(!s.contains("TOPSECRET"), "the secret must not leak");
1308        }
1309    }
1310
1311    #[cfg(unix)]
1312    #[tokio::test]
1313    async fn read_file_full_rejects_symlink_leaf() {
1314        use std::os::unix::fs::symlink;
1315        let dir = tempfile::tempdir().unwrap();
1316        let env = LocalSessionEnv::new(dir.path(), Limits::default())
1317            .await
1318            .unwrap();
1319        let (_outside, secret) = outside_secret("TOPSECRET");
1320        symlink(&secret, dir.path().join("link.txt")).unwrap();
1321        let res = env.read_file_full(Path::new("link.txt"), 1024).await;
1322        assert!(res.is_err(), "read_file_full must reject a symlink leaf");
1323        if let Ok(s) = res {
1324            assert!(!s.contains("TOPSECRET"));
1325        }
1326    }
1327
1328    #[cfg(unix)]
1329    #[tokio::test]
1330    async fn read_file_full_rejects_hardlink() {
1331        let dir = tempfile::tempdir().unwrap();
1332        let env = LocalSessionEnv::new(dir.path(), Limits::default())
1333            .await
1334            .unwrap();
1335        let (_outside, secret) = outside_secret("TOPSECRET");
1336        std::fs::hard_link(&secret, dir.path().join("link.txt")).unwrap();
1337        let res = env.read_file_full(Path::new("link.txt"), 1024).await;
1338        assert!(
1339            res.is_err(),
1340            "read_file_full must reject a hardlink (st_nlink > 1)"
1341        );
1342    }
1343
1344    #[cfg(unix)]
1345    #[tokio::test]
1346    async fn read_anchored_nested_relative_path_still_works() {
1347        // Regression guard: the anchored walk must still read a real nested
1348        // file (intermediate dirs are opened `O_NOFOLLOW` + read off the leaf fd).
1349        let dir = tempfile::tempdir().unwrap();
1350        let env = LocalSessionEnv::new(dir.path(), Limits::default())
1351            .await
1352            .unwrap();
1353        tokio::fs::create_dir_all(dir.path().join("a/b"))
1354            .await
1355            .unwrap();
1356        tokio::fs::write(dir.path().join("a/b/c.txt"), "deep\n")
1357            .await
1358            .unwrap();
1359        let got = env
1360            .read_file(Path::new("a/b/c.txt"), 100, 1024)
1361            .await
1362            .unwrap();
1363        assert_eq!(got, "deep\n");
1364    }
1365
1366    // ── B-Swift Phase C1b: fd-anchored write / exec / glob / grep TOCTOU ──
1367    // Each of these FAILED (or leaked) on the old path-based `resolve()` and
1368    // passes on the fd-anchored walk. The inside-target symlink cases are the
1369    // real TOCTOU proof: the OLD `resolve()` canonicalized a symlink whose
1370    // target was inside the root → passed containment → the subsequent path-
1371    // based op followed it. The fd-anchored walk rejects at `openat(NO_FOLLOW)`.
1372
1373    #[cfg(unix)]
1374    #[tokio::test]
1375    async fn write_file_rejects_symlink_leaf_pointing_inside() {
1376        // OLD: resolve() canonicalized `link.txt` → inside `target.txt`
1377        // (contained) → `tokio::fs::write` followed the symlink and overwrote
1378        // the target. NEW: `openat(O_NOFOLLOW)` rejects the symlink leaf; the
1379        // inside target is untouched.
1380        use std::os::unix::fs::symlink;
1381        let dir = tempfile::tempdir().unwrap();
1382        let env = LocalSessionEnv::new(dir.path(), Limits::default())
1383            .await
1384            .unwrap();
1385        tokio::fs::write(dir.path().join("target.txt"), "ORIGINAL")
1386            .await
1387            .unwrap();
1388        symlink("target.txt", dir.path().join("link.txt")).unwrap();
1389        let res = env.write_file(Path::new("link.txt"), "OVERWRITE").await;
1390        assert!(
1391            res.is_err(),
1392            "writing through a symlink leaf must be rejected"
1393        );
1394        let got = tokio::fs::read_to_string(dir.path().join("target.txt"))
1395            .await
1396            .unwrap();
1397        assert_eq!(
1398            got, "ORIGINAL",
1399            "the symlink target must not be overwritten"
1400        );
1401    }
1402
1403    #[cfg(unix)]
1404    #[tokio::test]
1405    async fn write_file_rejects_symlinked_intermediate_dir() {
1406        // OLD: resolve() canonicalized `linkdir/file.txt` through the symlink
1407        // (contained) → wrote through it. NEW: the mkdirat/openat walk rejects
1408        // the symlinked `linkdir` component.
1409        use std::os::unix::fs::symlink;
1410        let dir = tempfile::tempdir().unwrap();
1411        let env = LocalSessionEnv::new(dir.path(), Limits::default())
1412            .await
1413            .unwrap();
1414        tokio::fs::create_dir_all(dir.path().join("realdir"))
1415            .await
1416            .unwrap();
1417        symlink("realdir", dir.path().join("linkdir")).unwrap();
1418        let res = env.write_file(Path::new("linkdir/file.txt"), "data").await;
1419        assert!(
1420            res.is_err(),
1421            "writing through a symlinked intermediate dir must be rejected"
1422        );
1423    }
1424
1425    #[cfg(unix)]
1426    #[tokio::test]
1427    async fn write_file_rejects_hardlink_to_outside_secret() {
1428        // OLD: resolve() canonicalized the inside link (contained) →
1429        // `tokio::fs::write` wrote through the shared inode → corrupted
1430        // /outside/secret. NEW: fstat off the open fd sees `st_nlink > 1` →
1431        // reject; the outside file is unchanged.
1432        let dir = tempfile::tempdir().unwrap();
1433        let env = LocalSessionEnv::new(dir.path(), Limits::default())
1434            .await
1435            .unwrap();
1436        let (_outside, secret) = outside_secret("ORIGINAL-SECRET");
1437        std::fs::hard_link(&secret, dir.path().join("link.txt")).unwrap();
1438        let res = env.write_file(Path::new("link.txt"), "CORRUPTED").await;
1439        assert!(
1440            res.is_err(),
1441            "writing a hardlink (st_nlink > 1) must be rejected"
1442        );
1443        let got = std::fs::read_to_string(&secret).unwrap();
1444        assert_eq!(
1445            got, "ORIGINAL-SECRET",
1446            "the outside secret must not be corrupted"
1447        );
1448    }
1449
1450    #[tokio::test]
1451    async fn write_file_creates_new_nested_path() {
1452        // Regression: the mkdirat walk + leaf open must still create brand-new
1453        // nested files (the happy path must not regress).
1454        let dir = tempfile::tempdir().unwrap();
1455        let env = LocalSessionEnv::new(dir.path(), Limits::default())
1456            .await
1457            .unwrap();
1458        env.write_file(Path::new("a/b/c/new.txt"), "deep")
1459            .await
1460            .unwrap();
1461        let got = env
1462            .read_file(Path::new("a/b/c/new.txt"), 100, 1024)
1463            .await
1464            .unwrap();
1465        assert_eq!(got, "deep");
1466    }
1467
1468    #[cfg(unix)]
1469    #[tokio::test]
1470    async fn exec_rejects_symlinked_cwd_pointing_inside() {
1471        // OLD: resolve() canonicalized the symlinked cwd → inside dir
1472        // (contained) → the child ran there. NEW: open_anchored_dir rejects the
1473        // symlink at the openat component.
1474        use std::os::unix::fs::symlink;
1475        let dir = tempfile::tempdir().unwrap();
1476        let env = LocalSessionEnv::new(dir.path(), Limits::default())
1477            .await
1478            .unwrap();
1479        tokio::fs::create_dir_all(dir.path().join("realcwd"))
1480            .await
1481            .unwrap();
1482        symlink("realcwd", dir.path().join("linkcwd")).unwrap();
1483        let res = env
1484            .exec(
1485                "echo hi",
1486                Path::new("linkcwd"),
1487                None,
1488                &CancellationToken::new(),
1489            )
1490            .await;
1491        assert!(res.is_err(), "a symlinked cwd must be rejected");
1492    }
1493
1494    #[tokio::test]
1495    async fn exec_large_stdout_does_not_deadlock() {
1496        // Regression: `exec` used to `child.wait()` WITHOUT draining the stdout
1497        // pipe, so a child emitting more than the OS pipe buffer (~64 KB) blocked
1498        // on a full pipe while `wait()` blocked on the child — a deadlock. With no
1499        // timeout set (as here) the old code hung forever; `wait_with_output` now
1500        // drains both pipes concurrently, so the full output returns intact.
1501        let dir = tempfile::tempdir().unwrap();
1502        let env = LocalSessionEnv::new(dir.path(), Limits::default())
1503            .await
1504            .unwrap();
1505        let res = env
1506            .exec(
1507                "yes a | head -c 200000",
1508                Path::new("."),
1509                None,
1510                &CancellationToken::new(),
1511            )
1512            .await
1513            .unwrap();
1514        assert_eq!(res.exit_code, 0);
1515        assert_eq!(
1516            res.stdout.len(),
1517            200_000,
1518            "full >64 KB stdout must survive without deadlock"
1519        );
1520    }
1521
1522    #[tokio::test]
1523    async fn glob_returns_matching_files() {
1524        // Regression for the fd-anchored rewrite: it must still surface real
1525        // files at the base and nested under real subdirectories.
1526        let dir = tempfile::tempdir().unwrap();
1527        let env = LocalSessionEnv::new(dir.path(), Limits::default())
1528            .await
1529            .unwrap();
1530        tokio::fs::write(dir.path().join("top.txt"), "x")
1531            .await
1532            .unwrap();
1533        tokio::fs::create_dir_all(dir.path().join("sub"))
1534            .await
1535            .unwrap();
1536        tokio::fs::write(dir.path().join("sub/nested.txt"), "x")
1537            .await
1538            .unwrap();
1539        let matched = env.glob("*.txt", 100).await.unwrap();
1540        assert!(
1541            matched.iter().any(|m| m == "top.txt"),
1542            "base file should match: {matched:?}"
1543        );
1544        assert!(
1545            matched.iter().any(|m| m == "sub/nested.txt"),
1546            "nested file should match: {matched:?}"
1547        );
1548    }
1549
1550    #[tokio::test]
1551    async fn glob_subdir_pattern_reports_root_relative_paths() {
1552        // Regression for the base-prefix bug: a pattern with a subdir base must
1553        // report paths relative to the ROOT, not relative to the base.
1554        let dir = tempfile::tempdir().unwrap();
1555        let env = LocalSessionEnv::new(dir.path(), Limits::default())
1556            .await
1557            .unwrap();
1558        tokio::fs::create_dir_all(dir.path().join("sub"))
1559            .await
1560            .unwrap();
1561        tokio::fs::write(dir.path().join("sub/nested.txt"), "x")
1562            .await
1563            .unwrap();
1564        let matched = env.glob("sub/*.txt", 100).await.unwrap();
1565        assert!(
1566            matched.iter().any(|m| m == "sub/nested.txt"),
1567            "must be root-relative (`sub/nested.txt`), not base-relative: {matched:?}"
1568        );
1569        assert!(
1570            !matched.iter().any(|m| m == "nested.txt"),
1571            "base-relative leak must not happen: {matched:?}"
1572        );
1573    }
1574
1575    #[cfg(unix)]
1576    #[tokio::test]
1577    async fn glob_does_not_traverse_symlinked_dir_to_outside() {
1578        // OLD glob's `path.is_dir()` FOLLOWED the symlink → recursed into the
1579        // outside dir → leaked its `.txt`. NEW: descent is via
1580        // `openat(O_DIRECTORY|O_NOFOLLOW)` → the symlinked dir is never entered.
1581        use std::os::unix::fs::symlink;
1582        let dir = tempfile::tempdir().unwrap();
1583        let env = LocalSessionEnv::new(dir.path(), Limits::default())
1584            .await
1585            .unwrap();
1586        tokio::fs::write(dir.path().join("inside.txt"), "ok")
1587            .await
1588            .unwrap();
1589        tokio::fs::create_dir_all(dir.path().join("realdir"))
1590            .await
1591            .unwrap();
1592        tokio::fs::write(dir.path().join("realdir/nested.txt"), "ok")
1593            .await
1594            .unwrap();
1595        // A symlinked dir pointing at the outside temp dir (which holds
1596        // `secret.txt`).
1597        let (_outside, secret) = outside_secret("OUTSIDE-SECRET");
1598        let outside_dir = secret.parent().unwrap();
1599        symlink(outside_dir, dir.path().join("linkdir")).unwrap();
1600        let matched = env.glob("*.txt", 100).await.unwrap();
1601        assert!(
1602            matched.iter().any(|m| m == "inside.txt"),
1603            "inside file should match: {matched:?}"
1604        );
1605        assert!(
1606            matched.iter().any(|m| m == "realdir/nested.txt"),
1607            "real nested file should match: {matched:?}"
1608        );
1609        assert!(
1610            !matched.iter().any(|m| m.starts_with("linkdir")),
1611            "symlinked dir must not be traversed: {matched:?}"
1612        );
1613        for m in &matched {
1614            assert!(
1615                !m.contains("secret.txt") && !m.contains("OUTSIDE-SECRET"),
1616                "outside file must not leak: {m}"
1617            );
1618        }
1619    }
1620
1621    #[tokio::test]
1622    async fn grep_returns_matches() {
1623        // Regression for the inode-anchored search: it must still surface real
1624        // matches inside the root, AND the output must be root-relative (not the
1625        // absolute host temp/root path, which the inode-path search would
1626        // otherwise leak).
1627        let dir = tempfile::tempdir().unwrap();
1628        let env = LocalSessionEnv::new(dir.path(), Limits::default())
1629            .await
1630            .unwrap();
1631        tokio::fs::write(dir.path().join("note.md"), "findme here\n")
1632            .await
1633            .unwrap();
1634        let matched = env.grep("findme", &["."], 100).await.unwrap();
1635        assert!(
1636            matched.iter().any(|m| m.contains("findme")),
1637            "expected a match: {matched:?}"
1638        );
1639        // Output paths are root-relative...
1640        assert!(
1641            matched.iter().any(|m| m.starts_with("note.md:")),
1642            "expected a root-relative `note.md:` line: {matched:?}"
1643        );
1644        // ...and must NOT leak the host temp/root path.
1645        let root_str = dir.path().to_string_lossy().into_owned();
1646        for m in &matched {
1647            assert!(
1648                !m.contains(&root_str),
1649                "grep output must not leak the absolute root path: {m}"
1650            );
1651        }
1652    }
1653
1654    #[cfg(unix)]
1655    #[tokio::test]
1656    async fn grep_rejects_symlinked_search_path() {
1657        // `rg --no-follow` still follows a symlinked dir passed EXPLICITLY as a
1658        // search path, so the path is resolved fd-anchored to its inode and a
1659        // symlink is rejected outright (no leak via `linkdir -> outside`).
1660        use std::os::unix::fs::symlink;
1661        let dir = tempfile::tempdir().unwrap();
1662        let env = LocalSessionEnv::new(dir.path(), Limits::default())
1663            .await
1664            .unwrap();
1665        let (_outside, secret) = outside_secret("GREP-LEAK");
1666        let outside_dir = secret.parent().unwrap();
1667        symlink(outside_dir, dir.path().join("linkdir")).unwrap();
1668        // Explicit symlinked path → rejected (Err), never searched.
1669        let res = env.grep("GREP-LEAK", &["linkdir"], 100).await;
1670        assert!(
1671            res.is_err(),
1672            "an explicit symlinked search path must be rejected"
1673        );
1674        // And a `.` search must not traverse the symlinked dir either.
1675        let matched = env.grep("GREP-LEAK", &["."], 100).await.unwrap();
1676        assert!(
1677            matched.is_empty(),
1678            "the symlinked dir must not be traversed: {matched:?}"
1679        );
1680    }
1681
1682    #[cfg(unix)]
1683    #[tokio::test]
1684    async fn grep_anchors_to_root_fd_not_root_path() {
1685        // TOCTOU for grep: after the env is built, move the real root aside and
1686        // replace the root *path* with a symlink to an outside dir holding a
1687        // secret. OLD grep used `current_dir(self.root)` (the path) → would
1688        // chdir through the symlink and surface the secret. NEW grep anchors to
1689        // `/dev/fd/{root_fd}` → chdir to the real (moved) root → no leak.
1690        use std::os::unix::fs::symlink;
1691        // A parent dir we fully control (manual, not TempDir, so the swap + the
1692        // symlink-over-root don't confuse Drop cleanup).
1693        let nonce = std::time::SystemTime::now()
1694            .duration_since(std::time::UNIX_EPOCH)
1695            .map(|d| d.as_nanos())
1696            .unwrap_or(0);
1697        let parent = std::env::temp_dir().join(format!("fluers-grep-swap-{nonce}"));
1698        std::fs::create_dir_all(&parent).unwrap();
1699        let root_path = parent.join("root");
1700        std::fs::create_dir_all(&root_path).unwrap();
1701        let env = LocalSessionEnv::new(&root_path, Limits::default())
1702            .await
1703            .unwrap();
1704
1705        let outside = parent.join("outside");
1706        std::fs::create_dir_all(&outside).unwrap();
1707        std::fs::write(outside.join("leak.txt"), "PATHSWAP-SECRET\n").unwrap();
1708
1709        // Swap: move the real root aside (sibling), then symlink the root path
1710        // → outside.
1711        let moved = parent.join("moved-real-root");
1712        std::fs::rename(&root_path, &moved).unwrap();
1713        symlink(&outside, &root_path).unwrap();
1714
1715        let matched = env.grep("PATHSWAP-SECRET", &["."], 100).await.unwrap();
1716        assert!(
1717            matched.is_empty(),
1718            "root-fd anchoring must not follow the swapped root path: {matched:?}"
1719        );
1720
1721        // We own `parent` fully — clean up everything under it.
1722        let _ = std::fs::remove_dir_all(&parent);
1723    }
1724}