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.", diff --git a/crates/bashkit/src/builtins/mod.rs b/crates/bashkit/src/builtins/mod.rs index 170fc521a..b9b2ba030 100644 --- a/crates/bashkit/src/builtins/mod.rs +++ b/crates/bashkit/src/builtins/mod.rs @@ -790,8 +790,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, { @@ -816,6 +818,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/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 14310b7fb..7bde798c8 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] @@ -1306,9 +1283,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, } @@ -1363,7 +1339,8 @@ impl Interpreter { None, HashMap::new(), None, - ShellProfile::Full, + ShellFeatures::default(), + None, false, ) } @@ -1385,7 +1362,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. @@ -1624,8 +1602,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). @@ -1723,7 +1701,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, } } @@ -2968,7 +2946,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)); } @@ -4231,11 +4209,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 { @@ -6104,7 +6079,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)); } @@ -6683,7 +6658,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( @@ -6770,7 +6745,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, @@ -6791,7 +6766,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? @@ -6915,7 +6890,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; } @@ -8938,9 +8913,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(), )); } @@ -9012,7 +8987,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)); } @@ -9072,7 +9047,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 1002a8fd4..f2539ae5e 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!( @@ -48,7 +48,7 @@ impl Interpreter { // `< /dev/stdin` re-reads the current stdin: no-op. } else 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 @@ -105,7 +105,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; @@ -324,26 +324,24 @@ impl Interpreter { Ok(()) } - 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 0de556361..c39a9dd63 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")] @@ -488,7 +490,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")] @@ -508,7 +510,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, @@ -575,11 +578,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}; @@ -1883,7 +1888,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. @@ -2050,10 +2056,22 @@ 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 { - self.shell_profile = interpreter::ShellProfile::LogicOnly; + /// Enable or disable shell language features that reach beyond in-memory + /// computation (file redirects, process substitution, script execution). + /// + /// 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 } @@ -3294,9 +3312,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 @@ -3358,7 +3374,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, @@ -3577,7 +3594,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, @@ -3616,7 +3634,8 @@ impl BashBuilder { epoch_offset, custom_builtins, host_builtins, - shell_profile, + shell_features, + builtin_filter, hardened_timing, ); 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..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 = builder.logic_only(); + 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/logic_only.rs b/crates/bashkit/src/scripted_tool/logic_only.rs new file mode 100644 index 000000000..e466cfd39 --- /dev/null +++ b/crates/bashkit/src/scripted_tool/logic_only.rs @@ -0,0 +1,184 @@ +//! 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. + +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 logic-only 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 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!( + 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 logic-only 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/src/scripted_tool/mod.rs b/crates/bashkit/src/scripted_tool/mod.rs index d75742a90..2b009b014 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 logic_only; mod toolset; pub use extension::{ToolDefExtension, ToolDefExtensionBuilder, ToolDefInvocationTrace}; 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..20ce27779 --- /dev/null +++ b/crates/bashkit/tests/integration/core_extension_point_tests.rs @@ -0,0 +1,249 @@ +//! Public extension points that let tool layers live outside core: +//! `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, ShellFeatures, 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 shell_features_default_to_full_shell() { + assert_eq!(ShellFeatures::default(), ShellFeatures::all()); + let mut bash = Bash::builder().build(); + let result = bash + .exec("echo hi > /tmp/out; cat /tmp/out; cat <(echo sub)") + .await + .unwrap(); + assert_eq!(result.stdout, "hi\nsub\n"); +} + +#[tokio::test] +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 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}"); +} + +#[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 missing = bash.exec("cat /etc/passwd").await.unwrap(); + assert_eq!(missing.exit_code, 127); +} + +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 efe5c8b45..5453b2b1e 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..a90b8eb99 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 (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 f81a0fa81..bf5281947 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)` (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. @@ -179,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/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, +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/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 4091729a5..2d43c45df 100644 --- a/knowledge/log.md +++ b/knowledge/log.md @@ -2,6 +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}`, 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). * **Feature**: [In-Process Terminal](integrations/in-process-terminal.md) behind the `terminal` feature (off by default): `Terminal` drives a `Bash` session from raw keystrokes, reports `Idle` when the session waits for input, and exposes the screen as text through the `vt100` crate. libghostty was considered and rejected because it needs Zig and C FFI, which would break the wasm and binding builds. The new `vi` builtin reads the device in raw mode and edits VFS files. Time blocked on terminal input is excluded from the execution timeout so an editor session is not killed while an agent thinks; busy work still times out (TM-DOS-120). Buffers are bounded (TM-DOS-119). Gaps recorded as L-TERM-001..003: vi is a subset, `read` does not wait for typed input, Ctrl-C lands at the next command boundary. `less` and `more` page interactively only inside a `Terminal` and stay cat-like for `exec()`/`BashTool`/CLI callers, so no existing caller can block on a pager (L-TERM-004 covers redirected stdout inside a session). ## 2026-10-04 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 cfce40849..870482444 100644 --- a/knowledge/security/threat-model.md +++ b/knowledge/security/threat-model.md @@ -948,7 +948,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`. 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.