From ce9592830b19f211eb972bab079078895ec0c46b Mon Sep 17 00:00:00 2001 From: Mykhailo Chalyi Date: Fri, 16 Oct 2026 16:22:22 -0500 Subject: [PATCH 1/2] feat(capi): add revocable runtime host-function calls --- .github/workflows/ci.yml | 4 +- contracts/capability-parity.json | 18 +- crates/bashkit-capi/Cargo.toml | 2 +- .../bashkit-capi/examples/runtime_functions.c | 40 ++ crates/bashkit-capi/include/bashkit.h | 39 ++ crates/bashkit-capi/src/lib.rs | 121 ++++- crates/bashkit-capi/src/runtime_functions.rs | 255 +++++++++++ .../bashkit-capi/tests/runtime_functions.rs | 416 ++++++++++++++++++ crates/bashkit/docs/threat-model.md | 1 + crates/bashkit/src/runtime_value.rs | 22 +- docs/c-api.md | 42 +- scripts/run-c-api-examples.sh | 2 +- 12 files changed, 925 insertions(+), 37 deletions(-) create mode 100644 crates/bashkit-capi/examples/runtime_functions.c create mode 100644 crates/bashkit-capi/src/runtime_functions.rs create mode 100644 crates/bashkit-capi/tests/runtime_functions.rs diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index b11341f5b..b3decdf34 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -378,8 +378,8 @@ jobs: # The C ABI mount-root guard is Windows-conditional, so the Linux # workspace test run compiles it out. Nothing else runs bashkit-capi's # tests on Windows, which left them never executing anywhere. - - name: Test C ABI mount-root validation - run: cargo test -p bashkit-capi --lib + - name: Test C ABI mount-root validation and runtime callbacks + run: cargo test -p bashkit-capi --lib --test runtime_functions # The adoption shape this covers — bashkit as an agent's shell, with commands # bridged to host processes — exists to work on Windows without a real bash. diff --git a/contracts/capability-parity.json b/contracts/capability-parity.json index f6dd7c416..a3e5ba4b9 100644 --- a/contracts/capability-parity.json +++ b/contracts/capability-parity.json @@ -237,32 +237,32 @@ }, "c_abi": { "label": "C ABI", - "supported": ["execution_limits", "deadlines", "output_caps", "cwd", "env", "virtual_filesystem"], + "supported": ["execution_limits", "deadlines", "output_caps", "cwd", "env", "virtual_filesystem", "runtime_python", "runtime_typescript", "host_functions_python", "host_functions_typescript", "cancellation", "mounts"], "evidence": { "execution_limits": ["crates/bashkit-capi/tests/abi.rs#configures_environment_files_limits_and_final_environment"], "deadlines": ["crates/bashkit-capi/tests/abi.rs#configured_deadline_aborts_execution"], "output_caps": ["crates/bashkit-capi/tests/abi.rs#reports_output_truncation_flags"], "cwd": ["crates/bashkit-capi/tests/abi.rs#configures_environment_files_limits_and_final_environment"], "env": ["crates/bashkit-capi/tests/abi.rs#configures_environment_files_limits_and_final_environment"], - "virtual_filesystem": ["crates/bashkit-capi/tests/abi.rs#reads_and_writes_binary_vfs_content"] + "virtual_filesystem": ["crates/bashkit-capi/tests/abi.rs#reads_and_writes_binary_vfs_content"], + "runtime_python": ["crates/bashkit-capi/tests/runtime_functions.rs#c_runtime_callbacks_run_on_caller_thread_and_reject_reentry"], + "runtime_typescript": ["crates/bashkit-capi/tests/runtime_functions.rs#c_runtime_callbacks_run_on_caller_thread_and_reject_reentry"], + "host_functions_python": ["crates/bashkit-capi/tests/runtime_functions.rs#c_runtime_callbacks_accept_replies_from_other_threads"], + "host_functions_typescript": ["crates/bashkit-capi/tests/runtime_functions.rs#c_runtime_callbacks_accept_replies_from_other_threads"], + "cancellation": ["crates/bashkit-capi/tests/abi.rs#cancellation_aborts_running_execution_and_stays_sticky_until_cleared"], + "mounts": ["crates/bashkit-capi/tests/abi.rs#runtime_mount_and_unmount_round_trip"] }, "unsupported": { "runtime_env": "The C ABI exposes no post-construction configuration surface.", - "cancellation": "ABI v1 exposes synchronous execute with no cancellation handle.", "stdin": "ABI v1 execute accepts script bytes only.", "custom_builtins": "ABI v1 has no callback registration table.", "host_call_suspension": "ABI v1 has no asynchronous event and resume API.", "tool_callbacks": "ABI v1 has no tool-callback registration table.", "network_policy": "ABI v1 deliberately ships only git, jq, and VFS capabilities.", "transport_hooks": "ABI v1 has no host HTTP transport vtable.", - "mounts": "ABI v1 supports seeded and mutable VFS files, not filesystem mounts.", "snapshots": "ABI v1 has no snapshot functions.", - "runtime_python": "ABI v1 omits embedded language runtimes.", - "runtime_typescript": "ABI v1 omits embedded language runtimes.", "runtime_sqlite": "ABI v1 omits embedded language runtimes.", - "interactive_terminal": "Not exposed in the C ABI.", - "host_functions_python": "ABI v1 currently omits embedded language runtimes and runtime callback registration.", - "host_functions_typescript": "ABI v1 currently omits embedded language runtimes and runtime callback registration." + "interactive_terminal": "Not exposed in the C ABI." } } } diff --git a/crates/bashkit-capi/Cargo.toml b/crates/bashkit-capi/Cargo.toml index bc5c24e5b..256c5dff0 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", "realfs"] } +bashkit = { path = "../bashkit", default-features = false, features = ["git", "jq", "tzdata", "realfs", "python", "typescript"] } serde = { workspace = true } serde_json = { workspace = true } tokio = { workspace = true } diff --git a/crates/bashkit-capi/examples/runtime_functions.c b/crates/bashkit-capi/examples/runtime_functions.c new file mode 100644 index 000000000..ac9ddc33c --- /dev/null +++ b/crates/bashkit-capi/examples/runtime_functions.c @@ -0,0 +1,40 @@ +#include "bashkit.h" +#include +#include +#include + +static BashkitBytes bytes(const char *text) { + BashkitBytes value = {(const uint8_t *)text, strlen(text)}; + return value; +} + +static void lookup(void *data, BashkitRuntimeCall *call) { + unsigned *calls = (unsigned *)data; + BashkitBytes request = bashkit_runtime_call_request(call); + assert(request.len > 0); + assert(!bashkit_runtime_call_cancelled(call)); + ++*calls; + /* A real dispatcher parses request JSON and calls an application service. + It may instead transfer call to a worker, respond there, then free it. */ + assert(bashkit_runtime_call_respond_json(call, bytes("42")) == BASHKIT_OK); + bashkit_runtime_call_free(call); +} + +int main(void) { + const char *config = "{\"schema_version\":1,\"python\":true,\"typescript\":true," + "\"external_functions\":[\"lookup\"],\"typescript_external_functions\":[\"lookup\"]}"; + unsigned calls = 0; + Bashkit *bash = NULL; + BashkitError *error = NULL; + assert(bashkit_create_with_runtime_handler_v1(bytes(config), lookup, &calls, &bash, &error) == BASHKIT_OK); + BashkitResult *result = NULL; + assert(bashkit_execute(bash, bytes("python -c 'print(lookup())'; ts -c 'await lookup()'"), &result, &error) == BASHKIT_OK); + assert(bashkit_result_exit_code(result) == 0); + BashkitBytes output = bashkit_result_stdout(result); + assert(output.len == 6 && memcmp(output.ptr, "42\n42\n", 6) == 0); + assert(calls == 2); + fwrite(output.ptr, 1, output.len, stdout); + bashkit_result_free(result); + bashkit_free(bash); + return 0; +} diff --git a/crates/bashkit-capi/include/bashkit.h b/crates/bashkit-capi/include/bashkit.h index 8f547a860..788657b87 100644 --- a/crates/bashkit-capi/include/bashkit.h +++ b/crates/bashkit-capi/include/bashkit.h @@ -25,6 +25,7 @@ typedef struct bashkit Bashkit; typedef struct bashkit_result BashkitResult; typedef struct bashkit_error BashkitError; typedef struct bashkit_buffer BashkitBuffer; +typedef struct bashkit_runtime_call BashkitRuntimeCall; typedef struct { const uint8_t *ptr; @@ -40,6 +41,8 @@ typedef uint32_t BashkitStatus; #define BASHKIT_IO_ERROR 5u #define BASHKIT_UNSUPPORTED 6u #define BASHKIT_CANCELLED 7u +#define BASHKIT_BUSY 8u +#define BASHKIT_MAX_RUNTIME_VALUE_BYTES 10000000u #define BASHKIT_INTERNAL_ERROR 255u /* Static views returned by these functions remain valid for process lifetime. */ @@ -62,6 +65,42 @@ BASHKIT_API BashkitStatus bashkit_execute( BashkitResult **out_result, BashkitError **out_error); +/* Embedded runtime callbacks (capability runtime-host-functions). + Config schema v1 adds python/typescript booleans, external_functions and + typescript_external_functions arrays. Names require an enabled runtime and + this constructor's non-NULL handler. One dispatcher receives both languages. + + handler runs on bashkit_execute's caller thread and must return promptly. + It receives OWNERSHIP of call: respond/fail and free it once, or transfer it + to another thread and return. Request JSON contains language, name, args, + and kwargs for Python. JSON values are bounded to 64 nesting levels, finite + numbers and signed 64-bit integers. No arbitrary language objects. + + respond_json copies input; invalid input leaves the call unanswered. Exactly + one response is accepted. Late replies return CANCELLED. Poll cancelled to + stop host I/O after timeout/cancel; free an unanswered call to fail promptly. + Every call must be freed, including cancelled calls. View validity ends at + call_free. Respond/query may run concurrently, but never race call_free. + + Blocking handle APIs called from a handler return BUSY, including on another + handle (nested current-thread runtimes are forbidden). Callback-enabled + handles return BUSY for concurrent blocking operations. cancel/clear_cancel + and runtime_call_* stay available. Queue other work after handler returns. + + handler/user_data must remain valid through handle lifetime and executions. + Never unwind/longjmp through the callback or free a Bashkit during use. The + shared library must stay loaded until all handles/results/calls are freed. */ +typedef void (*BashkitRuntimeHandlerV1)(void *user_data, BashkitRuntimeCall *call); +BASHKIT_API BashkitStatus bashkit_create_with_runtime_handler_v1( + BashkitBytes config_json, BashkitRuntimeHandlerV1 handler, void *user_data, + Bashkit **out_bash, BashkitError **out_error); +BASHKIT_API BashkitBytes bashkit_runtime_call_request(const BashkitRuntimeCall *call); +BASHKIT_API uint32_t bashkit_runtime_call_cancelled(const BashkitRuntimeCall *call); +BASHKIT_API BashkitStatus bashkit_runtime_call_respond_json( + BashkitRuntimeCall *call, BashkitBytes value); +BASHKIT_API BashkitStatus bashkit_runtime_call_fail(BashkitRuntimeCall *call); +BASHKIT_API void bashkit_runtime_call_free(BashkitRuntimeCall *call); + /* 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 diff --git a/crates/bashkit-capi/src/lib.rs b/crates/bashkit-capi/src/lib.rs index e28fa6085..98ddd70f1 100644 --- a/crates/bashkit-capi/src/lib.rs +++ b/crates/bashkit-capi/src/lib.rs @@ -5,6 +5,9 @@ // boundary and reports a generic error so unwinding cannot enter the foreign // caller and the returned error does not include panic details. +mod runtime_functions; +pub use runtime_functions::*; + use bashkit::{ Bash, Error as BashError, ExecutionLimits, FileSystem, LimitExceeded, PosixFs, RealFs, RealFsMode, @@ -28,7 +31,7 @@ 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","cancellation"]}"#; + br#"{"abi":1,"features":["git","jq","vfs","realfs-mounts","cancellation","python","typescript","runtime-host-functions"]}"#; const VERSION: &[u8] = env!("CARGO_PKG_VERSION").as_bytes(); #[repr(u32)] @@ -42,6 +45,7 @@ pub enum BashkitStatus { IoError = 5, Unsupported = 6, Cancelled = 7, + Busy = 8, InternalError = 255, } @@ -83,6 +87,7 @@ pub struct Bashkit { // lock: bashkit_execute holds that lock while blocked, and cancellation // must stay reachable — and lock-free — from any thread. cancelled: Arc, + runtime_callbacks: bool, } pub struct BashkitResult { @@ -134,6 +139,14 @@ impl ApiFailure { struct ConfigV1 { schema_version: u32, #[serde(default)] + python: bool, + #[serde(default)] + typescript: bool, + #[serde(default)] + external_functions: Vec, + #[serde(default)] + typescript_external_functions: Vec, + #[serde(default)] profile: ProfileConfigV1, #[serde(default)] cwd: Option, @@ -320,7 +333,10 @@ fn apply_config_mounts( Ok(()) } -fn build_from_config(config: ConfigV1) -> Result<(Bash, usize, Arc<[String]>), ApiFailure> { +fn build_from_config( + config: ConfigV1, + handler: Option, +) -> Result<(Bash, usize, Arc<[String]>), ApiFailure> { if config.schema_version != 1 { return Err(ApiFailure::new( BashkitStatus::InvalidConfig, @@ -353,6 +369,8 @@ fn build_from_config(config: ConfigV1) -> Result<(Bash, usize, Arc<[String]>), A limits.capture_final_env = config.capture_final_env; let max_input_bytes = limits.max_input_bytes; + let python_limits = profile.python_limits().clone(); + let typescript_limits = profile.typescript_limits().clone(); let mut builder = Bash::builder() .profile(profile) .limits(limits) @@ -372,6 +390,45 @@ fn build_from_config(config: ConfigV1) -> Result<(Bash, usize, Arc<[String]>), A for (path, content) in config.files { builder = builder.mount_text(path, content); } + if (!config.external_functions.is_empty() || !config.typescript_external_functions.is_empty()) + && handler.is_none() + { + return Err(ApiFailure::new( + BashkitStatus::InvalidConfig, + "external functions require a runtime handler", + )); + } + if (!config.external_functions.is_empty() && !config.python) + || (!config.typescript_external_functions.is_empty() && !config.typescript) + { + return Err(ApiFailure::new( + BashkitStatus::InvalidConfig, + "external functions require their runtime to be enabled", + )); + } + if config.python { + builder = builder.env("BASHKIT_ALLOW_INPROCESS_PYTHON", "1"); + builder = if config.external_functions.is_empty() { + builder.python() + } else { + builder.python_with_external_handler( + python_limits, + config.external_functions, + runtime_functions::python_handler(handler.expect("validated runtime handler")), + ) + }; + } + if config.typescript { + builder = if config.typescript_external_functions.is_empty() { + builder.typescript() + } else { + builder.typescript_with_external_handler( + typescript_limits, + config.typescript_external_functions, + runtime_functions::typescript_handler(handler.expect("validated runtime handler")), + ) + }; + } let mut bash = builder.build(); let allowed: Arc<[String]> = Arc::from(config.allowed_mount_paths); apply_config_mounts(&mut bash, &config.mounts, &allowed)?; @@ -512,6 +569,7 @@ pub unsafe extern "C" fn bashkit_create_default( allowed_mount_paths: Arc::from(Vec::::new()), }), cancelled, + runtime_callbacks: false, }; *out_bash = Box::into_raw(Box::new(bash)); Ok(()) @@ -527,6 +585,15 @@ pub unsafe extern "C" fn bashkit_create_json( config_json: BashkitBytes, out_bash: *mut *mut Bashkit, out_error: *mut *mut BashkitError, +) -> BashkitStatus { + unsafe { create_json(config_json, None, out_bash, out_error) } +} + +unsafe fn create_json( + config_json: BashkitBytes, + handler: Option, + out_bash: *mut *mut Bashkit, + out_error: *mut *mut BashkitError, ) -> BashkitStatus { unsafe { ffi_boundary(out_error, || { @@ -544,7 +611,8 @@ pub unsafe extern "C" fn bashkit_create_json( format!("invalid configuration: {error}"), ) })?; - let (engine, max_input_bytes, allowed_mount_paths) = build_from_config(config)?; + let (engine, max_input_bytes, allowed_mount_paths) = + build_from_config(config, handler)?; let cancelled = engine.cancellation_token(); let bash = Bashkit { state: Mutex::new(State { @@ -554,6 +622,7 @@ pub unsafe extern "C" fn bashkit_create_json( allowed_mount_paths, }), cancelled, + runtime_callbacks: handler.is_some(), }; *out_bash = Box::into_raw(Box::new(bash)); Ok(()) @@ -588,9 +657,7 @@ pub unsafe extern "C" fn bashkit_execute( ffi_boundary(out_error, || { let out_result = output_slot(out_result, "out_result")?; let bash = handle(bash)?; - let mut state = bash.state.lock().map_err(|_| { - ApiFailure::new(BashkitStatus::InternalError, "bash instance is unavailable") - })?; + let mut state = lock_state(bash)?; if script.len > state.max_input_bytes { return Err(ApiFailure::from_bash(BashError::ResourceLimit( LimitExceeded::InputTooLarge(script.len, state.max_input_bytes), @@ -721,14 +788,38 @@ pub unsafe extern "C" fn bashkit_result_free(result: *mut BashkitResult) { })); } +fn lock_state(bash: &Bashkit) -> Result, ApiFailure> { + if runtime_functions::in_callback() { + return Err(ApiFailure::new( + BashkitStatus::Busy, + "blocking APIs cannot be called from a runtime handler", + )); + } + if bash.runtime_callbacks { + match bash.state.try_lock() { + Ok(state) => Ok(state), + Err(std::sync::TryLockError::WouldBlock) => Err(ApiFailure::new( + BashkitStatus::Busy, + "bash instance is busy", + )), + Err(std::sync::TryLockError::Poisoned(_)) => Err(ApiFailure::new( + BashkitStatus::InternalError, + "bash instance is unavailable", + )), + } + } else { + bash.state.lock().map_err(|_| { + ApiFailure::new(BashkitStatus::InternalError, "bash instance is unavailable") + }) + } +} + unsafe fn with_filesystem( bash: *mut Bashkit, operation: impl FnOnce(&Runtime, &dyn FileSystem) -> Result<(), BashError>, ) -> Result<(), ApiFailure> { let bash = unsafe { handle(bash)? }; - let state = bash.state.lock().map_err(|_| { - ApiFailure::new(BashkitStatus::InternalError, "bash instance is unavailable") - })?; + let state = lock_state(bash)?; operation(&state.runtime, state.bash.fs().as_ref()).map_err(ApiFailure::from_bash) } @@ -768,9 +859,7 @@ pub unsafe extern "C" fn bashkit_read_file( let out_buffer = output_slot(out_buffer, "out_buffer")?; let path = input_str(path, "path")?; let bash = handle(bash)?; - let state = bash.state.lock().map_err(|_| { - ApiFailure::new(BashkitStatus::InternalError, "bash instance is unavailable") - })?; + let state = lock_state(bash)?; let bytes = state .runtime .block_on(state.bash.fs().read_file(Path::new(path))) @@ -841,9 +930,7 @@ pub unsafe extern "C" fn bashkit_mount( 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 state = lock_state(bash)?; let root = validate_mount_root(host_root, &state.allowed_mount_paths)?; let mode = if writable != 0 { RealFsMode::ReadWrite @@ -883,9 +970,7 @@ pub unsafe extern "C" fn bashkit_unmount( 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") - })?; + let state = lock_state(bash)?; state .bash .unmount(Path::new(vfs_path)) diff --git a/crates/bashkit-capi/src/runtime_functions.rs b/crates/bashkit-capi/src/runtime_functions.rs new file mode 100644 index 000000000..059f4be06 --- /dev/null +++ b/crates/bashkit-capi/src/runtime_functions.rs @@ -0,0 +1,255 @@ +// Runtime calls are library-owned and may be answered from any thread. The +// callback itself runs on the execute caller's thread and must return promptly; +// no Rust future, allocator ownership or thread-affine runtime crosses the ABI. +// THREAT[TM-INT-012]: execution leases revoke late replies; owned calls and +// bounded, validated JSON avoid borrowed storage and stale caller context. +use super::*; +use bashkit::{ExecutionBudget, ExecutionCapability, RuntimeCallContext, runtime_value}; +use serde_json::Value; +use std::cell::Cell; +use std::ffi::c_void; +use tokio::sync::oneshot; + +pub const BASHKIT_MAX_RUNTIME_VALUE_BYTES: usize = 10_000_000; +pub type BashkitRuntimeHandlerV1 = unsafe extern "C" fn(*mut c_void, *mut BashkitRuntimeCall); + +#[derive(Clone, Copy)] +pub(super) struct Registration { + handler: BashkitRuntimeHandlerV1, + user_data: *mut c_void, +} +// SAFETY: the C contract requires handler/user_data to remain valid until all +// executions finish and permits execution from any host thread, serially per handle. +unsafe impl Send for Registration {} +unsafe impl Sync for Registration {} + +type Reply = Result; +type ReplyReceiver = oneshot::Receiver; + +pub struct BashkitRuntimeCall { + request: Vec, + reply: Mutex>>, + lease: ExecutionCapability<()>, + budget: Option>, +} + +thread_local! { static IN_CALLBACK: Cell = const { Cell::new(false) }; } +pub(super) fn in_callback() -> bool { + IN_CALLBACK.get() +} +struct CallbackScope(bool); +impl Drop for CallbackScope { + fn drop(&mut self) { + IN_CALLBACK.set(self.0); + } +} + +/// Construct with optional Python/TypeScript names and one host dispatcher. +/// +/// # Safety +/// Same pointer rules as `bashkit_create_json`. The callback and user_data must +/// remain valid until the Bashkit handle and all executions are finished. The +/// callback must not unwind, longjmp or block indefinitely. It receives ownership +/// of each call; answer/free it immediately or transfer it to another thread. +#[unsafe(no_mangle)] +pub unsafe extern "C" fn bashkit_create_with_runtime_handler_v1( + config_json: BashkitBytes, + handler: Option, + user_data: *mut c_void, + out_bash: *mut *mut Bashkit, + out_error: *mut *mut BashkitError, +) -> BashkitStatus { + unsafe { + let Some(handler) = handler else { + return ffi_boundary(out_error, || { + let _ = output_slot(out_bash, "out_bash")?; + Err(ApiFailure::invalid_argument("handler must not be NULL")) + }); + }; + create_json( + config_json, + Some(Registration { handler, user_data }), + out_bash, + out_error, + ) + } +} + +/// Borrow the JSON request: language, name, args, kwargs (Python only). +/// # Safety +/// `call` must be null or live. The view lasts until call_free. +#[unsafe(no_mangle)] +pub unsafe extern "C" fn bashkit_runtime_call_request( + call: *const BashkitRuntimeCall, +) -> BashkitBytes { + catch_unwind(AssertUnwindSafe(|| unsafe { + call.as_ref() + .map_or(BashkitBytes::EMPTY, |c| BashkitBytes::borrowed(&c.request)) + })) + .unwrap_or(BashkitBytes::EMPTY) +} + +/// Poll revocation, cancellation and the execution deadline from any thread. +/// # Safety +/// `call` must be null or live and cannot race call_free. +#[unsafe(no_mangle)] +pub unsafe extern "C" fn bashkit_runtime_call_cancelled(call: *const BashkitRuntimeCall) -> u32 { + catch_unwind(AssertUnwindSafe(|| unsafe { + let Some(call) = call.as_ref() else { + return 1; + }; + u32::from( + call.lease.try_with(|_| ()).is_err() + || call + .budget + .as_ref() + .is_some_and(|b| !matches!(b.try_with(ExecutionBudget::check), Ok(Ok(())))), + ) + })) + .unwrap_or(1) +} + +unsafe fn reply(call: *mut BashkitRuntimeCall, value: Reply) -> Result<(), ApiFailure> { + let call = unsafe { call.as_ref() } + .ok_or_else(|| ApiFailure::invalid_argument("call must not be NULL"))?; + if unsafe { bashkit_runtime_call_cancelled(call) } != 0 { + return Err(ApiFailure::new( + BashkitStatus::Cancelled, + "runtime call is no longer active", + )); + } + let sender = call + .reply + .lock() + .map_err(|_| ApiFailure::new(BashkitStatus::InternalError, "runtime reply unavailable"))? + .take() + .ok_or_else(|| ApiFailure::invalid_argument("runtime call already answered"))?; + sender + .send(value) + .map_err(|_| ApiFailure::new(BashkitStatus::Cancelled, "runtime call is no longer active")) +} + +/// Copy a JSON result into the library. Returns CANCELLED for a late reply. +/// Invalid JSON leaves the call unanswered, permitting a corrected response. +/// # Safety +/// `call` must be live; value bytes readable for this call. Cannot race call_free. +#[unsafe(no_mangle)] +pub unsafe extern "C" fn bashkit_runtime_call_respond_json( + call: *mut BashkitRuntimeCall, + value: BashkitBytes, +) -> BashkitStatus { + unsafe { + ffi_boundary(ptr::null_mut(), || { + if value.len > BASHKIT_MAX_RUNTIME_VALUE_BYTES { + return Err(ApiFailure::invalid_argument( + "runtime value exceeds 10000000 bytes", + )); + } + let value: Value = serde_json::from_slice(input_bytes(value, "value")?) + .map_err(|_| ApiFailure::invalid_argument("runtime response must be JSON"))?; + runtime_value::validate(&value) + .map_err(|e| ApiFailure::invalid_argument(e.to_string()))?; + reply(call, Ok(value)) + }) + } +} + +/// Report a generic script-visible failure; host diagnostics remain in the host. +/// # Safety +/// `call` must be live and cannot race call_free. +#[unsafe(no_mangle)] +pub unsafe extern "C" fn bashkit_runtime_call_fail(call: *mut BashkitRuntimeCall) -> BashkitStatus { + unsafe { + ffi_boundary(ptr::null_mut(), || { + reply(call, Err("host function failed".into())) + }) + } +} + +/// Release the owned call. An unanswered call fails the script promptly. +/// # Safety +/// `call` must be null or library-owned, released once, with no concurrent use. +#[unsafe(no_mangle)] +pub unsafe extern "C" fn bashkit_runtime_call_free(call: *mut BashkitRuntimeCall) { + let _ = catch_unwind(AssertUnwindSafe(|| { + if !call.is_null() { + unsafe { + drop(Box::from_raw(call)); + } + } + })); +} + +fn invoke( + registration: Registration, + request: Value, +) -> Result<(ExecutionCapability<()>, ReplyReceiver), String> { + let context = RuntimeCallContext::current(); + let lease = context + .execution_capability(()) + .ok_or("no active execution")?; + let (sender, receiver) = oneshot::channel(); + let request = request.to_string().into_bytes(); + if request.len() > BASHKIT_MAX_RUNTIME_VALUE_BYTES { + return Err("runtime request exceeds 10000000 bytes".into()); + } + let call = Box::new(BashkitRuntimeCall { + request, + reply: Mutex::new(Some(sender)), + lease: lease.clone(), + budget: context.execution_budget(), + }); + let _scope = CallbackScope(IN_CALLBACK.replace(true)); + // SAFETY: registration is host-provided and obeys the lifetime/no-unwind + // contract. Ownership of this call transfers to that callback exactly once. + unsafe { + (registration.handler)(registration.user_data, Box::into_raw(call)); + } + Ok((lease, receiver)) +} + +async fn dispatch(registration: Registration, request: Value) -> Result { + let (lease, receiver) = invoke(registration, request)?; + lease + .run(receiver) + .await + .map_err(|e| e.to_string())? + .map_err(|_| "host call released without a response".to_string())? +} + +pub(super) fn python_handler(registration: Registration) -> bashkit::PythonExternalFnHandler { + Arc::new(move |name, args, kwargs| { + Box::pin(async move { + let result = async { + let args = runtime_value::encode_python(&bashkit::MontyObject::Tuple(args)).map_err(|e| e.to_string())?; + let kwargs = runtime_value::encode_python(&bashkit::MontyObject::dict(kwargs)).map_err(|e| e.to_string())?; + let value = dispatch(registration, serde_json::json!({"language":"python", "name":name, "args":args, "kwargs":kwargs})).await?; + runtime_value::decode_python(&value).map_err(|e| e.to_string()) + }.await; + match result { + Ok(value) => bashkit::ExtFunctionResult::Return(value), + Err(message) => bashkit::ExtFunctionResult::Error(bashkit::MontyException::new( + bashkit::ExcType::RuntimeError, + Some(message), + )), + } + }) + }) +} + +pub(super) fn typescript_handler( + registration: Registration, +) -> bashkit::TypeScriptExternalFnHandler { + Arc::new(move |name, args| { + Box::pin(async move { + let args = runtime_value::encode_typescript(&bashkit::ZapcodeValue::Array(args)) + .map_err(|e| e.to_string())?; + let value = dispatch( + registration, + serde_json::json!({"language":"typescript", "name":name, "args":args}), + ) + .await?; + runtime_value::decode_typescript(&value).map_err(|e| e.to_string()) + }) + }) +} diff --git a/crates/bashkit-capi/tests/runtime_functions.rs b/crates/bashkit-capi/tests/runtime_functions.rs new file mode 100644 index 000000000..1702a6289 --- /dev/null +++ b/crates/bashkit-capi/tests/runtime_functions.rs @@ -0,0 +1,416 @@ +use bashkit_capi::*; +use std::{ + ffi::c_void, + ptr, + sync::{ + Mutex, + atomic::{AtomicPtr, AtomicU32, Ordering}, + }, + thread, + time::Duration, +}; + +fn bytes(s: &[u8]) -> BashkitBytes { + BashkitBytes { + ptr: s.as_ptr(), + len: s.len(), + } +} +unsafe fn view(v: BashkitBytes) -> Vec { + if v.len == 0 { + Vec::new() + } else { + unsafe { std::slice::from_raw_parts(v.ptr, v.len) }.to_vec() + } +} +struct Host { + mode: u8, + requests: Mutex>, + pending: Mutex>, + workers: Mutex>>, + bash: AtomicPtr, + reentry: AtomicU32, + response: AtomicU32, +} +impl Host { + fn new(mode: u8) -> Self { + Self { + mode, + requests: Mutex::new(Vec::new()), + pending: Mutex::new(Vec::new()), + workers: Mutex::new(Vec::new()), + bash: AtomicPtr::new(ptr::null_mut()), + reentry: AtomicU32::new(99), + response: AtomicU32::new(99), + } + } + fn join(&self) { + for worker in self.workers.lock().unwrap().drain(..) { + worker.join().unwrap(); + } + } +} +unsafe extern "C" fn handler(data: *mut c_void, call: *mut BashkitRuntimeCall) { + unsafe { + let host = &*data.cast::(); + let request = serde_json::from_slice(&view(bashkit_runtime_call_request(call))).unwrap(); + host.requests + .lock() + .unwrap() + .push((thread::current().id(), request)); + match host.mode { + 1 => { + // Transfer ownership to an asynchronous foreign worker. + let owned = call as usize; + host.workers.lock().unwrap().push(thread::spawn(move || { + thread::sleep(Duration::from_millis(5)); + let call = owned as *mut BashkitRuntimeCall; + bashkit_runtime_call_respond_json(call, bytes(b"42")); + bashkit_runtime_call_free(call); + })); + } + 2 => host.pending.lock().unwrap().push(call as usize), + 3 => { + bashkit_runtime_call_fail(call); + bashkit_runtime_call_free(call); + } + 4 => bashkit_runtime_call_free(call), + _ => { + let mut result = ptr::null_mut(); + let mut error = ptr::null_mut(); + let status = bashkit_execute( + host.bash.load(Ordering::SeqCst), + bytes(b"echo forbidden"), + &mut result, + &mut error, + ); + host.reentry.store(status as u32, Ordering::SeqCst); + bashkit_error_free(error); + let invalid = bashkit_runtime_call_respond_json(call, bytes(b"invalid JSON")); + host.response.store(invalid as u32, Ordering::SeqCst); + bashkit_runtime_call_respond_json(call, bytes(b"42")); + bashkit_runtime_call_free(call); + } + } + } +} +unsafe fn create(host: &Host, timeout: u64) -> *mut Bashkit { + let config = format!( + r#"{{"schema_version":1,"python":true,"typescript":true,"external_functions":["lookup"],"typescript_external_functions":["lookup"],"limits":{{"timeout_ms":{timeout}}}}}"# + ); + let mut bash = ptr::null_mut(); + let mut error = ptr::null_mut(); + assert_eq!( + unsafe { + bashkit_create_with_runtime_handler_v1( + bytes(config.as_bytes()), + Some(handler), + ptr::from_ref(host).cast_mut().cast(), + &mut bash, + &mut error, + ) + }, + BashkitStatus::Ok + ); + host.bash.store(bash, Ordering::SeqCst); + bash +} +unsafe fn execute(bash: *mut Bashkit, script: &str) -> (BashkitStatus, i32, Vec) { + unsafe { + let mut result = ptr::null_mut(); + let mut error = ptr::null_mut(); + let status = bashkit_execute(bash, bytes(script.as_bytes()), &mut result, &mut error); + let exit = bashkit_result_exit_code(result); + let stdout = view(bashkit_result_stdout(result)); + bashkit_result_free(result); + bashkit_error_free(error); + (status, exit, stdout) + } +} + +#[test] +fn c_runtime_callbacks_run_on_caller_thread_and_reject_reentry() { + unsafe { + let host = Host::new(0); + let bash = create(&host, 5000); + for script in [ + "python -c 'print(lookup(21, multiplier=2))'", + "ts -c 'await lookup(21, 2)'", + ] { + assert_eq!( + execute(bash, script), + (BashkitStatus::Ok, 0, b"42\n".to_vec()) + ); + } + let requests = host.requests.lock().unwrap(); + assert!(requests.iter().all(|(id, _)| *id == thread::current().id())); + assert_eq!(requests[0].1["kwargs"]["multiplier"], 2); + assert_eq!(requests[1].1["args"], serde_json::json!([21, 2])); + assert_eq!( + host.reentry.load(Ordering::SeqCst), + BashkitStatus::Busy as u32 + ); + assert_eq!( + host.response.load(Ordering::SeqCst), + BashkitStatus::InvalidArgument as u32 + ); + bashkit_free(bash); + } +} + +#[test] +fn c_runtime_callbacks_accept_replies_from_other_threads() { + let workers: Vec<_> = (0..8) + .map(|_| { + thread::spawn(|| unsafe { + let host = Host::new(1); + let bash = create(&host, 5000); + for _ in 0..5 { + assert_eq!( + execute(bash, "python -c 'print(lookup())'; ts -c 'await lookup()'"), + (BashkitStatus::Ok, 0, b"42\n42\n".to_vec()) + ); + } + host.join(); + bashkit_free(bash); + }) + }) + .collect(); + for worker in workers { + worker.join().unwrap(); + } +} + +#[test] +fn c_runtime_calls_are_revoked_after_deadline_and_handle_can_be_reused() { + unsafe { + for command in ["python -c 'lookup()'", "ts -c 'await lookup()'"] { + let host = Host::new(2); + let bash = create(&host, 200); + let (status, exit, _) = execute(bash, command); + assert!(status != BashkitStatus::Ok || exit != 0); + let call = host + .pending + .lock() + .unwrap() + .pop() + .expect("handler must start") as *mut BashkitRuntimeCall; + assert_eq!(bashkit_runtime_call_cancelled(call), 1); + assert_eq!( + bashkit_runtime_call_respond_json(call, bytes(b"42")), + BashkitStatus::Cancelled + ); + bashkit_runtime_call_free(call); + assert_eq!( + execute(bash, "echo reusable"), + (BashkitStatus::Ok, 0, b"reusable\n".to_vec()) + ); + bashkit_free(bash); + } + } +} + +#[test] +fn c_runtime_host_failures_and_abandoned_calls_fail_scripts() { + unsafe { + for mode in [3, 4] { + let host = Host::new(mode); + let bash = create(&host, 5000); + for script in ["python -c 'lookup()'", "ts -c 'await lookup()'"] { + let (status, exit, _) = execute(bash, script); + assert!(status != BashkitStatus::Ok || exit != 0); + } + bashkit_free(bash); + } + } +} + +#[test] +fn c_runtime_cancellation_revokes_pending_calls_from_another_thread() { + unsafe { + let host = Host::new(2); + let bash = create(&host, 5000); + let owned = bash as usize; + thread::scope(|scope| { + let execution = scope.spawn(|| execute(owned as *mut Bashkit, "python -c 'lookup()'")); + for _ in 0..1000 { + if !host.pending.lock().unwrap().is_empty() { + break; + } + thread::sleep(Duration::from_millis(1)); + } + let call = host + .pending + .lock() + .unwrap() + .pop() + .expect("host callback started") as *mut BashkitRuntimeCall; + assert_eq!(bashkit_cancel(bash), BashkitStatus::Ok); + let (status, exit, _) = execution.join().unwrap(); + assert!(status != BashkitStatus::Ok || exit != 0); + assert_eq!(bashkit_runtime_call_cancelled(call), 1); + assert_eq!( + bashkit_runtime_call_respond_json(call, bytes(b"42")), + BashkitStatus::Cancelled + ); + bashkit_runtime_call_free(call); + }); + assert_eq!(bashkit_clear_cancel(bash), BashkitStatus::Ok); + assert_eq!( + execute(bash, "echo reusable"), + (BashkitStatus::Ok, 0, b"reusable\n".to_vec()) + ); + bashkit_free(bash); + } +} + +#[test] +fn c_runtime_construction_and_json_boundaries_fail_closed() { + unsafe { + let host = Host::new(0); + for config in [ + r#"{"schema_version":1,"external_functions":["lookup"]}"#, + r#"{"schema_version":1,"python":true,"external_functions":["lookup"]}"#, + ] { + 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()); + bashkit_error_free(error); + } + let bash = create(&host, 5000); + assert_eq!(execute(bash, "python -c 'lookup(bytes([1]))'").1, 1); + assert_eq!(execute(bash, "ts -c 'await lookup(() => 42)'").1, 1); + assert!(host.requests.lock().unwrap().is_empty()); + bashkit_free(bash); + assert_eq!(bashkit_runtime_call_cancelled(ptr::null()), 1); + assert_eq!(bashkit_runtime_call_request(ptr::null()).len, 0); + bashkit_runtime_call_free(ptr::null_mut()); + assert_eq!( + bashkit_runtime_call_respond_json( + ptr::null_mut(), + BashkitBytes { + ptr: std::ptr::dangling::(), + len: BASHKIT_MAX_RUNTIME_VALUE_BYTES + 1 + } + ), + BashkitStatus::InvalidArgument + ); + } +} + +#[test] +fn c_runtime_idle_handle_migrates_between_caller_threads() { + unsafe { + let host = Host::new(0); + let bash = create(&host, 5000); + for _ in 0..5 { + let owned = bash as usize; + let id = thread::spawn(move || { + assert_eq!( + execute( + owned as *mut Bashkit, + "python -c 'print(lookup())'; ts -c 'await lookup()'" + ), + (BashkitStatus::Ok, 0, b"42\n42\n".to_vec()) + ); + thread::current().id() + }) + .join() + .unwrap(); + assert_eq!(host.requests.lock().unwrap().last().unwrap().0, id); + } + bashkit_free(bash); + } +} + +#[test] +fn c_runtime_owned_call_remains_safe_after_handle_is_freed() { + unsafe { + let host = Host::new(2); + let bash = create(&host, 100); + let (status, exit, _) = execute(bash, "ts -c 'await lookup()'"); + assert!(status != BashkitStatus::Ok || exit != 0); + let call = host.pending.lock().unwrap().pop().unwrap() as *mut BashkitRuntimeCall; + bashkit_free(bash); + assert!(!view(bashkit_runtime_call_request(call)).is_empty()); + assert_eq!(bashkit_runtime_call_cancelled(call), 1); + assert_eq!( + bashkit_runtime_call_respond_json(call, bytes(b"42")), + BashkitStatus::Cancelled + ); + bashkit_runtime_call_free(call); + } +} + +#[test] +fn c_runtime_flags_are_opt_in_and_missing_dispatchers_are_rejected() { + unsafe { + let mut bash = std::ptr::dangling_mut(); + let mut error = ptr::null_mut(); + assert_eq!( + bashkit_create_with_runtime_handler_v1( + bytes(br#"{"schema_version":1}"#), + None, + ptr::null_mut(), + &mut bash, + &mut error + ), + BashkitStatus::InvalidArgument + ); + assert!(bash.is_null()); + bashkit_error_free(error); + error = ptr::null_mut(); + assert_eq!( + bashkit_create_json( + bytes(br#"{"schema_version":1,"python":true,"typescript":true}"#), + &mut bash, + &mut error + ), + BashkitStatus::Ok + ); + assert_eq!( + execute(bash, "python -c 'print(21*2)'; ts -c '21*2'"), + (BashkitStatus::Ok, 0, b"42\n42\n".to_vec()) + ); + bashkit_free(bash); + assert_eq!( + bashkit_create_default(&mut bash, &mut error), + BashkitStatus::Ok + ); + assert_eq!(execute(bash, "python -c 'print(42)'").1, 127); + assert_eq!(execute(bash, "ts -c '42'").1, 127); + bashkit_free(bash); + } +} + +#[test] +fn c_runtime_callbacks_preserve_hardened_python_memory_limit() { + unsafe { + let host = Host::new(0); + let mut bash = ptr::null_mut(); + let mut error = ptr::null_mut(); + let config = br#"{"schema_version":1,"profile":"hardened","python":true,"external_functions":["lookup"]}"#; + assert_eq!( + bashkit_create_with_runtime_handler_v1( + bytes(config), + Some(handler), + ptr::from_ref(&host).cast_mut().cast(), + &mut bash, + &mut error + ), + BashkitStatus::Ok + ); + host.bash.store(bash, Ordering::SeqCst); + let (status, exit, _) = execute(bash, "python -c 'x=[0]*2500000; print(lookup())'"); + assert!( + status != BashkitStatus::Ok || exit != 0, + "hardened VM must reject a list larger than 16 MB" + ); + assert!(host.requests.lock().unwrap().is_empty()); + bashkit_free(bash); + } +} diff --git a/crates/bashkit/docs/threat-model.md b/crates/bashkit/docs/threat-model.md index aa9bad741..c08032147 100644 --- a/crates/bashkit/docs/threat-model.md +++ b/crates/bashkit/docs/threat-model.md @@ -609,6 +609,7 @@ All unexpected errors are caught and converted to safe, human-readable messages. | Binary output bypasses resource limits (TM-INT-009) | Invalid UTF-8 exceeds configured caps when counted as text | Byte-native accumulation and callbacks truncate by exact byte length | **MITIGATED** | | Invalid UTF-8 line input panics (TM-INT-010) | `read`/`select` apply a lossy-text newline offset to raw pipeline bytes | Line consumers find and split newlines in the authoritative byte buffer | **MITIGATED** | | Command resolver panic (TM-INT-011) | Unresolved command name triggers a panic in embedder resolver code | `catch_unwind` wrapper and sanitized shell error | **MITIGATED** | +| C runtime callback lifetime (TM-INT-012) | A delayed reply targets a later request, or callback reentry blocks a locked handle | Owned calls, copied bounded JSON and one-shot revocable execution leases reject stale replies; callback-enabled handles return BUSY for concurrent blocking access. Hosts must preserve code/user-data lifetime and free calls without racing use | **MITIGATED** | **Panic Recovery:** diff --git a/crates/bashkit/src/runtime_value.rs b/crates/bashkit/src/runtime_value.rs index 53d105b56..e320cf613 100644 --- a/crates/bashkit/src/runtime_value.rs +++ b/crates/bashkit/src/runtime_value.rs @@ -29,6 +29,18 @@ pub fn validate(value: &Value) -> Result<()> { Value::Number(n) if n.is_u64() && n.as_i64().is_none() => Err(Error::Execution( "host integers must fit in signed 64 bits".into(), )), + // serde_json falls back to f64 for integer literals beyond i64/u64. + // Reject those rounded boundary values instead of changing an + // oversized integer into a different floating-point value. + Value::Number(n) + if n.is_f64() + && n.as_f64() + .is_some_and(|v| v >= i64::MAX as f64 || v <= i64::MIN as f64) => + { + Err(Error::Execution( + "host numbers exceed signed 64-bit range".into(), + )) + } Value::Array(values) => values.iter().try_for_each(|v| walk(v, depth + 1)), Value::Object(values) => values.values().try_for_each(|v| walk(v, depth + 1)), _ => Ok(()), @@ -39,9 +51,11 @@ pub fn validate(value: &Value) -> Result<()> { #[cfg(any(feature = "python", feature = "typescript"))] fn number(value: f64) -> Result { - serde_json::Number::from_f64(value) + let value = serde_json::Number::from_f64(value) .map(Value::Number) - .ok_or_else(|| Error::Execution("host numbers must be finite".into())) + .ok_or_else(|| Error::Execution("host numbers must be finite".into()))?; + validate(&value)?; + Ok(value) } /// Encode a Python value as JSON, rejecting unsupported objects. @@ -187,6 +201,10 @@ mod tests { assert!(validate(&nested).is_err()); assert!(validate(&Value::from(u64::MAX)).is_err()); assert!(validate(&Value::from(i64::MAX)).is_ok()); + assert!(validate(&Value::from(i64::MIN)).is_ok()); + for source in ["-9223372036854775809", "18446744073709551616", "1e30"] { + assert!(validate(&serde_json::from_str::(source).unwrap()).is_err()); + } } #[cfg(feature = "python")] diff --git a/docs/c-api.md b/docs/c-api.md index f52d0eacc..2038e0799 100644 --- a/docs/c-api.md +++ b/docs/c-api.md @@ -141,10 +141,11 @@ free a handle while another thread is using it. ## Initial exclusions -ABI v1 does not expose callbacks, custom builtins, streaming output, async -execution, host filesystem mounts, transport hooks, snapshots, or the existing -cross-addon filesystem-handle ABI. These require separate callback, reentrancy, -and library-unload contracts. +ABI v1 does not expose custom builtins, streaming output, an asynchronous shell +execution handle, transport hooks, snapshots, or the cross-addon filesystem-handle +ABI. These remaining surfaces need explicit ownership and callback contracts. +Runtime host functions can complete asynchronously through owned call handles; +host filesystem mounts and cancellation are also supported. ## Compatibility @@ -174,3 +175,36 @@ major. - [Get started](start.md), choose another Bashkit package or runtime. - [Sandbox configuration and limits](configuration.md), common sandbox controls. - [Security](security.md), trust boundaries and resource protections. + +## Embedded runtime host functions + +Opt into Python and/or TypeScript through config schema v1. Use +`bashkit_create_with_runtime_handler_v1` when supplying `external_functions` +(Python) or `typescript_external_functions`. The callback receives one owned +`BashkitRuntimeCall` with a JSON request containing `language`, `name`, `args`, +and Python `kwargs`. Respond with `bashkit_runtime_call_respond_json`, then call +`bashkit_runtime_call_free`. A response is a JSON value, such as `42` or an object. + +The dispatcher runs on the execution caller's thread. For asynchronous I/O, +transfer the call to a worker and return immediately; the worker responds and +frees it. Poll `bashkit_runtime_call_cancelled` to stop I/O after a timeout or +cancellation. Late responses return `BASHKIT_CANCELLED`. An unanswered call fails +the script when freed. Use `bashkit_runtime_call_fail` for a generic script error; +log sensitive host diagnostics separately. + +JSON values support null, booleans, finite numbers, signed 64-bit integers, +strings, arrays and string-key objects, with depth <=64 and <=10 MB of JSON. +Numbers outside the signed 64-bit range are rejected, including JSON integer +literals that a parser would otherwise round into floating-point values. +Bytes, functions and other language objects fail explicitly. + +Blocking APIs invoked from the dispatcher return `BASHKIT_BUSY`. Callback-enabled +handles also reject concurrent blocking operations. Queue other handle operations +after the callback returns; cancellation and runtime-call APIs remain available. +Never free a handle during execution, race a call's destructor, unwind through C, +or unload the shared library while any owned object remains. Keep handler code +and user data alive through handle lifetime and all executions. Host callbacks +must enforce application authorization and observe cancellation themselves. + +See the runnable [runtime callback example](../crates/bashkit-capi/examples/runtime_functions.c) +and the public header for complete ownership rules. diff --git a/scripts/run-c-api-examples.sh b/scripts/run-c-api-examples.sh index 5753c2469..f16ad6ade 100755 --- a/scripts/run-c-api-examples.sh +++ b/scripts/run-c-api-examples.sh @@ -26,7 +26,7 @@ case "$(uname -s)" in ;; esac -for example in hello files; do +for example in hello files runtime_functions; do cc -std=c11 -Wall -Wextra -Werror \ -I "$repo_root/crates/bashkit-capi/include" \ "$example_dir/$example.c" \ From 31e9bef6ddda9c70e3b1e64dbd336c0633f80f34 Mon Sep 17 00:00:00 2001 From: Mykhailo Chalyi Date: Fri, 16 Oct 2026 16:22:29 -0500 Subject: [PATCH 2/2] chore(knowledge): document C runtime call ownership and parity --- knowledge/log.md | 2 ++ knowledge/operations/limitations.md | 2 +- knowledge/runtimes/c-api.md | 43 +++++++++++++++++++++++++-- knowledge/security/threat-model.md | 1 + knowledge/status/capability-parity.md | 18 ++++------- 5 files changed, 51 insertions(+), 15 deletions(-) diff --git a/knowledge/log.md b/knowledge/log.md index 85e7169d6..dc1fcae2d 100644 --- a/knowledge/log.md +++ b/knowledge/log.md @@ -2,6 +2,8 @@ ## 2026-10-16 +* **C runtime parity**: Added optional Python/TypeScript runtime flags and an additive ABI v1 host dispatcher. Owned, revocable runtime calls accept immediate or cross-thread replies; blocking callback reentry fails with BUSY. Added parallel-instance, caller-thread, cancellation/deadline/reuse and real C header/library tests. Corrected parity evidence for existing C cancellation and mounts. See [C API](runtimes/c-api.md). + * **Node runtime parity**: Added Python and TypeScript named host callbacks to Bash/BashTool and JS agent adapters, with per-execution caller context, cooperative abort, reentry guards and worker/platform tests. Private runtime ownership also handles forced worker teardown without dropping a Tokio runtime inside an async task. Added explicit runtime host-function capability rows and a shared bounded JSON codec for foreign bindings. See [Node Package](runtimes/node-package.md). * **Python / agent integrations**: Added Python and TypeScript host function callbacks to `BashTool`, LangChain and deepagents. Reuse per-execution callback sessions for caller-loop affinity, ContextVars and cancellation; sync execution uses the dedicated private worker loop. Reentry is callback-context-specific so independent requests queue safely. Private-loop timeout cancellation now targets its own work item; native result deliveries drain before interpreter finalization to prevent a shutdown race. Added FastAPI, real Jupyter, alternate-loop and cross-thread regression coverage plus macOS/Windows callback CI. See [Python Package](runtimes/python-package.md). diff --git a/knowledge/operations/limitations.md b/knowledge/operations/limitations.md index 7c49eac3b..178d84280 100644 --- a/knowledge/operations/limitations.md +++ b/knowledge/operations/limitations.md @@ -66,7 +66,7 @@ execution model. Evidence is a threat-model ID, a test, or `stance` | L-WASM-001 | **Removed:** JS-host timers now drive `sleep`, builtin `timeout`, execution limits, and tool `timeoutMs` | `gloo-timers` bridges the host event-loop clock without threads or cross-origin isolation | [Browser Package](../runtimes/browser-package.md) | | L-WASM-002 | Browser build: `executeSync()` cannot run async custom builtins (fails with a clear message); use `execute()` | Single-threaded event loop can't settle a JS `Promise` without yielding | `crates/bashkit-wasm/__test__/bashkit-wasm.test.mjs` | | L-WASM-003 | Browser build: background jobs (`cmd &`) run synchronously and `awk` file redirects drive the VFS inline; no work runs on a separate thread | `wasm32-unknown-unknown` is single-threaded, `std::thread::spawn`/`tokio::spawn` are unavailable; safe because the in-memory VFS never suspends | `crates/bashkit-wasm/__test__/bashkit-wasm.test.mjs` | -| L-CAPI-001 | C ABI v1 excludes callbacks, custom builtins, streaming, async cancellation, host mounts, transport hooks, snapshots, scripted tools, and external filesystem providers | These require explicit reentrancy, callback lifetime, and dynamic-library unload contracts | [C API](../runtimes/c-api.md), stance | +| L-CAPI-001 | C ABI v1 excludes custom builtins, streaming, transport hooks, snapshots, scripted tools, and external filesystem providers | Runtime host functions, cancellation and host mounts are supported; the remaining surfaces need explicit ownership and callback contracts | [C API](../runtimes/c-api.md), stance | | L-STREAM-001 | Shell words, variables, command substitution, script source, text-oriented builtins, and JSON tool responses cannot represent arbitrary bytes. Command substitution removes NUL; other text boundaries decode invalid UTF-8 with replacement. Use `StreamData`, binding byte fields, redirects, or byte-oriented builtins for exact data | Bash variables and the parser are text domains; JSON strings are Unicode | `byte_stream_tests`, [Architecture](../foundations/architecture.md) | ### Design Rationale diff --git a/knowledge/runtimes/c-api.md b/knowledge/runtimes/c-api.md index c366e0240..717de5bc3 100644 --- a/knowledge/runtimes/c-api.md +++ b/knowledge/runtimes/c-api.md @@ -37,7 +37,7 @@ oversized byte view before dereferencing it or validating UTF-8. This preserves the core input resource limit at the untrusted native boundary. Execution is synchronous in v1. Each handle owns a current-thread Tokio runtime -and mutex-protected `Bash`; same-handle calls serialize, while distinct handles +and mutex-protected `Bash`; ordinary same-handle calls serialize, while distinct handles can execute concurrently. Callers own lifetime synchronization and cannot race destruction with use. @@ -75,9 +75,48 @@ and defaults to deny on credential paths. - Additive functions and status values are allowed. - Breaking changes require a parallel ABI major. +## Embedded runtime host functions + +Config schema v1 adds explicit `python`/`typescript` flags and +`external_functions`/`typescript_external_functions` lists. Runtimes remain off +by default, and callback registration preserves the selected profile's VM limits. Names require `bashkit_create_with_runtime_handler_v1`, whose single +dispatcher receives JSON language/name/args/kwargs requests. Boundary values use +the shared bounded core codec (depth 64, signed i64, finite numbers, string-key +objects, arrays and scalars); request/response JSON is capped at 10 MB. + +Each callback runs on the `bashkit_execute` caller's thread and receives ownership +of a `BashkitRuntimeCall`. It can reply immediately or transfer the call to an I/O +worker and return promptly. Responses copy JSON into library-owned storage. One +response is accepted; invalid JSON leaves it unanswered, freeing without a +response fails the script, and a revoked response returns `BASHKIT_CANCELLED`. +Host workers poll `bashkit_runtime_call_cancelled` and must free every call, +including cancelled calls. Revocable execution leases prevent a late reply from +reaching another execution or extending its deadline. + +Callback-enabled handles reject concurrent blocking calls with `BASHKIT_BUSY`. +All blocking C APIs also reject calls on the callback's thread, even to another +handle, because a nested current-thread Tokio runtime is invalid. Cancellation +and call response/query/free remain available. Ordinary v1 handles retain their +existing serialization. Callers may move an idle handle between threads, and +distinct handles execute concurrently. + +The host owns handler/user_data lifetime through handle lifetime and all active +executions. Foreign callbacks must not unwind, longjmp, or block indefinitely; +Bashkit cannot preempt arbitrary C code. Freeing a handle during execution is +forbidden. Never unload the shared library until every handle, result, error, +buffer and runtime call is freed. Runtime call access may be concurrent, but +must never race its destructor. Host service authorization and I/O limits remain +the application's responsibility. + +`tests/runtime_functions.rs` exercises immediate and cross-thread replies, +parallel instances, caller affinity, reentry, deadlines, cancellation/reuse, +invalid values, abandonment and host failure. `examples/runtime_functions.c` +compiles against the public header with warnings denied and runs against the +actual shared library. + ## Deferred surface -Callbacks, custom builtins, streaming, async cancellation, +Custom builtins, streaming, 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. diff --git a/knowledge/security/threat-model.md b/knowledge/security/threat-model.md index 5ee71b09b..8669e20e0 100644 --- a/knowledge/security/threat-model.md +++ b/knowledge/security/threat-model.md @@ -1043,6 +1043,7 @@ to the shared filesystem. | TM-INT-009 | Binary output bypasses resource limits | Byte-native output or callbacks count characters instead of bytes, allowing invalid UTF-8 to exceed configured caps | Accumulation and streaming callbacks truncate `StreamData` by exact byte length before delivery; regression tests assert binary output-limit behavior | **MITIGATED** | | TM-INT-010 | Invalid UTF-8 line input panics the interpreter | `read` or `select` finds a newline in lossy text, then applies that expanded replacement-character offset to raw pipeline bytes | Line consumers find and split newlines in the authoritative byte buffer, then lossily decode only the consumed shell-text line; binary regressions cover both paths | **MITIGATED** | | TM-INT-011 | Command resolver panic crash | Attacker-controlled unresolved command names trigger a panic in embedder resolver code | `catch_unwind` around `CommandResolver::resolve`; sanitized shell error | **MITIGATED** | +| TM-INT-012 | C runtime callbacks outlive execution or reenter a locked interpreter | Delayed foreign replies target a later request; callbacks block on their own handle or expose borrowed storage | Owned call handles, copied bounded JSON, one-shot responses and revocable execution leases reject late replies; blocking callback reentry and concurrent calls on callback-enabled handles return BUSY. Hosts must keep code/user_data alive, free each call once, and never race destructors or unwind through C. Tests: `crates/bashkit-capi/tests/runtime_functions.rs` | **MITIGATED** | **Current Risk**: LOW. Implementation: `interpreter/mod.rs` wraps every builtin call in `AssertUnwindSafe(..).catch_unwind()` (TM-INT-001) and converts panics to the sanitized diff --git a/knowledge/status/capability-parity.md b/knowledge/status/capability-parity.md index 3902901f9..2651fcc30 100644 --- a/knowledge/status/capability-parity.md +++ b/knowledge/status/capability-parity.md @@ -22,7 +22,7 @@ A dash means the feature is intentionally unsupported and has a recorded reason | Capability | Rust BashBuilder | Rust BashTool | Rust ScriptedTool | CLI | Python | NAPI JavaScript | Browser WASM | C ABI | |---|---|---|---|---|---|---|---|---| | Host-configurable execution resource limits | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | -| Host-triggered cancellation of in-flight execution | ✅ | — | — | — | ✅ | ✅ | ✅ | — | +| Host-triggered cancellation of in-flight execution | ✅ | — | — | — | ✅ | ✅ | ✅ | ✅ | | Execution deadlines or timeouts | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | | Bounded stdout and stderr with truncation reporting | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | | Initial virtual working directory | ✅ | ✅ | — | — | ✅ | ✅ | ✅ | ✅ | @@ -35,14 +35,14 @@ A dash means the feature is intentionally unsupported and has a recorded reason | Explicit outbound-network policy | ✅ | ✅ | — | ✅ | ✅ | ✅ | — | — | | Host-provided HTTP transport or egress hook | ✅ | ✅ | — | — | — | — | — | — | | Host-visible virtual filesystem state | ✅ | ✅ | — | ✅ | ✅ | ✅ | ✅ | ✅ | -| Filesystem mounts beyond seeded virtual files | ✅ | ✅ | — | ✅ | ✅ | ✅ | — | — | +| Filesystem mounts beyond seeded virtual files | ✅ | ✅ | — | ✅ | ✅ | ✅ | — | ✅ | | Snapshot and restore of interpreter state | ✅ | — | — | — | ✅ | ✅ | ✅ | — | -| Explicit embedded Python opt-in | ✅ | ✅ | — | ✅ | ✅ | ✅ | — | — | -| Explicit embedded TypeScript opt-in | ✅ | ✅ | — | — | ✅ | ✅ | — | — | +| Explicit embedded Python opt-in | ✅ | ✅ | — | ✅ | ✅ | ✅ | — | ✅ | +| Explicit embedded TypeScript opt-in | ✅ | ✅ | — | — | ✅ | ✅ | — | ✅ | | Explicit embedded SQLite opt-in | ✅ | ✅ | — | ✅ | ✅ | ✅ | — | — | | In-process interactive terminal session (keystrokes in, screen text and per-command transcript out; vi, less) | ✅ | — | — | — | ✅ | ✅ | — | — | -| Named host callbacks invoked from embedded Python | ✅ | ✅ | — | — | ✅ | ✅ | — | — | -| Named host callbacks invoked from embedded TypeScript | ✅ | ✅ | — | — | ✅ | ✅ | — | — | +| Named host callbacks invoked from embedded Python | ✅ | ✅ | — | — | ✅ | ✅ | — | ✅ | +| Named host callbacks invoked from embedded TypeScript | ✅ | ✅ | — | — | ✅ | ✅ | — | ✅ | ## Intentional exclusions @@ -126,21 +126,15 @@ A dash means the feature is intentionally unsupported and has a recorded reason ### C ABI - `runtime_env`: The C ABI exposes no post-construction configuration surface. -- `cancellation`: ABI v1 exposes synchronous execute with no cancellation handle. - `stdin`: ABI v1 execute accepts script bytes only. - `custom_builtins`: ABI v1 has no callback registration table. - `host_call_suspension`: ABI v1 has no asynchronous event and resume API. - `tool_callbacks`: ABI v1 has no tool-callback registration table. - `network_policy`: ABI v1 deliberately ships only git, jq, and VFS capabilities. - `transport_hooks`: ABI v1 has no host HTTP transport vtable. -- `mounts`: ABI v1 supports seeded and mutable VFS files, not filesystem mounts. - `snapshots`: ABI v1 has no snapshot functions. -- `runtime_python`: ABI v1 omits embedded language runtimes. -- `runtime_typescript`: ABI v1 omits embedded language runtimes. - `runtime_sqlite`: ABI v1 omits embedded language runtimes. - `interactive_terminal`: Not exposed in the C ABI. -- `host_functions_python`: ABI v1 currently omits embedded language runtimes and runtime callback registration. -- `host_functions_typescript`: ABI v1 currently omits embedded language runtimes and runtime callback registration. ## Enforcement