diff --git a/crates/bashkit-js/__test__/runtime-compat/vfs.test.mjs b/crates/bashkit-js/__test__/runtime-compat/vfs.test.mjs index ce28fc809..efdd57b97 100644 --- a/crates/bashkit-js/__test__/runtime-compat/vfs.test.mjs +++ b/crates/bashkit-js/__test__/runtime-compat/vfs.test.mjs @@ -110,8 +110,10 @@ describe("VFS API", () => { bash.writeFile("/tmp/target.txt", "data"); bash.symlink("/tmp/target.txt", "/tmp/link.txt"); assert.equal(bash.readLink("/tmp/link.txt"), "/tmp/target.txt"); - const meta = bash.stat("/tmp/link.txt"); - assert.equal(meta.fileType, "symlink"); + assert.equal(bash.lstat("/tmp/link.txt").fileType, "symlink"); + // stat follows the link, like Node's fs.stat. + assert.equal(bash.stat("/tmp/link.txt").fileType, "file"); + assert.equal(bash.fs().lstat("/tmp/link.txt").fileType, "symlink"); }); it("fs() accessor provides same operations", () => { diff --git a/crates/bashkit-js/src/lib.rs b/crates/bashkit-js/src/lib.rs index d656e82a0..4b935d171 100644 --- a/crates/bashkit-js/src/lib.rs +++ b/crates/bashkit-js/src/lib.rs @@ -503,6 +503,16 @@ impl NativeFileSystemState { }) } + fn lstat(&self, path: String) -> napi::Result { + self.with_fs(|fs| async move { + let meta = fs + .lstat(Path::new(&path)) + .await + .map_err(|e| napi::Error::from_reason(e.to_string()))?; + Ok(metadata_to_js(&meta)) + }) + } + fn exists(&self, path: String) -> napi::Result { self.with_fs(|fs| async move { fs.exists(Path::new(&path)) @@ -661,6 +671,14 @@ pub fn file_system_stat( fs.stat(path) } +#[napi(js_name = "__fileSystemLstat")] +pub fn file_system_lstat( + fs: &External, + path: String, +) -> napi::Result { + fs.lstat(path) +} + #[napi(js_name = "__fileSystemExists")] pub fn file_system_exists( fs: &External, @@ -2241,7 +2259,7 @@ impl Bash { // VFS — direct filesystem access // ======================================================================== - /// Get metadata for a path in the virtual filesystem. + /// Get metadata for a path in the virtual filesystem, following symlinks. #[napi] pub fn stat(&self, path: String) -> napi::Result { block_on_with(&self.state, |s| async move { @@ -2255,6 +2273,20 @@ impl Bash { }) } + /// Get metadata without following a symlink in the final component. + #[napi] + pub fn lstat(&self, path: String) -> napi::Result { + block_on_with(&self.state, |s| async move { + let bash = s.inner.lock().await; + let meta = bash + .fs() + .lstat(Path::new(&path)) + .await + .map_err(|e| napi::Error::from_reason(e.to_string()))?; + Ok(metadata_to_js(&meta)) + }) + } + /// Append content to a file in the virtual filesystem. #[napi] pub fn append_file(&self, path: String, content: String) -> napi::Result<()> { @@ -2940,7 +2972,7 @@ impl BashTool { // VFS — direct filesystem access (no shell command composition) // ======================================================================== - /// Get metadata for a path in the virtual filesystem. + /// Get metadata for a path in the virtual filesystem, following symlinks. #[napi] pub fn stat(&self, path: String) -> napi::Result { block_on_with(&self.state, |s| async move { @@ -2954,6 +2986,20 @@ impl BashTool { }) } + /// Get metadata without following a symlink in the final component. + #[napi] + pub fn lstat(&self, path: String) -> napi::Result { + block_on_with(&self.state, |s| async move { + let bash = s.inner.lock().await; + let meta = bash + .fs() + .lstat(Path::new(&path)) + .await + .map_err(|e| napi::Error::from_reason(e.to_string()))?; + Ok(metadata_to_js(&meta)) + }) + } + /// Append content to a file in the virtual filesystem. #[napi] pub fn append_file(&self, path: String, content: String) -> napi::Result<()> { diff --git a/crates/bashkit-js/wrapper.ts b/crates/bashkit-js/wrapper.ts index b64c3758e..ecec5fdb8 100644 --- a/crates/bashkit-js/wrapper.ts +++ b/crates/bashkit-js/wrapper.ts @@ -86,6 +86,8 @@ const nativeFileSystemStat: ( modified: number; created: number; } = native.__fileSystemStat; +const nativeFileSystemLstat: typeof nativeFileSystemStat = + native.__fileSystemLstat; const nativeFileSystemExists: (fs: any, path: string) => boolean = native.__fileSystemExists; const nativeFileSystemReadDir: ( @@ -635,6 +637,16 @@ export class FileSystem { return nativeFileSystemStat(this.native, path); } + lstat(path: string): { + fileType: string; + size: number; + mode: number; + modified: number; + created: number; + } { + return nativeFileSystemLstat(this.native, path); + } + exists(path: string): boolean { return nativeFileSystemExists(this.native, path); } @@ -1164,7 +1176,7 @@ export class Bash { this.native.remove(path, recursive); } - /** Get metadata for a path (fileType, size, mode, timestamps). */ + /** Get metadata for a path (fileType, size, mode, timestamps), following symlinks. */ stat(path: string): { fileType: string; size: number; @@ -1175,6 +1187,17 @@ export class Bash { return this.native.stat(path); } + /** Like `stat`, but a symlink reports itself (`fileType: "symlink"`). */ + lstat(path: string): { + fileType: string; + size: number; + mode: number; + modified: number; + created: number; + } { + return this.native.lstat(path); + } + /** Append content to a file. */ appendFile(path: string, content: string): void { this.native.appendFile(path, content); @@ -1648,7 +1671,7 @@ export class BashTool { this.native.remove(path, recursive); } - /** Get metadata for a path (fileType, size, mode, timestamps). */ + /** Get metadata for a path (fileType, size, mode, timestamps), following symlinks. */ stat(path: string): { fileType: string; size: number; @@ -1659,6 +1682,17 @@ export class BashTool { return this.native.stat(path); } + /** Like `stat`, but a symlink reports itself (`fileType: "symlink"`). */ + lstat(path: string): { + fileType: string; + size: number; + mode: number; + modified: number; + created: number; + } { + return this.native.lstat(path); + } + /** Append content to a file. */ appendFile(path: string, content: string): void { this.native.appendFile(path, content); diff --git a/crates/bashkit/docs/compatibility.md b/crates/bashkit/docs/compatibility.md index 125e17bca..dd7437986 100644 --- a/crates/bashkit/docs/compatibility.md +++ b/crates/bashkit/docs/compatibility.md @@ -399,7 +399,7 @@ Default limits (configurable): |---------|--------|-------| | Virtual filesystem | ✅ | InMemoryFs, OverlayFs, MountableFs | | Real filesystem | ❌ | Virtual by default | -| Symlinks | ✅ | Stored but not followed | +| Symlinks | ✅ | Followed in the VFS (40-link cap); `..` resolves lexically | | Permissions | ✅ | Metadata stored, not enforced | | `/dev/null` | ✅ | Interpreter-level handling (cannot be bypassed) | | `/dev/zero` | ✅ | 1 MiB of zeros per read | diff --git a/crates/bashkit/docs/threat-model.md b/crates/bashkit/docs/threat-model.md index d901581af..201549797 100644 --- a/crates/bashkit/docs/threat-model.md +++ b/crates/bashkit/docs/threat-model.md @@ -47,7 +47,7 @@ through configurable limits. | Append flood (TM-DOS-010) | `while true; do echo x >> f; done` | FS + loop limits | MITIGATED | | RealFs append memory exhaustion (TM-DOS-105) | Tiny append to a large writable host file | Stream existing bytes into atomic sibling staging with bounded memory | MITIGATED | | Deep Agents VFS search amplification (TM-DOS-113) | Dense matching files or a broad recursive `grep`/`glob` bypass shell execution limits and amplify into host objects | Grep caps results at 1,000 matches and 100 KB of matched text; line scans stream; every direct walk carries a per-operation deadline, 10,000-file and 10 MB traversal budget, and a cancellation flag the async workers set | **MITIGATED** | -| Symlink loops (TM-DOS-011) | `ln -s /a /b; ln -s /b /a` | No symlink following | MITIGATED | +| Symlink loops (TM-DOS-011) | `ln -s /a /b; ln -s /b /a` | At most 40 links per lookup, then "Too many levels of symbolic links" | MITIGATED | | Deep dirs (TM-DOS-012) | `mkdir -p a/b/c/.../z` (1000 levels) | `max_path_depth` (100) | MITIGATED | | Long filenames (TM-DOS-013) | 10KB filename | `max_filename_length` (255) + `max_path_length` (4096) | MITIGATED | | Many dir entries (TM-DOS-014) | 1M files in one dir | `max_file_count` | MITIGATED | @@ -189,7 +189,7 @@ Scripts may attempt to break out of the sandbox to access the host system. | Threat | Attack Example | Mitigation | Status | |--------|---------------|------------|--------| | Path traversal (TM-ESC-001) | `cat /../../../etc/passwd` | Path normalization | MITIGATED | -| Symlink escape (TM-ESC-002) | `ln -s /etc/passwd /tmp/x` | Symlinks not followed | MITIGATED | +| Symlink escape (TM-ESC-002) | `ln -s /etc/passwd /tmp/x` | Links resolve to VFS paths only; real-mount containment still applies to the result | MITIGATED | | Real FS access (TM-ESC-003) | Direct syscalls | No real FS by default | MITIGATED | | Mount escape (TM-ESC-004) | Mount real paths | MountableFs controlled by caller | MITIGATED | | VFS limit bypass (TM-ESC-012) | `add_file()` skips limits | Restrict API visibility | **MITIGATED** | diff --git a/crates/bashkit/src/builtins/cpython/wasi.rs b/crates/bashkit/src/builtins/cpython/wasi.rs index b6610e702..d357cafb9 100644 --- a/crates/bashkit/src/builtins/cpython/wasi.rs +++ b/crates/bashkit/src/builtins/cpython/wasi.rs @@ -56,6 +56,8 @@ const FILETYPE_UNKNOWN: u8 = 0; const FILETYPE_DIRECTORY: u8 = 3; const FILETYPE_REGULAR_FILE: u8 = 4; const FILETYPE_SYMBOLIC_LINK: u8 = 7; +/// WASI `lookupflags`: follow a symlink in the final path component. +const LOOKUPFLAGS_SYMLINK_FOLLOW: i32 = 1; const OFLAGS_CREAT: u16 = 1; const OFLAGS_DIRECTORY: u16 = 2; @@ -533,7 +535,8 @@ fn encode_filestat(s: &Stat) -> [u8; 64] { b } -async fn stat_path(state: &GuestState, path: &Path) -> Result { +/// `follow` = stat semantics; otherwise lstat (a final symlink reports itself). +async fn stat_path(state: &GuestState, path: &Path, follow: bool) -> Result { if state.is_stdlib(path) { return Ok(Stat { filetype: FILETYPE_REGULAR_FILE, @@ -542,7 +545,12 @@ async fn stat_path(state: &GuestState, path: &Path) -> Result { ino: inode(path), }); } - match state.fs.stat(path).await { + let meta = if follow { + state.fs.stat(path).await + } else { + state.fs.lstat(path).await + }; + match meta { Ok(m) => Ok(Stat { filetype: filetype_of(m.file_type), size: m.size, @@ -787,7 +795,7 @@ pub(crate) fn add_to_linker(linker: &mut Linker) -> wasmtime::Result }, Fd::Dir(d) => { let path = d.path.clone(); - stat_path(c.data(), &path).await? + stat_path(c.data(), &path, true).await? } Fd::Stdin | Fd::Stdout | Fd::Stderr => Stat { filetype: FILETYPE_UNKNOWN, @@ -998,12 +1006,13 @@ pub(crate) fn add_to_linker(linker: &mut Linker) -> wasmtime::Result MODULE, "path_filestat_get", |mut c: Caller<'_, GuestState>, - (dirfd, _flags, path, path_len, buf): (i32, i32, i32, i32, i32)| { + (dirfd, flags, path, path_len, buf): (i32, i32, i32, i32, i32)| { Box::new(async move { let r = async { let raw = read_bytes(&mut c, path, path_len)?; let p = c.data().resolve(dirfd as u32, &raw)?; - let stat = stat_path(c.data(), &p).await?; + let follow = flags & LOOKUPFLAGS_SYMLINK_FOLLOW != 0; + let stat = stat_path(c.data(), &p, follow).await?; write_bytes(&mut c, buf, &encode_filestat(&stat)) } .await; @@ -1080,7 +1089,7 @@ pub(crate) fn add_to_linker(linker: &mut Linker) -> wasmtime::Result return Err(EACCES); } let fs = c.data().fs.clone(); - let meta = fs.stat(&p).await.map_err(|e| map_fs_error(&e))?; + let meta = fs.lstat(&p).await.map_err(|e| map_fs_error(&e))?; if !meta.file_type.is_dir() { return Err(ENOTDIR); } @@ -1107,7 +1116,7 @@ pub(crate) fn add_to_linker(linker: &mut Linker) -> wasmtime::Result return Err(EROFS); } let fs = c.data().fs.clone(); - let meta = fs.stat(&p).await.map_err(|e| map_fs_error(&e))?; + let meta = fs.lstat(&p).await.map_err(|e| map_fs_error(&e))?; if meta.file_type.is_dir() { return Err(EISDIR); } diff --git a/crates/bashkit/src/builtins/fileops.rs b/crates/bashkit/src/builtins/fileops.rs index d25ccadb9..bfd3b0008 100644 --- a/crates/bashkit/src/builtins/fileops.rs +++ b/crates/bashkit/src/builtins/fileops.rs @@ -174,9 +174,9 @@ impl Builtin for Rm { for file in files { let path = resolve_path(ctx.cwd, file); - // Check if exists - let exists = ctx.fs.exists(&path).await.unwrap_or(false); - if !exists { + // lstat: rm acts on a link itself (dangling links included). + let metadata = ctx.fs.lstat(&path).await; + if metadata.is_err() { if !force { return Ok(ExecResult::err( format!("rm: cannot remove '{}': No such file or directory\n", file), @@ -187,7 +187,6 @@ impl Builtin for Rm { } // Check if it's a directory - let metadata = ctx.fs.stat(&path).await; if let Ok(meta) = metadata && meta.file_type.is_dir() && !recursive diff --git a/crates/bashkit/src/builtins/find/mod.rs b/crates/bashkit/src/builtins/find/mod.rs index 2d5b5c24c..c5b054212 100644 --- a/crates/bashkit/src/builtins/find/mod.rs +++ b/crates/bashkit/src/builtins/find/mod.rs @@ -193,7 +193,7 @@ async fn canonicalize(fs: &dyn FileSystem, path: &Path) -> std::result::Result

{ hops += 1; if hops > MAX_SYMLINK_HOPS { @@ -726,7 +726,7 @@ impl FindRun { async fn start_root(&mut self, index: usize) { let root = self.cfg.roots[index].clone(); let abs = resolve_path(&self.cwd, &root); - if self.cfg.fs.stat(&abs).await.is_err() { + if self.cfg.fs.lstat(&abs).await.is_err() { self.error(format!("find: '{root}': No such file or directory\n")); return; } @@ -767,7 +767,7 @@ impl FindRun { } => (abs, display, root, depth, ancestors), }; let fs = self.cfg.fs.clone(); - let lmeta = match fs.stat(&abs).await { + let lmeta = match fs.lstat(&abs).await { Ok(m) => m, Err(e) => { self.error(format!("find: '{display}': {e}\n")); diff --git a/crates/bashkit/src/builtins/inspect.rs b/crates/bashkit/src/builtins/inspect.rs index 0d4de5d80..122b8ab84 100644 --- a/crates/bashkit/src/builtins/inspect.rs +++ b/crates/bashkit/src/builtins/inspect.rs @@ -118,15 +118,14 @@ impl Builtin for File { for file in files { let path = resolve_path(ctx.cwd, file); - if !ctx.fs.exists(&path).await.unwrap_or(false) { + // `file` describes a link itself. + let Ok(metadata) = ctx.fs.lstat(&path).await else { output.push_str(&format!( "{}: cannot open '{}' (No such file or directory)\n", file, file )); continue; - } - - let metadata = ctx.fs.stat(&path).await?; + }; let file_type_str = match metadata.file_type { FileType::Directory => "directory".to_string(), FileType::Symlink => { @@ -303,7 +302,7 @@ impl Builtin for Stat { 1, )); } - let _ = matches.get_flag("dereference"); + let dereference = matches.get_flag("dereference"); let _ = matches.get_flag("terse"); let format = matches @@ -325,14 +324,18 @@ impl Builtin for Stat { for file in &files { let path = resolve_path(ctx.cwd, file); - if !ctx.fs.exists(&path).await.unwrap_or(false) { + // GNU stat describes a link itself unless -L. + let metadata = if dereference { + ctx.fs.stat(&path).await + } else { + ctx.fs.lstat(&path).await + }; + let Ok(metadata) = metadata else { return Ok(ExecResult::err( format!("stat: cannot stat '{}': No such file or directory\n", file), 1, )); - } - - let metadata = ctx.fs.stat(&path).await?; + }; if let Some(fmt) = &format { output.push_str(&format_stat(file, &metadata, fmt)); diff --git a/crates/bashkit/src/builtins/ls/list.rs b/crates/bashkit/src/builtins/ls/list.rs index 51f17f3c6..13693c7ae 100644 --- a/crates/bashkit/src/builtins/ls/list.rs +++ b/crates/bashkit/src/builtins/ls/list.rs @@ -158,14 +158,14 @@ impl Builtin for Ls { let multiple_paths = paths.len() > 1 || opts.recursive; // Separate file and directory arguments (like real ls) - let mut file_args: Vec<(&str, crate::fs::Metadata)> = Vec::new(); + let mut file_args: Vec<(&str, std::path::PathBuf, crate::fs::Metadata)> = Vec::new(); let mut dir_args: Vec<(usize, &str, std::path::PathBuf)> = Vec::new(); for (i, path_str) in paths.iter().enumerate() { let path = resolve_path(ctx.cwd, path_str); - // Check if path exists - if !ctx.fs.exists(&path).await.unwrap_or(false) { + // lstat: a dangling link still lists as itself. + let Ok(lmeta) = ctx.fs.lstat(&path).await else { return Ok(ExecResult::err( format!( "ls: cannot access '{}': No such file or directory\n", @@ -173,16 +173,23 @@ impl Builtin for Ls { ), 2, )); - } - - let metadata = ctx.fs.stat(&path).await?; + }; + // GNU: a link operand is followed unless -l, -d or -F describe + // the link itself. + let metadata = if lmeta.file_type.is_symlink() + && !(opts.long || opts.directory || opts.classify) + { + ctx.fs.stat(&path).await.unwrap_or(lmeta) + } else { + lmeta + }; // With -d/--directory, list the directory entry itself rather than // descending into it: treat directory arguments like ordinary // non-directory entries. classify_suffix() still appends "/" under // -F because it keys off the metadata. (POSIX; common `ls -d */`.) - if metadata.file_type.is_file() || opts.directory { - file_args.push((path_str, metadata)); + if !metadata.file_type.is_dir() || opts.directory { + file_args.push((path_str, path, metadata)); } else { dir_args.push((i, path_str, path)); } @@ -190,13 +197,14 @@ impl Builtin for Ls { // Sort file arguments by time if -t, preserving original paths if opts.sort_by_time { - file_args.sort_by_key(|entry| std::cmp::Reverse(entry.1.modified)); + file_args.sort_by_key(|entry| std::cmp::Reverse(entry.2.modified)); } // Output file arguments first (preserving path as given by user) if opts.long { - for (path_str, metadata) in &file_args { + for (path_str, path, metadata) in &file_args { let mut entry = format_long_entry(path_str, metadata, opts.human); + push_link_target(&ctx, path, metadata, &mut entry).await; if opts.classify { // Insert suffix before the trailing newline let suffix = classify_suffix(metadata); @@ -209,7 +217,7 @@ impl Builtin for Ls { } else if !file_args.is_empty() { let names: Vec = file_args .iter() - .map(|(path_str, metadata)| { + .map(|(path_str, _, metadata)| { let mut name = (*path_str).to_string(); if opts.classify { name.push_str(classify_suffix(metadata)); @@ -293,6 +301,13 @@ async fn list_directory( if opts.long { for entry in &filtered { let mut line = format_long_entry(&entry.name, &entry.metadata, opts.human); + push_link_target( + ctx, + &vfs_join(path, &entry.name), + &entry.metadata, + &mut line, + ) + .await; if opts.classify { let suffix = classify_suffix(&entry.metadata); if !suffix.is_empty() { @@ -349,6 +364,21 @@ async fn list_directory( Ok(()) } +/// `ls -l`: append ` -> target` to a symlink's line (before the newline). +async fn push_link_target( + ctx: &Context<'_>, + path: &Path, + metadata: &crate::fs::Metadata, + line: &mut String, +) { + if metadata.file_type.is_symlink() + && let Ok(target) = ctx.fs.read_link(path).await + { + let at = line.len().saturating_sub(1); + line.insert_str(at, &format!(" -> {}", target.display())); + } +} + /// Return the classify indicator character for a file type. /// `/` for directories, `*` for executables, `@` for symlinks, `|` for FIFOs. pub(super) fn classify_suffix(metadata: &crate::fs::Metadata) -> &'static str { diff --git a/crates/bashkit/src/builtins/python.rs b/crates/bashkit/src/builtins/python.rs index 72f673778..07ab8c8af 100644 --- a/crates/bashkit/src/builtins/python.rs +++ b/crates/bashkit/src/builtins/python.rs @@ -861,7 +861,7 @@ async fn handle_os_call( Ok(meta) => ExtFunctionResult::Return(MontyObject::Bool(meta.file_type.is_dir())), Err(_) => ExtFunctionResult::Return(MontyObject::Bool(false)), }, - "Path.is_symlink" => match fs.stat(&path).await { + "Path.is_symlink" => match fs.lstat(&path).await { Ok(meta) => ExtFunctionResult::Return(MontyObject::Bool(meta.file_type.is_symlink())), Err(_) => ExtFunctionResult::Return(MontyObject::Bool(false)), }, diff --git a/crates/bashkit/src/builtins/test.rs b/crates/bashkit/src/builtins/test.rs index 2f4f0bdc6..31a17747c 100644 --- a/crates/bashkit/src/builtins/test.rs +++ b/crates/bashkit/src/builtins/test.rs @@ -202,7 +202,7 @@ async fn evaluate_unary( "-L" | "-h" => { // symbolic link let path = resolve_file_path(cwd, arg); - if let Ok(meta) = fs.stat(&path).await { + if let Ok(meta) = fs.lstat(&path).await { meta.file_type.is_symlink() } else { false diff --git a/crates/bashkit/src/execution_capability.rs b/crates/bashkit/src/execution_capability.rs index c2377a8af..92f8eee3b 100644 --- a/crates/bashkit/src/execution_capability.rs +++ b/crates/bashkit/src/execution_capability.rs @@ -433,6 +433,10 @@ impl crate::FileSystem for ExecutionFileSystem { self.run(self.inner.stat(path)).await? } + async fn lstat(&self, path: &std::path::Path) -> crate::Result { + self.run(self.inner.lstat(path)).await? + } + async fn read_dir(&self, path: &std::path::Path) -> crate::Result> { self.run(self.inner.read_dir(path)).await? } diff --git a/crates/bashkit/src/fs/follow.rs b/crates/bashkit/src/fs/follow.rs new file mode 100644 index 000000000..e37b3b5ad --- /dev/null +++ b/crates/bashkit/src/fs/follow.rs @@ -0,0 +1,425 @@ +//! Symlink-following view of the session filesystem. +//! +//! Decisions: +//! - [`FollowFs`] is the outermost layer of a session's filesystem stack, so +//! resolution happens in the VFS namespace, after mounts and overlays are +//! composed. A link's target is always a VFS path: an absolute target is +//! resolved from the VFS root, `..` clamps there, and every resolved path +//! goes back through the normal layers (mount tables, `RealFs` containment, +//! read-only wrappers). THREAT[TM-ESC-002]: a link can therefore never name +//! a host path, only another VFS path the script could already open. +//! - Inner layers keep their non-following semantics: their `stat` is an +//! `lstat`. Only this layer follows, with POSIX rules: every operation that +//! opens a path follows links in all components; `lstat`, `read_link`, +//! `remove`, `rename` and `symlink` follow only the parent components. +//! - THREAT[TM-DOS-011]: at most [`MAX_SYMLINK_HOPS`] links per lookup (Linux +//! `MAXSYMLINKS`); more fail with "Too many levels of symbolic links". +//! - Fast path: operations run against the inner filesystem first and only +//! resolve links when that fails (inner layers refuse to read or write +//! through a link) or when `stat` reports a link, so scripts that never +//! create links pay nothing. Writes check the final component with one +//! `lstat` so they never replace a link with a file. +//! - Permission and resource-limit errors are returned as-is, never retried +//! through link resolution, so containment errors stay visible. +//! - `..` in a path is resolved lexically by the interpreter before it reaches +//! the filesystem (`/link/..` is the link's parent, not the target's), the +//! same logical view `cd` and `pwd` use. +//! - Turned off with `BashBuilder::follow_symlinks(false)`. + +use async_trait::async_trait; +use std::io::{Error as IoError, ErrorKind}; +use std::path::{Path, PathBuf}; +use std::sync::Arc; + +use super::limits::{FsLimits, FsUsage}; +use super::traits::{DirEntry, FileSystem, FileSystemExt, Metadata}; +use super::{SearchCapable, normalize_path}; +use crate::error::{Error, Result}; +use crate::time_compat::SystemTime; + +/// Links followed per lookup before failing with ELOOP (Linux MAXSYMLINKS). +pub(crate) const MAX_SYMLINK_HOPS: usize = 40; + +fn eloop() -> Error { + IoError::other("Too many levels of symbolic links").into() +} + +/// Errors that may be caused by a link somewhere on the path. +fn may_be_link_error(e: &Error) -> bool { + matches!(e, Error::Io(io) if !matches!( + io.kind(), + ErrorKind::PermissionDenied | ErrorKind::Unsupported | ErrorKind::OutOfMemory + )) +} + +fn split(path: &Path) -> Vec { + normalize_path(path) + .to_str() + .unwrap_or("/") + .split('/') + .filter(|c| !c.is_empty()) + .map(str::to_owned) + .collect() +} + +fn join(parts: &[String]) -> PathBuf { + PathBuf::from(format!("/{}", parts.join("/"))) +} + +/// Filesystem wrapper that follows symbolic links. +pub(crate) struct FollowFs { + inner: Arc, +} + +impl FollowFs { + pub(crate) fn new(inner: Arc) -> Self { + Self { inner } + } + + /// Resolve every link on `path`. With `follow_last == false` the final + /// component is kept as-is (lstat semantics). Resolution stops at the + /// first missing component; the rest of the path is appended unchanged. + async fn resolve(&self, path: &Path, follow_last: bool) -> Result { + let mut pending: Vec = split(path); + pending.reverse(); + let mut done: Vec = Vec::new(); + let mut hops = 0usize; + while let Some(comp) = pending.pop() { + match comp.as_str() { + "." => continue, + ".." => { + done.pop(); + continue; + } + _ => {} + } + done.push(comp); + if pending.is_empty() && !follow_last { + break; + } + let here = join(&done); + match self.inner.stat(&here).await { + Ok(meta) if meta.file_type.is_symlink() => { + hops += 1; + if hops > MAX_SYMLINK_HOPS { + return Err(eloop()); + } + let target = self.inner.read_link(&here).await?; + done.pop(); + let target = target.to_str().unwrap_or_default(); + if target.starts_with('/') { + done.clear(); + } + pending.extend( + target + .split('/') + .filter(|c| !c.is_empty()) + .rev() + .map(str::to_owned), + ); + } + Ok(_) => {} + Err(_) => { + while let Some(rest) = pending.pop() { + done.push(rest); + } + break; + } + } + } + Ok(join(&done)) + } + + /// Path with all links followed, or `None` when nothing changed. + async fn followed(&self, path: &Path) -> Result> { + let resolved = self.resolve(path, true).await?; + Ok((resolved != normalize_path(path)).then_some(resolved)) + } + + /// Path with links followed in the parent components only. + async fn parent_followed(&self, path: &Path) -> Result> { + let resolved = self.resolve(path, false).await?; + Ok((resolved != normalize_path(path)).then_some(resolved)) + } + + /// Path to write: the link's target when the final component is a link. + async fn write_target(&self, path: &Path) -> Result { + match self.inner.stat(path).await { + Ok(meta) if meta.file_type.is_symlink() => self.resolve(path, true).await, + Ok(_) => Ok(path.to_path_buf()), + Err(_) => Ok(self + .parent_followed(path) + .await? + .unwrap_or_else(|| path.to_path_buf())), + } + } +} + +/// Run `$op` on the inner fs with `$path`; on a link-related error, retry once +/// on the path with links resolved by `$resolver`. +macro_rules! retry_resolved { + ($self:ident, $resolver:ident, $path:expr, |$p:ident| $op:expr) => {{ + let $p: &Path = $path; + match $op { + Err(e) if may_be_link_error(&e) => match $self.$resolver($p).await? { + Some(resolved) => { + let $p: &Path = &resolved; + $op + } + None => Err(e), + }, + other => other, + } + }}; +} + +#[async_trait] +impl FileSystemExt for FollowFs { + fn usage(&self) -> FsUsage { + self.inner.usage() + } + + async fn mkfifo(&self, path: &Path, mode: u32) -> Result<()> { + retry_resolved!(self, parent_followed, path, |p| self + .inner + .mkfifo(p, mode) + .await) + } + + fn limits(&self) -> FsLimits { + self.inner.limits() + } + + fn vfs_snapshot(&self) -> Option { + self.inner.vfs_snapshot() + } + + fn vfs_restore(&self, snapshot: &super::VfsSnapshot) -> Result<()> { + self.inner.vfs_restore(snapshot) + } + + fn backend_kind(&self) -> &'static str { + self.inner.backend_kind() + } +} + +#[async_trait] +impl FileSystem for FollowFs { + async fn read_file(&self, path: &Path) -> Result> { + retry_resolved!(self, followed, path, |p| self.inner.read_file(p).await) + } + + async fn write_file(&self, path: &Path, content: &[u8]) -> Result<()> { + let target = self.write_target(path).await?; + self.inner.write_file(&target, content).await + } + + async fn append_file(&self, path: &Path, content: &[u8]) -> Result<()> { + let target = self.write_target(path).await?; + self.inner.append_file(&target, content).await + } + + async fn mkdir(&self, path: &Path, recursive: bool) -> Result<()> { + // `mkdir -p` walks through linked directories; plain `mkdir` on an + // existing link fails with "File exists" like Linux. + if recursive { + retry_resolved!(self, followed, path, |p| self.inner.mkdir(p, true).await) + } else { + retry_resolved!(self, parent_followed, path, |p| self + .inner + .mkdir(p, false) + .await) + } + } + + async fn remove(&self, path: &Path, recursive: bool) -> Result<()> { + retry_resolved!(self, parent_followed, path, |p| self + .inner + .remove(p, recursive) + .await) + } + + async fn stat(&self, path: &Path) -> Result { + match self.inner.stat(path).await { + Ok(meta) if meta.file_type.is_symlink() => { + let resolved = self.resolve(path, true).await?; + self.inner.stat(&resolved).await + } + Err(e) if may_be_link_error(&e) => match self.followed(path).await? { + Some(resolved) => self.inner.stat(&resolved).await, + None => Err(e), + }, + other => other, + } + } + + async fn lstat(&self, path: &Path) -> Result { + retry_resolved!(self, parent_followed, path, |p| self.inner.stat(p).await) + } + + async fn read_dir(&self, path: &Path) -> Result> { + retry_resolved!(self, followed, path, |p| self.inner.read_dir(p).await) + } + + async fn exists(&self, path: &Path) -> Result { + // `test -e` follows links: a dangling link does not exist. + match self.stat(path).await { + Ok(_) => Ok(true), + Err(e) if may_be_link_error(&e) => Ok(false), + Err(e) => Err(e), + } + } + + async fn rename(&self, from: &Path, to: &Path) -> Result<()> { + match self.inner.rename(from, to).await { + Err(e) if may_be_link_error(&e) => { + let from_r = self.parent_followed(from).await?; + let to_r = self.parent_followed(to).await?; + if from_r.is_none() && to_r.is_none() { + return Err(e); + } + self.inner + .rename( + from_r.as_deref().unwrap_or(from), + to_r.as_deref().unwrap_or(to), + ) + .await + } + other => other, + } + } + + async fn copy(&self, from: &Path, to: &Path) -> Result<()> { + let from_r = self.resolve(from, true).await?; + let to_r = self.write_target(to).await?; + self.inner.copy(&from_r, &to_r).await + } + + async fn symlink(&self, target: &Path, link: &Path) -> Result<()> { + retry_resolved!(self, parent_followed, link, |p| self + .inner + .symlink(target, p) + .await) + } + + async fn read_link(&self, path: &Path) -> Result { + retry_resolved!(self, parent_followed, path, |p| self + .inner + .read_link(p) + .await) + } + + async fn chmod(&self, path: &Path, mode: u32) -> Result<()> { + let resolved = self.resolve(path, true).await?; + self.inner.chmod(&resolved, mode).await + } + + async fn set_modified_time(&self, path: &Path, time: SystemTime) -> Result<()> { + let resolved = self.resolve(path, true).await?; + self.inner.set_modified_time(&resolved, time).await + } + + fn as_search_capable(&self) -> Option<&dyn SearchCapable> { + self.inner.as_search_capable() + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::fs::InMemoryFs; + + fn fs() -> FollowFs { + FollowFs::new(Arc::new(InMemoryFs::new())) + } + + #[tokio::test] + async fn reads_and_writes_follow_links() { + let fs = fs(); + fs.mkdir(Path::new("/d/real"), true).await.unwrap(); + fs.write_file(Path::new("/d/real/f"), b"x").await.unwrap(); + fs.symlink(Path::new("real"), Path::new("/d/link")) + .await + .unwrap(); + fs.symlink(Path::new("/d/link/f"), Path::new("/f2")) + .await + .unwrap(); + assert_eq!(fs.read_file(Path::new("/f2")).await.unwrap(), b"x"); + fs.append_file(Path::new("/f2"), b"y").await.unwrap(); + assert_eq!(fs.read_file(Path::new("/d/real/f")).await.unwrap(), b"xy"); + assert!( + fs.stat(Path::new("/d/link")) + .await + .unwrap() + .file_type + .is_dir() + ); + assert!( + fs.lstat(Path::new("/d/link")) + .await + .unwrap() + .file_type + .is_symlink() + ); + assert_eq!(fs.read_dir(Path::new("/d/link")).await.unwrap().len(), 1); + } + + #[tokio::test] + async fn loops_fail_with_eloop() { + let fs = fs(); + fs.symlink(Path::new("/b"), Path::new("/a")).await.unwrap(); + fs.symlink(Path::new("/a"), Path::new("/b")).await.unwrap(); + let err = fs.read_file(Path::new("/a")).await.unwrap_err(); + assert!(err.to_string().contains("Too many levels"), "{err}"); + assert!(!fs.exists(Path::new("/a")).await.unwrap()); + // The link itself is still visible and removable. + assert!(fs.lstat(Path::new("/a")).await.is_ok()); + fs.remove(Path::new("/a"), false).await.unwrap(); + } + + #[tokio::test] + async fn absolute_targets_stay_in_vfs() { + // THREAT[TM-ESC-002]: `..` past the root clamps at the VFS root. + let fs = fs(); + fs.mkdir(Path::new("/tmp"), true).await.unwrap(); + fs.write_file(Path::new("/secret"), b"vfs").await.unwrap(); + fs.symlink(Path::new("../../../../secret"), Path::new("/tmp/l")) + .await + .unwrap(); + assert_eq!(fs.read_file(Path::new("/tmp/l")).await.unwrap(), b"vfs"); + } + + #[tokio::test] + async fn dangling_write_creates_target_and_rm_keeps_target() { + let fs = fs(); + fs.symlink(Path::new("/new"), Path::new("/l")) + .await + .unwrap(); + assert!(!fs.exists(Path::new("/l")).await.unwrap()); + fs.write_file(Path::new("/l"), b"made").await.unwrap(); + assert_eq!(fs.read_file(Path::new("/new")).await.unwrap(), b"made"); + fs.remove(Path::new("/l"), false).await.unwrap(); + assert!(fs.exists(Path::new("/new")).await.unwrap()); + } + + #[tokio::test] + async fn hop_limit_allows_long_chains() { + let fs = fs(); + fs.write_file(Path::new("/l0"), b"end").await.unwrap(); + for i in 1..=MAX_SYMLINK_HOPS { + fs.symlink( + Path::new(&format!("/l{}", i - 1)), + Path::new(&format!("/l{i}")), + ) + .await + .unwrap(); + } + let last = format!("/l{MAX_SYMLINK_HOPS}"); + assert_eq!(fs.read_file(Path::new(&last)).await.unwrap(), b"end"); + let over = format!("/l{}", MAX_SYMLINK_HOPS + 1); + fs.symlink(Path::new(&last), Path::new(&over)) + .await + .unwrap(); + assert!(fs.read_file(Path::new(&over)).await.is_err()); + } +} diff --git a/crates/bashkit/src/fs/mod.rs b/crates/bashkit/src/fs/mod.rs index 15954dc85..183241a86 100644 --- a/crates/bashkit/src/fs/mod.rs +++ b/crates/bashkit/src/fs/mod.rs @@ -401,6 +401,7 @@ //! ``` mod backend; +mod follow; mod limits; mod memory; mod mountable; @@ -416,6 +417,8 @@ mod std_streams; mod traits; pub use backend::FsBackend; +#[allow(unused_imports)] +pub(crate) use follow::{FollowFs, MAX_SYMLINK_HOPS}; pub use limits::{FsLimitExceeded, FsLimits, FsUsage}; pub use memory::{InMemoryFs, LazyLoader, VfsEntry, VfsEntryKind, VfsSnapshot}; pub use mountable::MountableFs; @@ -425,8 +428,7 @@ pub use posix::PosixFs; pub use readonly::ReadOnlyFs; #[cfg(feature = "realfs")] pub use realfs::{RealFs, RealFsMode}; -#[allow(unused_imports)] -pub(crate) use rootfs::{BIN_DIRS, RootFs, stub_command}; +pub(crate) use rootfs::{RootFs, stub_command}; pub use search::{ SearchCapabilities, SearchCapable, SearchMatch, SearchProvider, SearchQuery, SearchResults, }; diff --git a/crates/bashkit/src/fs/std_streams.rs b/crates/bashkit/src/fs/std_streams.rs index 9ba46f43c..715406a15 100644 --- a/crates/bashkit/src/fs/std_streams.rs +++ b/crates/bashkit/src/fs/std_streams.rs @@ -134,6 +134,12 @@ impl FileSystem for StdStreamsFs { None => self.inner.stat(path).await, } } + async fn lstat(&self, path: &Path) -> Result { + match std_stream_fd(path) { + Some(_) => Ok(self.stream_metadata()), + None => self.inner.lstat(path).await, + } + } async fn read_dir(&self, path: &Path) -> Result> { self.inner.read_dir(path).await } diff --git a/crates/bashkit/src/fs/traits.rs b/crates/bashkit/src/fs/traits.rs index 8d880d182..b50efc63b 100644 --- a/crates/bashkit/src/fs/traits.rs +++ b/crates/bashkit/src/fs/traits.rs @@ -326,6 +326,15 @@ pub trait FileSystem: FileSystemExt { /// Returns an error if the path does not exist. async fn stat(&self, path: &Path) -> Result; + /// Get metadata without following a symlink in the final component. + /// + /// Bashkit's own layers never follow links below the session's outermost + /// layer, so for them `stat` already has `lstat` semantics and the default + /// forwards to it. The session's link-following layer overrides both. + async fn lstat(&self, path: &Path) -> Result { + self.stat(path).await + } + /// List directory contents. /// /// Returns a list of entries (files, directories, symlinks) in the directory. diff --git a/crates/bashkit/src/lib.rs b/crates/bashkit/src/lib.rs index 45e23b969..ee4e87ec3 100644 --- a/crates/bashkit/src/lib.rs +++ b/crates/bashkit/src/lib.rs @@ -1929,6 +1929,8 @@ pub struct BashBuilder { readonly_filesystem: bool, /// When true, skip the default `/etc`, `/proc`, `/bin`, `/usr/bin` layer. no_rootfs: bool, + /// When true, symlinks are stored but not followed (pre-0.19 behavior). + no_follow_symlinks: bool, /// Interceptor hooks hooks_on_exit: Vec>, hooks_before_exec: Vec>, @@ -3296,6 +3298,35 @@ impl BashBuilder { self } + /// Follow symbolic links in path lookups (on by default). + /// + /// `cat link`, `cd linkdir`, `echo x > link` and `[ -d linkdir ]` act on + /// the link's target, as on Linux; `rm`, `mv`, `readlink`, `[ -L ]` and + /// `ls -l` act on the link itself. Targets are VFS paths, so a link can + /// never reach the host, and lookups give up after 40 links with "Too + /// many levels of symbolic links". With `false`, links are stored but + /// opening one fails. See `knowledge/foundations/vfs.md` ("Symlink + /// Handling"). + /// + /// ```rust + /// # use bashkit::Bash; + /// # #[tokio::main] + /// # async fn main() -> bashkit::Result<()> { + /// let mut bash = Bash::builder().build(); + /// let r = bash.exec("echo hi > /tmp/t; ln -s t /tmp/l; cat /tmp/l").await?; + /// assert_eq!(r.stdout, "hi\n"); + /// + /// let mut bare = Bash::builder().follow_symlinks(false).build(); + /// let r = bare.exec("echo hi > /tmp/t; ln -s t /tmp/l; cat /tmp/l").await?; + /// assert_eq!(r.exit_code, 1); + /// # Ok(()) + /// # } + /// ``` + pub fn follow_symlinks(mut self, enabled: bool) -> Self { + self.no_follow_symlinks = !enabled; + self + } + /// Build the Bash instance. /// /// If mounted files are specified, they are added via an [`OverlayFs`] layer @@ -3400,6 +3431,13 @@ impl BashBuilder { let mountable = Arc::new(MountableFs::new(base_fs)); let fs: Arc = Arc::clone(&mountable) as Arc; + // Layer 5: follow symlinks across every layer and mount. + let fs: Arc = if self.no_follow_symlinks { + fs + } else { + Arc::new(fs::FollowFs::new(fs)) + }; + let mut result = Self::build_with_fs( fs, mountable, diff --git a/crates/bashkit/tests/integration/cpython_security_tests.rs b/crates/bashkit/tests/integration/cpython_security_tests.rs index 78f469b77..4f941b198 100644 --- a/crates/bashkit/tests/integration/cpython_security_tests.rs +++ b/crates/bashkit/tests/integration/cpython_security_tests.rs @@ -87,16 +87,21 @@ print(open(\"/etc/passwd\").read(), end=\"\") } #[tokio::test] -async fn symlinks_are_not_followed() { - // L-FS-001 / TM-ESC-002: the VFS stores symlinks but never follows them. - let r = run("echo secret > /target; ln -s /target /link; \ +async fn symlinks_resolve_inside_the_vfs_only() { + // TM-ESC-002: links are followed, but only within the VFS. lstat still + // reports the link itself, and a link aimed at a host path finds nothing. + let host = std::env::current_exe().unwrap(); + let script = format!( + "echo secret > /target; ln -s /target /link; ln -s '{}' /hostlink; \ python3 -c 'import os; print(os.path.islink(\"/link\"), os.readlink(\"/link\")) -try: - open(\"/link\").read() -except OSError: - print(\"blocked\")'") - .await; - assert_eq!(r.stdout, "True /target\nblocked\n"); +print(open(\"/link\").read().strip()) +print(os.path.islink(\"/hostlink\"), os.path.exists(\"/hostlink\")) +os.unlink(\"/link\") +print(os.path.exists(\"/target\"), os.path.lexists(\"/link\"))'", + host.display() + ); + let r = run(&script).await; + assert_eq!(r.stdout, "True /target\nsecret\nTrue False\nTrue False\n"); } #[tokio::test] diff --git a/crates/bashkit/tests/integration/snapshot_history_tests.rs b/crates/bashkit/tests/integration/snapshot_history_tests.rs index ff7492496..0519397d3 100644 --- a/crates/bashkit/tests/integration/snapshot_history_tests.rs +++ b/crates/bashkit/tests/integration/snapshot_history_tests.rs @@ -1243,12 +1243,20 @@ async fn a_forged_symlink_cannot_reach_outside_the_vfs() { "canary\n" ); - for probe in [ - "cat /escape/passwd", - "ls /escape/", - "cat /loop", - "ls -L /loop", - ] { + // `/../../../etc` clamps at the VFS root: the link reaches the + // synthetic /etc, never the host's. + let result = bash.exec("cat /escape/passwd").await.unwrap(); + assert!( + result.stdout.starts_with("sandbox:x:1000:1000"), + "{:?}", + result.stdout + ); + assert!(!result.stdout.contains("root:x:0:0")); + let result = bash.exec("ls /escape/").await.unwrap(); + assert!(result.stdout.contains("os-release"), "{:?}", result.stdout); + assert!(!result.stdout.contains("shadow")); + + for probe in ["cat /loop", "ls -L /loop"] { let result = bash.exec(probe).await.unwrap(); assert!( result.stdout.is_empty(), diff --git a/crates/bashkit/tests/realfs_tests.rs b/crates/bashkit/tests/realfs_tests.rs index 97db85d65..e8f3a6770 100644 --- a/crates/bashkit/tests/realfs_tests.rs +++ b/crates/bashkit/tests/realfs_tests.rs @@ -885,6 +885,24 @@ async fn realfs_symlink_relative_escape_blocked() { ); } +/// THREAT[TM-ESC-002]: links planted on the host inside a mount must not be +/// followed out of it by the session's symlink-following layer. +#[cfg(unix)] +#[tokio::test] +async fn realfs_host_planted_symlink_is_not_followed_out() { + let dir = setup_host_dir(); + std::os::unix::fs::symlink("/etc/passwd", dir.path().join("abs")).unwrap(); + std::os::unix::fs::symlink("../../../../../../etc/passwd", dir.path().join("rel")).unwrap(); + let mut bash = builder_allowing_host_paths(&[dir.path()]) + .mount_real_readonly_at(dir.path(), "/mnt/workspace") + .build(); + for probe in ["cat /mnt/workspace/abs", "cat /mnt/workspace/rel"] { + let r = bash.exec(probe).await.unwrap(); + assert!(!r.stdout.contains("root:x:0:0"), "{probe}: {}", r.stdout); + assert_ne!(r.exit_code, 0, "{probe} must fail: {}", r.stdout); + } +} + #[tokio::test] async fn realfs_symlink_within_mount_allowed() { let dir = setup_host_dir(); diff --git a/crates/bashkit/tests/spec_cases/bash/symlink.test.sh b/crates/bashkit/tests/spec_cases/bash/symlink.test.sh new file mode 100644 index 000000000..dc180907f --- /dev/null +++ b/crates/bashkit/tests/spec_cases/bash/symlink.test.sh @@ -0,0 +1,133 @@ +### symlink_read_through_file_link +# cat, test and wc follow a link to a file +mkdir -p /tmp/sl1 && cd /tmp/sl1 +echo hello > target.txt +ln -s target.txt rel +ln -s /tmp/sl1/target.txt abs +cat rel abs +[ -f rel ] && echo file +[ -L rel ] && echo link +[ -L target.txt ] || echo notlink +wc -c < abs +### expect +hello +hello +file +link +notlink +6 +### end + +### symlink_write_through_link +# redirects and appends land in the target +mkdir -p /tmp/sl2 && cd /tmp/sl2 +echo one > t +ln -s t l +echo two >> l +cat t +readlink l +### expect +one +two +t +### end + +### symlink_directory_link +# a link to a directory can be listed, entered and written into +mkdir -p /tmp/sl3/real/sub && cd /tmp/sl3 +echo a > real/a.txt +ln -s real dl +ls dl +[ -d dl ] && echo dir +cat dl/a.txt +echo b > dl/b.txt +cat real/b.txt +mkdir -p dl/sub/deeper +[ -d real/sub/deeper ] && echo deep +cd dl && pwd && cat a.txt +### expect +a.txt +sub +dir +a +b +deep +/tmp/sl3/dl +a +### end + +### symlink_dangling +# a dangling link exists as a link but not as a file; writing creates the target +mkdir -p /tmp/sl4 && cd /tmp/sl4 +ln -s missing.txt dang +[ -e dang ] || echo noexist +[ -L dang ] && echo islink +cat dang 2>/dev/null || echo catfail +echo made > dang +cat missing.txt +### expect +noexist +islink +catfail +made +### end + +### symlink_loop_is_reported +# loops fail with ELOOP instead of hanging +mkdir -p /tmp/sl5 && cd /tmp/sl5 +ln -s b a +ln -s a b +cat a +echo "exit=$?" +[ -e a ] || echo noexist +### expect +exit=1 +noexist +### end + +### symlink_loop_message +mkdir -p /tmp/sl6 && cd /tmp/sl6 +ln -s b a +ln -s a b +cat a 2>&1 +### expect +cat: a: Too many levels of symbolic links +### end + +### symlink_rm_and_mv_act_on_link +# rm and mv never touch the target +mkdir -p /tmp/sl7 && cd /tmp/sl7 +echo keep > t +ln -s t l +mv l l2 +readlink l2 +rm l2 +cat t +[ -e l2 ] || echo gone +### expect +t +keep +gone +### end + +### symlink_ls_long_shows_target +mkdir -p /tmp/sl8 && cd /tmp/sl8 +touch t +ln -s t l +ls -l l | sed 's/.* l -> /l -> /' +### expect +l -> t +### end + +### symlink_relative_chain +# relative targets resolve against the link's own directory, through chains +mkdir -p /tmp/sl9/a/b && cd /tmp/sl9 +echo deep > a/b/f +ln -s b/f a/l1 +ln -s a/l1 l2 +cat l2 +readlink -f l2 +### expect +deep +/tmp/sl9/a/b/f +### end diff --git a/docs/filesystem.md b/docs/filesystem.md index 48d22dbff..c30c78b07 100644 --- a/docs/filesystem.md +++ b/docs/filesystem.md @@ -201,8 +201,13 @@ part of snapshots. Turn it off with `Bash::builder().rootfs(false)`. - **`/dev/null`** is handled at the interpreter level (not the filesystem), so a custom backend can't intercept it. **`/dev/urandom`** / **`/dev/random`** return bounded random data; **`/dev/zero`** returns 1 MiB of zeros per read. -- **Symlinks** are stored but never followed, this closes symlink-escape - (TM-ESC-002) and symlink-loop DoS (TM-DOS-011). +- **Symlinks** are followed like on Linux (`cat link`, `cd linkdir`, + `echo x > link`), but a target is always a VFS path: absolute targets start + at the VFS root and `..` stops there, so a link can't reach the host + (TM-ESC-002). A lookup gives up after 40 links with "Too many levels of + symbolic links" (TM-DOS-011). `rm`, `mv`, `readlink`, `[ -L ]` and `ls -l` + act on the link itself. `Bash::builder().follow_symlinks(false)` restores + store-only links. ## Binding parity diff --git a/docs/security.md b/docs/security.md index 83dc4c301..37aa7a3e0 100644 --- a/docs/security.md +++ b/docs/security.md @@ -12,7 +12,7 @@ individual threat IDs and mitigation status, see the | Boundary | What it does | |----------|-------------| -| **Virtual filesystem** | Scripts run against an in-memory VFS. No real filesystem access by default. Path traversal (`../../../etc/passwd`) is normalised away. Symlinks are stored but never followed. | +| **Virtual filesystem** | Scripts run against an in-memory VFS. No real filesystem access by default. Path traversal (`../../../etc/passwd`) is normalised away. Symlinks are followed inside the VFS only: a target is always a VFS path, so a link can never reach the host. | | **No process execution** | `exec` is excluded entirely. `bash -c` re-invokes the virtual interpreter instead of spawning a real process. Background jobs (`&`) parse but run synchronously. | | **Network allowlist** | HTTP/HTTPS only, pre-validated against an explicit host allowlist. No DNS resolution, no auto-redirect, no auto-decompression. | | **Resource limits** | Configurable caps on commands, loop iterations, recursion depth, AST depth, timeouts, and parser operations prevent denial-of-service from malicious scripts. | diff --git a/knowledge/foundations/vfs.md b/knowledge/foundations/vfs.md index 9cb384145..de8f37bda 100644 --- a/knowledge/foundations/vfs.md +++ b/knowledge/foundations/vfs.md @@ -134,6 +134,8 @@ the interpreter. ```text ┌──────────────────────────────────┐ +│ FollowFs (symlinks, default) │ ← BashBuilder::follow_symlinks(false) +├──────────────────────────────────┤ │ MountableFs (live mounts) │ ← Bash::mount() / unmount() ├──────────────────────────────────┤ │ ReadOnlyFs (optional) │ ← BashBuilder::readonly_filesystem() @@ -331,9 +333,32 @@ runs. Pre-dates per-component expansion and affects trailing components too. ### Symlink Handling -Symlinks are stored but intentionally not followed for security: -- Prevents symlink escape attacks (TM-ESC-002) -- Prevents symlink loop DoS (TM-DOS-011) +Decision (2026-10-06): follow symlinks like Linux, on by default. Agents and +build tools (`node_modules/.bin`, `current -> releases/v2`) expect it; the +old store-only links made `cat link` fail with "No such file". + +`FollowFs` (`fs/follow.rs`) is the outermost layer, so resolution sees the +whole composed namespace (mounts, overlays, rootfs). Inner layers never follow: +their `stat` is an `lstat`, and they refuse to read or write through a link. + +- Opening a path (`read_file`, `write_file`, `read_dir`, `stat`, `exists`, + `copy`, `chmod`, `mkdir -p`) follows links in every component. + `lstat`, `read_link`, `remove`, `rename`, `symlink` and plain `mkdir` + follow only the parent components. `FileSystem::lstat` is a trait method + whose default forwards to `stat`; wrappers that sit above `FollowFs` + (`ExecutionFileSystem`, `StdStreamsFs`) forward it explicitly. +- Builtins that describe a link use `lstat`: `test -L/-h`, `ls` operands + under `-l/-d/-F` (and `-> target` in long lines), `stat` (unless `-L`), + `file`, `rm`, `find` (`-P` default), Python `Path.is_symlink`. +- Targets are VFS paths: absolute from the VFS root, relative to the link's + directory, `..` clamped at `/` (TM-ESC-002). Writes through a dangling link + create the target. +- At most 40 links per lookup, then "Too many levels of symbolic links" + (TM-DOS-011). `exists` is false for dangling and looping links. +- Fast path: ops run on the inner fs first and resolve only on a + link-related error (never on permission or limit errors, so real-mount + containment errors stay as they are) or when `stat` reports a link. + Writes cost one extra `lstat` on the final component. ## Host Mount Table (`HostMounts`) diff --git a/knowledge/log.md b/knowledge/log.md index ad0c43369..dff3d4eaa 100644 --- a/knowledge/log.md +++ b/knowledge/log.md @@ -6,6 +6,8 @@ ## 2026-10-06 +* **Security**: Following symlinks in the VFS exposed a gap in the CPython WASI host: `path_filestat_get` ignored its lookup flags and called `stat`, so once `stat` followed links `os.path.islink` returned False and `os.unlink` on a link to a directory failed `EISDIR`. It now honors `SYMLINK_FOLLOW` (lstat when unset) and unlink/rmdir check the link itself. Resolution stays inside the VFS, so a link aimed at a host path finds nothing (TM-PY-CPY-001, `symlinks_resolve_inside_the_vfs_only`). See [CPython WebAssembly Runtime](runtimes/cpython-wasm.md). +* **Decision**: [VFS](foundations/vfs.md) follows symlinks like Linux, on by default (`FollowFs`, outermost layer; `follow_symlinks(false)` opts out). Targets are VFS paths only and lookups stop after 40 links, so TM-ESC-002 and TM-DOS-011 now rest on namespace confinement and the hop cap instead of never following. New `FileSystem::lstat`; builtins that describe a link use it. The JS bindings follow Node: `stat` follows links and the new `lstat` (on `Bash`, `BashTool` and `fs()`) reports the link itself. * **Feature**: Default [root filesystem](foundations/vfs.md) (`RootFs`): synthetic read-only `/etc`, static `/proc`, a `/bin` + `/usr/bin` stub per builtin, `/root`, `/dev/zero`, under the session fs (session files win). `/etc/passwd` has no root line so `root:x:0:0` always means a host leak. `rootfs(false)` opts out; the logic-only `ScriptedTool` shell does. * **Feature**: The browser package gained a `Terminal` class wrapping [In-Process Terminal](integrations/in-process-terminal.md), and bashkit.sh gained a `/playground` page that drives it through xterm.js. The `bashkit-wasm` cargo feature is off by default like the core one, but the npm build turns it on: it costs ~60 KB gzipped (about 2% of the bundle) and is inert until constructed, which beats shipping a second package. The playground falls back to a line editor over `Bash.execute` when the installed package predates `Terminal`, so the page works on today's npm release and upgrades itself when the site's dependency is bumped. See [WebAssembly Package](runtimes/browser-package.md). * **Decision**: Step 3 of the extraction. Python `ScriptedTool` moves to `bashkit.scripted` (old `bashkit.ScriptedTool` warns `FutureWarning` via module `__getattr__`), JS to the `@everruns/bashkit/scripted` entry (root keeps `@deprecated` aliases of the same class; shared helpers in non-exported `internal.ts` avoid an ESM cycle). Found while packing: `publish-js.yml`'s `js-stubs` artifact carried only `wrapper.*`, so npm 0.18.2 shipped without the `langchain`/`ai`/`anthropic`/`openai` entry files it exports; the artifact now lists every `tsc` output, guarded by `scripts/tests/test_js_publish_files.py`. Recorded in [Scripted Tool Orchestration](integrations/scripted-tool-orchestration.md) and [Release Process](operations/release-process.md). diff --git a/knowledge/operations/limitations.md b/knowledge/operations/limitations.md index 9e45b0148..bfe9ce80e 100644 --- a/knowledge/operations/limitations.md +++ b/knowledge/operations/limitations.md @@ -45,7 +45,7 @@ execution model. Evidence is a threat-model ID, a test, or `stance` | L-PROC-001 | `exec` does not replace the process; `exec cmd` runs cmd then stops execution (fd redirects work) | True process replace would break sandbox containment | TM-ESC-005 | | L-PROC-002 | No job control (`bg`, `fg`, `jobs`) | Requires process state; interactive-only feature | `l_proc_002_no_job_control` | | L-PROC-003 | No process spawning; external commands run as builtins | Core sandbox model: no fork/exec escape surface | `l_proc_003_no_process_spawning` | -| L-FS-001 | Symlinks stored but never followed in path resolution (`ln -s` works, `read_link()` returns targets, traversal blocked) | Prevents symlink loops and link-based sandbox escapes | TM-DOS-011 | +| L-FS-001 | Symlinks are followed, but `..` after a linked directory resolves lexically (`/link/..` is the link's parent, the `cd -L` view), and `ln` without `-s` makes a symlink, not a hard link | The interpreter normalizes paths before the VFS sees them; the VFS has no inodes to share | `symlink.test.sh`, `ln_default_symbolic` | | L-ROOTFS-001 | Default rootfs is static and read-only: `/proc` has no `self`, pid dirs, `uptime` or live counters; `/etc/passwd` has no root entry; `/dev/zero` yields 1 MiB per read; `/bin`, `/usr/bin` are stubs that dispatch builtins | Host state must not leak (TM-INF-003, TM-ISO-018); fixed values keep runs deterministic | `rootfs_layout`, `threat_etc_passwd_blocked` | | L-FS-002 | No file permission enforcement in the VFS | Single-tenant virtual FS; permissions would be theater | `l_fs_002_no_permission_enforcement` | | L-FS-003 | On Windows, `RealFs::symlink()` validates the target but creates an empty host file rather than a symlink/reparse point; pre-existing host symlinks and junctions remain readable subject to containment checks | Windows requires choosing file-vs-directory link semantics and may require link privileges; the portable VFS symlink contract does not carry that host metadata | TM-ESC-033 | diff --git a/knowledge/runtimes/cpython-wasm.md b/knowledge/runtimes/cpython-wasm.md index 3b3c0a6dc..2883e8c0b 100644 --- a/knowledge/runtimes/cpython-wasm.md +++ b/knowledge/runtimes/cpython-wasm.md @@ -89,8 +89,10 @@ calls: 0 failures, ~90 calls/s on 4 vCPUs, 4.7 GB peak RSS. return `ENOTSUP`; there is no process API. - Files are whole in-memory buffers written back on close/sync/exit. Buffers are bounded by `max_file_size` and, in total, by the call's memory budget. -- Paths normalize lexically and clamp at `/`. The VFS does not follow - symlinks (L-FS-001); neither does the host. +- Paths normalize lexically and clamp at `/`. Symlinks resolve inside the + VFS only (TM-ESC-002); `path_filestat_get` honors WASI `SYMLINK_FOLLOW`, so + `lstat`/`os.path.islink` see the link itself, and unlink/rmdir act on the + link, never its target. - The stdlib zip is a read-only overlay at a fixed path. Writes, renames, truncation, unlink and directory changes there fail (`EROFS`/`ENOENT`/ `EACCES`); tenant files placed next to it are not on `sys.path`. diff --git a/knowledge/security/threat-model.md b/knowledge/security/threat-model.md index af49552e1..177174bb2 100644 --- a/knowledge/security/threat-model.md +++ b/knowledge/security/threat-model.md @@ -206,7 +206,7 @@ delegation (see TM-DOS-046). Regression tests: `path_validation_security` module | ID | Threat | Attack Vector | Mitigation | Status | |----|--------|--------------|------------|--------| -| TM-DOS-011 | Symlink loops | `ln -s /a /b; ln -s /b /a` | No symlink following | **MITIGATED** | +| TM-DOS-011 | Symlink loops | `ln -s /a /b; ln -s /b /a` | `FollowFs` follows at most 40 links per lookup (Linux MAXSYMLINKS), then fails with "Too many levels of symbolic links"; `find -L` adds canonical-path loop detection (TM-DOS-121). Regressions: `fs::follow::tests::loops_fail_with_eloop`, `hop_limit_allows_long_chains`, `symlink_loop_is_reported` spec | **MITIGATED** | | TM-DOS-012 | Deep directory nesting | `mkdir -p a/b/c/.../z` (1000 levels) | `max_path_depth` limit (100) | **MITIGATED** | | TM-DOS-013 | Long filenames | Create 10KB filename | `max_filename_length` (255) + `max_path_length` (4096) | **MITIGATED** | | TM-DOS-014 | Many directory entries | Create 1M files in one dir | `max_file_count` limit | **MITIGATED** | @@ -214,8 +214,9 @@ delegation (see TM-DOS-046). Regression tests: `path_validation_security` module **Current Risk**: LOW. Implementation: `FsLimits` in `fs/limits.rs`, `max_path_depth` 100 (TM-DOS-012), `max_filename_length` 255 + `max_path_length` 4096 (TM-DOS-013); `validate_path()` rejects control chars and bidi overrides (TM-DOS-015). -**Note**: Symlink loops (TM-DOS-011) are mitigated because InMemoryFs stores symlinks but doesn't -follow them during path resolution - symlink targets are only returned by `read_link()`. +**Note**: Symlinks are followed only by the session's outermost layer (`fs/follow.rs`); inner +layers never follow. Each lookup is capped at 40 links, so a loop costs at most 40 x path-depth +`lstat` calls before failing with ELOOP (TM-DOS-011). #### 1.2 Infinite Loops @@ -324,7 +325,7 @@ panicked. Resolved with `wrapping_*` ops, masked shift amounts, clamped exponent | ID | Threat | Attack Vector | Mitigation | Status | |----|--------|--------------|------------|--------| | TM-ESC-001 | Path traversal | `cat ../../../etc/passwd` | Path normalization | **MITIGATED** | -| TM-ESC-002 | Symlink escape | `ln -s /etc/passwd /tmp/x` | Symlinks not followed | **MITIGATED** | +| TM-ESC-002 | Symlink escape | `ln -s /etc/passwd /tmp/x` | Links are followed in the VFS namespace only: targets are VFS paths (absolute from the VFS root, `..` clamped there) and every resolved path goes back through mounts and `RealFs` containment; permission errors are never retried through resolution, so host-planted links inside a real mount stay blocked. Regressions: `fs::follow::tests::absolute_targets_stay_in_vfs`, `realfs_host_planted_symlink_is_not_followed_out`, `a_forged_symlink_cannot_reach_outside_the_vfs` | **MITIGATED** | | TM-ESC-003 | Real FS access | Direct syscalls | No real FS by default; `RealFs` canonicalizes existing paths and nearest existing ancestors before attaching missing suffixes | **MITIGATED** | | TM-ESC-004 | Mount escape | Mount real paths | MountableFs controlled | **MITIGATED** | | TM-ESC-016 | Symlink escape via overlay rename | `ln -s /etc/passwd x; mv x y` | Overlay rename/copy preserve symlinks as symlinks | **FIXED** | @@ -1382,7 +1383,6 @@ This section maps former vulnerability IDs to the new threat ID scheme and track | Threat ID | Vulnerability | Impact | Rationale | |-----------|---------------|--------|-----------| | TM-CRY-002 | RSA timing sidechannel in `rsa` (RUSTSEC-2023-0071) | Private key recovery over the network | No upstream patch exists for any `rsa` version; reachable only via the opt-in `ssh` feature, and Ed25519 keys avoid the affected path | -| TM-DOS-011 | Symlinks not followed | Functionality gap | By design - prevents symlink attacks | | TM-DOS-025 | Regex backtracking | CPU exhaustion | Linear-time `regex` engine by default; fancy-regex paths (`grep -P`, `sed`) capped by `FANCY_BACKTRACK_LIMIT` | | TM-UNI-004 | Zero-width chars in variable names | Variable confusion | Matches Bash behavior | | TM-UNI-006 | Homoglyph filenames | Visual confusion | Impractical to fully detect | @@ -1420,7 +1420,7 @@ This section maps former vulnerability IDs to the new threat ID scheme and track | Path char validation | TM-DOS-015 | `fs/limits.rs` | Yes | | Archive bomb protection | TM-DOS-007, TM-DOS-102, TM-NET-013 | `builtins/archive.rs` | Yes | | Path normalization | TM-ESC-001, TM-ESC-033, TM-INJ-005 | `fs/mod.rs`, `fs/realfs.rs` | Yes | -| No symlink following | TM-ESC-002, TM-DOS-011 | `fs/memory.rs` | Yes | +| VFS-only symlink following, 40-link cap | TM-ESC-002, TM-DOS-011 | `fs/follow.rs` | Yes | | Network allowlist | TM-INF-010, TM-NET-001 to TM-NET-007 | `network/allowlist.rs` | Yes | | Domain allowlist | TM-NET-015, TM-NET-016, TM-NET-017 | `network/allowlist.rs` | Planned | | Sandboxed eval/bash/sh, no exec | TM-ESC-005 to TM-ESC-008, TM-ESC-015, TM-INJ-003 | `interpreter/mod.rs` | Yes | @@ -1806,7 +1806,7 @@ python code -> CPython (wasm guest) -> wasi_snapshot_preview1 imports -> WASI ho | ID | Threat | Severity | Mitigation | Test | |----|--------|----------|------------|------| -| TM-PY-CPY-001 | Host filesystem access (absolute paths, `..`, `/proc`, symlinks) | Critical | Only the VFS is reachable; paths clamp at `/`; symlinks are not followed | `host_files_are_not_reachable`, `parent_traversal_is_clamped_at_vfs_root`, `host_system_paths_absent`, `symlinks_are_not_followed` | +| TM-PY-CPY-001 | Host filesystem access (absolute paths, `..`, `/proc`, symlinks) | Critical | Only the VFS is reachable; paths clamp at `/`; symlinks resolve inside the VFS only, `lstat` reports the link | `host_files_are_not_reachable`, `parent_traversal_is_clamped_at_vfs_root`, `host_system_paths_absent`, `rootfs_files_come_from_the_vfs_not_the_host`, `symlinks_resolve_inside_the_vfs_only` | | TM-PY-CPY-002 | Tampering with or shadowing the stdlib | High | Stdlib zip is a read-only host overlay; tenant files next to it are not on `sys.path`; preloaded modules come from the snapshot | `stdlib_zip_is_read_only`, `vfs_files_cannot_shadow_stdlib` | | TM-PY-CPY-003 | Network, process, thread or native-code escape | Critical | No socket or process imports; sockets return `ENOTSUP`; no threads, `ctypes` or dynamic loading in the guest | `network_unavailable`, `urllib_cannot_fetch`, `processes_unavailable`, `threads_and_native_code_unavailable` | | TM-PY-CPY-004 | CPU exhaustion (busy loops, swallowed exceptions, sleeps) | High | Fuel-driven async yields; every poll checks the call deadline and the request `ExecutionBudget`; sleeps never pass the deadline | `infinite_loop_times_out`, `sleep_is_bounded_by_deadline`, `swallowing_exceptions_cannot_escape_timeout`, `shell_timeout_tighter_than_python_limit_wins`, `cancellation_stops_busy_guest` |