From c5265d1c90fde1d9dc443493468d561472a80890 Mon Sep 17 00:00:00 2001 From: tersePrompts Date: Thu, 3 Sep 2026 11:01:16 +0530 Subject: [PATCH 1/8] feat(capi): expose host-directory mounts over the C ABI Bring the C API to parity with the JS/Python bindings' real filesystem mounts, behind the same safety model: - config v1 gains optional mounts: [{path, root, writable}] and allowed_mount_paths; mounts are applied after build via the live Bash::mount API (RealFs wrapped in PosixFs), and readonly_filesystem continues to wrap mounted filesystems - new bashkit_mount / bashkit_unmount exports attach and detach host directories on a running session, preserving shell state - every mount root must resolve under an allowed_mount_paths prefix; roots are canonicalized before the prefix check so '..' segments and symlinks cannot escape, and comparison is case-folded on Windows - capabilities_json gains the realfs-mounts feature marker so embedders can feature-detect support - bashkit.def and include/bashkit.h extended additively; ABI v1 signatures are unchanged - three new ABI tests: read-only mounts (host file provably absent after denied writes), runtime mount/unmount round trip, and allowlist enforcement (missing allowlist and out-of-prefix roots rejected) --- crates/bashkit-capi/Cargo.toml | 2 +- crates/bashkit-capi/include/bashkit.def | 2 + crates/bashkit-capi/include/bashkit.h | 13 ++ crates/bashkit-capi/src/lib.rs | 179 +++++++++++++++++++++++- crates/bashkit-capi/tests/abi.rs | 169 ++++++++++++++++++++++ 5 files changed, 357 insertions(+), 8 deletions(-) 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..52538ddf1 100644 --- a/crates/bashkit-capi/include/bashkit.h +++ b/crates/bashkit-capi/include/bashkit.h @@ -91,6 +91,19 @@ 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. */ +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..e60203b35 100644 --- a/crates/bashkit-capi/src/lib.rs +++ b/crates/bashkit-capi/src/lib.rs @@ -5,15 +5,18 @@ // 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::{Arc, Mutex}; use std::time::Duration; use tokio::runtime::{Builder, Runtime}; @@ -23,7 +26,7 @@ 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"]}"#; const VERSION: &[u8] = env!("CARGO_PKG_VERSION").as_bytes(); #[repr(u32)] @@ -68,6 +71,7 @@ struct State { runtime: Runtime, bash: Bash, max_input_bytes: usize, + allowed_mount_paths: Arc<[String]>, } pub struct Bashkit { @@ -139,6 +143,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 +207,80 @@ fn make_runtime() -> Result { }) } -fn build_from_config(config: ConfigV1) -> Result<(Bash, usize), ApiFailure> { +// THREAT[TM-SBX-XXX]: host-directory mounts pierce the sandbox boundary, so +// every mount root must resolve under a configured `allowed_mount_paths` +// prefix. Canonicalization defuses `..` segments and symlinks before the +// prefix check; comparison is case-folded on Windows filesystems. +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(); + 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; + if exactly || under { + return Ok(canonical); + } + } + Err(ApiFailure::new( + BashkitStatus::InvalidConfig, + format!("mount root {root:?} is not under any allowed_mount_paths prefix"), + )) +} + +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 + }; + 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 +332,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 { @@ -375,6 +467,7 @@ pub unsafe extern "C" fn bashkit_create_default( runtime: make_runtime()?, bash: Bash::new(), max_input_bytes: ExecutionLimits::default().max_input_bytes, + allowed_mount_paths: Arc::from(Vec::::new()), }), }; *out_bash = Box::into_raw(Box::new(bash)); @@ -408,12 +501,13 @@ pub unsafe extern "C" fn bashkit_create_json( format!("invalid configuration: {error}"), ) })?; - let (bash, max_input_bytes) = build_from_config(config)?; + let (bash, max_input_bytes, allowed_mount_paths) = build_from_config(config)?; let bash = Bashkit { state: Mutex::new(State { runtime: make_runtime()?, bash, max_input_bytes, + allowed_mount_paths, }), }; *out_bash = Box::into_raw(Box::new(bash)); @@ -642,6 +736,77 @@ 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. +/// +/// # 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 + }; + 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..b9e09b669 100644 --- a/crates/bashkit-capi/tests/abi.rs +++ b/crates/bashkit-capi/tests/abi.rs @@ -345,3 +345,172 @@ 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); + } +} From bd4f2577fb06d7ec18d27a80e9765df11c7c0c2f Mon Sep 17 00:00:00 2001 From: tersePrompts Date: Sat, 5 Sep 2026 15:48:25 +0530 Subject: [PATCH 2/8] fix(capi): enforce the TM-FS-013 sensitive-path denylist for host mounts MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - expose bashkit::is_sensitive_mount_path as a public free function so embedder-side mount policies share one denylist with the builder - validate_mount_root now applies that denylist on the canonical root for both config-time mounts and bashkit_mount: 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 is not consent to expose credential stores - suppress the RealFs::new deprecation at both C-API call sites with the same justification as apply_real_mounts (synchronous FFI boundary) - replace the unregistered THREAT[TM-SBX-XXX] comment with TM-FS-013 and extend the threat-model row plus the C-API knowledge entry - regression tests: sensitive subdir refused at config time and runtime under a broad allowlist entry; exact-entry consent still mounts --- crates/bashkit-capi/include/bashkit.h | 4 +- crates/bashkit-capi/src/lib.rs | 47 ++++++++--- crates/bashkit-capi/tests/abi.rs | 112 ++++++++++++++++++++++++++ crates/bashkit/src/lib.rs | 101 ++++++++++++----------- knowledge/runtimes/c-api.md | 28 ++++++- knowledge/security/threat-model.md | 2 +- 6 files changed, 230 insertions(+), 64 deletions(-) diff --git a/crates/bashkit-capi/include/bashkit.h b/crates/bashkit-capi/include/bashkit.h index 52538ddf1..981598a3b 100644 --- a/crates/bashkit-capi/include/bashkit.h +++ b/crates/bashkit-capi/include/bashkit.h @@ -92,7 +92,9 @@ BASHKIT_API BashkitStatus bashkit_remove( BashkitError **out_error); /* Mounts require the `realfs-mounts` capability; host roots must resolve under - an `allowed_mount_paths` prefix from the session config. */ + 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, diff --git a/crates/bashkit-capi/src/lib.rs b/crates/bashkit-capi/src/lib.rs index e60203b35..432367958 100644 --- a/crates/bashkit-capi/src/lib.rs +++ b/crates/bashkit-capi/src/lib.rs @@ -207,10 +207,14 @@ fn make_runtime() -> Result { }) } -// THREAT[TM-SBX-XXX]: host-directory mounts pierce the sandbox boundary, so -// every mount root must resolve under a configured `allowed_mount_paths` -// prefix. Canonicalization defuses `..` segments and symlinks before the -// prefix check; comparison is case-folded on Windows filesystems. +// 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() { @@ -236,6 +240,8 @@ fn validate_mount_root(root: &str, allowed: &[String]) -> Result Result prefix.len() && candidate.starts_with(prefix) && candidate[prefix.len()] == std::path::MAIN_SEPARATOR as u8; - if exactly || under { - return Ok(canonical); - } + 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" + ), + )); } - Err(ApiFailure::new( - BashkitStatus::InvalidConfig, - format!("mount root {root:?} is not under any allowed_mount_paths prefix"), - )) + Ok(canonical) } fn apply_config_mounts( @@ -267,6 +284,8 @@ fn apply_config_mounts( } 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, @@ -738,7 +757,9 @@ 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. +/// 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. @@ -765,6 +786,8 @@ pub unsafe extern "C" fn bashkit_mount( } 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, diff --git a/crates/bashkit-capi/tests/abi.rs b/crates/bashkit-capi/tests/abi.rs index b9e09b669..49a4fac0f 100644 --- a/crates/bashkit-capi/tests/abi.rs +++ b/crates/bashkit-capi/tests/abi.rs @@ -514,3 +514,115 @@ fn mounts_require_allowlist_and_containment() { 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** | From e8c0928801f318cca4d21a6be3b347455272a9fb Mon Sep 17 00:00:00 2001 From: tersePrompts Date: Sat, 5 Sep 2026 18:17:57 +0530 Subject: [PATCH 3/8] feat(capi): expose execution cancellation over the C ABI Adds bashkit_cancel / bashkit_clear_cancel backed by the interpreter's shared cancellation token, kept outside the state mutex so cancel stays lock-free while bashkit_execute is blocked. A cancelled execution reports the new BASHKIT_CANCELLED (7) status, and the capabilities JSON gains a "cancellation" feature so bindings can feature-detect. Tests cancel a pending sleep: the request budget polls the token while the command is in flight, whereas loop-based scripts race the profile's command/iteration caps before the flag lands. Co-Authored-By: Claude Opus 5 (1M context) --- crates/bashkit-capi/include/bashkit.h | 8 +++ crates/bashkit-capi/src/lib.rs | 59 +++++++++++++++-- crates/bashkit-capi/tests/abi.rs | 91 +++++++++++++++++++++++++++ 3 files changed, 154 insertions(+), 4 deletions(-) diff --git a/crates/bashkit-capi/include/bashkit.h b/crates/bashkit-capi/include/bashkit.h index 981598a3b..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); diff --git a/crates/bashkit-capi/src/lib.rs b/crates/bashkit-capi/src/lib.rs index 432367958..c1792e860 100644 --- a/crates/bashkit-capi/src/lib.rs +++ b/crates/bashkit-capi/src/lib.rs @@ -16,6 +16,7 @@ use std::path::{Path, PathBuf}; use std::ptr; use std::slice; use std::str; +use std::sync::atomic::{AtomicBool, Ordering}; use std::sync::{Arc, Mutex}; use std::time::Duration; use tokio::runtime::{Builder, Runtime}; @@ -26,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","realfs-mounts"]}"#; +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)] @@ -39,6 +41,7 @@ pub enum BashkitStatus { ExecutionError = 4, IoError = 5, Unsupported = 6, + Cancelled = 7, InternalError = 255, } @@ -76,6 +79,10 @@ struct State { 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 { @@ -115,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()) @@ -481,13 +489,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(()) @@ -520,14 +531,16 @@ pub unsafe extern "C" fn bashkit_create_json( format!("invalid configuration: {error}"), ) })?; - let (bash, max_input_bytes, allowed_mount_paths) = 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(()) @@ -596,6 +609,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)] diff --git a/crates/bashkit-capi/tests/abi.rs b/crates/bashkit-capi/tests/abi.rs index 49a4fac0f..a1aa9ceb5 100644 --- a/crates/bashkit-capi/tests/abi.rs +++ b/crates/bashkit-capi/tests/abi.rs @@ -331,6 +331,97 @@ 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 + ); + + // Cancel a pending sleep: the request budget polls the token while the + // command is in flight, and loop-based scripts would instead race the + // profile's command/iteration caps before the flag lands. + // 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; + unsafe { + let mut result = ptr::null_mut(); + let mut thread_error = ptr::null_mut(); + let status = bashkit_execute( + bash, + bytes(b"sleep 30000"), + &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 { From efc83d57180acf06c275d00ebb9ffffe766ba883 Mon Sep 17 00:00:00 2001 From: tersePrompts Date: Sat, 5 Sep 2026 18:20:00 +0530 Subject: [PATCH 4/8] ci: restore ad-hoc native lib builds The re-landed mounts work dropped the workflow_dispatch lib builder that Bashkit4j packaging uses; upstream's c-api-binaries workflow only builds from release tags. Co-Authored-By: Claude Opus 5 (1M context) --- .github/workflows/build-native-libs.yml | 98 +++++++++++++++++++++++++ 1 file changed, 98 insertions(+) create mode 100644 .github/workflows/build-native-libs.yml diff --git a/.github/workflows/build-native-libs.yml b/.github/workflows/build-native-libs.yml new file mode 100644 index 000000000..c1893adf6 --- /dev/null +++ b/.github/workflows/build-native-libs.yml @@ -0,0 +1,98 @@ +name: Build native libs + +on: + workflow_dispatch: + +jobs: + test: + name: capi tests + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - uses: dtolnay/rust-toolchain@1.95.0 + + - uses: Swatinem/rust-cache@v2 + with: + key: capi-tests + + - name: Run capi ABI tests + run: cargo test --release -p bashkit-capi + + build: + name: ${{ matrix.artifact }} + runs-on: ${{ matrix.os }} + strategy: + fail-fast: false + matrix: + include: + - os: windows-latest + target: x86_64-pc-windows-msvc + artifact: windows-x86_64 + out: bashkit_capi.dll + lib: bashkit.dll + zig: false + - os: ubuntu-latest + target: x86_64-unknown-linux-gnu + artifact: linux-x86_64 + out: libbashkit_capi.so + lib: libbashkit.so + zig: false + - os: ubuntu-latest + target: aarch64-unknown-linux-gnu + artifact: linux-aarch64 + out: libbashkit_capi.so + lib: libbashkit.so + zig: true + - os: macos-latest + target: aarch64-apple-darwin + artifact: osx-aarch64 + out: libbashkit_capi.dylib + lib: libbashkit.dylib + zig: false + - os: macos-latest + target: x86_64-apple-darwin + artifact: osx-x86_64 + out: libbashkit_capi.dylib + lib: libbashkit.dylib + zig: false + steps: + - uses: actions/checkout@v4 + + - uses: dtolnay/rust-toolchain@1.95.0 + with: + targets: ${{ matrix.target }} + + - name: Install cargo-zigbuild + if: matrix.zig + uses: taiki-e/install-action@v2 + with: + tool: cargo-zigbuild + + - name: Set up Zig + if: matrix.zig + uses: mlugg/setup-zig@v2 + + - uses: Swatinem/rust-cache@v2 + with: + key: ${{ matrix.target }} + + - name: Build + run: | + if [ "${{ matrix.zig }}" = "true" ]; then + cargo zigbuild --release -p bashkit-capi --target ${{ matrix.target }} + else + cargo build --release -p bashkit-capi --target ${{ matrix.target }} + fi + shell: bash + + - name: Stage artifact + shell: bash + run: | + mkdir -p dist/${{ matrix.artifact }} + cp target/${{ matrix.target }}/release/${{ matrix.out }} dist/${{ matrix.artifact }}/${{ matrix.lib }} + + - uses: actions/upload-artifact@v4 + with: + name: bashkit-${{ matrix.artifact }} + path: dist/${{ matrix.artifact }} From bfe346af61dc164e199e3dbd51ca4cf0b5de0e3c Mon Sep 17 00:00:00 2001 From: tersePrompts Date: Sat, 5 Sep 2026 18:26:38 +0530 Subject: [PATCH 5/8] fix(capi): cancel a boundary-reaching script, not a pending sleep Cancellation only lands at command boundaries, so a cancelled sleep is not interrupted until the profile deadline ends it 30s later. Loop over 1-second sleeps instead: a boundary every second, negligible budget. Co-Authored-By: Claude Opus 5 (1M context) --- crates/bashkit-capi/tests/abi.rs | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/crates/bashkit-capi/tests/abi.rs b/crates/bashkit-capi/tests/abi.rs index a1aa9ceb5..65ffd4dce 100644 --- a/crates/bashkit-capi/tests/abi.rs +++ b/crates/bashkit-capi/tests/abi.rs @@ -349,9 +349,11 @@ fn cancellation_aborts_running_execution_and_stays_sticky_until_cleared() { BashkitStatus::Ok ); - // Cancel a pending sleep: the request budget polls the token while the - // command is in flight, and loop-based scripts would instead race the - // profile's command/iteration caps before the flag lands. + // 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; @@ -365,7 +367,7 @@ fn cancellation_aborts_running_execution_and_stays_sticky_until_cleared() { let mut thread_error = ptr::null_mut(); let status = bashkit_execute( bash, - bytes(b"sleep 30000"), + bytes(b"while true; do sleep 1; done"), &mut result, &mut thread_error, ); From 3e3caa60545844d41808fb2b151a5e72d140eb5c Mon Sep 17 00:00:00 2001 From: tersePrompts Date: Sat, 5 Sep 2026 19:09:07 +0530 Subject: [PATCH 6/8] chore(capi): apply rustfmt across the capi changes --- crates/bashkit-capi/src/lib.rs | 6 +----- crates/bashkit-capi/tests/abi.rs | 17 +++++++++++------ 2 files changed, 12 insertions(+), 11 deletions(-) diff --git a/crates/bashkit-capi/src/lib.rs b/crates/bashkit-capi/src/lib.rs index c1792e860..a6f7f9661 100644 --- a/crates/bashkit-capi/src/lib.rs +++ b/crates/bashkit-capi/src/lib.rs @@ -225,11 +225,7 @@ fn make_runtime() -> Result { // 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 - }; + let folded = if trimmed.is_empty() { path } else { trimmed }; if cfg!(windows) { folded.to_ascii_lowercase() } else { diff --git a/crates/bashkit-capi/tests/abi.rs b/crates/bashkit-capi/tests/abi.rs index 65ffd4dce..ca0d7bdf4 100644 --- a/crates/bashkit-capi/tests/abi.rs +++ b/crates/bashkit-capi/tests/abi.rs @@ -336,11 +336,13 @@ 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")); + assert!( + capabilities["features"] + .as_array() + .unwrap() + .iter() + .any(|feature| feature == "cancellation") + ); let mut bash = ptr::null_mut(); let mut error = ptr::null_mut(); @@ -713,7 +715,10 @@ fn sensitive_path_mounts_when_allowlisted_exactly() { 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"); + 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); From 894720ca9762f9101ba92ae1c5a20c1b7ef41a88 Mon Sep 17 00:00:00 2001 From: tersePrompts Date: Sat, 5 Sep 2026 22:34:23 +0530 Subject: [PATCH 7/8] fix(capi): drop the redundant unsafe block in the cancellation test The worker closure is lexically nested under the test's unsafe block, so its own unsafe block triggers clippy's unused_unsafe under --all-targets. --- crates/bashkit-capi/tests/abi.rs | 28 ++++++++++++++-------------- 1 file changed, 14 insertions(+), 14 deletions(-) diff --git a/crates/bashkit-capi/tests/abi.rs b/crates/bashkit-capi/tests/abi.rs index ca0d7bdf4..3fc31bfad 100644 --- a/crates/bashkit-capi/tests/abi.rs +++ b/crates/bashkit-capi/tests/abi.rs @@ -364,20 +364,20 @@ fn cancellation_aborts_running_execution_and_stays_sticky_until_cleared() { let writer = observed.clone(); let worker = std::thread::spawn(move || { let bash = handle as *mut Bashkit; - unsafe { - 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); - } + // 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); } }); From 99e0ea75e72c6edad30008acf62fe36074897bdf Mon Sep 17 00:00:00 2001 From: tersePrompts Date: Sat, 5 Sep 2026 22:34:23 +0530 Subject: [PATCH 8/8] chore(ci): drop the ad-hoc native lib build workflow from this PR Duplicates c-api-binaries.yml, uses unpinned action tags, and exists to drive the external Bashkit4j packaging pipeline; it needs its own justification (SHA pinning, permissions block, upstream-hosting decision). A copy is preserved on the fork's fork-main-backup branch. --- .github/workflows/build-native-libs.yml | 98 ------------------------- 1 file changed, 98 deletions(-) delete mode 100644 .github/workflows/build-native-libs.yml diff --git a/.github/workflows/build-native-libs.yml b/.github/workflows/build-native-libs.yml deleted file mode 100644 index c1893adf6..000000000 --- a/.github/workflows/build-native-libs.yml +++ /dev/null @@ -1,98 +0,0 @@ -name: Build native libs - -on: - workflow_dispatch: - -jobs: - test: - name: capi tests - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v4 - - - uses: dtolnay/rust-toolchain@1.95.0 - - - uses: Swatinem/rust-cache@v2 - with: - key: capi-tests - - - name: Run capi ABI tests - run: cargo test --release -p bashkit-capi - - build: - name: ${{ matrix.artifact }} - runs-on: ${{ matrix.os }} - strategy: - fail-fast: false - matrix: - include: - - os: windows-latest - target: x86_64-pc-windows-msvc - artifact: windows-x86_64 - out: bashkit_capi.dll - lib: bashkit.dll - zig: false - - os: ubuntu-latest - target: x86_64-unknown-linux-gnu - artifact: linux-x86_64 - out: libbashkit_capi.so - lib: libbashkit.so - zig: false - - os: ubuntu-latest - target: aarch64-unknown-linux-gnu - artifact: linux-aarch64 - out: libbashkit_capi.so - lib: libbashkit.so - zig: true - - os: macos-latest - target: aarch64-apple-darwin - artifact: osx-aarch64 - out: libbashkit_capi.dylib - lib: libbashkit.dylib - zig: false - - os: macos-latest - target: x86_64-apple-darwin - artifact: osx-x86_64 - out: libbashkit_capi.dylib - lib: libbashkit.dylib - zig: false - steps: - - uses: actions/checkout@v4 - - - uses: dtolnay/rust-toolchain@1.95.0 - with: - targets: ${{ matrix.target }} - - - name: Install cargo-zigbuild - if: matrix.zig - uses: taiki-e/install-action@v2 - with: - tool: cargo-zigbuild - - - name: Set up Zig - if: matrix.zig - uses: mlugg/setup-zig@v2 - - - uses: Swatinem/rust-cache@v2 - with: - key: ${{ matrix.target }} - - - name: Build - run: | - if [ "${{ matrix.zig }}" = "true" ]; then - cargo zigbuild --release -p bashkit-capi --target ${{ matrix.target }} - else - cargo build --release -p bashkit-capi --target ${{ matrix.target }} - fi - shell: bash - - - name: Stage artifact - shell: bash - run: | - mkdir -p dist/${{ matrix.artifact }} - cp target/${{ matrix.target }}/release/${{ matrix.out }} dist/${{ matrix.artifact }}/${{ matrix.lib }} - - - uses: actions/upload-artifact@v4 - with: - name: bashkit-${{ matrix.artifact }} - path: dist/${{ matrix.artifact }}