Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions crates/bashkit/docs/threat-model.md
Original file line number Diff line number Diff line change
Expand Up @@ -113,6 +113,7 @@ through configurable limits.
| 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 `<cmd>: <what> limit (<N>) exceeded` on stderr and exit non-zero; awk caps and formatting errors are fatal | **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 | **MITIGATED** |
| `$(<file)` resource-accounting bypass (TM-DOS-115) | Repeated file-read substitutions skip command limits and accumulate file contents during argument expansion | Optimized reads charge command, session-command, and work budgets; file and accumulated substitution bytes hold live-intermediate leases | **MITIGATED** |
| Silent scalar assignment rejection (TM-DOS-111) | A variable write over the byte or count limit is dropped while the script exits 0 | The first rejected write fails execution with a memory-limit error; a later exec can reuse the session | **MITIGATED** |
| `sed r` output amplification (TM-DOS-112) | `sed 'r FILE' FILE` re-emits the whole file after every input line, growing output quadratically inside the engine | Every sed sink leases from `max_live_intermediate_bytes` before growing; a refused lease is `sed: <error>` with exit 1. The stdout *capture* cap is deliberately not a sink limit, since sed output is often piped onward or redirected to a file | **MITIGATED** |
| Param expansion bomb (TM-DOS-059) | `${x//a/bigstring}` multiplicative amplification | `max_total_variable_bytes` + `max_stdout_bytes` | MITIGATED |
Expand Down
81 changes: 77 additions & 4 deletions crates/bashkit/src/interpreter/expansion.rs
Original file line number Diff line number Diff line change
Expand Up @@ -119,20 +119,43 @@ impl Interpreter {
/// Quote expansion output that came from a quoted segment of a mixed word.
/// THREAT[TM-INF-022]: Quoted user-controlled values must stay literal; only
/// unquoted suffix/prefix glob syntax in the source word may drive expansion.
/// The one definition of which characters the quoting above escapes.
///
/// THREAT[TM-DOS-115]: `expansion_appended_len` charges the execution budget
/// for what `append_expansion_for_word` is about to append, so both must
/// agree on this set. Keeping it in one place stops the byte charge from
/// silently under-counting when a metacharacter is added.
fn needs_glob_escape(ch: char) -> bool {
matches!(
ch,
'\\' | '*' | '?' | '[' | ']' | '{' | '}' | '@' | '!' | '+' | '(' | ')' | '|'
)
}

pub(super) fn quote_expansion_for_quoted_glob(value: &str) -> String {
let mut quoted = String::with_capacity(value.len());
for ch in value.chars() {
if matches!(
ch,
'\\' | '*' | '?' | '[' | ']' | '{' | '}' | '@' | '!' | '+' | '(' | ')' | '|'
) {
if Self::needs_glob_escape(ch) {
quoted.push('\\');
}
quoted.push(ch);
}
quoted
}

/// How many bytes `append_expansion_for_word` will append for `value`,
/// counted before anything is allocated so the budget is charged first.
fn expansion_appended_len(word: &Word, value: &str) -> usize {
if word.quoted && word.has_unquoted_glob {
value
.chars()
.map(|ch| ch.len_utf8() + usize::from(Self::needs_glob_escape(ch)))
.sum()
} else {
value.len()
}
}

pub(super) fn append_expansion_for_word(result: &mut String, word: &Word, value: &str) {
if word.quoted && word.has_unquoted_glob {
result.push_str(&Self::quote_expansion_for_quoted_glob(value));
Expand All @@ -143,6 +166,9 @@ impl Interpreter {

pub(super) async fn expand_word_inner(&mut self, word: &Word) -> Result<String> {
let mut result = String::new();
// Keep command-substitution bytes charged until the complete word has
// been consumed; sibling substitutions otherwise evade live-byte caps.
let mut substitution_leases = Vec::new();
let mut is_first_part = true;

for part in &word.parts {
Expand Down Expand Up @@ -206,6 +232,8 @@ impl Interpreter {
// THREAT[TM-DOS-089]: Delegate to Box::pin-ed helper to
// prevent stack growth proportional to nesting depth.
let trimmed = self.execute_cmd_subst(commands).await?;
let appended_bytes = Self::expansion_appended_len(word, &trimmed);
substitution_leases.push(self.execution_budget.lease_bytes(appended_bytes)?);
Self::append_expansion_for_word(&mut result, word, &trimmed);
}
WordPart::ArithmeticExpansion(expr) => {
Expand Down Expand Up @@ -1736,3 +1764,48 @@ impl Interpreter {
value.to_string()
}
}

#[cfg(test)]
mod expansion_charge_tests {
use super::*;

fn word(quoted: bool, has_unquoted_glob: bool) -> Word {
Word {
parts: Vec::new(),
quoted,
has_unquoted_glob,
part_quoted: Vec::new(),
}
}

/// THREAT[TM-DOS-115]: the budget charge must equal what actually gets
/// appended. If these drift, a substitution is under-charged and the
/// live-byte cap stops bounding it.
#[test]
fn appended_len_matches_what_append_writes() {
let values = [
"",
"plain text",
"*?[]{}@!+()|\\",
"mixed *glob* and text",
"unicode: héllo → 世界 *",
"\\\\already\\\\escaped",
"trailing backslash \\",
];
for quoted in [false, true] {
for has_unquoted_glob in [false, true] {
let w = word(quoted, has_unquoted_glob);
for value in values {
let mut appended = String::new();
Interpreter::append_expansion_for_word(&mut appended, &w, value);
assert_eq!(
Interpreter::expansion_appended_len(&w, value),
appended.len(),
"charge != appended for {value:?} \
(quoted={quoted}, has_unquoted_glob={has_unquoted_glob})"
);
}
}
}
}
}
42 changes: 28 additions & 14 deletions crates/bashkit/src/interpreter/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -64,7 +64,7 @@ use crate::builtins::{self, Builtin};
use crate::error::Error;
use crate::error::Result;
use crate::fs::FileSystem;
use crate::limits::{ExecutionCounters, ExecutionLimits, SessionLimits};
use crate::limits::{BudgetedString, ExecutionCounters, ExecutionLimits, SessionLimits};

/// A single command history entry.
#[derive(Debug, Clone)]
Expand Down Expand Up @@ -2894,13 +2894,7 @@ impl Interpreter {
Ok(ExecResult::ok(String::new()))
});

// Check command count limit (per-exec)
self.execution_budget.consume_work(1)?;
self.counters.tick_command(&self.limits)?;
// THREAT[TM-DOS-059]: Check session-level command limit
self.counters
.check_session_limits(&self.session_limits)
.map_err(|e| crate::error::Error::Execution(e.to_string()))?;
self.charge_command_execution()?;

match command {
Command::Simple(simple) => self.execute_simple_command(simple, None).await,
Expand Down Expand Up @@ -3021,6 +3015,16 @@ impl Interpreter {
})
}

/// Charge every executable AST command, including optimized command forms.
fn charge_command_execution(&mut self) -> Result<()> {
self.execution_budget.consume_work(1)?;
self.counters.tick_command(&self.limits)?;
// THREAT[TM-DOS-059]: Check session-level command limit.
self.counters
.check_session_limits(&self.session_limits)
.map_err(|e| crate::error::Error::Execution(e.to_string()))
}

/// Execute a compound command (if, for, while, etc.)
async fn execute_compound(&mut self, compound: &CompoundCommand) -> Result<ExecResult> {
match compound {
Expand Down Expand Up @@ -8945,12 +8949,16 @@ impl Interpreter {
// Command substitution runs in a subshell: snapshot all
// mutable state so mutations don't leak to the parent.
let snapshot = self.snapshot_subshell_state();
let mut stdout = String::new();
// THREAT[TM-DOS-111]: Expansion happens before top-level output caps,
// so reserve every byte before growing the substitution buffer.
let mut stdout = BudgetedString::new(Some(&self.execution_budget))?;
let file_read = Self::cmd_subst_file_read(commands);
if let Some(redirects) = file_read {
// `$(<file)` / `$(< file)`: bash's shorthand for `$(cat file)`
// (#2448). A bare input redirect would otherwise run an empty
// command and produce nothing.
// The optimized read replaces execution, not its accounting.
self.charge_command_execution()?;
let read = if self.logic_only_redirect_error(redirects).is_some() {
Err(crate::error::Error::CommandFailure(String::new()))
} else {
Expand All @@ -8959,7 +8967,11 @@ impl Interpreter {
match read {
Ok(content) => {
if let Some(content) = content {
stdout.push_str(&content.command_substitution_text());
// Account for the VFS-owned input while it and the
// decoded substitution text are simultaneously live.
let _content_lease =
self.execution_budget.lease_bytes(content.len())?;
stdout.try_push_str(&content.command_substitution_text())?;
}
self.last_exit_code = 0;
}
Expand All @@ -8972,7 +8984,7 @@ impl Interpreter {
let commands: &[Command] = if file_read.is_some() { &[] } else { commands };
for cmd in commands {
let cmd_result = self.execute_command(cmd).await?;
stdout.push_str(&cmd_result.stdout.command_substitution_text());
stdout.try_push_str(&cmd_result.stdout.command_substitution_text())?;
self.last_exit_code = cmd_result.exit_code;
if matches!(cmd_result.control_flow, ControlFlow::Exit(_)) {
break;
Expand All @@ -8992,13 +9004,15 @@ impl Interpreter {
.execute_capture_only_sequence(&trap_script.commands)
.await
{
stdout.push_str(&trap_result.stdout.command_substitution_text());
stdout.try_push_str(&trap_result.stdout.command_substitution_text())?;
}
self.restore_subshell_state(snapshot);
self.counters.pop_subst();
self.subst_generation += 1;
let trimmed = stdout.trim_end_matches('\n');
Ok(trimmed.to_string())
let trimmed_len = stdout.trim_end_matches('\n').len();
let mut stdout = stdout.into_inner();
stdout.truncate(trimmed_len);
Ok(stdout)
})
}

Expand Down
88 changes: 87 additions & 1 deletion crates/bashkit/tests/integration/execution_budget_tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,9 @@
use bashkit::ExecOptions;
use bashkit::{
Bash, Builtin, BuiltinContext, Error, ExecResult, ExecutionBudget, ExecutionLimits,
LimitExceeded, async_trait,
LimitExceeded, SessionLimits, async_trait,
};
use std::path::Path;
use std::sync::{Arc, Mutex};

fn assert_budget_exhausted(result: bashkit::Result<bashkit::ExecResult>) {
Expand All @@ -16,6 +17,91 @@ fn assert_budget_exhausted(result: bashkit::Result<bashkit::ExecResult>) {
);
}

#[tokio::test]
/// TM-DOS-111: the `$(<file)` fast path is still an executed command.
async fn command_substitution_file_read_respects_command_limit() {
let limits = ExecutionLimits::new().max_commands(1);
let mut bash = Bash::builder().limits(limits).build();
bash.fs()
.write_file(Path::new("/f"), b"data")
.await
.unwrap();

let result = bash.exec(": \"$(</f)\"").await;
assert!(
matches!(
result,
Err(Error::ResourceLimit(LimitExceeded::MaxCommands(1)))
),
"expected command limit exhaustion, got {result:?}"
);
}

#[tokio::test]
/// TM-DOS-111: shortcut commands count toward the cumulative session limit.
async fn command_substitution_file_read_respects_session_command_limit() {
let mut bash = Bash::builder()
.limits(ExecutionLimits::new().max_commands(100))
.session_limits(
SessionLimits::new()
.max_total_commands(1)
.max_exec_calls(100),
)
.build();
bash.fs()
.write_file(Path::new("/f"), b"data")
.await
.unwrap();

let error = bash.exec(": \"$(</f)\"").await.unwrap_err().to_string();
assert!(error.contains("session command limit"), "{error}");
}

#[tokio::test]
/// TM-DOS-111: the shortcut consumes the same shared work unit as a command.
async fn command_substitution_file_read_respects_work_limit() {
let limits = ExecutionLimits::new()
.max_commands(100)
.max_work_units(1)
.max_aggregate_input_bytes(1_000);
let mut bash = Bash::builder().limits(limits).build();
bash.fs()
.write_file(Path::new("/f"), b"data")
.await
.unwrap();

assert_budget_exhausted(bash.exec(": \"$(</f)\"").await);
}

#[tokio::test]
/// TM-DOS-111: file-backed substitution text is a live intermediate.
async fn command_substitution_file_read_respects_live_byte_limit() {
let limits = ExecutionLimits::new()
.max_commands(100)
.max_work_units(10_000)
.max_live_intermediate_bytes(4);
let mut bash = Bash::builder().limits(limits).build();
bash.fs()
.write_file(Path::new("/f"), b"12345")
.await
.unwrap();

assert_budget_exhausted(bash.exec(": \"$(</f)\"").await);
}

#[tokio::test]
/// TM-DOS-111: sibling substitutions cannot each reuse the full live budget.
async fn command_substitution_file_reads_share_live_byte_limit() {
let limits = ExecutionLimits::new()
.max_commands(100)
.max_work_units(10_000)
.max_live_intermediate_bytes(10);
let mut bash = Bash::builder().limits(limits).build();
bash.fs().write_file(Path::new("/f"), b"123").await.unwrap();

assert_budget_exhausted(bash.exec(": \"$(</f)$(</f)$(</f)\"").await);
}

#[tokio::test]
/// TM-DOS-096: nested parsers/interpreters must share aggregate work.
async fn nested_command_substitutions_cannot_refresh_work_budget() {
Expand Down
1 change: 1 addition & 0 deletions knowledge/log.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

## 2026-09-25

* **Security**: TM-DOS-115 added. The optimized `$(<file)` path now charges command, session-command, work, and live-intermediate budgets; accumulated sibling substitutions remain leased through word expansion. See [Threat Model](security/threat-model.md).
* **Security**: TM-DOS-110 added. A value growing inside awk or jq (`s = s s`, `until(false; . + .)`) allocated until the host process aborted (#2444). awk checks strings against a 16 MiB cap before allocating and caps variable memory at `max_live_intermediate_bytes`; jq meters every live value against the same limit and polls the deadline from value operations, so non-emitting loops stop too. See [Threat Model](security/threat-model.md).
* **Decision**: jaq-json is vendored, because a `[patch.crates-io]` override does not reach crates.io users and its value operations had no size hook. Mechanical adaptation and upstream sync are scripted, and syncing is a maintenance step. See [Vendored jaq-json](runtimes/jaq-json-vendor.md), [Dependency Policy](operations/dependencies.md) and [Maintenance](operations/maintenance.md).

Expand Down
Loading
Loading