diff --git a/crates/bashkit-js/src/lib.rs b/crates/bashkit-js/src/lib.rs index a64c66e0..9946b079 100644 --- a/crates/bashkit-js/src/lib.rs +++ b/crates/bashkit-js/src/lib.rs @@ -850,6 +850,10 @@ impl Bash { /// Mount a host directory into the VFS at runtime. /// /// Read-only by default; pass `writable: true` to enable writes. + /// + /// **Security**: Writable mounts log a warning. Consider using + /// `allowedMountPaths` in `BashOptions` to restrict which host paths + /// may be mounted. #[napi] pub fn mount( &self, @@ -857,9 +861,16 @@ impl Bash { vfs_path: String, writable: Option, ) -> napi::Result<()> { + let is_writable = writable.unwrap_or(false); + if is_writable { + eprintln!( + "bashkit: warning: writable mount at {} — scripts can modify host files", + host_path + ); + } block_on_with(&self.state, |s| async move { let bash = s.inner.lock().await; - let mode = if writable.unwrap_or(false) { + let mode = if is_writable { bashkit::RealFsMode::ReadWrite } else { bashkit::RealFsMode::ReadOnly @@ -1222,6 +1233,10 @@ impl BashTool { /// Mount a host directory into the VFS at runtime. /// /// Read-only by default; pass `writable: true` to enable writes. + /// + /// **Security**: Writable mounts log a warning. Consider using + /// `allowedMountPaths` in `BashOptions` to restrict which host paths + /// may be mounted. #[napi] pub fn mount( &self, @@ -1229,9 +1244,16 @@ impl BashTool { vfs_path: String, writable: Option, ) -> napi::Result<()> { + let is_writable = writable.unwrap_or(false); + if is_writable { + eprintln!( + "bashkit: warning: writable mount at {} — scripts can modify host files", + host_path + ); + } block_on_with(&self.state, |s| async move { let bash = s.inner.lock().await; - let mode = if writable.unwrap_or(false) { + let mode = if is_writable { bashkit::RealFsMode::ReadWrite } else { bashkit::RealFsMode::ReadOnly diff --git a/crates/bashkit/src/lib.rs b/crates/bashkit/src/lib.rs index bc909249..3641d672 100644 --- a/crates/bashkit/src/lib.rs +++ b/crates/bashkit/src/lib.rs @@ -1128,6 +1128,10 @@ pub struct BashBuilder { /// Real host directories to mount in the VFS #[cfg(feature = "realfs")] real_mounts: Vec, + /// Optional allowlist of host paths that may be mounted. + /// When set, only paths starting with an allowed prefix are accepted. + #[cfg(feature = "realfs")] + mount_path_allowlist: Option>, /// Optional VFS path for persistent history history_file: Option, /// Interceptor hooks @@ -2064,6 +2068,30 @@ impl BashBuilder { self } + /// Set an allowlist of host paths that may be mounted. + /// + /// When set, only host paths starting with an allowed prefix are accepted + /// by `mount_real_*` methods. Paths outside the allowlist are rejected with + /// a warning at build time. + /// + /// # Example + /// + /// ```rust,ignore + /// let bash = Bash::builder() + /// .allowed_mount_paths(["/home/user/projects", "/tmp"]) + /// .mount_real_readonly("/home/user/projects/data") // OK + /// .mount_real_readonly("/etc/passwd") // rejected + /// .build(); + /// ``` + #[cfg(feature = "realfs")] + pub fn allowed_mount_paths( + mut self, + paths: impl IntoIterator>, + ) -> Self { + self.mount_path_allowlist = Some(paths.into_iter().map(|p| p.into()).collect()); + self + } + /// Build the Bash instance. /// /// If mounted files are specified, they are added via an [`OverlayFs`] layer @@ -2103,7 +2131,11 @@ impl BashBuilder { // Layer 1: Apply real filesystem mounts (if any) #[cfg(feature = "realfs")] - let base_fs = Self::apply_real_mounts(&self.real_mounts, base_fs); + let base_fs = Self::apply_real_mounts( + &self.real_mounts, + self.mount_path_allowlist.as_deref(), + base_fs, + ); // Layer 2: If there are mounted text/lazy files, wrap in an OverlayFs let has_mounts = !self.mounted_files.is_empty() || !self.mounted_lazy_files.is_empty(); @@ -2186,13 +2218,14 @@ impl BashBuilder { result } - /// Apply real filesystem mounts to the base filesystem. - /// - /// - Mounts without a VFS path are overlaid at root (host files visible at /) - /// - Mounts with a VFS path use MountableFs to mount at that path + /// Sensitive host paths that are blocked from mounting by default. + #[cfg(feature = "realfs")] + const SENSITIVE_MOUNT_PATHS: &[&str] = &["/etc/shadow", "/etc/sudoers", "/proc", "/sys"]; + #[cfg(feature = "realfs")] fn apply_real_mounts( real_mounts: &[MountedRealDir], + mount_allowlist: Option<&[PathBuf]>, base_fs: Arc, ) -> Arc { if real_mounts.is_empty() { @@ -2203,6 +2236,40 @@ impl BashBuilder { let mut mount_points: Vec<(PathBuf, Arc)> = Vec::new(); for m in real_mounts { + // Warn on writable mounts + if m.mode == fs::RealFsMode::ReadWrite { + eprintln!( + "bashkit: warning: writable mount at {} — scripts can modify host files", + m.host_path.display() + ); + } + + // Block sensitive paths + let host_str = m.host_path.to_string_lossy(); + if Self::SENSITIVE_MOUNT_PATHS + .iter() + .any(|s| host_str.starts_with(s)) + { + eprintln!( + "bashkit: warning: refusing to mount sensitive path {}", + m.host_path.display() + ); + continue; + } + + // Check allowlist if configured + if let Some(allowlist) = mount_allowlist + && !allowlist + .iter() + .any(|allowed| m.host_path.starts_with(allowed)) + { + eprintln!( + "bashkit: warning: mount path {} not in allowlist, skipping", + m.host_path.display() + ); + continue; + } + let real_backend = match fs::RealFs::new(&m.host_path, m.mode) { Ok(b) => b, Err(e) => { diff --git a/crates/bashkit/tests/realfs_tests.rs b/crates/bashkit/tests/realfs_tests.rs index 372e8c90..56c76008 100644 --- a/crates/bashkit/tests/realfs_tests.rs +++ b/crates/bashkit/tests/realfs_tests.rs @@ -365,6 +365,46 @@ async fn realfs_symlink_within_mount_allowed() { ); } +// --- Mount path validation --- + +#[tokio::test] +async fn mount_allowlist_blocks_unlisted_path() { + let dir = setup_host_dir(); + std::fs::write(dir.path().join("data.txt"), "secret").unwrap(); + + // Mount with allowlist that does NOT include the dir + let mut bash = Bash::builder() + .allowed_mount_paths(["/nonexistent/allowed"]) + .mount_real_readonly_at(dir.path(), "/mnt/data") + .build(); + + // The mount should have been skipped — file should not be accessible + let r = bash + .exec("cat /mnt/data/data.txt 2>&1; echo $?") + .await + .unwrap(); + assert!( + r.stdout.trim().ends_with('1') || r.stdout.contains("No such file"), + "Mount outside allowlist should be blocked, got: {}", + r.stdout + ); +} + +#[tokio::test] +async fn mount_sensitive_path_blocked() { + // Attempting to mount /proc should be silently blocked + let mut bash = Bash::builder() + .mount_real_readonly_at("/proc", "/mnt/proc") + .build(); + + let r = bash.exec("ls /mnt/proc 2>&1; echo $?").await.unwrap(); + assert!( + r.stdout.trim().ends_with('1') || r.stdout.contains("No such file"), + "Sensitive path /proc should be blocked, got: {}", + r.stdout + ); +} + // --- Runtime mount/unmount (exercises Bash::mount / Bash::unmount) --- #[tokio::test]