Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
26 changes: 24 additions & 2 deletions crates/bashkit-js/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -850,16 +850,27 @@ 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,
host_path: String,
vfs_path: String,
writable: Option<bool>,
) -> 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
Expand Down Expand Up @@ -1222,16 +1233,27 @@ 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,
host_path: String,
vfs_path: String,
writable: Option<bool>,
) -> 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
Expand Down
77 changes: 72 additions & 5 deletions crates/bashkit/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1128,6 +1128,10 @@ pub struct BashBuilder {
/// Real host directories to mount in the VFS
#[cfg(feature = "realfs")]
real_mounts: Vec<MountedRealDir>,
/// 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<Vec<PathBuf>>,
/// Optional VFS path for persistent history
history_file: Option<PathBuf>,
/// Interceptor hooks
Expand Down Expand Up @@ -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<Item = impl Into<PathBuf>>,
) -> 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
Expand Down Expand Up @@ -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();
Expand Down Expand Up @@ -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<dyn FileSystem>,
) -> Arc<dyn FileSystem> {
if real_mounts.is_empty() {
Expand All @@ -2203,6 +2236,40 @@ impl BashBuilder {
let mut mount_points: Vec<(PathBuf, Arc<dyn FileSystem>)> = 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) => {
Expand Down
40 changes: 40 additions & 0 deletions crates/bashkit/tests/realfs_tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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]
Expand Down
Loading