diff --git a/crates/bashkit/docs/jq.md b/crates/bashkit/docs/jq.md index f761d661e..f4bafdf96 100644 --- a/crates/bashkit/docs/jq.md +++ b/crates/bashkit/docs/jq.md @@ -98,6 +98,11 @@ catch its way around it. Output is capped by `max_stdout_bytes`, and a filter that loops without emitting (`until(false; .)`) stops at the execution timeout with `jq: execution timed out`. +`--stream` produces path events incrementally, sharing ancestor keys. Event +and traversal storage obey the same memory limit, and traversal checks work, +cancellation and timeout limits even with an `empty` filter. `--slurp` +retains events and can hit the memory limit on otherwise small JSON input. + The limit counts real in-memory size, which is several times the JSON text: a 5.6 MB array of 100,000 small objects fits the default, much larger inputs need a larger `max_live_intermediate_bytes`. diff --git a/crates/bashkit/src/builtins/jq/convert.rs b/crates/bashkit/src/builtins/jq/convert.rs index 59bedbde3..2a5bf221b 100644 --- a/crates/bashkit/src/builtins/jq/convert.rs +++ b/crates/bashkit/src/builtins/jq/convert.rs @@ -326,44 +326,6 @@ pub(super) fn parse_json_stream_partial(input: &str) -> (Vec, Option Vec { - fn walk(v: &JqJson, path: &mut Vec, out: &mut Vec) { - let children: Vec<(JqJson, &JqJson)> = match v { - JqJson::Array(a) => a - .iter() - .enumerate() - .map(|(i, x)| (JqJson::Number(i.to_string()), x)) - .collect(), - JqJson::Object(o) => o - .iter() - .map(|(k, x)| (JqJson::String(k.clone()), x)) - .collect(), - _ => Vec::new(), - }; - if children.is_empty() { - out.push(JqJson::Array(vec![JqJson::Array(path.clone()), v.clone()])); - return; - } - let mut last = None; - for (k, x) in children { - path.push(k); - walk(x, path, out); - last = path.pop(); - } - if let Some(k) = last { - let mut closing = path.clone(); - closing.push(k); - out.push(JqJson::Array(vec![JqJson::Array(closing)])); - } - } - let mut out = Vec::new(); - walk(v, &mut Vec::new(), &mut out); - out -} - #[cfg(test)] mod tests { use super::*; diff --git a/crates/bashkit/src/builtins/jq/mod.rs b/crates/bashkit/src/builtins/jq/mod.rs index 18243e99d..9c71fb89c 100644 --- a/crates/bashkit/src/builtins/jq/mod.rs +++ b/crates/bashkit/src/builtins/jq/mod.rs @@ -6,6 +6,7 @@ //! - `args`: CLI parsing (incl. `--slurpfile`, `--rawfile`, `--args`, //! `--jsonargs`, `--indent`) //! - `convert`: order-preserving JSON reader -> JqJson <-> jaq Val, depth check +//! - `stream`: incremental, metered path events with shared ancestor keys //! - `format`: indent-aware output rendering (custom `--indent N`) //! - `compat`: prepended jq-compat definitions and global var names //! - `errors`: jq-style error formatting (no Debug-shape leaks) @@ -58,6 +59,7 @@ mod messages; )] mod jaq_json; mod regex_compat; +mod stream; #[cfg(test)] mod tests; @@ -67,7 +69,7 @@ use compat::{ ARGS_VAR_NAME, ENV_VAR_NAME, FILENAME_VAR_NAME, LINENO_VAR_NAME, PUBLIC_ENV_VAR_NAME, build_compat_prefix, }; -use convert::{JqJson, jq_to_val, parse_json_stream, stream_events, val_to_jq_capped}; +use convert::{JqJson, jq_to_val, parse_json_stream, val_to_jq_capped}; use errors::{format_compile_errors, format_load_errors, format_runtime_error_at}; use format::{Indent, render, sort_keys as sort_jq_keys}; @@ -141,7 +143,7 @@ pub struct Jq; /// Bookkeeping for one filter run: the input value and the per-input /// metadata (filename / line number) bound to compat globals. struct FilterInput { - value: Val, + value: JqJson, filename: Val, lineno: usize, } @@ -397,13 +399,8 @@ async fn run_jq(ctx: Context<'_>, parsed: JqArgs<'_>) -> Result { } }; - // Pre-convert globals to Val once. - let env_val = jq_to_val(&env_obj); - let args_val = jq_to_val(&args_obj); - let pre_var_vals: Vec = all_var_bindings.iter().map(|(_, v)| jq_to_val(v)).collect(); - - // Build the input stream. Each value keeps its file name and the line - // number jq reports for it in errors. + // Keep parsed input trees, not expanded events. Conversion and streaming + // happen inside the metered, unwind-protected synchronous evaluation. let mut parse_error: Option = None; let mut items: Vec = Vec::new(); let name_val = |name: &Option<&str>| match name { @@ -413,7 +410,7 @@ async fn run_jq(ctx: Context<'_>, parsed: JqArgs<'_>) -> Result { if parsed.raw_input && parsed.slurp { let text: String = sources.iter().map(|(_, t)| t.as_str()).collect(); items.push(FilterInput { - value: Val::from(text), + value: JqJson::String(text), filename: sources .last() .map(|(n, _)| name_val(n)) @@ -424,16 +421,14 @@ async fn run_jq(ctx: Context<'_>, parsed: JqArgs<'_>) -> Result { for (name, text) in &sources { for (i, line) in text.lines().enumerate() { items.push(FilterInput { - value: Val::from(line.to_string()), + value: JqJson::String(line.to_string()), filename: name_val(name), lineno: i + 1, }); } } } else { - let mut slurped: Vec = Vec::new(); for (name, text) in &sources { - // `--seq`: RS (0x1e) separates values; read it as whitespace. let seq_text; let text = if parsed.seq { seq_text = text.replace('\x1e', " "); @@ -443,44 +438,20 @@ async fn run_jq(ctx: Context<'_>, parsed: JqArgs<'_>) -> Result { }; let (vals, err) = parse_jq_json_stream_partial(&ctx, text)?; let lines = input::value_lines(text); - // `--stream`: each value becomes its path events. - let vals: Vec<(JqJson, usize)> = vals - .into_iter() - .enumerate() - .flat_map(|(i, v)| { - let line = lines.get(i).copied().unwrap_or(0); - if parsed.stream { - stream_events(&v).into_iter().map(|e| (e, line)).collect() - } else { - vec![(v, line)] - } - }) - .collect(); - if parsed.slurp { - slurped.extend(vals.into_iter().map(|(v, _)| v)); - } else { - for (v, line) in &vals { - items.push(FilterInput { - value: jq_to_val(v), - filename: name_val(name), - lineno: *line, - }); - } + for (i, value) in vals.into_iter().enumerate() { + items.push(FilterInput { + value, + filename: name_val(name), + lineno: lines.get(i).copied().unwrap_or(0), + }); } if let Some(e) = err { parse_error = Some(e); break; } } - if parsed.slurp && parse_error.is_none() { - items.push(FilterInput { - value: jq_to_val(&JqJson::Array(slurped)), - filename: sources - .last() - .map(|(n, _)| name_val(n)) - .unwrap_or(Val::Null), - lineno: 0, - }); + if parsed.slurp && parse_error.is_some() { + items.clear(); } } @@ -545,13 +516,6 @@ async fn run_jq(ctx: Context<'_>, parsed: JqArgs<'_>) -> Result { // line for `input_filename` and error locations. let current: std::rc::Rc> = std::rc::Rc::new(std::cell::RefCell::new((Val::Null, 0))); - let tracker = current.clone(); - let value_iter: Box>> = - Box::new(items.into_iter().map(move |fi| { - *tracker.borrow_mut() = (fi.filename, fi.lineno); - Ok::(fi.value) - })); - let shared_inputs = RcIter::new(value_iter); let null_input = parsed.null_input; let location = |current: &(Val, usize)| -> String { if null_input { @@ -573,7 +537,54 @@ async fn run_jq(ctx: Context<'_>, parsed: JqArgs<'_>) -> Result { // THREAT[TM-DOS-110]: the meter aborts a run by unwinding where jaq // gives it no error channel (see jaq_json::meter::Abort). + let input_error = std::cell::RefCell::new(None); let run_filter = std::panic::AssertUnwindSafe(|| -> Result> { + let env_val = jq_to_val(&env_obj); + let args_val = jq_to_val(&args_obj); + let pre_var_vals: Vec = all_var_bindings.iter().map(|(_, v)| jq_to_val(v)).collect(); + let tracker = current.clone(); + let values = items.iter().flat_map(|fi| { + let iter: Box> + '_> = + if parsed.stream && !parsed.raw_input { + Box::new(stream::StreamEvents::new(&fi.value, &ctx)) + } else { + Box::new(std::iter::once_with(|| Ok(jq_to_val(&fi.value)))) + }; + let tracker = tracker.clone(); + iter.inspect(move |_| { + *tracker.borrow_mut() = (fi.filename.clone(), fi.lineno); + }) + }); + let mut value_iter: Box> + '_> = + Box::new(values.map(|value| { + value.map_err(|error| { + let message = error.to_string(); + *input_error.borrow_mut() = Some(error); + message + }) + })); + if parsed.slurp && !parsed.raw_input && parse_error.is_none() { + let mut values = jaq_json::meter::Metered::>::default(); + for value in value_iter { + let value = match value { + Ok(value) => value, + Err(e) => { + return Ok(Some(ExecResult::err(format!("jq: input error: {e}\n"), 5))); + } + }; + stream::push_metered(&mut values, value)?; + } + *current.borrow_mut() = ( + sources + .last() + .map(|(n, _)| name_val(n)) + .unwrap_or(Val::Null), + 0, + ); + value_iter = Box::new(std::iter::once(Ok(Val::Arr(jaq_json::Rc::new(values))))); + } + let shared_inputs = RcIter::new(value_iter); + let mut first = true; 'inputs: loop { let jaq_input: Val = if null_input { @@ -711,8 +722,15 @@ async fn run_jq(ctx: Context<'_>, parsed: JqArgs<'_>) -> Result { } Ok(None) }); - match std::panic::catch_unwind(run_filter) { + let evaluation = std::panic::catch_unwind(run_filter); + match evaluation { Ok(result) => { + if meter.tripped() { + return size_error(&meter); + } + if let Some(error) = input_error.into_inner() { + return Err(error); + } if let Some(early) = result? { return Ok(early); } diff --git a/crates/bashkit/src/builtins/jq/stream.rs b/crates/bashkit/src/builtins/jq/stream.rs new file mode 100644 index 000000000..022c5429c --- /dev/null +++ b/crates/bashkit/src/builtins/jq/stream.rs @@ -0,0 +1,145 @@ +//! `--stream` traverses the depth-bounded input tree incrementally. Ancestor +//! keys are shared jaq strings, never copied into a retained list of events. +//! THREAT[TM-DOS-110]: cursor, path and event bodies are admitted by the jq +//! meter before allocation; each traversal step checks shared work/deadline/ +//! cancellation limits, including when the filter emits nothing. + +use super::Context; +use super::convert::{JqJson, jq_to_val}; +use super::jaq_json::{Rc, Val, meter}; +use crate::error::{Error, Result}; + +fn check(bytes: usize) -> Result<()> { + meter::check(bytes).map_err(|e| Error::Execution(e.to_string())) +} + +/// Admit capacity growth before reserve, charging it while the buffer lives. +pub(super) fn push_metered(values: &mut meter::Metered>, value: T) -> Result<()> { + if values.len() == values.capacity() { + let additional = values.capacity().max(1); + check(additional.saturating_mul(std::mem::size_of::()))?; + values.reserve_exact(additional); + values.resync(); + } + values.push(value); + Ok(()) +} + +fn array(values: impl ExactSizeIterator) -> Result { + check(values.len().saturating_mul(std::mem::size_of::()))?; + let mut body = Vec::with_capacity(values.len()); + body.extend(values); + Ok(Val::Arr(Rc::new(meter::Metered::new(body)))) +} + +enum Children<'a> { + Array(std::iter::Enumerate>), + Object(std::slice::Iter<'a, (String, JqJson)>), +} + +impl<'a> Children<'a> { + fn next(&mut self) -> Result> { + match self { + Self::Array(iter) => Ok(iter.next().map(|(i, v)| (Val::from(i), v))), + Self::Object(iter) => iter + .next() + .map(|(k, v)| { + check(k.len())?; + Ok((Val::from(k.clone()), v)) + }) + .transpose(), + } + } +} + +struct Frame<'a> { + children: Children<'a>, + path_len: usize, +} + +pub(super) struct StreamEvents<'a, 'shell> { + pending: Option<&'a JqJson>, + stack: meter::Metered>>, + path: meter::Metered>, + ctx: &'a Context<'shell>, + done: bool, +} + +impl<'a, 'shell> StreamEvents<'a, 'shell> { + pub(super) fn new(value: &'a JqJson, ctx: &'a Context<'shell>) -> Self { + Self { + pending: Some(value), + stack: meter::Metered::default(), + path: meter::Metered::default(), + ctx, + done: false, + } + } + + fn next_event(&mut self) -> Result> { + loop { + self.ctx.consume_budget_work(1)?; + meter::tick(); + if let Some(value) = self.pending.take() { + let children = match value { + JqJson::Array(a) if !a.is_empty() => { + Some(Children::Array(a.iter().enumerate())) + } + JqJson::Object(o) if !o.is_empty() => Some(Children::Object(o.iter())), + _ => None, + }; + if let Some(children) = children { + push_metered( + &mut self.stack, + Frame { + children, + path_len: self.path.len(), + }, + )?; + continue; + } + if let JqJson::String(s) | JqJson::Number(s) = value { + check(s.len())?; + } + let leaf = jq_to_val(value); + let path = array(self.path.iter().cloned())?; + return array([path, leaf].into_iter()).map(Some); + } + let Some(frame) = self.stack.last_mut() else { + return Ok(None); + }; + if let Some((key, value)) = frame.children.next()? { + self.path.truncate(frame.path_len); + push_metered(&mut self.path, key)?; + self.pending = Some(value); + } else { + // A closing event keeps the last child's key, not its descendants. + self.path.truncate(frame.path_len + 1); + self.stack.pop(); + let path = array(self.path.iter().cloned())?; + return array([path].into_iter()).map(Some); + } + } + } +} + +impl Iterator for StreamEvents<'_, '_> { + type Item = Result; + + fn next(&mut self) -> Option { + if self.done { + return None; + } + match self.next_event() { + Ok(Some(value)) => Some(Ok(value)), + Ok(None) => { + self.done = true; + None + } + Err(error) => { + self.done = true; + Some(Err(error)) + } + } + } +} diff --git a/crates/bashkit/src/builtins/jq/tests.rs b/crates/bashkit/src/builtins/jq/tests.rs index cff7dbf39..730e3ef82 100644 --- a/crates/bashkit/src/builtins/jq/tests.rs +++ b/crates/bashkit/src/builtins/jq/tests.rs @@ -2210,6 +2210,34 @@ async fn stream_flag_feeds_path_events() { assert_eq!(out, "{\"a\":[1,2]}\n"); } +#[tokio::test] +async fn stream_slurp_and_metadata_preserve_event_order() { + let result = run_jq_with_files( + &[ + "-c", + "--stream", + "[input_filename, .]", + "/first.json", + "/second.json", + ], + &[("/first.json", "[1,[]]"), ("/second.json", "{}")], + ) + .await + .unwrap(); + assert_eq!( + result.stdout, + "[\"/first.json\",[[0],1]]\n[\"/first.json\",[[1],[]]]\n[\"/first.json\",[[1]]]\n[\"/second.json\",[[],{}]]\n" + ); + let out = run_jq_with_args(&["-sc", "--stream", "."], "[1,[]] {}") + .await + .unwrap(); + assert_eq!(out, "[[[0],1],[[1],[]],[[1]],[[],{}]]\n"); + let out = run_jq_with_args(&["-nc", "--stream", "[limit(1; inputs)], input"], "[1,2]") + .await + .unwrap(); + assert_eq!(out, "[[[0],1]]\n[[1],2]\n"); +} + #[tokio::test] async fn seq_flag_uses_record_separators() { let out = run_jq_with_args(&["--seq", "-c", "."], "\x1e[1]\n\x1e2\n") diff --git a/crates/bashkit/tests/integration/execution_budget_tests.rs b/crates/bashkit/tests/integration/execution_budget_tests.rs index 9bb504d4b..5658b019a 100644 --- a/crates/bashkit/tests/integration/execution_budget_tests.rs +++ b/crates/bashkit/tests/integration/execution_budget_tests.rs @@ -279,6 +279,98 @@ async fn jq_control_normalization_respects_live_memory_budget() { ); } +#[cfg(feature = "jq")] +#[tokio::test] +/// TM-DOS-110: stream preprocessing must obey the intermediate limit even for empty. +async fn jq_stream_long_key_respects_memory_limit() { + let mut bash = Bash::builder() + .limits(ExecutionLimits::new().max_live_intermediate_bytes(32_768)) + .build(); + let input = format!("{{\"{}\":[null,null]}}", "k".repeat(65_536)); + bash.fs() + .write_file(Path::new("/input.json"), input.as_bytes()) + .await + .unwrap(); + let result = bash.exec("jq --stream empty /input.json").await.unwrap(); + assert_eq!(result.exit_code, 5); + assert!( + result + .stderr + .contains("value size limit (32768 bytes) exceeded"), + "{}", + result.stderr + ); + assert!(result.stdout.is_empty()); + assert_eq!( + bash.exec("echo recovered").await.unwrap().stdout, + "recovered\n" + ); +} + +#[cfg(feature = "jq")] +#[tokio::test] +/// TM-DOS-110: slurping events retains them and must meter their bodies. +async fn jq_stream_slurp_respects_memory_limit() { + let mut bash = Bash::builder() + .limits(ExecutionLimits::new().max_live_intermediate_bytes(65_536)) + .build(); + let input = format!( + "{{\"{}\":[{}]}}", + "k".repeat(1024), + vec!["null"; 2000].join(",") + ); + let result = bash + .exec_with_options("jq --stream --slurp empty", ExecOptions::new().stdin(input)) + .await + .unwrap(); + assert_eq!(result.exit_code, 5); + assert!( + result + .stderr + .contains("value size limit (65536 bytes) exceeded"), + "{}", + result.stderr + ); + assert!(result.stdout.is_empty()); +} + +#[cfg(feature = "jq")] +#[tokio::test] +/// Shared keys and lazy events keep many leaves below a small live limit. +async fn jq_stream_many_leaves_fit_small_memory_limit() { + let mut bash = Bash::builder() + .limits(ExecutionLimits::new().max_live_intermediate_bytes(65_536)) + .build(); + let input = format!( + "{{\"{}\":[{}]}}", + "k".repeat(1024), + vec!["null"; 2000].join(",") + ); + let result = bash + .exec_with_options( + "jq -nc --stream 'reduce inputs as $event (0; . + 1)'", + ExecOptions::new().stdin(input), + ) + .await + .unwrap(); + assert_eq!(result.exit_code, 0, "{}", result.stderr); + assert_eq!(result.stdout, "2002\n"); +} + +#[cfg(feature = "jq")] +#[tokio::test] +/// Non-emitting filters must charge traversal, beyond input normalization. +async fn jq_stream_empty_consumes_shared_work_budget() { + let input = format!("[{}]", vec!["null"; 1000].join(",")); + let mut bash = Bash::builder() + .limits(ExecutionLimits::new().max_work_units(input.len() as u64 + 500)) + .build(); + assert_budget_exhausted( + bash.exec_with_options("jq --stream empty", ExecOptions::new().stdin(input)) + .await, + ); +} + #[cfg(feature = "python")] #[tokio::test] /// TM-DOS-096: separate Python entries cannot each claim a fresh VM allowance. diff --git a/knowledge/runtimes/jaq-json-vendor.md b/knowledge/runtimes/jaq-json-vendor.md index e83f522f5..5446e3d62 100644 --- a/knowledge/runtimes/jaq-json-vendor.md +++ b/knowledge/runtimes/jaq-json-vendor.md @@ -77,6 +77,12 @@ same rewrite applies to every release: - Growth operations check the meter *before* allocating and fail with the jq error `value size limit (N bytes) exceeded` (exit 5). The limit is `ExecutionLimits::max_live_intermediate_bytes` (default 32 MB). +- `--stream` traverses the depth-bounded parsed tree incrementally inside + the metered evaluation. Ancestor keys are shared `Val` strings; cursor, + path, event arrays and `--slurp` retention are charged before capacity + growth. Each traversal step consumes shared execution work, checking + cancellation and deadlines even for `empty`. Parsed input trees remain + subject to the existing input and JSON-depth restrictions. - The meter is sticky: once tripped, `try ... catch` cannot keep a loop going. - Where there is no error channel (infallible `FromIterator`, `From`) and for loops that never emit a value (`until(false; .)`), the run is diff --git a/knowledge/security/threat-model.md b/knowledge/security/threat-model.md index e83f7396d..d741ab9c8 100644 --- a/knowledge/security/threat-model.md +++ b/knowledge/security/threat-model.md @@ -279,7 +279,7 @@ runaway scripts without permanently breaking the session. | TM-DOS-062 | jq file binding amplification | Repeated `--rawfile` / `--slurpfile` bindings to one max-sized VFS file multiply retained jq globals and `$ARGS.named` values without consuming more VFS quota | `MAX_FILE_VAR_REQUESTS` caps binding count; `MAX_FILE_VAR_BYTES` counts cumulative file bytes per binding before retaining globals | **MITIGATED** | | TM-DOS-063 | Persistent fd exhaustion | `exec N>/tmp/f` across many `N` values grows `exec_fd_table`/coproc fd buffers for the session | `ExecutionLimits::max_file_descriptors` (default 1024) caps persistent custom descriptors; reused fds and standard 0/1/2 don't count | **MITIGATED** | | TM-DOS-109 | Silent truncation at builtin caps | A builtin that stops at a resource cap but exits 0 with no diagnostic (awk loops/recursion/getline caps, `seq`, `yes`, `numfmt`, `history`), or an awk `sprintf` expression that reports a format cap yet returns an empty value, gives scripts and agents incomplete output as success (#2446, #2456, like sed #1005) | Reaching a builtin cap is never silent: `builtins::limits::cap_exceeded` keeps the partial output, writes `: limit () exceeded; output truncated` and exits 1; awk caps and formatting errors are gawk-style fatal errors (`awk: fatal: ...`, exit 2, END skipped). Regression tests: `builtin_cap_security_tests` | **MITIGATED** | -| TM-DOS-110 | In-builtin memory growth aborts the host | A value that keeps growing inside awk (`s = s s`, `gsub` with a long replacement, `sprintf("%s%s%s", s, s, s)`, `$1000000000 = 1`, filling arrays, deep recursion shadowing large values) or jq (`until(false; . + .)`, `"x" * 1e12`, `[range(1e12)]`, a non-emitting `until(false; .)` growing evaluator state) allocates until the allocator fails and the embedding process aborts with SIGABRT (#2444) | awk: every string is checked against `AWK_MAX_STRING_BYTES` (16 MiB) *before* it is allocated (concat, `SUBSEP` keys, `sub`/`gsub`/`gensub` as the result grows, `sprintf`/`printf`, `print` joins, rebuilt `$0`); `$N` assignment is capped at `AWK_MAX_FIELD_INDEX`; variables, array elements and values parked in call frames are accounted and capped at `ExecutionLimits::max_live_intermediate_bytes` (default 32 MB); each is a fatal awk error (exit 2) and the script carries on. jq: jaq-json is vendored with a live-memory meter charging every string and array/object body to the same limit, checked before growth (`jq: error: value size limit (N bytes) exceeded`, exit 5), sticky across `try`; value operations poll the execution deadline so non-emitting loops stop at the timeout; output conversion is capped by `max_stdout_bytes`. Regression tests: `memory_growth_security_tests`. Jq evaluator contexts are now counted (64 live-context ceiling) and every context clone polls the deadline, so pure tail recursion times out and non-tail recursion fails before host stack exhaustion (#2465). The unwind-based abort requires `panic = "unwind"`; panic-abort embedders need process isolation for untrusted jq | **MITIGATED** | +| TM-DOS-110 | In-builtin memory growth aborts the host | A value that keeps growing inside awk (`s = s s`, `gsub` with a long replacement, `sprintf("%s%s%s", s, s, s)`, `$1000000000 = 1`, filling arrays, deep recursion shadowing large values) or jq (`until(false; . + .)`, `"x" * 1e12`, `[range(1e12)]`, a non-emitting `until(false; .)` growing evaluator state) allocates until the allocator fails and the embedding process aborts with SIGABRT (#2444) | awk: every string is checked against `AWK_MAX_STRING_BYTES` (16 MiB) *before* it is allocated (concat, `SUBSEP` keys, `sub`/`gsub`/`gensub` as the result grows, `sprintf`/`printf`, `print` joins, rebuilt `$0`); `$N` assignment is capped at `AWK_MAX_FIELD_INDEX`; variables, array elements and values parked in call frames are accounted and capped at `ExecutionLimits::max_live_intermediate_bytes` (default 32 MB); each is a fatal awk error (exit 2) and the script carries on. jq: jaq-json is vendored with a live-memory meter charging every string and array/object body to the same limit, checked before growth (`jq: error: value size limit (N bytes) exceeded`, exit 5), sticky across `try`; value operations poll the execution deadline so non-emitting loops stop at the timeout; output conversion is capped by `max_stdout_bytes`; `--stream` events are produced incrementally with shared ancestor keys, metered cursor/path/event bodies and pre-allocation checks, including `--slurp`, and every traversal step checks shared work/cancellation/deadline limits. Regression tests: stream memory/work coverage in `execution_budget_tests` and `memory_growth_security_tests`. Jq evaluator contexts are now counted (64 live-context ceiling) and every context clone polls the deadline, so pure tail recursion times out and non-tail recursion fails before host stack exhaustion (#2465). The unwind-based abort requires `panic = "unwind"`; panic-abort embedders need process isolation for untrusted jq | **MITIGATED** | | TM-DOS-114 | Arrays bypass the retained-memory byte budget | `max_array_entries` counted entries but never their keys or values, so `x` doubled to 4 MiB and parked in ten array slots retained ~40 MB past the 10 MB variable-byte budget and exited 0 (#2462) | Array keys and values charge `max_total_variable_bytes` alongside scalars (`MemoryBudget::array_bytes`; indexed keys are machine integers already covered by the entry count, associative keys are attacker-controlled so they count). Element writes, `+=` appends, bulk `a=(...)` replacement, and `declare -A` compound writes are checked before insertion; bytes are released on element unset, whole-array unset, replacement, and local scope pop. An over-budget write records the first memory-limit error and fails execution (TM-DOS-111 mechanism) rather than dropping the value; reaching `max_array_entries` keeps its established silent-skip behaviour (TM-DOS-060). Regression tests: `array_budget_security_tests` | **MITIGATED** | | TM-DOS-115 | `$(