From 74544391c4a3911e0b0ecd2c03bbe5122893f9df Mon Sep 17 00:00:00 2001 From: Mykhailo Chalyi Date: Thu, 5 Nov 2026 04:29:12 +0000 Subject: [PATCH 1/2] fix(typescript): lift the per-call budget cap, exit 124 on timeout, guard deep nesting - Each ts call reserved max_allocations (1M) work units, capping a script at about 95 calls. Charge 4096 units plus VM time instead. - ZapCode time limit now exits 124 with 'ts: execution timed out after Ns'. - ZapCode's parser/compiler recurse without a depth guard; ~6 KB of nested source aborted the host process. Add a conservative nesting estimate (cap 1000) and run parse/compile on a 128 MiB-stack thread (TM-TS-024). --- crates/bashkit/docs/typescript.md | 5 + crates/bashkit/src/builtins/mod.rs | 2 + crates/bashkit/src/builtins/typescript.rs | 214 ++++++- .../src/builtins/typescript_nesting.rs | 525 ++++++++++++++++++ .../integration/execution_budget_tests.rs | 10 +- .../integration/typescript_security_tests.rs | 54 ++ knowledge/log.md | 1 + knowledge/runtimes/zapcode-runtime.md | 30 +- knowledge/security/threat-model.md | 1 + 9 files changed, 826 insertions(+), 16 deletions(-) create mode 100644 crates/bashkit/src/builtins/typescript_nesting.rs diff --git a/crates/bashkit/docs/typescript.md b/crates/bashkit/docs/typescript.md index ddac0ed91..f55373675 100644 --- a/crates/bashkit/docs/typescript.md +++ b/crates/bashkit/docs/typescript.md @@ -174,6 +174,11 @@ let bash = Bash::builder() | Memory | 64 MB | Heap memory cap | | Stack depth | 512 | Call stack depth | | Allocations | 1,000,000 | Heap allocation cap | +| Source nesting | ~1,000 (fixed) | Parser/compiler depth; deeper source fails with `ts: source is too deeply nested` | + +When the duration limit fires, the command exits with status 124 and prints +`ts: execution timed out after Ns` on stderr, like coreutils `timeout` and +`python3`. Any other error (including a script's own `throw`) exits 1. ## Configuration diff --git a/crates/bashkit/src/builtins/mod.rs b/crates/bashkit/src/builtins/mod.rs index 81caf4dbb..5c8c60403 100644 --- a/crates/bashkit/src/builtins/mod.rs +++ b/crates/bashkit/src/builtins/mod.rs @@ -164,6 +164,8 @@ mod python; #[cfg(feature = "typescript")] mod typescript; +#[cfg(feature = "typescript")] +mod typescript_nesting; #[cfg(feature = "sqlite")] mod sqlite; diff --git a/crates/bashkit/src/builtins/typescript.rs b/crates/bashkit/src/builtins/typescript.rs index 1aeff28f5..074bd4d4f 100644 --- a/crates/bashkit/src/builtins/typescript.rs +++ b/crates/bashkit/src/builtins/typescript.rs @@ -15,6 +15,7 @@ //! Supports: `ts -c "code"`, `ts script.ts`, stdin piping, and //! `node`/`deno`/`bun` aliases. +use crate::time_compat::Instant; #[cfg(all(target_arch = "wasm32", target_os = "unknown"))] use crate::zapcode_browser as zapcode_core; use async_trait::async_trait; @@ -38,6 +39,19 @@ const DEFAULT_MAX_STACK_DEPTH: usize = 512; /// Default heap-allocation cap. TypeScript-only: ZapCode counts allocations, /// Monty stopped exposing that axis in 0.0.19, so it is not a shared limit. const DEFAULT_MAX_ALLOCATIONS: usize = 1_000_000; +/// Fixed shared-budget work charged per `ts` entry (same as the CPython runtime). +const TS_CALL_WORK_UNITS: u64 = 4096; + +/// Native stack for the thread that parses, compiles and starts a script. +/// ZapCode recurses once or more per AST level with no depth guard, and a +/// stack overflow aborts the host process. Source deeper than +/// [`MAX_TS_NESTING`](super::typescript_nesting::MAX_TS_NESTING) is rejected +/// up front; this stack is sized so the accepted depth fits with margin even +/// in debug builds (32 MiB was the smallest that passed +/// `deep_nesting_at_the_limit_does_not_overflow` in a debug build; 128 MiB +/// gives 4x). The memory is reserved, not committed, until used. +#[cfg(not(target_arch = "wasm32"))] +const TS_VM_STACK_BYTES: usize = 128 * 1024 * 1024; /// Resource limits for the embedded TypeScript (ZapCode) interpreter. /// @@ -815,7 +829,12 @@ impl Builtin for TypeScript { let limits = self.effective_limits(remaining); ctx.consume_budget_input(code.len())?; ctx.consume_budget_work(u64::try_from(code.len().div_ceil(64)).unwrap_or(u64::MAX))?; - ctx.consume_budget_work(u64::try_from(limits.max_allocations).unwrap_or(u64::MAX))?; + // Important security decision: each entry pays a fixed admission cost + // here plus the wall-clock time its VM steps actually run (see + // `charge_vm_time`), so repeated entries share one budget without the + // old flat `max_allocations` reservation (1M units), which capped a + // default script at ~95 `ts` calls. ZapCode exposes no allocation count. + ctx.consume_budget_work(TS_CALL_WORK_UNITS)?; let code = self.external_fns.as_ref().map_or(code.clone(), |external| { rewrite_typescript_access(&code, &external.rewrites) @@ -878,6 +897,19 @@ async fn run_typescript( ext_fn_names.extend(ef.names.iter().cloned()); } + let time_limit = ts_limits.common.max_duration; + let nesting = super::typescript_nesting::estimate(code); + if nesting > super::typescript_nesting::MAX_TS_NESTING { + return Ok(ExecResult::err( + format!( + "ts: source is too deeply nested (estimated depth {nesting}, limit {})\n", + super::typescript_nesting::MAX_TS_NESTING + ), + 1, + )); + } + + let started = Instant::now(); let runner = match ZapcodeRun::new( code.to_string(), Vec::new(), @@ -886,19 +918,68 @@ async fn run_typescript( ) { Ok(r) => r, Err(e) => { - return Ok(ExecResult::err(format!("{e}\n"), 1)); + charge_vm_time(execution_budget.as_ref(), started)?; + return Ok(format_error_with_output(e, "", time_limit)); } }; - let result = match runner.run(Vec::new()) { + let result = run_on_vm_stack(runner); + charge_vm_time(execution_budget.as_ref(), started)?; + let result = match result { Ok(r) => r, - Err(e) => { - return Ok(ExecResult::err(format!("{e}\n"), 1)); - } + Err(e) => return Ok(format_error_with_output(e, "", time_limit)), }; // Process the result through the suspend/resume loop for VFS bridging - process_vm_result(result, &fs, cwd, external_fns, execution_budget.as_ref()).await + process_vm_result( + result, + &fs, + cwd, + external_fns, + execution_budget.as_ref(), + time_limit, + ) + .await +} + +/// Parse, compile and start the script. Off wasm this runs on a dedicated +/// thread with a [`TS_VM_STACK_BYTES`] stack; see that constant. +#[cfg(not(target_arch = "wasm32"))] +fn run_on_vm_stack( + runner: ZapcodeRun, +) -> std::result::Result { + std::thread::scope(|scope| { + match std::thread::Builder::new() + .name("bashkit-ts".into()) + .stack_size(TS_VM_STACK_BYTES) + .spawn_scoped(scope, || runner.run(Vec::new())) + { + Ok(handle) => match handle.join() { + Ok(result) => result, + Err(panic) => std::panic::resume_unwind(panic), + }, + // Could not get a thread (resource exhaustion): run inline. + // The nesting cap still applies. + Err(_) => runner.run(Vec::new()), + } + }) +} + +#[cfg(target_arch = "wasm32")] +fn run_on_vm_stack( + runner: ZapcodeRun, +) -> std::result::Result { + runner.run(Vec::new()) +} + +/// Charge the wall-clock time a synchronous VM step ran to the shared work +/// budget, one unit per microsecond. +fn charge_vm_time(budget: Option<&crate::limits::ExecutionBudget>, started: Instant) -> Result<()> { + if let Some(budget) = budget { + let micros = u64::try_from(started.elapsed().as_micros()).unwrap_or(u64::MAX); + budget.consume_work(micros)?; + } + Ok(()) } /// Process a VmState, handling suspension for external function calls. @@ -908,6 +989,7 @@ async fn process_vm_result( cwd: &Path, external_fns: Option<&TypeScriptExternalFns>, execution_budget: Option<&crate::limits::ExecutionBudget>, + time_limit: std::time::Duration, ) -> Result { let stdout = result.stdout; let mut state = result.state; @@ -947,10 +1029,13 @@ async fn process_vm_result( Err(error) => return Ok(ExecResult::err(format!("{error}\n"), 1)), }; - state = match snapshot.resume(return_value) { + let started = Instant::now(); + let resumed = snapshot.resume(return_value); + charge_vm_time(execution_budget, started)?; + state = match resumed { Ok(s) => s, Err(e) => { - return Ok(format_error_with_output(e, &stdout)); + return Ok(format_error_with_output(e, &stdout, time_limit)); } }; } @@ -1117,7 +1202,24 @@ async fn handle_stat(args: &[Value], fs: &Arc, cwd: &Path) -> Va } /// Format a ZapCode error with any stdout already produced. -fn format_error_with_output(e: zapcode_core::ZapcodeError, stdout: &str) -> ExecResult { +/// +/// Decision: ZapCode's time limit reports like the Python runtimes and +/// coreutils `timeout` (exit 124, `ts: execution timed out after Ns`). It is a +/// distinct error variant, so a script's own `throw` cannot fake it. +fn format_error_with_output( + e: zapcode_core::ZapcodeError, + stdout: &str, + time_limit: std::time::Duration, +) -> ExecResult { + if matches!(e, zapcode_core::ZapcodeError::TimeLimitExceeded) { + let mut result = ExecResult::with_code(stdout.to_string(), 124); + result.stderr = format!( + "ts: execution timed out after {:.1}s\n", + time_limit.as_secs_f64() + ) + .into(); + return result; + } let mut result = ExecResult::err(format!("{e}\n"), 1); result.stdout = stdout.to_string().into(); result @@ -1131,6 +1233,7 @@ fn format_error_with_output(e: zapcode_core::ZapcodeError, stdout: &str) -> Exec mod tests { use super::*; use crate::builtins::Context; + use crate::builtins::typescript_nesting; use crate::fs::InMemoryFs; use std::collections::HashMap; use std::path::PathBuf; @@ -1607,4 +1710,95 @@ mod tests { assert_eq!(r.exit_code, 1); assert!(!r.stderr.contains("hint:"), "should not contain hint text"); } + + type MakeSource = Box String>; + + /// Named generators of `n`-deep nesting, one per AST shape. + fn nesting_shapes() -> Vec<(&'static str, MakeSource)> { + fn wrap(open: &'static str, close: &'static str) -> MakeSource { + Box::new(move |n| format!("let x = {}1{};", open.repeat(n), close.repeat(n))) + } + fn chain(head: &'static str, step: &'static str) -> MakeSource { + Box::new(move |n| format!("let x = {head}{};", step.repeat(n))) + } + fn prefix(step: &'static str) -> MakeSource { + Box::new(move |n| format!("let x = {}1;", step.repeat(n))) + } + vec![ + ("array", wrap("[", "]")), + ("paren", wrap("(", ")")), + ("object", wrap("{a:", "}")), + ("template", wrap("`${", "}`")), + ("call-arg", wrap("String(", ")")), + ("arrow-block", wrap("(()=>{return ", "})()")), + ("function", wrap("(function(){return ", "})()")), + ("binary", chain("1", "+1")), + ("member", chain("Math", ".constructor")), + ("call", chain("(()=>()=>1)", "()")), + ("not", prefix("!")), + ("neg", prefix("- ")), + ("typeof", prefix("typeof ")), + ("arrow", prefix("a=>")), + ("ternary", prefix("1?1:")), + ( + "block", + Box::new(|n| format!("{}let x = 1;{}", "{".repeat(n), "}".repeat(n))), + ), + ( + "if", + Box::new(|n| format!("{}console.log(1);", "if(1)".repeat(n))), + ), + ( + "else-if", + Box::new(|n| format!("if(0){{}}{}else{{}}", "else if(0){}".repeat(n))), + ), + ] + } + + /// Largest `n` whose source the nesting estimate still accepts. + fn largest_accepted(make: &dyn Fn(usize) -> String) -> usize { + let (mut lo, mut hi) = (0, typescript_nesting::MAX_TS_NESTING + 1); + while lo + 1 < hi { + let mid = (lo + hi) / 2; + if typescript_nesting::estimate(&make(mid)) <= typescript_nesting::MAX_TS_NESTING { + lo = mid; + } else { + hi = mid; + } + } + lo + } + + /// Every shape at the deepest accepted size must run without overflowing + /// the VM thread's stack. Debug builds use the most stack per level, so + /// this is the margin check for `TS_VM_STACK_BYTES`. + #[tokio::test] + async fn deep_nesting_at_the_limit_does_not_overflow() { + for (name, make) in nesting_shapes() { + let n = largest_accepted(make.as_ref()); + assert!(n >= 30, "{name}: only {n} levels accepted"); + let src = make(n); + let r = run(&["-c", &src], None).await; + assert!( + !r.stderr.contains("too deeply nested"), + "{name} at {n} was rejected: {}", + r.stderr + ); + } + } + + #[tokio::test] + async fn deep_nesting_past_the_limit_is_rejected() { + for (name, make) in nesting_shapes() { + let src = make(5_000); + let r = run(&["-c", &src], None).await; + assert_eq!(r.exit_code, 1, "{name}"); + assert!( + r.stderr.starts_with("ts: source is too deeply nested"), + "{name}: {}", + r.stderr + ); + assert!(r.stdout.is_empty(), "{name}"); + } + } } diff --git a/crates/bashkit/src/builtins/typescript_nesting.rs b/crates/bashkit/src/builtins/typescript_nesting.rs new file mode 100644 index 000000000..20c8c930b --- /dev/null +++ b/crates/bashkit/src/builtins/typescript_nesting.rs @@ -0,0 +1,525 @@ +//! Conservative syntax-nesting estimate for TypeScript source, checked before +//! the source reaches ZapCode. +//! +//! # Why +//! +//! ZapCode 1.5.1 parses with oxc and then lowers and compiles the AST with +//! plain recursion and no depth guard. A few KB of nested source (`[[[...`, +//! `1+1+...`, `!!!...`) overflows the native stack, which aborts the whole +//! host process. Monty's parser has a recursion limit ("Source is too deeply +//! nested"); ZapCode has none, so bashkit adds one in front of it. +//! +//! # Decisions +//! +//! - The estimate must never be lower than the real AST depth. It is an upper +//! bound: every frame (`(`, `[`, `{`, `${`) counts one, plus every token since +//! the last sibling boundary in each open frame. An AST level always consumes +//! at least one token, so chains like `1+1+1`, `a.b.c`, `f()()`, `!!x`, +//! `x=>x=>`, `a?b:c?d:` and `else if` are all counted. +//! - Sibling boundaries (`,` and `;`, and a newline between two complete +//! statements in a block or at top level) reset the current frame's count, +//! so long array literals, argument lists and programs stay small. +//! - A closing bracket merges the child frame's count into its parent instead +//! of dropping it, so a mismatched or fake closer cannot lower the estimate. +//! A closer that does not match the open frame is counted as a plain token. +//! - Regex-versus-division is ambiguous to a lexer. Regex bodies are charged one +//! token per punctuation char and per word, so mistaking a division chain for +//! a regex still counts the chain. `/` after the `)` of `if`/`for`/`while`/ +//! `with` is a regex, as in the JS grammar. After `}` a `/` is read as +//! division; a regex statement right after a block cannot continue an +//! expression or an `else if` chain, so misreading it cannot hide depth. +//! - On wasm32 there is no thread with a large stack; the same cap applies +//! but ZapCode runs on the default wasm stack (see knowledge). +//! - The estimate may be too high for unusual code (a single statement with +//! more than [`MAX_TS_NESTING`] tokens and no `,`/`;`); that is rejected with +//! a clear error rather than risking a crash. + +/// Highest accepted nesting estimate. ZapCode runs on a thread whose stack is +/// sized for this (see `TS_VM_STACK_BYTES` in `typescript.rs`), with margin +/// checked by `deep_nesting_at_the_limit_does_not_overflow` in debug builds. +pub(crate) const MAX_TS_NESTING: usize = 1000; + +#[derive(Clone, Copy, PartialEq, Eq)] +enum FrameKind { + Top, + Paren { keyword: bool }, + Bracket, + Brace, + Template, +} + +struct Frame { + kind: FrameKind, + count: usize, +} + +#[derive(Clone, Copy, PartialEq, Eq)] +enum Tok { + /// Identifier or keyword that ends an operand (`x`, `this`, `true`). + Word, + /// Keyword that expects an operand after it (`return`, `typeof`, `new`). + PrefixKeyword, + Literal, + Open, + /// `)` of `if (...)`, `for (...)`, `while (...)`, `with (...)`. + KeywordClose, + Close, + Punct, + Boundary, +} + +/// Keywords after which an expression (and therefore a regex) may start, and +/// after which a newline does not end a statement. +const PREFIX_KEYWORDS: &[&str] = &[ + "return", + "typeof", + "instanceof", + "in", + "of", + "new", + "delete", + "void", + "throw", + "case", + "do", + "else", + "yield", + "await", + "extends", + "as", + "satisfies", + "keyof", + "export", + "default", + "import", + "let", + "const", + "var", + "function", + "class", + "async", + "static", + "readonly", + "type", + "interface", + "enum", + "declare", + "if", + "while", + "for", + "with", +]; + +/// Words that continue the previous line's expression. +const CONTINUATION_WORDS: &[&str] = &["in", "instanceof", "of", "as", "satisfies", "else"]; + +struct Scanner<'a> { + src: &'a [u8], + i: usize, + frames: Vec, + /// Sum of every frame's `count`, plus the number of frames. + depth: usize, + max: usize, + prev: Tok, + newline_pending: bool, + /// The previous token was `if`/`for`/`while`/`with`. + prev_paren_keyword: bool, +} + +/// Return a conservative upper bound on the AST nesting depth of `src`. +pub(crate) fn estimate(src: &str) -> usize { + let mut s = Scanner { + src: src.as_bytes(), + i: 0, + frames: vec![Frame { + kind: FrameKind::Top, + count: 0, + }], + depth: 1, + max: 1, + prev: Tok::Boundary, + newline_pending: false, + prev_paren_keyword: false, + }; + s.run(); + s.max +} + +fn is_word_byte(c: u8) -> bool { + c.is_ascii_alphanumeric() || c == b'_' || c == b'$' || c >= 0x80 +} + +impl Scanner<'_> { + fn peek(&self, off: usize) -> u8 { + self.src.get(self.i + off).copied().unwrap_or(0) + } + + fn top(&mut self) -> &mut Frame { + // The Top frame is never popped (`close` checks the kind first). + let last = self.frames.len() - 1; + &mut self.frames[last] + } + + fn add(&mut self, n: usize) { + self.top().count += n; + self.depth += n; + self.max = self.max.max(self.depth); + } + + fn reset(&mut self) { + let c = std::mem::take(&mut self.top().count); + self.depth -= c; + } + + fn push(&mut self, kind: FrameKind) { + self.frames.push(Frame { kind, count: 0 }); + self.depth += 1; + self.max = self.max.max(self.depth); + } + + /// Called before each real token; resolves a pending newline boundary. + fn begin(&mut self, next: Tok, word: &[u8]) { + if !self.newline_pending { + return; + } + self.newline_pending = false; + if !matches!(self.top().kind, FrameKind::Top | FrameKind::Brace) { + return; + } + let prev_ends = matches!(self.prev, Tok::Word | Tok::Literal | Tok::Close); + let next_starts = match next { + Tok::Word | Tok::PrefixKeyword => { + !CONTINUATION_WORDS.iter().any(|w| w.as_bytes() == word) + } + Tok::Close => true, + _ => false, + }; + if prev_ends && next_starts { + self.reset(); + } + } + + fn regex_allowed(&self) -> bool { + matches!( + self.prev, + Tok::PrefixKeyword | Tok::Open | Tok::KeywordClose | Tok::Punct | Tok::Boundary + ) + } + + fn run(&mut self) { + while self.i < self.src.len() { + let c = self.src[self.i]; + let paren_keyword = std::mem::take(&mut self.prev_paren_keyword); + match c { + b'\n' | b'\r' => { + self.newline_pending = true; + self.prev_paren_keyword = paren_keyword; + self.i += 1; + } + b' ' | b'\t' | 0x0b | 0x0c => { + self.prev_paren_keyword = paren_keyword; + self.i += 1; + } + b'/' if self.peek(1) == b'/' => { + self.prev_paren_keyword = paren_keyword; + self.line_comment(); + } + b'/' if self.peek(1) == b'*' => { + self.prev_paren_keyword = paren_keyword; + self.block_comment(); + } + b'/' if self.regex_allowed() => self.regex(), + b'\'' | b'"' => { + self.begin(Tok::Literal, b""); + self.i += 1; + self.string(c); + self.add(1); + self.prev = Tok::Literal; + } + b'`' => { + self.begin(Tok::Literal, b""); + self.i += 1; + self.add(1); + self.template_text(); + } + b'(' | b'[' | b'{' => { + self.begin(Tok::Open, b""); + self.i += 1; + self.add(1); + self.push(match c { + b'(' => FrameKind::Paren { + keyword: paren_keyword, + }, + b'[' => FrameKind::Bracket, + _ => FrameKind::Brace, + }); + self.prev = Tok::Open; + } + b')' | b']' | b'}' => { + self.begin(Tok::Close, b""); + self.i += 1; + self.close(c); + } + b',' | b';' => { + self.begin(Tok::Boundary, b""); + self.i += 1; + self.reset(); + self.prev = Tok::Boundary; + } + c if is_word_byte(c) => self.word(), + _ => { + self.begin(Tok::Punct, b""); + self.i += 1; + self.add(1); + self.prev = Tok::Punct; + } + } + } + } + + fn word(&mut self) { + let start = self.i; + while self.i < self.src.len() && is_word_byte(self.src[self.i]) { + self.i += 1; + } + let word = &self.src[start..self.i]; + let numeric = word[0].is_ascii_digit(); + let kind = if numeric { + Tok::Literal + } else if PREFIX_KEYWORDS.iter().any(|k| k.as_bytes() == word) { + Tok::PrefixKeyword + } else { + Tok::Word + }; + self.begin(kind, word); + // A number like `1.5e+3` lexes as several tokens; that only overcounts. + self.add(1); + self.prev = kind; + self.prev_paren_keyword = matches!(word, b"if" | b"for" | b"while" | b"with") && !numeric; + } + + fn close(&mut self, c: u8) { + let top = self.frames.len() - 1; + let matches = matches!( + (self.frames[top].kind, c), + (FrameKind::Paren { .. }, b')') + | (FrameKind::Bracket, b']') + | (FrameKind::Brace | FrameKind::Template, b'}') + ); + if !matches { + // Unbalanced source: count the closer, never lower the estimate. + self.add(1); + self.prev = Tok::Close; + return; + } + let frame = self.frames.pop().expect("matched frame exists"); + // Fold the child into the parent (frame slot + its tokens stay counted). + self.top().count += frame.count + 1; + self.max = self.max.max(self.depth); + match frame.kind { + FrameKind::Template => self.template_text(), + FrameKind::Paren { keyword: true } => self.prev = Tok::KeywordClose, + _ => self.prev = Tok::Close, + } + } + + fn string(&mut self, quote: u8) { + while self.i < self.src.len() { + match self.src[self.i] { + b'\\' => self.i += 2, + c if c == quote => { + self.i += 1; + return; + } + _ => self.i += 1, + } + } + } + + /// Scan template text after a backtick or after the `}` of `${...}`. + fn template_text(&mut self) { + while self.i < self.src.len() { + match self.src[self.i] { + b'\\' => self.i += 2, + b'`' => { + self.i += 1; + self.prev = Tok::Literal; + return; + } + b'$' if self.peek(1) == b'{' => { + self.i += 2; + self.add(1); + self.push(FrameKind::Template); + self.prev = Tok::Open; + return; + } + _ => self.i += 1, + } + } + self.prev = Tok::Literal; + } + + fn line_comment(&mut self) { + while self.i < self.src.len() && self.src[self.i] != b'\n' { + self.i += 1; + } + } + + fn block_comment(&mut self) { + self.i += 2; + while self.i < self.src.len() { + if self.src[self.i] == b'*' && self.peek(1) == b'/' { + self.i += 2; + return; + } + if self.src[self.i] == b'\n' { + self.newline_pending = true; + } + self.i += 1; + } + } + + /// Scan a regex literal. Its body is charged like code (one token per + /// punctuation char and per word), so a division chain mistaken for a + /// regex is still counted. A newline before the closing `/` means it was + /// not a regex: the `/` is then counted as an operator. + fn regex(&mut self) { + self.begin(Tok::Literal, b""); + let start = self.i; + self.i += 1; + let mut tokens = 1; + let mut in_class = false; + let mut in_word = false; + while self.i < self.src.len() { + let c = self.src[self.i]; + if c == b'\n' || c == b'\r' { + self.i = start + 1; + self.add(1); + self.prev = Tok::Punct; + return; + } + let word = is_word_byte(c); + if (word && !in_word) || (!word && !c.is_ascii_whitespace()) { + tokens += 1; + } + in_word = word; + match c { + b'\\' => self.i += 2, + b'[' => { + in_class = true; + self.i += 1; + } + b']' => { + in_class = false; + self.i += 1; + } + b'/' if !in_class => { + self.i += 1; + while self.i < self.src.len() && is_word_byte(self.src[self.i]) { + self.i += 1; + } + self.add(tokens); + self.prev = Tok::Literal; + return; + } + _ => self.i += 1, + } + } + // Unterminated: treat as division from the `/` on. + self.i = start + 1; + self.add(1); + self.prev = Tok::Punct; + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn nest(open: &str, inner: &str, close: &str, n: usize) -> String { + format!("{}{}{}", open.repeat(n), inner, close.repeat(n)) + } + + #[test] + fn ordinary_programs_stay_small() { + let program = r#" +const nums = [1, 2, 3, 4, 5, 6, 7, 8, 9, 10]; +function total(xs: number[]): number { + let s = 0 + for (const x of xs) { + if (x % 2 === 0) { s += x } else { s -= x } + } + return s +} +const re = /a(b)c[)\]]/g +console.log(`sum=${total(nums)} ${"a" + "b"}`) +"#; + assert!(estimate(program) < 100, "{}", estimate(program)); + } + + #[test] + fn long_flat_lists_and_programs_stay_small() { + let arr = format!("const a = [{}];", vec!["1"; 50_000].join(", ")); + assert!(estimate(&arr) < 10); + let lines = "let x = 1\nx = x + 1\nconsole.log(x)\n".repeat(5_000); + assert!(estimate(&lines) < 10); + let semis = "x = x + 1;".repeat(5_000); + assert!(estimate(&semis) < 10); + let methods = format!("class A {{\n{}}}", " m() { return 1 }\n".repeat(2_000)); + assert!(estimate(&methods) < 20, "methods {}", estimate(&methods)); + } + + #[test] + fn every_nesting_shape_is_counted() { + let n = 2_000; + let cases = [ + nest("[", "1", "]", n), + nest("(", "1", ")", n), + nest("{", "", "}", n), + nest("`${", "1", "}`", n), + format!("x = {}1", "1+".repeat(n)), + format!("x = {}1", "!".repeat(n)), + format!("x = {}1", "- ".repeat(n)), + format!("x = {}1", "typeof ".repeat(n)), + format!("x = a{}", ".b".repeat(n)), + format!("x = f{}", "()".repeat(n)), + format!("x = {}1", "a=>".repeat(n)), + format!("x = {}1", "a?b:".repeat(n)), + format!("x = {}1", "new ".repeat(n)), + format!("{}x", "if(1)".repeat(n)), + format!("if(1){{}}{}", "else if(1){}".repeat(n)), + format!("x = 1{}", "\n+1".repeat(n)), + format!("x = a{}", "\n.b".repeat(n)), + format!("x = {}1", "!\n".repeat(n)), + // Division chain after `}` that a lexer could read as a regex. + format!("x = {{}}/{}1/2", "1+".repeat(n)), + // Regex after `if (...)` holding fake closers and separators. + format!("{}x", "if(1)/\\),;\\]/\nelse ".repeat(n)), + ]; + for (i, src) in cases.iter().enumerate() { + assert!(estimate(src) >= n, "case {i}: {} < {n}", estimate(src)); + } + } + + #[test] + fn fake_closers_do_not_lower_the_estimate() { + let n = 2_000; + let src = format!("x = ({}1", "1+".repeat(n)) + &")".repeat(50) + &"+1".repeat(n); + assert!(estimate(&src) >= 2 * n); + let unbalanced = format!("{}{}", "[".repeat(n), "}".repeat(n)); + assert!(estimate(&unbalanced) >= n); + } + + #[test] + fn strings_comments_and_templates_hide_brackets() { + let src = format!( + "x = '{}' + \"{}\" // {}\n/* {} */ y = `{}`", + "(".repeat(5_000), + "[".repeat(5_000), + "{".repeat(5_000), + "(".repeat(5_000), + "[".repeat(5_000) + ); + assert!(estimate(&src) < 20, "{}", estimate(&src)); + } +} diff --git a/crates/bashkit/tests/integration/execution_budget_tests.rs b/crates/bashkit/tests/integration/execution_budget_tests.rs index a04a12d17..a66678eaf 100644 --- a/crates/bashkit/tests/integration/execution_budget_tests.rs +++ b/crates/bashkit/tests/integration/execution_budget_tests.rs @@ -392,14 +392,18 @@ async fn repeated_python_entries_share_runtime_admission_budget() { #[cfg(feature = "typescript")] #[tokio::test] -/// TM-DOS-096: separate TypeScript entries cannot refresh allocation fuel. +/// TM-DOS-096: separate TypeScript entries cannot refresh fuel: each pays a +/// fixed admission cost plus the VM time it runs from one shared budget. async fn repeated_typescript_entries_share_runtime_admission_budget() { let limits = ExecutionLimits::new() - .max_work_units(1_500_000) + .max_work_units(20_000) .max_aggregate_input_bytes(100_000); let mut bash = Bash::builder().limits(limits).typescript().build(); - assert_budget_exhausted(bash.exec("ts -c '1 + 1'; ts -c '2 + 2'").await); + assert_budget_exhausted( + bash.exec("for i in 1 2 3 4 5 6 7 8 9 10; do ts -c '1 + 1'; done") + .await, + ); } #[cfg(feature = "sqlite")] diff --git a/crates/bashkit/tests/integration/typescript_security_tests.rs b/crates/bashkit/tests/integration/typescript_security_tests.rs index 0f713a978..40417cb15 100644 --- a/crates/bashkit/tests/integration/typescript_security_tests.rs +++ b/crates/bashkit/tests/integration/typescript_security_tests.rs @@ -147,6 +147,60 @@ mod blackbox_blocked_features { mod blackbox_resource_exhaustion { use super::*; + /// Regression: each `ts` call reserved `max_allocations` work units (1M + /// by default) of the 100M budget up front, so the 96th call in a script + /// failed even for `1`. + #[tokio::test] + async fn many_small_invocations_fit_in_one_script() { + let mut bash = bash_ts(); + let r = bash + .exec("n=0; for i in $(seq 1 300); do ts -c 'let x = 1' && n=$((n+1)); done; echo $n") + .await + .unwrap(); + assert_eq!(r.stdout.trim(), "300", "stderr: {}", r.stderr); + } + + #[tokio::test] + async fn deeply_nested_source_fails_cleanly() { + // Regression: ZapCode's parser/compiler recursion has no depth guard; + // ~6 KB of nesting overflowed the native stack and aborted the host. + let mut bash = bash_ts(); + let script = format!( + "ts -c 'let x = {}1{}'; echo rc=$?; ts -c 'let y = {}1'; echo rc=$?; echo alive", + "[".repeat(3000), + "]".repeat(3000), + "1+".repeat(3000) + ); + let r = bash.exec(&script).await.unwrap(); + assert_eq!(r.stdout, "rc=1\nrc=1\nalive\n"); + assert!(r.stderr.contains("ts: source is too deeply nested")); + } + + /// A ZapCode time-limit hit reports like the Python runtimes and + /// coreutils `timeout`: exit 124 and `ts: execution timed out`. + #[tokio::test] + async fn time_limit_exits_124() { + let mut bash = + bash_ts_limits(TypeScriptLimits::default().max_duration(Duration::from_millis(200))); + let r = bash + .exec("ts -c 'console.log(\"before\"); while (true) {}'; echo \"rc=$?\"") + .await + .unwrap(); + assert!(r.stdout.ends_with("rc=124\n"), "{}", r.stdout); + assert_eq!(r.stderr, "ts: execution timed out after 0.2s\n"); + } + + /// A script's own thrown error is not a limit hit. + #[tokio::test] + async fn thrown_error_is_not_a_limit() { + let mut bash = bash_ts(); + let r = bash + .exec("ts -c 'throw new Error(\"execution time limit exceeded\")'; echo \"rc=$?\"") + .await + .unwrap(); + assert!(r.stdout.ends_with("rc=1\n"), "{}", r.stdout); + } + /// TM-TS-001: Infinite loop blocked by time limit #[tokio::test] async fn threat_ts_infinite_loop() { diff --git a/knowledge/log.md b/knowledge/log.md index 25097a030..31da7d914 100644 --- a/knowledge/log.md +++ b/knowledge/log.md @@ -2,6 +2,7 @@ ## 2026-11-05 +* **TypeScript runtime: same gaps as Python**: `ts` reserved 1M budget units per call (about 95 calls per script), reported its time limit as exit 1, and about 6 KB of nested source (`[[[...`, `1+1+...`) overflowed ZapCode's recursive parser/compiler and aborted the host process. Calls now charge 4096 units plus VM time, timeouts exit 124, and a nesting estimate plus a large-stack VM thread guard the parser (TM-TS-024). See [ZapCode Runtime](runtimes/zapcode-runtime.md). * **Python `re` gate parses code**: the embedded Monty `python3` blocked `import re` by matching lines that start with `import`/`from`, so `pass; import re` or `if True: import re` loaded the module anyway (found by a downstream integration review). The gate now walks the parsed statement tree. See [Python Builtin](runtimes/python-builtin.md). ## 2026-11-03 diff --git a/knowledge/runtimes/zapcode-runtime.md b/knowledge/runtimes/zapcode-runtime.md index d759237c4..5378eb608 100644 --- a/knowledge/runtimes/zapcode-runtime.md +++ b/knowledge/runtimes/zapcode-runtime.md @@ -65,12 +65,33 @@ Configurable via `TypeScriptLimits`: | Max stack depth | 512 | `.max_stack_depth(n)` | Prevent stack overflow | | Max allocations | 1,000,000 | `.max_allocations(n)` | Bound VM allocation count | -Every VM entry additionally charges source bytes and a non-refundable -allocation-fuel admission reservation to the request-scoped +Every VM entry charges source bytes, a fixed 4096 work units, and the +microseconds each synchronous VM step ran to the request-scoped `ExecutionBudget`. Suspend/resume cycles and external/VFS callbacks retain the same shared clone. Separate `ts`/`node`/`deno`/`bun` commands inside one host execution therefore cannot refresh aggregate work, while ZapCode's own limits -remain in force. +remain in force. Until 2026-11-05 each entry reserved `max_allocations` (1M) +units up front, which capped a script at about 95 `ts` calls against the +default 100M budget (same bug as the Python runtime had). + +**Source nesting.** ZapCode 1.5.1 parses (oxc), lowers and compiles with plain +recursion and no depth guard; about 6 KB of nested source (`[[[...`, +`1+1+...`) overflowed the native stack and aborted the host process in release +builds (about 100 levels on a 2 MB debug thread). Bashkit adds two layers: + +1. `builtins/typescript_nesting.rs` computes a conservative upper bound on AST + depth before ZapCode sees the source (frames plus tokens since the last + `,`/`;`/statement newline in each open frame; closers fold counts into the + parent, regex bodies are charged per token). Above 1000 the command fails + with `ts: source is too deeply nested` (exit 1). +2. Parse, compile and the first VM run happen on a dedicated thread with a + 128 MiB stack (reserved, not committed). 32 MiB was the smallest stack on + which every shape in `deep_nesting_at_the_limit_does_not_overflow` passed + in a debug build, so this is 4x margin. On wasm32 there is no such thread; + the cap still applies on the default stack. + +Upstream fix wanted: a depth guard in zapcode-core's `AstLowerer` and +compiler (Monty and ruff already return "Source is too deeply nested"). ### Language Support @@ -138,6 +159,9 @@ for the full threat analysis. - Syntax errors: Exit code 1, error message on stderr - Runtime errors: Exit code 1, error on stderr, any stdout preserved +- Time limit: Exit code 124, `ts: execution timed out after Ns` on stderr + (ZapCode's `TimeLimitExceeded` variant, so a script's `throw` cannot fake it) +- Source nested past the cap: Exit code 1, `ts: source is too deeply nested` - 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 diff --git a/knowledge/security/threat-model.md b/knowledge/security/threat-model.md index fe921478f..7013a7a3b 100644 --- a/knowledge/security/threat-model.md +++ b/knowledge/security/threat-model.md @@ -2214,6 +2214,7 @@ TypeScript execution requires two explicit opt-in steps, matching the Python bui | 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` | +| TM-TS-024 | Deeply nested source overflows the native stack in ZapCode's parser/compiler and aborts the host process | Critical | Conservative nesting estimate rejects depth > 1000 before ZapCode; parse/compile run on a 128 MiB-stack thread (4x debug margin) | `deeply_nested_source_fails_cleanly`, `deep_nesting_at_the_limit_does_not_overflow`, `deep_nesting_past_the_limit_is_rejected` | ### Blocked Language Features From 67c9f972ae90994eda03a00f73f71c8c0cb42b03 Mon Sep 17 00:00:00 2001 From: Mykhailo Chalyi Date: Thu, 5 Nov 2026 04:41:49 +0000 Subject: [PATCH 2/2] fix(typescript): run every ZapCode step on the VM stack, fail if no thread - snapshot.resume after a VFS/external call now runs on the 128 MiB thread too. - No inline fallback when the thread cannot start: exit 1 with 'ts: cannot start execution thread'. - Port TM-TS-024 to the public threat model; document wasm residual risk. --- crates/bashkit/docs/threat-model.md | 3 +- crates/bashkit/src/builtins/typescript.rs | 56 +++++++++++++---------- knowledge/runtimes/zapcode-runtime.md | 14 ++++-- 3 files changed, 44 insertions(+), 29 deletions(-) diff --git a/crates/bashkit/docs/threat-model.md b/crates/bashkit/docs/threat-model.md index a435da16a..12a006e6b 100644 --- a/crates/bashkit/docs/threat-model.md +++ b/crates/bashkit/docs/threat-model.md @@ -877,9 +877,10 @@ access is bridged through Bashkit's VFS, never the host filesystem. | Shell execution from TS (TM-TS-021) | `child_process` / exec globals | No process/subprocess/exec globals | MITIGATED | | Script reads host filesystem (TM-TS-022) | Load a host script | Script file loaded via VFS | MITIGATED | | Shebang line injection (TM-TS-023) | `#!...` header | Shebang stripped safely | MITIGATED | +| Deeply nested source aborts the host (TM-TS-024) | A few KB of `[[[...` or `1+1+...` overflows ZapCode's recursive parser/compiler | Nesting estimate rejects depth over 1000; ZapCode steps run on a 128 MiB-stack thread | MITIGATED | | Bash var expansion injection (TM-TS-020) | Unquoted `$VAR` expanded before TS runs | By design; use single quotes to prevent expansion | MITIGATED | -Per-threat coverage (TM-TS-001 … TM-TS-023), including error-isolation and +Per-threat coverage (TM-TS-001 … TM-TS-024), including error-isolation and exit-code propagation cases, is also exercised by the `threat_ts_*` tests. ### Request Signing & Snapshot Integrity (TM-CRY-*, TM-SNAP-*) diff --git a/crates/bashkit/src/builtins/typescript.rs b/crates/bashkit/src/builtins/typescript.rs index 074bd4d4f..c4a2d9aec 100644 --- a/crates/bashkit/src/builtins/typescript.rs +++ b/crates/bashkit/src/builtins/typescript.rs @@ -923,11 +923,12 @@ async fn run_typescript( } }; - let result = run_on_vm_stack(runner); + let result = on_vm_stack(|| runner.run(Vec::new())); charge_vm_time(execution_budget.as_ref(), started)?; let result = match result { - Ok(r) => r, - Err(e) => return Ok(format_error_with_output(e, "", time_limit)), + Some(Ok(r)) => r, + Some(Err(e)) => return Ok(format_error_with_output(e, "", time_limit)), + None => return Ok(ExecResult::err(VM_THREAD_ERROR.to_string(), 1)), }; // Process the result through the suspend/resume loop for VFS bridging @@ -942,34 +943,34 @@ async fn run_typescript( .await } -/// Parse, compile and start the script. Off wasm this runs on a dedicated -/// thread with a [`TS_VM_STACK_BYTES`] stack; see that constant. +/// Message when no VM thread could be started (resource exhaustion). +const VM_THREAD_ERROR: &str = "ts: cannot start execution thread\n"; + +/// Run one synchronous ZapCode step (parse/compile/run, or a resume). Off +/// wasm this runs on a dedicated thread with a [`TS_VM_STACK_BYTES`] stack; +/// see that constant. `None` means no thread could be started: the caller +/// fails the command rather than running accepted-depth source on a small +/// stack. #[cfg(not(target_arch = "wasm32"))] -fn run_on_vm_stack( - runner: ZapcodeRun, -) -> std::result::Result { +fn on_vm_stack(step: impl FnOnce() -> T + Send) -> Option { std::thread::scope(|scope| { - match std::thread::Builder::new() + let handle = std::thread::Builder::new() .name("bashkit-ts".into()) .stack_size(TS_VM_STACK_BYTES) - .spawn_scoped(scope, || runner.run(Vec::new())) - { - Ok(handle) => match handle.join() { - Ok(result) => result, - Err(panic) => std::panic::resume_unwind(panic), - }, - // Could not get a thread (resource exhaustion): run inline. - // The nesting cap still applies. - Err(_) => runner.run(Vec::new()), + .spawn_scoped(scope, step) + .ok()?; + match handle.join() { + Ok(result) => Some(result), + Err(panic) => std::panic::resume_unwind(panic), } }) } +/// wasm32 has no threads: run on the current stack. The nesting cap still +/// applies; see the zapcode-runtime knowledge page for the residual risk. #[cfg(target_arch = "wasm32")] -fn run_on_vm_stack( - runner: ZapcodeRun, -) -> std::result::Result { - runner.run(Vec::new()) +fn on_vm_stack(step: impl FnOnce() -> T) -> Option { + Some(step()) } /// Charge the wall-clock time a synchronous VM step ran to the shared work @@ -1030,13 +1031,18 @@ async fn process_vm_result( }; let started = Instant::now(); - let resumed = snapshot.resume(return_value); + let resumed = on_vm_stack(move || snapshot.resume(return_value)); charge_vm_time(execution_budget, started)?; state = match resumed { - Ok(s) => s, - Err(e) => { + Some(Ok(s)) => s, + Some(Err(e)) => { return Ok(format_error_with_output(e, &stdout, time_limit)); } + None => { + let mut result = ExecResult::err(VM_THREAD_ERROR.to_string(), 1); + result.stdout = stdout.into(); + return Ok(result); + } }; } } diff --git a/knowledge/runtimes/zapcode-runtime.md b/knowledge/runtimes/zapcode-runtime.md index 5378eb608..e94e274d7 100644 --- a/knowledge/runtimes/zapcode-runtime.md +++ b/knowledge/runtimes/zapcode-runtime.md @@ -84,11 +84,19 @@ builds (about 100 levels on a 2 MB debug thread). Bashkit adds two layers: `,`/`;`/statement newline in each open frame; closers fold counts into the parent, regex bodies are charged per token). Above 1000 the command fails with `ts: source is too deeply nested` (exit 1). -2. Parse, compile and the first VM run happen on a dedicated thread with a +2. Every synchronous ZapCode step (parse, compile and first run, and each + `resume` after a VFS/external call) runs on a dedicated thread with a 128 MiB stack (reserved, not committed). 32 MiB was the smallest stack on which every shape in `deep_nesting_at_the_limit_does_not_overflow` passed - in a debug build, so this is 4x margin. On wasm32 there is no such thread; - the cap still applies on the default stack. + in a debug build, so this is 4x margin. If the thread cannot be started the + command fails with `ts: cannot start execution thread` (exit 1); it never + falls back to the caller's smaller stack. + +Residual risk on wasm32: there are no threads, so ZapCode runs on the default +wasm stack with the same 1000 cap, which was validated only against the +128 MiB native thread. An overflow there traps the wasm instance (no host +process abort, but the instance is lost). A lower wasm cap waits on measuring +the wasm stack per AST level. Upstream fix wanted: a depth guard in zapcode-core's `AstLowerer` and compiler (Monty and ruff already return "Source is too deeply nested").