diff --git a/crates/bashkit/docs/threat-model.md b/crates/bashkit/docs/threat-model.md index 152abca7b..27fdf8c0f 100644 --- a/crates/bashkit/docs/threat-model.md +++ b/crates/bashkit/docs/threat-model.md @@ -81,6 +81,7 @@ through configurable limits. | Array and arithmetic reports (TM-DOS-130) | `$((` nested in arithmetic, many `${a[-9]}` reads, huge `${!r}` target | 32 nesting levels; warnings capped at 64 KiB per command; indirect target echoed truncated to 256 chars | MITIGATED | | History files (TM-DOS-131) | `history -a` loops, huge `$HISTSIZE`, giant `$HISTFILE` | History list bounded by `max_history_entries`/`max_history_bytes`; `$HISTFILE` reads refused past `max_input_bytes`; VFS only | MITIGATED | | Completion specs and bindings (TM-DOS-132) | `complete`/`bind` in a loop | 1024 specs (64 KiB each) and 1024 binding changes (4 KiB each) per interpreter; never shared between instances | MITIGATED | +| Prompt recursion (TM-DOS-133) | `x='${x@P}'; echo "${x@P}"`, mutual cycles or substitution-mediated cycles | Cumulative prompt depth capped at `min(max_ast_depth, 8)`; shared work, aggregate input, cancellation and deadline checks on each prompt; descendant shells inherit depth | MITIGATED | **Stack Overflow / Recursion:** diff --git a/crates/bashkit/src/interpreter/prompt.rs b/crates/bashkit/src/interpreter/prompt.rs index 7d584925e..b419039d6 100644 --- a/crates/bashkit/src/interpreter/prompt.rs +++ b/crates/bashkit/src/interpreter/prompt.rs @@ -13,6 +13,9 @@ //! virtual `date` clock and `TZ`, `\l` is `tty` (bashkit has no terminal //! device), `\s`/`\v`/`\V` follow `$0` and `$BASH_VERSION`. //! - `\[` and `\]` (readline's non-printing markers) expand to nothing. +//! - Reparses share request fuel, input accounting and cancellation. Cumulative +//! prompt depth follows `max_ast_depth`, capped at eight for native stack safety; +//! fresh shallow parsers must never reset the recursive polling limit. use super::Interpreter; use crate::error::Result; @@ -31,15 +34,27 @@ impl Interpreter { } async fn expand_prompt_work(&mut self, value: String) -> Result { - let decoded = self.decode_prompt_escapes(&value); + // THREAT[TM-DOS-133]: Charge before decoding or recursively polling. + self.execution_budget + .consume_work(u64::try_from(value.len()).unwrap_or(u64::MAX).max(1))?; + self.execution_budget.consume_input(value.len())?; + self.counters.push_prompt(&self.limits)?; + let result = self.expand_prompt_body(&value).await; + self.counters.pop_prompt(); + result + } + + async fn expand_prompt_body(&mut self, value: &str) -> Result { + let decoded = self.decode_prompt_escapes(value); if !crate::builtins::shopt_on(&self.scoped.variables, "promptvars") { return Ok(unquote_prompt_text(&decoded)); } - let word = Parser::parse_heredoc_body_with_limits( + let word = Parser::parse_prompt_body( &decoded, self.limits.max_ast_depth, self.limits.max_parser_operations, - ); + self.execution_budget.clone(), + )?; self.expand_word(&word).await } @@ -217,6 +232,70 @@ fn unquote_prompt_text(text: &str) -> String { #[cfg(test)] mod tests { use super::*; + use crate::{Error, ExecutionLimits, InMemoryFs}; + use std::sync::{Arc, atomic::Ordering}; + + #[tokio::test] + async fn prompt_checks_cancellation_before_decoding() { + let mut interpreter = Interpreter::new(Arc::new(InMemoryFs::new())); + interpreter.begin_execution_budget(); + interpreter.cancelled.store(true, Ordering::Relaxed); + assert!(matches!( + interpreter.expand_prompt_string("literal".into()).await, + Err(Error::Cancelled) + )); + assert_eq!(interpreter.counters.prompt_depth, 0); + } + + #[tokio::test] + async fn prompt_checks_shared_deadline_before_decoding() { + let mut interpreter = Interpreter::new(Arc::new(InMemoryFs::new())); + interpreter.set_limits(ExecutionLimits::new().timeout(std::time::Duration::ZERO)); + interpreter.begin_execution_budget(); + tokio::time::sleep(std::time::Duration::from_millis(120)).await; + assert!(matches!( + interpreter.expand_prompt_string("literal".into()).await, + Err(Error::ResourceLimit(crate::LimitExceeded::Timeout(_))) + )); + assert_eq!(interpreter.counters.prompt_depth, 0); + } + + #[tokio::test] + async fn prompt_error_unwinds_depth() { + let mut interpreter = Interpreter::new(Arc::new(InMemoryFs::new())); + interpreter.begin_execution_budget(); + interpreter.set_env("x", "${x@P}"); + assert!(matches!( + interpreter.expand_prompt_string("${x@P}".into()).await, + Err(Error::ResourceLimit(crate::LimitExceeded::MaxPromptDepth( + 8 + ))) + )); + assert_eq!(interpreter.counters.prompt_depth, 0); + assert_eq!( + interpreter.expand_prompt_string("ok".into()).await.unwrap(), + "ok" + ); + } + + #[tokio::test] + async fn prompt_child_parser_cannot_hide_budget_exhaustion() { + let value = "$(echo $(echo $(echo ok)))"; + let mut interpreter = Interpreter::new(Arc::new(InMemoryFs::new())); + interpreter.set_limits(ExecutionLimits::new().max_work_units(value.len() as u64 + 2)); + interpreter.begin_execution_budget(); + let result = interpreter.expand_prompt_string(value.into()).await; + assert!( + matches!( + result, + Err(Error::ResourceLimit(crate::LimitExceeded::ExecutionBudget( + crate::ExecutionBudgetExceeded::WorkUnits { .. } + ))) + ), + "{result:?}" + ); + assert_eq!(interpreter.counters.prompt_depth, 0); + } #[test] fn quoting_round_trips_without_expansion() { diff --git a/crates/bashkit/src/limits.rs b/crates/bashkit/src/limits.rs index b6e8bc403..2e860618e 100644 --- a/crates/bashkit/src/limits.rs +++ b/crates/bashkit/src/limits.rs @@ -36,6 +36,10 @@ use crate::time_compat::Instant; // embedders request a higher logical recursion limit. const HARD_MAX_FUNCTION_DEPTH: usize = 16; +// Prompt reparses poll recursively on the host stack, even with shallow ASTs. +// THREAT[TM-DOS-133]: No configurable AST limit may remove this stack ceiling. +const HARD_MAX_PROMPT_DEPTH: usize = 8; + #[cfg(feature = "failpoints")] use fail::fail_point; @@ -94,6 +98,7 @@ pub struct ExecutionLimits { /// Maximum AST nesting depth during parsing /// Default: 100 /// Protects against stack overflow from deeply nested scripts (V4 in threat model). + /// Also bounds cumulative prompt expansion depth, with a hard ceiling of 8. pub max_ast_depth: usize, /// Maximum parser operations (fuel model for parsing) @@ -475,6 +480,9 @@ pub struct ExecutionCounters { /// Current explicit subshell nesting depth. pub subshell_depth: usize, + /// Current prompt expansion nesting depth (including descendant shells). + pub prompt_depth: usize, + // THREAT[TM-DOS-059]: Session-level cumulative counters. // These persist across exec() calls (never reset by reset_for_execution). /// Total commands across all exec() calls in this session. @@ -497,11 +505,12 @@ impl ExecutionCounters { self.commands = 0; self.loop_iterations.clear(); self.total_loop_iterations = 0; - // function_depth/subst_depth/subshell_depth should already be 0 between + // Recursion depths should already be 0 between // exec() calls, but reset defensively to avoid stuck state. self.function_depth = 0; self.subst_depth = 0; self.subshell_depth = 0; + self.prompt_depth = 0; } /// Increment command counter, returns error if limit exceeded @@ -684,6 +693,20 @@ impl ExecutionCounters { pub fn pop_subshell(&mut self) { self.subshell_depth = self.subshell_depth.saturating_sub(1); } + + /// Enter a prompt reparse under the AST policy and native stack ceiling. + pub(crate) fn push_prompt(&mut self, limits: &ExecutionLimits) -> Result<(), LimitExceeded> { + let max_depth = limits.max_ast_depth.min(HARD_MAX_PROMPT_DEPTH); + if self.prompt_depth >= max_depth { + return Err(LimitExceeded::MaxPromptDepth(max_depth)); + } + self.prompt_depth += 1; + Ok(()) + } + + pub(crate) fn pop_prompt(&mut self) { + self.prompt_depth = self.prompt_depth.saturating_sub(1); + } } /// Error returned when a resource limit is exceeded @@ -710,6 +733,9 @@ pub enum LimitExceeded { #[error("maximum subshell depth exceeded ({0})")] MaxSubshellDepth(usize), + #[error("maximum prompt expansion depth exceeded ({0})")] + MaxPromptDepth(usize), + #[error("maximum file descriptors exceeded ({0})")] MaxFileDescriptors(usize), @@ -1838,6 +1864,7 @@ mod tests { counters.function_depth = 3; counters.subst_depth = 2; counters.subshell_depth = 2; + counters.prompt_depth = 2; // Reset should restore all counters counters.reset_for_execution(); @@ -1847,6 +1874,7 @@ mod tests { assert_eq!(counters.function_depth, 0); assert_eq!(counters.subst_depth, 0); assert_eq!(counters.subshell_depth, 0); + assert_eq!(counters.prompt_depth, 0); // Should be able to tick commands again assert!(counters.tick_command(&limits).is_ok()); diff --git a/crates/bashkit/src/parser/mod.rs b/crates/bashkit/src/parser/mod.rs index 54b7b7819..84861d600 100644 --- a/crates/bashkit/src/parser/mod.rs +++ b/crates/bashkit/src/parser/mod.rs @@ -294,6 +294,22 @@ impl<'a> Parser<'a> { parser.parse_word(heredoc_body_escapes(input)) } + /// Prompt reparses must charge the same request budget as their caller. + pub(crate) fn parse_prompt_body( + input: &str, + max_depth: usize, + max_fuel: usize, + budget: crate::limits::ExecutionBudget, + ) -> Result { + let parser = + Parser::with_limits(input, max_depth, max_fuel).with_execution_budget(budget.clone()); + let word = parser.parse_word(heredoc_body_escapes(input)); + // Word parsing can turn child parse errors into literal text. A poisoned + // request must still fail closed rather than continue with that fallback. + budget.check()?; + Ok(word) + } + /// Create a parse error with the current position. A grammar error /// (a construct that cannot continue with the current token) gets bash's /// wording instead of `message`; see [`Self::bash_grammar_error`]. diff --git a/crates/bashkit/tests/integration/main.rs b/crates/bashkit/tests/integration/main.rs index 3bc4432a5..76534a8ce 100644 --- a/crates/bashkit/tests/integration/main.rs +++ b/crates/bashkit/tests/integration/main.rs @@ -119,6 +119,7 @@ pub mod parallel_sessions_tests; pub mod partial_parse_tests; pub mod pipeline_tests; pub mod process_substitution_fd_tests; +pub mod prompt_resource_tests; pub mod proptest_differential; pub mod python_integration_tests; pub mod python_security_tests; diff --git a/crates/bashkit/tests/integration/prompt_resource_tests.rs b/crates/bashkit/tests/integration/prompt_resource_tests.rs new file mode 100644 index 000000000..73edd0dc9 --- /dev/null +++ b/crates/bashkit/tests/integration/prompt_resource_tests.rs @@ -0,0 +1,117 @@ +// Prompt reparses must share runtime limits; each parsed expression is shallow. +use bashkit::{Bash, Error, ExecutionBudgetExceeded, ExecutionLimits, LimitExceeded}; + +fn assert_depth(result: bashkit::Result, depth: usize) { + assert!( + matches!(result, Err(Error::ResourceLimit(LimitExceeded::MaxPromptDepth(d))) if d == depth), + "expected prompt depth {depth}, got {result:?}" + ); +} + +fn prompt_chain(depth: usize, limits: ExecutionLimits) -> Bash { + let mut bash = Bash::builder().limits(limits).build(); + bash.set_env("p0", "done"); + for i in 1..depth { + bash.set_env(&format!("p{i}"), &format!("${{p{}@P}}", i - 1)); + } + bash +} + +#[tokio::test] +async fn finite_prompt_chain_respects_depth_limit() { + // Finite six-level chain: safe to execute against the unfixed interpreter. + let mut bash = prompt_chain(6, ExecutionLimits::new().max_ast_depth(5)); + let result = bash.exec("echo \"${p5@P}\"").await; + assert_depth(result, 5); +} + +#[tokio::test] +async fn prompt_cycles_stop_and_session_recovers() { + // Run cycles only with the guard installed; depth tests above are finite. + for script in [ + r#"x='${x@P}'; echo "${x@P}""#, + r#"x='${y@P}'; y='${x@P}'; echo "${x@P}""#, + r#"x='$(echo "${x@P}")'; echo "${x@P}""#, + r#"x='$(echo "${x@P}" | cat)'; echo "${x@P}""#, + ] { + let mut bash = Bash::new(); + assert_depth(bash.exec(script).await, 8); + assert_eq!( + bash.exec(r#"x=ok; echo "${x@P}""#).await.unwrap().stdout, + "ok\n" + ); + } +} + +#[tokio::test] +async fn background_prompt_cycle_is_contained() { + let mut bash = Bash::new(); + let result = bash + .exec(r#"x='${x@P}'; echo "${x@P}" & wait"#) + .await + .unwrap(); + assert!( + result + .stderr + .contains("maximum prompt expansion depth exceeded (8)") + ); + assert_eq!( + bash.exec("echo recovered").await.unwrap().stdout, + "recovered\n" + ); +} + +#[tokio::test] +async fn prompt_depth_boundary_and_siblings() { + let mut bash = prompt_chain(8, ExecutionLimits::new()); + let result = bash.exec(r#"echo "${p7@P}|${p7@P}""#).await.unwrap(); + assert_eq!(result.stdout, "done|done\n"); + bash.set_env("p8", "${p7@P}"); + assert_depth(bash.exec(r#"echo "${p8@P}""#).await, 8); + // Raising AST depth cannot remove the native-stack ceiling. + let mut bash = prompt_chain(9, ExecutionLimits::new().max_ast_depth(usize::MAX)); + assert_depth(bash.exec(r#"echo "${p8@P}""#).await, 8); +} + +#[tokio::test] +async fn promptvars_disabled_keeps_cycle_literal() { + let mut bash = Bash::new(); + let result = bash + .exec(r#"shopt -u promptvars; x='${x@P}'; echo "${x@P}""#) + .await + .unwrap(); + assert_eq!(result.stdout, "${x@P}\n"); +} + +#[tokio::test] +async fn prompt_reparses_share_work_budget() { + let mut bash = prompt_chain(6, ExecutionLimits::new().max_work_units(20)); + let result = bash.exec(r#"echo "${p5@P}""#).await; + assert!( + matches!( + result, + Err(Error::ResourceLimit(LimitExceeded::ExecutionBudget( + ExecutionBudgetExceeded::WorkUnits { .. } + ))) + ), + "expected shared work budget: {result:?}" + ); +} + +#[tokio::test] +async fn prompt_siblings_share_input_budget() { + let mut bash = Bash::builder() + .limits(ExecutionLimits::new().max_aggregate_input_bytes(80)) + .env("x", "a".repeat(40)) + .build(); + let result = bash.exec(r#"echo "${x@P}${x@P}""#).await; + assert!( + matches!( + result, + Err(Error::ResourceLimit(LimitExceeded::ExecutionBudget( + ExecutionBudgetExceeded::InputBytes { .. } + ))) + ), + "expected shared input budget: {result:?}" + ); +} diff --git a/crates/bashkit/tests/spec_cases/bash/prompt-decode.test.sh b/crates/bashkit/tests/spec_cases/bash/prompt-decode.test.sh index f4bb7e815..bad861e67 100644 --- a/crates/bashkit/tests/spec_cases/bash/prompt-decode.test.sh +++ b/crates/bashkit/tests/spec_cases/bash/prompt-decode.test.sh @@ -1,6 +1,15 @@ # Prompt string decoding: ${x@P} (bash decode_prompt_string), then the # promptvars expansion pass. +### prompt_finite_nested_transformations +p0='done' +p1='${p0@P}' +p2='${p1@P}' +echo "${p2@P}|${p2@P}" +### expect +done|done +### end + ### prompt_literal_escapes PS1='\a\e\r\n' printf '%s' "${PS1@P}" | od -An -c | tr -s ' ' diff --git a/docs/security.md b/docs/security.md index 91dc142b8..7b7ae81fb 100644 --- a/docs/security.md +++ b/docs/security.md @@ -55,6 +55,13 @@ at the first match, before further traversal. ## Threat model +Prompt expansion (`${x@P}` and interactive prompts) shares the execution work, +aggregate-input, cancellation and deadline budgets. Recursive prompt reparses +stop at `min(max_ast_depth, 8)` with a resource-limit error, including cycles +through command substitutions. Raising the AST limit cannot remove this host +stack protection. Finite prompt expansion and `shopt -u promptvars` retain their +normal behavior within these limits. + Bashkit maintains a living threat model in [`knowledge/security/threat-model.md`](../knowledge/security/threat-model.md) with stable threat IDs across these categories: diff --git a/knowledge/integrations/interactive-shell.md b/knowledge/integrations/interactive-shell.md index dba82fb58..08885fd75 100644 --- a/knowledge/integrations/interactive-shell.md +++ b/knowledge/integrations/interactive-shell.md @@ -163,6 +163,14 @@ global. (completion specs, binding changes). Gaps: limitations "Interactive builtins" row, L-HIST-001. +Prompt decoding (`${x@P}` and PS1/PS2/PS4) also shares the request's work, +aggregate-input, cancellation and deadline budget. Reparsed prompts count +cumulative runtime depth, including substitutions and forked descendants: +`min(max_ast_depth, 8)`, independent of each fresh parser's shallow AST. +Exhaustion returns `MaxPromptDepth`; success and errors unwind depth, and the +next host `exec()` resets it defensively. `promptvars` off preserves literal +text but still charges decoding. See TM-DOS-133 and `prompt_resource_tests`. + ### Testing Unit tests cover incomplete-input detection, PS1 expansion, prompt format, diff --git a/knowledge/log.md b/knowledge/log.md index eb74f6c2a..d350026fc 100644 --- a/knowledge/log.md +++ b/knowledge/log.md @@ -14,6 +14,12 @@ ## 2026-10-08 +- Bound cumulative prompt reparse depth at `min(max_ast_depth, 8)` and charge + shared request work/input, cancellation and deadlines (TM-DOS-133). Preserve + finite nested expansions and literal `promptvars`-off behavior. Regression + coverage includes cycles, substitutions, sibling budgets and session recovery; + see [interactive shell](integrations/interactive-shell.md). + * **Fix**: Oils long tail. `test`/`[` follow bash's `test.c` grammar (argument-count rules, `-a`/`-o`/`!`/`( )` precedence, `argument expected`, `)' expected`, `too many arguments`). `$empty cmd` runs `cmd`; `PWD`/`OLDPWD` are exported; `B=(b b) cmd` binds the text `(b b)` for `cmd` only. `rm` reports each bad operand and goes on; `cat` reads buffered stdin once; `date foo` fails; `umask 1 2` uses the first operand; `history -5` is an invalid option; `help help`, `help -- X` and bash's `no help topics match`; `set +` is ignored; `getopts` rejects a bad variable name and unsets `OPTARG` at the end. `shift 1 2` (like `exit`/`break`/`continue` with extra operands) discards the line, and a builtin's discard ends a child `bash -c` string (status 1) while expansion errors still resume. Arithmetic: `set -u` makes an unbound name fatal (also in `${#v}`/`${v:..}`), backticks and `\`-newline work inside, a base with a leading zero (`010#1`) is invalid, `a[$(cmd)]=v` and `${a[$(cmd)]}` run the substitution, `a[]=x` and `${a[]}` are bad subscripts. Array literals evaluate subscripts in order (`[i++]=x`, self references), skip tilde for assoc values, and refuse bare assoc elements; `a["k"]=v` and nameref targets `A["K"]`/`A[$key]` resolve quoted and variable keys; `declare -nx` exports. `printf %q` and `${x@Q}`/`@A` use `$'...'` for control characters; `$'\cX'` and invalid `\u` match bash; `${s:}` is a bad substitution; quotes nested in a double-quoted `${..}` operand stay protected; `{a,b}""` keeps brace expansion; `f() ...` with `$`/quotes in the name and `cmd (` are refused. `unset PS4` traces with no prefix; `time a | b` keeps its output. Oils spec pass rate 92.8% to 95.8% (79 cases, no new misses; `array-literal`, `array`, `blog2`, `bool-parse`, `builtin-misc`, `func-parsing`, `builtin-getopts`, `builtin-set`, `exit-status`, `var-op-slice` to 0); every remaining miss is categorized under limitations "Oils Spec Misses". * **Fix**: interactive builtins. `history` and `fc` keep bash's per-shell history: one entry per top-level input line (with `set -o history` / `bash -i`), `history -c -d N/-N/A-B -a -n -r -w -p -s`, `HISTSIZE`/`HISTFILESIZE` trims, `histappend`, listings in bash's format; `fc -l/-n/-r` ranges and prefixes, `fc -s old=new cmd` and `fc -e -` rerun (no editor, L-HIST-001). `HISTFILE` is read and written through the VFS only, reads are capped at `max_input_bytes` (TM-DOS-131); `history -c` still persists at once for the builder's history file. `complete`/`compgen`/`compopt` keep per-interpreter specs (at most 1024 specs and 64 KiB each, TM-DOS-132): every `-A` action, `-W` split at IFS with `$var` expansion, `-G`, `-F` (with `COMP_*` set and `COMPREPLY` read), `-C`, `-X` with `!` and `&`, `-P`/`-S`, `-o plusdirs/dirnames/default`, `complete -p`/`-r` in bash's format. `bind` lists bash's default emacs bindings, readline functions and variables and records changes per shell (capped, TM-DOS-132). `${x@P}` decodes prompt escapes (`\u \h \w \W \s \v \! \# \t \D{fmt}`, octal, `\[ \]`) from the sandbox identity and virtual clock, then expands like a here-doc body under `promptvars`. `bash -i` runs an interactive child: `$-` has `i`, `PS1`/`PS2`/`PS4`/`HISTFILE` defaults, `~/.bashrc` or `--rcfile`, `--norc`, and for a stdin script `PS1` printed to stderr plus `PROMPT_COMMAND` per line and `exit` at EOF. `hostname` takes `-s`/`-f`/`-d`. Oils spec pass rate 89.0% to 92.8% (`builtin-completion` 33 misses to 2, `prompt` 25 to 0, `builtin-history` and `builtin-fc` 12 to 0, `interactive` 11 to 0, `builtin-bind` 9 to 0); gaps in the limitations "Interactive builtins" row and L-HIST-001. * **Fix**: Oils arrays, indirection and traps batch. `'a'"$x"` expands `$x`; `a[i + 1]=v` with blanks in the subscript is an assignment; `a= (x)` and operators inside `( )` are syntax errors; `a[0]=(..)` is `cannot assign list to array member`. Unquoted `${a[@]}` elements split; `${a[@]:o:n}` slices by index (negative offset from the highest index), `${a[@]: -9}` is empty and `${a[-9]}` reads empty with `bad array subscript`; offsets read nested `${..}`/`$((..))` and `?:`. `${!r}` goes through `a[i]`/`a[@]` targets and takes every operator (`${!r:-x}`, `${!r^}`, `${!r:1:2}`, `${!r[@]/x/y}`), reads element 0 of an array, and an unset or invalid target aborts the line. Quoted `"${a[@]-x}"` keeps elements, `"${unset[*]}"` is one empty field, `${a[@]@a}` repeats per element. Subscripts are evaluated once with side effects (`b[i++]=v`), `$((..))` nests inside arithmetic (32 levels), `a[5&3]` is index 1. DEBUG fires for `(( ))`, `[[ ]]`, `case`, `for` rounds, arithmetic-`for` clauses and pipeline stages (in the shell, before the stages), is dormant in functions/subshells/`$(...)` without `set -T` (fires on function entry with it), keeps `$?`, and `return`/`exit` in it return from the function or end the shell; `trap ' 42 ' EXIT` is a handler; an assignment-only `X=${x?msg}` is fatal at once, so `(X=${x?msg})` and `bash -c` end only themselves (the error used to fire at the parent's next command). `printf %06s` pads with blanks; a strftime result of 128 bytes or more is empty. Oils spec pass rate 86.0% to 89.0% (`var-op-test`, `nix-idioms`, `var-op-bash`, `array-basic`, `append` to 0 misses); deliberate gaps in L-ARITH-001 (command substitution in an indirect target) and the DEBUG trap / Arrays rows of the limitations. diff --git a/knowledge/security/threat-model.md b/knowledge/security/threat-model.md index db87172e1..c9c61f6af 100644 --- a/knowledge/security/threat-model.md +++ b/knowledge/security/threat-model.md @@ -301,6 +301,7 @@ runaway scripts without permanently breaking the session. | 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-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** | **TM-DOS-051** is historical. The custom parser was deleted with the `yaml` helper; the replacement `yq` parser/evaluator boundary is tracked by TM-DOS-101.