Skip to content

Commit 4563ab5

Browse files
authored
feat: expose maxMemory to prevent OOM from untrusted input (#1075)
Adds `BashBuilder::max_memory(bytes)` in the Rust core that caps `max_total_variable_bytes` and clamps `max_function_body_bytes`. Exposed through JS bindings (`maxMemory`) and Python bindings (`max_memory`) on both `Bash` and `BashTool` classes. Includes Rust unit test + doctest and 5 JS security tests. Closes #1072
1 parent c06d1c5 commit 4563ab5

File tree

6 files changed

+165
-1
lines changed

6 files changed

+165
-1
lines changed

crates/bashkit-js/__test__/security.spec.ts

Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -88,6 +88,75 @@ test("WB: fork bomb pattern blocked (TM-DOS-021)", (t) => {
8888
);
8989
});
9090

91+
// ============================================================================
92+
// 1b. WHITE-BOX — Memory Limit Enforcement (TM-DOS-059)
93+
// ============================================================================
94+
95+
test("WB: maxMemory caps exponential string doubling (TM-DOS-059)", (t) => {
96+
// 1 KB limit — string doubling silently stops when budget is exceeded
97+
const bash = new Bash({
98+
maxMemory: 1024,
99+
maxLoopIterations: 10000,
100+
maxCommands: 10000,
101+
});
102+
const r = bash.executeSync(
103+
'x=AAAAAAAAAA; i=0; while [ $i -lt 25 ]; do x="$x$x"; i=$((i+1)); done; echo ${#x}',
104+
);
105+
// String must be capped well below what 25 doublings would produce (335 544 320)
106+
const len = parseInt(r.stdout.trim(), 10);
107+
t.true(len <= 1024, `string length ${len} must be ≤ 1024`);
108+
});
109+
110+
test("WB: maxMemory — small scripts still work within budget", (t) => {
111+
const bash = new Bash({ maxMemory: 1024 * 1024 }); // 1 MB
112+
const r = bash.executeSync('x="hello world"; echo $x');
113+
t.is(r.exitCode, 0);
114+
t.is(r.stdout.trim(), "hello world");
115+
});
116+
117+
test("WB: maxMemory — recovery after exceeding limit", (t) => {
118+
const bash = new Bash({
119+
maxMemory: 1024,
120+
maxLoopIterations: 10000,
121+
maxCommands: 10000,
122+
});
123+
// Exceed limit (variable silently stops growing)
124+
bash.executeSync(
125+
'x=AAAAAAAAAA; i=0; while [ $i -lt 25 ]; do x="$x$x"; i=$((i+1)); done',
126+
);
127+
// Next exec should still work
128+
const r = bash.executeSync("echo recovered");
129+
t.is(r.exitCode, 0);
130+
t.is(r.stdout.trim(), "recovered");
131+
});
132+
133+
test("WB: maxMemory via BashTool (TM-DOS-059)", (t) => {
134+
const tool = new BashTool({
135+
maxMemory: 1024,
136+
maxLoopIterations: 10000,
137+
maxCommands: 10000,
138+
});
139+
const r = tool.executeSync(
140+
'x=AAAAAAAAAA; i=0; while [ $i -lt 25 ]; do x="$x$x"; i=$((i+1)); done; echo ${#x}',
141+
);
142+
const len = parseInt(r.stdout.trim(), 10);
143+
t.true(len <= 1024, `BashTool: string length ${len} must be ≤ 1024`);
144+
});
145+
146+
test("WB: default memory limit prevents OOM without maxMemory", (t) => {
147+
// Without maxMemory, default 10 MB limit still applies
148+
const bash = new Bash({ maxLoopIterations: 10000, maxCommands: 10000 });
149+
const r = bash.executeSync(
150+
'x=AAAAAAAAAA; i=0; while [ $i -lt 30 ]; do x="$x$x"; i=$((i+1)); done; echo ${#x}',
151+
);
152+
// 30 doublings of 10 bytes = 10 GB without limits; default 10 MB cap stops it
153+
const len = parseInt(r.stdout.trim(), 10);
154+
t.true(
155+
len <= 10_000_000,
156+
`default limit: string length ${len} must be ≤ 10MB`,
157+
);
158+
});
159+
91160
// ============================================================================
92161
// 2. WHITE-BOX — Output Truncation (TM-DOS-002)
93162
// ============================================================================

crates/bashkit-js/src/lib.rs

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -433,6 +433,12 @@ pub struct BashOptions {
433433
pub max_parser_operations: Option<u32>,
434434
pub max_stdout_bytes: Option<u32>,
435435
pub max_stderr_bytes: Option<u32>,
436+
/// Maximum interpreter memory in bytes (variables, arrays, functions).
437+
///
438+
/// Caps `max_total_variable_bytes` and clamps `max_function_body_bytes`.
439+
/// Prevents OOM from untrusted input such as exponential string doubling.
440+
/// Default (when omitted): 10 MB.
441+
pub max_memory: Option<f64>,
436442
/// Whether to capture the final environment state in ExecResult.
437443
pub capture_final_env: Option<bool>,
438444
/// Files to mount in the virtual filesystem.
@@ -461,6 +467,7 @@ fn default_opts() -> BashOptions {
461467
max_parser_operations: None,
462468
max_stdout_bytes: None,
463469
max_stderr_bytes: None,
470+
max_memory: None,
464471
capture_final_env: None,
465472
files: None,
466473
mounts: None,
@@ -490,6 +497,7 @@ struct SharedState {
490497
max_parser_operations: Option<u32>,
491498
max_stdout_bytes: Option<u32>,
492499
max_stderr_bytes: Option<u32>,
500+
max_memory: Option<f64>,
493501
capture_final_env: Option<bool>,
494502
mounts: Option<Vec<MountConfig>>,
495503
python: bool,
@@ -1459,6 +1467,10 @@ fn build_bash_from_state(state: &SharedState, files: Option<&HashMap<String, Str
14591467

14601468
builder = builder.limits(build_limits(state));
14611469

1470+
if let Some(max_mem) = state.max_memory {
1471+
builder = builder.max_memory(max_mem as usize);
1472+
}
1473+
14621474
// Mount files into the virtual filesystem
14631475
if let Some(files) = files {
14641476
for (path, content) in files {
@@ -1533,6 +1545,7 @@ fn shared_state_from_opts(
15331545
max_parser_operations: opts.max_parser_operations,
15341546
max_stdout_bytes: opts.max_stdout_bytes,
15351547
max_stderr_bytes: opts.max_stderr_bytes,
1548+
max_memory: opts.max_memory,
15361549
capture_final_env: opts.capture_final_env,
15371550
mounts: mounts.clone(),
15381551
python: py,
@@ -1565,6 +1578,7 @@ fn shared_state_from_opts(
15651578
max_parser_operations: opts.max_parser_operations,
15661579
max_stdout_bytes: opts.max_stdout_bytes,
15671580
max_stderr_bytes: opts.max_stderr_bytes,
1581+
max_memory: opts.max_memory,
15681582
capture_final_env: opts.capture_final_env,
15691583
mounts,
15701584
python: py,

crates/bashkit-js/wrapper.ts

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,18 @@ export interface BashOptions {
3232
hostname?: string;
3333
maxCommands?: number;
3434
maxLoopIterations?: number;
35+
/**
36+
* Maximum interpreter memory in bytes (variables, arrays, functions).
37+
*
38+
* Caps the total byte budget for variable storage and function bodies.
39+
* Prevents OOM from untrusted input such as exponential string doubling.
40+
*
41+
* @example
42+
* ```typescript
43+
* const bash = new Bash({ maxMemory: 10 * 1024 * 1024 }); // 10 MB
44+
* ```
45+
*/
46+
maxMemory?: number;
3547
/**
3648
* Files to mount in the virtual filesystem.
3749
* Keys are absolute paths, values are content strings or lazy providers.
@@ -122,6 +134,7 @@ function toNativeOptions(
122134
hostname: options?.hostname,
123135
maxCommands: options?.maxCommands,
124136
maxLoopIterations: options?.maxLoopIterations,
137+
maxMemory: options?.maxMemory,
125138
files: resolvedFiles,
126139
python: options?.python,
127140
externalFunctions: options?.externalFunctions,

crates/bashkit-python/src/lib.rs

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -662,6 +662,7 @@ pub struct PyBash {
662662
real_mounts: Vec<RealMountConfig>,
663663
max_commands: Option<u64>,
664664
max_loop_iterations: Option<u64>,
665+
max_memory: Option<u64>,
665666
}
666667

667668
#[pymethods]
@@ -672,6 +673,7 @@ impl PyBash {
672673
hostname=None,
673674
max_commands=None,
674675
max_loop_iterations=None,
676+
max_memory=None,
675677
python=false,
676678
external_functions=None,
677679
external_handler=None,
@@ -689,6 +691,7 @@ impl PyBash {
689691
hostname: Option<String>,
690692
max_commands: Option<u64>,
691693
max_loop_iterations: Option<u64>,
694+
max_memory: Option<u64>,
692695
python: bool,
693696
external_functions: Option<Vec<String>>,
694697
external_handler: Option<Py<PyAny>>,
@@ -717,6 +720,10 @@ impl PyBash {
717720
}
718721
builder = builder.limits(limits);
719722

723+
if let Some(mm) = max_memory {
724+
builder = builder.max_memory(usize::try_from(mm).unwrap_or(usize::MAX));
725+
}
726+
720727
let (mounted_text_files, real_mounts) = parse_mount_configs(
721728
mount_text,
722729
mount_readonly_text,
@@ -786,6 +793,7 @@ impl PyBash {
786793
real_mounts,
787794
max_commands,
788795
max_loop_iterations,
796+
max_memory,
789797
})
790798
}
791799

@@ -935,6 +943,7 @@ impl PyBash {
935943
let hostname = self.hostname.clone();
936944
let max_commands = self.max_commands;
937945
let max_loop_iterations = self.max_loop_iterations;
946+
let max_memory = self.max_memory;
938947
let python = self.python;
939948
let external_functions = self.external_functions.clone();
940949
let mounted_text_files = self.mounted_text_files.clone();
@@ -961,6 +970,9 @@ impl PyBash {
961970
limits = limits.max_loop_iterations(usize::try_from(mli).unwrap_or(usize::MAX));
962971
}
963972
builder = builder.limits(limits);
973+
if let Some(mm) = max_memory {
974+
builder = builder.max_memory(usize::try_from(mm).unwrap_or(usize::MAX));
975+
}
964976
builder = apply_python_config(builder, python, external_functions, handler_clone);
965977
builder = apply_fs_config(builder, &mounted_text_files, &real_mounts);
966978
*bash = builder.build();
@@ -1067,6 +1079,7 @@ pub struct BashTool {
10671079
real_mounts: Vec<RealMountConfig>,
10681080
max_commands: Option<u64>,
10691081
max_loop_iterations: Option<u64>,
1082+
max_memory: Option<u64>,
10701083
}
10711084

10721085
impl BashTool {
@@ -1101,6 +1114,7 @@ impl BashTool {
11011114
hostname=None,
11021115
max_commands=None,
11031116
max_loop_iterations=None,
1117+
max_memory=None,
11041118
mount_text=None,
11051119
mount_readonly_text=None,
11061120
mount_real_readonly=None,
@@ -1113,6 +1127,7 @@ impl BashTool {
11131127
hostname: Option<String>,
11141128
max_commands: Option<u64>,
11151129
max_loop_iterations: Option<u64>,
1130+
max_memory: Option<u64>,
11161131
mount_text: Option<Vec<(String, String)>>,
11171132
mount_readonly_text: Option<Vec<(String, String)>>,
11181133
mount_real_readonly: Option<Vec<String>>,
@@ -1138,6 +1153,10 @@ impl BashTool {
11381153
}
11391154
builder = builder.limits(limits);
11401155

1156+
if let Some(mm) = max_memory {
1157+
builder = builder.max_memory(usize::try_from(mm).unwrap_or(usize::MAX));
1158+
}
1159+
11411160
let (mounted_text_files, real_mounts) = parse_mount_configs(
11421161
mount_text,
11431162
mount_readonly_text,
@@ -1163,6 +1182,7 @@ impl BashTool {
11631182
real_mounts,
11641183
max_commands,
11651184
max_loop_iterations,
1185+
max_memory,
11661186
})
11671187
}
11681188

@@ -1295,6 +1315,7 @@ impl BashTool {
12951315
let real_mounts = self.real_mounts.clone();
12961316
let max_commands = self.max_commands;
12971317
let max_loop_iterations = self.max_loop_iterations;
1318+
let max_memory = self.max_memory;
12981319
let cancelled = self.cancelled.clone();
12991320

13001321
py.detach(|| {
@@ -1315,6 +1336,9 @@ impl BashTool {
13151336
limits = limits.max_loop_iterations(usize::try_from(mli).unwrap_or(usize::MAX));
13161337
}
13171338
builder = builder.limits(limits);
1339+
if let Some(mm) = max_memory {
1340+
builder = builder.max_memory(usize::try_from(mm).unwrap_or(usize::MAX));
1341+
}
13181342
builder = apply_fs_config(builder, &mounted_text_files, &real_mounts);
13191343
*bash = builder.build();
13201344
// Swap the cancellation token to the new interpreter's token so

crates/bashkit/src/lib.rs

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1100,6 +1100,30 @@ impl BashBuilder {
11001100
self
11011101
}
11021102

1103+
/// Cap total interpreter memory to `bytes`.
1104+
///
1105+
/// Convenience wrapper over [`memory_limits`](Self::memory_limits) that
1106+
/// sets `max_total_variable_bytes` to `bytes` and clamps
1107+
/// `max_function_body_bytes` to `min(bytes, default)`. Count-based
1108+
/// sub-limits (variable count, array entries, function count) stay at
1109+
/// their defaults.
1110+
///
1111+
/// # Example
1112+
/// ```
1113+
/// # use bashkit::Bash;
1114+
/// let bash = Bash::builder()
1115+
/// .max_memory(10 * 1024 * 1024) // 10 MB
1116+
/// .build();
1117+
/// ```
1118+
pub fn max_memory(self, bytes: usize) -> Self {
1119+
let defaults = MemoryLimits::default();
1120+
self.memory_limits(
1121+
MemoryLimits::new()
1122+
.max_total_variable_bytes(bytes)
1123+
.max_function_body_bytes(bytes.min(defaults.max_function_body_bytes)),
1124+
)
1125+
}
1126+
11031127
/// Set the trace mode for structured execution tracing.
11041128
///
11051129
/// - `TraceMode::Off` (default): No events, zero overhead
@@ -5367,4 +5391,23 @@ echo missing fi"#,
53675391
async fn test_streaming_equivalence_subshell() {
53685392
assert_streaming_equivalence("x=$(echo hello); echo $x").await;
53695393
}
5394+
5395+
#[tokio::test]
5396+
async fn test_max_memory_caps_string_growth() {
5397+
let mut bash = Bash::builder()
5398+
.max_memory(1024)
5399+
.limits(
5400+
ExecutionLimits::new()
5401+
.max_commands(10_000)
5402+
.max_loop_iterations(10_000),
5403+
)
5404+
.build();
5405+
let result = bash
5406+
.exec(r#"x=AAAAAAAAAA; i=0; while [ $i -lt 25 ]; do x="$x$x"; i=$((i+1)); done; echo ${#x}"#)
5407+
.await
5408+
.unwrap();
5409+
let len: usize = result.stdout.trim().parse().unwrap();
5410+
// 25 doublings of 10 bytes = 335 544 320 without limits; must be capped ≤ 1024
5411+
assert!(len <= 1024, "string length {len} must be ≤ 1024");
5412+
}
53705413
}

specs/009-implementation-status.md

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -450,6 +450,7 @@ Default limits (configurable):
450450
| Commands | 10,000 |
451451
| Loop iterations | 100,000 |
452452
| Function depth | 100 |
453+
| Interpreter memory | 10MB (`max_memory` / `maxMemory`) |
453454
| Output size | 10MB |
454455
| Parser timeout | 5 seconds |
455456
| Parser operations (fuel) | 100,000 |
@@ -473,7 +474,7 @@ NAPI-RS bindings in `crates/bashkit-js/`. TypeScript wrapper in `wrapper.ts`.
473474

474475
**Platform matrix:** macOS (x86_64, aarch64), Linux (x86_64, aarch64), Windows (x86_64), WASM
475476

476-
**Tests:** `crates/bashkit-js/__test__/` — VFS roundtrip, interop, error handling, security (90+ white/black-box tests covering TM-DOS, TM-ESC, TM-INF, TM-INT, TM-ISO, TM-UNI, TM-INJ, TM-NET)
477+
**Tests:** `crates/bashkit-js/__test__/` — VFS roundtrip, interop, error handling, security (100+ white/black-box tests covering TM-DOS, TM-ESC, TM-INF, TM-INT, TM-ISO, TM-UNI, TM-INJ, TM-NET)
477478

478479
### Python (`bashkit`)
479480

0 commit comments

Comments
 (0)