From cbbf3837e632272bb108134ac79a3cd40c55f8da Mon Sep 17 00:00:00 2001 From: Mykhailo Chalyi Date: Tue, 6 Oct 2026 18:51:33 +0000 Subject: [PATCH 1/4] refactor(core): add public extension points for out-of-core tool layers Step 1 of moving ToolDef/ToolRegistry/ScriptedTool out of the core crate. Core gains generic public APIs; the registry now uses only those. - RuntimeCallContext: request extensions, budget, lease and deadline for Python/TypeScript host functions (replaces the registry's task-local) - Python / TypeScriptExtension::with_external_handler_and_prelude public; Python re-exported - ExecutionCapability::derive, ExecutionBudget::run public - BuiltinContext::run_budgeted public, remaining_deadline() added - BashBuilder::code_mode() replaces crate-private logic_only() - ExecutionPlan / SubCommand re-exported - Tool contract helpers and ToolExecution::new public Claude-Session: https://claude.ai/code/session_01LSQwMLncQmLPG9CYatpbVk --- crates/bashkit/src/builtins/mod.rs | 20 +- crates/bashkit/src/builtins/python.rs | 13 +- crates/bashkit/src/builtins/typescript.rs | 13 +- crates/bashkit/src/execution_capability.rs | 6 +- crates/bashkit/src/lib.rs | 21 +- crates/bashkit/src/limits.rs | 24 +-- crates/bashkit/src/runtime_call.rs | 80 ++++++++ crates/bashkit/src/scripted_tool/execute.rs | 2 +- crates/bashkit/src/tool.rs | 25 ++- crates/bashkit/src/tool_registry.rs | 96 ++++----- .../integration/core_extension_point_tests.rs | 191 ++++++++++++++++++ crates/bashkit/tests/integration/main.rs | 1 + knowledge/foundations/execution-profiles.md | 4 +- .../scripted-tool-orchestration.md | 10 +- knowledge/integrations/tool-contract.md | 5 + knowledge/log.md | 4 + knowledge/runtimes/python-builtin.md | 2 + knowledge/runtimes/zapcode-runtime.md | 6 + knowledge/security/threat-model.md | 2 +- 19 files changed, 423 insertions(+), 102 deletions(-) create mode 100644 crates/bashkit/src/runtime_call.rs create mode 100644 crates/bashkit/tests/integration/core_extension_point_tests.rs diff --git a/crates/bashkit/src/builtins/mod.rs b/crates/bashkit/src/builtins/mod.rs index c27a1a79c..2037379be 100644 --- a/crates/bashkit/src/builtins/mod.rs +++ b/crates/bashkit/src/builtins/mod.rs @@ -782,8 +782,10 @@ impl<'a> Context<'a> { } /// Run async boundary work under this request's cancellation/lifecycle gate. - #[cfg(any(feature = "http_client", feature = "scripted_tool"))] - pub(crate) async fn run_budgeted(&self, future: F) -> Result + /// + /// Host callbacks invoked from a builtin should run through this so a + /// cancelled or over-budget request drops the callback future. + pub async fn run_budgeted(&self, future: F) -> Result where F: std::future::Future, { @@ -808,6 +810,20 @@ impl<'a> Context<'a> { .and_then(|shell| shell.execution_extensions.get::()) } + /// Remaining wall-clock budget of the current `exec*` call, if limited. + pub fn remaining_deadline(&self) -> Option { + self.execution_extension::()? + .try_with(ExecutionDeadline::remaining) + .ok() + } + + #[cfg(any(feature = "python", feature = "typescript"))] + pub(crate) fn execution_extensions(&self) -> Option> { + self.shell + .as_ref() + .map(|shell| shell.execution_extensions.clone()) + } + /// Bind a host value to the current execution lease. pub fn execution_capability(&self, value: T) -> Option> where diff --git a/crates/bashkit/src/builtins/python.rs b/crates/bashkit/src/builtins/python.rs index daefe4221..72f673778 100644 --- a/crates/bashkit/src/builtins/python.rs +++ b/crates/bashkit/src/builtins/python.rs @@ -321,7 +321,11 @@ impl Python { self } - pub(crate) fn with_external_handler_and_prelude( + /// Like [`Self::with_external_handler`], plus a Python prelude evaluated + /// before user code (e.g. classes that route `tools.get_user(...)` to an + /// external function). Lets embedders expose namespaced host APIs + /// without core knowing their shape. + pub fn with_external_handler_and_prelude( mut self, names: Vec, handler: PythonExternalFnHandler, @@ -576,11 +580,8 @@ impl Builtin for Python { .execution_budget() .and_then(|budget| budget.try_with(Clone::clone).ok()); let future = run_python(&code, &filename, ctx.fs.clone(), ctx.cwd, ctx.env, policy); - #[cfg(feature = "scripted_tool")] - let future = crate::tool_registry::scope_runtime_call( - crate::tool_registry::ToolCallScope::from_context(&ctx), - future, - ); + // Host functions read the request through RuntimeCallContext::current(). + let future = crate::runtime_call::scope(&ctx, future); match execution_budget { Some(budget) => budget.run(future).await?, None => future.await, diff --git a/crates/bashkit/src/builtins/typescript.rs b/crates/bashkit/src/builtins/typescript.rs index 05a63635a..55a979745 100644 --- a/crates/bashkit/src/builtins/typescript.rs +++ b/crates/bashkit/src/builtins/typescript.rs @@ -302,7 +302,11 @@ impl TypeScriptExtension { } } - pub(crate) fn with_external_handler_and_prelude( + /// Like [`Self::with_external_handler`], plus a source prelude evaluated + /// before user code and `(from, to)` call rewrites (e.g. `tools.get_user` + /// to an external function name). Lets embedders expose namespaced host + /// APIs without core knowing their shape. + pub fn with_external_handler_and_prelude( limits: TypeScriptLimits, external_fns: Vec, handler: TypeScriptExternalFnHandler, @@ -835,11 +839,8 @@ impl Builtin for TypeScript { ctx.execution_budget() .and_then(|budget| budget.try_with(Clone::clone).ok()), ); - #[cfg(feature = "scripted_tool")] - let future = crate::tool_registry::scope_runtime_call( - crate::tool_registry::ToolCallScope::from_context(&ctx), - future, - ); + // Host functions read the request through RuntimeCallContext::current(). + let future = crate::runtime_call::scope(&ctx, future); match execution_budget { Some(budget) => budget.run(future).await?, None => future.await, diff --git a/crates/bashkit/src/execution_capability.rs b/crates/bashkit/src/execution_capability.rs index 7c97e7524..c2377a8af 100644 --- a/crates/bashkit/src/execution_capability.rs +++ b/crates/bashkit/src/execution_capability.rs @@ -243,8 +243,10 @@ impl ExecutionCapability { self.scope.cleanup_report() } - #[cfg(feature = "scripted_tool")] - pub(crate) fn derive(&self, value: U) -> Option> + /// Bind another host value to the same execution lease as this handle. + /// + /// Returns `None` once the originating execution has ended. + pub fn derive(&self, value: U) -> Option> where U: Send + Sync + 'static, { diff --git a/crates/bashkit/src/lib.rs b/crates/bashkit/src/lib.rs index 5b2b7715d..a23a19d20 100644 --- a/crates/bashkit/src/lib.rs +++ b/crates/bashkit/src/lib.rs @@ -452,6 +452,8 @@ mod network; /// Parser module - exposed for fuzzing and testing pub mod parser; mod profile; +#[cfg(any(feature = "python", feature = "typescript"))] +mod runtime_call; /// Scripted tool: compose ToolDef+callback pairs into a single Tool via bash scripts. /// Requires the `scripted_tool` feature. #[cfg(feature = "scripted_tool")] @@ -486,7 +488,7 @@ pub use builtins::git::GitConfig; pub use builtins::ssh::{SshAllowlist, SshConfig, TrustedHostKey}; pub use builtins::{ BashkitContext, Builtin, BuiltinRegistry, ClapBuiltin, CommandResolver, - Context as BuiltinContext, Extension, + Context as BuiltinContext, ExecutionPlan, Extension, SubCommand, }; pub use clap; #[cfg(feature = "http_client")] @@ -573,11 +575,13 @@ pub use builtins::git::GitClient; pub use builtins::ssh::{SshClient, SshHandler, SshOutput, SshTarget}; #[cfg(feature = "python")] -pub use builtins::{PythonExternalFnHandler, PythonExternalFns, PythonLimits}; +pub use builtins::{Python, PythonExternalFnHandler, PythonExternalFns, PythonLimits}; // Shared resource-limit core for embedded language VMs (Python, TypeScript). #[cfg(any(feature = "python", feature = "typescript"))] pub use builtins::RuntimeLimits; +#[cfg(any(feature = "python", feature = "typescript"))] +pub use runtime_call::RuntimeCallContext; #[cfg(feature = "sqlite")] pub use builtins::{Sqlite, SqliteBackend, SqliteLimits}; @@ -2030,9 +2034,16 @@ impl BashBuilder { self } - /// Restrict this shell to logic/data-flow commands and custom builtins. - #[cfg(feature = "scripted_tool")] - pub(crate) fn logic_only(mut self) -> Self { + /// Code mode: restrict this shell to logic/data-flow commands and custom + /// builtins. + /// + /// Bash becomes a control-flow and data-transformation language rather + /// than a VFS shell. Every filesystem operation is rejected, file + /// redirects, process substitution and path script execution are + /// unavailable, and only stdin-to-stdout builtins (`echo`, `printf`, + /// `test`, `read`, `grep`, `sed`, `awk`, `jq`, `sort`, ...) plus custom + /// builtins and extensions stay registered. + pub fn code_mode(mut self) -> Self { self.shell_profile = interpreter::ShellProfile::LogicOnly; self } diff --git a/crates/bashkit/src/limits.rs b/crates/bashkit/src/limits.rs index 81b37fa04..a7d8aed3e 100644 --- a/crates/bashkit/src/limits.rs +++ b/crates/bashkit/src/limits.rs @@ -852,16 +852,8 @@ impl ExecutionBudget { /// Await request-owned async work while polling cancellation and rejecting /// results that arrive after the request boundary has closed. - #[cfg(all( - not(target_family = "wasm"), - any( - feature = "python", - feature = "typescript", - feature = "http_client", - feature = "scripted_tool" - ) - ))] - pub(crate) async fn run(&self, future: F) -> Result + #[cfg(not(target_family = "wasm"))] + pub async fn run(&self, future: F) -> Result where F: std::future::Future, { @@ -882,16 +874,8 @@ impl ExecutionBudget { /// wasm has no reliable timer driver; synchronous checkpoints still reject /// closed/cancelled work before and after the awaited operation. - #[cfg(all( - target_family = "wasm", - any( - feature = "python", - feature = "typescript", - feature = "http_client", - feature = "scripted_tool" - ) - ))] - pub(crate) async fn run(&self, future: F) -> Result + #[cfg(target_family = "wasm")] + pub async fn run(&self, future: F) -> Result where F: std::future::Future, { diff --git a/crates/bashkit/src/runtime_call.rs b/crates/bashkit/src/runtime_call.rs new file mode 100644 index 000000000..90c0f4f5a --- /dev/null +++ b/crates/bashkit/src/runtime_call.rs @@ -0,0 +1,80 @@ +// Request context for host functions called from embedded Python/TypeScript. +// +// Important decision: host-function handlers (`PythonExternalFnHandler`, +// `TypeScriptExternalFnHandler`) keep their signatures. The interpreter VMs +// suspend inside a Tokio task-local scope instead, and handlers read the +// active request with `RuntimeCallContext::current()`. Embedders that build +// their own tool layers (e.g. a ToolDef registry outside core) get tenant +// extensions, the aggregate budget and the wall-clock deadline without core +// knowing about tools. + +use std::sync::Arc; +use std::time::Duration; + +use crate::builtins::{Context, ExecutionDeadline, ExecutionExtensions}; +use crate::{ExecutionBudget, ExecutionCapability}; + +/// Per-request context visible to host functions invoked from embedded +/// Python or TypeScript code. +/// +/// Obtain it inside a handler with [`RuntimeCallContext::current`]. Outside a +/// runtime call (or on a runtime started without a request) every accessor +/// returns `None`. +#[derive(Clone, Default)] +pub struct RuntimeCallContext { + extensions: Option>, +} + +impl RuntimeCallContext { + pub(crate) fn from_context(ctx: &Context<'_>) -> Self { + Self { + extensions: ctx.execution_extensions(), + } + } + + /// Context of the runtime call currently executing on this task. + pub fn current() -> Self { + RUNTIME_CALL_CONTEXT + .try_with(Clone::clone) + .unwrap_or_default() + } + + /// Look up a typed, revocable per-execution extension, if present. + pub fn execution_extension(&self) -> Option> + where + T: Send + Sync + 'static, + { + self.extensions.as_ref()?.get::() + } + + /// Bind a host value to the current execution lease. + pub fn execution_capability(&self, value: T) -> Option> + where + T: Send + Sync + 'static, + { + self.extensions.as_ref()?.capability(value) + } + + /// Aggregate budget shared by this request's commands and runtimes. + pub fn execution_budget(&self) -> Option> { + self.execution_extension::() + } + + /// Remaining wall-clock budget of the current `exec*` call. + pub fn remaining_deadline(&self) -> Option { + self.execution_extension::()? + .try_with(ExecutionDeadline::remaining) + .ok() + } +} + +tokio::task_local! { + static RUNTIME_CALL_CONTEXT: RuntimeCallContext; +} + +/// Run an embedded-runtime future with `ctx` as its [`RuntimeCallContext`]. +pub(crate) async fn scope(ctx: &Context<'_>, future: F) -> F::Output { + RUNTIME_CALL_CONTEXT + .scope(RuntimeCallContext::from_context(ctx), future) + .await +} diff --git a/crates/bashkit/src/scripted_tool/execute.rs b/crates/bashkit/src/scripted_tool/execute.rs index 899860dc2..069668d5e 100644 --- a/crates/bashkit/src/scripted_tool/execute.rs +++ b/crates/bashkit/src/scripted_tool/execute.rs @@ -72,7 +72,7 @@ impl ScriptedTool { if let Some(ref profile) = self.profile { builder = builder.profile(profile.clone()); } - builder = builder.logic_only(); + builder = builder.code_mode(); if let Some(ref limits) = self.limits { builder = builder.limits(limits.clone()); diff --git a/crates/bashkit/src/tool.rs b/crates/bashkit/src/tool.rs index 7484da686..537ea20da 100644 --- a/crates/bashkit/src/tool.rs +++ b/crates/bashkit/src/tool.rs @@ -183,7 +183,11 @@ pub struct ToolExecution { } impl ToolExecution { - pub(crate) fn new(runner: F) -> Self + /// Wrap a runner for a [`Tool::execution`] implementation. + /// + /// `runner` receives a stream sender only when the caller asked for + /// [`Self::output_stream`] before executing. + pub fn new(runner: F) -> Self where F: FnOnce(Option>) -> Fut + Send @@ -341,7 +345,10 @@ impl From for ToolResponse { } /// JSON schema for the stable tool request contract. -pub(crate) fn tool_request_schema() -> serde_json::Value { +/// +/// Shared by every [`Tool`] that accepts `{commands, timeout_ms}` (e.g. +/// `BashTool` and tools built outside core on the same contract). +pub fn tool_request_schema() -> serde_json::Value { serde_json::json!({ "type": "object", "required": ["commands"], @@ -361,7 +368,7 @@ pub(crate) fn tool_request_schema() -> serde_json::Value { } /// JSON schema for the stable tool response contract. -pub(crate) fn tool_response_schema() -> serde_json::Value { +pub fn tool_response_schema() -> serde_json::Value { serde_json::json!({ "type": "object", "required": ["stdout", "stderr", "exit_code"], @@ -1182,8 +1189,7 @@ fn error_kind(e: &Error) -> String { } /// Build a ToolResponse for a timed-out execution (exit code 124, like bash `timeout`). -/// -pub(crate) fn timeout_response(dur: Duration) -> ToolResponse { +pub fn timeout_response(dur: Duration) -> ToolResponse { ToolResponse { stdout: String::new(), stderr: format!( @@ -1196,7 +1202,8 @@ pub(crate) fn timeout_response(dur: Duration) -> ToolResponse { } } -pub(crate) fn localized<'a>(locale: &str, en: &'a str, uk: &'a str) -> &'a str { +/// Pick the English or Ukrainian variant of a message for `locale`. +pub fn localized<'a>(locale: &str, en: &'a str, uk: &'a str) -> &'a str { if locale.starts_with("uk") { uk } else { en } } @@ -1383,7 +1390,8 @@ fn build_bash_help(tool: &BashTool) -> String { doc } -pub(crate) fn tool_request_from_value( +/// Parse and validate `{commands, timeout_ms}` tool arguments. +pub fn tool_request_from_value( locale: &str, args: serde_json::Value, ) -> Result { @@ -1429,7 +1437,8 @@ pub(crate) fn tool_request_from_value( }) } -pub(crate) fn tool_output_from_response( +/// Convert a [`ToolResponse`] into the [`ToolOutput`] returned by an execution. +pub fn tool_output_from_response( response: ToolResponse, duration: Duration, ) -> Result { diff --git a/crates/bashkit/src/tool_registry.rs b/crates/bashkit/src/tool_registry.rs index b41f8469b..7a3dc99cb 100644 --- a/crates/bashkit/src/tool_registry.rs +++ b/crates/bashkit/src/tool_registry.rs @@ -2,9 +2,7 @@ // Surface adapters only translate arguments/results; policy, validation, // deadlines, sanitization, callback identity, and tracing stay here. -use crate::builtins::Context; -#[cfg(not(target_family = "wasm"))] -use crate::builtins::ExecutionDeadline; +use crate::BuiltinContext as Context; use crate::scripted_tool::{ CallbackKind, RegisteredTool, ScriptedCommandKind, ToolDefInvocationTrace, }; @@ -84,34 +82,54 @@ impl ToolCallRequest { } } +// Built only from public request accessors (`BuiltinContext` for shell +// commands, `RuntimeCallContext` for embedded Python/TypeScript) so the +// registry needs no interpreter internals. #[derive(Clone)] -pub(crate) struct ToolCallScope { +struct ToolCallScope { request: Option>, - budget: Option, + budget: Option, lease: Option>, #[cfg(not(target_family = "wasm"))] deadline: Option, } impl ToolCallScope { - pub(crate) fn from_context(ctx: &Context<'_>) -> Self { + fn from_context(ctx: &Context<'_>) -> Self { + Self::new( + ctx.execution_extension::(), + ctx.execution_budget(), + ctx.execution_capability(()), + ctx.remaining_deadline(), + ) + } + + #[cfg(any(feature = "python", feature = "typescript"))] + fn from_runtime() -> Self { + let ctx = crate::RuntimeCallContext::current(); + Self::new( + ctx.execution_extension::(), + ctx.execution_budget(), + ctx.execution_capability(()), + ctx.remaining_deadline(), + ) + } + + fn new( + request: Option>, + budget: Option>, + lease: Option>, + #[cfg_attr(target_family = "wasm", allow(unused_variables))] remaining: Option< + std::time::Duration, + >, + ) -> Self { Self { - request: ctx.execution_extension::(), - budget: ctx - .execution_budget() - .and_then(|budget| budget.try_with(Clone::clone).ok()), - lease: ctx.execution_capability(()), + request, + budget: budget.and_then(|budget| budget.try_with(Clone::clone).ok()), + lease, #[cfg(not(target_family = "wasm"))] - deadline: ctx - .execution_extension::() - .and_then(|deadline| { - deadline - .try_with(ExecutionDeadline::remaining) - .ok() - .and_then(|remaining| { - crate::time_compat::Instant::now().checked_add(remaining) - }) - }), + deadline: remaining + .and_then(|remaining| crate::time_compat::Instant::now().checked_add(remaining)), } } @@ -139,29 +157,6 @@ impl ToolCallScope { } } -#[cfg(any(feature = "python", feature = "typescript"))] -tokio::task_local! { - static RUNTIME_TOOL_SCOPE: ToolCallScope; -} - -#[cfg(any(feature = "python", feature = "typescript"))] -pub(crate) async fn scope_runtime_call(scope: ToolCallScope, future: F) -> F::Output { - RUNTIME_TOOL_SCOPE.scope(scope, future).await -} - -#[cfg(any(feature = "python", feature = "typescript"))] -fn runtime_scope() -> ToolCallScope { - RUNTIME_TOOL_SCOPE - .try_with(Clone::clone) - .unwrap_or(ToolCallScope { - request: None, - budget: None, - lease: None, - #[cfg(not(target_family = "wasm"))] - deadline: None, - }) -} - #[derive(Clone)] pub struct ToolRegistry { inner: Arc, @@ -333,8 +328,15 @@ impl ToolRegistry { params: Value, surface: ToolCallSurface, ) -> RegistryOutput { - self.invoke(name, params, None, surface, runtime_scope(), Vec::new()) - .await + self.invoke( + name, + params, + None, + surface, + ToolCallScope::from_runtime(), + Vec::new(), + ) + .await } async fn invoke( @@ -492,7 +494,7 @@ impl ToolRegistry { #[cfg(any(feature = "python", feature = "typescript"))] fn discover_runtime(&self, params: &Value) -> Value { - let scope = runtime_scope(); + let scope = ToolCallScope::from_runtime(); let trace = match scope.trace() { Ok(Some(trace)) => trace, Ok(None) => self.default_trace(), diff --git a/crates/bashkit/tests/integration/core_extension_point_tests.rs b/crates/bashkit/tests/integration/core_extension_point_tests.rs new file mode 100644 index 000000000..7eada5a8c --- /dev/null +++ b/crates/bashkit/tests/integration/core_extension_point_tests.rs @@ -0,0 +1,191 @@ +//! Public extension points that let tool layers live outside core: +//! `RuntimeCallContext` for embedded-runtime host functions, `code_mode()`, +//! and `BuiltinContext::remaining_deadline()`. + +#[cfg(any(feature = "python", feature = "typescript"))] +use bashkit::ExecutionExtensions; +use bashkit::{Bash, Builtin, BuiltinContext, ExecResult, ExecutionLimits, async_trait}; +use std::time::Duration; + +#[cfg(any(feature = "python", feature = "typescript"))] +#[derive(Clone)] +struct Tenant(&'static str); + +#[cfg(any(feature = "python", feature = "typescript"))] +fn current_tenant() -> String { + bashkit::RuntimeCallContext::current() + .execution_extension::() + .and_then(|tenant| tenant.try_with(|tenant| tenant.0.to_string()).ok()) + .unwrap_or_else(|| "none".to_string()) +} + +#[cfg(any(feature = "python", feature = "typescript"))] +#[test] +fn runtime_call_context_is_empty_outside_a_runtime_call() { + let ctx = bashkit::RuntimeCallContext::current(); + assert!(ctx.execution_extension::().is_none()); + assert!(ctx.execution_budget().is_none()); + assert!(ctx.remaining_deadline().is_none()); + assert!(ctx.execution_capability(()).is_none()); +} + +#[cfg(feature = "python")] +fn python_bash() -> Bash { + use bashkit::{ExtFunctionResult, MontyObject, PythonExternalFnHandler, PythonLimits}; + use std::sync::Arc; + + let handler: PythonExternalFnHandler = Arc::new(|_, _, _| { + Box::pin(async move { ExtFunctionResult::Return(MontyObject::String(current_tenant())) }) + }); + Bash::builder() + .python_with_external_handler(PythonLimits::default(), vec!["tenant".into()], handler) + .env("BASHKIT_ALLOW_INPROCESS_PYTHON", "1") + .build() +} + +#[cfg(feature = "python")] +#[tokio::test] +async fn python_host_function_sees_request_extensions() { + let mut bash = python_bash(); + let result = bash + .exec_with_extensions( + "python -c 'print(tenant())'", + ExecutionExtensions::new().with(Tenant("acme")), + ) + .await + .unwrap(); + assert_eq!(result.stdout, "acme\n", "stderr: {}", result.stderr); +} + +#[cfg(feature = "python")] +#[tokio::test] +async fn python_host_function_without_request_extension_sees_none() { + let mut bash = python_bash(); + let result = bash.exec("python -c 'print(tenant())'").await.unwrap(); + assert_eq!(result.stdout, "none\n", "stderr: {}", result.stderr); +} + +#[cfg(feature = "python")] +#[tokio::test] +async fn python_host_function_context_does_not_leak_across_requests() { + let mut bash = python_bash(); + let first = bash + .exec_with_extensions( + "python -c 'print(tenant())'", + ExecutionExtensions::new().with(Tenant("acme")), + ) + .await + .unwrap(); + assert_eq!(first.stdout, "acme\n"); + let second = bash.exec("python -c 'print(tenant())'").await.unwrap(); + assert_eq!(second.stdout, "none\n"); +} + +#[cfg(feature = "typescript")] +#[tokio::test] +async fn typescript_host_function_sees_request_extensions() { + use bashkit::{TypeScriptExternalFnHandler, TypeScriptLimits, ZapcodeValue}; + use std::sync::Arc; + + let handler: TypeScriptExternalFnHandler = + Arc::new(|_, _| Box::pin(async move { Ok(ZapcodeValue::String(current_tenant().into())) })); + let mut bash = Bash::builder() + .typescript_with_external_handler( + TypeScriptLimits::default(), + vec!["tenant".into()], + handler, + ) + .build(); + let result = bash + .exec_with_extensions( + "ts -c 'await tenant()'", + ExecutionExtensions::new().with(Tenant("acme")), + ) + .await + .unwrap(); + assert_eq!(result.stdout, "acme\n", "stderr: {}", result.stderr); + + let result = bash.exec("ts -c 'await tenant()'").await.unwrap(); + assert_eq!(result.stdout, "none\n", "stderr: {}", result.stderr); +} + +struct Upper; + +#[async_trait] +impl Builtin for Upper { + async fn execute(&self, ctx: BuiltinContext<'_>) -> bashkit::Result { + let input = ctx.stdin_text_lossy().unwrap_or_default().to_uppercase(); + Ok(ExecResult::ok(input)) + } +} + +#[tokio::test] +async fn code_mode_keeps_logic_and_custom_builtins() { + let mut bash = Bash::builder() + .code_mode() + .builtin("upper", Box::new(Upper)) + .build(); + let result = bash + .exec("for x in a b; do echo $x; done | upper | sort -r") + .await + .unwrap(); + assert_eq!(result.stdout, "B\nA\n"); + assert_eq!(result.exit_code, 0); +} + +#[tokio::test] +async fn code_mode_rejects_filesystem_access() { + let mut bash = Bash::builder().code_mode().build(); + + let redirect = bash.exec("echo secret > /tmp/out").await.unwrap(); + assert_ne!(redirect.exit_code, 0, "file redirect must fail"); + + let read = bash.exec("cat /etc/passwd").await.unwrap(); + assert_ne!(read.exit_code, 0, "cat is not a code-mode builtin"); + + let write = bash.exec("mkdir /work").await.unwrap(); + assert_ne!(write.exit_code, 0, "mkdir is not a code-mode builtin"); +} + +struct Deadline; + +#[async_trait] +impl Builtin for Deadline { + async fn execute(&self, ctx: BuiltinContext<'_>) -> bashkit::Result { + let out = match ctx.remaining_deadline() { + Some(remaining) if remaining <= Duration::from_secs(30) => "bounded\n", + Some(_) => "too-long\n", + None => "none\n", + }; + Ok(ExecResult::ok(out.to_string())) + } +} + +#[tokio::test] +async fn builtin_sees_remaining_deadline() { + let mut bash = Bash::builder() + .limits(ExecutionLimits::new().timeout(Duration::from_secs(30))) + .builtin("deadline", Box::new(Deadline)) + .build(); + let result = bash.exec("deadline").await.unwrap(); + assert_eq!(result.stdout, "bounded\n"); +} + +#[tokio::test] +async fn builtin_run_budgeted_returns_future_output() { + struct Budgeted; + + #[async_trait] + impl Builtin for Budgeted { + async fn execute(&self, ctx: BuiltinContext<'_>) -> bashkit::Result { + let value = ctx.run_budgeted(async { 41 + 1 }).await?; + Ok(ExecResult::ok(format!("{value}\n"))) + } + } + + let mut bash = Bash::builder() + .builtin("budgeted", Box::new(Budgeted)) + .build(); + let result = bash.exec("budgeted").await.unwrap(); + assert_eq!(result.stdout, "42\n"); +} diff --git a/crates/bashkit/tests/integration/main.rs b/crates/bashkit/tests/integration/main.rs index d188a3553..59edf7824 100644 --- a/crates/bashkit/tests/integration/main.rs +++ b/crates/bashkit/tests/integration/main.rs @@ -39,6 +39,7 @@ pub mod command_resolver_tests; pub mod competitor_regression_tests; pub mod compgen_tests; pub mod coproc_tests; +pub mod core_extension_point_tests; pub mod coreutils_differential_tests; pub mod credential_injection_tests; pub mod curl_data_compat_tests; diff --git a/knowledge/foundations/execution-profiles.md b/knowledge/foundations/execution-profiles.md index a7a18c394..e69ef0601 100644 --- a/knowledge/foundations/execution-profiles.md +++ b/knowledge/foundations/execution-profiles.md @@ -53,8 +53,8 @@ an arbitrary backend. Real read-write mounts retain their documented host-disk responsibility. `ScriptedTool` and `ScriptingToolSet` accept profiles for execution, session, -and memory policy, but always retain logic-only mode: filesystem, network, and -embedded-runtime capabilities stay unavailable. +and memory policy, but always retain logic-only mode (`BashBuilder::code_mode()`): +filesystem, network, and embedded-runtime capabilities stay unavailable. ## Validation and bindings diff --git a/knowledge/integrations/scripted-tool-orchestration.md b/knowledge/integrations/scripted-tool-orchestration.md index f81a0fa81..9596f01f3 100644 --- a/knowledge/integrations/scripted-tool-orchestration.md +++ b/knowledge/integrations/scripted-tool-orchestration.md @@ -117,7 +117,13 @@ other output remains a string. `ToolCallRequest`, carried through `ExecOptions::extensions`, supplies tenant identity and a request-local `ToolDefInvocationTrace`. The request scope crosses -Monty/ZapCode suspension with a Tokio task-local, never mutable registry state. +Monty/ZapCode suspension through core's public `RuntimeCallContext` (a Tokio +task-local the Python/TypeScript builtins set for every run), never mutable +registry state. Shell commands read the same data from `BuiltinContext` +(`execution_extension`, `execution_budget`, `execution_capability`, +`remaining_deadline`). Decision: the registry uses only these public request +accessors, so it can move out of core (planned `bashkit-scripted-tool` crate) +without core knowing about tools. Policy sees name, parameters, tenant, and surface; callbacks read tenant/surface from `ToolArgs`. A shared registry therefore shares callbacks and policy while keeping request traces and tenant context isolated. Registry callback futures use @@ -163,7 +169,7 @@ Two arguments per tool: definition + callback. `.tool_fn()` sync, `.async_tool_f `.builtin(name, Box)` registers a raw-argv builtin alongside the `ToolDef` tools, mirroring `BashBuilder::builtin` and `BashToolBuilder::builtin`. Stored as `Arc` so one instance is reused by the fresh shell each `execute()` builds. -Why it exists: `parse_flags` is schema-driven and only understands `--key value`. Commands that need positionals, short flags, `--`, or parse-time required-argument checking cannot be expressed as a `ToolDef`, and before this the only workaround was to rebuild the shell on `Bash::builder()` downstream, which is not possible because `BashBuilder::logic_only` is `pub(crate)`. Registering the builtin gives it `ctx.args` unparsed, so it can use [`ClapBuiltin`](../foundations/builtins.md) — the house parser for `Bash` builtins — inside a `ScriptedTool`. Example: `crates/bashkit/examples/scripted_tool_clap_builtin.rs`. +Why it exists: `parse_flags` is schema-driven and only understands `--key value`. Commands that need positionals, short flags, `--`, or parse-time required-argument checking cannot be expressed as a `ToolDef`, and before this the only workaround was to rebuild the shell on `Bash::builder()` downstream, which was not possible while the logic-only switch was `pub(crate)` (now public as `BashBuilder::code_mode()`). Registering the builtin gives it `ctx.args` unparsed, so it can use [`ClapBuiltin`](../foundations/builtins.md) — the house parser for `Bash` builtins — inside a `ScriptedTool`. Example: `crates/bashkit/examples/scripted_tool_clap_builtin.rs`. Security posture is unchanged. The builtin runs in the same logic-only shell and gets the same rejecting filesystem, so it cannot reach a real file; the logic-only decision stays enforced by the profile, not by the builtin allowlist. Registration order is custom builtins first, `ToolDefExtension` second: `BashBuilder` keys custom builtins by name and later registrations win, so a custom builtin can never shadow a registered tool command, `help`, or `discover`. A custom builtin is otherwise ordinary trusted host code — whatever it reaches, the script reaches. Its fatal `Err(Error)` diagnostics cross the same `sanitize_errors` boundary as `ToolDef` callback errors: generic by default, raw only after explicit opt-out. Cancellation and resource-limit errors are preserved because callers need their categories for control flow. diff --git a/knowledge/integrations/tool-contract.md b/knowledge/integrations/tool-contract.md index f8d4402dd..ee0972c6c 100644 --- a/knowledge/integrations/tool-contract.md +++ b/knowledge/integrations/tool-contract.md @@ -118,6 +118,11 @@ or `kind = "stderr"`; chunk data is JSON string content. `ToolOutput.metadata.extra` currently includes `{ "exit_code": 0 }`. +Contract helpers are public so `Tool` implementations outside core share one +request/response shape: `tool::{tool_request_schema, tool_response_schema, +tool_request_from_value, tool_output_from_response, timeout_response, +localized}` and `ToolExecution::new`. + ## Scripted tool specifics `ScriptedToolBuilder` and `ScriptingToolSetBuilder` follow the same contract: diff --git a/knowledge/log.md b/knowledge/log.md index ebdc02ef1..8696bc43e 100644 --- a/knowledge/log.md +++ b/knowledge/log.md @@ -1,5 +1,9 @@ # Bashkit Knowledge Update Log +## 2026-10-06 + +* **Decision**: Everything built on `ToolDef` (`ToolDef`/`ToolArgs`/`ToolImpl`, `ToolDefExtension`, `ToolRegistry`, `ScriptedTool`, `ScriptingToolSet`) leaves the core crate for a published `bashkit-scripted-tool` crate; Python/JS move the class to `bashkit.scripted` / `@everruns/bashkit/scripted`, with a public migration guide. Step 1 adds only generic public extension points to core: `RuntimeCallContext` (request extensions, budget, lease and deadline for Python/TypeScript host functions, replacing the registry's private task-local), public `Python`/`TypeScriptExtension::with_external_handler_and_prelude`, `ExecutionCapability::derive`, `ExecutionBudget::run`, `BuiltinContext::{run_budgeted, remaining_deadline}`, `BashBuilder::code_mode()` (was crate-private `logic_only`), public `ExecutionPlan`/`SubCommand` re-exports, and the `Tool` contract helpers. Recorded in [Scripted Tool Orchestration](integrations/scripted-tool-orchestration.md), [Python Builtin](runtimes/python-builtin.md), [ZapCode Runtime](runtimes/zapcode-runtime.md), [Tool Contract](integrations/tool-contract.md). + ## 2026-10-04 * **Build**: Nightly went red on a tree nobody had touched. `turso_core` depends on `branches` with `default-features = false`, and that no_std path compiles `core::intrinsics::abort()` — an unstable intrinsic rustc removed in 1.101.0-nightly. Only the AddressSanitizer job both tracks floating nightly and builds `turso_core` (`bashkit-cli` enables `sqlite` by default; Miri builds `bashkit --lib` without it), so stable CI stayed green and the same commit that passed as run #241 failed as #242 a day later. There is no version to bump to: `branches` 0.5.0, its latest release, carries the same line, and `turso_core` requires `^0.4.3` regardless. Cargo cannot add a feature to a transitive dependency, so `bashkit` now declares a direct `branches` edge under the `sqlite` feature purely to turn `std` on for the whole graph — `abort()` becomes `std::process::abort()`, which is what a std-linking build wants anyway, and the unstable intrinsic leaves the tree. Recorded in [Dependency Policy](operations/dependencies.md#feature-unification-edges). diff --git a/knowledge/runtimes/python-builtin.md b/knowledge/runtimes/python-builtin.md index fa2e6be2d..dd2e97333 100644 --- a/knowledge/runtimes/python-builtin.md +++ b/knowledge/runtimes/python-builtin.md @@ -196,6 +196,8 @@ let bash = Bash::builder() - Handler signature: `(function_name: String, positional_args: Vec, keyword_args: Vec<(MontyObject, MontyObject)>) -> Pin + Send>>`. - Returns `ExtFunctionResult::Return(MontyObject)` (value to Python) or `ExtFunctionResult::Error(MontyException)` (raises). - **Dispatch:** one handler receives all registered names; dispatch on `function_name` inside it. +- **Request context:** inside the handler, `RuntimeCallContext::current()` exposes the calling `exec*` request: typed `ExecOptions` extensions, the aggregate `ExecutionBudget`, an execution lease (`execution_capability`), and the remaining wall-clock deadline. Outside a Python run every accessor returns `None`. Decision: handler signatures stay unchanged; the builtin wraps each run in a task-local scope instead. +- **Prelude:** `Python::with_external_handler_and_prelude(names, handler, prelude)` (public) evaluates host-provided Python before user code, e.g. classes that route `tools.orders.list(...)` to one external function. - **Timeouts:** Each awaited handler call is wrapped in the remaining `PythonLimits::max_duration` wall-clock budget for the current Python invocation. If the budget expires while a handler is pending, Bashkit resumes Python with a `RuntimeError` instead of waiting for the handler indefinitely. - **Trust model:** same as `BashBuilder::builtin()` and `ScriptedTool` callbacks, host registers trusted Rust code, untrusted scripts invoke by name. Handlers are trusted host code and should still enforce independent limits for outbound I/O, remote services, and other resources they consume. - **Unstable re-exports:** `MontyObject`, `ExtFunctionResult`, `MontyException`, `ExcType` re-exported from the `monty` crate (pre-1.0, tracked at `0.0.x`); may break between bashkit releases. diff --git a/knowledge/runtimes/zapcode-runtime.md b/knowledge/runtimes/zapcode-runtime.md index 1423153c0..aa3a2f6e0 100644 --- a/knowledge/runtimes/zapcode-runtime.md +++ b/knowledge/runtimes/zapcode-runtime.md @@ -111,6 +111,12 @@ Hosts can register custom external functions callable by name from TypeScript where `handler: TypeScriptExternalFnHandler` is an `Arc` async closure `(name, args) -> Result`. See rustdoc. +Inside the handler, `RuntimeCallContext::current()` returns the calling +request's extensions, budget, execution lease, and remaining deadline (same +contract as Python). `TypeScriptExtension::with_external_handler_and_prelude` +(public) adds a source prelude and `(from, to)` call rewrites so hosts can +expose namespaced APIs (`tools.orders.list(...)`) without core knowing them. + ### Security See [Threat Model](../security/threat-model.md) section "TypeScript / ZapCode Security (TM-TS)" diff --git a/knowledge/security/threat-model.md b/knowledge/security/threat-model.md index b9766713c..bf3a1a987 100644 --- a/knowledge/security/threat-model.md +++ b/knowledge/security/threat-model.md @@ -946,7 +946,7 @@ Only exact domain matches are allowed (TM-NET-017). | TM-ISO-023 | `set -e` leaks across `exec()` calls | `set` options (`-e`, `-x`, etc.) persist across `exec()` calls, causing unexpected abort behavior | `reset_transient_state()` clears `SET_OPTION_VARS` | **FIXED** | | TM-ISO-024 | `$?` leaks into VFS subprocess | Parent `last_exit_code` visible inside VFS script subprocess, causing false `set -e` failures | `execute_script_content()` sets `last_exit_code = 0`, clears `nounset_error`, and clears `traps` for the child | **MITIGATED** | | TM-ISO-025 | Wrapper rebuild silently drops or restores revoked capabilities | A binding's `reset()` or fresh-execution path rebuilds `Bash` without limits, policy files, callbacks, network policy, or other host configuration, or replays an explicitly revoked mount, changing the sandbox contract after the first call | The canonical capability manifest requires executable evidence for every supported surface cell. NAPI `SharedState` retains constructor `files`, and `build_bash_from_state` is the single rebuild path used by construction and reset. Host mutations applied *after* construction are recorded on the same path: NAPI `runtime_env`/`runtime_mounts` and the Python equivalents replay `set_env()` and runtime mounts on every rebuild. Runtime mount replay keys use the VFS-normalized mount point, so `unmount()` retracts the record even when mount and unmount callers use equivalent paths containing `.` or `..`; a rebuild cannot resurrect the removed mount. Script-set env is not recorded, so `reset()` still discards it. Regressions: `Bash: reset restores configured files`, `Bash: reset preserves setEnv values`, `Bash: reset preserves a runtime FileSystem mount`, `Bash: unmount retracts the replay, so reset does not resurrect it`, `Bash: unmount retracts an equivalent normalized mount path`, `test_reset_preserves_set_env`, `test_unmount_retracts_replay`, `test_unmount_retracts_equivalent_normalized_mount_path` | **MITIGATED** | -| TM-ISO-026 | Shared ToolRegistry leaks tenant identity or traces across requests/runtimes | One immutable registry and callback set may serve concurrent tenants through shell, Python, and TypeScript; registry-global mutable request state would cross-contaminate authorization and telemetry | `ToolCallRequest` carries tenant + a fresh bounded trace through `ExecutionExtensions`; runtime bridges copy it into a Tokio task-local only for the current suspension; callbacks receive owned `ToolArgs` context; no tenant value is stored in the registry (`tool_registry.rs`) | **MITIGATED** | +| TM-ISO-026 | Shared ToolRegistry leaks tenant identity or traces across requests/runtimes | One immutable registry and callback set may serve concurrent tenants through shell, Python, and TypeScript; registry-global mutable request state would cross-contaminate authorization and telemetry | `ToolCallRequest` carries tenant + a fresh bounded trace through `ExecutionExtensions`; runtime bridges expose it through the core `RuntimeCallContext` task-local only for the current run; callbacks receive owned `ToolArgs` context; no tenant value is stored in the registry (`tool_registry.rs`) | **MITIGATED** | | TM-ISO-027 | Request-owned execution authority or retained facility handles survive completion | A retained runtime, transport, callback, budget, extension, or custom-builtin VFS handle emits late output, charges a reused request, or accesses facilities after completion/cancellation | One `ExecutionBudget` and one capability lease are created before initialization and closed/revoked on every exit. The budget gates work and async results; `ExecutionCapability`, scoped VFS wrappers, host-call brokers, and `ToolArgs` context gate retained facilities. Cleanup is bounded/idempotent and reports only sanitized counts. `BuiltinRegistry::insert_trusted` explicitly models the intentional unscoped host escape hatch. Shared lifecycle evidence is defined in [Request Execution Lifecycle](request-lifecycle.md); Rust and Python capability regressions cover late/cross-request/cancellation use and cleanup failure. | **MITIGATED** | **TM-ISO-004**: Fixed, see table; env wiring in `builtins/jq/compat.rs` and `builtins/jq/mod.rs`. From f2ad3a4935de19c7ddf5b437897cd6bb98babeb7 Mon Sep 17 00:00:00 2001 From: Mykhailo Chalyi Date: Tue, 6 Oct 2026 19:09:36 +0000 Subject: [PATCH 2/4] refactor(core): replace logic-only shell profile with generic switches Core no longer has a named restricted mode. ShellProfile::LogicOnly is replaced by public building blocks: - ShellFeatures (file_redirects, process_substitution, script_execution), set with BashBuilder::shell_features - BashBuilder::builtin_filter for the default builtin set The logic-only shell (ShellFeatures::none(), builtin allowlist, rejecting filesystem) now lives in scripted_tool/orchestration.rs and moves with ScriptedTool. Drops the code_mode() method added earlier on this branch. Claude-Session: https://claude.ai/code/session_01LSQwMLncQmLPG9CYatpbVk --- crates/bashkit/src/fs/mod.rs | 74 ------ crates/bashkit/src/interpreter/mod.rs | 213 ++++++++---------- crates/bashkit/src/interpreter/redirection.rs | 38 ++-- crates/bashkit/src/lib.rs | 44 ++-- crates/bashkit/src/scripted_tool/execute.rs | 2 +- crates/bashkit/src/scripted_tool/mod.rs | 1 + .../src/scripted_tool/orchestration.rs | 185 +++++++++++++++ .../integration/core_extension_point_tests.rs | 96 ++++++-- knowledge/foundations/execution-profiles.md | 2 +- .../scripted-tool-orchestration.md | 10 +- knowledge/log.md | 2 +- 11 files changed, 413 insertions(+), 254 deletions(-) create mode 100644 crates/bashkit/src/scripted_tool/orchestration.rs diff --git a/crates/bashkit/src/fs/mod.rs b/crates/bashkit/src/fs/mod.rs index 8d23745e6..372795439 100644 --- a/crates/bashkit/src/fs/mod.rs +++ b/crates/bashkit/src/fs/mod.rs @@ -430,83 +430,9 @@ pub use search::{ pub use traits::{DirEntry, FileSystem, FileSystemExt, FileType, Metadata, fs_errors}; use crate::error::Result; -use crate::time_compat::SystemTime; use std::io::{Error as IoError, ErrorKind}; use std::path::{Path, PathBuf}; -/// Filesystem implementation for logic-only shells. -/// -/// Important decision: ScriptedTool code mode still needs an `Arc` -/// because the interpreter and builtin context require one, but every real -/// operation is rejected so scripts cannot use VFS as storage or input. -pub(crate) struct DisabledFs; - -fn disabled_fs_error() -> crate::Error { - IoError::new(ErrorKind::PermissionDenied, "filesystem access disabled").into() -} - -#[async_trait::async_trait] -impl FileSystemExt for DisabledFs {} - -#[async_trait::async_trait] -impl FileSystem for DisabledFs { - async fn read_file(&self, _path: &Path) -> Result> { - Err(disabled_fs_error()) - } - - async fn write_file(&self, _path: &Path, _content: &[u8]) -> Result<()> { - Err(disabled_fs_error()) - } - - async fn append_file(&self, _path: &Path, _content: &[u8]) -> Result<()> { - Err(disabled_fs_error()) - } - - async fn mkdir(&self, _path: &Path, _recursive: bool) -> Result<()> { - Err(disabled_fs_error()) - } - - async fn remove(&self, _path: &Path, _recursive: bool) -> Result<()> { - Err(disabled_fs_error()) - } - - async fn stat(&self, _path: &Path) -> Result { - Err(disabled_fs_error()) - } - - async fn read_dir(&self, _path: &Path) -> Result> { - Err(disabled_fs_error()) - } - - async fn exists(&self, _path: &Path) -> Result { - Ok(false) - } - - async fn rename(&self, _from: &Path, _to: &Path) -> Result<()> { - Err(disabled_fs_error()) - } - - async fn copy(&self, _from: &Path, _to: &Path) -> Result<()> { - Err(disabled_fs_error()) - } - - async fn symlink(&self, _target: &Path, _link: &Path) -> Result<()> { - Err(disabled_fs_error()) - } - - async fn read_link(&self, _path: &Path) -> Result { - Err(disabled_fs_error()) - } - - async fn chmod(&self, _path: &Path, _mode: u32) -> Result<()> { - Err(disabled_fs_error()) - } - - async fn set_modified_time(&self, _path: &Path, _time: SystemTime) -> Result<()> { - Err(disabled_fs_error()) - } -} - /// Join `child` onto the VFS path `base`, always using `/` as the separator. /// /// `PathBuf::join`/`push` insert the *host* separator (`\` on Windows) whenever diff --git a/crates/bashkit/src/interpreter/mod.rs b/crates/bashkit/src/interpreter/mod.rs index cd2c13e29..4dd85686b 100644 --- a/crates/bashkit/src/interpreter/mod.rs +++ b/crates/bashkit/src/interpreter/mod.rs @@ -100,109 +100,86 @@ fn format_history_entries(entries: &[HistoryEntry]) -> String { content } -/// Runtime command surface for an interpreter instance. -#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] -pub(crate) enum ShellProfile { - /// Full Bashkit shell with VFS-backed commands. - #[default] - Full, - /// Logic-only shell for ScriptedTool code mode: no filesystem primitives. - LogicOnly, +/// Shell language features that reach beyond in-memory computation. +/// +/// All are enabled by default. Turning them off (with +/// [`crate::BashBuilder::shell_features`], usually together with +/// [`crate::BashBuilder::builtin_filter`] and a rejecting +/// [`crate::FileSystem`]) is how embedders build restricted shells, such as +/// the shell `ScriptedTool` runs scripts in. Decision: core knows the +/// switches, not any named restricted mode; modes are assembled by callers. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct ShellFeatures { + file_redirects: bool, + process_substitution: bool, + script_execution: bool, } -impl ShellProfile { - pub(crate) fn is_logic_only(self) -> bool { - self == Self::LogicOnly +impl Default for ShellFeatures { + fn default() -> Self { + Self::all() } } -fn logic_only_builtin_allowed(name: &str) -> bool { - matches!( - name, - // Core shell/data flow - "echo" - | "true" - | "false" - | "exit" - | "break" - | "continue" - | "return" - | "test" - | "[" - | "printf" - | "export" - | "read" - | "set" - | "unset" - | "shift" - | "local" - | ":" - | "readonly" - | "times" - | "eval" - // Text and data transforms that work from stdin - | "grep" - | "sed" - | "awk" - | "head" - | "tail" - | "sort" - | "uniq" - | "cut" - | "tr" - | "wc" - | "nl" - | "paste" - | "column" - | "comm" - | "strings" - | "tac" - | "rev" - | "fold" - | "expand" - | "unexpand" - | "join" - | "split" - | "jq" - | "seq" - | "expr" - | "bc" - | "numfmt" - // Shell state, introspection, and structured transforms - | "env" - | "printenv" - | "type" - | "which" - | "hash" - | "alias" - | "unalias" - | "trap" - | "caller" - | "mapfile" - | "readarray" - | "shopt" - | "clear" - | "envsubst" - | "assert" - | "log" - | "retry" - | "semver" - | "verify" - | "compgen" - | "csv" - | "help" - | "iconv" - | "json" - | "parallel" - | "template" - | "tomlq" - | "yq" - | "timeout" - | "xargs" - | "wait" - ) +impl ShellFeatures { + /// Every feature enabled (the default full shell). + pub const fn all() -> Self { + Self { + file_redirects: true, + process_substitution: true, + script_execution: true, + } + } + + /// Every feature disabled. + pub const fn none() -> Self { + Self { + file_redirects: false, + process_substitution: false, + script_execution: false, + } + } + + /// File redirects (`<`, `>`, `>>`, `&>`, `>|`, `$( Self { + self.file_redirects = enabled; + self + } + + /// Process substitution (`<(cmd)`, `>(cmd)`). + pub const fn process_substitution(mut self, enabled: bool) -> Self { + self.process_substitution = enabled; + self + } + + /// Running scripts from the filesystem or as nested shells: path + /// execution (`./x.sh`), `$PATH` lookup, `source`/`.`, `exec`, `bash`, + /// and `sh`. + pub const fn script_execution(mut self, enabled: bool) -> Self { + self.script_execution = enabled; + self + } + + /// Whether file redirects are enabled. + pub const fn has_file_redirects(self) -> bool { + self.file_redirects + } + + /// Whether process substitution is enabled. + pub const fn has_process_substitution(self) -> bool { + self.process_substitution + } + + /// Whether script execution is enabled. + pub const fn has_script_execution(self) -> bool { + self.script_execution + } } +/// Predicate selecting which default builtins a shell registers. +pub(crate) type BuiltinFilter = Arc bool + Send + Sync>; + fn word_literal_text(word: &Word) -> Option<&str> { if word.parts.len() == 1 && let WordPart::Literal(s) = &word.parts[0] @@ -1262,9 +1239,8 @@ pub struct Interpreter { /// Uses `AtomicU32` for interior mutability so $RANDOM can advance state /// in `expand_variable(&self, ...)` while remaining `Send + Sync`. random_state: AtomicU32, - /// Runtime command surface. ScriptedTool uses LogicOnly to prevent scripts - /// from reaching VFS-backed commands while preserving shell logic. - shell_profile: ShellProfile, + /// Language features enabled for this shell (see [`ShellFeatures`]). + shell_features: ShellFeatures, /// Hardened profiles intentionally reduce elapsed-time precision. hardened_timing: bool, } @@ -1319,7 +1295,8 @@ impl Interpreter { None, HashMap::new(), None, - ShellProfile::Full, + ShellFeatures::default(), + None, false, ) } @@ -1341,7 +1318,8 @@ impl Interpreter { epoch_offset: Option, custom_builtins: HashMap>, host_builtins: Option, - shell_profile: ShellProfile, + shell_features: ShellFeatures, + builtin_filter: Option, hardened_timing: bool, ) -> Self { // Macro to reduce boilerplate for simple zero-arg builtin registration. @@ -1575,8 +1553,8 @@ impl Interpreter { builtins.insert("sftp".to_string(), Arc::new(builtins::Sftp)); } - if shell_profile.is_logic_only() { - builtins.retain(|name, _| logic_only_builtin_allowed(name)); + if let Some(filter) = builtin_filter { + builtins.retain(|name, _| filter(name)); } // Merge custom builtins (override defaults if same name). @@ -1674,7 +1652,7 @@ impl Interpreter { deferred_proc_subs: Vec::new(), proc_sub_paths: HashSet::new(), random_state: AtomicU32::new(random_seed), - shell_profile, + shell_features, hardened_timing, } } @@ -2901,7 +2879,7 @@ impl Interpreter { Command::Pipeline(pipeline) => self.execute_pipeline(pipeline).await, Command::List(list) => self.execute_list(list).await, Command::Compound(compound, redirects) => { - if let Some(stderr) = self.logic_only_redirect_error(redirects) { + if let Some(stderr) = self.disabled_redirect_error(redirects) { return Ok(ExecResult::err(stderr, 1)); } @@ -4164,11 +4142,8 @@ impl Interpreter { None }; let output = if let Some(word) = &time_cmd.output { - if self.shell_profile.is_logic_only() { - return Ok(ExecResult::err( - "time: output files disabled in logic-only shell\n", - 1, - )); + if !self.shell_features.has_file_redirects() { + return Ok(ExecResult::err("time: output files disabled\n", 1)); } Some(self.expand_word(word).await?) } else { @@ -6002,7 +5977,7 @@ impl Interpreter { }); } - if let Some(stderr) = self.logic_only_redirect_error(&command.redirects) { + if let Some(stderr) = self.disabled_redirect_error(&command.redirects) { return Ok(ExecResult::err(stderr, 1)); } @@ -6570,7 +6545,7 @@ impl Interpreter { stdin: Option, redirects: &[Redirect], ) -> Option> { - if self.shell_profile.is_logic_only() + if !self.shell_features.has_script_execution() && matches!(name, "exec" | "bash" | "sh" | "source" | ".") { return Some(Ok(ExecResult::err( @@ -6657,7 +6632,7 @@ impl Interpreter { // Script execution by path if name.contains('/') { - if self.shell_profile.is_logic_only() { + if !self.shell_features.has_script_execution() { return Ok(ExecResult::err( format!("bash: {}: command not found\n", name), 127, @@ -6678,7 +6653,7 @@ impl Interpreter { .flatten(); // $PATH search - if !self.shell_profile.is_logic_only() + if self.shell_features.has_script_execution() && let Some(result) = self .try_execute_script_via_path_search(name, &args, stdin, &command.redirects) .await? @@ -6802,7 +6777,7 @@ impl Interpreter { /// Resolve a command name to its full path via PATH search on VFS. /// Returns the resolved path string if found, None otherwise. async fn resolve_command_path(&self, name: &str) -> Option { - if self.shell_profile.is_logic_only() { + if !self.shell_features.has_script_execution() { return None; } @@ -8825,9 +8800,9 @@ impl Interpreter { commands: &[Command], is_input: bool, ) -> Result { - if self.shell_profile.is_logic_only() { + if !self.shell_features.has_process_substitution() { return Err(crate::error::Error::Execution( - "bash: process substitution disabled in logic-only shell".to_string(), + "bash: process substitution disabled".to_string(), )); } @@ -8899,7 +8874,7 @@ impl Interpreter { exit_code: i32, redirects: &[Redirect], ) -> Result { - if let Some(stderr) = self.logic_only_redirect_error(redirects) { + if let Some(stderr) = self.disabled_redirect_error(redirects) { self.last_exit_code = 1; return Ok(ExecResult::err(stderr, 1)); } @@ -8959,7 +8934,7 @@ impl Interpreter { // command and produce nothing. // The optimized read replaces execution, not its accounting. self.charge_command_execution()?; - let read = if self.logic_only_redirect_error(redirects).is_some() { + let read = if self.disabled_redirect_error(redirects).is_some() { Err(crate::error::Error::CommandFailure(String::new())) } else { self.process_input_redirections(None, redirects).await diff --git a/crates/bashkit/src/interpreter/redirection.rs b/crates/bashkit/src/interpreter/redirection.rs index 5d350a8ce..9c6c49e8d 100644 --- a/crates/bashkit/src/interpreter/redirection.rs +++ b/crates/bashkit/src/interpreter/redirection.rs @@ -33,7 +33,7 @@ impl Interpreter { for redirect in redirects { match redirect.kind { RedirectKind::Input => { - if self.shell_profile.is_logic_only() + if !self.shell_features.has_file_redirects() && !word_is_literal_dev_null(&redirect.target) { return Err(crate::error::Error::Execution(format!( @@ -46,7 +46,7 @@ impl Interpreter { // Handle /dev/null at interpreter level - cannot be bypassed if is_dev_null(&path) { stdin = Some(crate::StreamData::new()); // EOF - } else if self.shell_profile.is_logic_only() { + } else if !self.shell_features.has_file_redirects() { return Err(crate::error::Error::Execution(format!( "bash: {}: filesystem redirection disabled", target_path @@ -103,7 +103,7 @@ impl Interpreter { mut result: ExecResult, redirects: &[Redirect], ) -> Result { - if let Some(stderr) = self.logic_only_redirect_error(redirects) { + if let Some(stderr) = self.disabled_redirect_error(redirects) { result.stdout = crate::StreamData::new(); result.stderr = stderr.into(); result.exit_code = 1; @@ -295,26 +295,24 @@ impl Interpreter { Ok(result) } - pub(super) fn logic_only_redirect_error(&self, redirects: &[Redirect]) -> Option { - if !self.shell_profile.is_logic_only() { - return None; - } - + pub(super) fn disabled_redirect_error(&self, redirects: &[Redirect]) -> Option { for redirect in redirects { - if word_has_process_substitution(&redirect.target) { - return Some( - "bash: process substitution disabled in logic-only shell\n".to_string(), - ); + if !self.shell_features.has_process_substitution() + && word_has_process_substitution(&redirect.target) + { + return Some("bash: process substitution disabled\n".to_string()); } - if matches!( - redirect.kind, - RedirectKind::Output - | RedirectKind::Clobber - | RedirectKind::Append - | RedirectKind::Input - | RedirectKind::OutputBoth - ) && !word_is_literal_dev_null(&redirect.target) + if !self.shell_features.has_file_redirects() + && matches!( + redirect.kind, + RedirectKind::Output + | RedirectKind::Clobber + | RedirectKind::Append + | RedirectKind::Input + | RedirectKind::OutputBoth + ) + && !word_is_literal_dev_null(&redirect.target) { return Some(format!( "bash: {}: filesystem redirection disabled\n", diff --git a/crates/bashkit/src/lib.rs b/crates/bashkit/src/lib.rs index a23a19d20..eb4b27099 100644 --- a/crates/bashkit/src/lib.rs +++ b/crates/bashkit/src/lib.rs @@ -508,7 +508,8 @@ pub use fs::{ pub use fs::{RealFs, RealFsMode}; pub use host_call::{ExecutionEvent, ExecutionHandle, HostCallId, HostCallRequest}; pub use interpreter::{ - ControlFlow, ExecResult, HistoryEntry, OutputCallback, ShellState, ShellStateView, + ControlFlow, ExecResult, HistoryEntry, OutputCallback, ShellFeatures, ShellState, + ShellStateView, }; pub use limits::{ ExecutionBudget, ExecutionBudgetExceeded, ExecutionBudgetLease, ExecutionCounters, @@ -1867,7 +1868,8 @@ pub struct BashBuilder { fixed_epoch: Option, /// Constant seconds offset applied to real-clock for `date` (TM-INF-018) epoch_offset: Option, - shell_profile: interpreter::ShellProfile, + shell_features: ShellFeatures, + builtin_filter: Option, custom_builtins: HashMap>, /// Optional host-owned mutable registry. Entries here are consulted at /// dispatch time, so embedders can register/remove builtins after build. @@ -2034,17 +2036,22 @@ impl BashBuilder { self } - /// Code mode: restrict this shell to logic/data-flow commands and custom - /// builtins. + /// Enable or disable shell language features that reach beyond in-memory + /// computation (file redirects, process substitution, script execution). /// - /// Bash becomes a control-flow and data-transformation language rather - /// than a VFS shell. Every filesystem operation is rejected, file - /// redirects, process substitution and path script execution are - /// unavailable, and only stdin-to-stdout builtins (`echo`, `printf`, - /// `test`, `read`, `grep`, `sed`, `awk`, `jq`, `sort`, ...) plus custom - /// builtins and extensions stay registered. - pub fn code_mode(mut self) -> Self { - self.shell_profile = interpreter::ShellProfile::LogicOnly; + /// Combine with [`Self::builtin_filter`] and a rejecting [`Self::fs`] to + /// build a restricted shell. See [`ShellFeatures`]. + pub fn shell_features(mut self, features: ShellFeatures) -> Self { + self.shell_features = features; + self + } + + /// Keep only the default builtins for which `keep(name)` returns true. + /// + /// Applies to Bashkit's own builtins only: builtins added with + /// [`Self::builtin`] and extensions are always registered. + pub fn builtin_filter(mut self, keep: impl Fn(&str) -> bool + Send + Sync + 'static) -> Self { + self.builtin_filter = Some(Arc::new(keep)); self } @@ -3285,9 +3292,7 @@ impl BashBuilder { /// # } /// ``` pub fn build(self) -> Bash { - let base_fs: Arc = if self.shell_profile.is_logic_only() { - Arc::new(fs::DisabledFs) - } else if let Some(fs) = self.fs { + let base_fs: Arc = if let Some(fs) = self.fs { fs } else { // No custom filesystem was supplied: provision the default @@ -3349,7 +3354,8 @@ impl BashBuilder { self.fixed_epoch, self.epoch_offset, self.cwd, - self.shell_profile, + self.shell_features, + self.builtin_filter, self.profile.name() == ExecutionProfileName::Hardened, self.limits, self.session_limits, @@ -3568,7 +3574,8 @@ impl BashBuilder { fixed_epoch: Option, epoch_offset: Option, cwd: Option, - shell_profile: interpreter::ShellProfile, + shell_features: ShellFeatures, + builtin_filter: Option, hardened_timing: bool, limits: ExecutionLimits, session_limits: SessionLimits, @@ -3607,7 +3614,8 @@ impl BashBuilder { epoch_offset, custom_builtins, host_builtins, - shell_profile, + shell_features, + builtin_filter, hardened_timing, ); diff --git a/crates/bashkit/src/scripted_tool/execute.rs b/crates/bashkit/src/scripted_tool/execute.rs index 069668d5e..f30b1d177 100644 --- a/crates/bashkit/src/scripted_tool/execute.rs +++ b/crates/bashkit/src/scripted_tool/execute.rs @@ -72,7 +72,7 @@ impl ScriptedTool { if let Some(ref profile) = self.profile { builder = builder.profile(profile.clone()); } - builder = builder.code_mode(); + builder = super::orchestration::apply(builder); if let Some(ref limits) = self.limits { builder = builder.limits(limits.clone()); diff --git a/crates/bashkit/src/scripted_tool/mod.rs b/crates/bashkit/src/scripted_tool/mod.rs index d75742a90..c066fd8fd 100644 --- a/crates/bashkit/src/scripted_tool/mod.rs +++ b/crates/bashkit/src/scripted_tool/mod.rs @@ -129,6 +129,7 @@ mod execute; mod extension; +mod orchestration; mod toolset; pub use extension::{ToolDefExtension, ToolDefExtensionBuilder, ToolDefInvocationTrace}; diff --git a/crates/bashkit/src/scripted_tool/orchestration.rs b/crates/bashkit/src/scripted_tool/orchestration.rs new file mode 100644 index 000000000..9c32467e9 --- /dev/null +++ b/crates/bashkit/src/scripted_tool/orchestration.rs @@ -0,0 +1,185 @@ +//! The restricted shell `ScriptedTool` runs scripts in. +//! +//! Important decision: core has no named restricted mode. This module +//! assembles one from public building blocks: every [`ShellFeatures`] switch +//! off, a default-builtin allowlist, and a filesystem that rejects every +//! call. Bash stays a control-flow and data-transformation language; +//! registered tool commands and custom builtins are the only way out. +//! TODO(naming): "orchestration shell" is a placeholder until the name is chosen. + +use crate::{BashBuilder, DirEntry, FileSystem, FileSystemExt, Metadata, Result, ShellFeatures}; +use std::io::{Error as IoError, ErrorKind}; +use std::path::{Path, PathBuf}; +use std::sync::Arc; + +type SystemTime = crate::time_compat::SystemTime; + +/// Restrict `builder` to the orchestration shell. +pub(crate) fn apply(builder: BashBuilder) -> BashBuilder { + builder + .fs(Arc::new(DisabledFs)) + .shell_features(ShellFeatures::none()) + .builtin_filter(builtin_allowed) +} + +/// Default builtins kept in the orchestration shell: shell logic and +/// stdin-to-stdout transforms. Anything that reads or writes files is out. +pub(crate) fn builtin_allowed(name: &str) -> bool { + matches!( + name, + // Core shell/data flow + "echo" + | "true" + | "false" + | "exit" + | "break" + | "continue" + | "return" + | "test" + | "[" + | "printf" + | "export" + | "read" + | "set" + | "unset" + | "shift" + | "local" + | ":" + | "readonly" + | "times" + | "eval" + // Text and data transforms that work from stdin + | "grep" + | "sed" + | "awk" + | "head" + | "tail" + | "sort" + | "uniq" + | "cut" + | "tr" + | "wc" + | "nl" + | "paste" + | "column" + | "comm" + | "strings" + | "tac" + | "rev" + | "fold" + | "expand" + | "unexpand" + | "join" + | "split" + | "jq" + | "seq" + | "expr" + | "bc" + | "numfmt" + // Shell state, introspection, and structured transforms + | "env" + | "printenv" + | "type" + | "which" + | "hash" + | "alias" + | "unalias" + | "trap" + | "caller" + | "mapfile" + | "readarray" + | "shopt" + | "clear" + | "envsubst" + | "assert" + | "log" + | "retry" + | "semver" + | "verify" + | "compgen" + | "csv" + | "help" + | "iconv" + | "json" + | "parallel" + | "template" + | "tomlq" + | "yq" + | "timeout" + | "xargs" + | "wait" + ) +} + +/// Filesystem that rejects every operation. +/// +/// Important decision: the interpreter and builtin context require an +/// `Arc`, so the orchestration shell installs one whose every +/// real operation fails, and scripts cannot use a VFS as storage or input. +pub(crate) struct DisabledFs; + +fn disabled_fs_error() -> crate::Error { + IoError::new(ErrorKind::PermissionDenied, "filesystem access disabled").into() +} + +#[async_trait::async_trait] +impl FileSystemExt for DisabledFs {} + +#[async_trait::async_trait] +impl FileSystem for DisabledFs { + async fn read_file(&self, _path: &Path) -> Result> { + Err(disabled_fs_error()) + } + + async fn write_file(&self, _path: &Path, _content: &[u8]) -> Result<()> { + Err(disabled_fs_error()) + } + + async fn append_file(&self, _path: &Path, _content: &[u8]) -> Result<()> { + Err(disabled_fs_error()) + } + + async fn mkdir(&self, _path: &Path, _recursive: bool) -> Result<()> { + Err(disabled_fs_error()) + } + + async fn remove(&self, _path: &Path, _recursive: bool) -> Result<()> { + Err(disabled_fs_error()) + } + + async fn stat(&self, _path: &Path) -> Result { + Err(disabled_fs_error()) + } + + async fn read_dir(&self, _path: &Path) -> Result> { + Err(disabled_fs_error()) + } + + async fn exists(&self, _path: &Path) -> Result { + Ok(false) + } + + async fn rename(&self, _from: &Path, _to: &Path) -> Result<()> { + Err(disabled_fs_error()) + } + + async fn copy(&self, _from: &Path, _to: &Path) -> Result<()> { + Err(disabled_fs_error()) + } + + async fn symlink(&self, _target: &Path, _link: &Path) -> Result<()> { + Err(disabled_fs_error()) + } + + async fn read_link(&self, _path: &Path) -> Result { + Err(disabled_fs_error()) + } + + async fn chmod(&self, _path: &Path, _mode: u32) -> Result<()> { + Err(disabled_fs_error()) + } + + async fn set_modified_time(&self, _path: &Path, _time: SystemTime) -> Result<()> { + Err(disabled_fs_error()) + } +} diff --git a/crates/bashkit/tests/integration/core_extension_point_tests.rs b/crates/bashkit/tests/integration/core_extension_point_tests.rs index 7eada5a8c..20ce27779 100644 --- a/crates/bashkit/tests/integration/core_extension_point_tests.rs +++ b/crates/bashkit/tests/integration/core_extension_point_tests.rs @@ -1,10 +1,13 @@ //! Public extension points that let tool layers live outside core: -//! `RuntimeCallContext` for embedded-runtime host functions, `code_mode()`, -//! and `BuiltinContext::remaining_deadline()`. +//! `RuntimeCallContext` for embedded-runtime host functions, `ShellFeatures` +//! and `builtin_filter` for restricted shells, and +//! `BuiltinContext::remaining_deadline()`. #[cfg(any(feature = "python", feature = "typescript"))] use bashkit::ExecutionExtensions; -use bashkit::{Bash, Builtin, BuiltinContext, ExecResult, ExecutionLimits, async_trait}; +use bashkit::{ + Bash, Builtin, BuiltinContext, ExecResult, ExecutionLimits, ShellFeatures, async_trait, +}; use std::time::Duration; #[cfg(any(feature = "python", feature = "typescript"))] @@ -120,31 +123,86 @@ impl Builtin for Upper { } #[tokio::test] -async fn code_mode_keeps_logic_and_custom_builtins() { - let mut bash = Bash::builder() - .code_mode() - .builtin("upper", Box::new(Upper)) - .build(); +async fn shell_features_default_to_full_shell() { + assert_eq!(ShellFeatures::default(), ShellFeatures::all()); + let mut bash = Bash::builder().build(); let result = bash - .exec("for x in a b; do echo $x; done | upper | sort -r") + .exec("echo hi > /tmp/out; cat /tmp/out; cat <(echo sub)") .await .unwrap(); - assert_eq!(result.stdout, "B\nA\n"); - assert_eq!(result.exit_code, 0); + assert_eq!(result.stdout, "hi\nsub\n"); } #[tokio::test] -async fn code_mode_rejects_filesystem_access() { - let mut bash = Bash::builder().code_mode().build(); +async fn disabled_file_redirects_reject_files_but_keep_dev_null() { + let mut bash = Bash::builder() + .shell_features(ShellFeatures::all().file_redirects(false)) + .build(); + + let write = bash.exec("echo secret > /tmp/out").await.unwrap(); + assert_ne!(write.exit_code, 0); + assert!(write.stderr.contains("filesystem redirection disabled")); - let redirect = bash.exec("echo secret > /tmp/out").await.unwrap(); - assert_ne!(redirect.exit_code, 0, "file redirect must fail"); + let read = bash.exec("cat < /etc/passwd").await.unwrap(); + assert_ne!(read.exit_code, 0); + + let null = bash.exec("echo quiet > /dev/null; echo ok").await.unwrap(); + assert_eq!(null.stdout, "ok\n"); + + // Other features stay on. + let sub = bash.exec("cat <(echo sub)").await.unwrap(); + assert_eq!(sub.stdout, "sub\n"); +} + +#[tokio::test] +async fn disabled_process_substitution_is_rejected() { + let mut bash = Bash::builder() + .shell_features(ShellFeatures::all().process_substitution(false)) + .build(); + // Expansion-time rejection surfaces as an execution error. + let err = match bash.exec("cat <(echo sub)").await { + Ok(result) => panic!("expected rejection, got {result:?}"), + Err(err) => err.to_string(), + }; + assert!(err.contains("process substitution disabled"), "{err}"); +} - let read = bash.exec("cat /etc/passwd").await.unwrap(); - assert_ne!(read.exit_code, 0, "cat is not a code-mode builtin"); +#[tokio::test] +async fn disabled_script_execution_rejects_paths_and_nested_shells() { + let mut bash = Bash::builder() + .shell_features(ShellFeatures::all().script_execution(false)) + .mount_text("/work/x.sh", "echo ran\n") + .build(); + for script in [ + "/work/x.sh", + "source /work/x.sh", + ". /work/x.sh", + "bash /work/x.sh", + "sh -c 'echo ran'", + ] { + let result = bash.exec(script).await.unwrap(); + assert_eq!(result.exit_code, 127, "{script}: {}", result.stderr); + assert!(!result.stdout.contains("ran"), "{script}"); + } + // Files themselves are still readable: only execution is off. + let read = bash.exec("cat /work/x.sh").await.unwrap(); + assert_eq!(read.stdout, "echo ran\n"); +} + +#[tokio::test] +async fn builtin_filter_drops_defaults_but_keeps_custom_builtins() { + let mut bash = Bash::builder() + .builtin_filter(|name| matches!(name, "echo" | "sort")) + .builtin("upper", Box::new(Upper)) + .build(); + let result = bash + .exec("for x in a b; do echo $x; done | upper | sort -r") + .await + .unwrap(); + assert_eq!(result.stdout, "B\nA\n"); - let write = bash.exec("mkdir /work").await.unwrap(); - assert_ne!(write.exit_code, 0, "mkdir is not a code-mode builtin"); + let missing = bash.exec("cat /etc/passwd").await.unwrap(); + assert_eq!(missing.exit_code, 127); } struct Deadline; diff --git a/knowledge/foundations/execution-profiles.md b/knowledge/foundations/execution-profiles.md index e69ef0601..a90b8eb99 100644 --- a/knowledge/foundations/execution-profiles.md +++ b/knowledge/foundations/execution-profiles.md @@ -53,7 +53,7 @@ an arbitrary backend. Real read-write mounts retain their documented host-disk responsibility. `ScriptedTool` and `ScriptingToolSet` accept profiles for execution, session, -and memory policy, but always retain logic-only mode (`BashBuilder::code_mode()`): +and memory policy, but always retain logic-only mode (assembled from `ShellFeatures::none()`, a builtin allowlist and a rejecting filesystem): filesystem, network, and embedded-runtime capabilities stay unavailable. ## Validation and bindings diff --git a/knowledge/integrations/scripted-tool-orchestration.md b/knowledge/integrations/scripted-tool-orchestration.md index 9596f01f3..4b28dfbaa 100644 --- a/knowledge/integrations/scripted-tool-orchestration.md +++ b/knowledge/integrations/scripted-tool-orchestration.md @@ -169,7 +169,7 @@ Two arguments per tool: definition + callback. `.tool_fn()` sync, `.async_tool_f `.builtin(name, Box)` registers a raw-argv builtin alongside the `ToolDef` tools, mirroring `BashBuilder::builtin` and `BashToolBuilder::builtin`. Stored as `Arc` so one instance is reused by the fresh shell each `execute()` builds. -Why it exists: `parse_flags` is schema-driven and only understands `--key value`. Commands that need positionals, short flags, `--`, or parse-time required-argument checking cannot be expressed as a `ToolDef`, and before this the only workaround was to rebuild the shell on `Bash::builder()` downstream, which was not possible while the logic-only switch was `pub(crate)` (now public as `BashBuilder::code_mode()`). Registering the builtin gives it `ctx.args` unparsed, so it can use [`ClapBuiltin`](../foundations/builtins.md) — the house parser for `Bash` builtins — inside a `ScriptedTool`. Example: `crates/bashkit/examples/scripted_tool_clap_builtin.rs`. +Why it exists: `parse_flags` is schema-driven and only understands `--key value`. Commands that need positionals, short flags, `--`, or parse-time required-argument checking cannot be expressed as a `ToolDef`, and before this the only workaround was to rebuild the shell on `Bash::builder()` downstream, which was not possible while the logic-only switch was `pub(crate)` (core now exposes the generic `BashBuilder::shell_features` / `builtin_filter` switches it is built from). Registering the builtin gives it `ctx.args` unparsed, so it can use [`ClapBuiltin`](../foundations/builtins.md) — the house parser for `Bash` builtins — inside a `ScriptedTool`. Example: `crates/bashkit/examples/scripted_tool_clap_builtin.rs`. Security posture is unchanged. The builtin runs in the same logic-only shell and gets the same rejecting filesystem, so it cannot reach a real file; the logic-only decision stays enforced by the profile, not by the builtin allowlist. Registration order is custom builtins first, `ToolDefExtension` second: `BashBuilder` keys custom builtins by name and later registrations win, so a custom builtin can never shadow a registered tool command, `help`, or `discover`. A custom builtin is otherwise ordinary trusted host code — whatever it reaches, the script reaches. Its fatal `Err(Error)` diagnostics cross the same `sanitize_errors` boundary as `ToolDef` callback errors: generic by default, raw only after explicit opt-out. Cancellation and resource-limit errors are preserved because callers need their categories for control flow. @@ -185,6 +185,14 @@ The logic-only shell keeps: variables, arrays, functions, arithmetic, command su Rejected filesystem surfaces: file commands (`cat`, `ls`, `find`, `mkdir`, `rm`, `cp`, `mv`, `touch`, `chmod`, `ln`, `stat`, `source`, `.`); path execution (`/tmp/script.sh`, `$PATH` lookup); file redirection (`<`, `>`, `>>`, `&>`) except `/dev/null`; process substitution; file operands to dual-use tools, the internal filesystem rejects all real operations with `filesystem access disabled`. +Decision: core has no named restricted mode. The logic-only shell is assembled +in `scripted_tool/orchestration.rs` from public switches: +`ShellFeatures::none()` (file redirects except `/dev/null`, process +substitution, and script execution — path, `$PATH`, `source`/`.`, `exec`, +`bash`, `sh` — all off), `builtin_filter` with the default-builtin allowlist, +and a filesystem that rejects every call. Any embedder can build its own +restricted shell the same way; the interpreter only enforces the switches. + ### Built-in `help` command `help --list` (names + descriptions), `help ` (usage), `help --json` (machine-readable: `name`, `description`, `input_schema`), lets LLMs discover enum values, required fields, etc. at runtime without loading all schemas into context. diff --git a/knowledge/log.md b/knowledge/log.md index 8696bc43e..5bc6f19be 100644 --- a/knowledge/log.md +++ b/knowledge/log.md @@ -2,7 +2,7 @@ ## 2026-10-06 -* **Decision**: Everything built on `ToolDef` (`ToolDef`/`ToolArgs`/`ToolImpl`, `ToolDefExtension`, `ToolRegistry`, `ScriptedTool`, `ScriptingToolSet`) leaves the core crate for a published `bashkit-scripted-tool` crate; Python/JS move the class to `bashkit.scripted` / `@everruns/bashkit/scripted`, with a public migration guide. Step 1 adds only generic public extension points to core: `RuntimeCallContext` (request extensions, budget, lease and deadline for Python/TypeScript host functions, replacing the registry's private task-local), public `Python`/`TypeScriptExtension::with_external_handler_and_prelude`, `ExecutionCapability::derive`, `ExecutionBudget::run`, `BuiltinContext::{run_budgeted, remaining_deadline}`, `BashBuilder::code_mode()` (was crate-private `logic_only`), public `ExecutionPlan`/`SubCommand` re-exports, and the `Tool` contract helpers. Recorded in [Scripted Tool Orchestration](integrations/scripted-tool-orchestration.md), [Python Builtin](runtimes/python-builtin.md), [ZapCode Runtime](runtimes/zapcode-runtime.md), [Tool Contract](integrations/tool-contract.md). +* **Decision**: Everything built on `ToolDef` (`ToolDef`/`ToolArgs`/`ToolImpl`, `ToolDefExtension`, `ToolRegistry`, `ScriptedTool`, `ScriptingToolSet`) leaves the core crate for a published `bashkit-scripted-tool` crate; Python/JS move the class to `bashkit.scripted` / `@everruns/bashkit/scripted`, with a public migration guide. Step 1 adds only generic public extension points to core: `RuntimeCallContext` (request extensions, budget, lease and deadline for Python/TypeScript host functions, replacing the registry's private task-local), public `Python`/`TypeScriptExtension::with_external_handler_and_prelude`, `ExecutionCapability::derive`, `ExecutionBudget::run`, `BuiltinContext::{run_budgeted, remaining_deadline}`, generic restricted-shell switches `BashBuilder::shell_features(ShellFeatures)` (file redirects, process substitution, script execution) and `BashBuilder::builtin_filter`, which replace the interpreter-level `ShellProfile::LogicOnly`; the logic-only shell itself (allowlist + rejecting filesystem) now lives beside `ScriptedTool` and moves with it, public `ExecutionPlan`/`SubCommand` re-exports, and the `Tool` contract helpers. Recorded in [Scripted Tool Orchestration](integrations/scripted-tool-orchestration.md), [Python Builtin](runtimes/python-builtin.md), [ZapCode Runtime](runtimes/zapcode-runtime.md), [Tool Contract](integrations/tool-contract.md). ## 2026-10-04 From 667f9fb3c80d99b7f131203573b597cb49bae786 Mon Sep 17 00:00:00 2001 From: Mykhailo Chalyi Date: Tue, 6 Oct 2026 20:16:14 +0000 Subject: [PATCH 3/4] refactor(scripted_tool): name the restricted shell logic-only Claude-Session: https://claude.ai/code/session_01LSQwMLncQmLPG9CYatpbVk --- crates/bashkit/src/scripted_tool/execute.rs | 2 +- .../scripted_tool/{orchestration.rs => logic_only.rs} | 9 ++++----- crates/bashkit/src/scripted_tool/mod.rs | 2 +- knowledge/integrations/scripted-tool-orchestration.md | 2 +- knowledge/status/capability-parity.md | 4 ++-- 5 files changed, 9 insertions(+), 10 deletions(-) rename crates/bashkit/src/scripted_tool/{orchestration.rs => logic_only.rs} (93%) diff --git a/crates/bashkit/src/scripted_tool/execute.rs b/crates/bashkit/src/scripted_tool/execute.rs index f30b1d177..1c74f4269 100644 --- a/crates/bashkit/src/scripted_tool/execute.rs +++ b/crates/bashkit/src/scripted_tool/execute.rs @@ -72,7 +72,7 @@ impl ScriptedTool { if let Some(ref profile) = self.profile { builder = builder.profile(profile.clone()); } - builder = super::orchestration::apply(builder); + builder = super::logic_only::apply(builder); if let Some(ref limits) = self.limits { builder = builder.limits(limits.clone()); diff --git a/crates/bashkit/src/scripted_tool/orchestration.rs b/crates/bashkit/src/scripted_tool/logic_only.rs similarity index 93% rename from crates/bashkit/src/scripted_tool/orchestration.rs rename to crates/bashkit/src/scripted_tool/logic_only.rs index 9c32467e9..e466cfd39 100644 --- a/crates/bashkit/src/scripted_tool/orchestration.rs +++ b/crates/bashkit/src/scripted_tool/logic_only.rs @@ -1,11 +1,10 @@ -//! The restricted shell `ScriptedTool` runs scripts in. +//! The logic-only shell: the restricted shell `ScriptedTool` runs scripts in. //! //! Important decision: core has no named restricted mode. This module //! assembles one from public building blocks: every [`ShellFeatures`] switch //! off, a default-builtin allowlist, and a filesystem that rejects every //! call. Bash stays a control-flow and data-transformation language; //! registered tool commands and custom builtins are the only way out. -//! TODO(naming): "orchestration shell" is a placeholder until the name is chosen. use crate::{BashBuilder, DirEntry, FileSystem, FileSystemExt, Metadata, Result, ShellFeatures}; use std::io::{Error as IoError, ErrorKind}; @@ -14,7 +13,7 @@ use std::sync::Arc; type SystemTime = crate::time_compat::SystemTime; -/// Restrict `builder` to the orchestration shell. +/// Restrict `builder` to the logic-only shell. pub(crate) fn apply(builder: BashBuilder) -> BashBuilder { builder .fs(Arc::new(DisabledFs)) @@ -22,7 +21,7 @@ pub(crate) fn apply(builder: BashBuilder) -> BashBuilder { .builtin_filter(builtin_allowed) } -/// Default builtins kept in the orchestration shell: shell logic and +/// Default builtins kept in the logic-only shell: shell logic and /// stdin-to-stdout transforms. Anything that reads or writes files is out. pub(crate) fn builtin_allowed(name: &str) -> bool { matches!( @@ -114,7 +113,7 @@ pub(crate) fn builtin_allowed(name: &str) -> bool { /// Filesystem that rejects every operation. /// /// Important decision: the interpreter and builtin context require an -/// `Arc`, so the orchestration shell installs one whose every +/// `Arc`, so the logic-only shell installs one whose every /// real operation fails, and scripts cannot use a VFS as storage or input. pub(crate) struct DisabledFs; diff --git a/crates/bashkit/src/scripted_tool/mod.rs b/crates/bashkit/src/scripted_tool/mod.rs index c066fd8fd..2b009b014 100644 --- a/crates/bashkit/src/scripted_tool/mod.rs +++ b/crates/bashkit/src/scripted_tool/mod.rs @@ -129,7 +129,7 @@ mod execute; mod extension; -mod orchestration; +mod logic_only; mod toolset; pub use extension::{ToolDefExtension, ToolDefExtensionBuilder, ToolDefInvocationTrace}; diff --git a/knowledge/integrations/scripted-tool-orchestration.md b/knowledge/integrations/scripted-tool-orchestration.md index 4b28dfbaa..bf5281947 100644 --- a/knowledge/integrations/scripted-tool-orchestration.md +++ b/knowledge/integrations/scripted-tool-orchestration.md @@ -186,7 +186,7 @@ The logic-only shell keeps: variables, arrays, functions, arithmetic, command su Rejected filesystem surfaces: file commands (`cat`, `ls`, `find`, `mkdir`, `rm`, `cp`, `mv`, `touch`, `chmod`, `ln`, `stat`, `source`, `.`); path execution (`/tmp/script.sh`, `$PATH` lookup); file redirection (`<`, `>`, `>>`, `&>`) except `/dev/null`; process substitution; file operands to dual-use tools, the internal filesystem rejects all real operations with `filesystem access disabled`. Decision: core has no named restricted mode. The logic-only shell is assembled -in `scripted_tool/orchestration.rs` from public switches: +in `scripted_tool/logic_only.rs` from public switches: `ShellFeatures::none()` (file redirects except `/dev/null`, process substitution, and script execution — path, `$PATH`, `source`/`.`, `exec`, `bash`, `sh` — all off), `builtin_filter` with the default-builtin allowlist, diff --git a/knowledge/status/capability-parity.md b/knowledge/status/capability-parity.md index 30881cdc6..76a3b9c9a 100644 --- a/knowledge/status/capability-parity.md +++ b/knowledge/status/capability-parity.md @@ -63,8 +63,8 @@ A dash means the feature is intentionally unsupported and has a recorded reason - `cwd`: ScriptedTool intentionally uses a logic-only shell without filesystem-backed cwd configuration. - `custom_builtins`: Only registered ToolDef callbacks become commands; arbitrary Bash builtins are not accepted. - `host_call_suspension`: ScriptedTool invokes registered callbacks directly and exposes no suspended execution handle. -- `network_policy`: The logic-only orchestration shell has no network builtins. -- `transport_hooks`: The logic-only orchestration shell has no HTTP transport. +- `network_policy`: The logic-only shell has no network builtins. +- `transport_hooks`: The logic-only shell has no HTTP transport. - `virtual_filesystem`: Filesystem commands and redirections are intentionally rejected. - `mounts`: Filesystem commands and redirections are intentionally rejected. - `snapshots`: Each execution uses a fresh logic-only shell. From f29062a8752a2fdeca6e56d0dfce10cc0238fd66 Mon Sep 17 00:00:00 2001 From: Mykhailo Chalyi Date: Tue, 6 Oct 2026 20:38:22 +0000 Subject: [PATCH 4/4] chore(contracts): match logic-only shell wording in capability parity source Claude-Session: https://claude.ai/code/session_01LSQwMLncQmLPG9CYatpbVk --- contracts/capability-parity.json | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/contracts/capability-parity.json b/contracts/capability-parity.json index a376b8acd..d9adcd722 100644 --- a/contracts/capability-parity.json +++ b/contracts/capability-parity.json @@ -93,8 +93,8 @@ "cwd": "ScriptedTool intentionally uses a logic-only shell without filesystem-backed cwd configuration.", "custom_builtins": "Only registered ToolDef callbacks become commands; arbitrary Bash builtins are not accepted.", "host_call_suspension": "ScriptedTool invokes registered callbacks directly and exposes no suspended execution handle.", - "network_policy": "The logic-only orchestration shell has no network builtins.", - "transport_hooks": "The logic-only orchestration shell has no HTTP transport.", + "network_policy": "The logic-only shell has no network builtins.", + "transport_hooks": "The logic-only shell has no HTTP transport.", "virtual_filesystem": "Filesystem commands and redirections are intentionally rejected.", "mounts": "Filesystem commands and redirections are intentionally rejected.", "snapshots": "Each execution uses a fresh logic-only shell.",