diff --git a/crates/bashkit-capi/Cargo.toml b/crates/bashkit-capi/Cargo.toml index 9d82cc471..bc5c24e5b 100644 --- a/crates/bashkit-capi/Cargo.toml +++ b/crates/bashkit-capi/Cargo.toml @@ -17,7 +17,7 @@ crate-type = ["cdylib", "rlib"] # `tzdata` is listed explicitly: the C ABI is a general-purpose embedding # surface, so `date` must keep honouring named `TZ=` zones the way it did # before that became a feature. -bashkit = { path = "../bashkit", default-features = false, features = ["git", "jq", "tzdata"] } +bashkit = { path = "../bashkit", default-features = false, features = ["git", "jq", "tzdata", "realfs"] } serde = { workspace = true } serde_json = { workspace = true } tokio = { workspace = true } diff --git a/crates/bashkit-capi/include/bashkit.def b/crates/bashkit-capi/include/bashkit.def index b18e2feea..98b4c1c74 100644 --- a/crates/bashkit-capi/include/bashkit.def +++ b/crates/bashkit-capi/include/bashkit.def @@ -12,6 +12,7 @@ EXPORTS bashkit_execute bashkit_free bashkit_mkdir + bashkit_mount bashkit_read_file bashkit_remove bashkit_result_exit_code @@ -20,5 +21,6 @@ EXPORTS bashkit_result_free bashkit_result_stderr bashkit_result_stdout + bashkit_unmount bashkit_version bashkit_write_file diff --git a/crates/bashkit-capi/include/bashkit.h b/crates/bashkit-capi/include/bashkit.h index a7a6b5702..8f547a860 100644 --- a/crates/bashkit-capi/include/bashkit.h +++ b/crates/bashkit-capi/include/bashkit.h @@ -39,6 +39,7 @@ typedef uint32_t BashkitStatus; #define BASHKIT_EXECUTION_ERROR 4u #define BASHKIT_IO_ERROR 5u #define BASHKIT_UNSUPPORTED 6u +#define BASHKIT_CANCELLED 7u #define BASHKIT_INTERNAL_ERROR 255u /* Static views returned by these functions remain valid for process lifetime. */ @@ -61,6 +62,13 @@ BASHKIT_API BashkitStatus bashkit_execute( BashkitResult **out_result, BashkitError **out_error); +/* Cancellation requires the `cancellation` capability. Both are lock-free and + safe to call from any thread while bashkit_execute is blocked on the same + handle; execution aborts at the next command boundary with + BASHKIT_CANCELLED. The flag is sticky until bashkit_clear_cancel. */ +BASHKIT_API BashkitStatus bashkit_cancel(Bashkit *bash); +BASHKIT_API BashkitStatus bashkit_clear_cancel(Bashkit *bash); + /* Result byte views remain valid until bashkit_result_free(result). */ BASHKIT_API int32_t bashkit_result_exit_code(const BashkitResult *result); BASHKIT_API BashkitBytes bashkit_result_stdout(const BashkitResult *result); @@ -91,6 +99,21 @@ BASHKIT_API BashkitStatus bashkit_remove( uint32_t recursive, BashkitError **out_error); +/* Mounts require the `realfs-mounts` capability; host roots must resolve under + an `allowed_mount_paths` prefix from the session config. Sensitive host + paths (home trees, `/etc`, `.ssh`, ...) are refused unless the allowlist + names the root exactly (TM-FS-013). */ +BASHKIT_API BashkitStatus bashkit_mount( + Bashkit *bash, + BashkitBytes vfs_path, + BashkitBytes host_root, + uint32_t writable, + BashkitError **out_error); +BASHKIT_API BashkitStatus bashkit_unmount( + Bashkit *bash, + BashkitBytes vfs_path, + BashkitError **out_error); + /* Buffer byte views remain valid until bashkit_buffer_free(buffer). */ BASHKIT_API BashkitBytes bashkit_buffer_bytes(const BashkitBuffer *buffer); BASHKIT_API void bashkit_buffer_free(BashkitBuffer *buffer); diff --git a/crates/bashkit-capi/src/lib.rs b/crates/bashkit-capi/src/lib.rs index db4643fda..a6f7f9661 100644 --- a/crates/bashkit-capi/src/lib.rs +++ b/crates/bashkit-capi/src/lib.rs @@ -5,15 +5,19 @@ // boundary and reports a generic error so unwinding cannot enter the foreign // caller and the returned error does not include panic details. -use bashkit::{Bash, Error as BashError, ExecutionLimits, FileSystem, LimitExceeded}; +use bashkit::{ + Bash, Error as BashError, ExecutionLimits, FileSystem, LimitExceeded, PosixFs, RealFs, + RealFsMode, +}; use serde::Deserialize; use std::collections::{BTreeMap, HashMap}; use std::panic::{AssertUnwindSafe, catch_unwind}; -use std::path::Path; +use std::path::{Path, PathBuf}; use std::ptr; use std::slice; use std::str; -use std::sync::Mutex; +use std::sync::atomic::{AtomicBool, Ordering}; +use std::sync::{Arc, Mutex}; use std::time::Duration; use tokio::runtime::{Builder, Runtime}; @@ -23,7 +27,8 @@ pub const BASHKIT_RESULT_STDOUT_TRUNCATED: u32 = 1 << 0; pub const BASHKIT_RESULT_STDERR_TRUNCATED: u32 = 1 << 1; const MAX_ERROR_BYTES: usize = 1024; -const CAPABILITIES_JSON: &[u8] = br#"{"abi":1,"features":["git","jq","vfs"]}"#; +const CAPABILITIES_JSON: &[u8] = + br#"{"abi":1,"features":["git","jq","vfs","realfs-mounts","cancellation"]}"#; const VERSION: &[u8] = env!("CARGO_PKG_VERSION").as_bytes(); #[repr(u32)] @@ -36,6 +41,7 @@ pub enum BashkitStatus { ExecutionError = 4, IoError = 5, Unsupported = 6, + Cancelled = 7, InternalError = 255, } @@ -68,10 +74,15 @@ struct State { runtime: Runtime, bash: Bash, max_input_bytes: usize, + allowed_mount_paths: Arc<[String]>, } pub struct Bashkit { state: Mutex, + // Shared interpreter cancellation flag. Deliberately outside the state + // lock: bashkit_execute holds that lock while blocked, and cancellation + // must stay reachable — and lock-free — from any thread. + cancelled: Arc, } pub struct BashkitResult { @@ -111,6 +122,7 @@ impl ApiFailure { fn from_bash(error: BashError) -> Self { let status = match error { BashError::Io(_) => BashkitStatus::IoError, + BashError::Cancelled => BashkitStatus::Cancelled, _ => BashkitStatus::ExecutionError, }; Self::new(status, error.to_string()) @@ -139,6 +151,18 @@ struct ConfigV1 { readonly_filesystem: bool, #[serde(default)] capture_final_env: bool, + #[serde(default)] + mounts: Vec, + #[serde(default)] + allowed_mount_paths: Vec, +} + +#[derive(Clone, Deserialize)] +struct MountConfigV1 { + path: String, + root: String, + #[serde(default)] + writable: bool, } #[derive(Clone, Copy, Default, Deserialize)] @@ -191,7 +215,95 @@ fn make_runtime() -> Result { }) } -fn build_from_config(config: ConfigV1) -> Result<(Bash, usize), ApiFailure> { +// THREAT[TM-FS-013]: host-directory mounts pierce the sandbox boundary, so +// every mount root must (a) resolve under a configured `allowed_mount_paths` +// prefix and (b) clear the shared sensitive-path denylist +// (`bashkit::is_sensitive_mount_path`). Canonicalization defuses `..` segments +// and symlinks before the checks; comparison is case-folded on Windows +// filesystems. A sensitive root (home trees, `/etc`, `.ssh`, ...) additionally +// requires an allowlist entry that names it exactly: a broad parent entry such +// as the home directory itself is not consent to expose credential stores. +fn fold_path(path: &str) -> String { + let trimmed = path.trim_end_matches(std::path::MAIN_SEPARATOR); + let folded = if trimmed.is_empty() { path } else { trimmed }; + if cfg!(windows) { + folded.to_ascii_lowercase() + } else { + folded.to_string() + } +} + +fn validate_mount_root(root: &str, allowed: &[String]) -> Result { + if allowed.is_empty() { + return Err(ApiFailure::new( + BashkitStatus::InvalidConfig, + "mount rejected: session has no allowed_mount_paths".to_string(), + )); + } + let path = PathBuf::from(root); + let canonical = std::fs::canonicalize(&path).unwrap_or(path); + let candidate = fold_path(&canonical.to_string_lossy()); + let candidate = candidate.as_bytes(); + let mut covered = false; + let mut exact = false; + for prefix in allowed { + let prefix_path = PathBuf::from(prefix); + let prefix_canonical = std::fs::canonicalize(&prefix_path).unwrap_or(prefix_path); + let prefix_folded = fold_path(&prefix_canonical.to_string_lossy()); + let prefix = prefix_folded.as_bytes(); + let exactly = candidate == prefix; + let under = candidate.len() > prefix.len() + && candidate.starts_with(prefix) + && candidate[prefix.len()] == std::path::MAIN_SEPARATOR as u8; + covered |= exactly || under; + exact |= exactly; + } + if !covered { + return Err(ApiFailure::new( + BashkitStatus::InvalidConfig, + format!("mount root {root:?} is not under any allowed_mount_paths prefix"), + )); + } + if bashkit::is_sensitive_mount_path(&canonical) && !exact { + return Err(ApiFailure::new( + BashkitStatus::InvalidConfig, + format!( + "mount root {root:?} is a sensitive host path; name it exactly in \ + allowed_mount_paths to mount it" + ), + )); + } + Ok(canonical) +} + +fn apply_config_mounts( + bash: &mut Bash, + mounts: &[MountConfigV1], + allowed: &[String], +) -> Result<(), ApiFailure> { + for mount in mounts { + let root = validate_mount_root(&mount.root, allowed)?; + let mode = if mount.writable { + RealFsMode::ReadWrite + } else { + RealFsMode::ReadOnly + }; + #[allow(deprecated)] // The C ABI boundary is synchronous; there is no + // async context at this call, mirroring `apply_real_mounts`. + let fs = RealFs::new(&root, mode).map_err(|error| { + ApiFailure::new( + BashkitStatus::InvalidConfig, + format!("failed to open mount root {:?}: {error}", mount.root), + ) + })?; + let fs: Arc = Arc::new(PosixFs::new(fs)); + bash.mount(Path::new(&mount.path), fs) + .map_err(ApiFailure::from_bash)?; + } + Ok(()) +} + +fn build_from_config(config: ConfigV1) -> Result<(Bash, usize, Arc<[String]>), ApiFailure> { if config.schema_version != 1 { return Err(ApiFailure::new( BashkitStatus::InvalidConfig, @@ -243,7 +355,10 @@ fn build_from_config(config: ConfigV1) -> Result<(Bash, usize), ApiFailure> { for (path, content) in config.files { builder = builder.mount_text(path, content); } - Ok((builder.build(), max_input_bytes)) + let mut bash = builder.build(); + let allowed: Arc<[String]> = Arc::from(config.allowed_mount_paths); + apply_config_mounts(&mut bash, &config.mounts, &allowed)?; + Ok((bash, max_input_bytes, allowed)) } fn truncate_error(message: String) -> Vec { @@ -370,12 +485,16 @@ pub unsafe extern "C" fn bashkit_create_default( unsafe { ffi_boundary(out_error, || { let out_bash = output_slot(out_bash, "out_bash")?; + let engine = Bash::new(); + let cancelled = engine.cancellation_token(); let bash = Bashkit { state: Mutex::new(State { runtime: make_runtime()?, - bash: Bash::new(), + bash: engine, max_input_bytes: ExecutionLimits::default().max_input_bytes, + allowed_mount_paths: Arc::from(Vec::::new()), }), + cancelled, }; *out_bash = Box::into_raw(Box::new(bash)); Ok(()) @@ -408,13 +527,16 @@ pub unsafe extern "C" fn bashkit_create_json( format!("invalid configuration: {error}"), ) })?; - let (bash, max_input_bytes) = build_from_config(config)?; + let (engine, max_input_bytes, allowed_mount_paths) = build_from_config(config)?; + let cancelled = engine.cancellation_token(); let bash = Bashkit { state: Mutex::new(State { runtime: make_runtime()?, - bash, + bash: engine, max_input_bytes, + allowed_mount_paths, }), + cancelled, }; *out_bash = Box::into_raw(Box::new(bash)); Ok(()) @@ -483,6 +605,44 @@ pub unsafe extern "C" fn bashkit_execute( } } +/// Requests cancellation of the execution currently running on `bash`. +/// Requires the `cancellation` capability. Lock-free — never touches the state +/// mutex — so it is safe to call from any thread while `bashkit_execute` is +/// blocked on the same handle. Execution aborts at the next command boundary +/// and `bashkit_execute` reports `BASHKIT_CANCELLED`. +/// +/// The flag is sticky: reset it with `bashkit_clear_cancel` before the next +/// execute, or that call aborts immediately. +/// +/// # Safety +/// `bash` must be live. +#[unsafe(no_mangle)] +pub unsafe extern "C" fn bashkit_cancel(bash: *mut Bashkit) -> BashkitStatus { + unsafe { + ffi_boundary(ptr::null_mut(), || { + let bash = handle(bash)?; + bash.cancelled.store(true, Ordering::SeqCst); + Ok(()) + }) + } +} + +/// Clears the flag set by `bashkit_cancel`, restoring normal execution. +/// Lock-free like `bashkit_cancel`. +/// +/// # Safety +/// `bash` must be live. +#[unsafe(no_mangle)] +pub unsafe extern "C" fn bashkit_clear_cancel(bash: *mut Bashkit) -> BashkitStatus { + unsafe { + ffi_boundary(ptr::null_mut(), || { + let bash = handle(bash)?; + bash.cancelled.store(false, Ordering::SeqCst); + Ok(()) + }) + } +} + /// # Safety /// `result` must be null or a live result pointer. #[unsafe(no_mangle)] @@ -642,6 +802,81 @@ pub unsafe extern "C" fn bashkit_remove( } } +/// Mounts a host directory at `vfs_path` for the duration of the session. +/// Requires the `realfs-mounts` capability and a `host_root` that resolves +/// under one of the session's `allowed_mount_paths` prefixes. Sensitive host +/// paths (home trees, `/etc`, `.ssh`, ...) are refused unless the allowlist +/// names the root exactly (TM-FS-013) — the same rule config-time mounts use. +/// +/// # Safety +/// `bash` must be live; path bytes must remain readable for the call. +/// `out_error`, when non-null, must be writable. +#[unsafe(no_mangle)] +pub unsafe extern "C" fn bashkit_mount( + bash: *mut Bashkit, + vfs_path: BashkitBytes, + host_root: BashkitBytes, + writable: u32, + out_error: *mut *mut BashkitError, +) -> BashkitStatus { + unsafe { + ffi_boundary(out_error, || { + let bash = handle(bash)?; + let vfs_path = input_str(vfs_path, "vfs_path")?; + let host_root = input_str(host_root, "host_root")?; + let state = bash.state.lock().map_err(|_| { + ApiFailure::new(BashkitStatus::InternalError, "bash instance is unavailable") + })?; + let root = validate_mount_root(host_root, &state.allowed_mount_paths)?; + let mode = if writable != 0 { + RealFsMode::ReadWrite + } else { + RealFsMode::ReadOnly + }; + #[allow(deprecated)] // The C ABI boundary is synchronous; there + // is no async context at this call, mirroring `apply_real_mounts`. + let fs = RealFs::new(&root, mode).map_err(|error| { + ApiFailure::new( + BashkitStatus::InvalidArgument, + format!("failed to open mount root {host_root:?}: {error}"), + ) + })?; + let fs: Arc = Arc::new(PosixFs::new(fs)); + state + .bash + .mount(Path::new(vfs_path), fs) + .map_err(ApiFailure::from_bash) + }) + } +} + +/// Removes the mount at `vfs_path`. Shell state is preserved; paths under +/// `vfs_path` fall back to the underlying filesystem. +/// +/// # Safety +/// `bash` must be live; path bytes must remain readable for the call. +/// `out_error`, when non-null, must be writable. +#[unsafe(no_mangle)] +pub unsafe extern "C" fn bashkit_unmount( + bash: *mut Bashkit, + vfs_path: BashkitBytes, + out_error: *mut *mut BashkitError, +) -> BashkitStatus { + unsafe { + ffi_boundary(out_error, || { + let bash = handle(bash)?; + let vfs_path = input_str(vfs_path, "vfs_path")?; + let state = bash.state.lock().map_err(|_| { + ApiFailure::new(BashkitStatus::InternalError, "bash instance is unavailable") + })?; + state + .bash + .unmount(Path::new(vfs_path)) + .map_err(ApiFailure::from_bash) + }) + } +} + /// # Safety /// `buffer` must be null or live. The returned view expires with `buffer`. #[unsafe(no_mangle)] diff --git a/crates/bashkit-capi/tests/abi.rs b/crates/bashkit-capi/tests/abi.rs index c54d66be3..3fc31bfad 100644 --- a/crates/bashkit-capi/tests/abi.rs +++ b/crates/bashkit-capi/tests/abi.rs @@ -331,6 +331,101 @@ fn configured_deadline_aborts_execution() { } } +#[test] +fn cancellation_aborts_running_execution_and_stays_sticky_until_cleared() { + unsafe { + let capabilities: serde_json::Value = + serde_json::from_slice(&borrowed(bashkit_capabilities_json())).unwrap(); + assert!( + capabilities["features"] + .as_array() + .unwrap() + .iter() + .any(|feature| feature == "cancellation") + ); + + let mut bash = ptr::null_mut(); + let mut error = ptr::null_mut(); + assert_eq!( + bashkit_create_default(&mut bash, &mut error), + BashkitStatus::Ok + ); + + // Cancellation lands at command boundaries, so the script must reach + // one quickly without tripping the profile's command/iteration caps: + // a loop of 1-second sleeps gives a boundary every second while + // burning almost no budget. (A pending single sleep is NOT + // interruptible; only the profile deadline ends it.) + // Raw pointers are not Send and edition-2021 closures would capture the + // inner field of any wrapper anyway, so cross the thread as a usize. + let handle = bash as usize; + + let observed = std::sync::Arc::new(std::sync::Mutex::new(None::)); + let writer = observed.clone(); + let worker = std::thread::spawn(move || { + let bash = handle as *mut Bashkit; + // No inner `unsafe` block: the closure literal is lexically nested + // under the test's `unsafe` block, which covers the body. + let mut result = ptr::null_mut(); + let mut thread_error = ptr::null_mut(); + let status = bashkit_execute( + bash, + bytes(b"while true; do sleep 1; done"), + &mut result, + &mut thread_error, + ); + assert!(result.is_null()); + *writer.lock().unwrap() = Some(status); + if !thread_error.is_null() { + bashkit_error_free(thread_error); + } + }); + + std::thread::sleep(std::time::Duration::from_millis(200)); + assert_eq!(bashkit_cancel(bash), BashkitStatus::Ok); + worker.join().unwrap(); + assert_eq!( + observed.lock().unwrap().take(), + Some(BashkitStatus::Cancelled) + ); + + // Sticky: the next execute aborts immediately until the flag is cleared. + let mut result = ptr::null_mut(); + assert_eq!( + bashkit_execute(bash, bytes(b"echo blocked"), &mut result, &mut error), + BashkitStatus::Cancelled + ); + assert!(result.is_null()); + bashkit_error_free(error); + + // clear_cancel restores normal execution without losing shell state. + assert_eq!(bashkit_clear_cancel(bash), BashkitStatus::Ok); + let mut result = ptr::null_mut(); + assert_eq!( + bashkit_execute(bash, bytes(b"echo resumed"), &mut result, &mut error), + BashkitStatus::Ok + ); + assert_eq!(borrowed(bashkit_result_stdout(result)), b"resumed\n"); + bashkit_result_free(result); + + bashkit_free(bash); + } +} + +#[test] +fn cancel_rejects_null_handle_without_touching_state() { + unsafe { + assert_eq!( + bashkit_cancel(ptr::null_mut()), + BashkitStatus::InvalidArgument + ); + assert_eq!( + bashkit_clear_cancel(ptr::null_mut()), + BashkitStatus::InvalidArgument + ); + } +} + #[test] fn null_destructors_and_accessors_are_safe() { unsafe { @@ -345,3 +440,287 @@ fn null_destructors_and_accessors_are_safe() { assert_eq!(bashkit_error_message(ptr::null()).len, 0); } } + +fn temp_dir(name: &str) -> std::path::PathBuf { + let dir = std::env::temp_dir().join(format!("bashkit-capi-{name}-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(&dir).unwrap(); + dir +} + +#[test] +fn config_mounts_expose_host_dir_read_only() { + unsafe { + let host = temp_dir("mount-ro"); + std::fs::write(host.join("note.txt"), b"host-bytes").unwrap(); + let config = serde_json::json!({ + "schema_version": 1, + "allowed_mount_paths": [host.to_string_lossy()], + "mounts": [{"path": "/data", "root": host.to_string_lossy()}], + }) + .to_string(); + + let mut bash = ptr::null_mut(); + let mut error = ptr::null_mut(); + assert_eq!( + bashkit_create_json(bytes(config.as_bytes()), &mut bash, &mut error), + BashkitStatus::Ok + ); + + let mut result = ptr::null_mut(); + assert_eq!( + bashkit_execute(bash, bytes(b"cat /data/note.txt"), &mut result, &mut error), + BashkitStatus::Ok + ); + assert_eq!(borrowed(bashkit_result_stdout(result)), b"host-bytes"); + bashkit_result_free(result); + + // Read-only mount: writes through the mount never reach the host. + let mut result = ptr::null_mut(); + assert_eq!( + bashkit_execute( + bash, + bytes(b"echo nope > /data/denied.txt"), + &mut result, + &mut error, + ), + BashkitStatus::Ok + ); + bashkit_result_free(result); + assert!(!host.join("denied.txt").exists()); + + bashkit_free(bash); + let _ = std::fs::remove_dir_all(&host); + } +} + +#[test] +fn runtime_mount_and_unmount_round_trip() { + unsafe { + let host = temp_dir("mount-rt"); + std::fs::write(host.join("f.txt"), b"rt").unwrap(); + let config = serde_json::json!({ + "schema_version": 1, + "allowed_mount_paths": [host.to_string_lossy()], + }) + .to_string(); + + let mut bash = ptr::null_mut(); + let mut error = ptr::null_mut(); + assert_eq!( + bashkit_create_json(bytes(config.as_bytes()), &mut bash, &mut error), + BashkitStatus::Ok + ); + + let mut result = ptr::null_mut(); + assert_eq!( + bashkit_execute(bash, bytes(b"cat /mnt/f.txt"), &mut result, &mut error), + BashkitStatus::Ok + ); + assert_ne!(bashkit_result_exit_code(result), 0); + bashkit_result_free(result); + + assert_eq!( + bashkit_mount( + bash, + bytes(b"/mnt"), + bytes(host.to_string_lossy().as_bytes()), + 0, + &mut error, + ), + BashkitStatus::Ok + ); + let mut result = ptr::null_mut(); + assert_eq!( + bashkit_execute(bash, bytes(b"cat /mnt/f.txt"), &mut result, &mut error), + BashkitStatus::Ok + ); + assert_eq!(borrowed(bashkit_result_stdout(result)), b"rt"); + bashkit_result_free(result); + + assert_eq!( + bashkit_unmount(bash, bytes(b"/mnt"), &mut error), + BashkitStatus::Ok + ); + let mut result = ptr::null_mut(); + assert_eq!( + bashkit_execute(bash, bytes(b"cat /mnt/f.txt"), &mut result, &mut error), + BashkitStatus::Ok + ); + assert_ne!(bashkit_result_exit_code(result), 0); + bashkit_result_free(result); + + bashkit_free(bash); + let _ = std::fs::remove_dir_all(&host); + } +} + +#[test] +fn mounts_require_allowlist_and_containment() { + unsafe { + // No allowed_mount_paths: config mount is rejected outright. + let host = temp_dir("mount-denied"); + let config = serde_json::json!({ + "schema_version": 1, + "mounts": [{"path": "/data", "root": host.to_string_lossy()}], + }) + .to_string(); + let mut bash = ptr::null_mut(); + let mut error = ptr::null_mut(); + assert_eq!( + bashkit_create_json(bytes(config.as_bytes()), &mut bash, &mut error), + BashkitStatus::InvalidConfig + ); + assert_eq!( + bashkit_error_code(error), + BashkitStatus::InvalidConfig as u32 + ); + bashkit_error_free(error); + + // Root outside every allowed prefix is rejected at mount time. + let allowed = temp_dir("mount-allowed"); + let outside = temp_dir("mount-outside"); + let config = serde_json::json!({ + "schema_version": 1, + "allowed_mount_paths": [allowed.to_string_lossy()], + }) + .to_string(); + let mut bash = ptr::null_mut(); + let mut error = ptr::null_mut(); + assert_eq!( + bashkit_create_json(bytes(config.as_bytes()), &mut bash, &mut error), + BashkitStatus::Ok + ); + assert_ne!( + bashkit_mount( + bash, + bytes(b"/data"), + bytes(outside.to_string_lossy().as_bytes()), + 0, + &mut error, + ), + BashkitStatus::Ok + ); + assert!(!error.is_null()); + bashkit_error_free(error); + bashkit_free(bash); + let _ = std::fs::remove_dir_all(&host); + let _ = std::fs::remove_dir_all(&allowed); + let _ = std::fs::remove_dir_all(&outside); + } +} + +// THREAT[TM-FS-013]: a broad allowlist entry (the home directory itself) is +// not consent to expose a credential directory under it. +#[test] +fn config_mounts_refuse_sensitive_paths_under_broad_allowlist() { + unsafe { + let home = temp_dir("mount-home"); + let ssh_dir = home.join(".ssh"); + std::fs::create_dir_all(&ssh_dir).unwrap(); + std::fs::write(ssh_dir.join("id_rsa"), b"PRIVATE-KEY-BYTES").unwrap(); + + let config = serde_json::json!({ + "schema_version": 1, + "allowed_mount_paths": [home.to_string_lossy()], + "mounts": [{"path": "/data", "root": ssh_dir.to_string_lossy()}], + }) + .to_string(); + let mut bash = ptr::null_mut(); + let mut error = ptr::null_mut(); + assert_eq!( + bashkit_create_json(bytes(config.as_bytes()), &mut bash, &mut error), + BashkitStatus::InvalidConfig + ); + assert!(bash.is_null()); + assert!( + error_message(error).contains("sensitive host path"), + "error must name the sensitive-path rule" + ); + let _ = std::fs::remove_dir_all(&home); + } +} + +#[test] +fn runtime_mount_refuses_sensitive_paths_under_broad_allowlist() { + unsafe { + let home = temp_dir("mount-home-rt"); + let ssh_dir = home.join(".ssh"); + std::fs::create_dir_all(&ssh_dir).unwrap(); + std::fs::write(ssh_dir.join("id_rsa"), b"PRIVATE-KEY-BYTES").unwrap(); + + let config = serde_json::json!({ + "schema_version": 1, + "allowed_mount_paths": [home.to_string_lossy()], + }) + .to_string(); + let mut bash = ptr::null_mut(); + let mut error = ptr::null_mut(); + assert_eq!( + bashkit_create_json(bytes(config.as_bytes()), &mut bash, &mut error), + BashkitStatus::Ok + ); + + assert_ne!( + bashkit_mount( + bash, + bytes(b"/data"), + bytes(ssh_dir.to_string_lossy().as_bytes()), + 0, + &mut error, + ), + BashkitStatus::Ok + ); + assert!(!error.is_null()); + bashkit_error_free(error); + + // The refused mount left nothing behind: the path stays unresolved. + let mut result = ptr::null_mut(); + assert_eq!( + bashkit_execute(bash, bytes(b"cat /data/id_rsa"), &mut result, &mut error), + BashkitStatus::Ok + ); + assert_ne!(bashkit_result_exit_code(result), 0); + bashkit_result_free(result); + + bashkit_free(bash); + let _ = std::fs::remove_dir_all(&home); + } +} + +#[test] +fn sensitive_path_mounts_when_allowlisted_exactly() { + unsafe { + let home = temp_dir("mount-home-exact"); + let ssh_dir = home.join(".ssh"); + std::fs::create_dir_all(&ssh_dir).unwrap(); + std::fs::write(ssh_dir.join("id_rsa"), b"PRIVATE-KEY-BYTES").unwrap(); + + // Naming the sensitive root itself in the allowlist is explicit + // consent: the mount is allowed for both config-time and runtime. + let config = serde_json::json!({ + "schema_version": 1, + "allowed_mount_paths": [ssh_dir.to_string_lossy()], + "mounts": [{"path": "/data", "root": ssh_dir.to_string_lossy()}], + }) + .to_string(); + let mut bash = ptr::null_mut(); + let mut error = ptr::null_mut(); + assert_eq!( + bashkit_create_json(bytes(config.as_bytes()), &mut bash, &mut error), + BashkitStatus::Ok + ); + let mut result = ptr::null_mut(); + assert_eq!( + bashkit_execute(bash, bytes(b"cat /data/id_rsa"), &mut result, &mut error), + BashkitStatus::Ok + ); + assert_eq!( + borrowed(bashkit_result_stdout(result)), + b"PRIVATE-KEY-BYTES" + ); + bashkit_result_free(result); + bashkit_free(bash); + let _ = std::fs::remove_dir_all(&home); + } +} diff --git a/crates/bashkit/src/lib.rs b/crates/bashkit/src/lib.rs index 0464c2b5e..2e151ed07 100644 --- a/crates/bashkit/src/lib.rs +++ b/crates/bashkit/src/lib.rs @@ -3415,52 +3415,6 @@ impl BashBuilder { result } - /// THREAT[TM-FS-013]: Host prefixes refused as `RealFs` mount targets unless - /// the embedder explicitly allowlists a narrower path under them. Mounting - /// any of these (or a child of them) exposes broad system / kernel / - /// secrets surface to sandboxed scripts via a single mount call. - #[cfg(feature = "realfs")] - const SENSITIVE_MOUNT_PATHS: &[&str] = &[ - // Kernel and pseudo-filesystems - "/proc", "/sys", "/dev", // System configuration / secret stores - "/etc", "/boot", // Privileged user directories (whole tree, not just secrets) - "/root", // User home roots — refuse the whole tree; embedder must narrow. - "/Users", "/home", // Runtime / sockets / pid dirs (host IPC surface) - "/run", "/var/run", // macOS canonicalized roots that mirror the above - "/private", - ]; - - /// THREAT[TM-FS-013]: Path components that always indicate a secret-bearing - /// directory regardless of where they live (typically inside a user home). - /// Any mount whose canonicalized path contains one of these as a component - /// is refused unless explicitly allowlisted. - #[cfg(feature = "realfs")] - const SENSITIVE_PATH_COMPONENTS: &[&str] = - &[".ssh", ".aws", ".kube", ".docker", ".gnupg", ".gcloud"]; - - /// Returns `true` if `host_path` (already canonicalized) is a sensitive - /// mount target — either the host root itself, a path under one of the - /// `SENSITIVE_MOUNT_PATHS` prefixes, or a path containing a known secret - /// directory component. - #[cfg(feature = "realfs")] - fn is_sensitive_mount_path(host_path: &Path) -> bool { - // THREAT[TM-FS-013]: A canonical host root has no parent. This covers - // `/` plus Windows drive, UNC-share, and device-namespace roots. - if host_path.parent().is_none() { - return true; - } - if Self::SENSITIVE_MOUNT_PATHS - .iter() - .any(|s| host_path.starts_with(Path::new(s))) - { - return true; - } - host_path.components().any(|c| { - let s = c.as_os_str(); - Self::SENSITIVE_PATH_COMPONENTS.iter().any(|sec| s == *sec) - }) - } - #[cfg(feature = "realfs")] #[allow(deprecated)] // BashBuilder::build is intentionally synchronous. fn apply_real_mounts( @@ -3518,7 +3472,7 @@ impl BashBuilder { // THREAT[TM-FS-013]: Sensitive paths are refused by default. They // can still be mounted by adding an explicit `allowed_mount_paths` // entry that covers them. - let is_sensitive = Self::is_sensitive_mount_path(&canonical_host); + let is_sensitive = is_sensitive_mount_path(&canonical_host); if let Some(allowlist) = &canonical_allowlist { if !allowlist @@ -3742,6 +3696,59 @@ impl BashBuilder { } } +/// THREAT[TM-FS-013]: Host prefixes refused as `RealFs` mount targets unless +/// the embedder explicitly allowlists a narrower path under them. Mounting +/// any of these (or a child of them) exposes broad system / kernel / +/// secrets surface to sandboxed scripts via a single mount call. +#[cfg(feature = "realfs")] +const SENSITIVE_MOUNT_PATHS: &[&str] = &[ + // Kernel and pseudo-filesystems + "/proc", "/sys", "/dev", // System configuration / secret stores + "/etc", "/boot", // Privileged user directories (whole tree, not just secrets) + "/root", // User home roots — refuse the whole tree; embedder must narrow. + "/Users", "/home", // Runtime / sockets / pid dirs (host IPC surface) + "/run", "/var/run", // macOS canonicalized roots that mirror the above + "/private", +]; + +/// THREAT[TM-FS-013]: Path components that always indicate a secret-bearing +/// directory regardless of where they live (typically inside a user home). +/// Any mount whose canonicalized path contains one of these as a component +/// is refused unless explicitly allowlisted. +#[cfg(feature = "realfs")] +const SENSITIVE_PATH_COMPONENTS: &[&str] = + &[".ssh", ".aws", ".kube", ".docker", ".gnupg", ".gcloud"]; + +/// Returns `true` if `host_path` (already canonicalized) is a sensitive +/// `RealFs` mount target: the host root itself, a path under one of the +/// privileged prefixes (`/etc`, `/home`, `/Users`, `/proc`, ...), or a path +/// containing a known secret-directory component (`.ssh`, `.aws`, ...). +/// +/// Embedders that implement their own mount policy (FFI layers, config-driven +/// setup) should call this before attaching a host directory so they inherit +/// the same denylist as `BashBuilder::mount_real_readonly_at`. +/// +// THREAT[TM-FS-013]: keep the denylist in one place; every mount path — +// builder, config-time, and runtime — must consult this function. +#[cfg(feature = "realfs")] +pub fn is_sensitive_mount_path(host_path: &Path) -> bool { + // THREAT[TM-FS-013]: A canonical host root has no parent. This covers + // `/` plus Windows drive, UNC-share, and device-namespace roots. + if host_path.parent().is_none() { + return true; + } + if SENSITIVE_MOUNT_PATHS + .iter() + .any(|s| host_path.starts_with(Path::new(s))) + { + return true; + } + host_path.components().any(|c| { + let s = c.as_os_str(); + SENSITIVE_PATH_COMPONENTS.iter().any(|sec| s == *sec) + }) +} + // ============================================================================= // Documentation Modules // ============================================================================= diff --git a/knowledge/runtimes/c-api.md b/knowledge/runtimes/c-api.md index 28144149d..c854344d6 100644 --- a/knowledge/runtimes/c-api.md +++ b/knowledge/runtimes/c-api.md @@ -46,6 +46,24 @@ data uses direct VFS functions rather than base64 configuration. Shell nonzero exit codes are successful ABI calls represented in `BashkitResult`; ABI status is reserved for boundary and execution failures. +Host-directory mounts are exposed additively (capability marker +`realfs-mounts`): config schema v1 gains optional `mounts` (`path`, `root`, +`writable`) and `allowed_mount_paths` keys, and `bashkit_mount` / +`bashkit_unmount` attach and detach host directories on a live session while +preserving shell state. + +THREAT[TM-FS-013]: the mount allowlist is mandatory — with no +`allowed_mount_paths` configured, every mount is rejected. Roots are +canonicalized (defusing `..` and symlinks, case-folded on Windows) before +the prefix check, and must also clear the shared sensitive-path denylist +(`bashkit::is_sensitive_mount_path`). A sensitive root (home trees, `/etc`, +`.ssh`, ...) is only mountable when an allowlist entry names it exactly: a +broad parent entry, such as the home directory itself, is not consent to +expose credential stores. This is deliberately stricter than the builder and +JS binding live-mount precedent, where any covering allowlist entry +overrides the denylist; the C ABI is the lowest-level, config-driven surface +and defaults to deny on credential paths. + ## Compatibility - ABI version is independent of the Bashkit package version. @@ -57,7 +75,7 @@ is reserved for boundary and execution failures. ## Deferred surface -Callbacks, custom builtins, streaming, async cancellation, host mounts, +Callbacks, custom builtins, streaming, async cancellation, transport hooks, snapshots, scripted tools, and external filesystem providers remain outside v1. They need explicit reentrancy, callback lifetime, and dynamic library unload rules before becoming permanent ABI. @@ -66,8 +84,12 @@ library unload rules before becoming permanent ABI. Rust contract tests cover success, shell failure, configuration, binary VFS content, invalid UTF-8, pre-validation script limits, null outputs, and version -rejection. The C example runner compiles the public header under C11 with -warnings denied and executes two programs against the built shared library. +rejection. Mount tests cover the read-only round trip, live mount/unmount with +shell state preserved, allowlist containment at config time and runtime, and +the sensitive-path rule (refused under a broad allowlist entry, allowed when +the entry names the root exactly). The C example runner compiles the public +header under C11 with warnings denied and executes two programs against the +built shared library. ## See also diff --git a/knowledge/security/threat-model.md b/knowledge/security/threat-model.md index 66f9c5983..1487f85c7 100644 --- a/knowledge/security/threat-model.md +++ b/knowledge/security/threat-model.md @@ -319,7 +319,7 @@ panicked. Resolved with `wrapping_*` ops, masked shift amounts, clamped exponent | TM-ESC-031 | Namespace source-root or policy escape | `..` selects a shorter mount, escapes a rebased source root, or bypasses a nested read-only mount | Normalize before longest-prefix selection; join only the stripped suffix; independently enforce both mutation endpoints | **MITIGATED** | | TM-ESC-033 | Windows host-path namespace escape | A direct VFS/RealFs path preserves a drive-relative, drive-absolute, UNC, or device prefix; `root.join(path)` discards the configured root, or a symlink/junction redirects an existing prefix | Shared POSIX VFS normalization discards host prefixes before backend joins; RealFs canonicalizes existing paths or the nearest existing ancestor and performs component-aware root checks; drive-relative symlink targets are rejected; Windows CI exercises alternate separators, case behavior, root-prefix siblings, reparse points, and missing descendants | **MITIGATED** | | TM-ESC-034 | Host mount resolver traversal | An embedder passes `/workspace/../secret` to `host_path_for`, and an unnormalized suffix escapes the selected host mount when joined | Normalize mount points and lookup paths with the shared POSIX VFS normalizer before longest-prefix selection and host joining | **MITIGATED** | -| TM-FS-013 | Permissive RealFs mount default | `mount_real_readonly_at("/", …)` exposes whole host without `allowed_mount_paths` | Allowlist-first: `/`, `/etc`, `/root`, `/Users`, `/home`, `/dev`, `/proc`, `/sys`, `/run`, `/var/run`, `/boot`, `/private`, and any path component matching `.ssh`, `.aws`, `.kube`, `.docker`, `.gnupg`, `.gcloud` are refused unless explicitly allowlisted | **MITIGATED** | +| TM-FS-013 | Permissive RealFs mount default | `mount_real_readonly_at("/", …)` exposes whole host without `allowed_mount_paths` | Allowlist-first: `/`, `/etc`, `/root`, `/Users`, `/home`, `/dev`, `/proc`, `/sys`, `/run`, `/var/run`, `/boot`, `/private`, and any path component matching `.ssh`, `.aws`, `.kube`, `.docker`, `.gnupg`, `.gcloud` are refused unless explicitly allowlisted. The C ABI enforces the same denylist (`bashkit::is_sensitive_mount_path`) for config-time and runtime mounts, where a sensitive root additionally requires an exact allowlist entry — a broad parent entry (e.g. the home directory) is not consent to expose `.ssh` etc. | **MITIGATED** | | TM-FS-014 | Partial filesystem mutation | Failed write/copy or copy-delete move corrupts/replaces a destination, duplicates a source, or consumes retained quota | `FileSystem` failure-atomicity contract; locked in-memory rename; MountableFs restores cross-mount destinations while NamespaceFs rejects cross-mount rename; RealFs stages and flushes sibling files before rename; failpoint and conformance regressions | **MITIGATED** | | TM-FS-015 | Partial archive extraction | A late traversal, malformed header, or size failure leaves earlier attacker-controlled files behind | Tar validates the complete archive and per-file limits before its first VFS mutation; conformance regression uses a valid entry followed by traversal | **MITIGATED** | | TM-FS-016 | yq in-place partial or destructive update | Parse, evaluation, serialization, or write failure truncates the source; predictable temporary names permit collisions | Complete evaluation and bounded serialization first; write a random sibling temporary file, preserve mode, and rename only after success; failpoint regressions cover allocation, all backend-write classes, chmod, rename, original-byte retention, and temporary cleanup | **MITIGATED** |