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 @@ -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:**

Expand Down
85 changes: 82 additions & 3 deletions crates/bashkit/src/interpreter/prompt.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -31,15 +34,27 @@ impl Interpreter {
}

async fn expand_prompt_work(&mut self, value: String) -> Result<String> {
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<String> {
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
}

Expand Down Expand Up @@ -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() {
Expand Down
30 changes: 29 additions & 1 deletion crates/bashkit/src/limits.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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.
Expand All @@ -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
Expand Down Expand Up @@ -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
Expand All @@ -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),

Expand Down Expand Up @@ -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();
Expand All @@ -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());
Expand Down
16 changes: 16 additions & 0 deletions crates/bashkit/src/parser/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<Word> {
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`].
Expand Down
1 change: 1 addition & 0 deletions crates/bashkit/tests/integration/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
117 changes: 117 additions & 0 deletions crates/bashkit/tests/integration/prompt_resource_tests.rs
Original file line number Diff line number Diff line change
@@ -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<bashkit::ExecResult>, 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:?}"
);
}
9 changes: 9 additions & 0 deletions crates/bashkit/tests/spec_cases/bash/prompt-decode.test.sh
Original file line number Diff line number Diff line change
@@ -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 ' '
Expand Down
7 changes: 7 additions & 0 deletions docs/security.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:

Expand Down
Loading
Loading