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
247 changes: 187 additions & 60 deletions crates/bashkit/src/interpreter/arithmetic.rs

Large diffs are not rendered by default.

63 changes: 38 additions & 25 deletions crates/bashkit/src/interpreter/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2926,15 +2926,15 @@ impl Interpreter {
Arc::make_mut(&mut self.scoped.namerefs)
}

/// Check if cancellation has been requested.
/// Check cancellation and resource failures recorded by read-only expansion.
fn check_cancelled(&self) -> Result<()> {
if let Some(error) = &self.memory_limit_error {
return Err(crate::error::Error::ResourceLimit(error.clone()));
}
if self.cancelled.load(Ordering::Relaxed) {
Err(crate::error::Error::Cancelled)
} else {
Ok(())
self.execution_budget.check().map_err(Into::into)
}
}

Expand Down Expand Up @@ -13146,57 +13146,70 @@ impl Interpreter {
self.resolve_param_expansion_name(name).1
})
};
Self::expand_key_text_using(s, lookup).unwrap()
let mut out = String::new();
Self::scan_key_text(s, lookup, |text| {
out.push_str(text);
Ok(())
})
.unwrap();
out
}

/// One quote/key scanner; callers own lookup semantics and recursion budgets.
fn expand_key_text_using<E>(
fn scan_key_text<T: std::ops::Deref<Target = str>, E>(
s: &str,
mut lookup: impl FnMut(&str, bool) -> std::result::Result<String, E>,
) -> std::result::Result<String, E> {
let mut out = String::new();
let mut chars = s.chars().peekable();
while let Some(c) = chars.next() {
mut lookup: impl FnMut(&str, bool) -> std::result::Result<T, E>,
mut append: impl FnMut(&str) -> std::result::Result<(), E>,
) -> std::result::Result<(), E> {
// Borrow names from the source: scanners must not allocate an
// unmetered temporary before a fallible expansion callback runs.
let mut chars = s.char_indices().peekable();
while let Some((_, c)) = chars.next() {
match c {
'"' => {}
'\'' => {
for q in chars.by_ref() {
for (_, q) in chars.by_ref() {
if q == '\'' {
break;
}
out.push(q);
append(q.encode_utf8(&mut [0; 4]))?;
}
}
'\\' => {
if let Some((_, c)) = chars.next() {
append(c.encode_utf8(&mut [0; 4]))?;
}
}
'\\' => out.extend(chars.next()),
'$' if chars.peek() == Some(&'{') => {
chars.next();
let mut name = String::new();
for n in chars.by_ref() {
'$' if chars.peek().is_some_and(|(_, c)| *c == '{') => {
let (start, _) = chars.next().unwrap();
let mut end = s.len();
for (i, n) in chars.by_ref() {
if n == '}' {
end = i;
break;
}
name.push(n);
}
out.push_str(&lookup(&name, true)?);
append(&lookup(&s[start + 1..end], true)?)?;
}
'$' if chars
.peek()
.is_some_and(|n| n.is_ascii_alphanumeric() || *n == '_') =>
.is_some_and(|(_, n)| n.is_ascii_alphanumeric() || *n == '_') =>
{
let mut name = String::new();
while let Some(&n) = chars.peek() {
let start = chars.peek().unwrap().0;
let mut end = start;
while let Some(&(i, n)) = chars.peek() {
if !(n.is_ascii_alphanumeric() || n == '_') {
break;
}
name.push(n);
end = i + n.len_utf8();
chars.next();
}
out.push_str(&lookup(&name, false)?);
append(&lookup(&s[start..end], false)?)?;
}
_ => out.push(c),
_ => append(c.encode_utf8(&mut [0; 4]))?,
}
}
Ok(out)
Ok(())
}

/// Fully expand an associative array key using standard word expansion.
Expand Down
129 changes: 129 additions & 0 deletions crates/bashkit/tests/integration/arithmetic_resource_tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -141,3 +141,132 @@ async fn dollar_subscript_writes_remain_read_only() {
assert_eq!(result.stdout, "0 7\n");
assert!(result.stderr.is_empty());
}

#[tokio::test]
async fn arithmetic_array_keys_obey_live_budget_and_recover() {
use bashkit::{Error, ExecutionLimits, LimitExceeded};
for reference in ["$x", "${x}"] {
for expr in ["c[KEY]=1", "c[KEY]+=1", "c[KEY]++", "--c[KEY]", "c[KEY]"] {
for entry in ["((EXPR))", "let 'EXPR'", "echo $((EXPR))"] {
let limits = ExecutionLimits::new().max_live_intermediate_bytes(512);
let mut bash = Bash::builder().limits(limits).build();
bash.exec(&format!("declare -A c; x={}", "A".repeat(64)))
.await
.unwrap();
let expr = expr.replace("KEY", &reference.repeat(32));
let script = entry.replace("EXPR", &expr);
let result = bash.exec(&script).await;
assert!(
matches!(
result,
Err(Error::ResourceLimit(LimitExceeded::ExecutionBudget(_)))
),
"{script}: {result:?}"
);
let recovery = bash.exec("echo recovered; echo ${#c[@]}").await.unwrap();
assert_eq!(recovery.stdout, "recovered\n0\n");
assert_eq!(recovery.exit_code, 0);
}
}
}
}

#[tokio::test]
async fn arithmetic_array_key_scanner_obeys_budgets() {
use bashkit::{Error, ExecutionLimits, LimitExceeded};
for expression in ["${c[k$x$x$x$x]}", "${c[k${x}${x}${x}${x}]}", "$r"] {
for limits in [
ExecutionLimits::new().max_live_intermediate_bytes(512),
ExecutionLimits::new().max_work_units(400),
] {
let mut bash = Bash::builder()
.limits(limits)
.env("x", "A".repeat(256))
.build();
bash.exec("declare -A c; declare -n r='c[k$x$x$x$x]'")
.await
.unwrap();
let result = bash.exec(&format!("let '{expression}'")).await;
assert!(
matches!(
result,
Err(Error::ResourceLimit(LimitExceeded::ExecutionBudget(_)))
),
"{expression}: {result:?}"
);
assert_eq!(
bash.exec("echo recovered").await.unwrap().stdout,
"recovered\n"
);
}
}
}

#[tokio::test]
async fn arithmetic_array_key_size_boundary() {
for size in [65_535, 65_536] {
let mut bash = Bash::builder().env("x", "a".repeat(size)).build();
bash.exec("declare -A c").await.unwrap();
// The literal prefix makes the scanner's output exactly 64 KiB or
// one byte over. No huge arithmetic source or host-exhaustion probe.
let result = bash.exec("let '1+${c[k$x]:-0}'").await.unwrap();
if size == 65_535 {
assert_eq!(result.exit_code, 0, "{}", result.stderr);
assert!(result.stderr.is_empty());
} else {
assert_eq!(result.exit_code, 1);
assert!(result.stderr.contains("expression too long"));
}
assert_eq!(
bash.exec("echo recovered").await.unwrap().stdout,
"recovered\n"
);
}
let mut bash = Bash::builder().env("x", "a".repeat(65_537)).build();
// Inspecting length does not materialize that value as an expression/key.
let result = bash.exec("echo $((${#x}+1))").await.unwrap();
assert_eq!(result.stdout, "65538\n");
}

#[tokio::test]
async fn arithmetic_array_keys_match_modern_bash() {
let version = std::process::Command::new("bash")
.args(["--noprofile", "--norc", "-c", "echo ${BASH_VERSINFO[0]}"])
.output()
.unwrap();
if String::from_utf8_lossy(&version.stdout)
.trim()
.parse::<u32>()
.unwrap()
< 4
{
// Associative arrays require Bash >= 4; Linux CI supplies Bash 5.x.
return;
}
for script in [
"declare -A c; x='a b'; ((c[$x$x]=3)); ((c[$x$x]+=2)); ((c[$x$x]++)); let 'c[$x$x]--'; echo $((c[$x$x])); echo ${c['a ba b']}",
"declare -A c; x=é; ((c[${x}${x}]=7)); echo $((c[${x}${x}])); let 'c[éé]+=1'; echo ${c[éé]}",
"declare -A c; c[abcabc]=7; x=abc; echo $((1+${c[k${x}${x}]:-2})); echo $((1+${c[$x$x]})); declare -n r='c[$x$x]'; echo $(($r))",
"declare -A c; a=(a b); c[k'a b']=8; echo $((1+${c[k${a[@]}]}))",
] {
let expected = std::process::Command::new("bash")
.args(["--noprofile", "--norc", "-c", script])
.output()
.unwrap();
assert!(
expected.status.success(),
"{script}: {}",
String::from_utf8_lossy(&expected.stderr)
);
let mut bash = Bash::new();
let result = bash.exec(script).await.unwrap();
assert_eq!(
result.exit_code,
expected.status.code().unwrap(),
"{script}: {}",
result.stderr
);
assert_eq!(result.stdout.as_bytes(), expected.stdout, "{script}");
assert!(result.stderr.is_empty(), "{script}: {}", result.stderr);
}
}
6 changes: 6 additions & 0 deletions docs/security.md
Original file line number Diff line number Diff line change
Expand Up @@ -153,6 +153,12 @@ Arithmetic limits cover textual dollar expansion inside expressions.
Nested array subscripts share the enclosing expression's recursion and work
limits, including quoted arguments to `let`. Exceeding either limit returns
an arithmetic error; subsequent executions can reuse the session.
Expanded arithmetic expressions and associative-array keys are capped at 64 KiB.
Their buffers reserve capacity against `max_live_intermediate_bytes` before
allocation, including temporary copies of variable values. Each append charges
`max_work_units` and checks cancellation and the execution deadline. Shared-budget
exhaustion returns a resource-limit error rather than continuing the script;
subsequent executions start with a fresh request budget.

## Panic safety

Expand Down
4 changes: 4 additions & 0 deletions knowledge/log.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,9 @@
# Bashkit Knowledge Update Log

## 2026-10-11

* **Fix**: arithmetic dollar expansion and associative-key scanning reserve intermediate capacity before allocation, cap generated expression/key bytes at 64 KiB before append, and charge shared work with synchronous cancellation/deadline checks. Scalar storage is borrowed before budgeted copying; temporary value buffers keep their leases until consumed. Poisoned request budgets propagate through command boundaries as resource-limit errors. Small-budget regressions cover arithmetic entry points, read/write operations, key scanning, namerefs, size boundaries, recovery, and finite Bash parity. See [threat model](security/threat-model.md) TM-DOS-026 and [security testing](security/security-testing.md).

## 2026-10-09

* **Test**: first run of the tagged 88-task `bashkit_bash` and `bashkit_generate` on five models (Opus 5.5 87/88, Kimi K3 86, Sonnet 5.5 85, Muse Spark 84, Gemini 3.8 Flash 74; generate 13-15/15); GPT-6 models pending (OpenAI credit). The homepage eval table now only lists runs over the newest run's task count, so scores on different dataset sizes never mix. Results in `crates/bashkit-eval/README.md`.
Expand Down
14 changes: 14 additions & 0 deletions knowledge/security/security-testing.md
Original file line number Diff line number Diff line change
Expand Up @@ -96,6 +96,20 @@ orphan entries or retained filename allocation. No host-exhaustion probe is need
The real-Bash comparison sources both the original and post-unset replacement;
Bash versions differ in the filename they assign to a definition inside `-c`.

## Arithmetic Associative-Key Budgets

`arithmetic_resource_tests` exercises the public `Bash::exec` path with keys
bounded to a few KiB and small live-memory/work budgets. It covers repeated
`$name` and `${name}` through `((...))`, quoted `let`, and `$((...))`, reads,
assignment/compound assignment/increment/decrement, recursive key scanning,
namerefs, 64 KiB key boundaries, and session recovery after rejection. Finite
valid-key cases compare stdout and status with Bash >= 4 (associative arrays
are unavailable in macOS's stock Bash 3.2; Linux CI supplies Bash 5.x).
The interpreter unit test checks the exact UTF-8 append boundary and a
cancellation checkpoint without growing a buffer after rejection. Resource
probes run in externally supervised subprocesses; full memory-exhaustion
payloads are unnecessary.

## Adding New Fail Points

Add `fail_point!("module::function", |action| ...)` under
Expand Down
2 changes: 1 addition & 1 deletion knowledge/security/threat-model.md
Original file line number Diff line number Diff line change
Expand Up @@ -240,7 +240,7 @@ runaway scripts without permanently breaking the session.
| TM-DOS-020 | Function and signal-trap recursion | `f() { f; }; f` or `trap 'kill -USR1 $$' USR1; kill -USR1 $$` | Signal handlers, functions, source, and child shells share the function-depth counter, plus a dedicated trap-nesting cap of 3 that trips first on small stacks; direct, mutual, and mixed signal recursion cannot refresh either. Nested-trap exhaustion is skipped with a diagnostic and the first trip fails the exec as a resource-limit error, while handler logic failures never disturb the interrupted command (TM-DOS-030). Effective depth limit is at most 16 even if an embedder requests more; the previous default 100 and hardened 64 could overflow a 2 MiB host stack before the counter fired (#2459). CLI subprocess tests run direct, mutual, and mixed signal/function/source recursion on that stack size with small command budgets. | **MITIGATED** |
| TM-DOS-021 | Command sub nesting | `$($($($())))` | Child parsers increment shared AST depth and inherit ceiling, fuel, budget and timeout origin; heap ownership and outlined nonrecursive frames keep the hard cap stack-safe | **MITIGATED** |
| TM-DOS-022 | Parser recursion | Deeply nested `(((())))` | `max_ast_depth` limit (100) + `HARD_MAX_AST_DEPTH` cap (100) | **MITIGATED** |
| TM-DOS-026 | Arithmetic recursion and expansion amplification | `$((((...))))`, `a=a+a; $((a))`, `i=arr[i]; $((arr[i]))`, nested `${a[${a[...]}]}` passed to `let` | `MAX_ARITHMETIC_DEPTH` limit (50) checked before dollar expansion and tokenization; shared fuel across textual expansion, nested read-only subscripts, nameref/key lookup, and recursive variable evaluation; expanded-size cap | **MITIGATED** |
| TM-DOS-026 | Arithmetic recursion and expansion amplification | `$((((...))))`, `a=a+a; $((a))`, `i=arr[i]; $((arr[i]))`, nested `${a[${a[...]}]}` passed to `let` | `MAX_ARITHMETIC_DEPTH` limit (50) checked before dollar expansion and tokenization; shared fuel across textual expansion, nested read-only subscripts, nameref/key lookup, and recursive variable evaluation; generated expression/key bytes capped at 64 KiB before append; `BudgetedString` leases capacity and temporary value copies before allocation; appends charge shared work and check cancellation/deadline; command boundaries propagate poisoned shared budgets as resource-limit errors. Regressions: `arithmetic_resource_tests`, `arithmetic_text_cap_and_cancellation_precede_growth` | **MITIGATED** |
| TM-DOS-064 | Heredoc suffix re-injection CPU amplification | Many `: <<E && : <<E ...` heredocs on one logical line repeatedly copy command-line suffixes | `read_heredoc_with_strip_metered` reports re-injected suffix length; parser charges it to `max_parser_operations` fuel | **MITIGATED** |

**Current Risk**: LOW. Implementation: `max_function_depth` 16 with a hard ceiling of 16 (TM-DOS-020/021) and `max_ast_depth` 100 (TM-DOS-022) in `limits.rs`; child parsers in command/process substitution inherit remaining depth budget + fuel from parent (TM-DOS-021, `parser/mod.rs`); arithmetic evaluator caps recursion at `MAX_ARITHMETIC_DEPTH` 50, shares expansion fuel across dollar expansion and subscript/variable evaluation, bounds variable cycles, and caps generated expression bytes (TM-DOS-026, `interpreter/mod.rs` `MAX_ARITHMETIC_DEPTH` / `MAX_ARITHMETIC_EXPANSION_*`); heredoc rest-of-line re-injection charged to parser fuel (TM-DOS-064).
Expand Down
Loading