From e1f101469d93c791964be005018e9d92da995be4 Mon Sep 17 00:00:00 2001 From: Mykhailo Chalyi Date: Thu, 8 Oct 2026 20:05:38 -0500 Subject: [PATCH 01/10] fix(parser): bound syntax diagnostic construction --- crates/bashkit/docs/threat-model.md | 1 + crates/bashkit/src/error.rs | 186 +++++++++++++++--- crates/bashkit/src/parser/mod.rs | 5 +- .../tests/integration/partial_parse_tests.rs | 36 ++++ docs/security.md | 6 + 5 files changed, 208 insertions(+), 26 deletions(-) diff --git a/crates/bashkit/docs/threat-model.md b/crates/bashkit/docs/threat-model.md index c7a17f912..196b709ab 100644 --- a/crates/bashkit/docs/threat-model.md +++ b/crates/bashkit/docs/threat-model.md @@ -129,6 +129,7 @@ through configurable limits. | Pattern substitution (TM-DOS-127) | `${x//*a*/b}` over a long value | Globs run as a size-capped linear-time regex; extglob search capped at 10,000 attempts | MITIGATED | | Unbounded builtin output (TM-DOS-058) | `seq 1 1000000` produces 1M lines | Add `max_stdout_bytes` limit | **MITIGATED** | | Silent truncation at builtin caps (TM-DOS-109) | `seq 200000`, an awk loop past its cap, or an oversized `sprintf` expression returns incomplete output with exit 0 | Caps report `: limit () exceeded` on stderr and exit non-zero; awk caps and formatting errors are fatal | **MITIGATED** | +| Syntax diagnostic amplification (TM-DOS-135) | A multiline unexpected token and long `bash -c` ARG0 multiply diagnostic storage before stderr capture | Unexpected-token messages and complete syntax reports are capped at 1 KiB during construction; overflow stops formatting and appends `[diagnostic truncated]`. Short reports retain Bash text and syntax-error exit status | **MITIGATED** | | In-builtin memory growth (TM-DOS-110) | `awk 'BEGIN { s = "x"; while (1) s = s s }'` or `jq -n '"x" \| until(false; . + .)'` allocates until the host aborts | awk checks each string against a 16 MiB cap before allocating it, caps `$N` field indexes, and caps total variable memory at `max_live_intermediate_bytes` (fatal, exit 2). jq meters every live string, array and object against the same limit and fails before growing (exit 5); non-emitting jq loops and pure recursion stop at the timeout; non-tail recursion hits a live-context ceiling (64) before host stack exhaustion | **MITIGATED** | | Arrays bypass the retained-memory budget (TM-DOS-114) | Ten array slots each holding a 4 MiB string retain ~40 MB past the 10 MB variable-byte budget | Array keys and values charge `max_total_variable_bytes` alongside scalars, are released on unset/replacement/scope pop, and an over-budget write fails execution. Pending indexed compound-assignment keys, values, fields and containers also hold shared intermediate-memory leases, including repeated subscripts | **MITIGATED** | | `$( String { - let message = if message.starts_with("syntax error") || message.starts_with("unexpected EOF") { - message.to_string() - } else { - format!("syntax error: {message}") - }; - if line == 0 { - return format!("{who}: {message}\n"); + // THREAT[TM-DOS-135]: stop before growth, including temporary formatting. + let mut out = Diagnostic::new(); + let _ = (|| -> fmt::Result { + let prefix = if message.starts_with("syntax error") || message.starts_with("unexpected EOF") + { + "" + } else { + "syntax error: " + }; + if line == 0 { + return writeln!(out, "{who}: {prefix}{message}"); + } + let at = line.saturating_add(shift); + let mut lines = message.lines(); + let first = lines.next().unwrap_or_default(); + writeln!(out, "{who}: line {at}: {prefix}{first}")?; + if first.contains("near unexpected token") + && let Some(text) = source.lines().nth(line - 1) + { + writeln!(out, "{who}: line {at}: `{text}'")?; + } + for more in lines { + let further = more.strip_prefix("line ").and_then(|rest| { + let (n, text) = rest.split_once(": ")?; + Some((n.parse::().ok()?.saturating_add(shift), text)) + }); + match further { + Some((n, text)) => writeln!(out, "{who}: line {n}: {text}")?, + None => writeln!(out, "{who}: line {at}: {more}")?, + } + } + Ok(()) + })(); + out.finish() +} + +const MAX_DIAGNOSTIC_BYTES: usize = 1024; +const DIAGNOSTIC_TRUNCATED: &str = "[diagnostic truncated]\n"; + +/// Fixed-capacity writer: formatting cannot allocate the full untrusted text. +struct Diagnostic { + text: String, + truncated: bool, +} + +impl Diagnostic { + fn new() -> Self { + Self { + text: String::with_capacity(MAX_DIAGNOSTIC_BYTES), + truncated: false, + } } - let at = line + shift; - let mut lines = message.lines(); - let first = lines.next().unwrap_or_default(); - let mut out = format!("{who}: line {at}: {first}\n"); - if first.contains("near unexpected token") - && let Some(text) = source.lines().nth(line - 1) - { - out.push_str(&format!("{who}: line {at}: `{text}'\n")); + + fn finish(mut self) -> String { + if self.truncated { + let mut end = self + .text + .len() + .min(MAX_DIAGNOSTIC_BYTES - DIAGNOSTIC_TRUNCATED.len()); + while !self.text.is_char_boundary(end) { + end -= 1; + } + self.text.truncate(end); + self.text.push_str(DIAGNOSTIC_TRUNCATED); + } + self.text } - for more in lines { - let further = more.strip_prefix("line ").and_then(|rest| { - let (n, text) = rest.split_once(": ")?; - Some((n.parse::().ok()? + shift, text)) - }); - match further { - Some((n, text)) => out.push_str(&format!("{who}: line {n}: {text}\n")), - None => out.push_str(&format!("{who}: line {at}: {more}\n")), +} + +impl Write for Diagnostic { + fn write_str(&mut self, text: &str) -> fmt::Result { + let remaining = MAX_DIAGNOSTIC_BYTES - self.text.len(); + if text.len() > remaining { + let mut end = remaining; + while !text.is_char_boundary(end) { + end -= 1; + } + self.text.push_str(&text[..end]); + self.truncated = true; + return Err(fmt::Error); } + self.text.push_str(text); + Ok(()) } - out +} + +/// Bound parser token diagnostics at their creation, before report formatting. +pub(crate) fn bounded_diagnostic(args: fmt::Arguments<'_>) -> String { + let mut out = Diagnostic::new(); + let _ = out.write_fmt(args); + out.finish() } #[cfg(test)] @@ -359,6 +429,72 @@ mod tests { assert_eq!(io_error_reason(&err), "execution error: boom"); } + #[test] + fn syntax_report_bounds_multiline_token_and_shell_name() { + let source = format!("for x \"{}\"", "a\n".repeat(16)); + let error = crate::parser::Parser::new(&source).parse().unwrap_err(); + let report = error.syntax_report(&"x".repeat(256), &source).unwrap(); + assert!(report.len() <= 1024, "report was {} bytes", report.len()); + assert!(report.ends_with("[diagnostic truncated]\n")); + } + + #[test] + fn syntax_report_bounds_each_untrusted_component() { + for (who, source, message, line) in [ + ("é".repeat(2048), String::new(), "syntax error".into(), 0), + ("bash".into(), String::new(), "界".repeat(2048), 0), + ( + "bash".into(), + "界".repeat(2048), + "syntax error near unexpected token `x'".into(), + 1, + ), + ( + "bash".into(), + String::new(), + format!("syntax error\n{}", "line 1: bad\n".repeat(2048)), + 1, + ), + ] { + let report = syntax_report(&who, &source, &message, line, 0); + assert!(report.len() <= MAX_DIAGNOSTIC_BYTES); + assert_eq!(report.capacity(), MAX_DIAGNOSTIC_BYTES); + assert!(report.ends_with(DIAGNOSTIC_TRUNCATED)); + } + } + + #[test] + fn syntax_report_preserves_line_numbers_and_exact_boundary() { + assert_eq!( + syntax_report("bash", "", "unexpected EOF\nline 7: unmatched quote", 2, 3), + "bash: line 5: unexpected EOF\nbash: line 10: unmatched quote\n" + ); + let message = format!("syntax error: {}", "x".repeat(1003)); + let report = syntax_report("bash", "", &message, 0, 0); + assert_eq!(report.len(), MAX_DIAGNOSTIC_BYTES); + assert!(!report.contains(DIAGNOSTIC_TRUNCATED)); + assert_eq!( + syntax_report("bash", "", "bad input", 0, 0), + "bash: syntax error: bad input\n" + ); + assert!( + syntax_report("bash", "", "syntax error\nline 1: extra", 1, usize::MAX) + .contains(&usize::MAX.to_string()) + ); + } + + #[test] + fn parser_bounds_unexpected_token_before_copying() { + let source = format!("for x \"{}\"", "é\n".repeat(2048)); + let Error::Parse { message, .. } = crate::parser::Parser::new(&source).parse().unwrap_err() + else { + panic!("expected parse error") + }; + assert!(message.len() <= MAX_DIAGNOSTIC_BYTES); + assert_eq!(message.capacity(), MAX_DIAGNOSTIC_BYTES); + assert!(message.ends_with(DIAGNOSTIC_TRUNCATED)); + } + use super::*; #[test] diff --git a/crates/bashkit/src/parser/mod.rs b/crates/bashkit/src/parser/mod.rs index 84861d600..39936d00f 100644 --- a/crates/bashkit/src/parser/mod.rs +++ b/crates/bashkit/src/parser/mod.rs @@ -345,7 +345,10 @@ impl<'a> Parser<'a> { let line = self.current_span.start.line; let near = |token: &str| { Some(Error::parse_at( - format!("syntax error near unexpected token `{token}'"), + // THREAT[TM-DOS-135]: a quoted token may span the whole input. + crate::error::bounded_diagnostic(format_args!( + "syntax error near unexpected token `{token}'" + )), line, self.current_span.start.column, )) diff --git a/crates/bashkit/tests/integration/partial_parse_tests.rs b/crates/bashkit/tests/integration/partial_parse_tests.rs index 4656fc369..e6537b914 100644 --- a/crates/bashkit/tests/integration/partial_parse_tests.rs +++ b/crates/bashkit/tests/integration/partial_parse_tests.rs @@ -91,3 +91,39 @@ async fn bash_n_still_rejects_whole_script() { .unwrap(); assert_eq!(r.stdout, "rc=2\n"); } + +/// A multiline token and long $0 must not multiply diagnostic storage. +#[tokio::test] +async fn child_syntax_report_is_bounded() { + let script = format!("for x \"{}\"", "a\n".repeat(16)); + let request = format!("bash -c '{script}' '{}'", "x".repeat(256)); + let mut bash = Bash::new(); + let result = bash.exec(&request).await.unwrap(); + assert_eq!(result.exit_code, 2); + assert!(result.stdout.is_empty()); + assert!( + result.stderr.len() <= 1024, + "report was {} bytes", + result.stderr.len() + ); + assert!(result.stderr.ends_with("[diagnostic truncated]\n")); + assert_eq!(bash.exec("echo alive").await.unwrap().stdout, "alive\n"); +} + +#[tokio::test] +async fn small_child_syntax_reports_match_bash() { + for script in ["if then fi", "echo kept\nif then", "for x ; wat"] { + let expected = std::process::Command::new("bash") + .args(["-c", script, "CASE"]) + .output() + .unwrap(); + let mut bash = Bash::new(); + let result = bash + .exec(&format!("bash -c '{script}' CASE")) + .await + .unwrap(); + assert_eq!(result.exit_code, expected.status.code().unwrap()); + assert_eq!(result.stdout.as_bytes(), expected.stdout); + assert_eq!(result.stderr.as_bytes(), expected.stderr); + } +} diff --git a/docs/security.md b/docs/security.md index dfce1d1bc..dee6fb826 100644 --- a/docs/security.md +++ b/docs/security.md @@ -59,6 +59,12 @@ execution work and deadline budget; file contents are admitted against aggregate input and live intermediate limits and scanned one file at a time. `-q` stops at the first match, before further traversal. +Syntax-error reports are limited to 1 KiB during construction, including an +explicit `[diagnostic truncated]` marker when text is omitted. This prevents +multiline unexpected tokens and long `bash -c` shell names from multiplying +host-memory usage before stderr capture. Ordinary short reports retain Bash's +text, line numbers, and exit status. + ## Threat model Prompt expansion (`${x@P}` and interactive prompts) shares the execution work, From b56ae7c1c3a518c2064a5ae34e444e79b90ba803 Mon Sep 17 00:00:00 2001 From: Mykhailo Chalyi Date: Thu, 8 Oct 2026 20:05:38 -0500 Subject: [PATCH 02/10] chore(knowledge): document bounded syntax diagnostics --- knowledge/foundations/parser.md | 11 +++++++++++ knowledge/security/threat-model.md | 1 + 2 files changed, 12 insertions(+) diff --git a/knowledge/foundations/parser.md b/knowledge/foundations/parser.md index fbbc16446..6d7490b04 100644 --- a/knowledge/foundations/parser.md +++ b/knowledge/foundations/parser.md @@ -158,6 +158,17 @@ TM-ESC-032), and the deferred error rejects the script the way bash does. Process substitution keeps its part for the same reason, and hard-errors on budget failures (TM-DOS-021). +**Bounded syntax diagnostics (TM-DOS-135).** Unexpected-token messages and +complete `Error::syntax_report` results are capped at 1 KiB during construction, +including `[diagnostic truncated]` and its trailing newline when text is omitted. +A fixed-capacity UTF-8 writer stops formatting on the first overflow; neither a +multiline token, a long `$0`, nor the echoed source line can grow a temporary +report before stderr capture applies. Short reports keep Bash's text and line +numbers. This cap is independent of configurable capture and execution budgets. +Regression coverage: `error::tests::syntax_report_*`, +`error::tests::parser_bounds_unexpected_token_before_copying`, and +`partial_parse_tests::{child_syntax_report_is_bounded, small_child_syntax_reports_match_bash}`. + **Partial execution before a syntax error.** Bash reads and runs a script line by line, so `echo a` on line 1 runs before `if then` on line 2 is reported (exit 2). `Parser::parse_recovering` returns the complete top-level commands diff --git a/knowledge/security/threat-model.md b/knowledge/security/threat-model.md index 035d9bcaf..ece714486 100644 --- a/knowledge/security/threat-model.md +++ b/knowledge/security/threat-model.md @@ -300,6 +300,7 @@ runaway scripts without permanently breaking the session. | TM-DOS-130 | Array and arithmetic report growth | `$(( $(( ... )) ))` nested inside arithmetic, thousands of `${a[-9]}` reads in one command, `${!r}` with a huge invalid target | Nested `$((` in arithmetic retains its 32-occurrence admission cap and shares the arithmetic depth/fuel budget with dollar subscripts (`expression recursion level exceeded`); queued `bad array subscript` warnings stop at 64 KiB per command; an invalid indirect target is echoed truncated to 256 chars. Regression: `array_and_arith_reports_are_bounded` | **MITIGATED** | | TM-DOS-131 | History file growth and reads | `history -a` in a loop, `HISTSIZE` huge, a `bash -i` child reading a giant `$HISTFILE`, `history -r` on a multi-GB file | The history list stays under `max_history_entries`/`max_history_bytes` whatever `$HISTSIZE` says (TM-DOS-094 caps apply to every recorded line); `$HISTFILE` reads are refused past `max_input_bytes`; files are read and written through the VFS only, so its size and write limits apply; a `bash -i` child keeps its own list and the parent's comes back after it. Regression: `history_lines_stay_bounded`, `histfile_read_is_capped` | **MITIGATED** | | TM-DOS-132 | Completion spec and key binding growth | `complete -W "$big" nameN` or `bind -x '"\C-xN": cmd'` in a loop keeps per-shell state growing | At most 1024 completion specs of at most 64 KiB option text each, and at most 1024 `bind` changes of at most 4 KiB each, per interpreter; past a cap the builtin fails with status 1. The state is built on first use, lives in the interpreter (never global, so tenants never share it) and is copied into subshells like other shell state. Regression: `completion_specs_are_bounded`, `binding_changes_are_capped` | **MITIGATED** | +| TM-DOS-135 | Syntax diagnostic amplification | A multiline unexpected token combined with a long attacker-controlled `bash -c` ARG0 repeats the shell name for every token line, allocating quadratically before stderr capture | Unexpected-token messages and complete syntax reports use a fixed-capacity UTF-8 writer capped at 1 KiB before any append, stop formatting on overflow, and include `[diagnostic truncated]`; short reports preserve Bash semantics. Regressions: `error::tests::syntax_report_*`, `parser_bounds_unexpected_token_before_copying`, and `partial_parse_tests::{child_syntax_report_is_bounded, small_child_syntax_reports_match_bash}` | **MITIGATED** | | TM-DOS-101 | yq structured-data amplification | YAML aliases can expand exponentially during deserialization; deep YAML/JSON, document floods, jaq generators, and YAML re-serialization can consume stack, CPU, or memory beyond the source size | Reject YAML alias tokens with a bounded lexical pass before deserialization; VFS/stdin and aggregate input budgets; serde_yaml_ng recursion cap 128 plus Bashkit depth 100; 4096-document cap; shared jaq work/deadline/output limits; post-serialization stdout cap; YAML+JSON depth/document/input/output regressions plus `yq_fuzz` and arbitrary-input proptest | **MITIGATED** | | TM-DOS-133 | Unbounded prompt expansion | `x='${x@P}'; echo "${x@P}"`, mutual variable cycles or cycles through command substitutions repeatedly create shallow parsers and recursively poll futures | Prompt reparses count cumulative runtime depth capped at `min(max_ast_depth, 8)` before recursive polling; descendants inherit depth. Each prompt charges the shared work and aggregate-input budget, checking cancellation and deadlines before decoding; child parsers share the request budget and fail closed on poison. Depth unwinds on returned errors and resets at host execution boundaries. Regressions: `prompt_resource_tests`, `prompt_checks_cancellation_before_decoding`, `prompt_checks_shared_deadline_before_decoding`, `prompt_error_unwinds_depth` | **MITIGATED** | | TM-DOS-135 | Indirect array-target operand recursion | `r='a[0]:-${!r}]'; echo "${!r}"`: the bracket validator accepted trailing operator/nesting text, so desugar re-parsed `${a[0]:-${!r}]}` and the unset-element default re-entered indirect expansion without bound (stack overflow). Numbered to avoid collision with the adjacent tilde-budget entry | A bracket target is valid only when the first complete `[...]` reference (nesting- and quote-aware) consumes the whole value; any trailing text is refused with `invalid variable name`, like modern bash 5.x (older 3.2 ignored the tail). Refusal keeps a nested `${!...}` from ever reaching the desugar re-parse while valid nested subscripts (`a[b[0]]`) resolve whole; `$(`/backquote/process-substitution guards unchanged. Regressions: `indirect_array_boundary_rejects_trailing_operators`, `indirect_hostile_cycles_rejected_in_bounded_child`, `indirect_nested_indexed_matches_real_bash` | **MITIGATED** | From 39eaba7f2721c4a7cbcd6a181b4edd29ecac97d8 Mon Sep 17 00:00:00 2001 From: Mykhailo Chalyi Date: Sat, 10 Oct 2026 22:01:19 -0500 Subject: [PATCH 03/10] test(parser): normalize host bash line numbers in differential test macOS bash 3.2 reports -c input as line 0, modern GNU bash as line 1. Compare diagnostic shape with line number normalized. --- .../tests/integration/partial_parse_tests.rs | 26 ++++++++++++++++++- 1 file changed, 25 insertions(+), 1 deletion(-) diff --git a/crates/bashkit/tests/integration/partial_parse_tests.rs b/crates/bashkit/tests/integration/partial_parse_tests.rs index e6537b914..b5b9f4e09 100644 --- a/crates/bashkit/tests/integration/partial_parse_tests.rs +++ b/crates/bashkit/tests/integration/partial_parse_tests.rs @@ -112,6 +112,26 @@ async fn child_syntax_report_is_bounded() { #[tokio::test] async fn small_child_syntax_reports_match_bash() { + // Host /bin/bash varies by platform (macOS bash 3.2 reports `-c` input as + // `line 0`, modern GNU bash reports `line 1`, matching our parser), so + // compare the diagnostic shape with the line number normalized away. + fn normalize_line(input: &[u8]) -> Vec { + let mut out = input.to_vec(); + let mut i = 0; + while i + 5 < out.len() { + if out[i..].starts_with(b"line ") { + let mut j = i + 5; + while j < out.len() && out[j].is_ascii_digit() { + j += 1; + } + if j > i + 5 { + out.splice(i + 5..j, [b'#']); + } + } + i += 1; + } + out + } for script in ["if then fi", "echo kept\nif then", "for x ; wat"] { let expected = std::process::Command::new("bash") .args(["-c", script, "CASE"]) @@ -124,6 +144,10 @@ async fn small_child_syntax_reports_match_bash() { .unwrap(); assert_eq!(result.exit_code, expected.status.code().unwrap()); assert_eq!(result.stdout.as_bytes(), expected.stdout); - assert_eq!(result.stderr.as_bytes(), expected.stderr); + assert_eq!( + normalize_line(result.stderr.as_bytes()), + normalize_line(&expected.stderr), + "script: {script:?}" + ); } } From db5c74da99753b9a60167d2c97f46938aed6989d Mon Sep 17 00:00:00 2001 From: Mykhailo Chalyi Date: Sat, 10 Oct 2026 23:21:29 -0500 Subject: [PATCH 04/10] fix(parser): truncate shell name in syntax diagnostics, add scaled regression A long -c ARG0 stamped on every report line could evict the structural payload out of the 1 KiB diagnostic budget. Truncate the shell name to 64 chars first so 'syntax error ...' always survives; renumber the threat entry to TM-DOS-136 (main already owns 135 via #2674); add a bounded-child regression exercising the exact finding shape (multiline token x long ARG0) at two scales. --- crates/bashkit/docs/threat-model.md | 2 +- crates/bashkit/src/error.rs | 36 ++++++- crates/bashkit/src/parser/mod.rs | 2 +- .../tests/integration/threat_model_tests.rs | 97 +++++++++++++++++++ knowledge/foundations/parser.md | 2 +- knowledge/security/threat-model.md | 2 +- 6 files changed, 134 insertions(+), 7 deletions(-) diff --git a/crates/bashkit/docs/threat-model.md b/crates/bashkit/docs/threat-model.md index 196b709ab..bf8c23d3e 100644 --- a/crates/bashkit/docs/threat-model.md +++ b/crates/bashkit/docs/threat-model.md @@ -129,7 +129,7 @@ through configurable limits. | Pattern substitution (TM-DOS-127) | `${x//*a*/b}` over a long value | Globs run as a size-capped linear-time regex; extglob search capped at 10,000 attempts | MITIGATED | | Unbounded builtin output (TM-DOS-058) | `seq 1 1000000` produces 1M lines | Add `max_stdout_bytes` limit | **MITIGATED** | | Silent truncation at builtin caps (TM-DOS-109) | `seq 200000`, an awk loop past its cap, or an oversized `sprintf` expression returns incomplete output with exit 0 | Caps report `: limit () exceeded` on stderr and exit non-zero; awk caps and formatting errors are fatal | **MITIGATED** | -| Syntax diagnostic amplification (TM-DOS-135) | A multiline unexpected token and long `bash -c` ARG0 multiply diagnostic storage before stderr capture | Unexpected-token messages and complete syntax reports are capped at 1 KiB during construction; overflow stops formatting and appends `[diagnostic truncated]`. Short reports retain Bash text and syntax-error exit status | **MITIGATED** | +| Syntax diagnostic amplification (TM-DOS-136) | A multiline unexpected token and long `bash -c` ARG0 multiply diagnostic storage before stderr capture | Unexpected-token messages and complete syntax reports are capped at 1 KiB during construction; overflow stops formatting and appends `[diagnostic truncated]`. Short reports retain Bash text and syntax-error exit status | **MITIGATED** | | In-builtin memory growth (TM-DOS-110) | `awk 'BEGIN { s = "x"; while (1) s = s s }'` or `jq -n '"x" \| until(false; . + .)'` allocates until the host aborts | awk checks each string against a 16 MiB cap before allocating it, caps `$N` field indexes, and caps total variable memory at `max_live_intermediate_bytes` (fatal, exit 2). jq meters every live string, array and object against the same limit and fails before growing (exit 5); non-emitting jq loops and pure recursion stop at the timeout; non-tail recursion hits a live-context ceiling (64) before host stack exhaustion | **MITIGATED** | | Arrays bypass the retained-memory budget (TM-DOS-114) | Ten array slots each holding a 4 MiB string retain ~40 MB past the 10 MB variable-byte budget | Array keys and values charge `max_total_variable_bytes` alongside scalars, are released on unset/replacement/scope pop, and an over-budget write fails execution. Pending indexed compound-assignment keys, values, fields and containers also hold shared intermediate-memory leases, including repeated subscripts | **MITIGATED** | | `$( String { - // THREAT[TM-DOS-135]: stop before growth, including temporary formatting. + // THREAT[TM-DOS-136]: stop before growth, including temporary formatting. + // The shell name (`-c` ARG0) is attacker-controlled and stamped on every + // report line: truncate it first so a long ARG0 cannot evict the + // structural payload (`syntax error ...`, token excerpt, marker) out of + // the byte budget. Short names print verbatim. + let shell: std::borrow::Cow<'_, str> = if who.chars().count() > MAX_SHELL_NAME_CHARS { + let mut end = MAX_SHELL_NAME_CHARS; + while !who.is_char_boundary(end) { + end -= 1; + } + std::borrow::Cow::Owned(format!("{}...", &who[..end])) + } else { + std::borrow::Cow::Borrowed(who) + }; + let who = shell.as_ref(); let mut out = Diagnostic::new(); let _ = (|| -> fmt::Result { let prefix = if message.starts_with("syntax error") || message.starts_with("unexpected EOF") @@ -332,6 +346,10 @@ pub(crate) fn syntax_report( } const MAX_DIAGNOSTIC_BYTES: usize = 1024; +/// Display width for the shell name (`-c` ARG0) inside one diagnostic. +/// Short names print verbatim; longer ones are cut here so a long ARG0 +/// cannot evict the structural payload out of the byte budget. +const MAX_SHELL_NAME_CHARS: usize = 64; const DIAGNOSTIC_TRUNCATED: &str = "[diagnostic truncated]\n"; /// Fixed-capacity writer: formatting cannot allocate the full untrusted text. @@ -441,8 +459,7 @@ mod tests { #[test] fn syntax_report_bounds_each_untrusted_component() { for (who, source, message, line) in [ - ("é".repeat(2048), String::new(), "syntax error".into(), 0), - ("bash".into(), String::new(), "界".repeat(2048), 0), + ("bash".to_owned(), String::new(), "界".repeat(2048), 0), ( "bash".into(), "界".repeat(2048), @@ -463,6 +480,19 @@ mod tests { } } + #[test] + fn syntax_report_long_shell_name_keeps_payload() { + // A long `-c` ARG0 is truncated first: the structural payload must + // survive inside the byte budget, with the marker only if the rest + // still overflows. + let report = syntax_report(&"B".repeat(8192), "", "syntax error", 1, 1); + assert!(report.len() <= MAX_DIAGNOSTIC_BYTES); + assert!(report.contains("syntax error")); + assert!(report.contains("...")); + let short = syntax_report("CASE", "", "syntax error", 1, 1); + assert!(short.contains("CASE"), "short names print verbatim"); + } + #[test] fn syntax_report_preserves_line_numbers_and_exact_boundary() { assert_eq!( diff --git a/crates/bashkit/src/parser/mod.rs b/crates/bashkit/src/parser/mod.rs index 39936d00f..b68384495 100644 --- a/crates/bashkit/src/parser/mod.rs +++ b/crates/bashkit/src/parser/mod.rs @@ -345,7 +345,7 @@ impl<'a> Parser<'a> { let line = self.current_span.start.line; let near = |token: &str| { Some(Error::parse_at( - // THREAT[TM-DOS-135]: a quoted token may span the whole input. + // THREAT[TM-DOS-136]: a quoted token may span the whole input. crate::error::bounded_diagnostic(format_args!( "syntax error near unexpected token `{token}'" )), diff --git a/crates/bashkit/tests/integration/threat_model_tests.rs b/crates/bashkit/tests/integration/threat_model_tests.rs index 9cf3d67f7..69c9e47f0 100644 --- a/crates/bashkit/tests/integration/threat_model_tests.rs +++ b/crates/bashkit/tests/integration/threat_model_tests.rs @@ -78,6 +78,103 @@ mod resource_exhaustion { } } + /// Runs argv (a `-c` script plus trailing `$0`/ARG0 words) in a child + /// CLI process under the same bounds contract as [`run_cli_bounded`]: + /// wall-clock watchdog, 8 KiB output cap, real exit code preserved. + fn run_cli_argv_bounded(argv: &[&str], limit: Duration) -> BoundedRun { + use std::io::Read; + let mut child = Command::new(bashkit_cli_path()); + child + .args(argv) + .stdin(Stdio::null()) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()); + let mut child = child.spawn().expect("spawn bounded bashkit CLI"); + let deadline = Instant::now() + limit; + loop { + match child.try_wait().expect("poll bounded CLI") { + Some(status) => { + let mut stdout = String::new(); + let mut stderr = String::new(); + child + .stdout + .take() + .unwrap() + .read_to_string(&mut stdout) + .unwrap(); + child + .stderr + .take() + .unwrap() + .read_to_string(&mut stderr) + .unwrap(); + return BoundedRun { + exit_code: status.code(), + stdout: stdout.chars().take(8192).collect(), + stderr: stderr.chars().take(8192).collect(), + timed_out: false, + }; + } + None => { + if Instant::now() >= deadline { + child.kill().ok(); + child.wait().ok(); + return BoundedRun { + exit_code: None, + stdout: String::new(), + stderr: String::new(), + timed_out: true, + }; + } + std::thread::sleep(Duration::from_millis(5)); + } + } + } + } + + /// TM-DOS-136: scaled multiline token x long ARG0 stays bounded in a + /// child. The exact finding shape: `for x "` plus N `a`+newline lines + /// plus the closing quote (the parser expects `do`), invoked with a long + /// `-c` ARG0. Diagnostic storage used to grow as token lines x ARG0 + /// before stderr capture. Both scales must exit 2 with a Bash-compatible + /// shape and flat <=1 KiB stderr, proving the report no longer grows + /// with the product. Never runs in the test process. + #[test] + fn syntax_diagnostic_multiline_times_arg0_is_bounded_in_child() { + for token_lines in [200usize, 2000usize] { + let mut script = String::from("for x \""); + for _ in 0..token_lines { + script.push('a'); + script.push('\n'); + } + script.push('"'); + let arg0 = "B".repeat(8192); + let run = run_cli_argv_bounded(&["-c", &script, &arg0], Duration::from_secs(20)); + assert!( + !run.timed_out, + "token_lines={token_lines}: child hit the 20s watchdog" + ); + assert_eq!( + run.exit_code, + Some(2), + "token_lines={token_lines}: raw child exit code" + ); + assert!( + run.stdout.is_empty(), + "token_lines={token_lines}: stdout must stay empty" + ); + assert!( + run.stderr.contains("syntax error"), + "token_lines={token_lines}: keep the Bash-compatible shape" + ); + assert!( + run.stderr.len() <= 1024, + "token_lines={token_lines}: {} stderr bytes, want <= 1024", + run.stderr.len() + ); + } + } + /// TM-DOS-135: hostile indirect cycles are refused in a bounded child. /// Exact finding reproducer plus the mutual variant. Both run in a /// child CLI under a wall-clock watchdog (never in the test runner: diff --git a/knowledge/foundations/parser.md b/knowledge/foundations/parser.md index 6d7490b04..871aed79c 100644 --- a/knowledge/foundations/parser.md +++ b/knowledge/foundations/parser.md @@ -158,7 +158,7 @@ TM-ESC-032), and the deferred error rejects the script the way bash does. Process substitution keeps its part for the same reason, and hard-errors on budget failures (TM-DOS-021). -**Bounded syntax diagnostics (TM-DOS-135).** Unexpected-token messages and +**Bounded syntax diagnostics (TM-DOS-136).** Unexpected-token messages and complete `Error::syntax_report` results are capped at 1 KiB during construction, including `[diagnostic truncated]` and its trailing newline when text is omitted. A fixed-capacity UTF-8 writer stops formatting on the first overflow; neither a diff --git a/knowledge/security/threat-model.md b/knowledge/security/threat-model.md index ece714486..f09fb23da 100644 --- a/knowledge/security/threat-model.md +++ b/knowledge/security/threat-model.md @@ -300,7 +300,7 @@ runaway scripts without permanently breaking the session. | TM-DOS-130 | Array and arithmetic report growth | `$(( $(( ... )) ))` nested inside arithmetic, thousands of `${a[-9]}` reads in one command, `${!r}` with a huge invalid target | Nested `$((` in arithmetic retains its 32-occurrence admission cap and shares the arithmetic depth/fuel budget with dollar subscripts (`expression recursion level exceeded`); queued `bad array subscript` warnings stop at 64 KiB per command; an invalid indirect target is echoed truncated to 256 chars. Regression: `array_and_arith_reports_are_bounded` | **MITIGATED** | | TM-DOS-131 | History file growth and reads | `history -a` in a loop, `HISTSIZE` huge, a `bash -i` child reading a giant `$HISTFILE`, `history -r` on a multi-GB file | The history list stays under `max_history_entries`/`max_history_bytes` whatever `$HISTSIZE` says (TM-DOS-094 caps apply to every recorded line); `$HISTFILE` reads are refused past `max_input_bytes`; files are read and written through the VFS only, so its size and write limits apply; a `bash -i` child keeps its own list and the parent's comes back after it. Regression: `history_lines_stay_bounded`, `histfile_read_is_capped` | **MITIGATED** | | TM-DOS-132 | Completion spec and key binding growth | `complete -W "$big" nameN` or `bind -x '"\C-xN": cmd'` in a loop keeps per-shell state growing | At most 1024 completion specs of at most 64 KiB option text each, and at most 1024 `bind` changes of at most 4 KiB each, per interpreter; past a cap the builtin fails with status 1. The state is built on first use, lives in the interpreter (never global, so tenants never share it) and is copied into subshells like other shell state. Regression: `completion_specs_are_bounded`, `binding_changes_are_capped` | **MITIGATED** | -| TM-DOS-135 | Syntax diagnostic amplification | A multiline unexpected token combined with a long attacker-controlled `bash -c` ARG0 repeats the shell name for every token line, allocating quadratically before stderr capture | Unexpected-token messages and complete syntax reports use a fixed-capacity UTF-8 writer capped at 1 KiB before any append, stop formatting on overflow, and include `[diagnostic truncated]`; short reports preserve Bash semantics. Regressions: `error::tests::syntax_report_*`, `parser_bounds_unexpected_token_before_copying`, and `partial_parse_tests::{child_syntax_report_is_bounded, small_child_syntax_reports_match_bash}` | **MITIGATED** | +| TM-DOS-136 | Syntax diagnostic amplification | A multiline unexpected token combined with a long attacker-controlled `bash -c` ARG0 repeats the shell name for every token line, allocating quadratically before stderr capture | Unexpected-token messages and complete syntax reports use a fixed-capacity UTF-8 writer capped at 1 KiB before any append, stop formatting on overflow, and include `[diagnostic truncated]`; short reports preserve Bash semantics. Regressions: `error::tests::syntax_report_*`, `parser_bounds_unexpected_token_before_copying`, and `partial_parse_tests::{child_syntax_report_is_bounded, small_child_syntax_reports_match_bash}` | **MITIGATED** | | TM-DOS-101 | yq structured-data amplification | YAML aliases can expand exponentially during deserialization; deep YAML/JSON, document floods, jaq generators, and YAML re-serialization can consume stack, CPU, or memory beyond the source size | Reject YAML alias tokens with a bounded lexical pass before deserialization; VFS/stdin and aggregate input budgets; serde_yaml_ng recursion cap 128 plus Bashkit depth 100; 4096-document cap; shared jaq work/deadline/output limits; post-serialization stdout cap; YAML+JSON depth/document/input/output regressions plus `yq_fuzz` and arbitrary-input proptest | **MITIGATED** | | TM-DOS-133 | Unbounded prompt expansion | `x='${x@P}'; echo "${x@P}"`, mutual variable cycles or cycles through command substitutions repeatedly create shallow parsers and recursively poll futures | Prompt reparses count cumulative runtime depth capped at `min(max_ast_depth, 8)` before recursive polling; descendants inherit depth. Each prompt charges the shared work and aggregate-input budget, checking cancellation and deadlines before decoding; child parsers share the request budget and fail closed on poison. Depth unwinds on returned errors and resets at host execution boundaries. Regressions: `prompt_resource_tests`, `prompt_checks_cancellation_before_decoding`, `prompt_checks_shared_deadline_before_decoding`, `prompt_error_unwinds_depth` | **MITIGATED** | | TM-DOS-135 | Indirect array-target operand recursion | `r='a[0]:-${!r}]'; echo "${!r}"`: the bracket validator accepted trailing operator/nesting text, so desugar re-parsed `${a[0]:-${!r}]}` and the unset-element default re-entered indirect expansion without bound (stack overflow). Numbered to avoid collision with the adjacent tilde-budget entry | A bracket target is valid only when the first complete `[...]` reference (nesting- and quote-aware) consumes the whole value; any trailing text is refused with `invalid variable name`, like modern bash 5.x (older 3.2 ignored the tail). Refusal keeps a nested `${!...}` from ever reaching the desugar re-parse while valid nested subscripts (`a[b[0]]`) resolve whole; `$(`/backquote/process-substitution guards unchanged. Regressions: `indirect_array_boundary_rejects_trailing_operators`, `indirect_hostile_cycles_rejected_in_bounded_child`, `indirect_nested_indexed_matches_real_bash` | **MITIGATED** | From a57c5d96c0d88226e848203e4a7484a973ba7599 Mon Sep 17 00:00:00 2001 From: Mykhailo Chalyi Date: Sat, 10 Oct 2026 23:44:21 -0500 Subject: [PATCH 05/10] fix(parser): keep shell-name cut above realistic script paths The 64-char cut mangled long script paths (breaking the oracle's path replacement and hiding which script failed). Widen to 256 chars with a 16-char tail: realistic paths print verbatim with attribution suffixes (': eval') intact, while a kilobyte-scale ARG0 still cannot evict the structural payload out of the 1 KiB total. The total stays the enforced bound. --- crates/bashkit/src/error.rs | 41 +++++++++++++++++++++++++++++++------ 1 file changed, 35 insertions(+), 6 deletions(-) diff --git a/crates/bashkit/src/error.rs b/crates/bashkit/src/error.rs index 71a32367b..de787629c 100644 --- a/crates/bashkit/src/error.rs +++ b/crates/bashkit/src/error.rs @@ -301,11 +301,22 @@ pub(crate) fn syntax_report( // structural payload (`syntax error ...`, token excerpt, marker) out of // the byte budget. Short names print verbatim. let shell: std::borrow::Cow<'_, str> = if who.chars().count() > MAX_SHELL_NAME_CHARS { - let mut end = MAX_SHELL_NAME_CHARS; - while !who.is_char_boundary(end) { - end -= 1; + let head_len = MAX_SHELL_NAME_CHARS - SHELL_NAME_TAIL_CHARS; + let mut head_end = head_len; + while !who.is_char_boundary(head_end) { + head_end -= 1; } - std::borrow::Cow::Owned(format!("{}...", &who[..end])) + let mut tail_start = who.len(); + for _ in 0..SHELL_NAME_TAIL_CHARS { + if tail_start == 0 { + break; + } + tail_start -= 1; + while !who.is_char_boundary(tail_start) { + tail_start -= 1; + } + } + std::borrow::Cow::Owned(format!("{}...{}", &who[..head_end], &who[tail_start..])) } else { std::borrow::Cow::Borrowed(who) }; @@ -348,8 +359,14 @@ pub(crate) fn syntax_report( const MAX_DIAGNOSTIC_BYTES: usize = 1024; /// Display width for the shell name (`-c` ARG0) inside one diagnostic. /// Short names print verbatim; longer ones are cut here so a long ARG0 -/// cannot evict the structural payload out of the byte budget. -const MAX_SHELL_NAME_CHARS: usize = 64; +/// cannot evict the structural payload out of the byte budget. The cut keeps +/// the head and the tail: the tail carries attribution suffixes such as +/// `: eval` (bash prints `