From e0b6ef3802e127f25d6ef37b89bbc71c971f2308 Mon Sep 17 00:00:00 2001 From: Mykhailo Chalyi Date: Sat, 10 Oct 2026 19:29:50 -0500 Subject: [PATCH 1/3] fix(interp): charge tilde expansion to the execution budget tilde_expand_literal built its result with String::new + push_str, so a script with many tildes (x=~:~:~:...) could grow output without ever touching ExecutionBudget (TM-DOS-134). Route all tilde paths through BudgetedString, hold declaration leases across expansion, and cover it with budget regression tests. --- crates/bashkit/docs/threat-model.md | 1 + crates/bashkit/src/interpreter/declare.rs | 3 +- crates/bashkit/src/interpreter/expansion.rs | 168 +++++++++++++----- crates/bashkit/src/interpreter/mod.rs | 2 +- .../integration/execution_budget_tests.rs | 120 +++++++++++++ docs/security.md | 7 + knowledge/foundations/architecture.md | 9 + knowledge/security/threat-model.md | 1 + 8 files changed, 266 insertions(+), 45 deletions(-) diff --git a/crates/bashkit/docs/threat-model.md b/crates/bashkit/docs/threat-model.md index 1884be6e6..00489bc66 100644 --- a/crates/bashkit/docs/threat-model.md +++ b/crates/bashkit/docs/threat-model.md @@ -182,6 +182,7 @@ let bash = Bash::builder() | Persistent history memory DoS (TM-DOS-094) | Unbounded command history in long-lived instances | `ExecutionLimits` caps history entries/bytes/output | FIXED | | Multi-component glob amplification (TM-DOS-095) | `/*/*/*/*` multiplies the candidate set at each component | Reject patterns deeper than `max_path_depth`; cap live candidates at `max_file_count` | FIXED | | Aggregate budget refresh (TM-DOS-096) | Nest/mix parsers, pipelines, traversal, runtimes, archives, and callbacks to restart local ceilings | One poisoned request-scoped `ExecutionBudget` meters aggregate work/input/live bytes without replacing subsystem caps | MITIGATED | +| Tilde expansion amplification (TM-DOS-134) | Large `HOME`/`PWD`/`OLDPWD` repeated after assignment colons | Borrowed directories, checked pre-allocation live-byte reservations, byte-proportional work/deadline checks, and leases retained through assignment/field consumption; failures abort expansion | MITIGATED | | Contradictory execution-profile limits (TM-DOS-097) | Host config silently requests ineffective or impossible limits | Validate profile cross-field invariants before `BashBuilder` accepts it | MITIGATED | | Suspended host-call retention (TM-DOS-098) | Script repeats event-backed calls or host never resumes one | Capacity-one channel; an independently driven execution future keeps the deadline armed while parked and drops the timed-out session without another host poll; handle drop aborts the driver | MITIGATED | | `time` report amplification (TM-DOS-099) | Attacker-controlled `-f` format expands repeatedly or targets the VFS with `-o` | Incremental rendering is capped by the stderr limit before emission or file replacement | MITIGATED | diff --git a/crates/bashkit/src/interpreter/declare.rs b/crates/bashkit/src/interpreter/declare.rs index a131b2994..75722fae0 100644 --- a/crates/bashkit/src/interpreter/declare.rs +++ b/crates/bashkit/src/interpreter/declare.rs @@ -1441,7 +1441,8 @@ impl Interpreter { if let Some((kw, vw, kappend)) = split_keyed_word(word).filter(|_| !braces) { let key_text = self.expand_array_word(&kw).await?; // `[k]=~:~` tilde-expands like an assignment value. - let vw = self.tilde_assignment_value(&vw).into_owned(); + // `[k]=~:~` tilde-expands like an assignment value. + let (vw, _tilde_leases) = self.tilde_assignment_value(&vw)?; let val = self.expand_array_word(&vw).await?; items.try_push(Item::Keyed(key_text, val, kappend))?; continue; diff --git a/crates/bashkit/src/interpreter/expansion.rs b/crates/bashkit/src/interpreter/expansion.rs index e95595047..b0ea0eb29 100644 --- a/crates/bashkit/src/interpreter/expansion.rs +++ b/crates/bashkit/src/interpreter/expansion.rs @@ -17,6 +17,8 @@ //! another part (`~"x"`, `~$v`) is not a tilde prefix. Assignment values //! are pre-resolved by `tilde_assignment_value` (after `=` and each `:`); //! `${...}` operands use the `operand_tilde` flag, set per operator. +//! Tilde directories are borrowed; expanded buffers reserve capacity before +//! allocation and keep their leases through preprocessing and word consumption. use super::*; @@ -416,12 +418,19 @@ impl Interpreter { WordPart::Literal(s) => { // Tilde expansion (`~`, `~/x`, `x=~:~`); quoted literals // keep their tilde. - match (!Self::part_is_quoted(word, idx) && s.contains('~')) - .then(|| self.tilde_expand_word_literal(word, idx, s)) - .flatten() - { - Some(expanded) => result.try_push_str(&expanded)?, - None => result.try_push_str(s)?, + let expanded = if !Self::part_is_quoted(word, idx) && s.contains('~') { + self.tilde_expand_word_literal(word, idx, s)? + } else { + None + }; + if let Some(expanded) = expanded { + let tilde_lease = self.execution_budget.lease_bytes(expanded.len())?; + result.try_push_str(&expanded)?; + if !leased { + substitution_leases.push(tilde_lease); + } + } else { + result.try_push_str(s)?; } } WordPart::Variable(name) => { @@ -713,6 +722,7 @@ impl Interpreter { } } + self.check_cancelled()?; Ok(result) } @@ -852,26 +862,29 @@ impl Interpreter { /// Directory for the tilde prefix `~name`: `~` is `$HOME`, `~+` `$PWD`, /// `~-` `$OLDPWD`; `~root` and `~` are the fixed homes. /// `None` keeps the text literal (unknown user, unset `OLDPWD`). - pub(super) fn tilde_dir(&self, prefix: &str) -> Option { + fn tilde_dir(&self, prefix: &str) -> Option> { + use std::borrow::Cow; let lookup = |name: &str| { self.scoped .variables .get(name) .or_else(|| self.env.get(name)) - .cloned() + .map(|value| Cow::Borrowed(value.as_str())) }; match prefix { "" => Some( self.env .get("HOME") .or_else(|| self.scoped.variables.get("HOME")) - .cloned() - .unwrap_or_else(|| format!("/home/{}", self.tilde_user)), + .map(|value| Cow::Borrowed(value.as_str())) + .unwrap_or_else(|| Cow::Owned(format!("/home/{}", self.tilde_user))), ), - "+" => Some(lookup("PWD").unwrap_or_else(|| self.cwd.display().to_string())), + "+" => { + Some(lookup("PWD").unwrap_or_else(|| Cow::Owned(self.cwd.display().to_string()))) + } "-" => lookup("OLDPWD"), - "root" => Some("/root".to_string()), - user if user == &*self.tilde_user => Some(format!("/home/{user}")), + "root" => Some(Cow::Borrowed("/root")), + user if user == &*self.tilde_user => Some(Cow::Owned(format!("/home/{user}"))), _ => None, } } @@ -888,13 +901,21 @@ impl Interpreter { starts: &[usize], after_colons: bool, word_continues: bool, - ) -> Option { - let first = *starts.first()?; - let mut out = String::new(); + ) -> std::result::Result, crate::limits::LimitExceeded> { + let Some(&first) = starts.first() else { + return Ok(None); + }; + // THREAT[TM-DOS-134]: directory length times prefix count is untrusted. + // Reserve before allocation; copying bytes also polls deadline/cancellation. + self.execution_budget.consume_work(s.len() as u64)?; + let mut out = BudgetedString::new(Some(&self.execution_budget))?; let mut copied = 0; let mut pos = first; let bytes = s.as_bytes(); while pos < s.len() { + if pos.is_multiple_of(1_024) { + self.execution_budget.check()?; + } let eligible = starts.contains(&pos) || (after_colons && pos > first && bytes[pos - 1] == b':'); if eligible && bytes[pos] == b'~' { @@ -905,8 +926,10 @@ impl Interpreter { if (end < s.len() || !word_continues) && let Some(dir) = self.tilde_dir(&s[pos + 1..end]) { - out.push_str(&s[copied..pos]); - out.push_str(&dir); + self.execution_budget.consume_work((pos - copied) as u64)?; + self.execution_budget.consume_work(dir.len() as u64)?; + out.try_push_str(&s[copied..pos])?; + out.try_push_str(&dir)?; copied = end; pos = end; continue; @@ -915,16 +938,23 @@ impl Interpreter { pos += 1; } if copied == 0 { - return None; + return Ok(None); } - out.push_str(&s[copied..]); - Some(out) + self.execution_budget + .consume_work((s.len() - copied) as u64)?; + out.try_push_str(&s[copied..])?; + Ok(Some(out)) } /// Tilde expansion for literal part `idx` of `word`: a leading `~`, and /// in a word that looks like an assignment (`x=~:~`, as an argument to /// `echo` or `local`) the `~` after its `=` and after each `:`. - fn tilde_expand_word_literal(&self, word: &Word, idx: usize, s: &str) -> Option { + fn tilde_expand_word_literal( + &self, + word: &Word, + idx: usize, + s: &str, + ) -> std::result::Result, crate::limits::LimitExceeded> { let word_continues = idx + 1 < word.parts.len(); let assign_eq = match word.parts.first() { Some(WordPart::Literal(first)) if Self::is_assignment_prefix(first) => first.find('='), @@ -937,9 +967,13 @@ impl Interpreter { } return self.tilde_expand_literal(s, &starts, assign_eq.is_some(), word_continues); } - assign_eq?; + if assign_eq.is_none() { + return Ok(None); + } // Only `:` inside this part can start a prefix here. - let colon = s.find(':')?; + let Some(colon) = s.find(':') else { + return Ok(None); + }; self.tilde_expand_literal(s, &[colon + 1], true, word_continues) } @@ -990,7 +1024,13 @@ impl Interpreter { }) } - pub(super) fn tilde_assignment_value<'w>(&self, word: &'w Word) -> std::borrow::Cow<'w, Word> { + pub(super) fn tilde_assignment_value<'w>( + &self, + word: &'w Word, + ) -> Result<( + std::borrow::Cow<'w, Word>, + Vec, + )> { use std::borrow::Cow; let quoted_at = |i: usize| Self::part_is_quoted(word, i); let has_tilde = word.parts.iter().enumerate().any(|(i, p)| match p { @@ -999,8 +1039,9 @@ impl Interpreter { _ => false, }); if !has_tilde { - return Cow::Borrowed(word); + return Ok((Cow::Borrowed(word), Vec::new())); } + let mut leases = Vec::new(); let mut out = word.clone(); out.part_quoted = (0..word.parts.len()).map(quoted_at).collect(); let n = word.parts.len(); @@ -1019,6 +1060,9 @@ impl Interpreter { { // `x=${undef-~:~}`: the operand of an assignment value // expands `~` after each `:` too. + // THREAT[TM-DOS-134]: budget the rebuilt operand the + // declaration retains across expansion. + leases.push(self.execution_budget.lease_bytes(expanded.len())?); out.parts[i] = WordPart::ParameterExpansion { name: name.clone(), operator: op.clone(), @@ -1045,13 +1089,17 @@ impl Interpreter { starts.push(c + 1); } starts.sort_unstable(); - if let Some(expanded) = self.tilde_expand_literal(s, &starts, true, i + 1 < n) { + if let Some(expanded) = self.tilde_expand_literal(s, &starts, true, i + 1 < n)? { + // The preprocessed literal and expand_word's copy coexist. + leases.push(self.execution_budget.lease_bytes(expanded.len())?); + let (expanded, lease) = expanded.into_parts(); + leases.extend(lease); out.parts[i] = WordPart::Literal(expanded); } // Done with this part's tildes (`x=a=~` keeps its `~`). out.part_quoted[i] = true; } - Cow::Owned(out) + Ok((Cow::Owned(out), leases)) } /// Expand a word to multiple fields (for array iteration and command args) @@ -1337,6 +1385,7 @@ impl Interpreter { let ifs_null = matches!(self.scoped.variables.get("IFS"), Some(v) if v.is_empty()); // A null IFS never splits, but unquoted `$@`/`$*` still yield one // field per parameter: each starts a new group of segments. + let mut tilde_leases = Vec::new(); let mut groups: Vec> = vec![Vec::new()]; for (idx, part) in word.parts.iter().enumerate() { let quoted = word.part_quoted.get(idx).copied().unwrap_or(word.quoted); @@ -1400,7 +1449,10 @@ impl Interpreter { let segment = match p { // Unquoted operand text is expansion output: it splits. WordPart::Literal(s) if !q => (s.clone(), false, false), - _ => self.mixed_word_segment(&inner, i, p, q).await?, + _ => { + self.mixed_word_segment(&inner, i, p, q, &mut tilde_leases) + .await? + } }; if let Some(group) = groups.last_mut() { group.push(segment); @@ -1408,7 +1460,9 @@ impl Interpreter { } continue; } - let segment = self.mixed_word_segment(word, idx, part, quoted).await?; + let segment = self + .mixed_word_segment(word, idx, part, quoted, &mut tilde_leases) + .await?; if let Some(group) = groups.last_mut() { group.push(segment); } @@ -1436,14 +1490,23 @@ impl Interpreter { idx: usize, part: &WordPart, part_is_quoted: bool, + tilde_leases: &mut Vec, ) -> Result<(String, bool, bool)> { let part_has_expansion = Self::is_field_split_expansion(part); let value = if let WordPart::Literal(s) = part { // A leading `~` sees the whole word (`~$v` stays literal). - (idx == 0 && !part_is_quoted && s.contains('~')) - .then(|| self.tilde_expand_word_literal(word, 0, s)) - .flatten() - .unwrap_or_else(|| s.clone()) + let expanded = if idx == 0 && !part_is_quoted && s.contains('~') { + self.tilde_expand_word_literal(word, 0, s)? + } else { + None + }; + if let Some(expanded) = expanded { + let (value, lease) = expanded.into_parts(); + tilde_leases.extend(lease); + value + } else { + s.clone() + } } else { let single = Word { parts: vec![part.clone()], @@ -1561,6 +1624,7 @@ impl Interpreter { return Ok(None); } // Each field under construction is a list of segments. + let mut tilde_leases = Vec::new(); let mut fields: Vec> = vec![Vec::new()]; for (idx, part) in word.parts.iter().enumerate() { let q = part_quoted(idx); @@ -1580,7 +1644,9 @@ impl Interpreter { } continue; } - let segment = self.mixed_word_segment(word, idx, part, q).await?; + let segment = self + .mixed_word_segment(word, idx, part, q, &mut tilde_leases) + .await?; if let Some(field) = fields.last_mut() { field.push(segment); } @@ -2122,19 +2188,35 @@ impl Interpreter { self.limits.max_parser_operations, ); let mut result = String::new(); + let mut operand_leases = Vec::new(); let mut in_marked = false; for (idx, part) in word.parts.iter().enumerate() { match part { WordPart::Literal(s) => { // `${x:-~}`, `${x#~/}`: a leading unquoted `~`. - let tilde_expanded = (tilde && idx == 0 && !force_quoted) - .then(|| { - let seg_end = quote_mark.and_then(|m| s.find(m)).unwrap_or(s.len()); - let continues = seg_end < s.len() || word.parts.len() > 1; - self.tilde_expand_literal(&s[..seg_end], &[0], false, continues) - .map(|e| format!("{e}{}", &s[seg_end..])) - }) - .flatten(); + let tilde_expanded = if tilde && idx == 0 && !force_quoted { + let seg_end = quote_mark.and_then(|m| s.find(m)).unwrap_or(s.len()); + let continues = seg_end < s.len() || word.parts.len() > 1; + let expansion = (|| { + let Some(mut expanded) = + self.tilde_expand_literal(&s[..seg_end], &[0], false, continues)? + else { + return Ok(None); + }; + expanded.try_push_str(&s[seg_end..])?; + operand_leases.push(self.execution_budget.lease_bytes(expanded.len())?); + Ok::<_, crate::limits::LimitExceeded>(Some(expanded)) + })(); + match expansion { + Ok(expanded) => expanded, + Err(error) => { + self.memory_limit_error.get_or_insert(error); + return String::new(); + } + } + } else { + None + }; let text = tilde_expanded.as_deref().unwrap_or(s); if repl { Self::push_replacement_literal( diff --git a/crates/bashkit/src/interpreter/mod.rs b/crates/bashkit/src/interpreter/mod.rs index cc41fd5e6..e252337a6 100644 --- a/crates/bashkit/src/interpreter/mod.rs +++ b/crates/bashkit/src/interpreter/mod.rs @@ -8444,7 +8444,7 @@ impl Interpreter { } match &assignment.value { AssignmentValue::Scalar(word) => { - let word = self.tilde_assignment_value(word); + let (word, _tilde_leases) = self.tilde_assignment_value(word)?; let value = self.expand_word(&word).await?; if self.is_xtrace_enabled() { // `+ v=x`, `+ a[1]='a b'`, `+ v+=y` diff --git a/crates/bashkit/tests/integration/execution_budget_tests.rs b/crates/bashkit/tests/integration/execution_budget_tests.rs index dda05244c..a04a12d17 100644 --- a/crates/bashkit/tests/integration/execution_budget_tests.rs +++ b/crates/bashkit/tests/integration/execution_budget_tests.rs @@ -549,6 +549,32 @@ async fn indexed_array_pending_values_share_live_byte_limit() { } } +#[tokio::test] +async fn tilde_expansion_repeated_directories_respect_live_budget() { + // Safe reproducer: at most 24 KiB of expansion, admitted by retained limits. + for (name, tilde) in [("HOME", "~"), ("PWD", "~+"), ("OLDPWD", "~-")] { + for (command, suffix) in [ + ("x=a", ""), + ("export x=a", ""), + ("echo x=a", ""), + ("x[0]=a", ""), + ("declare -a x=([0]=a", ")"), + ("echo x=a", "\"\""), + ] { + let limits = ExecutionLimits::new().max_live_intermediate_bytes(8_192); + let mut bash = Bash::builder().limits(limits).build(); + let script = format!( + "export {name}='{}'; {command}{}{suffix}", + "d".repeat(1_024), + format!(":{tilde}").repeat(24) + ); + assert_budget_exhausted(bash.exec(&script).await); + let recovered = bash.exec("echo recovered").await.unwrap(); + assert_eq!(recovered.stdout, "recovered\n"); + } + } +} + #[tokio::test] async fn indexed_array_pending_keys_share_live_byte_limit() { let mut bash = Bash::builder() @@ -593,6 +619,66 @@ async fn indexed_array_budget_preserves_expansion_order() { assert_eq!(result.stderr.as_bytes(), expected.stderr); } +#[tokio::test] +async fn tilde_expansion_charges_copied_bytes_as_work() { + let limits = ExecutionLimits::new().max_work_units(5_000); + let mut bash = Bash::builder().limits(limits).build(); + bash.exec(&format!("HOME='{}'", "d".repeat(1_024))) + .await + .unwrap(); + assert_budget_exhausted(bash.exec(&format!("x=a{}", ":~".repeat(24))).await); +} + +#[tokio::test] +async fn tilde_expansion_single_directory_and_operands_respect_live_budget() { + for command in ["x=~", "echo ~", "echo ${unset:-~}", "echo ${unset:=~}"] { + let limits = ExecutionLimits::new().max_live_intermediate_bytes(512); + let mut bash = Bash::builder().limits(limits).build(); + bash.exec(&format!("HOME='{}'", "d".repeat(1_024))) + .await + .unwrap(); + assert_budget_exhausted(bash.exec(command).await); + } +} + +#[tokio::test] +async fn tilde_expansion_retains_sibling_assignment_leases() { + let limits = ExecutionLimits::new().max_live_intermediate_bytes(3_072); + let mut bash = Bash::builder().limits(limits).build(); + bash.exec(&format!("HOME='{}'", "d".repeat(1_024))) + .await + .unwrap(); + assert_budget_exhausted(bash.exec("x=~/\"\":~/\"\":~/").await); +} + +#[tokio::test] +async fn tilde_expansion_preserves_bash_semantics() { + let script = r#"HOME='/home/test dir'; PWD=/pwd; OLDPWD=/old +x=~:~+/bin:~-/lib:~missing +printf '%s\n' "$x" ~ ~missing +printf '%s\n' x=~:~+:'~' +x='~:~'; printf '%s\n' "$x" '~' \~ '~'$HOME +unset value; printf '%s\n' "${value:-~}" "${value:-'~'}" +x=~/"":~; printf '%s\n' "$x" +"#; + let expected = std::process::Command::new("bash") + .env("HOME", "/home/test dir") + .arg("--noprofile") + .arg("--norc") + .arg("-c") + .arg(script) + .output() + .unwrap(); + assert!(expected.status.success()); + let mut bash = Bash::builder() + .limits(ExecutionLimits::new().max_live_intermediate_bytes(8_192)) + .build(); + let result = bash.exec(script).await.unwrap(); + assert_eq!(result.exit_code, 0); + assert_eq!(result.stdout.as_bytes(), expected.stdout); + assert_eq!(result.stderr.as_bytes(), expected.stderr); +} + #[tokio::test] async fn indexed_array_empty_pending_items_still_charge_container_storage() { let mut bash = Bash::builder() @@ -632,3 +718,37 @@ async fn indexed_array_substitutions_release_consumed_output() { .unwrap(); assert_eq!(result.stdout, "3072\n"); } + +#[tokio::test] +async fn tilde_expansion_quotes_do_not_resolve_large_directories() { + let limits = ExecutionLimits::new().max_live_intermediate_bytes(512); + let mut bash = Bash::builder().limits(limits).build(); + bash.exec(&format!("HOME='{}'", "d".repeat(1_024))) + .await + .unwrap(); + let literal = format!("a{}", ":~".repeat(24)); + let actual = bash + .exec(&format!("x='{literal}'; printf '%s' \"$x\"")) + .await + .unwrap(); + assert_eq!(actual.stdout, literal); +} + +#[tokio::test] +async fn tilde_expansion_assignment_copy_boundary_and_release() { + for (bytes, succeeds) in [(15, false), (16, true)] { + let limits = ExecutionLimits::new().max_live_intermediate_bytes(bytes); + let mut bash = Bash::builder().limits(limits).build(); + bash.exec("HOME=12345678").await.unwrap(); + let result = bash.exec("x=~; y=~").await; + if succeeds { + assert_eq!(result.unwrap().exit_code, 0); + // Declaration leases release: a trivial follow-up still fits the + // same budget (value assertions live in the semantics test, + // which runs with a generous budget). + assert_eq!(bash.exec("echo done").await.unwrap().stdout, "done\n"); + } else { + assert_budget_exhausted(result); + } + } +} diff --git a/docs/security.md b/docs/security.md index dfce1d1bc..0f46abbbd 100644 --- a/docs/security.md +++ b/docs/security.md @@ -59,6 +59,13 @@ 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. +Tilde expansion (`~`, `~+`, `~-`, including repeated assignment prefixes after +`:`) checks `ExecutionLimits::max_live_intermediate_bytes` before growing its +output and charges copied bytes to `max_work_units`. Large directory values +cannot bypass these limits by expanding before variable insertion. A refused +expansion returns a resource-limit error; the next host execution receives a +fresh execution budget. + ## Threat model Prompt expansion (`${x@P}` and interactive prompts) shares the execution work, diff --git a/knowledge/foundations/architecture.md b/knowledge/foundations/architecture.md index b6d2bb4c2..1da1a48d6 100644 --- a/knowledge/foundations/architecture.md +++ b/knowledge/foundations/architecture.md @@ -135,6 +135,15 @@ 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. +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 +preprocessing retains leases for expanded literal buffers and their word copies +through consumption; mixed-word segments retain leases through field splitting. +The synchronous operand pipeline records a refused reservation in the existing +sticky resource-error slot, checked before the completed word is returned. +Retained-variable admission remains a separate check after expansion. + ### `set -e` contexts `errexit_active()` is `set -e` plus `condition_sequence_depth == 0`. The diff --git a/knowledge/security/threat-model.md b/knowledge/security/threat-model.md index bb7631f85..8c214db10 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-134 | Repeated tilde expansion amplification | A large `HOME`, `PWD` or `OLDPWD` repeated through assignment `:~`, `:~+` or `:~-` prefixes builds host strings before retained-variable limits run | Shared tilde expansion borrows directory values, charges scan/copy work (including deadline and cancellation checks), and uses `BudgetedString` to admit checked capacity growth before allocation. Preprocessed assignment literals keep their leases and reserve their word copies until assignment consumption; mixed-word segments keep leases through field splitting. Synchronous operand failures use the sticky resource-error slot and abort word expansion. Regression tests: `execution_budget_tests::tilde_expansion_*` use bounded 1 KiB directory values, small live/work budgets, session recovery and a real-Bash oracle | **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-107 | Static-analysis command validation amplification | A large comment before thousands of commands makes a whole-script ordered-subsequence scan per command | Build one source-character position index, then validate each analyzed command-name character with a logarithmic position lookup instead of rescanning source | **MITIGATED** | From afc0f8192193f21b8589647a170e66aa5ae2211c Mon Sep 17 00:00:00 2001 From: Mykhailo Chalyi Date: Sat, 10 Oct 2026 21:21:45 -0500 Subject: [PATCH 2/3] refactor(interp): extract tilde literal append to shrink poll frames Move the BudgetedString temporaries out of expand_word_buffer's Literal arm into append_literal_segment, so each $(...) nesting level's poll frame stays small. Measured depth-32 stack threshold restored to the pre-tilde-change baseline (pass at 1968 KiB, abort at/below 1952 KiB); the inline version cost 16-32 KiB and aborted the coverage job under tarpaulin instrumentation. --- crates/bashkit/src/interpreter/expansion.rs | 58 +++++++++++++++------ 1 file changed, 42 insertions(+), 16 deletions(-) diff --git a/crates/bashkit/src/interpreter/expansion.rs b/crates/bashkit/src/interpreter/expansion.rs index 97fd98d7a..f1e64753d 100644 --- a/crates/bashkit/src/interpreter/expansion.rs +++ b/crates/bashkit/src/interpreter/expansion.rs @@ -429,22 +429,13 @@ impl Interpreter { result.try_push_str(&text)?; } WordPart::Literal(s) => { - // Tilde expansion (`~`, `~/x`, `x=~:~`); quoted literals - // keep their tilde. - let expanded = if !part_quoted && s.contains('~') { - self.tilde_expand_word_literal(word, idx, s)? - } else { - None - }; - if let Some(expanded) = expanded { - let tilde_lease = self.execution_budget.lease_bytes(expanded.len())?; - result.try_push_str(&expanded)?; - if !leased { - substitution_leases.push(tilde_lease); - } - } else { - result.try_push_str(s)?; - } + self.append_literal_segment( + &mut result, + (word, idx, s), + part_quoted, + leased, + &mut substitution_leases, + )?; } WordPart::Variable(name) => { if self.is_nounset() && !self.is_variable_set(name) { @@ -911,6 +902,41 @@ impl Interpreter { /// Directory for the tilde prefix `~name`: `~` is `$HOME`, `~+` `$PWD`, /// `~-` `$OLDPWD`; `~root` and `~` are the fixed homes. /// `None` keeps the text literal (unknown user, unset `OLDPWD`). + /// Append one literal segment, tilde-expanding when unquoted and eligible. + /// Tilde expansion (`~`, `~/x`, `x=~:~`) runs during substitution: `~` + /// only expands at the start of the word or after `:`/`=` in an + /// assignment-like prefix. Quoted literals keep their tilde. + /// + /// Split out of `expand_word_buffer` so the per-level poll frame holds no + /// `BudgetedString` temporaries: every `$(...)` nesting level awaits + /// through that future, and heavy arm locals abort depth-32 under + /// coverage instrumentation (cf. #1089). + fn append_literal_segment( + &self, + result: &mut BudgetedString, + segment: (&Word, usize, &str), + part_quoted: bool, + leased: bool, + substitution_leases: &mut Vec, + ) -> Result<()> { + let (word, idx, s) = segment; + let expanded = if !part_quoted && s.contains('~') { + self.tilde_expand_word_literal(word, idx, s)? + } else { + None + }; + if let Some(expanded) = expanded { + let tilde_lease = self.execution_budget.lease_bytes(expanded.len())?; + result.try_push_str(&expanded)?; + if !leased { + substitution_leases.push(tilde_lease); + } + } else { + result.try_push_str(s)?; + } + Ok(()) + } + fn tilde_dir(&self, prefix: &str) -> Option> { use std::borrow::Cow; let lookup = |name: &str| { From 91997c51009e188d51270e6a54425c6d70a6c1b6 Mon Sep 17 00:00:00 2001 From: Mykhailo Chalyi Date: Sun, 11 Oct 2026 01:53:47 -0500 Subject: [PATCH 3/3] refactor(interp): return LimitExceeded from tilde literal append append_literal_segment only fails with budget exhaustion, so return the small limit type directly instead of the shared Error. Keeps the hot poll frame lean (x86_64 closure sub rsp 1320 -> 1288; base 1144) with identical Error::from(LimitExceeded) semantics at the single conversion point. No accounting change: budget/stack suites green, depth-32 threshold unchanged. --- crates/bashkit/src/interpreter/expansion.rs | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/crates/bashkit/src/interpreter/expansion.rs b/crates/bashkit/src/interpreter/expansion.rs index 4c308b818..23fdb1208 100644 --- a/crates/bashkit/src/interpreter/expansion.rs +++ b/crates/bashkit/src/interpreter/expansion.rs @@ -918,7 +918,11 @@ impl Interpreter { part_quoted: bool, leased: bool, substitution_leases: &mut Vec, - ) -> Result<()> { + ) -> std::result::Result<(), crate::limits::LimitExceeded> { + // Small error type on purpose: every fallible op in here already + // reports LimitExceeded, and a lean Result keeps the caller's hot + // poll frame small. The caller converts to the shared Error once, + // outside this path (same Error::from(LimitExceeded) as `?` uses). let (word, idx, s) = segment; let expanded = if !part_quoted && s.contains('~') { self.tilde_expand_word_literal(word, idx, s)?