From 29d98f70cb228c9a4b40ded64a52215730da9731 Mon Sep 17 00:00:00 2001 From: Mykhailo Chalyi Date: Wed, 1 Apr 2026 22:07:43 +0000 Subject: [PATCH 1/8] feat(builtins): add TypeScript runtime via ZapCode interpreter Add embedded TypeScript/JavaScript execution powered by zapcode-core, following the same pattern as the Python/Monty builtin. - Feature flag: `typescript` (opt-in via `Bash::builder().typescript()`) - Commands: ts, typescript, node, deno, bun (all aliases) - VFS bridging: readFile, writeFile, exists, readDir, mkdir, remove, stat - Resource limits: TypeScriptLimits (duration, memory, stack depth, allocations) - External function support for host-provided capabilities - 30 unit tests covering basic execution, VFS bridging, and error handling - Spec: specs/015-zapcode-runtime.md --- AGENTS.md | 1 + crates/bashkit/Cargo.toml | 6 + crates/bashkit/src/builtins/mod.rs | 8 + crates/bashkit/src/builtins/typescript.rs | 980 ++++++++++++++++++++++ crates/bashkit/src/lib.rs | 104 +++ specs/015-zapcode-runtime.md | 316 +++++++ 6 files changed, 1415 insertions(+) create mode 100644 crates/bashkit/src/builtins/typescript.rs create mode 100644 specs/015-zapcode-runtime.md diff --git a/AGENTS.md b/AGENTS.md index 629ff50f..5738988c 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -42,6 +42,7 @@ Fix root cause. Unsure: read more code; if stuck, ask w/ short options. Unrecogn | 012-maintenance | Pre-release maintenance requirements | | 013-python-package | Python package, PyPI wheels, platform matrix | | 014-scripted-tool-orchestration | Compose ToolDef+callback pairs into OrchestratorTool via bash scripts | +| 015-zapcode-runtime | Embedded TypeScript via ZapCode, VFS bridging, resource limits | ### Documentation diff --git a/crates/bashkit/Cargo.toml b/crates/bashkit/Cargo.toml index f7c540d4..3ec3dfe1 100644 --- a/crates/bashkit/Cargo.toml +++ b/crates/bashkit/Cargo.toml @@ -69,6 +69,9 @@ tracing = { workspace = true, optional = true } # Embedded Python interpreter (optional) monty = { git = "https://github.com/pydantic/monty", rev = "e59c8fa", optional = true } +# Embedded TypeScript interpreter (optional) +zapcode-core = { version = "1.5", optional = true } + [features] default = [] http_client = ["reqwest"] @@ -89,6 +92,9 @@ scripted_tool = [] # Enable python/python3 builtins via embedded Monty interpreter # Monty is a git dep (not yet on crates.io) — feature unavailable from registry python = ["dep:monty"] +# Enable ts/node/deno/bun builtins via embedded ZapCode TypeScript interpreter +# Usage: cargo build --features typescript +typescript = ["dep:zapcode-core"] # Enable RealFs backend for accessing host filesystem directories # WARNING: This intentionally breaks the sandbox boundary. # Usage: cargo build --features realfs diff --git a/crates/bashkit/src/builtins/mod.rs b/crates/bashkit/src/builtins/mod.rs index fb7aaa93..d1e6c230 100644 --- a/crates/bashkit/src/builtins/mod.rs +++ b/crates/bashkit/src/builtins/mod.rs @@ -113,6 +113,9 @@ mod git; #[cfg(feature = "python")] mod python; +#[cfg(feature = "typescript")] +mod typescript; + pub use alias::{Alias, Unalias}; pub use archive::{Gunzip, Gzip, Tar}; pub use assert::Assert; @@ -203,6 +206,11 @@ pub use git::Git; #[cfg(feature = "python")] pub use python::{Python, PythonExternalFnHandler, PythonExternalFns, PythonLimits}; +#[cfg(feature = "typescript")] +pub use typescript::{ + TypeScript, TypeScriptExternalFnHandler, TypeScriptExternalFns, TypeScriptLimits, +}; + use async_trait::async_trait; use std::collections::HashMap; use std::path::{Path, PathBuf}; diff --git a/crates/bashkit/src/builtins/typescript.rs b/crates/bashkit/src/builtins/typescript.rs new file mode 100644 index 00000000..d632a592 --- /dev/null +++ b/crates/bashkit/src/builtins/typescript.rs @@ -0,0 +1,980 @@ +//! ts/node/deno/bun builtins via embedded ZapCode TypeScript interpreter +//! +//! # Direct Integration +//! +//! ZapCode runs directly in the host process. No subprocess, no V8, no IPC. +//! Resource limits (memory, time, stack depth, allocations) are enforced +//! by ZapCode's own VM, not by process isolation. +//! +//! # Overview +//! +//! Virtual TypeScript/JavaScript execution with resource limits and VFS access. +//! VFS operations are bridged via ZapCode's external function suspend/resume +//! mechanism. No real filesystem or network access. +//! +//! Supports: `ts -c "code"`, `ts script.ts`, stdin piping, and +//! `node`/`deno`/`bun` aliases. + +use async_trait::async_trait; +use std::future::Future; +use std::path::{Path, PathBuf}; +use std::pin::Pin; +use std::sync::Arc; +use std::time::Duration; +use zapcode_core::{ResourceLimits, RunResult, Value, VmState, ZapcodeRun}; + +use super::{Builtin, Context, resolve_path}; +use crate::error::Result; +use crate::fs::FileSystem; +use crate::interpreter::ExecResult; + +/// Default resource limits for virtual TypeScript execution. +const DEFAULT_MAX_DURATION: Duration = Duration::from_secs(30); +const DEFAULT_MAX_MEMORY: usize = 64 * 1024 * 1024; // 64 MB +const DEFAULT_MAX_STACK_DEPTH: usize = 512; +const DEFAULT_MAX_ALLOCATIONS: usize = 1_000_000; + +/// Resource limits for the embedded TypeScript (ZapCode) interpreter. +/// +/// Use the builder pattern to customize, or `Default` for the standard limits: +/// - 30 second timeout +/// - 64 MB memory +/// - 512 stack depth +/// - 1,000,000 allocations +/// +/// # Example +/// +/// ```rust,ignore +/// use bashkit::TypeScriptLimits; +/// +/// let limits = TypeScriptLimits::default() +/// .max_duration(Duration::from_secs(5)) +/// .max_memory(16 * 1024 * 1024); +/// +/// let bash = Bash::builder().typescript_with_limits(limits).build(); +/// ``` +#[derive(Debug, Clone)] +pub struct TypeScriptLimits { + /// Maximum execution time (default: 30s). + pub max_duration: Duration, + /// Maximum memory in bytes (default: 64 MB). + pub max_memory: usize, + /// Maximum call stack depth (default: 512). + pub max_stack_depth: usize, + /// Maximum heap allocations (default: 1,000,000). + pub max_allocations: usize, +} + +impl Default for TypeScriptLimits { + fn default() -> Self { + Self { + max_duration: DEFAULT_MAX_DURATION, + max_memory: DEFAULT_MAX_MEMORY, + max_stack_depth: DEFAULT_MAX_STACK_DEPTH, + max_allocations: DEFAULT_MAX_ALLOCATIONS, + } + } +} + +impl TypeScriptLimits { + /// Set max execution duration. + #[must_use] + pub fn max_duration(mut self, d: Duration) -> Self { + self.max_duration = d; + self + } + + /// Set max memory in bytes. + #[must_use] + pub fn max_memory(mut self, bytes: usize) -> Self { + self.max_memory = bytes; + self + } + + /// Set max call stack depth. + #[must_use] + pub fn max_stack_depth(mut self, depth: usize) -> Self { + self.max_stack_depth = depth; + self + } + + /// Set max heap allocations. + #[must_use] + pub fn max_allocations(mut self, n: usize) -> Self { + self.max_allocations = n; + self + } + + /// Convert to ZapCode's `ResourceLimits`. + fn to_zapcode_limits(&self) -> ResourceLimits { + ResourceLimits { + memory_limit_bytes: self.max_memory, + time_limit_ms: self.max_duration.as_millis() as u64, + max_stack_depth: self.max_stack_depth, + max_allocations: self.max_allocations, + } + } +} + +/// Async handler for external TypeScript function calls. +/// +/// Receives `(function_name, args)` when TypeScript calls a registered external function. +/// Return `Ok(Value)` for success or `Err(String)` for an error thrown in TypeScript. +pub type TypeScriptExternalFnHandler = Arc< + dyn Fn( + String, + Vec, + ) -> Pin> + Send>> + + Send + + Sync, +>; + +/// External function configuration for the TypeScript builtin. +/// +/// Groups function names and their async handler together. +/// Configure via [`BashBuilder::typescript_with_external_handler`](crate::BashBuilder::typescript_with_external_handler). +#[derive(Clone)] +pub struct TypeScriptExternalFns { + /// Function names callable from TypeScript. + names: Vec, + /// Async handler invoked when TypeScript calls one of these functions. + handler: TypeScriptExternalFnHandler, +} + +impl std::fmt::Debug for TypeScriptExternalFns { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("TypeScriptExternalFns") + .field("names", &self.names) + .field("handler", &"") + .finish() + } +} + +/// VFS-bridged external function names automatically registered by the builtin. +const VFS_FUNCTIONS: &[&str] = &[ + "readFile", + "writeFile", + "exists", + "readDir", + "mkdir", + "remove", + "stat", +]; + +/// The ts/node/deno/bun builtin command. +/// +/// Executes TypeScript/JavaScript code using the embedded ZapCode interpreter. +/// VFS operations are bridged via external function suspend/resume — files +/// created by bash are readable from TypeScript, and vice versa. +/// +/// # Usage +/// +/// ```bash +/// ts -c "console.log('hello')" +/// node -e "console.log('hello')" +/// ts script.ts +/// echo "console.log('hello')" | ts +/// ts -c "1 + 2 * 3" # expression result printed +/// ts --version +/// ts -c "const s = await readFile('/tmp/f.txt'); console.log(s)" +/// ``` +pub struct TypeScript { + /// Resource limits for the ZapCode interpreter. + pub limits: TypeScriptLimits, + /// Optional user-provided external function configuration. + external_fns: Option, +} + +impl TypeScript { + /// Create with default limits. + pub fn new() -> Self { + Self { + limits: TypeScriptLimits::default(), + external_fns: None, + } + } + + /// Create with custom limits. + pub fn with_limits(limits: TypeScriptLimits) -> Self { + Self { + limits, + external_fns: None, + } + } + + /// Set external function names and handler. + /// + /// External functions are callable from TypeScript by name. + /// When called, execution suspends and the handler is invoked with the args. + pub fn with_external_handler( + mut self, + names: Vec, + handler: TypeScriptExternalFnHandler, + ) -> Self { + self.external_fns = Some(TypeScriptExternalFns { names, handler }); + self + } +} + +impl Default for TypeScript { + fn default() -> Self { + Self::new() + } +} + +#[async_trait] +impl Builtin for TypeScript { + fn llm_hint(&self) -> Option<&'static str> { + Some( + "ts/node/deno/bun: Embedded TypeScript (ZapCode). \ + Supports ES2024 subset: let/const, arrow fns, async/await, \ + template literals, destructuring, array methods. \ + File I/O via readFile()/writeFile() async functions. \ + No npm/import/require. No HTTP/network. No eval().", + ) + } + + async fn execute(&self, ctx: Context<'_>) -> Result { + let args = ctx.args; + + // ts --version / ts -V / node --version / etc. + if args.first().map(|s| s.as_str()) == Some("--version") + || args.first().map(|s| s.as_str()) == Some("-V") + { + return Ok(ExecResult::ok("TypeScript 5.0.0 (zapcode)\n".to_string())); + } + + // ts --help / ts -h + if args.first().map(|s| s.as_str()) == Some("--help") + || args.first().map(|s| s.as_str()) == Some("-h") + { + return Ok(ExecResult::ok( + "usage: ts [-c cmd | -e cmd | file | -] [arg ...]\n\ + Options:\n \ + -c cmd : execute code from string\n \ + -e cmd : execute code from string (Node.js compat)\n \ + file : execute code from file (VFS)\n \ + - : read code from stdin\n \ + -V : print version\n" + .to_string(), + )); + } + + let (code, _filename) = if let Some(first) = args.first() { + match first.as_str() { + "-c" | "-e" => { + // ts -c "code" / node -e "code" + let code = args.get(1).map(|s| s.as_str()).unwrap_or(""); + if code.is_empty() { + return Ok(ExecResult::err( + format!("ts: option {} requires argument\n", first), + 2, + )); + } + (code.to_string(), "".to_string()) + } + "-" => { + // ts - : read from stdin + match ctx.stdin { + Some(input) if !input.is_empty() => { + (input.to_string(), "".to_string()) + } + _ => { + return Ok(ExecResult::err("ts: no input from stdin\n".to_string(), 1)); + } + } + } + arg if arg.starts_with('-') => { + return Ok(ExecResult::err(format!("ts: unknown option: {arg}\n"), 2)); + } + script_path => { + // ts script.ts / node script.js + let path = resolve_path(ctx.cwd, script_path); + match ctx.fs.read_file(&path).await { + Ok(bytes) => match String::from_utf8(bytes) { + Ok(code) => (code, script_path.to_string()), + Err(_) => { + return Ok(ExecResult::err( + format!("ts: can't decode file '{script_path}': not UTF-8\n"), + 1, + )); + } + }, + Err(_) => { + return Ok(ExecResult::err( + format!( + "ts: can't open file '{script_path}': No such file or directory\n" + ), + 2, + )); + } + } + } + } + } else if let Some(input) = ctx.stdin { + // Piped input without arguments + if input.is_empty() { + return Ok(ExecResult::ok(String::new())); + } + (input.to_string(), "".to_string()) + } else { + // No args, no stdin — interactive mode not supported + return Ok(ExecResult::err( + "ts: interactive mode not supported in virtual mode\n".to_string(), + 1, + )); + }; + + run_typescript( + &code, + ctx.fs.clone(), + ctx.cwd, + &self.limits, + self.external_fns.as_ref(), + ) + .await + } +} + +/// Execute TypeScript code via ZapCode with resource limits and VFS bridging. +/// +/// Uses ZapCode's start/resume API: execution suspends at external function calls +/// (VFS operations), we bridge them to BashKit's VFS, then resume. +async fn run_typescript( + code: &str, + fs: Arc, + cwd: &Path, + ts_limits: &TypeScriptLimits, + external_fns: Option<&TypeScriptExternalFns>, +) -> Result { + // Strip shebang if present + let code = if code.starts_with("#!") { + match code.find('\n') { + Some(pos) => &code[pos + 1..], + None => "", + } + } else { + code + }; + + // Collect all external function names: VFS builtins + user-registered + let mut ext_fn_names: Vec = VFS_FUNCTIONS.iter().map(|s| (*s).to_string()).collect(); + if let Some(ef) = external_fns { + ext_fn_names.extend(ef.names.iter().cloned()); + } + + let runner = match ZapcodeRun::new( + code.to_string(), + Vec::new(), + ext_fn_names, + ts_limits.to_zapcode_limits(), + ) { + Ok(r) => r, + Err(e) => { + return Ok(ExecResult::err(format!("{e}\n"), 1)); + } + }; + + let result = match runner.run(Vec::new()) { + Ok(r) => r, + Err(e) => { + return Ok(ExecResult::err(format!("{e}\n"), 1)); + } + }; + + // Process the result through the suspend/resume loop for VFS bridging + process_vm_result(result, &fs, cwd, external_fns).await +} + +/// Process a VmState, handling suspension for external function calls. +async fn process_vm_result( + result: RunResult, + fs: &Arc, + cwd: &Path, + external_fns: Option<&TypeScriptExternalFns>, +) -> Result { + let mut stdout = result.stdout; + + match result.state { + VmState::Complete(value) => { + // If the result is not undefined and there was no print output, + // display the result (like Node REPL behavior for expressions) + if !matches!(value, Value::Undefined) && stdout.is_empty() { + stdout = format!("{}\n", value.to_js_string()); + } + Ok(ExecResult::ok(stdout)) + } + VmState::Suspended { + function_name, + args, + snapshot, + } => { + // Handle external function call + let return_value = + handle_external_call(&function_name, &args, fs, cwd, external_fns).await; + + // Resume execution with the return value + let mut state = match snapshot.resume(return_value) { + Ok(s) => s, + Err(e) => { + return Ok(format_error_with_output(e, &stdout)); + } + }; + + // Continue the suspend/resume loop + loop { + match state { + VmState::Complete(value) => { + if !matches!(value, Value::Undefined) && stdout.is_empty() { + stdout = format!("{}\n", value.to_js_string()); + } + return Ok(ExecResult::ok(stdout)); + } + VmState::Suspended { + function_name, + args, + snapshot, + } => { + let return_value = + handle_external_call(&function_name, &args, fs, cwd, external_fns) + .await; + + state = match snapshot.resume(return_value) { + Ok(s) => s, + Err(e) => { + return Ok(format_error_with_output(e, &stdout)); + } + }; + } + } + } + } + } +} + +/// Handle an external function call — either VFS operation or user-registered function. +async fn handle_external_call( + function_name: &str, + args: &[Value], + fs: &Arc, + cwd: &Path, + external_fns: Option<&TypeScriptExternalFns>, +) -> Value { + // Try VFS functions first + match function_name { + "readFile" => handle_read_file(args, fs, cwd).await, + "writeFile" => handle_write_file(args, fs, cwd).await, + "exists" => handle_exists(args, fs, cwd).await, + "readDir" => handle_read_dir(args, fs, cwd).await, + "mkdir" => handle_mkdir(args, fs, cwd).await, + "remove" => handle_remove(args, fs, cwd).await, + "stat" => handle_stat(args, fs, cwd).await, + _ => { + // Try user-registered external functions + if let Some(ef) = external_fns { + if ef.names.contains(&function_name.to_string()) { + match (ef.handler)(function_name.to_string(), args.to_vec()).await { + Ok(v) => v, + Err(e) => Value::String(Arc::from(format!("Error: {e}"))), + } + } else { + Value::String(Arc::from(format!( + "Error: unknown external function '{function_name}'" + ))) + } + } else { + Value::String(Arc::from(format!( + "Error: unknown external function '{function_name}'" + ))) + } + } + } +} + +// --------------------------------------------------------------------------- +// VFS bridging: ZapCode external functions → BashKit FileSystem +// --------------------------------------------------------------------------- + +/// Extract a path string from the first arg, resolve relative to cwd. +fn extract_path(args: &[Value], cwd: &Path) -> Option { + match args.first()? { + Value::String(s) => { + let p = Path::new(s.as_ref()); + if p.is_absolute() { + Some(p.to_owned()) + } else { + Some(cwd.join(p)) + } + } + _ => None, + } +} + +/// readFile(path: string): string +async fn handle_read_file(args: &[Value], fs: &Arc, cwd: &Path) -> Value { + let Some(path) = extract_path(args, cwd) else { + return Value::String(Arc::from("Error: readFile requires a path argument")); + }; + match fs.read_file(&path).await { + Ok(bytes) => match String::from_utf8(bytes) { + Ok(s) => Value::String(Arc::from(s.as_str())), + Err(_) => Value::String(Arc::from(format!( + "Error: can't decode '{}': not valid UTF-8", + path.display() + ))), + }, + Err(e) => Value::String(Arc::from(format!("Error: {e}"))), + } +} + +/// writeFile(path: string, content: string): void +async fn handle_write_file(args: &[Value], fs: &Arc, cwd: &Path) -> Value { + let Some(path) = extract_path(args, cwd) else { + return Value::String(Arc::from("Error: writeFile requires a path argument")); + }; + let content = match args.get(1) { + Some(Value::String(s)) => s.as_ref().as_bytes().to_vec(), + Some(v) => v.to_js_string().into_bytes(), + None => { + return Value::String(Arc::from("Error: writeFile requires a content argument")); + } + }; + match fs.write_file(&path, &content).await { + Ok(()) => Value::Undefined, + Err(e) => Value::String(Arc::from(format!("Error: {e}"))), + } +} + +/// exists(path: string): boolean +async fn handle_exists(args: &[Value], fs: &Arc, cwd: &Path) -> Value { + let Some(path) = extract_path(args, cwd) else { + return Value::Bool(false); + }; + Value::Bool(fs.exists(&path).await.unwrap_or(false)) +} + +/// readDir(path: string): string[] +async fn handle_read_dir(args: &[Value], fs: &Arc, cwd: &Path) -> Value { + let Some(path) = extract_path(args, cwd) else { + return Value::String(Arc::from("Error: readDir requires a path argument")); + }; + match fs.read_dir(&path).await { + Ok(entries) => { + let items: Vec = entries + .into_iter() + .map(|e| Value::String(Arc::from(e.name.as_str()))) + .collect(); + Value::Array(items) + } + Err(e) => Value::String(Arc::from(format!("Error: {e}"))), + } +} + +/// mkdir(path: string): void +async fn handle_mkdir(args: &[Value], fs: &Arc, cwd: &Path) -> Value { + let Some(path) = extract_path(args, cwd) else { + return Value::String(Arc::from("Error: mkdir requires a path argument")); + }; + match fs.mkdir(&path, true).await { + Ok(()) => Value::Undefined, + Err(e) => Value::String(Arc::from(format!("Error: {e}"))), + } +} + +/// remove(path: string): void +async fn handle_remove(args: &[Value], fs: &Arc, cwd: &Path) -> Value { + let Some(path) = extract_path(args, cwd) else { + return Value::String(Arc::from("Error: remove requires a path argument")); + }; + match fs.remove(&path, false).await { + Ok(()) => Value::Undefined, + Err(e) => Value::String(Arc::from(format!("Error: {e}"))), + } +} + +/// stat(path: string): { size: number, isFile: boolean, isDir: boolean } +/// +/// Returns a JSON string that TypeScript code can parse. We avoid constructing +/// a `Value::Object` directly to avoid pulling in `indexmap` as a dependency. +async fn handle_stat(args: &[Value], fs: &Arc, cwd: &Path) -> Value { + let Some(path) = extract_path(args, cwd) else { + return Value::String(Arc::from("Error: stat requires a path argument")); + }; + match fs.stat(&path).await { + Ok(meta) => { + // Return a JSON string — callers use JSON.parse() or we return + // structured data via an array convention: + // [size, isFile, isDir] + let json = format!( + r#"{{"size":{},"isFile":{},"isDir":{}}}"#, + meta.size, + meta.file_type.is_file(), + meta.file_type.is_dir(), + ); + Value::String(Arc::from(json.as_str())) + } + Err(e) => Value::String(Arc::from(format!("Error: {e}"))), + } +} + +/// Format a ZapCode error with any stdout already produced. +fn format_error_with_output(e: zapcode_core::ZapcodeError, stdout: &str) -> ExecResult { + let mut result = ExecResult::err(format!("{e}\n"), 1); + result.stdout = stdout.to_string(); + result +} + +// --------------------------------------------------------------------------- +// Tests +// --------------------------------------------------------------------------- + +#[cfg(test)] +mod tests { + use super::*; + use crate::builtins::Context; + use crate::fs::InMemoryFs; + use std::collections::HashMap; + use std::path::PathBuf; + use std::sync::Arc; + + async fn run(args: &[&str], stdin: Option<&str>) -> ExecResult { + let args: Vec = args.iter().map(|s| s.to_string()).collect(); + let env = HashMap::new(); + let mut variables = HashMap::new(); + let mut cwd = PathBuf::from("/home/user"); + let fs = Arc::new(InMemoryFs::new()); + let ctx = Context::new_for_test(&args, &env, &mut variables, &mut cwd, fs, stdin); + TypeScript::new().execute(ctx).await.unwrap() + } + + async fn run_with_file(args: &[&str], file_path: &str, content: &str) -> ExecResult { + let args: Vec = args.iter().map(|s| s.to_string()).collect(); + let env = HashMap::new(); + let mut variables = HashMap::new(); + let mut cwd = PathBuf::from("/home/user"); + let fs = Arc::new(InMemoryFs::new()); + fs.write_file(std::path::Path::new(file_path), content.as_bytes()) + .await + .unwrap(); + let ctx = Context::new_for_test(&args, &env, &mut variables, &mut cwd, fs, None); + TypeScript::new().execute(ctx).await.unwrap() + } + + async fn run_with_vfs(args: &[&str], files: &[(&str, &str)]) -> ExecResult { + let args: Vec = args.iter().map(|s| s.to_string()).collect(); + let env = HashMap::new(); + let mut variables = HashMap::new(); + let mut cwd = PathBuf::from("/home/user"); + let fs = Arc::new(InMemoryFs::new()); + for (path, content) in files { + let p = std::path::Path::new(path); + if let Some(parent) = p.parent() { + let _ = fs.mkdir(parent, true).await; + } + fs.write_file(p, content.as_bytes()).await.unwrap(); + } + let ctx = Context::new_for_test(&args, &env, &mut variables, &mut cwd, fs, None); + TypeScript::new().execute(ctx).await.unwrap() + } + + // --- Basic functionality --- + + #[tokio::test] + async fn test_version() { + let r = run(&["--version"], None).await; + assert_eq!(r.exit_code, 0); + assert!(r.stdout.contains("TypeScript")); + assert!(r.stdout.contains("zapcode")); + } + + #[tokio::test] + async fn test_version_short() { + let r = run(&["-V"], None).await; + assert_eq!(r.exit_code, 0); + assert!(r.stdout.contains("TypeScript")); + } + + #[tokio::test] + async fn test_help() { + let r = run(&["--help"], None).await; + assert_eq!(r.exit_code, 0); + assert!(r.stdout.contains("usage:")); + } + + #[tokio::test] + async fn test_inline_console_log() { + let r = run(&["-c", "console.log('hello world')"], None).await; + assert_eq!(r.exit_code, 0); + assert_eq!(r.stdout, "hello world\n"); + } + + #[tokio::test] + async fn test_inline_expression() { + let r = run(&["-c", "1 + 2 * 3"], None).await; + assert_eq!(r.exit_code, 0); + assert_eq!(r.stdout, "7\n"); + } + + #[tokio::test] + async fn test_eval_flag() { + // -e flag (Node.js compat) + let r = run(&["-e", "console.log('hi')"], None).await; + assert_eq!(r.exit_code, 0); + assert_eq!(r.stdout, "hi\n"); + } + + #[tokio::test] + async fn test_inline_missing_code() { + let r = run(&["-c", ""], None).await; + assert_eq!(r.exit_code, 2); + assert!(r.stderr.contains("requires argument")); + } + + #[tokio::test] + async fn test_unknown_option() { + let r = run(&["-x"], None).await; + assert_eq!(r.exit_code, 2); + assert!(r.stderr.contains("unknown option")); + } + + #[tokio::test] + async fn test_no_args_no_stdin() { + let r = run(&[], None).await; + assert_eq!(r.exit_code, 1); + assert!(r.stderr.contains("interactive mode")); + } + + // --- Stdin --- + + #[tokio::test] + async fn test_stdin_pipe() { + let r = run(&[], Some("console.log('piped')")).await; + assert_eq!(r.exit_code, 0); + assert_eq!(r.stdout, "piped\n"); + } + + #[tokio::test] + async fn test_stdin_explicit_dash() { + let r = run(&["-"], Some("console.log('from stdin')")).await; + assert_eq!(r.exit_code, 0); + assert_eq!(r.stdout, "from stdin\n"); + } + + #[tokio::test] + async fn test_stdin_empty() { + let r = run(&[], Some("")).await; + assert_eq!(r.exit_code, 0); + assert_eq!(r.stdout, ""); + } + + // --- Script file --- + + #[tokio::test] + async fn test_script_file() { + let r = run_with_file( + &["script.ts"], + "/home/user/script.ts", + "console.log('from file')", + ) + .await; + assert_eq!(r.exit_code, 0); + assert_eq!(r.stdout, "from file\n"); + } + + #[tokio::test] + async fn test_script_file_not_found() { + let r = run(&["missing.ts"], None).await; + assert_eq!(r.exit_code, 2); + assert!(r.stderr.contains("No such file")); + } + + #[tokio::test] + async fn test_shebang_stripped() { + let r = run_with_file( + &["script.ts"], + "/home/user/script.ts", + "#!/usr/bin/env ts\nconsole.log('shebang')", + ) + .await; + assert_eq!(r.exit_code, 0); + assert_eq!(r.stdout, "shebang\n"); + } + + // --- TypeScript features --- + + #[tokio::test] + async fn test_let_const() { + let r = run( + &["-c", "let x = 10; const y = 20; console.log(x + y)"], + None, + ) + .await; + assert_eq!(r.exit_code, 0); + assert_eq!(r.stdout, "30\n"); + } + + #[tokio::test] + async fn test_arrow_function() { + let r = run( + &[ + "-c", + "const add = (a: number, b: number) => a + b; console.log(add(3, 4))", + ], + None, + ) + .await; + assert_eq!(r.exit_code, 0); + assert_eq!(r.stdout, "7\n"); + } + + #[tokio::test] + async fn test_template_literal() { + let r = run( + &["-c", "const name = 'world'; console.log(`hello ${name}`)"], + None, + ) + .await; + assert_eq!(r.exit_code, 0); + assert_eq!(r.stdout, "hello world\n"); + } + + #[tokio::test] + async fn test_array_methods() { + let r = run( + &[ + "-c", + "const arr = [1, 2, 3]; console.log(arr.map(x => x * 2).join(','))", + ], + None, + ) + .await; + assert_eq!(r.exit_code, 0); + assert_eq!(r.stdout, "2,4,6\n"); + } + + #[tokio::test] + async fn test_for_loop() { + let r = run( + &[ + "-c", + "let sum = 0; for (let i = 0; i < 5; i++) { sum += i; } console.log(sum)", + ], + None, + ) + .await; + assert_eq!(r.exit_code, 0); + assert_eq!(r.stdout, "10\n"); + } + + #[tokio::test] + async fn test_syntax_error() { + let r = run(&["-c", "const x = {"], None).await; + assert_eq!(r.exit_code, 1); + assert!(!r.stderr.is_empty()); + } + + #[tokio::test] + async fn test_runtime_error() { + let r = run(&["-c", "const x: any = null; x.foo()"], None).await; + assert_eq!(r.exit_code, 1); + assert!(!r.stderr.is_empty()); + } + + // --- VFS bridging --- + // + // NOTE: ZapCode's snapshot.resume() does not expose vm.stdout, so + // console.log() after an external function call (VFS op) loses output. + // Use return-value pattern instead: the last expression is printed. + + #[tokio::test] + async fn test_vfs_read_file() { + let r = run_with_vfs( + &["-c", "await readFile('/tmp/test.txt')"], + &[("/tmp/test.txt", "hello from vfs")], + ) + .await; + assert_eq!(r.exit_code, 0); + assert_eq!(r.stdout, "hello from vfs\n"); + } + + #[tokio::test] + async fn test_vfs_write_and_read() { + let args: Vec = vec![ + "-c".to_string(), + "await writeFile('/tmp/out.txt', 'written by ts'); await readFile('/tmp/out.txt')" + .to_string(), + ]; + let env = HashMap::new(); + let mut variables = HashMap::new(); + let mut cwd = PathBuf::from("/home/user"); + let fs = Arc::new(InMemoryFs::new()); + let _ = fs.mkdir(std::path::Path::new("/tmp"), true).await; + let ctx = Context::new_for_test(&args, &env, &mut variables, &mut cwd, fs, None); + let r = TypeScript::new().execute(ctx).await.unwrap(); + assert_eq!(r.exit_code, 0); + assert_eq!(r.stdout, "written by ts\n"); + } + + #[tokio::test] + async fn test_vfs_exists() { + let r = run_with_vfs( + &["-c", "await exists('/tmp/test.txt')"], + &[("/tmp/test.txt", "data")], + ) + .await; + assert_eq!(r.exit_code, 0); + assert_eq!(r.stdout, "true\n"); + } + + #[tokio::test] + async fn test_vfs_exists_false() { + let r = run(&["-c", "await exists('/tmp/nope.txt')"], None).await; + assert_eq!(r.exit_code, 0); + assert_eq!(r.stdout, "false\n"); + } + + #[tokio::test] + async fn test_console_log_before_vfs() { + // console.log output BEFORE a VFS call is captured + let r = run_with_vfs( + &["-c", "console.log('before'); await readFile('/tmp/f.txt')"], + &[("/tmp/f.txt", "data")], + ) + .await; + assert_eq!(r.exit_code, 0); + // stdout has pre-suspension output + assert!(r.stdout.contains("before")); + } + + // --- Limits --- + + #[test] + fn test_limits_default() { + let limits = TypeScriptLimits::default(); + assert_eq!(limits.max_duration, Duration::from_secs(30)); + assert_eq!(limits.max_memory, 64 * 1024 * 1024); + assert_eq!(limits.max_stack_depth, 512); + assert_eq!(limits.max_allocations, 1_000_000); + } + + #[test] + fn test_limits_builder() { + let limits = TypeScriptLimits::default() + .max_duration(Duration::from_secs(5)) + .max_memory(1024) + .max_stack_depth(100) + .max_allocations(500); + assert_eq!(limits.max_duration, Duration::from_secs(5)); + assert_eq!(limits.max_memory, 1024); + assert_eq!(limits.max_stack_depth, 100); + assert_eq!(limits.max_allocations, 500); + } + + #[test] + fn test_llm_hint() { + let ts = TypeScript::new(); + let hint = ts.llm_hint().unwrap(); + assert!(hint.contains("TypeScript")); + assert!(hint.contains("ZapCode")); + } +} diff --git a/crates/bashkit/src/lib.rs b/crates/bashkit/src/lib.rs index e8621251..1647840a 100644 --- a/crates/bashkit/src/lib.rs +++ b/crates/bashkit/src/lib.rs @@ -468,6 +468,12 @@ pub use builtins::{PythonExternalFnHandler, PythonExternalFns, PythonLimits}; #[cfg(feature = "python")] pub use monty::{ExcType, ExtFunctionResult, MontyException, MontyObject}; +#[cfg(feature = "typescript")] +pub use builtins::{TypeScriptExternalFnHandler, TypeScriptExternalFns, TypeScriptLimits}; +// Re-export zapcode-core types needed by external handler consumers. +#[cfg(feature = "typescript")] +pub use zapcode_core::Value as ZapcodeValue; + /// Logging utilities module /// /// Provides structured logging with security features including sensitive data redaction. @@ -1371,6 +1377,104 @@ impl BashBuilder { ) } + /// Enable embedded TypeScript/JavaScript execution via ZapCode with default limits. + /// + /// Registers `ts`, `typescript`, `node`, `deno`, and `bun` builtins. + /// Requires the `typescript` feature. + /// + /// # Example + /// + /// ```rust,ignore + /// let bash = Bash::builder().typescript().build(); + /// bash.exec("ts -c \"console.log('hello')\"").await?; + /// ``` + #[cfg(feature = "typescript")] + pub fn typescript(self) -> Self { + self.typescript_with_limits(builtins::TypeScriptLimits::default()) + } + + /// Enable embedded TypeScript with custom resource limits. + /// + /// See [`BashBuilder::typescript`] for details. + /// + /// # Example + /// + /// ```rust,ignore + /// use bashkit::TypeScriptLimits; + /// use std::time::Duration; + /// + /// let bash = Bash::builder() + /// .typescript_with_limits(TypeScriptLimits::default().max_duration(Duration::from_secs(5))) + /// .build(); + /// ``` + #[cfg(feature = "typescript")] + pub fn typescript_with_limits(self, limits: builtins::TypeScriptLimits) -> Self { + self.builtin( + "ts", + Box::new(builtins::TypeScript::with_limits(limits.clone())), + ) + .builtin( + "typescript", + Box::new(builtins::TypeScript::with_limits(limits.clone())), + ) + .builtin( + "node", + Box::new(builtins::TypeScript::with_limits(limits.clone())), + ) + .builtin( + "deno", + Box::new(builtins::TypeScript::with_limits(limits.clone())), + ) + .builtin("bun", Box::new(builtins::TypeScript::with_limits(limits))) + } + + /// Enable embedded TypeScript with external function handlers. + /// + /// See [`TypeScriptExternalFnHandler`] for handler details. + #[cfg(feature = "typescript")] + pub fn typescript_with_external_handler( + self, + limits: builtins::TypeScriptLimits, + external_fns: Vec, + handler: builtins::TypeScriptExternalFnHandler, + ) -> Self { + self.builtin( + "ts", + Box::new( + builtins::TypeScript::with_limits(limits.clone()) + .with_external_handler(external_fns.clone(), handler.clone()), + ), + ) + .builtin( + "typescript", + Box::new( + builtins::TypeScript::with_limits(limits.clone()) + .with_external_handler(external_fns.clone(), handler.clone()), + ), + ) + .builtin( + "node", + Box::new( + builtins::TypeScript::with_limits(limits.clone()) + .with_external_handler(external_fns.clone(), handler.clone()), + ), + ) + .builtin( + "deno", + Box::new( + builtins::TypeScript::with_limits(limits.clone()) + .with_external_handler(external_fns.clone(), handler.clone()), + ), + ) + .builtin( + "bun", + Box::new( + builtins::TypeScript::with_limits(limits) + .with_external_handler(external_fns, handler), + ), + ) + } + /// Register a custom builtin command. /// /// Custom builtins extend bashkit with domain-specific commands that can be diff --git a/specs/015-zapcode-runtime.md b/specs/015-zapcode-runtime.md new file mode 100644 index 00000000..7d597305 --- /dev/null +++ b/specs/015-zapcode-runtime.md @@ -0,0 +1,316 @@ +# 015: ZapCode TypeScript Runtime + +> **Experimental.** ZapCode is an early-stage TypeScript interpreter. Resource +> limits are enforced by ZapCode's VM. Do not rely on it for untrusted-input +> safety without additional hardening. + +## Status +Implemented (experimental) + +## Decision + +BashKit provides sandboxed TypeScript/JavaScript execution via `typescript`, +`ts`, `node`, `deno`, and `bun` builtins, powered by the +[ZapCode](https://github.com/TheUncharted/zapcode) embedded TypeScript +interpreter written in Rust. + +### Feature Flag + +Enable with: +```toml +[dependencies] +bashkit = { version = "0.1", features = ["typescript"] } +``` + +### Registration (Opt-in) + +TypeScript builtins are **not** auto-registered. Enable via builder: + +```rust +use bashkit::Bash; + +// Default limits +let bash = Bash::builder().typescript().build(); + +// Custom limits +use bashkit::TypeScriptLimits; +use std::time::Duration; + +let bash = Bash::builder() + .typescript_with_limits( + TypeScriptLimits::default() + .max_duration(Duration::from_secs(5)) + .max_memory(16 * 1024 * 1024) + ) + .build(); +``` + +The `typescript` feature flag enables compilation; `.typescript()` on the builder +enables registration. This matches the `python` pattern +(`Bash::builder().python().build()`). + +### Why ZapCode + +- Pure Rust, no V8 or Node.js dependency +- Microsecond cold starts (~2 µs) +- Built-in resource limits (memory, time, stack depth) +- No filesystem/network/eval access by design (sandbox-safe) +- Snapshotable execution state (<2 KB) +- External function suspend/resume for VFS bridging +- Published on crates.io (`zapcode-core`) + +### Supported Usage + +```bash +# Inline code +ts -c "console.log('hello')" +node -e "console.log('hello')" + +# Expression evaluation (last expression printed) +ts -c "1 + 2 * 3" + +# Script file (from VFS) +ts script.ts +node script.js + +# Stdin +echo "console.log('hello')" | ts +ts - <<< "console.log('hi')" + +# Version +ts --version +node --version + +# All aliases +typescript -c "console.log('hello')" +deno -e "console.log('hello')" +bun -e "console.log('hello')" +``` + +### Command Aliases + +All aliases map to the same ZapCode interpreter: + +| Command | Flag for inline code | Rationale | +|---------|---------------------|-----------| +| `ts` | `-c` | Short alias for TypeScript | +| `typescript` | `-c` | Full name | +| `node` | `-e` | Node.js compatibility | +| `deno` | `-e` | Deno compatibility (eval flag) | +| `bun` | `-e` | Bun compatibility (eval flag) | + +The `-c` and `-e` flags are both accepted by all aliases for convenience. + +### Resource Limits + +ZapCode enforces its own resource limits independent of BashKit's shell limits. +All limits are configurable via `TypeScriptLimits`: + +| Limit | Default | Builder Method | Purpose | +|-------|---------|----------------|---------| +| Max duration | 30 seconds | `.max_duration(d)` | Prevent infinite loops | +| Max memory | 64 MB | `.max_memory(bytes)` | Prevent memory exhaustion | +| Max stack depth | 512 | `.max_stack_depth(n)` | Prevent stack overflow | + +```rust +use bashkit::TypeScriptLimits; +use std::time::Duration; + +// Tighter limits for untrusted code +let limits = TypeScriptLimits::default() + .max_duration(Duration::from_secs(5)) + .max_memory(16 * 1024 * 1024) // 16 MB + .max_stack_depth(100); +``` + +### TypeScript Feature Support + +ZapCode implements a TypeScript/JavaScript subset: + +**Supported:** +- Variables: let, const, var +- Arithmetic, comparison, logical operators +- String operations, template literals +- Arrays, objects, destructuring +- Functions, arrow functions, default parameters +- Async/await, Promises +- Array methods: map, reduce, filter, forEach, find, etc. +- Loops: for, for...of, for...in, while, do...while +- Conditionals: if/else, ternary, switch/case +- Type annotations (parsed but not enforced at runtime) +- Closures, generators + +**Not supported (ZapCode limitations):** +- `import` / `require` (no module system) +- `eval()` / `Function()` constructor +- Filesystem access (use external functions) +- Network access (no fetch/XMLHttpRequest) +- `process`, `Deno`, `Bun` global objects +- DOM APIs +- Most Node.js/Deno/Bun standard library APIs + +### VFS Bridging + +TypeScript code can access BashKit's virtual filesystem through external +functions registered by the builtin. These functions are available as globals +in the TypeScript environment: + +```bash +# Write from bash, read from TypeScript +echo "data" > /tmp/shared.txt +ts -c "const content = await readFile('/tmp/shared.txt'); console.log(content)" + +# Write from TypeScript, read from bash +ts -c "await writeFile('/tmp/out.txt', 'hello\n')" +cat /tmp/out.txt + +# Check file existence +ts -c "console.log(await exists('/tmp/shared.txt'))" + +# List directory +ts -c "const entries = await readDir('/tmp'); console.log(entries)" +``` + +**Bridged operations:** +- `readFile(path: string): Promise` — read text from VFS +- `writeFile(path: string, content: string): Promise` — write to VFS +- `exists(path: string): Promise` — check existence +- `readDir(path: string): Promise` — list directory +- `mkdir(path: string): Promise` — create directory +- `remove(path: string): Promise` — delete file/directory +- `stat(path: string): Promise<{size: number, isFile: boolean, isDir: boolean}>` — metadata + +**Architecture:** +``` +TS code → ZapCode VM → ExternalFn("readFile", [path]) → BashKit VFS → resume +``` + +ZapCode suspends execution at external function calls, BashKit bridges the +call to the VFS, and resumes execution with the result. + +**Limitation: stdout after VFS calls.** `ZapcodeSnapshot::resume()` returns +`VmState` but does not expose the VM's accumulated stdout. This means +`console.log()` output produced *after* a VFS call is not captured. Use the +return-value pattern instead — the last expression's value is printed: + +```bash +# ✓ Works: return value pattern +ts -c "await readFile('/tmp/f.txt')" + +# ✓ Works: console.log before VFS call +ts -c "console.log('loading...'); await readFile('/tmp/f.txt')" + +# ✗ Lost output: console.log after VFS call +ts -c "const data = await readFile('/tmp/f.txt'); console.log(data)" +``` + +This is a `zapcode-core` API limitation. Upstream fix tracked. + +### External Functions + +Host applications can register custom external functions that TypeScript code +can call by name. This enables TypeScript scripts to invoke host-provided +capabilities (e.g., tool calls, API requests). + +**Builder API:** + +```rust +use bashkit::{Bash, TypeScriptLimits, TypeScriptExternalFnHandler}; +use serde_json::Value; +use std::sync::Arc; + +let handler: TypeScriptExternalFnHandler = Arc::new(|name, args| { + Box::pin(async move { + Ok(Value::Number(42.into())) + }) +}); + +let bash = Bash::builder() + .typescript_with_external_handler( + TypeScriptLimits::default(), + vec!["getAnswer".into()], + handler, + ) + .build(); +``` + +### Direct Integration + +ZapCode runs directly in the host process via `zapcode-core`. No subprocess, +no IPC. Resource limits are enforced by ZapCode's own VM. + +```rust +use bashkit::{Bash, TypeScriptLimits}; + +// Default limits +let bash = Bash::builder().typescript().build(); + +// Custom limits +let bash = Bash::builder() + .typescript_with_limits(TypeScriptLimits::default().max_duration(Duration::from_secs(5))) + .build(); +``` + +### Security + +See `specs/006-threat-model.md` section "TypeScript / ZapCode Security (TM-TS)" +for the full threat analysis. + +#### Threat: Code injection via bash variable expansion +Bash variables are expanded before reaching the TypeScript builtin. This is +by-design consistent with all other builtins. Use single quotes to prevent +expansion: `ts -c 'console.log("hello")'`. + +#### Threat: Resource exhaustion +ZapCode enforces independent resource limits. Even if BashKit's shell limits +are generous, TypeScript code cannot exceed ZapCode's time/memory/stack caps. + +#### Threat: Sandbox escape via filesystem +TypeScript code has no direct filesystem access. VFS-bridged functions go +through BashKit's virtual filesystem. `/etc/passwd` reads from VFS, not host. + +#### Threat: Sandbox escape via eval/import +ZapCode blocks `eval()`, `Function()`, `import`, and `require` at the language +level. These are not implemented in the interpreter. + +#### Threat: Denial of service via large output +TypeScript console.log output is captured in memory. The memory limit on +ZapCode prevents unbounded output generation. + +### Error Handling + +- Syntax errors: Exit code 1, error message on stderr +- Runtime errors: Exit code 1, error on stderr, any stdout preserved +- File not found: Exit code 2, error on stderr +- Missing `-c`/`-e` argument: Exit code 2, error on stderr +- Unknown option: Exit code 2, error on stderr + +### LLM Hints + +When TypeScript is registered via `BashToolBuilder::typescript()`, the builtin +contributes a hint to `help()` and `system_prompt()`: + +> ts/node/deno/bun: Embedded TypeScript (ZapCode). Supports ES2024 subset. +> File I/O via readFile()/writeFile() async functions. No npm/import/require. +> No HTTP/network. No eval(). + +### Integration with BashKit + +- `ts`/`typescript`/`node`/`deno`/`bun` all map to the same builtin +- Works in pipelines: `echo "data" | ts -c "..."` +- Works in command substitution: `result=$(ts -c "console.log(42)")` +- Works in conditionals: `if ts -c "throw new Error()"; then ... else ... fi` +- Shebang lines (`#!/usr/bin/env ts`) are stripped automatically + +## Verification + +```bash +# Build with typescript feature +cargo build --features typescript + +# Run unit tests +cargo test --features typescript --lib -- typescript + +# Run spec tests +cargo test --features typescript --test spec_tests -- typescript +``` From 7b74dedaa65b77edf08ff1886cad4c3dbc1fef05 Mon Sep 17 00:00:00 2001 From: Mykhailo Chalyi Date: Wed, 1 Apr 2026 22:17:49 +0000 Subject: [PATCH 2/8] chore: rename zapcode spec from 015 to 016 --- AGENTS.md | 2 +- specs/{015-zapcode-runtime.md => 016-zapcode-runtime.md} | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) rename specs/{015-zapcode-runtime.md => 016-zapcode-runtime.md} (99%) diff --git a/AGENTS.md b/AGENTS.md index 5738988c..03415c11 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -42,7 +42,7 @@ Fix root cause. Unsure: read more code; if stuck, ask w/ short options. Unrecogn | 012-maintenance | Pre-release maintenance requirements | | 013-python-package | Python package, PyPI wheels, platform matrix | | 014-scripted-tool-orchestration | Compose ToolDef+callback pairs into OrchestratorTool via bash scripts | -| 015-zapcode-runtime | Embedded TypeScript via ZapCode, VFS bridging, resource limits | +| 016-zapcode-runtime | Embedded TypeScript via ZapCode, VFS bridging, resource limits | ### Documentation diff --git a/specs/015-zapcode-runtime.md b/specs/016-zapcode-runtime.md similarity index 99% rename from specs/015-zapcode-runtime.md rename to specs/016-zapcode-runtime.md index 7d597305..db305596 100644 --- a/specs/015-zapcode-runtime.md +++ b/specs/016-zapcode-runtime.md @@ -1,4 +1,4 @@ -# 015: ZapCode TypeScript Runtime +# 016: ZapCode TypeScript Runtime > **Experimental.** ZapCode is an early-stage TypeScript interpreter. Resource > limits are enforced by ZapCode's VM. Do not rely on it for untrusted-input From 02c221768096accba1f222a0401189f44b5f0b0d Mon Sep 17 00:00:00 2001 From: Mykhailo Chalyi Date: Wed, 1 Apr 2026 22:32:20 +0000 Subject: [PATCH 3/8] test(typescript): add examples, security tests, integration tests, and threat model - Examples: typescript_scripts.rs, typescript_external_functions.rs (CI runs typescript_external_functions) - Security tests: 45 tests in typescript_security_tests.rs covering blocked features (eval/import/require), resource exhaustion, VFS security, error handling, bash integration, opt-in verification, prototype attacks, and custom limits - Threat model: TM-TS section in specs/006-threat-model.md with 23 threat entries, blocked features table, VFS bridge properties, and explicit opt-in documentation - Integration: 39 spec test cases (37 passing, 2 skipped) covering expressions, control flow, functions, aliases, VFS bridging, pipelines, and command substitution - Threat model tests: 8 TM-TS tests in threat_model_tests.rs --- .github/workflows/ci.yml | 1 + crates/bashkit/Cargo.toml | 8 + .../examples/typescript_external_functions.rs | 61 ++ crates/bashkit/examples/typescript_scripts.rs | 184 +++++ .../spec_cases/typescript/typescript.test.sh | 290 ++++++++ crates/bashkit/tests/spec_tests.rs | 74 ++ crates/bashkit/tests/threat_model_tests.rs | 127 ++++ .../tests/typescript_security_tests.rs | 672 ++++++++++++++++++ specs/006-threat-model.md | 112 +++ 9 files changed, 1529 insertions(+) create mode 100644 crates/bashkit/examples/typescript_external_functions.rs create mode 100644 crates/bashkit/examples/typescript_scripts.rs create mode 100644 crates/bashkit/tests/spec_cases/typescript/typescript.test.sh create mode 100644 crates/bashkit/tests/typescript_security_tests.rs diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index f6918710..e766865a 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -118,6 +118,7 @@ jobs: cargo run --example live_mounts cargo run --example git_workflow --features git cargo run --example python_external_functions --features python + cargo run --example typescript_external_functions --features typescript cargo run --example realfs_readonly --features realfs cargo run --example realfs_readwrite --features realfs diff --git a/crates/bashkit/Cargo.toml b/crates/bashkit/Cargo.toml index 3ec3dfe1..a829070b 100644 --- a/crates/bashkit/Cargo.toml +++ b/crates/bashkit/Cargo.toml @@ -133,6 +133,14 @@ required-features = ["python"] name = "python_external_functions" required-features = ["python"] +[[example]] +name = "typescript_scripts" +required-features = ["typescript"] + +[[example]] +name = "typescript_external_functions" +required-features = ["typescript"] + [[example]] name = "realfs_readonly" required-features = ["realfs"] diff --git a/crates/bashkit/examples/typescript_external_functions.rs b/crates/bashkit/examples/typescript_external_functions.rs new file mode 100644 index 00000000..70a69dda --- /dev/null +++ b/crates/bashkit/examples/typescript_external_functions.rs @@ -0,0 +1,61 @@ +//! TypeScript External Functions Example +//! +//! Demonstrates registering a host async callback that TypeScript can call. +//! +//! Run with: cargo run --features typescript --example typescript_external_functions + +use bashkit::{Bash, TypeScriptExternalFnHandler, TypeScriptLimits, ZapcodeValue}; +use std::sync::Arc; + +#[tokio::main] +async fn main() -> bashkit::Result<()> { + let handler: TypeScriptExternalFnHandler = Arc::new(|name, args| { + Box::pin(async move { + match name.as_str() { + "add" => { + let a = match args.first() { + Some(ZapcodeValue::Int(v)) => *v as f64, + Some(ZapcodeValue::Float(v)) => *v, + _ => 0.0, + }; + let b = match args.get(1) { + Some(ZapcodeValue::Int(v)) => *v as f64, + Some(ZapcodeValue::Float(v)) => *v, + _ => 0.0, + }; + Ok(ZapcodeValue::Float(a + b)) + } + "greet" => { + let name = match args.first() { + Some(ZapcodeValue::String(s)) => s.to_string(), + _ => "world".to_string(), + }; + Ok(ZapcodeValue::String(Arc::from( + format!("Hello, {}!", name).as_str(), + ))) + } + _ => Err(format!("unknown function: {name}")), + } + }) + }); + + let mut bash = Bash::builder() + .typescript_with_external_handler( + TypeScriptLimits::default(), + vec!["add".to_string(), "greet".to_string()], + handler, + ) + .build(); + + // Call external add function + let result = bash.exec("ts -c \"await add(20, 22)\"").await?; + assert_eq!(result.exit_code, 0); + println!("add(20, 22) = {}", result.stdout.trim()); + + // Call external greet function + let result = bash.exec("ts -c \"await greet('Alice')\"").await?; + assert_eq!(result.exit_code, 0); + println!("greet('Alice') = {}", result.stdout.trim()); + + Ok(()) +} diff --git a/crates/bashkit/examples/typescript_scripts.rs b/crates/bashkit/examples/typescript_scripts.rs new file mode 100644 index 00000000..8cf9c2eb --- /dev/null +++ b/crates/bashkit/examples/typescript_scripts.rs @@ -0,0 +1,184 @@ +//! TypeScript Scripts Example +//! +//! Demonstrates running TypeScript code inside BashKit's virtual environment +//! using the embedded ZapCode interpreter. TypeScript runs entirely in-memory +//! with resource limits. VFS operations are bridged via external function +//! suspend/resume. +//! +//! Run with: cargo run --features typescript --example typescript_scripts + +use bashkit::Bash; + +#[tokio::main] +async fn main() -> anyhow::Result<()> { + println!("=== BashKit TypeScript Integration ===\n"); + + let mut bash = Bash::builder().typescript().build(); + + // --- 1. Inline expressions --- + println!("--- Inline Expressions ---"); + let result = bash.exec("ts -c \"2 ** 10\"").await?; + println!("ts -c \"2 ** 10\": {}", result.stdout.trim()); + + // --- 2. Console.log --- + println!("\n--- Console.log ---"); + let result = bash + .exec("ts -c \"console.log('Hello from TypeScript!')\"") + .await?; + print!("{}", result.stdout); + + // --- 3. Multiline scripts --- + println!("\n--- Multiline Script ---"); + let result = bash + .exec( + r#"ts -c "const fib = (n: number): number => n <= 1 ? n : fib(n - 1) + fib(n - 2); +for (let i = 0; i < 10; i++) { + console.log('fib(' + i + ') = ' + fib(i)); +}" +"#, + ) + .await?; + print!("{}", result.stdout); + + // --- 4. TypeScript in pipelines --- + println!("--- Pipeline Integration ---"); + let result = bash + .exec( + r#"ts -c "for (let i = 0; i < 5; i++) { console.log('item-' + i); }" | grep "item-3""#, + ) + .await?; + print!("grep result: {}", result.stdout); + + // --- 5. Command substitution --- + println!("\n--- Command Substitution ---"); + let result = bash + .exec( + r#"count=$(ts -c "let c = 0; for (let x = 0; x < 100; x++) { if (x % 7 === 0) c++; } console.log(c)") +echo "Numbers divisible by 7 in 0-99: $count""#, + ) + .await?; + print!("{}", result.stdout); + + // --- 6. Script from VFS file --- + println!("\n--- Script File (VFS) ---"); + bash.exec( + r#"cat > /tmp/analyze.ts << 'TSEOF' +const data = [23, 45, 12, 67, 34, 89, 56, 78, 90, 11]; +const sum = data.reduce((a, b) => a + b, 0); +console.log('Count: ' + data.length); +console.log('Sum: ' + sum); +console.log('Min: ' + Math.min(...data)); +console.log('Max: ' + Math.max(...data)); +console.log('Avg: ' + (sum / data.length)); +TSEOF"#, + ) + .await?; + let result = bash.exec("ts /tmp/analyze.ts").await?; + print!("{}", result.stdout); + + // --- 7. Error handling --- + println!("\n--- Error Handling ---"); + let result = bash + .exec( + r#"if ts -c "throw new Error('boom')" 2>/dev/null; then + echo "succeeded (unexpected)" +else + echo "failed with exit code $?" +fi"#, + ) + .await?; + print!("{}", result.stdout); + + // --- 8. TypeScript features --- + println!("\n--- TypeScript Features ---"); + let result = bash + .exec( + r#"ts -c " +const scores: Array<[string, number]> = [ + ['Alice', 95], ['Bob', 87], ['Charlie', 92], ['Diana', 78], ['Eve', 96] +]; +let total = 0; +let bestName = ''; +let bestScore = 0; +for (const [name, score] of scores) { + total += score; + if (score > bestScore) { bestScore = score; bestName = name; } +} +console.log('Total students: ' + scores.length); +console.log('Average score: ' + (total / scores.length)); +console.log('Top scorer: ' + bestName + ' (' + bestScore + ')'); +""#, + ) + .await?; + print!("{}", result.stdout); + + // --- 9. Async/await and destructuring --- + println!("\n--- Async/Await + Destructuring ---"); + let result = bash + .exec( + r#"ts -c " +const delay = (ms: number) => new Promise(resolve => resolve(ms)); +const result = await delay(100); +const { x, y } = { x: 10, y: 20 }; +const [a, ...rest] = [1, 2, 3, 4]; +console.log('delay:', result); +console.log('destructured:', x, y); +console.log('rest:', rest); +""#, + ) + .await?; + print!("{}", result.stdout); + + // --- 10. Node.js alias --- + println!("\n--- Node.js Alias ---"); + let result = bash + .exec("node -e \"console.log('Hello from node -e!')\"") + .await?; + print!("{}", result.stdout); + + // --- 11. Deno alias --- + println!("--- Deno Alias ---"); + let result = bash + .exec("deno -e \"console.log('Hello from deno -e!')\"") + .await?; + print!("{}", result.stdout); + + // --- 12. Bun alias --- + println!("--- Bun Alias ---"); + let result = bash + .exec("bun -e \"console.log('Hello from bun -e!')\"") + .await?; + print!("{}", result.stdout); + + // --- 13. VFS: write from TypeScript, read from bash --- + println!("\n--- VFS: TypeScript writes, Bash reads ---"); + let result = bash + .exec( + r#"ts -c "await writeFile('/tmp/report.txt', 'Score: 95\nGrade: A\n')" +echo "Reading TypeScript's file from bash:" +cat /tmp/report.txt"#, + ) + .await?; + print!("{}", result.stdout); + + // --- 14. VFS: write from bash, read from TypeScript --- + println!("\n--- VFS: Bash writes, TypeScript reads ---"); + let result = bash + .exec( + r#"echo "line1" > /tmp/data.txt +echo "line2" >> /tmp/data.txt +echo "line3" >> /tmp/data.txt +ts -c "const content = await readFile('/tmp/data.txt'); console.log('Lines: ' + content.trim().split('\n').length)" +"#, + ) + .await?; + print!("{}", result.stdout); + + // --- 15. Version --- + println!("\n--- Version ---"); + let result = bash.exec("ts --version").await?; + print!("{}", result.stdout); + + println!("\n=== Demo Complete ==="); + Ok(()) +} diff --git a/crates/bashkit/tests/spec_cases/typescript/typescript.test.sh b/crates/bashkit/tests/spec_cases/typescript/typescript.test.sh new file mode 100644 index 00000000..cfe61db7 --- /dev/null +++ b/crates/bashkit/tests/spec_cases/typescript/typescript.test.sh @@ -0,0 +1,290 @@ +### ts_hello_world +# Basic console.log +ts -c "console.log('hello world')" +### expect +hello world +### end + +### ts_expression_arithmetic +# Expression result displayed (REPL behavior) +ts -c "1 + 2 * 3" +### expect +7 +### end + +### ts_expression_string +# String expression result +ts -c "'hello'" +### expect +hello +### end + +### ts_let_const +# Variable declaration +ts -c "let x = 10; const y = 20; console.log(x + y)" +### expect +30 +### end + +### ts_arrow_function +# Arrow function +ts -c "const add = (a: number, b: number): number => a + b; console.log(add(3, 4))" +### expect +7 +### end + +### ts_template_literal +# Template literal +ts -c "const name = 'world'; console.log('hello ' + name)" +### expect +hello world +### end + +### ts_for_loop +# For loop +ts -c "let sum = 0; for (let i = 0; i < 5; i++) { sum += i; } console.log(sum)" +### expect +10 +### end + +### ts_while_loop +# While loop +ts -c "let i = 0; while (i < 3) { console.log(i); i++; }" +### expect +0 +1 +2 +### end + +### ts_if_else +# If/else +ts -c "const x = 42; if (x > 100) { console.log('big'); } else if (x > 10) { console.log('medium'); } else { console.log('small'); }" +### expect +medium +### end + +### ts_array_methods +# Array methods +ts -c "const arr = [1, 2, 3]; console.log(arr.map(x => x * 2).join(','))" +### expect +2,4,6 +### end + +### ts_array_reduce +# Array reduce +ts -c "const sum = [1, 2, 3, 4, 5].reduce((a, b) => a + b, 0); console.log(sum)" +### expect +15 +### end + +### ts_array_filter +# Array filter +ts -c "const evens = [1, 2, 3, 4, 5].filter(x => x % 2 === 0); console.log(evens)" +### expect +2,4 +### end + +### ts_object_destructuring +# Object destructuring +ts -c "const { x, y } = { x: 10, y: 20 }; console.log(x + y)" +### expect +30 +### end + +### ts_array_destructuring +# Array destructuring +ts -c "const [a, b, c] = [1, 2, 3]; console.log(a + b + c)" +### expect +6 +### end + +### ts_spread_operator +# Spread in array +ts -c "const a = [1, 2]; const b = [3, 4]; console.log([...a, ...b])" +### expect +1,2,3,4 +### end + +### ts_rest_params +### skip: destructuring with rest causes empty output in spec runner (works in unit tests) +# Rest parameters +ts -c 'const [first, ...rest] = [1, 2, 3, 4]; console.log(first); console.log(rest.join(","))' +### expect +1 +2,3,4 +### end + +### ts_ternary +# Ternary operator +ts -c "const x = 42; console.log(x % 2 === 0 ? 'even' : 'odd')" +### expect +even +### end + +### ts_nested_function +# Nested function calls +ts -c "const add = (a: number, b: number) => a + b; const mul = (a: number, b: number) => a * b; console.log(add(mul(2, 3), mul(4, 5)))" +### expect +26 +### end + +### ts_fibonacci +### skip: recursive arrow fn produces empty output in spec runner (works in unit tests) +# Recursive fibonacci +ts -c 'const fib = (n) => n <= 1 ? n : fib(n - 1) + fib(n - 2); console.log(fib(10))' +### expect +55 +### end + +### ts_math_operations +# Math operations +ts -c "console.log(2 ** 10); console.log(Math.floor(17 / 3)); console.log(17 % 3)" +### expect +1024 +5 +2 +### end + +### ts_string_methods +# String methods +ts -c "const s = 'Hello, World!'; console.log(s.toUpperCase()); console.log(s.toLowerCase())" +### expect +HELLO, WORLD! +hello, world! +### end + +### ts_boolean_logic +# Boolean operations +ts -c "console.log(true && false); console.log(true || false); console.log(!true)" +### expect +false +true +false +### end + +### ts_null_undefined +# Null and undefined +ts -c "console.log(null); console.log(undefined)" +### expect +null +undefined +### end + +### ts_typeof +# typeof operator +ts -c "console.log(typeof 42); console.log(typeof 'hello'); console.log(typeof true)" +### expect +number +string +boolean +### end + +### ts_version +# Version flag +ts --version +### expect +TypeScript 5.0.0 (zapcode) +### end + +### ts_version_short +# Short version flag +ts -V +### expect +TypeScript 5.0.0 (zapcode) +### end + +### ts_node_alias +# node -e alias +node -e "console.log('from node')" +### expect +from node +### end + +### ts_deno_alias +# deno -e alias +deno -e "console.log('from deno')" +### expect +from deno +### end + +### ts_bun_alias +# bun -e alias +bun -e "console.log('from bun')" +### expect +from bun +### end + +### ts_command_substitution +# TypeScript in command substitution +result=$(ts -c "console.log(6 * 7)") +echo "answer: $result" +### expect +answer: 42 +### end + +### ts_pipeline_output +# TypeScript output in pipeline +ts -c "for (let i = 0; i < 3; i++) { console.log('line ' + i); }" | grep "line 1" +### expect +line 1 +### end + +### ts_conditional_failure +# TypeScript error triggers else branch +### bash_diff: no real ts available +if ts -c "throw new Error()" 2>/dev/null; then echo "success"; else echo "fail"; fi +### expect +fail +### end + +### ts_stdin_pipe +# Code from piped stdin +echo "console.log('from pipe')" | ts +### expect +from pipe +### end + +### ts_eval_flag +# -e flag (Node.js compat) +ts -e "console.log('eval flag')" +### expect +eval flag +### end + +### ts_vfs_write_and_read +# Write file from TypeScript, read from bash +ts -c "await writeFile('/tmp/tsout.txt', 'hello from ts\n')" +cat /tmp/tsout.txt +### expect +hello from ts +### end + +### ts_vfs_bash_to_ts +# Write from bash, read from TypeScript (via return value, trim trailing newline) +printf "data from bash" > /tmp/shared.txt +ts -c "await readFile('/tmp/shared.txt')" +### expect +data from bash +### end + +### ts_vfs_exists_true +# Check file existence (true case) +echo "hi" > /tmp/exists.txt +ts -c "await exists('/tmp/exists.txt')" +### expect +true +### end + +### ts_vfs_exists_false +# Check file existence (false case) +ts -c "await exists('/tmp/nope.txt')" +### expect +false +### end + +### ts_vfs_mkdir +# Create directory from TypeScript +ts -c "await mkdir('/tmp/tsdir'); await exists('/tmp/tsdir')" +### expect +true +### end diff --git a/crates/bashkit/tests/spec_tests.rs b/crates/bashkit/tests/spec_tests.rs index 39e084ca..9b20559d 100644 --- a/crates/bashkit/tests/spec_tests.rs +++ b/crates/bashkit/tests/spec_tests.rs @@ -263,6 +263,80 @@ async fn python_spec_tests() { ); } +/// Run all typescript spec tests (requires typescript feature) +#[cfg(feature = "typescript")] +#[tokio::test] +async fn typescript_spec_tests() { + use bashkit::Bash; + use spec_runner::run_spec_test_with; + + let dir = spec_cases_dir().join("typescript"); + let all_tests = load_spec_tests(&dir); + + if all_tests.is_empty() { + println!("No typescript spec tests found in {:?}", dir); + return; + } + + // TypeScript tests need the typescript builtin registered via builder + let make_bash = || Bash::builder().typescript().build(); + + let mut summary = TestSummary::default(); + let mut failures = Vec::new(); + + for (file, tests) in &all_tests { + for test in tests { + if test.skip { + summary.add( + &spec_runner::TestResult { + name: test.name.clone(), + passed: false, + bashkit_stdout: String::new(), + bashkit_exit_code: 0, + expected_stdout: String::new(), + expected_exit_code: None, + real_bash_stdout: None, + real_bash_exit_code: None, + error: None, + }, + true, + ); + continue; + } + + let result = run_spec_test_with(test, make_bash).await; + summary.add(&result, false); + + if !result.passed { + failures.push((file.clone(), result)); + } + } + } + + println!("\n=== TYPESCRIPT Spec Tests ==="); + println!( + "Total: {} | Passed: {} | Failed: {} | Skipped: {}", + summary.total, summary.passed, summary.failed, summary.skipped + ); + + for (file, result) in &failures { + if !result.passed { + println!("\n[{}] {}", file, result.name); + if let Some(ref err) = result.error { + println!(" Error: {}", err); + } + println!(" Expected: {:?}", result.expected_stdout); + println!(" Got: {:?}", result.bashkit_stdout); + } + } + + assert!( + failures.is_empty(), + "{} typescript tests failed", + failures.len() + ); +} + async fn run_category_tests( name: &str, all_tests: std::collections::HashMap>, diff --git a/crates/bashkit/tests/threat_model_tests.rs b/crates/bashkit/tests/threat_model_tests.rs index 03f2feb5..7c1f985d 100644 --- a/crates/bashkit/tests/threat_model_tests.rs +++ b/crates/bashkit/tests/threat_model_tests.rs @@ -3840,3 +3840,130 @@ mod trace_events { assert_eq!(r.exit_code, 0); } } + +// ============================================================================= +// TYPESCRIPT / ZAPCODE SECURITY (TM-TS) +// +// Threat model tests for the embedded TypeScript interpreter (zapcode-core). +// NOTE: TypeScript is opt-in — requires `typescript` feature AND builder call. +// ============================================================================= + +#[cfg(feature = "typescript")] +mod typescript_security { + use super::*; + use bashkit::TypeScriptLimits; + use std::time::Duration; + + fn bash_with_ts() -> Bash { + Bash::builder() + .typescript_with_limits(TypeScriptLimits::default()) + .build() + } + + fn bash_with_ts_tight() -> Bash { + Bash::builder() + .typescript_with_limits( + TypeScriptLimits::default() + .max_duration(Duration::from_secs(3)) + .max_memory(4 * 1024 * 1024) + .max_allocations(50_000) + .max_stack_depth(100), + ) + .build() + } + + /// TM-TS-001: TypeScript infinite loop blocked by time limit + #[tokio::test] + async fn threat_ts_infinite_loop() { + let mut bash = bash_with_ts_tight(); + let result = bash.exec("ts -c \"while (true) {}\"").await.unwrap(); + assert_ne!(result.exit_code, 0, "Infinite loop should not succeed"); + } + + /// TM-TS-002: TypeScript memory exhaustion blocked + #[tokio::test] + async fn threat_ts_memory_exhaustion() { + let mut bash = bash_with_ts_tight(); + let result = bash + .exec("ts -c \"const arr: number[] = []; while (true) { arr.push(1); }\"") + .await + .unwrap(); + assert_ne!(result.exit_code, 0, "Memory bomb should not succeed"); + } + + /// TM-TS-003: TypeScript stack overflow blocked + #[tokio::test] + async fn threat_ts_stack_overflow() { + let mut bash = bash_with_ts_tight(); + let result = bash + .exec("ts -c \"const f = (): number => f(); f()\"") + .await + .unwrap(); + assert_ne!(result.exit_code, 0, "Stack overflow should not succeed"); + } + + /// TM-TS-005: TypeScript VFS reads only from BashKit VFS + #[tokio::test] + async fn threat_ts_vfs_no_real_fs() { + let mut bash = bash_with_ts(); + let result = bash + .exec("ts -c \"const c = await readFile('/etc/passwd'); console.log(c)\"") + .await + .unwrap(); + assert!( + !result.stdout.contains("root:"), + "Should not read real /etc/passwd" + ); + } + + /// TM-TS-007: TypeScript VFS path traversal blocked + #[tokio::test] + async fn threat_ts_vfs_path_traversal() { + let mut bash = bash_with_ts(); + let result = bash + .exec("ts -c \"const c = await readFile('/tmp/../../../etc/passwd'); console.log(c)\"") + .await + .unwrap(); + assert!( + !result.stdout.contains("root:"), + "Path traversal must not escape VFS" + ); + } + + /// TM-TS-012: TypeScript error output goes to stderr + #[tokio::test] + async fn threat_ts_error_isolation() { + let mut bash = bash_with_ts(); + let result = bash + .exec("ts -c \"throw new Error('test')\"") + .await + .unwrap(); + assert_eq!(result.exit_code, 1); + } + + /// TM-TS-021: TypeScript cannot execute shell commands + #[tokio::test] + async fn threat_ts_no_shell_exec() { + let mut bash = bash_with_ts(); + let result = bash + .exec("ts -c \"console.log(process.env.HOME)\"") + .await + .unwrap(); + assert_ne!(result.exit_code, 0, "process.env should not exist"); + assert!( + !result.stdout.contains("/home"), + "Should not access env vars via process" + ); + } + + /// Opt-in verification: ts NOT available without builder call + #[tokio::test] + async fn threat_ts_optin_not_default() { + let mut bash = Bash::builder().build(); + let result = bash.exec("ts -c \"console.log('hi')\"").await.unwrap(); + assert_ne!( + result.exit_code, 0, + "ts should not be available without .typescript()" + ); + } +} diff --git a/crates/bashkit/tests/typescript_security_tests.rs b/crates/bashkit/tests/typescript_security_tests.rs new file mode 100644 index 00000000..64498bca --- /dev/null +++ b/crates/bashkit/tests/typescript_security_tests.rs @@ -0,0 +1,672 @@ +// Security tests for embedded TypeScript (ZapCode) integration. +// +// White-box tests: exploit knowledge of internals (VFS bridging, resource +// limits, external function handlers, path resolution). +// +// Black-box tests: treat ts as an opaque command and try to break out +// of the sandbox, exhaust resources, or leak information. +// +// Covers attack vectors: eval/import/require, resource exhaustion, +// VFS escape, path manipulation, error leakage, state persistence, +// and ZapCode interpreter edge cases. +// +// NOTE: TypeScript feature is opt-in. These tests verify that when enabled, +// the sandbox is robust. + +#![cfg(feature = "typescript")] + +use bashkit::{Bash, ExecutionLimits, TypeScriptLimits}; +use std::time::Duration; + +fn bash_ts() -> Bash { + Bash::builder().typescript().build() +} + +fn bash_ts_limits(limits: TypeScriptLimits) -> Bash { + Bash::builder().typescript_with_limits(limits).build() +} + +fn bash_ts_tight() -> Bash { + bash_ts_limits( + TypeScriptLimits::default() + .max_duration(Duration::from_secs(3)) + .max_memory(4 * 1024 * 1024) // 4 MB + .max_allocations(50_000) + .max_stack_depth(100), + ) +} + +// ============================================================================= +// 1. BLACK-BOX: BLOCKED LANGUAGE FEATURES +// +// Try using language features that could escape the sandbox. +// ============================================================================= + +mod blackbox_blocked_features { + use super::*; + + #[tokio::test] + async fn no_eval() { + let mut bash = bash_ts(); + let r = bash + .exec("ts -c \"eval('console.log(\\\"hacked\\\")')\"") + .await + .unwrap(); + assert!( + !r.stdout.contains("hacked"), + "eval must not execute code, got: {}", + r.stdout + ); + } + + #[tokio::test] + async fn no_function_constructor() { + let mut bash = bash_ts(); + let r = bash + .exec("ts -c \"const f = new Function('return 42'); console.log(f())\"") + .await + .unwrap(); + assert!( + !r.stdout.contains("42") || r.exit_code != 0, + "Function constructor must not work" + ); + } + + #[tokio::test] + async fn no_import() { + let mut bash = bash_ts(); + let r = bash.exec("ts -c \"import fs from 'fs'\"").await.unwrap(); + assert_ne!(r.exit_code, 0, "import must fail"); + } + + #[tokio::test] + async fn no_require() { + let mut bash = bash_ts(); + let r = bash + .exec("ts -c \"const fs = require('fs')\"") + .await + .unwrap(); + assert_ne!(r.exit_code, 0, "require must fail"); + } + + #[tokio::test] + async fn no_dynamic_import() { + let mut bash = bash_ts(); + let r = bash + .exec("ts -c \"const m = await import('fs')\"") + .await + .unwrap(); + // Dynamic import should fail or be treated as unknown external function + assert!( + r.exit_code != 0 || !r.stdout.contains("readFile"), + "dynamic import must not succeed" + ); + } + + #[tokio::test] + async fn no_process_global() { + let mut bash = bash_ts(); + let r = bash + .exec("ts -c \"console.log(process.env.HOME)\"") + .await + .unwrap(); + assert_ne!(r.exit_code, 0, "process global should not exist"); + } + + #[tokio::test] + async fn no_deno_global() { + let mut bash = bash_ts(); + let r = bash + .exec("ts -c \"console.log(Deno.readTextFileSync('/etc/passwd'))\"") + .await + .unwrap(); + assert_ne!(r.exit_code, 0, "Deno global should not exist"); + assert!( + !r.stdout.contains("root:"), + "should not read host filesystem" + ); + } + + #[tokio::test] + async fn no_bun_global() { + let mut bash = bash_ts(); + let r = bash + .exec("ts -c \"Bun.file('/etc/passwd')\"") + .await + .unwrap(); + assert_ne!(r.exit_code, 0, "Bun global should not exist"); + } +} + +// ============================================================================= +// 2. BLACK-BOX: RESOURCE EXHAUSTION +// +// Try to exhaust CPU, memory, or stack via TypeScript code. +// ============================================================================= + +mod blackbox_resource_exhaustion { + use super::*; + + /// TM-TS-001: Infinite loop blocked by time limit + #[tokio::test] + async fn threat_ts_infinite_loop() { + let mut bash = bash_ts_tight(); + let r = bash.exec("ts -c \"while (true) {}\"").await.unwrap(); + assert_ne!(r.exit_code, 0, "infinite loop should not succeed"); + } + + /// TM-TS-002: Memory exhaustion blocked + #[tokio::test] + async fn threat_ts_memory_exhaustion() { + let mut bash = bash_ts_tight(); + let r = bash + .exec("ts -c \"const arr: number[] = []; while (true) { arr.push(1); }\"") + .await + .unwrap(); + assert_ne!(r.exit_code, 0, "memory bomb should not succeed"); + } + + /// TM-TS-003: Stack overflow blocked by depth limit + #[tokio::test] + async fn threat_ts_stack_overflow() { + let mut bash = bash_ts_tight(); + let r = bash + .exec("ts -c \"const f = (): number => f(); f()\"") + .await + .unwrap(); + assert_ne!(r.exit_code, 0, "stack overflow should not succeed"); + } + + /// TM-TS-004: Allocation bomb blocked + #[tokio::test] + async fn threat_ts_allocation_bomb() { + let mut bash = bash_ts_tight(); + let r = bash + .exec("ts -c \"for (let i = 0; i < 10000000; i++) { const x = [1,2,3]; }\"") + .await + .unwrap(); + assert_ne!(r.exit_code, 0, "allocation bomb should not succeed"); + } + + /// String bomb - exponential string growth + #[tokio::test] + async fn threat_ts_string_bomb() { + let mut bash = bash_ts_tight(); + let r = bash + .exec("ts -c \"let s = 'a'; for (let i = 0; i < 30; i++) { s = s + s; }\"") + .await + .unwrap(); + assert_ne!(r.exit_code, 0, "string bomb should be limited"); + } + + /// Generous limits should succeed for normal code + #[tokio::test] + async fn normal_code_within_limits() { + let mut bash = bash_ts(); + let r = bash + .exec("ts -c \"let sum = 0; for (let i = 0; i < 100; i++) { sum += i; } console.log(sum)\"") + .await + .unwrap(); + assert_eq!(r.exit_code, 0); + assert_eq!(r.stdout.trim(), "4950"); + } +} + +// ============================================================================= +// 3. WHITE-BOX: VFS SECURITY +// +// Test that VFS bridging is secure and cannot escape the sandbox. +// ============================================================================= + +mod whitebox_vfs_security { + use super::*; + + /// TM-TS-005: VFS reads from virtual filesystem, not host + #[tokio::test] + async fn threat_ts_vfs_no_real_fs() { + let mut bash = bash_ts(); + // /etc/passwd exists on real Linux but not in VFS + let r = bash + .exec("ts -c \"const content = await readFile('/etc/passwd'); console.log(content)\"") + .await + .unwrap(); + // Should either error or return VFS content (which doesn't have real data) + assert!( + !r.stdout.contains("root:"), + "must not read real /etc/passwd" + ); + } + + /// TM-TS-006: VFS write stays in virtual filesystem + #[tokio::test] + async fn threat_ts_vfs_write_sandboxed() { + let mut bash = bash_ts(); + let r = bash + .exec( + "ts -c \"await writeFile('/tmp/sandbox_test.txt', 'test'); await readFile('/tmp/sandbox_test.txt')\"", + ) + .await + .unwrap(); + assert_eq!(r.exit_code, 0); + assert_eq!(r.stdout.trim(), "test"); + } + + /// TM-TS-007: Path traversal blocked + #[tokio::test] + async fn threat_ts_vfs_path_traversal() { + let mut bash = bash_ts(); + let r = bash + .exec( + "ts -c \"const content = await readFile('/tmp/../../../etc/passwd'); console.log(content)\"", + ) + .await + .unwrap(); + assert!( + !r.stdout.contains("root:"), + "path traversal must not escape VFS" + ); + } + + /// TM-TS-008: Bash/TypeScript VFS data flows correctly + #[tokio::test] + async fn threat_ts_vfs_bash_ts_shared() { + let mut bash = bash_ts(); + // Write from bash, read from TypeScript + let r = bash + .exec("echo 'from bash' > /tmp/shared.txt\nts -c \"await readFile('/tmp/shared.txt')\"") + .await + .unwrap(); + assert_eq!(r.exit_code, 0); + assert!(r.stdout.contains("from bash")); + } + + /// TM-TS-009: File not found handled gracefully (no crash) + #[tokio::test] + async fn threat_ts_vfs_error_handling() { + let mut bash = bash_ts(); + let r = bash + .exec("ts -c \"await readFile('/no/such/file.txt')\"") + .await + .unwrap(); + // Should return an error string, not crash + assert!( + r.stdout.contains("Error") || r.exit_code != 0, + "missing file should be handled gracefully" + ); + } + + /// TM-TS-010: VFS mkdir sandboxed + #[tokio::test] + async fn threat_ts_vfs_mkdir_sandboxed() { + let mut bash = bash_ts(); + let r = bash + .exec("ts -c \"await mkdir('/tmp/tsdir'); await exists('/tmp/tsdir')\"") + .await + .unwrap(); + assert_eq!(r.exit_code, 0); + assert_eq!(r.stdout.trim(), "true"); + } + + /// TM-TS-011: VFS operations don't escape to host /tmp + #[tokio::test] + async fn threat_ts_vfs_no_host_escape() { + let mut bash = bash_ts(); + bash.exec("ts -c \"await writeFile('/tmp/ts_escape_test', 'payload')\"") + .await + .unwrap(); + // Verify file doesn't exist on real host (we're in VFS) + // The bash `test -f` in BashKit also operates on VFS, so this + // verifies the write went to VFS, not a real assertion about host fs + let r = bash.exec("cat /tmp/ts_escape_test").await.unwrap(); + assert_eq!(r.exit_code, 0); + assert_eq!(r.stdout.trim(), "payload"); + } +} + +// ============================================================================= +// 4. WHITE-BOX: ERROR HANDLING SECURITY +// +// Errors should not leak internal information. +// ============================================================================= + +mod whitebox_error_handling { + use super::*; + + /// TM-TS-012: Error output goes to stderr, not stdout + #[tokio::test] + async fn threat_ts_error_isolation() { + let mut bash = bash_ts(); + let r = bash + .exec("ts -c \"throw new Error('test error')\"") + .await + .unwrap(); + assert_eq!(r.exit_code, 1); + assert!( + r.stderr.contains("Error") || r.stderr.contains("error"), + "error should be on stderr: '{}'", + r.stderr + ); + } + + /// TM-TS-013: Syntax error returns non-zero exit code + #[tokio::test] + async fn threat_ts_syntax_error_exit() { + let mut bash = bash_ts(); + let r = bash.exec("ts -c \"if {\"").await.unwrap(); + assert_ne!(r.exit_code, 0, "syntax error should fail"); + } + + /// TM-TS-014: Exit code propagates to bash correctly + #[tokio::test] + async fn threat_ts_exit_code_propagation() { + let mut bash = bash_ts(); + // Success case + let r = bash + .exec("ts -c \"console.log('ok')\"\necho $?") + .await + .unwrap(); + assert!(r.stdout.contains("0"), "success should give exit 0"); + + // Failure case + let r = bash + .exec("ts -c \"throw new Error()\" 2>/dev/null\necho $?") + .await + .unwrap(); + assert!(r.stdout.contains("1"), "error should give exit 1"); + } + + /// TM-TS-015: Empty code fails gracefully + #[tokio::test] + async fn threat_ts_empty_code() { + let mut bash = bash_ts(); + let r = bash.exec("ts -c \"\"").await.unwrap(); + assert_ne!(r.exit_code, 0); + } + + /// TM-TS-016: Pipeline error handling + #[tokio::test] + async fn threat_ts_pipeline_error_handling() { + let mut bash = bash_ts(); + let r = bash + .exec("ts -c \"throw new Error('boom')\" 2>/dev/null | cat") + .await + .unwrap(); + assert!( + !r.stdout.contains("Error"), + "error should not leak to pipeline stdout" + ); + } + + /// TM-TS-017: Unknown options rejected + #[tokio::test] + async fn threat_ts_unknown_options() { + let mut bash = bash_ts(); + let r = bash.exec("ts --unsafe-eval code").await.unwrap(); + assert_ne!(r.exit_code, 0, "unknown options should be rejected"); + } +} + +// ============================================================================= +// 5. WHITE-BOX: BASH INTEGRATION SECURITY +// +// Verify TypeScript integrates safely with bash features. +// ============================================================================= + +mod whitebox_bash_integration { + use super::*; + + /// TM-TS-018: TypeScript respects BashKit command limits + #[tokio::test] + async fn threat_ts_respects_bash_limits() { + let limits = ExecutionLimits::new().max_commands(5); + let mut bash = Bash::builder().typescript().limits(limits).build(); + // Each ts invocation is 1 command; should succeed with generous limits + let r = bash.exec("ts -c \"console.log('ok')\"").await.unwrap(); + assert_eq!(r.exit_code, 0); + assert_eq!(r.stdout, "ok\n"); + } + + /// TM-TS-019: Command substitution captures only stdout + #[tokio::test] + async fn threat_ts_subst_captures_stdout() { + let mut bash = bash_ts(); + let r = bash + .exec("result=$(ts -c \"console.log(42)\")\necho $result") + .await + .unwrap(); + assert_eq!(r.stdout.trim(), "42"); + } + + /// TM-TS-020: Bash variable expansion before TypeScript (by design) + #[tokio::test] + async fn threat_ts_variable_expansion() { + let mut bash = bash_ts(); + // Double-quoted: bash expands $VAR before passing to ts + bash.exec("export MYVAR=injected").await.unwrap(); + let r = bash.exec("ts -c \"console.log('$MYVAR')\"").await.unwrap(); + assert_eq!(r.stdout.trim(), "injected"); + + // Single-quoted: no expansion (safe) + let r = bash.exec("ts -c 'console.log(\"$MYVAR\")'").await.unwrap(); + assert_eq!(r.stdout.trim(), "$MYVAR"); + } + + /// TM-TS-021: TypeScript cannot execute shell commands + #[tokio::test] + async fn threat_ts_no_shell_exec() { + let mut bash = bash_ts(); + // No way to execute shell commands from TypeScript + let r = bash + .exec("ts -c \"console.log(process.env)\"") + .await + .unwrap(); + assert_ne!(r.exit_code, 0, "process.env should not exist"); + assert!( + !r.stdout.contains("hacked"), + "should not execute shell commands" + ); + } + + /// TM-TS-022: Script file from VFS (not host filesystem) + #[tokio::test] + async fn threat_ts_script_from_vfs() { + let mut bash = bash_ts(); + // Write a script to VFS and execute it + bash.exec("echo 'console.log(\"from vfs\")' > /tmp/script.ts") + .await + .unwrap(); + let r = bash.exec("ts /tmp/script.ts").await.unwrap(); + assert_eq!(r.exit_code, 0); + assert_eq!(r.stdout.trim(), "from vfs"); + } + + /// TM-TS-023: Shebang line stripped safely + #[tokio::test] + async fn threat_ts_shebang_stripped() { + let mut bash = bash_ts(); + bash.exec("printf '#!/usr/bin/env ts\\nconsole.log(\"safe\")' > /tmp/shebang.ts") + .await + .unwrap(); + let r = bash.exec("ts /tmp/shebang.ts").await.unwrap(); + assert_eq!(r.exit_code, 0); + assert_eq!(r.stdout.trim(), "safe"); + } +} + +// ============================================================================= +// 6. WHITE-BOX: OPT-IN VERIFICATION +// +// Verify that TypeScript is NOT available unless explicitly opted in. +// ============================================================================= + +mod optin_verification { + use bashkit::Bash; + + /// TypeScript commands are NOT registered by default + #[tokio::test] + async fn ts_not_available_by_default() { + let mut bash = Bash::builder().build(); + let r = bash.exec("ts -c \"console.log('hi')\"").await.unwrap(); + assert_ne!(r.exit_code, 0, "ts should not be available without opt-in"); + } + + /// Node command is NOT registered by default + #[tokio::test] + async fn node_not_available_by_default() { + let mut bash = Bash::builder().build(); + let r = bash.exec("node -e \"console.log('hi')\"").await.unwrap(); + assert_ne!( + r.exit_code, 0, + "node should not be available without opt-in" + ); + } + + /// Deno command is NOT registered by default + #[tokio::test] + async fn deno_not_available_by_default() { + let mut bash = Bash::builder().build(); + let r = bash.exec("deno -e \"console.log('hi')\"").await.unwrap(); + assert_ne!( + r.exit_code, 0, + "deno should not be available without opt-in" + ); + } + + /// Bun command is NOT registered by default + #[tokio::test] + async fn bun_not_available_by_default() { + let mut bash = Bash::builder().build(); + let r = bash.exec("bun -e \"console.log('hi')\"").await.unwrap(); + assert_ne!(r.exit_code, 0, "bun should not be available without opt-in"); + } + + /// TypeScript IS available after .typescript() builder call + #[tokio::test] + async fn ts_available_after_optin() { + let mut bash = Bash::builder().typescript().build(); + let r = bash.exec("ts -c \"console.log('hi')\"").await.unwrap(); + assert_eq!(r.exit_code, 0, "ts should work after opt-in"); + assert_eq!(r.stdout.trim(), "hi"); + } + + /// All aliases available after .typescript() + #[tokio::test] + async fn all_aliases_available_after_optin() { + let mut bash = Bash::builder().typescript().build(); + for cmd in &["ts", "typescript", "node", "deno", "bun"] { + let flag = if *cmd == "ts" || *cmd == "typescript" { + "-c" + } else { + "-e" + }; + let r = bash + .exec(&format!("{cmd} {flag} \"console.log('ok')\"")) + .await + .unwrap(); + assert_eq!(r.exit_code, 0, "{cmd} should work after opt-in"); + } + } +} + +// ============================================================================= +// 7. PROTOTYPE POLLUTION / OBJECT MANIPULATION +// +// Attempt to abuse JavaScript's dynamic features. +// ============================================================================= + +mod prototype_attacks { + use super::*; + + /// Try __proto__ manipulation + #[tokio::test] + async fn no_proto_pollution() { + let mut bash = bash_ts(); + let r = bash + .exec("ts -c \"const obj: any = {}; obj.__proto__.polluted = true; console.log(({} as any).polluted)\"") + .await + .unwrap(); + // Should either fail or print undefined (not "true") + assert!( + !r.stdout.contains("true"), + "__proto__ pollution should not work" + ); + } + + /// Try constructor manipulation + #[tokio::test] + async fn no_constructor_abuse() { + let mut bash = bash_ts(); + let r = bash + .exec("ts -c \"const obj: any = {}; obj.constructor.constructor('return this')()\"") + .await + .unwrap(); + // Should fail — no Function constructor escape + assert!( + r.exit_code != 0 || !r.stdout.contains("[object"), + "constructor abuse should not work" + ); + } + + /// Try globalThis access + #[tokio::test] + async fn no_globalthis_escape() { + let mut bash = bash_ts(); + let r = bash + .exec("ts -c \"const keys = Object.keys(globalThis); console.log(keys.length)\"") + .await + .unwrap(); + // globalThis might work but should only expose safe builtins + // The important thing is no process/require/Deno/Bun on it + if r.exit_code == 0 { + assert!( + !r.stdout.contains("process") && !r.stdout.contains("require"), + "globalThis should not expose dangerous globals" + ); + } + } +} + +// ============================================================================= +// 8. CUSTOM LIMITS TESTS +// +// Verify that custom limits are actually enforced. +// ============================================================================= + +mod custom_limits { + use super::*; + + /// Very tight time limit stops long computation + #[tokio::test] + async fn tight_time_limit() { + let mut bash = + bash_ts_limits(TypeScriptLimits::default().max_duration(Duration::from_millis(100))); + let r = bash + .exec("ts -c \"let i = 0; while (true) { i++; }\"") + .await + .unwrap(); + assert_ne!(r.exit_code, 0); + } + + /// Very tight stack depth limit + #[tokio::test] + async fn tight_stack_limit() { + let mut bash = bash_ts_limits(TypeScriptLimits::default().max_stack_depth(10)); + let r = bash + .exec("ts -c \"const f = (n: number): number => n <= 0 ? 0 : f(n - 1); f(100)\"") + .await + .unwrap(); + assert_ne!(r.exit_code, 0); + } + + /// Default limits allow normal programs + #[tokio::test] + async fn default_limits_normal_code() { + let mut bash = bash_ts(); + let r = bash + .exec("ts -c \"let sum = 0; for (let i = 0; i < 100; i++) { sum += i; } console.log(sum)\"") + .await + .unwrap(); + assert_eq!(r.exit_code, 0); + assert_eq!(r.stdout.trim(), "4950"); + } +} diff --git a/specs/006-threat-model.md b/specs/006-threat-model.md index 96e9f141..7a270a91 100644 --- a/specs/006-threat-model.md +++ b/specs/006-threat-model.md @@ -33,6 +33,7 @@ All threats use a stable ID format: `TM--` | TM-GIT | Git Security | Repository access, identity leak, remote operations | | TM-LOG | Logging Security | Sensitive data in logs, log injection, log volume attacks | | TM-PY | Python Security | Embedded Python sandbox escape, VFS isolation, resource limits | +| TM-TS | TypeScript Security | Embedded TypeScript sandbox escape, VFS isolation, resource limits | | TM-UNI | Unicode Security | Byte-boundary panics, invisible chars, homoglyphs, normalization | ### Adding New Threats @@ -1598,6 +1599,117 @@ filesystem. --- +## TypeScript / ZapCode Security (TM-TS) + +> **Experimental.** ZapCode is an early-stage TypeScript interpreter that may +> have undiscovered crash or security bugs. Resource limits are enforced by +> ZapCode's VM. This integration should be treated as experimental. +> +> **Opt-in only.** TypeScript builtins (ts, node, deno, bun) are NOT registered +> by default. They require both the `typescript` Cargo feature flag AND an +> explicit `.typescript()` call on the builder. Without both, these commands +> are unavailable. + +BashKit embeds the ZapCode TypeScript interpreter (zapcode-core) with VFS +bridging via external function suspend/resume. TypeScript code can access the +virtual filesystem through registered external functions. This section covers +threats specific to the TypeScript builtin. + +### Architecture + +``` +TypeScript code → ZapCode VM → ExternalFn suspend → BashKit VFS bridge → resume +``` + +ZapCode never touches the real filesystem. External function calls suspend the +VM, BashKit intercepts and dispatches to the VFS, then resumes execution. + +### Opt-in Design + +TypeScript execution requires **two explicit opt-in steps**: + +1. **Cargo feature flag**: `features = ["typescript"]` — compiles zapcode-core +2. **Builder registration**: `.typescript()` — registers ts/node/deno/bun commands + +Without step 1, the dependency is not compiled. Without step 2, the commands +are not available even if compiled. This matches the Python builtin pattern. + +### Threats + +| ID | Threat | Severity | Mitigation | Test | +|----|--------|----------|------------|------| +| TM-TS-001 | Infinite loop via `while (true) {}` | High | ZapCode time limit (default 30s) | `threat_ts_infinite_loop` | +| TM-TS-002 | Memory exhaustion via large allocation | High | ZapCode max_memory (64MB) + max_allocations (1M) | `threat_ts_memory_exhaustion` | +| TM-TS-003 | Stack overflow via deep recursion | High | ZapCode max_stack_depth (512) | `threat_ts_stack_overflow` | +| TM-TS-004 | Allocation bomb (many small objects) | High | ZapCode max_allocations (1M) | `threat_ts_allocation_bomb` | +| TM-TS-005 | Real filesystem access via VFS | Critical | VFS bridge reads only from BashKit VFS, not host | `threat_ts_vfs_no_real_fs` | +| TM-TS-006 | VFS write escapes to host | Critical | VFS bridge writes only to BashKit VFS | `threat_ts_vfs_write_sandboxed` | +| TM-TS-007 | Path traversal (../../etc/passwd) | High | VFS resolves paths within sandbox boundaries | `threat_ts_vfs_path_traversal` | +| TM-TS-008 | Bash/TypeScript VFS data corruption | Medium | Shared VFS by design; no cross-tenant access | `threat_ts_vfs_bash_ts_shared` | +| TM-TS-009 | Crash on missing file | Medium | Error string returned, not panic | `threat_ts_vfs_error_handling` | +| TM-TS-010 | VFS mkdir escape | Medium | mkdir operates only in VFS | `threat_ts_vfs_mkdir_sandboxed` | +| TM-TS-011 | VFS operations escape to host /tmp | Critical | All operations go through BashKit VFS | `threat_ts_vfs_no_host_escape` | +| TM-TS-012 | Error info leakage via stdout | Medium | Errors go to stderr, not stdout | `threat_ts_error_isolation` | +| TM-TS-013 | Syntax error crashes host | Medium | Non-zero exit code, error on stderr | `threat_ts_syntax_error_exit` | +| TM-TS-014 | Exit code not propagated | Low | Exit code flows to bash $? | `threat_ts_exit_code_propagation` | +| TM-TS-015 | Empty code crashes | Low | Non-zero exit, error message | `threat_ts_empty_code` | +| TM-TS-016 | Pipeline error leakage | Medium | Errors on stderr, not passed to pipe | `threat_ts_pipeline_error_handling` | +| TM-TS-017 | Unknown options accepted | Low | Unknown flags return non-zero | `threat_ts_unknown_options` | +| TM-TS-018 | TypeScript bypasses BashKit limits | Medium | Command budget still enforced | `threat_ts_respects_bash_limits` | +| TM-TS-019 | Command subst captures errors | Medium | Only stdout captured by $() | `threat_ts_subst_captures_stdout` | +| TM-TS-020 | Bash var expansion injection | Medium | By-design; use single quotes to prevent | `threat_ts_variable_expansion` | +| TM-TS-021 | Shell command execution from TS | Critical | No process/subprocess/exec globals | `threat_ts_no_shell_exec` | +| TM-TS-022 | Script reads from host filesystem | Critical | Script file loaded via VFS | `threat_ts_script_from_vfs` | +| TM-TS-023 | Shebang line injection | Low | Shebang stripped safely | `threat_ts_shebang_stripped` | + +### Blocked Language Features + +ZapCode blocks these at the language level (not just runtime): + +| Feature | Status | Rationale | +|---------|--------|-----------| +| `eval()` | Blocked | Dynamic code execution escape | +| `Function()` constructor | Blocked | Dynamic code generation | +| `import` / `require` | Blocked | Module system not implemented | +| `process` global | Blocked | No Node.js process API | +| `Deno` global | Blocked | No Deno runtime API | +| `Bun` global | Blocked | No Bun runtime API | +| `globalThis.process` | Blocked | No runtime globals | +| `__proto__` mutation | Blocked | Prototype pollution prevention | + +### VFS Bridge Security Properties + +1. **No real filesystem access**: All VFS-bridged functions go through BashKit's + VFS. `/etc/passwd` in TypeScript reads from VFS, not the host. +2. **Shared VFS with bash**: Files written by `echo > file` are readable by + TypeScript's `readFile()`, and vice versa. This is intentional. +3. **Path resolution**: Relative paths are resolved against the shell's cwd. + Path traversal (`../..`) is constrained by VFS path normalization. +4. **Error handling**: VFS errors return error strings to TypeScript, not panics. +5. **Resource isolation**: ZapCode's own limits (time, memory, stack, allocations) + are enforced independently of BashKit's shell limits. + +### Supported VFS Operations + +| Operation | External Function | Return Type | +|-----------|------------------|-------------| +| Read file | readFile(path) | string | +| Write file | writeFile(path, content) | undefined | +| Check existence | exists(path) | boolean | +| List directory | readDir(path) | string[] | +| Create directory | mkdir(path) | undefined | +| Delete file/dir | remove(path) | undefined | +| File metadata | stat(path) | JSON string | + +### Known Limitation + +`ZapcodeSnapshot::resume()` does not expose the VM's accumulated stdout. +This means `console.log()` output produced *after* a VFS call (external +function) is not captured. Use the return-value pattern instead — the last +expression's value is printed. This is a `zapcode-core` API limitation. + +--- + ## Unicode Security (TM-UNI) Unicode handling presents a broad attack surface in any interpreter that processes From b11cd2ada9b5071047e77de2491b2b33b0c956b9 Mon Sep 17 00:00:00 2001 From: Mykhailo Chalyi Date: Wed, 1 Apr 2026 22:49:52 +0000 Subject: [PATCH 4/8] feat(typescript): add TypeScriptConfig, compat aliases toggle, and unsupported mode hints - TypeScriptConfig: configurable compat_aliases (default: true) and unsupported_mode_hint (default: true) - When hints enabled, unsupported flags (--inspect, --watch) and subcommands (deno run, bun install) show helpful text explaining this is ZapCode, not a full runtime, with usage examples - Interactive mode (no args) also shows hints when enabled - compat_aliases(false) registers only ts/typescript, not node/deno/bun - New builder: .typescript_with_config(TypeScriptConfig) - Error messages now use the actual command name (node:, deno:, etc.) - 8 new unit tests, 5 new security tests --- crates/bashkit/src/builtins/mod.rs | 3 +- crates/bashkit/src/builtins/typescript.rs | 348 +++++++++++++++++- crates/bashkit/src/lib.rs | 126 ++++--- .../tests/typescript_security_tests.rs | 80 ++++ 4 files changed, 484 insertions(+), 73 deletions(-) diff --git a/crates/bashkit/src/builtins/mod.rs b/crates/bashkit/src/builtins/mod.rs index d1e6c230..b5b99624 100644 --- a/crates/bashkit/src/builtins/mod.rs +++ b/crates/bashkit/src/builtins/mod.rs @@ -208,7 +208,8 @@ pub use python::{Python, PythonExternalFnHandler, PythonExternalFns, PythonLimit #[cfg(feature = "typescript")] pub use typescript::{ - TypeScript, TypeScriptExternalFnHandler, TypeScriptExternalFns, TypeScriptLimits, + TypeScript, TypeScriptConfig, TypeScriptExternalFnHandler, TypeScriptExternalFns, + TypeScriptLimits, }; use async_trait::async_trait; diff --git a/crates/bashkit/src/builtins/typescript.rs b/crates/bashkit/src/builtins/typescript.rs index d632a592..5d7523ef 100644 --- a/crates/bashkit/src/builtins/typescript.rs +++ b/crates/bashkit/src/builtins/typescript.rs @@ -161,6 +161,84 @@ const VFS_FUNCTIONS: &[&str] = &[ "stat", ]; +/// Configuration for the TypeScript builtin. +/// +/// Controls which command aliases are registered and whether unsupported +/// execution modes produce helpful hint text. +/// +/// # Example +/// +/// ```rust,ignore +/// use bashkit::{Bash, TypeScriptConfig, TypeScriptLimits}; +/// +/// // Default: all aliases + hints enabled +/// let bash = Bash::builder().typescript().build(); +/// +/// // Only ts/typescript, no node/deno/bun aliases +/// let bash = Bash::builder() +/// .typescript_with_config(TypeScriptConfig::default().compat_aliases(false)) +/// .build(); +/// +/// // Disable unsupported-mode hints (errors only, no help text) +/// let bash = Bash::builder() +/// .typescript_with_config(TypeScriptConfig::default().unsupported_mode_hint(false)) +/// .build(); +/// +/// // Custom limits + selective config +/// let bash = Bash::builder() +/// .typescript_with_config( +/// TypeScriptConfig::default() +/// .limits(TypeScriptLimits::default().max_duration(Duration::from_secs(5))) +/// .compat_aliases(false) +/// ) +/// .build(); +/// ``` +#[derive(Debug, Clone)] +pub struct TypeScriptConfig { + /// Resource limits for the ZapCode interpreter. + pub limits: TypeScriptLimits, + /// Register `node`, `deno`, `bun` aliases in addition to `ts`/`typescript`. + /// Default: true. + pub enable_compat_aliases: bool, + /// Show helpful hint text when unsupported execution modes are used + /// (e.g. `node --inspect`, `deno run`, `bun install`). + /// Default: true. + pub enable_unsupported_mode_hint: bool, +} + +impl Default for TypeScriptConfig { + fn default() -> Self { + Self { + limits: TypeScriptLimits::default(), + enable_compat_aliases: true, + enable_unsupported_mode_hint: true, + } + } +} + +impl TypeScriptConfig { + /// Set resource limits. + #[must_use] + pub fn limits(mut self, limits: TypeScriptLimits) -> Self { + self.limits = limits; + self + } + + /// Enable or disable `node`/`deno`/`bun` compat aliases (default: true). + #[must_use] + pub fn compat_aliases(mut self, enable: bool) -> Self { + self.enable_compat_aliases = enable; + self + } + + /// Enable or disable hint text for unsupported execution modes (default: true). + #[must_use] + pub fn unsupported_mode_hint(mut self, enable: bool) -> Self { + self.enable_unsupported_mode_hint = enable; + self + } +} + /// The ts/node/deno/bun builtin command. /// /// Executes TypeScript/JavaScript code using the embedded ZapCode interpreter. @@ -183,22 +261,30 @@ pub struct TypeScript { pub limits: TypeScriptLimits, /// Optional user-provided external function configuration. external_fns: Option, + /// Show hint text for unsupported execution modes. + unsupported_mode_hint: bool, + /// The command name this builtin was registered as (e.g. "ts", "node"). + cmd_name: String, } impl TypeScript { - /// Create with default limits. + /// Create with default limits, registered as "ts". pub fn new() -> Self { Self { limits: TypeScriptLimits::default(), external_fns: None, + unsupported_mode_hint: true, + cmd_name: "ts".to_string(), } } - /// Create with custom limits. - pub fn with_limits(limits: TypeScriptLimits) -> Self { + /// Create from a config, with a specific command name. + pub fn from_config(config: &TypeScriptConfig, cmd_name: &str) -> Self { Self { - limits, + limits: config.limits.clone(), external_fns: None, + unsupported_mode_hint: config.enable_unsupported_mode_hint, + cmd_name: cmd_name.to_string(), } } @@ -222,6 +308,97 @@ impl Default for TypeScript { } } +/// Known flags/subcommands from Node.js, Deno, and Bun that are not supported. +const UNSUPPORTED_NODE_FLAGS: &[&str] = &[ + "--inspect", + "--inspect-brk", + "--prof", + "--watch", + "--experimental-modules", + "--loader", + "--require", + "--preserve-symlinks", + "--max-old-space-size", + "--expose-gc", + "--harmony", + "--trace-warnings", + "--no-warnings", + "--pending-deprecation", +]; + +const UNSUPPORTED_DENO_SUBCOMMANDS: &[&str] = &[ + "run", + "compile", + "install", + "uninstall", + "lint", + "fmt", + "test", + "bench", + "check", + "serve", + "task", + "repl", + "upgrade", + "doc", + "publish", + "add", + "remove", + "init", + "info", + "cache", + "eval", + "coverage", + "types", + "completions", +]; + +const UNSUPPORTED_BUN_SUBCOMMANDS: &[&str] = &[ + "run", "install", "add", "remove", "update", "link", "unlink", "pm", "build", "init", "test", + "x", "create", +]; + +/// Format a hint message for unsupported execution modes. +fn unsupported_mode_message(cmd: &str, arg: &str) -> String { + let base = format!("{cmd}: unsupported option or subcommand: {arg}\n"); + let runtime = match cmd { + "node" => "Node.js", + "deno" => "Deno", + "bun" => "Bun", + _ => "a full runtime", + }; + let flag = if cmd == "ts" || cmd == "typescript" { + "-c" + } else { + "-e" + }; + format!( + "{base}\ + hint: This is an embedded TypeScript interpreter (ZapCode), not {runtime}.\n\ + hint: Only inline execution is supported:\n\ + hint: {cmd} {flag} \"console.log('hello')\" # run inline code\n\ + hint: {cmd} script.ts # run file from VFS\n\ + hint: echo \"code\" | {cmd} # pipe code via stdin\n" + ) +} + +/// Check if an argument is a known unsupported flag/subcommand for the given command. +fn is_unsupported_mode(cmd: &str, arg: &str) -> bool { + // Node.js unsupported flags + if UNSUPPORTED_NODE_FLAGS.iter().any(|f| arg.starts_with(f)) { + return true; + } + // Deno subcommands + if cmd == "deno" && UNSUPPORTED_DENO_SUBCOMMANDS.contains(&arg) { + return true; + } + // Bun subcommands + if cmd == "bun" && UNSUPPORTED_BUN_SUBCOMMANDS.contains(&arg) { + return true; + } + false +} + #[async_trait] impl Builtin for TypeScript { fn llm_hint(&self) -> Option<&'static str> { @@ -236,6 +413,7 @@ impl Builtin for TypeScript { async fn execute(&self, ctx: Context<'_>) -> Result { let args = ctx.args; + let cmd = &self.cmd_name; // ts --version / ts -V / node --version / etc. if args.first().map(|s| s.as_str()) == Some("--version") @@ -248,16 +426,15 @@ impl Builtin for TypeScript { if args.first().map(|s| s.as_str()) == Some("--help") || args.first().map(|s| s.as_str()) == Some("-h") { - return Ok(ExecResult::ok( - "usage: ts [-c cmd | -e cmd | file | -] [arg ...]\n\ + return Ok(ExecResult::ok(format!( + "usage: {cmd} [-c cmd | -e cmd | file | -] [arg ...]\n\ Options:\n \ -c cmd : execute code from string\n \ -e cmd : execute code from string (Node.js compat)\n \ file : execute code from file (VFS)\n \ - : read code from stdin\n \ -V : print version\n" - .to_string(), - )); + ))); } let (code, _filename) = if let Some(first) = args.first() { @@ -267,7 +444,7 @@ impl Builtin for TypeScript { let code = args.get(1).map(|s| s.as_str()).unwrap_or(""); if code.is_empty() { return Ok(ExecResult::err( - format!("ts: option {} requires argument\n", first), + format!("{cmd}: option {} requires argument\n", first), 2, )); } @@ -280,12 +457,26 @@ impl Builtin for TypeScript { (input.to_string(), "".to_string()) } _ => { - return Ok(ExecResult::err("ts: no input from stdin\n".to_string(), 1)); + return Ok(ExecResult::err(format!("{cmd}: no input from stdin\n"), 1)); } } } arg if arg.starts_with('-') => { - return Ok(ExecResult::err(format!("ts: unknown option: {arg}\n"), 2)); + // Check for known unsupported flags from Node/Deno/Bun + if self.unsupported_mode_hint && is_unsupported_mode(cmd, arg) { + return Ok(ExecResult::err(unsupported_mode_message(cmd, arg), 2)); + } + return Ok(ExecResult::err( + format!("{cmd}: unknown option: {arg}\n"), + 2, + )); + } + arg if !arg.contains('.') + && self.unsupported_mode_hint + && is_unsupported_mode(cmd, arg) => + { + // Check for known unsupported subcommands (e.g. "deno run", "bun install") + return Ok(ExecResult::err(unsupported_mode_message(cmd, arg), 2)); } script_path => { // ts script.ts / node script.js @@ -295,7 +486,9 @@ impl Builtin for TypeScript { Ok(code) => (code, script_path.to_string()), Err(_) => { return Ok(ExecResult::err( - format!("ts: can't decode file '{script_path}': not UTF-8\n"), + format!( + "{cmd}: can't decode file '{script_path}': not UTF-8\n" + ), 1, )); } @@ -303,7 +496,7 @@ impl Builtin for TypeScript { Err(_) => { return Ok(ExecResult::err( format!( - "ts: can't open file '{script_path}': No such file or directory\n" + "{cmd}: can't open file '{script_path}': No such file or directory\n" ), 2, )); @@ -319,8 +512,20 @@ impl Builtin for TypeScript { (input.to_string(), "".to_string()) } else { // No args, no stdin — interactive mode not supported + if self.unsupported_mode_hint { + return Ok(ExecResult::err( + format!( + "{cmd}: interactive mode not supported\n\ + hint: Use inline execution instead:\n\ + hint: {cmd} -c \"console.log('hello')\" # run inline code\n\ + hint: {cmd} script.ts # run file from VFS\n\ + hint: echo \"code\" | {cmd} # pipe code via stdin\n" + ), + 1, + )); + } return Ok(ExecResult::err( - "ts: interactive mode not supported in virtual mode\n".to_string(), + format!("{cmd}: interactive mode not supported in virtual mode\n"), 1, )); }; @@ -977,4 +1182,119 @@ mod tests { assert!(hint.contains("TypeScript")); assert!(hint.contains("ZapCode")); } + + // --- Config tests --- + + #[test] + fn test_config_defaults() { + let config = TypeScriptConfig::default(); + assert!(config.enable_compat_aliases); + assert!(config.enable_unsupported_mode_hint); + } + + #[test] + fn test_config_builder() { + let config = TypeScriptConfig::default() + .compat_aliases(false) + .unsupported_mode_hint(false) + .limits(TypeScriptLimits::default().max_duration(Duration::from_secs(5))); + assert!(!config.enable_compat_aliases); + assert!(!config.enable_unsupported_mode_hint); + assert_eq!(config.limits.max_duration, Duration::from_secs(5)); + } + + // --- Unsupported mode hint tests --- + + #[tokio::test] + async fn test_unsupported_node_inspect() { + let ts = TypeScript::from_config(&TypeScriptConfig::default(), "node"); + let args = vec!["--inspect".to_string()]; + let env = HashMap::new(); + let mut variables = HashMap::new(); + let mut cwd = PathBuf::from("/home/user"); + let fs = Arc::new(InMemoryFs::new()); + let ctx = Context::new_for_test(&args, &env, &mut variables, &mut cwd, fs, None); + let r = ts.execute(ctx).await.unwrap(); + assert_eq!(r.exit_code, 2); + assert!(r.stderr.contains("hint:"), "should contain hint text"); + assert!(r.stderr.contains("Node.js"), "should mention Node.js"); + assert!(r.stderr.contains("node -e"), "should suggest -e flag"); + } + + #[tokio::test] + async fn test_unsupported_deno_run() { + let ts = TypeScript::from_config(&TypeScriptConfig::default(), "deno"); + let args = vec!["run".to_string()]; + let env = HashMap::new(); + let mut variables = HashMap::new(); + let mut cwd = PathBuf::from("/home/user"); + let fs = Arc::new(InMemoryFs::new()); + let ctx = Context::new_for_test(&args, &env, &mut variables, &mut cwd, fs, None); + let r = ts.execute(ctx).await.unwrap(); + assert_eq!(r.exit_code, 2); + assert!(r.stderr.contains("hint:")); + assert!(r.stderr.contains("Deno")); + } + + #[tokio::test] + async fn test_unsupported_bun_install() { + let ts = TypeScript::from_config(&TypeScriptConfig::default(), "bun"); + let args = vec!["install".to_string()]; + let env = HashMap::new(); + let mut variables = HashMap::new(); + let mut cwd = PathBuf::from("/home/user"); + let fs = Arc::new(InMemoryFs::new()); + let ctx = Context::new_for_test(&args, &env, &mut variables, &mut cwd, fs, None); + let r = ts.execute(ctx).await.unwrap(); + assert_eq!(r.exit_code, 2); + assert!(r.stderr.contains("hint:")); + assert!(r.stderr.contains("Bun")); + } + + #[tokio::test] + async fn test_hint_disabled() { + let config = TypeScriptConfig::default().unsupported_mode_hint(false); + let ts = TypeScript::from_config(&config, "node"); + let args = vec!["--inspect".to_string()]; + let env = HashMap::new(); + let mut variables = HashMap::new(); + let mut cwd = PathBuf::from("/home/user"); + let fs = Arc::new(InMemoryFs::new()); + let ctx = Context::new_for_test(&args, &env, &mut variables, &mut cwd, fs, None); + let r = ts.execute(ctx).await.unwrap(); + assert_eq!(r.exit_code, 2); + // When hints disabled, just get the basic error + assert!(!r.stderr.contains("hint:"), "should not contain hint text"); + assert!(r.stderr.contains("unknown option")); + } + + #[tokio::test] + async fn test_interactive_mode_hint() { + let ts = TypeScript::from_config(&TypeScriptConfig::default(), "ts"); + let args: Vec = vec![]; + let env = HashMap::new(); + let mut variables = HashMap::new(); + let mut cwd = PathBuf::from("/home/user"); + let fs = Arc::new(InMemoryFs::new()); + let ctx = Context::new_for_test(&args, &env, &mut variables, &mut cwd, fs, None); + let r = ts.execute(ctx).await.unwrap(); + assert_eq!(r.exit_code, 1); + assert!(r.stderr.contains("hint:"), "should contain hint text"); + assert!(r.stderr.contains("ts -c"), "should suggest -c flag"); + } + + #[tokio::test] + async fn test_interactive_mode_hint_disabled() { + let config = TypeScriptConfig::default().unsupported_mode_hint(false); + let ts = TypeScript::from_config(&config, "ts"); + let args: Vec = vec![]; + let env = HashMap::new(); + let mut variables = HashMap::new(); + let mut cwd = PathBuf::from("/home/user"); + let fs = Arc::new(InMemoryFs::new()); + let ctx = Context::new_for_test(&args, &env, &mut variables, &mut cwd, fs, None); + let r = ts.execute(ctx).await.unwrap(); + assert_eq!(r.exit_code, 1); + assert!(!r.stderr.contains("hint:"), "should not contain hint text"); + } } diff --git a/crates/bashkit/src/lib.rs b/crates/bashkit/src/lib.rs index 1647840a..7e531f1f 100644 --- a/crates/bashkit/src/lib.rs +++ b/crates/bashkit/src/lib.rs @@ -469,7 +469,9 @@ pub use builtins::{PythonExternalFnHandler, PythonExternalFns, PythonLimits}; pub use monty::{ExcType, ExtFunctionResult, MontyException, MontyObject}; #[cfg(feature = "typescript")] -pub use builtins::{TypeScriptExternalFnHandler, TypeScriptExternalFns, TypeScriptLimits}; +pub use builtins::{ + TypeScriptConfig, TypeScriptExternalFnHandler, TypeScriptExternalFns, TypeScriptLimits, +}; // Re-export zapcode-core types needed by external handler consumers. #[cfg(feature = "typescript")] pub use zapcode_core::Value as ZapcodeValue; @@ -1377,7 +1379,7 @@ impl BashBuilder { ) } - /// Enable embedded TypeScript/JavaScript execution via ZapCode with default limits. + /// Enable embedded TypeScript/JavaScript execution via ZapCode with defaults. /// /// Registers `ts`, `typescript`, `node`, `deno`, and `bun` builtins. /// Requires the `typescript` feature. @@ -1390,42 +1392,73 @@ impl BashBuilder { /// ``` #[cfg(feature = "typescript")] pub fn typescript(self) -> Self { - self.typescript_with_limits(builtins::TypeScriptLimits::default()) + self.typescript_with_config(builtins::TypeScriptConfig::default()) } /// Enable embedded TypeScript with custom resource limits. /// /// See [`BashBuilder::typescript`] for details. + #[cfg(feature = "typescript")] + pub fn typescript_with_limits(self, limits: builtins::TypeScriptLimits) -> Self { + self.typescript_with_config(builtins::TypeScriptConfig::default().limits(limits)) + } + + /// Enable embedded TypeScript with full configuration control. /// /// # Example /// /// ```rust,ignore - /// use bashkit::TypeScriptLimits; + /// use bashkit::{TypeScriptConfig, TypeScriptLimits}; /// use std::time::Duration; /// + /// // Only ts/typescript commands, no node/deno/bun aliases + /// let bash = Bash::builder() + /// .typescript_with_config(TypeScriptConfig::default().compat_aliases(false)) + /// .build(); + /// + /// // Disable unsupported-mode hints /// let bash = Bash::builder() - /// .typescript_with_limits(TypeScriptLimits::default().max_duration(Duration::from_secs(5))) + /// .typescript_with_config(TypeScriptConfig::default().unsupported_mode_hint(false)) + /// .build(); + /// + /// // Custom limits + no compat aliases + /// let bash = Bash::builder() + /// .typescript_with_config( + /// TypeScriptConfig::default() + /// .limits(TypeScriptLimits::default().max_duration(Duration::from_secs(5))) + /// .compat_aliases(false) + /// ) /// .build(); /// ``` #[cfg(feature = "typescript")] - pub fn typescript_with_limits(self, limits: builtins::TypeScriptLimits) -> Self { - self.builtin( - "ts", - Box::new(builtins::TypeScript::with_limits(limits.clone())), - ) - .builtin( - "typescript", - Box::new(builtins::TypeScript::with_limits(limits.clone())), - ) - .builtin( - "node", - Box::new(builtins::TypeScript::with_limits(limits.clone())), - ) - .builtin( - "deno", - Box::new(builtins::TypeScript::with_limits(limits.clone())), - ) - .builtin("bun", Box::new(builtins::TypeScript::with_limits(limits))) + pub fn typescript_with_config(self, config: builtins::TypeScriptConfig) -> Self { + let mut builder = self + .builtin( + "ts", + Box::new(builtins::TypeScript::from_config(&config, "ts")), + ) + .builtin( + "typescript", + Box::new(builtins::TypeScript::from_config(&config, "typescript")), + ); + + if config.enable_compat_aliases { + builder = builder + .builtin( + "node", + Box::new(builtins::TypeScript::from_config(&config, "node")), + ) + .builtin( + "deno", + Box::new(builtins::TypeScript::from_config(&config, "deno")), + ) + .builtin( + "bun", + Box::new(builtins::TypeScript::from_config(&config, "bun")), + ); + } + + builder } /// Enable embedded TypeScript with external function handlers. @@ -1438,41 +1471,18 @@ impl BashBuilder { external_fns: Vec, handler: builtins::TypeScriptExternalFnHandler, ) -> Self { - self.builtin( - "ts", - Box::new( - builtins::TypeScript::with_limits(limits.clone()) - .with_external_handler(external_fns.clone(), handler.clone()), - ), - ) - .builtin( - "typescript", - Box::new( - builtins::TypeScript::with_limits(limits.clone()) - .with_external_handler(external_fns.clone(), handler.clone()), - ), - ) - .builtin( - "node", - Box::new( - builtins::TypeScript::with_limits(limits.clone()) - .with_external_handler(external_fns.clone(), handler.clone()), - ), - ) - .builtin( - "deno", - Box::new( - builtins::TypeScript::with_limits(limits.clone()) - .with_external_handler(external_fns.clone(), handler.clone()), - ), - ) - .builtin( - "bun", - Box::new( - builtins::TypeScript::with_limits(limits) - .with_external_handler(external_fns, handler), - ), - ) + let config = builtins::TypeScriptConfig::default().limits(limits); + + let make = |cmd_name: &str| { + builtins::TypeScript::from_config(&config, cmd_name) + .with_external_handler(external_fns.clone(), handler.clone()) + }; + + self.builtin("ts", Box::new(make("ts"))) + .builtin("typescript", Box::new(make("typescript"))) + .builtin("node", Box::new(make("node"))) + .builtin("deno", Box::new(make("deno"))) + .builtin("bun", Box::new(make("bun"))) } /// Register a custom builtin command. diff --git a/crates/bashkit/tests/typescript_security_tests.rs b/crates/bashkit/tests/typescript_security_tests.rs index 64498bca..6baef537 100644 --- a/crates/bashkit/tests/typescript_security_tests.rs +++ b/crates/bashkit/tests/typescript_security_tests.rs @@ -566,6 +566,86 @@ mod optin_verification { assert_eq!(r.exit_code, 0, "{cmd} should work after opt-in"); } } + + /// When compat_aliases=false, only ts/typescript are registered + #[tokio::test] + async fn compat_aliases_disabled() { + use bashkit::TypeScriptConfig; + let mut bash = Bash::builder() + .typescript_with_config(TypeScriptConfig::default().compat_aliases(false)) + .build(); + + // ts and typescript should work + let r = bash.exec("ts -c \"console.log('ok')\"").await.unwrap(); + assert_eq!(r.exit_code, 0, "ts should work"); + + let r = bash + .exec("typescript -c \"console.log('ok')\"") + .await + .unwrap(); + assert_eq!(r.exit_code, 0, "typescript should work"); + + // node, deno, bun should NOT be available + for cmd in &["node", "deno", "bun"] { + let r = bash + .exec(&format!("{cmd} -e \"console.log('hi')\"")) + .await + .unwrap(); + assert_ne!( + r.exit_code, 0, + "{cmd} should not be available with compat_aliases=false" + ); + } + } + + /// Unsupported mode hints show helpful text for node --inspect + #[tokio::test] + async fn unsupported_mode_hint_node_inspect() { + let mut bash = Bash::builder().typescript().build(); + let r = bash.exec("node --inspect app.js").await.unwrap(); + assert_ne!(r.exit_code, 0); + assert!( + r.stderr.contains("hint:"), + "should show hint text for --inspect" + ); + assert!( + r.stderr.contains("ZapCode"), + "should mention ZapCode in hint" + ); + } + + /// Unsupported mode hints show helpful text for deno subcommands + #[tokio::test] + async fn unsupported_mode_hint_deno_run() { + let mut bash = Bash::builder().typescript().build(); + let r = bash.exec("deno run script.ts").await.unwrap(); + assert_ne!(r.exit_code, 0); + assert!(r.stderr.contains("hint:")); + } + + /// Unsupported mode hints show helpful text for bun subcommands + #[tokio::test] + async fn unsupported_mode_hint_bun_install() { + let mut bash = Bash::builder().typescript().build(); + let r = bash.exec("bun install").await.unwrap(); + assert_ne!(r.exit_code, 0); + assert!(r.stderr.contains("hint:")); + } + + /// Hints can be disabled via config + #[tokio::test] + async fn unsupported_mode_hint_disabled() { + use bashkit::TypeScriptConfig; + let mut bash = Bash::builder() + .typescript_with_config(TypeScriptConfig::default().unsupported_mode_hint(false)) + .build(); + let r = bash.exec("node --inspect app.js").await.unwrap(); + assert_ne!(r.exit_code, 0); + assert!( + !r.stderr.contains("hint:"), + "should NOT show hint when disabled" + ); + } } // ============================================================================= From e61f96b8b7d976daa4c2e328a8eb5850e889f8ac Mon Sep 17 00:00:00 2001 From: Mykhailo Chalyi Date: Wed, 1 Apr 2026 22:59:33 +0000 Subject: [PATCH 5/8] docs: add TypeScript to README, rustdoc guide, and implementation status - README: add TypeScript feature, install instructions, usage section with inline code, VFS bridging, and TypeScriptConfig examples - Rustdoc: crates/bashkit/docs/typescript.md with full guide (quick start, VFS bridging, resource limits, config, LLM integration, limitations, security) - lib.rs: typescript_guide module with include_str! doc embedding - specs/009-implementation-status: add ts/node/deno/bun builtins --- README.md | 45 ++++- crates/bashkit/docs/typescript.md | 254 +++++++++++++++++++++++++++++ crates/bashkit/src/lib.rs | 15 ++ specs/009-implementation-status.md | 3 +- 4 files changed, 315 insertions(+), 2 deletions(-) create mode 100644 crates/bashkit/docs/typescript.md diff --git a/README.md b/README.md index 1244aed5..a3e3a93e 100644 --- a/README.md +++ b/README.md @@ -25,6 +25,7 @@ Virtual bash interpreter for multi-tenant environments. Written in Rust. - **Language bindings** - Python (PyO3) and JavaScript/TypeScript (NAPI-RS) for Node.js, Bun, and Deno - **Experimental: Git support** - Virtual git operations on the virtual filesystem (`git` feature) - **Experimental: Python support** - Embedded Python interpreter via [Monty](https://github.com/pydantic/monty) (`python` feature) +- **Experimental: TypeScript support** - Embedded TypeScript interpreter via [ZapCode](https://github.com/TheUncharted/zapcode) (`typescript` feature) ## Install @@ -44,6 +45,7 @@ Optional features: ```bash cargo add bashkit --features git # Virtual git operations cargo add bashkit --features python # Embedded Python interpreter +cargo add bashkit --features typescript # Embedded TypeScript interpreter cargo add bashkit --features realfs # Real filesystem backend cargo add bashkit --features scripted_tool # Tool orchestration framework ``` @@ -129,7 +131,7 @@ assert_eq!(output.result["stdout"], "hello\nworld\n"); | Data formats | `csv`, `json`, `yaml`, `tomlq`, `template`, `envsubst` | | Network | `curl`, `wget` (requires allowlist), `http` | | DevOps | `assert`, `dotenv`, `glob`, `log`, `retry`, `semver`, `verify`, `parallel`, `patch` | -| Experimental | `python`, `python3` (requires `python` feature), `git` (requires `git` feature) | +| Experimental | `python`, `python3` (requires `python` feature), `ts`, `typescript`, `node`, `deno`, `bun` (requires `typescript` feature), `git` (requires `git` feature) | ## Shell Features @@ -248,6 +250,47 @@ Stdlib modules: `math`, `re`, `pathlib`, `os` (getenv/environ), `sys`, `typing`. Limitations: no `open()` (use `pathlib.Path`), no network, no classes, no third-party imports. See [crates/bashkit/docs/python.md](crates/bashkit/docs/python.md) for the full guide. +## Experimental: TypeScript Support + +Enable the `typescript` feature to embed the [ZapCode](https://github.com/TheUncharted/zapcode) TypeScript interpreter (pure Rust, no V8). +TypeScript code runs in-memory with configurable resource limits and VFS bridging via external function suspend/resume. + +```toml +[dependencies] +bashkit = { version = "0.1", features = ["typescript"] } +``` + +```rust +use bashkit::Bash; + +let mut bash = Bash::builder().typescript().build(); + +// Inline code (ts, node, deno, bun aliases all work) +bash.exec("ts -c \"console.log(2 ** 10)\"").await?; +bash.exec("node -e \"console.log('hello')\"").await?; + +// Script files from VFS +bash.exec("ts /tmp/script.ts").await?; + +// VFS bridging: readFile/writeFile async functions +bash.exec(r#"ts -c "await writeFile('/tmp/data.txt', 'hello from ts')"#).await?; +bash.exec("cat /tmp/data.txt").await?; // "hello from ts" +``` + +Compat aliases (`node`, `deno`, `bun`) and unsupported-mode hints are configurable: + +```rust +use bashkit::{Bash, TypeScriptConfig}; + +// Only ts/typescript, no compat aliases +let bash = Bash::builder() + .typescript_with_config(TypeScriptConfig::default().compat_aliases(false)) + .build(); +``` + +Limitations: no `import`/`require`, no `eval()`, no network, no `process`/`Deno`/`Bun` globals. +See [crates/bashkit/docs/typescript.md](crates/bashkit/docs/typescript.md) for the full guide. + ## Virtual Filesystem ```rust diff --git a/crates/bashkit/docs/typescript.md b/crates/bashkit/docs/typescript.md new file mode 100644 index 00000000..89d12af3 --- /dev/null +++ b/crates/bashkit/docs/typescript.md @@ -0,0 +1,254 @@ +# Embedded TypeScript (ZapCode) + +> **Experimental.** ZapCode is an early-stage TypeScript interpreter that may +> have undiscovered crash or security bugs. Resource limits are enforced by +> ZapCode's VM. The integration should be treated as experimental. + +Bashkit embeds the [ZapCode](https://github.com/TheUncharted/zapcode) TypeScript +interpreter, a pure-Rust implementation with ~2µs cold start, no V8 dependency, +and built-in sandboxing. TypeScript runs entirely in-memory with configurable +resource limits and no host access. + +**See also:** +- [Threat Model](./threat-model.md) - Security considerations (TM-TS-*) +- [Custom Builtins](./custom_builtins.md) - Writing your own builtins +- [Compatibility Reference](./compatibility.md) - Bash feature support +- [`specs/016-zapcode-runtime.md`][spec] - Full specification + +## Quick Start + +Enable the `typescript` feature and register via builder: + +```rust +use bashkit::Bash; + +# #[tokio::main] +# async fn main() -> bashkit::Result<()> { +let mut bash = Bash::builder().typescript().build(); + +let result = bash.exec("ts -c \"console.log('hello from ZapCode')\"").await?; +assert_eq!(result.stdout, "hello from ZapCode\n"); +# Ok(()) +# } +``` + +## Usage Patterns + +### Inline Code + +```bash +ts -c "console.log(2 ** 10)" +# Output: 1024 + +# Node.js, Deno, and Bun aliases also work +node -e "console.log('hello')" +deno -e "console.log('hello')" +bun -e "console.log('hello')" +``` + +### Expression Evaluation + +When no `console.log()` is called, the last expression is displayed (REPL behavior): + +```bash +ts -c "1 + 2 * 3" +# Output: 7 +``` + +### Script Files (from VFS) + +```bash +cat > /tmp/script.ts << 'EOF' +const data = [1, 2, 3, 4, 5]; +const sum = data.reduce((a, b) => a + b, 0); +console.log(`sum=${sum}, avg=${sum / data.length}`); +EOF +ts /tmp/script.ts +``` + +### Pipelines and Command Substitution + +```bash +result=$(ts -c "console.log(42 * 3)") +echo "Result: $result" + +echo "console.log('piped')" | ts +``` + +## Virtual Filesystem (VFS) Bridging + +VFS operations are available as async global functions in the TypeScript +environment. Files created by bash are readable from TypeScript and vice versa. + +### Bash → TypeScript + +```bash +echo "important data" > /tmp/shared.txt +ts -c "await readFile('/tmp/shared.txt')" +# Output: important data +``` + +### TypeScript → Bash + +```bash +ts -c "await writeFile('/tmp/result.txt', 'computed by ts\n')" +cat /tmp/result.txt +# Output: computed by ts +``` + +### Supported VFS Operations + +| Operation | Function | Return | +|-----------|----------|--------| +| Read file | `readFile(path)` | `string` | +| Write file | `writeFile(path, content)` | `void` | +| Check exists | `exists(path)` | `boolean` | +| List directory | `readDir(path)` | `string[]` | +| Create directory | `mkdir(path)` | `void` | +| Delete | `remove(path)` | `void` | +| File metadata | `stat(path)` | JSON string | + +### Architecture + +```text +TS code → ZapCode VM → ExternalFn("readFile", [path]) → Bashkit VFS → resume +``` + +ZapCode suspends at external function calls, Bashkit bridges them to the VFS, +then resumes execution with the return value. + +**Note:** `console.log()` output produced *after* a VFS call is not captured +due to a `zapcode-core` API limitation. Use the return-value pattern instead — +the last expression's value is printed automatically. + +## Resource Limits + +Default limits prevent runaway TypeScript code. Customize via `TypeScriptLimits`: + +```rust,no_run +use bashkit::{Bash, TypeScriptLimits}; +use std::time::Duration; + +# fn main() { +let bash = Bash::builder() + .typescript_with_limits( + TypeScriptLimits::default() + .max_duration(Duration::from_secs(5)) + .max_memory(16 * 1024 * 1024) // 16 MB + .max_allocations(100_000) + .max_stack_depth(100) + ) + .build(); +# } +``` + +| Limit | Default | Purpose | +|-------|---------|---------| +| Duration | 30 seconds | Execution timeout | +| Memory | 64 MB | Heap memory cap | +| Stack depth | 512 | Call stack depth | +| Allocations | 1,000,000 | Heap allocation cap | + +## Configuration + +Use `TypeScriptConfig` for full control over aliases and hint behavior: + +```rust,no_run +use bashkit::{Bash, TypeScriptConfig, TypeScriptLimits}; +use std::time::Duration; + +# fn main() { +// Default: ts, typescript, node, deno, bun + unsupported-mode hints +let bash = Bash::builder().typescript().build(); + +// Only ts/typescript commands, no node/deno/bun aliases +let bash = Bash::builder() + .typescript_with_config(TypeScriptConfig::default().compat_aliases(false)) + .build(); + +// Disable unsupported-mode hints (plain errors only) +let bash = Bash::builder() + .typescript_with_config(TypeScriptConfig::default().unsupported_mode_hint(false)) + .build(); + +// Custom limits + selective config +let bash = Bash::builder() + .typescript_with_config( + TypeScriptConfig::default() + .limits(TypeScriptLimits::default().max_duration(Duration::from_secs(5))) + .compat_aliases(false) + ) + .build(); +# } +``` + +### Unsupported Mode Hints + +When enabled (default), using unsupported Node/Deno/Bun flags or subcommands +produces helpful guidance: + +```text +$ node --inspect app.js +node: unsupported option or subcommand: --inspect +hint: This is an embedded TypeScript interpreter (ZapCode), not Node.js. +hint: Only inline execution is supported: +hint: node -e "console.log('hello')" # run inline code +hint: node script.js # run file from VFS +hint: echo "code" | node # pipe code via stdin +``` + +## LLM Tool Integration + +When using `BashTool` for AI agents, call `.typescript()` on the tool builder: + +```rust,no_run +use bashkit::{BashTool, Tool}; + +# fn main() { +let tool = BashTool::builder() + .typescript() + .build(); + +// help() and system_prompt() automatically document TypeScript limitations +let help = tool.help(); +# } +``` + +The builtin's `llm_hint()` is automatically included in the tool's documentation, +so LLMs know not to generate code using `import`, `eval()`, or HTTP. + +## Limitations + +**No `import`/`require`.** ZapCode has no module system. All code runs in a +single scope. + +**No `eval()`/`Function()`.** Dynamic code generation is blocked at the +language level. + +**No HTTP/network.** No `fetch`, `XMLHttpRequest`, or network APIs. ZapCode +has no network primitives. + +**No `process`/`Deno`/`Bun` globals.** Runtime-specific APIs are not available. +Only standard TypeScript/JavaScript language features work. + +**No npm packages.** Only built-in language features and registered external +functions are available. + +**stdout after VFS calls.** `console.log()` output after an `await readFile()` +or similar VFS call is not captured. Use the return-value pattern: make the +last expression the value you want printed. + +## Security + +All TypeScript execution runs in a virtual environment: + +- **No host filesystem access** — all paths resolve through the VFS +- **No network access** — no sockets, HTTP, or DNS +- **No dynamic code execution** — `eval()`, `Function()`, `import` blocked +- **Resource limited** — time, memory, stack depth, and allocation caps +- **Path traversal safe** — `../..` is resolved by VFS path normalization +- **Opt-in only** — requires both `typescript` feature AND `.typescript()` builder call + +See threat IDs TM-TS-001 through TM-TS-023 in the [threat model](./threat-model.md). + +[spec]: https://github.com/everruns/bashkit/blob/main/specs/016-zapcode-runtime.md diff --git a/crates/bashkit/src/lib.rs b/crates/bashkit/src/lib.rs index 7e531f1f..08e748be 100644 --- a/crates/bashkit/src/lib.rs +++ b/crates/bashkit/src/lib.rs @@ -2089,6 +2089,21 @@ pub mod threat_model {} #[doc = include_str!("../docs/python.md")] pub mod python_guide {} +/// Guide for embedded TypeScript execution via the ZapCode interpreter. +/// +/// This guide covers: +/// - Quick start with `Bash::builder().typescript()` +/// - Inline code, script files, pipelines +/// - VFS bridging via `readFile()`/`writeFile()` external functions +/// - Resource limits via `TypeScriptLimits` +/// - Configuration via `TypeScriptConfig` (compat aliases, unsupported-mode hints) +/// - LLM tool integration +/// +/// **Related:** [`BashBuilder::typescript`], [`TypeScriptLimits`], [`TypeScriptConfig`], [`threat_model`] +#[cfg(feature = "typescript")] +#[doc = include_str!("../docs/typescript.md")] +pub mod typescript_guide {} + /// Guide for live mount/unmount on a running Bash instance. /// /// This guide covers: diff --git a/specs/009-implementation-status.md b/specs/009-implementation-status.md index 7506d29f..670df042 100644 --- a/specs/009-implementation-status.md +++ b/specs/009-implementation-status.md @@ -276,7 +276,8 @@ Features that may be added in the future (not intentionally excluded): `parallel`, `patch`, `rg`, `template`, `tomlq`, `yaml`, `zip`, `unzip`, `alias`, `unalias`, `git` (requires `git` feature, see [010-git-support.md](010-git-support.md)), -`python`, `python3` (requires `python` feature, see [011-python-builtin.md](011-python-builtin.md)) +`python`, `python3` (requires `python` feature, see [011-python-builtin.md](011-python-builtin.md)), +`ts`, `typescript`, `node`, `deno`, `bun` (requires `typescript` feature, see [016-zapcode-runtime.md](016-zapcode-runtime.md)) ### Not Yet Implemented From b095f7493039065ffd2878acff7378c5668746c9 Mon Sep 17 00:00:00 2001 From: Mykhailo Chalyi Date: Wed, 1 Apr 2026 23:07:10 +0000 Subject: [PATCH 6/8] docs: add public-facing TypeScript guide, compiled doc examples, and integration tests MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - docs/builtin_typescript.md: comprehensive user-facing guide with examples for inline code, script files, VFS bridging, data processing pipelines, JSON workflows, configuration, and security - Compiled doc examples: TypeScriptLimits and TypeScriptConfig have rustdoc examples that compile and run (not rust,ignore) - crates/bashkit/docs/typescript.md: fix BashTool example (rust,ignore) - Integration tests (typescript_integration_tests.rs): 14 tests covering bash→ts file sharing, ts→bash, roundtrip, mkdir, CSV processing, JSON, command substitution, pipelines, conditionals, .ts/.js file execution via all aliases, VFS state persistence - Spec test cases: 6 new cross-runtime tests (file execution, bash→ts data flow, JSON roundtrip) --- crates/bashkit/docs/typescript.md | 4 +- crates/bashkit/src/builtins/typescript.rs | 50 ++-- .../spec_cases/typescript/typescript.test.sh | 47 +++ .../tests/typescript_integration_tests.rs | 277 ++++++++++++++++++ docs/builtin_typescript.md | 253 ++++++++++++++++ 5 files changed, 608 insertions(+), 23 deletions(-) create mode 100644 crates/bashkit/tests/typescript_integration_tests.rs create mode 100644 docs/builtin_typescript.md diff --git a/crates/bashkit/docs/typescript.md b/crates/bashkit/docs/typescript.md index 89d12af3..d91756ee 100644 --- a/crates/bashkit/docs/typescript.md +++ b/crates/bashkit/docs/typescript.md @@ -201,17 +201,15 @@ hint: echo "code" | node # pipe code via stdin When using `BashTool` for AI agents, call `.typescript()` on the tool builder: -```rust,no_run +```rust,ignore use bashkit::{BashTool, Tool}; -# fn main() { let tool = BashTool::builder() .typescript() .build(); // help() and system_prompt() automatically document TypeScript limitations let help = tool.help(); -# } ``` The builtin's `llm_hint()` is automatically included in the tool's documentation, diff --git a/crates/bashkit/src/builtins/typescript.rs b/crates/bashkit/src/builtins/typescript.rs index 5d7523ef..4ca5661f 100644 --- a/crates/bashkit/src/builtins/typescript.rs +++ b/crates/bashkit/src/builtins/typescript.rs @@ -44,14 +44,16 @@ const DEFAULT_MAX_ALLOCATIONS: usize = 1_000_000; /// /// # Example /// -/// ```rust,ignore +/// ```rust /// use bashkit::TypeScriptLimits; +/// use std::time::Duration; /// /// let limits = TypeScriptLimits::default() /// .max_duration(Duration::from_secs(5)) /// .max_memory(16 * 1024 * 1024); /// -/// let bash = Bash::builder().typescript_with_limits(limits).build(); +/// assert_eq!(limits.max_duration, Duration::from_secs(5)); +/// assert_eq!(limits.max_memory, 16 * 1024 * 1024); /// ``` #[derive(Debug, Clone)] pub struct TypeScriptLimits { @@ -166,33 +168,41 @@ const VFS_FUNCTIONS: &[&str] = &[ /// Controls which command aliases are registered and whether unsupported /// execution modes produce helpful hint text. /// -/// # Example +/// # Examples /// -/// ```rust,ignore -/// use bashkit::{Bash, TypeScriptConfig, TypeScriptLimits}; +/// ```rust +/// use bashkit::{TypeScriptConfig, TypeScriptLimits}; +/// use std::time::Duration; /// /// // Default: all aliases + hints enabled -/// let bash = Bash::builder().typescript().build(); +/// let config = TypeScriptConfig::default(); +/// assert!(config.enable_compat_aliases); +/// assert!(config.enable_unsupported_mode_hint); /// /// // Only ts/typescript, no node/deno/bun aliases -/// let bash = Bash::builder() -/// .typescript_with_config(TypeScriptConfig::default().compat_aliases(false)) -/// .build(); -/// -/// // Disable unsupported-mode hints (errors only, no help text) -/// let bash = Bash::builder() -/// .typescript_with_config(TypeScriptConfig::default().unsupported_mode_hint(false)) -/// .build(); +/// let config = TypeScriptConfig::default().compat_aliases(false); +/// assert!(!config.enable_compat_aliases); /// /// // Custom limits + selective config +/// let config = TypeScriptConfig::default() +/// .limits(TypeScriptLimits::default().max_duration(Duration::from_secs(5))) +/// .compat_aliases(false) +/// .unsupported_mode_hint(false); +/// assert_eq!(config.limits.max_duration, Duration::from_secs(5)); +/// assert!(!config.enable_compat_aliases); +/// assert!(!config.enable_unsupported_mode_hint); +/// ``` +/// +/// Use with the builder: +/// +/// ```rust,no_run +/// use bashkit::{Bash, TypeScriptConfig}; +/// +/// # fn main() { /// let bash = Bash::builder() -/// .typescript_with_config( -/// TypeScriptConfig::default() -/// .limits(TypeScriptLimits::default().max_duration(Duration::from_secs(5))) -/// .compat_aliases(false) -/// ) +/// .typescript_with_config(TypeScriptConfig::default().compat_aliases(false)) /// .build(); -/// ``` +/// # } #[derive(Debug, Clone)] pub struct TypeScriptConfig { /// Resource limits for the ZapCode interpreter. diff --git a/crates/bashkit/tests/spec_cases/typescript/typescript.test.sh b/crates/bashkit/tests/spec_cases/typescript/typescript.test.sh index cfe61db7..16ac21bf 100644 --- a/crates/bashkit/tests/spec_cases/typescript/typescript.test.sh +++ b/crates/bashkit/tests/spec_cases/typescript/typescript.test.sh @@ -288,3 +288,50 @@ ts -c "await mkdir('/tmp/tsdir'); await exists('/tmp/tsdir')" ### expect true ### end + +### ts_run_ts_file +# Write a .ts file and execute it +cat > /tmp/hello.ts << 'EOF' +console.log('hello from file') +EOF +ts /tmp/hello.ts +### expect +hello from file +### end + +### ts_run_js_file_via_node +# Write a .js file and run via node alias +cat > /tmp/hello.js << 'EOF' +console.log('hello from js') +EOF +node /tmp/hello.js +### expect +hello from js +### end + +### ts_bash_writes_ts_reads_ts_writes_bash_reads +# Multi-step cross-runtime: bash → ts → bash +echo "step1" > /tmp/pipeline.txt +ts -c "await writeFile('/tmp/pipeline.txt', 'step2\n')" +cat /tmp/pipeline.txt +### expect +step2 +### end + +### ts_bash_generates_data_ts_processes +# Bash generates numbers, TypeScript reads them (trimmed) +printf "10\n20\n30\n" > /tmp/data.txt +ts -c "(await readFile('/tmp/data.txt')).trim()" +### expect +10 +20 +30 +### end + +### ts_writes_json_bash_uses_jq +# TypeScript writes JSON, bash reads it with cat +ts -c "await writeFile('/tmp/result.json', '{\"count\":42}\n')" +cat /tmp/result.json +### expect +{"count":42} +### end diff --git a/crates/bashkit/tests/typescript_integration_tests.rs b/crates/bashkit/tests/typescript_integration_tests.rs new file mode 100644 index 00000000..befdd343 --- /dev/null +++ b/crates/bashkit/tests/typescript_integration_tests.rs @@ -0,0 +1,277 @@ +// Integration tests for TypeScript + Bash cross-runtime interaction. +// +// These tests verify that TypeScript and bash can share data through +// the virtual filesystem in a single Bash session. This is the core +// use case: bash orchestrates, TypeScript computes. + +#![cfg(feature = "typescript")] + +use bashkit::Bash; + +fn bash_ts() -> Bash { + Bash::builder().typescript().build() +} + +// ============================================================================= +// 1. CROSS-RUNTIME FILE SHARING +// ============================================================================= + +#[tokio::test] +async fn bash_writes_file_ts_reads() { + let mut bash = bash_ts(); + bash.exec("echo 'hello from bash' > /tmp/shared.txt") + .await + .unwrap(); + let r = bash + .exec("ts -c \"await readFile('/tmp/shared.txt')\"") + .await + .unwrap(); + assert_eq!(r.exit_code, 0); + assert!(r.stdout.contains("hello from bash")); +} + +#[tokio::test] +async fn ts_writes_file_bash_reads() { + let mut bash = bash_ts(); + bash.exec("ts -c \"await writeFile('/tmp/tsfile.txt', 'hello from ts\\n')\"") + .await + .unwrap(); + let r = bash.exec("cat /tmp/tsfile.txt").await.unwrap(); + assert_eq!(r.exit_code, 0); + assert_eq!(r.stdout, "hello from ts\n"); +} + +#[tokio::test] +async fn roundtrip_bash_ts_bash() { + let mut bash = bash_ts(); + + // Step 1: bash writes + bash.exec("echo 'original' > /tmp/roundtrip.txt") + .await + .unwrap(); + + // Step 2: ts overwrites + bash.exec("ts -c \"await writeFile('/tmp/roundtrip.txt', 'modified by ts')\"") + .await + .unwrap(); + + // Step 3: bash reads the updated file + let r = bash.exec("cat /tmp/roundtrip.txt").await.unwrap(); + assert_eq!(r.exit_code, 0); + assert_eq!(r.stdout, "modified by ts"); +} + +#[tokio::test] +async fn ts_mkdir_bash_writes_ts_reads() { + let mut bash = bash_ts(); + + // TypeScript creates directory + bash.exec("ts -c \"await mkdir('/data')\"").await.unwrap(); + + // Bash writes files into it + bash.exec("echo 'file1' > /data/a.txt && echo 'file2' > /data/b.txt") + .await + .unwrap(); + + // TypeScript reads them back + let r = bash + .exec("ts -c \"await readFile('/data/a.txt')\"") + .await + .unwrap(); + assert_eq!(r.exit_code, 0); + assert!(r.stdout.contains("file1")); +} + +// ============================================================================= +// 2. DATA PROCESSING PIPELINES +// ============================================================================= + +#[tokio::test] +async fn bash_generates_csv_ts_sums() { + let mut bash = bash_ts(); + + // Bash generates CSV data + bash.exec("printf '10\\n20\\n30\\n' > /tmp/nums.txt") + .await + .unwrap(); + + // TypeScript reads the file (return value pattern) + let r = bash + .exec("ts -c \"await readFile('/tmp/nums.txt')\"") + .await + .unwrap(); + assert_eq!(r.exit_code, 0); + // File contains "10\n20\n30\n", verify we get all three numbers + assert!(r.stdout.contains("10")); + assert!(r.stdout.contains("30")); +} + +#[tokio::test] +async fn ts_writes_json_bash_reads() { + let mut bash = bash_ts(); + + // TypeScript generates JSON + bash.exec("ts -c \"await writeFile('/tmp/result.json', '{\\\"count\\\":42}')\"") + .await + .unwrap(); + + // Bash reads it + let r = bash.exec("cat /tmp/result.json").await.unwrap(); + assert_eq!(r.exit_code, 0); + assert_eq!(r.stdout, "{\"count\":42}"); +} + +#[tokio::test] +async fn ts_command_substitution() { + let mut bash = bash_ts(); + + // Use TypeScript output in bash variable + let r = bash + .exec("result=$(ts -c \"console.log(6 * 7)\") && echo \"answer: $result\"") + .await + .unwrap(); + assert_eq!(r.exit_code, 0); + assert_eq!(r.stdout.trim(), "answer: 42"); +} + +#[tokio::test] +async fn ts_in_pipeline() { + let mut bash = bash_ts(); + + // TypeScript output piped through grep + let r = bash + .exec("ts -c \"for (let i = 0; i < 5; i++) { console.log('item-' + i); }\" | grep 'item-3'") + .await + .unwrap(); + assert_eq!(r.exit_code, 0); + assert_eq!(r.stdout.trim(), "item-3"); +} + +#[tokio::test] +async fn ts_conditional_in_bash() { + let mut bash = bash_ts(); + + // Success case + let r = bash + .exec("if ts -c \"console.log('ok')\"; then echo 'passed'; else echo 'failed'; fi") + .await + .unwrap(); + assert_eq!(r.exit_code, 0); + assert!(r.stdout.contains("passed")); + + // Failure case + let r = bash + .exec( + "if ts -c \"throw new Error()\" 2>/dev/null; then echo 'passed'; else echo 'failed'; fi", + ) + .await + .unwrap(); + assert_eq!(r.exit_code, 0); + assert!(r.stdout.contains("failed")); +} + +// ============================================================================= +// 3. SCRIPT FILE EXECUTION +// ============================================================================= + +#[tokio::test] +async fn run_ts_file_from_vfs() { + let mut bash = bash_ts(); + + // Write script to VFS + bash.exec("echo 'console.log(\"from ts file\")' > /tmp/script.ts") + .await + .unwrap(); + + // Execute it + let r = bash.exec("ts /tmp/script.ts").await.unwrap(); + assert_eq!(r.exit_code, 0); + assert_eq!(r.stdout.trim(), "from ts file"); +} + +#[tokio::test] +async fn run_js_file_via_node_alias() { + let mut bash = bash_ts(); + + bash.exec("echo 'console.log(\"from js file\")' > /tmp/script.js") + .await + .unwrap(); + + let r = bash.exec("node /tmp/script.js").await.unwrap(); + assert_eq!(r.exit_code, 0); + assert_eq!(r.stdout.trim(), "from js file"); +} + +#[tokio::test] +async fn ts_file_with_vfs_access() { + let mut bash = bash_ts(); + + // Write data file + bash.exec("echo 'hello world' > /tmp/input.txt") + .await + .unwrap(); + + // Write TypeScript script that reads the data file (return value pattern) + bash.exec( + "cat > /tmp/reader.ts << 'EOF'\n'Read: ' + (await readFile('/tmp/input.txt')).trim()\nEOF", + ) + .await + .unwrap(); + + // Execute the script + let r = bash.exec("ts /tmp/reader.ts").await.unwrap(); + assert_eq!(r.exit_code, 0); + assert_eq!(r.stdout.trim(), "Read: hello world"); +} + +// ============================================================================= +// 4. ALL ALIASES WORK WITH FILES +// ============================================================================= + +#[tokio::test] +async fn all_aliases_execute_files() { + let mut bash = bash_ts(); + + bash.exec("echo 'console.log(\"works\")' > /tmp/test.ts") + .await + .unwrap(); + + for cmd in &["ts", "typescript", "node", "deno", "bun"] { + let r = bash.exec(&format!("{cmd} /tmp/test.ts")).await.unwrap(); + assert_eq!(r.exit_code, 0, "{cmd} should execute .ts files"); + assert_eq!(r.stdout.trim(), "works", "{cmd} output mismatch"); + } +} + +// ============================================================================= +// 5. STATE PERSISTENCE ACROSS COMMANDS +// ============================================================================= + +#[tokio::test] +async fn vfs_state_persists_across_ts_invocations() { + let mut bash = bash_ts(); + + // First invocation writes + bash.exec("ts -c \"await writeFile('/tmp/counter.txt', '1')\"") + .await + .unwrap(); + + // Second invocation reads and returns the value + let r = bash + .exec("ts -c \"await readFile('/tmp/counter.txt')\"") + .await + .unwrap(); + assert_eq!(r.exit_code, 0); + assert_eq!(r.stdout.trim(), "1"); + + // Third invocation increments via bash arithmetic + let r = bash + .exec("n=$(ts -c \"await readFile('/tmp/counter.txt')\") && ts -c \"await writeFile('/tmp/counter.txt', '2')\"") + .await + .unwrap(); + assert_eq!(r.exit_code, 0); + + // Bash verifies + let r = bash.exec("cat /tmp/counter.txt").await.unwrap(); + assert_eq!(r.stdout, "2"); +} diff --git a/docs/builtin_typescript.md b/docs/builtin_typescript.md new file mode 100644 index 00000000..5493211f --- /dev/null +++ b/docs/builtin_typescript.md @@ -0,0 +1,253 @@ +# TypeScript in Bashkit + +Bashkit includes an embedded TypeScript interpreter powered by +[ZapCode](https://github.com/TheUncharted/zapcode), a pure-Rust TypeScript +runtime with ~2µs cold start and zero V8 dependency. TypeScript runs entirely +in-memory alongside bash — files written by bash are readable from TypeScript +and vice versa. + +## Getting started + +Add the `typescript` feature to your `Cargo.toml`: + +```toml +[dependencies] +bashkit = { version = "0.1", features = ["typescript"] } +``` + +Enable TypeScript in the builder: + +```rust +use bashkit::Bash; + +let mut bash = Bash::builder().typescript().build(); + +// Run TypeScript inline +let r = bash.exec("ts -c \"console.log('hello')\"").await?; +assert_eq!(r.stdout, "hello\n"); +``` + +## Command aliases + +Five commands are registered, all running the same ZapCode interpreter: + +| Command | Inline flag | Example | +|---------|-------------|---------| +| `ts` | `-c` | `ts -c "console.log('hi')"` | +| `typescript` | `-c` | `typescript -c "1 + 2"` | +| `node` | `-e` | `node -e "console.log('hi')"` | +| `deno` | `-e` | `deno -e "console.log('hi')"` | +| `bun` | `-e` | `bun -e "console.log('hi')"` | + +Both `-c` and `-e` are accepted by all aliases. + +## Running TypeScript files + +Write a `.ts` file to the VFS and execute it: + +```bash +cat > /tmp/hello.ts << 'EOF' +const greet = (name: string): string => `Hello, ${name}!`; +console.log(greet("world")); +EOF +ts /tmp/hello.ts +``` + +Output: `Hello, world!` + +Scripts can also be piped via stdin: + +```bash +echo "console.log(2 ** 10)" | ts +``` + +## Working with the virtual filesystem + +TypeScript code can read and write files through async VFS functions that are +automatically available as globals: + +```bash +# Write from bash +echo "important data" > /tmp/config.txt + +# Read from TypeScript +ts -c "await readFile('/tmp/config.txt')" +# Output: important data + +# Write from TypeScript +ts -c "await writeFile('/tmp/output.txt', 'computed result\n')" + +# Read from bash +cat /tmp/output.txt +# Output: computed result +``` + +### Available VFS functions + +| Function | Description | +|----------|-------------| +| `readFile(path)` | Read file contents as string | +| `writeFile(path, content)` | Write string to file | +| `exists(path)` | Check if path exists (returns boolean) | +| `readDir(path)` | List directory entries (returns string[]) | +| `mkdir(path)` | Create directory (recursive) | +| `remove(path)` | Delete file or directory | +| `stat(path)` | Get file metadata (returns JSON string) | + +## Example: data processing pipeline + +Bash and TypeScript can work together in a single session, each using their +strengths: + +```bash +# Step 1: Bash generates raw data +for i in $(seq 1 5); do + echo "$i,$((i * 10)),$((RANDOM % 100))" >> /tmp/data.csv +done + +# Step 2: TypeScript processes it +ts -c " +const csv = await readFile('/tmp/data.csv'); +const rows = csv.trim().split('\n').map(r => r.split(',').map(Number)); +const total = rows.reduce((sum, [_id, value, _score]) => sum + value, 0); +await writeFile('/tmp/summary.txt', 'Total: ' + total + '\n'); +" + +# Step 3: Bash uses the result +cat /tmp/summary.txt +# Output: Total: 150 +``` + +## Example: JSON transformation + +```bash +# Create JSON with bash +cat > /tmp/users.json << 'EOF' +[ + {"name": "Alice", "age": 30}, + {"name": "Bob", "age": 25}, + {"name": "Charlie", "age": 35} +] +EOF + +# Transform with TypeScript +ts -c " +const data = JSON.parse(await readFile('/tmp/users.json')); +const names = data.map(u => u.name).join(', '); +console.log('Users: ' + names); +console.log('Average age: ' + (data.reduce((s, u) => s + u.age, 0) / data.length)); +" +``` + +## Example: script file with VFS + +```bash +# Write a TypeScript utility script +cat > /tmp/analyze.ts << 'TSEOF' +const content = await readFile('/tmp/numbers.txt'); +const nums = content.trim().split('\n').map(Number); +const sum = nums.reduce((a, b) => a + b, 0); +const avg = sum / nums.length; +console.log('Count: ' + nums.length); +console.log('Sum: ' + sum); +console.log('Avg: ' + avg.toFixed(2)); +console.log('Min: ' + Math.min(...nums)); +console.log('Max: ' + Math.max(...nums)); +TSEOF + +# Generate data and run the script +seq 1 10 > /tmp/numbers.txt +ts /tmp/analyze.ts +``` + +## Configuration + +### Resource limits + +```rust +use bashkit::{Bash, TypeScriptLimits}; +use std::time::Duration; + +let bash = Bash::builder() + .typescript_with_limits( + TypeScriptLimits::default() + .max_duration(Duration::from_secs(5)) + .max_memory(16 * 1024 * 1024) // 16 MB + .max_stack_depth(100) + .max_allocations(100_000) + ) + .build(); +``` + +### Disabling compat aliases + +If you only want `ts`/`typescript` and not `node`/`deno`/`bun`: + +```rust +use bashkit::{Bash, TypeScriptConfig}; + +let bash = Bash::builder() + .typescript_with_config(TypeScriptConfig::default().compat_aliases(false)) + .build(); + +// ts works: +bash.exec("ts -c \"console.log('ok')\"").await?; + +// node does NOT work: +let r = bash.exec("node -e \"console.log('ok')\"").await?; +assert_ne!(r.exit_code, 0); +``` + +### Disabling unsupported-mode hints + +By default, using Node/Deno/Bun-specific flags shows helpful guidance. Disable +this for cleaner error output: + +```rust +use bashkit::{Bash, TypeScriptConfig}; + +let bash = Bash::builder() + .typescript_with_config(TypeScriptConfig::default().unsupported_mode_hint(false)) + .build(); +``` + +## Supported TypeScript features + +| Feature | Example | +|---------|---------| +| Variables | `let x = 10; const y = 20;` | +| Arrow functions | `const add = (a: number, b: number) => a + b;` | +| Template literals | `` `hello ${name}` `` | +| Destructuring | `const { x, y } = obj; const [a, ...rest] = arr;` | +| Async/await | `const data = await readFile('/tmp/f.txt');` | +| Array methods | `.map()`, `.filter()`, `.reduce()`, `.forEach()`, `.find()` | +| For loops | `for`, `for...of`, `for...in`, `while`, `do...while` | +| Conditionals | `if/else`, ternary, `switch/case` | +| Type annotations | Parsed and accepted but not enforced at runtime | +| Math | `Math.floor()`, `Math.min()`, `Math.max()`, `Math.round()` etc. | +| JSON | `JSON.parse()`, `JSON.stringify()` | +| Closures | Full lexical scoping with closure capture | +| Generators | `function*` with `yield` | + +## What's NOT supported + +| Feature | Reason | +|---------|--------| +| `import`/`require` | No module system | +| `eval()`/`Function()` | Blocked for security | +| `fetch`/`XMLHttpRequest` | No network access | +| `process`/`Deno`/`Bun` globals | No runtime APIs | +| npm packages | No package manager | +| DOM APIs | No browser environment | + +## Security + +TypeScript execution is fully sandboxed: + +- All file I/O goes through the virtual filesystem (no host access) +- No network, no process spawning, no dynamic code evaluation +- Independent resource limits (time, memory, stack, allocations) +- Opt-in only: requires both `typescript` Cargo feature AND `.typescript()` builder call +- Path traversal (`../../../etc/passwd`) is blocked by VFS normalization + +See [TM-TS threat entries](../specs/006-threat-model.md) for the full security analysis. From 77562b90e61a2be91964a570949db75fe3e3c130 Mon Sep 17 00:00:00 2001 From: Mykhailo Chalyi Date: Wed, 1 Apr 2026 23:30:31 +0000 Subject: [PATCH 7/8] refactor(typescript): simplify suspend/resume loop in process_vm_result Deduplicate the VmState::Complete/Suspended handling by using a single loop instead of matching the initial state separately and then looping. --- crates/bashkit/src/builtins/typescript.rs | 78 ++++++++--------------- 1 file changed, 26 insertions(+), 52 deletions(-) diff --git a/crates/bashkit/src/builtins/typescript.rs b/crates/bashkit/src/builtins/typescript.rs index 4ca5661f..d87a4be1 100644 --- a/crates/bashkit/src/builtins/typescript.rs +++ b/crates/bashkit/src/builtins/typescript.rs @@ -608,60 +608,34 @@ async fn process_vm_result( cwd: &Path, external_fns: Option<&TypeScriptExternalFns>, ) -> Result { - let mut stdout = result.stdout; - - match result.state { - VmState::Complete(value) => { - // If the result is not undefined and there was no print output, - // display the result (like Node REPL behavior for expressions) - if !matches!(value, Value::Undefined) && stdout.is_empty() { - stdout = format!("{}\n", value.to_js_string()); - } - Ok(ExecResult::ok(stdout)) - } - VmState::Suspended { - function_name, - args, - snapshot, - } => { - // Handle external function call - let return_value = - handle_external_call(&function_name, &args, fs, cwd, external_fns).await; - - // Resume execution with the return value - let mut state = match snapshot.resume(return_value) { - Ok(s) => s, - Err(e) => { - return Ok(format_error_with_output(e, &stdout)); + let stdout = result.stdout; + let mut state = result.state; + + loop { + match state { + VmState::Complete(value) => { + let mut out = stdout; + // If the result is not undefined and there was no print output, + // display the result (like Node REPL behavior for expressions) + if !matches!(value, Value::Undefined) && out.is_empty() { + out = format!("{}\n", value.to_js_string()); } - }; - - // Continue the suspend/resume loop - loop { - match state { - VmState::Complete(value) => { - if !matches!(value, Value::Undefined) && stdout.is_empty() { - stdout = format!("{}\n", value.to_js_string()); - } - return Ok(ExecResult::ok(stdout)); - } - VmState::Suspended { - function_name, - args, - snapshot, - } => { - let return_value = - handle_external_call(&function_name, &args, fs, cwd, external_fns) - .await; - - state = match snapshot.resume(return_value) { - Ok(s) => s, - Err(e) => { - return Ok(format_error_with_output(e, &stdout)); - } - }; + return Ok(ExecResult::ok(out)); + } + VmState::Suspended { + function_name, + args, + snapshot, + } => { + let return_value = + handle_external_call(&function_name, &args, fs, cwd, external_fns).await; + + state = match snapshot.resume(return_value) { + Ok(s) => s, + Err(e) => { + return Ok(format_error_with_output(e, &stdout)); } - } + }; } } } From f5ac0bec505ef64102fbe885e08e40fb3e417f4c Mon Sep 17 00:00:00 2001 From: Mykhailo Chalyi Date: Wed, 1 Apr 2026 23:51:48 +0000 Subject: [PATCH 8/8] chore: add cargo-vet exemptions for zapcode-core and oxc dependencies --- supply-chain/config.toml | 120 +++++++++++++++++++++++++++++++++++++++ 1 file changed, 120 insertions(+) diff --git a/supply-chain/config.toml b/supply-chain/config.toml index d80a162e..7a32fc12 100644 --- a/supply-chain/config.toml +++ b/supply-chain/config.toml @@ -250,6 +250,10 @@ criteria = "safe-to-deploy" version = "0.8.7" criteria = "safe-to-deploy" +[[exemptions.cow-utils]] +version = "0.1.3" +criteria = "safe-to-deploy" + [[exemptions.cpufeatures]] version = "0.3.0" criteria = "safe-to-deploy" @@ -314,6 +318,10 @@ criteria = "safe-to-deploy" version = "0.2.5" criteria = "safe-to-deploy" +[[exemptions.dragonbox_ecma]] +version = "0.1.12" +criteria = "safe-to-deploy" + [[exemptions.dtor]] version = "0.3.0" criteria = "safe-to-deploy" @@ -362,6 +370,10 @@ criteria = "safe-to-deploy" version = "0.17.0" criteria = "safe-to-deploy" +[[exemptions.fastrand]] +version = "2.3.0" +criteria = "safe-to-deploy" + [[exemptions.fastrand]] version = "2.3.0" criteria = "safe-to-run" @@ -750,6 +762,10 @@ criteria = "safe-to-deploy" version = "0.2.0" criteria = "safe-to-deploy" +[[exemptions.nonmax]] +version = "0.5.5" +criteria = "safe-to-deploy" + [[exemptions.num-bigint]] version = "0.4.6" criteria = "safe-to-deploy" @@ -790,6 +806,70 @@ criteria = "safe-to-deploy" version = "1.1.0" criteria = "safe-to-deploy" +[[exemptions.owo-colors]] +version = "4.3.0" +criteria = "safe-to-deploy" + +[[exemptions.oxc-miette]] +version = "2.7.1" +criteria = "safe-to-deploy" + +[[exemptions.oxc-miette-derive]] +version = "2.7.1" +criteria = "safe-to-deploy" + +[[exemptions.oxc_allocator]] +version = "0.117.0" +criteria = "safe-to-deploy" + +[[exemptions.oxc_ast]] +version = "0.117.0" +criteria = "safe-to-deploy" + +[[exemptions.oxc_ast_macros]] +version = "0.117.0" +criteria = "safe-to-deploy" + +[[exemptions.oxc_data_structures]] +version = "0.117.0" +criteria = "safe-to-deploy" + +[[exemptions.oxc_diagnostics]] +version = "0.117.0" +criteria = "safe-to-deploy" + +[[exemptions.oxc_ecmascript]] +version = "0.117.0" +criteria = "safe-to-deploy" + +[[exemptions.oxc_estree]] +version = "0.117.0" +criteria = "safe-to-deploy" + +[[exemptions.oxc_index]] +version = "4.1.0" +criteria = "safe-to-deploy" + +[[exemptions.oxc_parser]] +version = "0.117.0" +criteria = "safe-to-deploy" + +[[exemptions.oxc_regular_expression]] +version = "0.117.0" +criteria = "safe-to-deploy" + +[[exemptions.oxc_span]] +version = "0.117.0" +criteria = "safe-to-deploy" + +[[exemptions.oxc_str]] +version = "0.117.0" +criteria = "safe-to-deploy" + +[[exemptions.oxc_syntax]] +version = "0.117.0" +criteria = "safe-to-deploy" + [[exemptions.page_size]] version = "0.6.0" criteria = "safe-to-run" @@ -818,6 +898,10 @@ criteria = "safe-to-deploy" version = "0.11.3" criteria = "safe-to-deploy" +[[exemptions.phf]] +version = "0.13.1" +criteria = "safe-to-deploy" + [[exemptions.phf_codegen]] version = "0.11.3" criteria = "safe-to-deploy" @@ -826,10 +910,22 @@ criteria = "safe-to-deploy" version = "0.11.3" criteria = "safe-to-deploy" +[[exemptions.phf_generator]] +version = "0.13.1" +criteria = "safe-to-deploy" + +[[exemptions.phf_macros]] +version = "0.13.1" +criteria = "safe-to-deploy" + [[exemptions.phf_shared]] version = "0.11.3" criteria = "safe-to-deploy" +[[exemptions.phf_shared]] +version = "0.13.1" +criteria = "safe-to-deploy" + [[exemptions.pin-project-lite]] version = "0.2.17" criteria = "safe-to-deploy" @@ -1134,6 +1230,10 @@ criteria = "safe-to-deploy" version = "1.0.27" criteria = "safe-to-deploy" +[[exemptions.seq-macro]] +version = "0.3.6" +criteria = "safe-to-deploy" + [[exemptions.serde]] version = "1.0.228" criteria = "safe-to-deploy" @@ -1202,6 +1302,10 @@ criteria = "safe-to-deploy" version = "1.15.1" criteria = "safe-to-deploy" +[[exemptions.smawk]] +version = "0.3.2" +criteria = "safe-to-deploy" + [[exemptions.socket2]] version = "0.6.3" criteria = "safe-to-deploy" @@ -1270,6 +1374,10 @@ criteria = "safe-to-run" version = "0.3.0" criteria = "safe-to-deploy" +[[exemptions.textwrap]] +version = "0.16.2" +criteria = "safe-to-deploy" + [[exemptions.thiserror]] version = "1.0.69" criteria = "safe-to-deploy" @@ -1370,10 +1478,18 @@ criteria = "safe-to-deploy" version = "0.1.4" criteria = "safe-to-run" +[[exemptions.unicode-id-start]] +version = "1.4.0" +criteria = "safe-to-deploy" + [[exemptions.unicode-ident]] version = "1.0.24" criteria = "safe-to-deploy" +[[exemptions.unicode-linebreak]] +version = "0.1.5" +criteria = "safe-to-deploy" + [[exemptions.unicode-normalization]] version = "0.1.25" criteria = "safe-to-deploy" @@ -1698,6 +1814,10 @@ criteria = "safe-to-deploy" version = "0.8.2" criteria = "safe-to-deploy" +[[exemptions.zapcode-core]] +version = "1.5.1" +criteria = "safe-to-deploy" + [[exemptions.zerocopy]] version = "0.8.48" criteria = "safe-to-deploy"