diff --git a/crates/bashkit/docs/threat-model.md b/crates/bashkit/docs/threat-model.md index 456d9de18..3966adae2 100644 --- a/crates/bashkit/docs/threat-model.md +++ b/crates/bashkit/docs/threat-model.md @@ -135,6 +135,7 @@ through configurable limits. | `$(` with exit 1. The stdout *capture* cap is deliberately not a sink limit, since sed output is often piped onward or redirected to a file | **MITIGATED** | | Newline-dense head/tail input (TM-DOS-117) | A small line selection over a newline-dense file builds an offset for every line | Line selection scans from the nearest end and stores no per-line offset collection | **MITIGATED** | | Param expansion bomb (TM-DOS-059) | `${x//a/bigstring}` multiplicative amplification | `max_total_variable_bytes` + `max_stdout_bytes` | MITIGATED | diff --git a/crates/bashkit/src/interpreter/mod.rs b/crates/bashkit/src/interpreter/mod.rs index b80d96940..c310f91e8 100644 --- a/crates/bashkit/src/interpreter/mod.rs +++ b/crates/bashkit/src/interpreter/mod.rs @@ -23,6 +23,9 @@ // `name: msg` for bash shell builtins) and `prefix_builtin_diagnostics` // rewrites the line start at the single dispatch point // (`execute_builtin_arc`), before redirects or streaming see the text. +// Diagnostic names are capped at 1,024 UTF-8 bytes without changing `$0`. +// Rewriting leases both bytes and cached text before fallible allocation; +// leases survive routing, and each scan/emission checks the request budget. // Execution plans and `/dev/stderr` operand data are not rewritten; usage // lines (`name: usage:`) and coreutils-style builtins (cat, grep, ...: // external programs in bash) stay unprefixed. Interactive shells @@ -3191,15 +3194,21 @@ impl Interpreter { /// (`BASH_SOURCE[0]`, so a sourced file names itself), else `$0`. Mirrors /// bash's `get_name_for_error`. pub(crate) fn diag_name(&self) -> String { - if !self.interactive + // THREAT[TM-DOS-103]: bound attacker-controlled names before copying. + let name = if !self.interactive && let Some(src) = self.bash_source_stack.last().filter(|s| !s.file.is_empty()) { - return src.file.to_string(); - } - if let Some(frame) = self.call_stack.iter().rev().find(|f| !f.keeps_arg0) { - return frame.name.clone(); + &src.file + } else if let Some(frame) = self.call_stack.iter().rev().find(|f| !f.keeps_arg0) { + frame.name.as_str() + } else { + Self::DEFAULT_ARG0 + }; + let mut end = name.len().min(1024); + while !name.is_char_boundary(end) { + end -= 1; } - Self::DEFAULT_ARG0.to_string() + name[..end].to_string() } /// Prefix of a shell diagnostic: `$0: line N: ` (non-interactive bash), @@ -9971,11 +9980,20 @@ impl Interpreter { }; // Before `/dev/stderr` operand data is appended and before any // redirect or streaming emission sees the text. + // Keep rewritten byte/text storage charged until redirects and + // result hooks finish consuming it, including discarded stderr. + let mut diagnostic_storage = None; if bundled - && let Some(stderr) = - prefix_builtin_diagnostics(&result.stderr, name, &self.diag_prefix()) + && !result.stderr.is_empty() + && let Some((stderr, storage)) = prefix_builtin_diagnostics( + &result.stderr, + name, + &self.diag_prefix(), + &self.execution_budget, + )? { result.stderr = stderr; + diagnostic_storage = Some(storage); } if let Some(capture) = std_capture { let capture = @@ -10005,7 +10023,9 @@ impl Interpreter { self.apply_builtin_side_effects(&mut result).await?; let result = self.apply_redirections(result, redirects).await?; - self.apply_after_tool(name, result) + let result = self.apply_after_tool(name, result); + drop(diagnostic_storage); + result }) } @@ -14856,33 +14876,58 @@ fn prefix_builtin_diagnostics( stderr: &crate::StreamData, name: &str, prefix: &str, -) -> Option { + budget: &crate::limits::ExecutionBudget, +) -> Result> { let bytes = stderr.as_bytes(); if bytes.is_empty() { - return None; + return Ok(None); + } + // THREAT[TM-DOS-103]: account before scanning or multiplying prefixes. + // Poll by input chunk even when a single diagnostic line is enormous. + for chunk in bytes.chunks(4096) { + budget.consume_work(chunk.len().div_ceil(1024) as u64)?; } let shell_builtin = BASH_SHELL_BUILTINS.contains(&name); let own = format!("{name}: "); let usage = format!("{name}: usage:"); - let mut out = Vec::with_capacity(bytes.len() + prefix.len()); - let mut changed = false; + let mut out = None; + let mut offset = 0; for line in bytes.split_inclusive(|b| *b == b'\n') { - if let Some(rest) = line.strip_prefix(b"bash: ".as_slice()) { - out.extend_from_slice(prefix.as_bytes()); - out.extend_from_slice(rest); - changed = true; - } else if shell_builtin - && line.starts_with(own.as_bytes()) - && !line.starts_with(usage.as_bytes()) - { - out.extend_from_slice(prefix.as_bytes()); - out.extend_from_slice(line); - changed = true; + let (add_prefix, rest) = if let Some(rest) = line.strip_prefix(b"bash: ".as_slice()) { + (true, rest) } else { - out.extend_from_slice(line); + ( + shell_builtin + && line.starts_with(own.as_bytes()) + && !line.starts_with(usage.as_bytes()), + line, + ) + }; + budget.consume_work( + 1 + rest.len().div_ceil(1024) as u64 + + if add_prefix { + prefix.len().div_ceil(1024) as u64 + } else { + 0 + }, + )?; + if add_prefix { + if out.is_none() { + let mut bytes_out = crate::limits::BudgetedBytes::new(Some(budget))?; + bytes_out.try_extend_from_slice(&bytes[..offset])?; + out = Some(bytes_out); + } + out.as_mut() + .unwrap() + .try_extend_from_slice(prefix.as_bytes())?; + } + if let Some(out) = &mut out { + out.try_extend_from_slice(rest)?; } + offset += line.len(); } - changed.then(|| crate::StreamData::from(out)) + out.map(|out| crate::StreamData::from_budgeted_bytes(out, budget).map_err(Into::into)) + .transpose() } fn builtin_usage_error(msg: &str) -> ExecResult { @@ -14927,6 +14972,22 @@ mod tests { assert_eq!(bash.interpreter.memory_budget.function_count, 0); } + #[test] + fn diagnostic_rewrite_checks_work_and_cancellation() { + for cancelled in [false, true] { + let limits = crate::limits::ExecutionLimits::new().max_work_units(2); + let budget = crate::limits::ExecutionBudget::new( + &limits, + Arc::new(std::sync::atomic::AtomicBool::new(cancelled)), + ); + let stderr = crate::StreamData::from("bash: unalias: x: not found\n".repeat(10)); + let error = prefix_builtin_diagnostics(&stderr, "unalias", "child: line 1: ", &budget) + .unwrap_err(); + let expected = if cancelled { "cancel" } else { "work" }; + assert!(error.to_string().contains(expected), "{error}"); + } + } + /// TM-DOS-042: comma-list brace expansion must not recurse one frame per /// brace group (stack overflow) nor accumulate unbounded memory. A long /// `{a,b}{a,b}...` sequence — far under the input cap — used to descend to diff --git a/crates/bashkit/src/stream.rs b/crates/bashkit/src/stream.rs index 2ec479a0f..e22fb3799 100644 --- a/crates/bashkit/src/stream.rs +++ b/crates/bashkit/src/stream.rs @@ -6,6 +6,9 @@ use std::borrow::Cow; use std::fmt; +/// Leases for a materialized stream's byte storage and cached text storage. +pub(crate) type StreamStorage = [Option; 2]; + /// Owned data carried by a shell byte stream. #[derive(Clone, Default, Eq, PartialEq)] pub struct StreamData { @@ -14,6 +17,26 @@ pub struct StreamData { } impl StreamData { + /// Materialize cached text without releasing the byte buffer's lease or + /// allocating a temporary lossy string outside the request budget. + // THREAT[TM-DOS-103]: both representations must be admitted before growth. + pub(crate) fn from_budgeted_bytes( + bytes: crate::limits::BudgetedBytes, + budget: &crate::limits::ExecutionBudget, + ) -> Result<(Self, StreamStorage), crate::limits::LimitExceeded> { + let mut text = crate::limits::BudgetedString::new(Some(budget))?; + for chunk in bytes.utf8_chunks() { + budget.consume_work(1 + chunk.valid().len().div_ceil(1024) as u64)?; + text.try_push_str(chunk.valid())?; + if !chunk.invalid().is_empty() { + text.try_push('\u{fffd}')?; + } + } + let (bytes, byte_storage) = bytes.into_parts(); + let (text, text_storage) = text.into_parts(); + Ok((Self { bytes, text }, [byte_storage, text_storage])) + } + pub const fn new() -> Self { Self { bytes: Vec::new(), @@ -182,6 +205,39 @@ impl serde::Serialize for StreamData { mod tests { use super::StreamData; + #[test] + fn budgeted_text_preserves_bytes_and_lossy_decoding() { + for input in [ + b"plain".as_slice(), + "é".as_bytes(), + b"a\xff\xc3z\0", + b"\xf0\x90\x80", + ] { + let budget = crate::limits::ExecutionBudget::new( + &crate::limits::ExecutionLimits::default(), + std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false)), + ); + let mut bytes = crate::limits::BudgetedBytes::new(Some(&budget)).unwrap(); + bytes.try_extend_from_slice(input).unwrap(); + let (stream, _storage) = StreamData::from_budgeted_bytes(bytes, &budget).unwrap(); + assert_eq!(stream.as_bytes(), input); + assert_eq!(stream.text_lossy(), String::from_utf8_lossy(input)); + } + } + + #[test] + fn budgeted_text_copy_counts_against_live_memory() { + let limits = crate::limits::ExecutionLimits::new().max_live_intermediate_bytes(7); + let budget = crate::limits::ExecutionBudget::new( + &limits, + std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false)), + ); + let mut bytes = crate::limits::BudgetedBytes::new(Some(&budget)).unwrap(); + bytes.try_extend_from_slice(b"text").unwrap(); + let error = StreamData::from_budgeted_bytes(bytes, &budget).unwrap_err(); + assert!(error.to_string().contains("live intermediate"), "{error}"); + } + #[test] fn append_redecodes_utf8_split_across_chunks() { let mut stream = StreamData::from(vec![0xc3]); diff --git a/crates/bashkit/tests/integration/diagnostic_prefix_tests.rs b/crates/bashkit/tests/integration/diagnostic_prefix_tests.rs index 5c429ad85..9dbe3e350 100644 --- a/crates/bashkit/tests/integration/diagnostic_prefix_tests.rs +++ b/crates/bashkit/tests/integration/diagnostic_prefix_tests.rs @@ -1,9 +1,10 @@ //! Shell diagnostics carry bash's non-interactive `$0: line N: ` prefix. //! -//! Every expected string below was checked against real bash 5.2 running the +//! Standard diagnostic expectations were checked against real bash 5.2 running the //! same text as `bash SCRIPT` (so `$0` is `SCRIPT`) or `bash -c '...'` (so //! `$0` is `bash`). Coreutils-style builtins are external programs in bash and -//! keep their own `cat: ...` form; usage lines stay unprefixed. +//! keep their own `cat: ...` form; usage lines stay unprefixed. Resource tests +//! also cover the intentional diagnostic-name cap and shared execution budget. use bashkit::{Bash, ExecOptions}; use std::sync::{Arc, Mutex}; @@ -347,3 +348,142 @@ async fn short_arithmetic_diagnostics_are_not_truncated() { assert!(err.contains(&expr), "{err}"); assert!(!err.contains("..."), "{err}"); } + +/// Small-budget reproduction: the original stderr fits; repeating $0 does not. +#[tokio::test] +async fn diagnostic_prefix_amplification_hits_shared_memory_budget() { + for (redirect, streaming) in [ + ("", false), + ("", true), + (" 2>/tmp/diagnostics", true), + (" 2>&1", true), + (" 2>/dev/null", true), + ] { + let mut bash = Bash::builder() + .limits( + bashkit::ExecutionLimits::new() + .timeout(std::time::Duration::from_secs(600)) + .max_live_intermediate_bytes(8192) + .max_stderr_bytes(64), + ) + .build(); + let seen = Arc::new(Mutex::new(String::new())); + let seen_cb = seen.clone(); + let script = format!( + "bash -c 'unalias {{1..100}}' '{}'{}", + "x".repeat(128), + redirect + ); + let options = if streaming { + ExecOptions::new().streaming(Box::new(move |stdout, stderr| { + let mut seen = seen_cb.lock().unwrap(); + seen.push_str(stdout); + seen.push_str(stderr); + })) + } else { + ExecOptions::new() + }; + let error = bash + .exec_with_options(&script, options) + .await + .expect_err("prefix amplification must fail before routing or capture"); + assert!( + error.to_string().contains("live intermediate"), + "{redirect}: {error}" + ); + assert!(seen.lock().unwrap().is_empty()); + let recovered = bash.exec("echo recovered").await.unwrap(); + assert_eq!(recovered.stdout, "recovered\n"); + } +} + +#[tokio::test] +async fn diagnostic_name_is_bounded_utf8_without_changing_arg0() { + let name = "€".repeat(400); + let mut bash = Bash::new(); + let script = format!("bash -c 'echo ${{#0}}; unalias absent' '{name}'"); + let result = bash.exec(&script).await.unwrap(); + assert_eq!(result.stdout, "400\n"); + assert_eq!( + result.stderr, + format!("{}: line 1: unalias: absent: not found\n", "€".repeat(341)) + ); +} + +/// Replace the `line N` counter with `line N*` so oracle comparisons hold +/// across bash versions: bash 5.x numbers `bash -c` text from line 1 while the +/// macOS bash 3.2 oracle numbers it from line 0. Our own output stays pinned +/// to the bash 5.2 contract asserted below. +fn normalize_line_numbers(s: &str) -> String { + let mut out = String::with_capacity(s.len()); + let mut rest = s; + while let Some(idx) = rest.find("line ") { + let (head, tail) = rest.split_at(idx); + out.push_str(head); + let digits = tail[5..].chars().take_while(char::is_ascii_digit).count(); + if digits == 0 { + out.push_str("line "); + rest = &tail[5..]; + } else { + out.push_str("line N*"); + rest = &tail[5 + digits..]; + } + } + out.push_str(rest); + out +} + +#[tokio::test] +async fn diagnostic_prefix_matches_bash_and_redirect_keeps_full_output() { + let name = "child".repeat(24); + let body = "unalias first second third"; + let oracle = std::process::Command::new("bash") + .env_clear() + .env("PATH", std::env::var_os("PATH").unwrap_or_default()) + .args(["-c", body, &name]) + .output() + .unwrap(); + let script = format!("bash -c '{body}' '{name}'"); + let mut bash = Bash::builder() + .limits(bashkit::ExecutionLimits::new().max_live_intermediate_bytes(8192)) + .build(); + let result = bash.exec(&script).await.unwrap(); + let stderr = String::from_utf8_lossy(result.stderr.as_bytes()).into_owned(); + let line1 = format!("{name}: line 1: unalias: "); + let expected = line1.clone() + + "first: not found\n" + + &line1 + + "second: not found\n" + + &line1 + + "third: not found\n"; + assert_eq!(stderr, expected); + assert_eq!( + normalize_line_numbers(&stderr), + normalize_line_numbers(&String::from_utf8_lossy(&oracle.stderr)) + ); + assert_eq!(result.stdout.as_bytes(), oracle.stdout); + assert_eq!(Some(result.exit_code), oracle.status.code()); + + let mut bash = Bash::builder() + .limits( + bashkit::ExecutionLimits::new() + .max_live_intermediate_bytes(8192) + .max_stderr_bytes(64), + ) + .build(); + // Redirect the builtin before the child captures stderr. An outer + // redirect receives the child's already capped capture. + let result = bash + .exec(&format!( + "bash -c '{body} 2>/tmp/diagnostics' '{name}'; cat /tmp/diagnostics" + )) + .await + .unwrap(); + assert!(result.stderr.is_empty()); + let stdout = String::from_utf8_lossy(result.stdout.as_bytes()).into_owned(); + assert_eq!(stdout, expected); + assert_eq!( + normalize_line_numbers(&stdout), + normalize_line_numbers(&String::from_utf8_lossy(&oracle.stderr)) + ); +} diff --git a/docs/security.md b/docs/security.md index bfcbecc00..bce0d7ec0 100644 --- a/docs/security.md +++ b/docs/security.md @@ -24,6 +24,15 @@ bytes of stack padding; output capacity is admitted against `ExecutionLimits::max_live_intermediate_bytes` before fallible allocation. This memory budget applies to pipelines and redirected output too. +Shell diagnostic names (`$0` or the current source file) show at most 1,024 +UTF-8 bytes; the actual `$0` value is unchanged. Repeating a prefix across +builtin errors uses the shared live-memory and work budget before allocation, +including the cached text representation. A refused reservation aborts the +request before capture, streaming, or redirection; the next host execution +can reuse the shell with a fresh budget. Capture limits remain downstream so +command-local redirected diagnostics keep their full content when they fit +the memory budget. An outer redirect on a child-shell invocation receives +that child's already capped capture. `cd` searches `CDPATH` one candidate at a time. Temporary path and normalization storage is reserved against `max_live_intermediate_bytes` before allocation. Searches also consume the shared work budget and check diff --git a/knowledge/foundations/architecture.md b/knowledge/foundations/architecture.md index f8025b46a..09fb8b6f9 100644 --- a/knowledge/foundations/architecture.md +++ b/knowledge/foundations/architecture.md @@ -150,6 +150,16 @@ compression paths use these builders because their expansion ratios and nested buffers make post-hoc leasing unsafe. Atomic compare/exchange admission keeps concurrent descendants from wrapping or temporarily exceeding the shared cap. +Shell diagnostic names are truncated to 1,024 UTF-8 bytes before copying, +without changing positional `$0`. Builtin diagnostic prefix rewriting uses +`BudgetedBytes` and fallible `StreamData` text construction under the same +request budget. Both storage leases survive through redirection and hooks; +input scanning and every emitted line charge work and check the shared +cancellation/deadline. Capture caps remain downstream of a command's own +redirections, so command-local redirected diagnostics retain full content. +A child shell captures uncaught stderr before an outer redirect on the +`bash -c` invocation sees it; that outer redirect receives the child's capped +capture. Put the redirect on the child command to bypass capture. Tilde expansion uses the same owning string builder before each append; it borrows `HOME`/`PWD`/`OLDPWD` instead of cloning the directory on each prefix. Scan and copy work polls the shared deadline and cancellation flag. Assignment diff --git a/knowledge/operations/limitations.md b/knowledge/operations/limitations.md index 3e28a3df1..b2d175488 100644 --- a/knowledge/operations/limitations.md +++ b/knowledge/operations/limitations.md @@ -42,6 +42,7 @@ execution model. Evidence is a threat-model ID, a test, or `stance` | ID | Limitation | Why | Evidence | |----|------------|-----|----------| +| L-DIAG-001 | Shell diagnostic names show at most 1,024 UTF-8 bytes of `$0` or the current source filename; the actual `$0` is unchanged. Prefix amplification can abort a request at the shared live-memory/work cap, including redirected or streamed diagnostics | Prevent attacker-controlled diagnostic names from multiplying into unchecked host allocations (TM-DOS-103) | `diagnostic_prefix_tests` | | L-PROC-001 | `exec` does not replace the process; `exec cmd` runs cmd then stops execution. fd redirects work; `exec >log 2>&1` routes the shell's output per top-level command (each element of a top-level `;`/`&&`/`||` list counts as one), so output written earlier in the same top-level command (not streamed yet) also lands in the log, and output under the redirect reaches a streaming caller only when that command ends. `exec N>&-` closes fd 1 or 2 for the rest of the script: later writes to it are dropped, the command reports `write error: Bad file descriptor` naming itself, and its status is 1. An `exec` redirect inside a subshell applies to the subshell's own output, routed at the subshell boundary; output the subshell wrote before the `exec` still reaches the caller. Inside a `bash -c` child it still does not take effect. `exec &>file` and `exec >&word` are ignored | True process replace would break sandbox containment; there is no real fd 1 to swap, the shell's output is a value returned to the caller | TM-ESC-005, `exec-redirect-shell-output.test.sh` | | L-PROC-002 | Background jobs run concurrently (`jobs`, `wait -n`, `kill`, `ps`, `pgrep`), but cannot be stopped or resumed: `kill -STOP/-CONT` are ignored, there is no `suspend`/Ctrl-Z, `bg` is a no-op and `fg` just waits; jobs end when `exec()` returns | No terminal process groups in a virtual shell; a job must not outlive the call that owns it (TM-DOS-122) | `l_proc_002_no_job_control` | | L-PROC-003 | No process spawning; external commands run as builtins. bash's fork suppression is modeled only for `$SHLVL` (see `NoforkScope` in the interpreter): a `bash`/`sh` child bash would exec in place keeps the level, but another command run there still sees the unlowered value (bash's `bash -c 'printenv SHLVL'` prints one less than the child's level), nor do `env`/`timeout`/`xargs` starting a shell, and a `SHLVL=n` prefix on the in-place command counts | Core sandbox model: no fork/exec escape surface | `l_proc_003_no_process_spawning` | diff --git a/knowledge/security/threat-model.md b/knowledge/security/threat-model.md index 76ff71d0c..9a8444da3 100644 --- a/knowledge/security/threat-model.md +++ b/knowledge/security/threat-model.md @@ -1417,7 +1417,7 @@ This section maps former vulnerability IDs to the new threat ID scheme and track | TM-DOS-100 | jq control-character normalization amplification | A jq JSON string consisting of literal controls expands sixfold when each byte becomes `\u00XX`; an unmetered compatibility copy can exhaust memory or CPU before strict parsing | The jq-only normalizer charges input-length work before its single pass, borrows unchanged input, and acquires/grows a shared live-intermediate lease before every allocation growth (`builtins/jq/input.rs`), **MITIGATED** | | TM-DOS-101 | yq structured-data amplification | YAML aliases, YAML/JSON nesting, multi-document floods, filters, and output format expansion are all attacker-controlled | A pre-deserialization lexical pass rejects aliases without misclassifying quoted or block scalar content; real parsers with recursion/depth caps, aggregate budgets, shared jaq work/deadline/output controls, and a final rendered-output cap; integration tests cover alias bombs plus YAML+JSON depth/document/input/output bounds, proptest composes arbitrary YAML+filters, and `yq_fuzz` covers stdin/in-place format paths, **MITIGATED** | | TM-DOS-102 | Archive decoder allocation-before-check | A small gzip/bzip2 stream makes the decoder output buffer allocate beyond live-memory or filesystem quotas before the post-growth size check runs | Decoder chunks validate absolute/ratio bounds and acquire a shared execution-budget lease before `try_reserve_exact` and copy; corrupt, truncated, CRC-invalid, ratio-bomb, and live-budget tests fail closed, **MITIGATED** | -| TM-DOS-103 | Post-allocation resource charging | Archive/compression and other growing buffers could call `reserve`/`extend` before taking a live-byte lease; concurrent `fetch_add` accounting could also wrap or transiently overcommit the ceiling | `BudgetedVec`/`BudgetedBytes` and `BudgetedString` own their `ExecutionBudgetLease`, charge planned capacity before `try_reserve_exact`, roll back on allocation/error, and release on drop. CAS admission rejects overflow and concurrent overcommit. Archive creation, extraction/list output, gzip/bzip2 encoders, decompression, and `od` output use the builders. `od` rejects widths above 65,536 bytes before layout arithmetic (including empty input), pads only a single <= 8-byte field on the stack, uses u64 padding products for 32-bit safety, and charges input/field rendering work; `od_resource_tests` cover huge-width rejection, boundary formatting, live/work budgets, and subsequent execution, **MITIGATED** | +| TM-DOS-103 | Post-allocation resource charging | Archive/compression and other growing buffers could call `reserve`/`extend` before taking a live-byte lease; concurrent `fetch_add` accounting could also wrap or transiently overcommit the ceiling | `BudgetedVec`/`BudgetedBytes` and `BudgetedString` own their `ExecutionBudgetLease`, charge planned capacity before `try_reserve_exact`, roll back on allocation/error, and release on drop. CAS admission rejects overflow and concurrent overcommit. Archive creation, extraction/list output, gzip/bzip2 encoders, decompression, and `od` output use the builders. Shell diagnostic names are bounded to 1,024 UTF-8 bytes before copying; prefix rewriting and its cached lossy text use these builders before routing, retain both leases through routing/hooks, and charge work/check cancellation and deadlines during scanning and emission. `diagnostic_prefix_tests` covers small-budget amplification across capture, streaming and redirects, plus recovery. `od` rejects widths above 65,536 bytes before layout arithmetic (including empty input), pads only a single <= 8-byte field on the stack, uses u64 padding products for 32-bit safety, and charges input/field rendering work; `od_resource_tests` cover huge-width rejection, boundary formatting, live/work budgets, and subsequent execution, **MITIGATED** | | TM-DOS-104 | Pipeline stderr aggregation exceeds the configured output cap before final truncation | A pipeline with many stderr-producing stages can accumulate their combined diagnostics in memory | The pipeline aggregate stops appending at `max_stderr_bytes` and propagates its truncation signal to the final result, **MITIGATED** | | TM-DOS-106 | Zip creation allocation-before-check | `zip` recursively collected complete file contents and encoded its output before acquiring live-byte leases, allowing transient allocations above the configured intermediate-memory ceiling | Zip traversal collects paths only; each VFS file size is leased before `read_file`, leases remain live with the entries, and archive encoding uses `BudgetedBytes` to charge capacity before growth — **MITIGATED** | | TM-DOS-108 | CLI host-stdin read can stall before execution | A producer keeps the one-shot CLI's stdin pipe open while idle or trickles fewer than 10 MiB, so the pre-execution read never reaches EOF or the byte cap | Read stdin on a dedicated thread and bound the wait with the selected execution timeout; timeout exits with a diagnostic suggesting `--no-stdin`, while the existing byte cap remains enforced — **MITIGATED** |