Skip to content

Commit 4a787fa

Browse files
authored
feat(cli): relax execution limits for CLI mode (#1076)
## Summary - CLI/script modes now use `ExecutionLimits::cli()` — counting-based limits (max commands, loop iterations) are effectively unlimited and no timeout is applied - MCP mode retains the existing sandboxed defaults (`ExecutionLimits::new()`) - Stdout/stderr capture caps raised to 10 MB for CLI mode - Memory-guarding limits (function depth, AST depth, parser fuel) are kept at safe values in all modes - New CLI flags `--max-loop-iterations`, `--max-total-loop-iterations`, and `--timeout` allow overriding the defaults when needed - Session limits set to unlimited for non-MCP modes ## Test plan - [x] `cargo fmt --check` — clean - [x] `cargo clippy -p bashkit -p bashkit-cli --all-features -- -D warnings` — no warnings - [x] `cargo test -p bashkit-cli --all-features` — 30/30 pass - [ ] CI green
1 parent ff6624a commit 4a787fa

2 files changed

Lines changed: 79 additions & 21 deletions

File tree

crates/bashkit-cli/src/main.rs

Lines changed: 59 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,11 @@
22
// Provide --no-http, --no-git, --no-python to disable individually.
33
// Decision: keep one-shot CLI on a current-thread runtime; reserve multi-thread
44
// runtime for MCP only so cold-start work stays off the common path.
5+
// Decision: CLI uses relaxed execution limits (ExecutionLimits::cli()) because
6+
// the user explicitly chose to run the script. Counting-based limits are
7+
// effectively unlimited; timeout is removed (user has Ctrl-C). Memory-guarding
8+
// limits (function depth, AST depth, parser fuel) are kept.
9+
// MCP mode keeps the sandboxed defaults since requests come from LLM agents.
510

611
//! Bashkit CLI - Command line interface for virtual bash execution
712
//!
@@ -68,10 +73,22 @@ struct Args {
6873
#[cfg_attr(feature = "realfs", arg(long, value_name = "PATH"))]
6974
mount_rw: Vec<String>,
7075

71-
/// Maximum number of commands to execute (default: 10000)
76+
/// Maximum number of commands to execute (unlimited for CLI, 10000 for MCP)
7277
#[arg(long)]
7378
max_commands: Option<usize>,
7479

80+
/// Maximum iterations for a single loop (unlimited for CLI, 10000 for MCP)
81+
#[arg(long)]
82+
max_loop_iterations: Option<usize>,
83+
84+
/// Maximum total loop iterations across all loops (unlimited for CLI, 1000000 for MCP)
85+
#[arg(long)]
86+
max_total_loop_iterations: Option<usize>,
87+
88+
/// Execution timeout in seconds (unlimited for CLI, 30 for MCP)
89+
#[arg(long)]
90+
timeout: Option<u64>,
91+
7592
#[command(subcommand)]
7693
subcommand: Option<SubCmd>,
7794
}
@@ -97,7 +114,7 @@ struct RunOutput {
97114
exit_code: i32,
98115
}
99116

100-
fn build_bash(args: &Args) -> bashkit::Bash {
117+
fn build_bash(args: &Args, mode: CliMode) -> bashkit::Bash {
101118
let mut builder = bashkit::Bash::builder();
102119

103120
if !args.no_http {
@@ -118,8 +135,28 @@ fn build_bash(args: &Args) -> bashkit::Bash {
118135
builder = apply_real_mounts(builder, &args.mount_ro, &args.mount_rw);
119136
}
120137

121-
if let Some(max_cmds) = args.max_commands {
122-
builder = builder.limits(bashkit::ExecutionLimits::new().max_commands(max_cmds));
138+
// CLI/script modes use relaxed limits; MCP keeps sandboxed defaults.
139+
let mut limits = if mode == CliMode::Mcp {
140+
bashkit::ExecutionLimits::new()
141+
} else {
142+
bashkit::ExecutionLimits::cli()
143+
};
144+
if let Some(v) = args.max_commands {
145+
limits = limits.max_commands(v);
146+
}
147+
if let Some(v) = args.max_loop_iterations {
148+
limits = limits.max_loop_iterations(v);
149+
}
150+
if let Some(v) = args.max_total_loop_iterations {
151+
limits = limits.max_total_loop_iterations(v);
152+
}
153+
if let Some(v) = args.timeout {
154+
limits = limits.timeout(std::time::Duration::from_secs(v));
155+
}
156+
builder = builder.limits(limits);
157+
158+
if mode != CliMode::Mcp {
159+
builder = builder.session_limits(bashkit::SessionLimits::unlimited());
123160
}
124161

125162
builder.build()
@@ -184,10 +221,11 @@ fn main() -> Result<()> {
184221

185222
let args = Args::parse();
186223

187-
match cli_mode(&args) {
188-
CliMode::Mcp => run_mcp(args),
224+
let mode = cli_mode(&args);
225+
match mode {
226+
CliMode::Mcp => run_mcp(args, mode),
189227
CliMode::Command | CliMode::Script => {
190-
let output = run_oneshot(args)?;
228+
let output = run_oneshot(args, mode)?;
191229
print!("{}", output.stdout);
192230
if !output.stderr.is_empty() {
193231
eprint!("{}", output.stderr);
@@ -202,21 +240,21 @@ fn main() -> Result<()> {
202240
}
203241
}
204242

205-
fn run_mcp(args: Args) -> Result<()> {
243+
fn run_mcp(args: Args, mode: CliMode) -> Result<()> {
206244
Builder::new_multi_thread()
207245
.enable_all()
208246
.build()
209247
.context("Failed to build MCP runtime")?
210-
.block_on(mcp::run(move || build_bash(&args)))
248+
.block_on(mcp::run(move || build_bash(&args, mode)))
211249
}
212250

213-
fn run_oneshot(args: Args) -> Result<RunOutput> {
251+
fn run_oneshot(args: Args, mode: CliMode) -> Result<RunOutput> {
214252
Builder::new_current_thread()
215253
.enable_all()
216254
.build()
217255
.context("Failed to build CLI runtime")?
218256
.block_on(async move {
219-
let mut bash = build_bash(&args);
257+
let mut bash = build_bash(&args, mode);
220258

221259
if let Some(cmd) = args.command {
222260
let result = bash.exec(&cmd).await.context("Failed to execute command")?;
@@ -302,7 +340,7 @@ mod tests {
302340
#[tokio::test]
303341
async fn python_enabled_by_default() {
304342
let args = Args::parse_from(["bashkit", "-c", "python --version"]);
305-
let mut bash = build_bash(&args);
343+
let mut bash = build_bash(&args, CliMode::Command);
306344
let result = bash.exec("python --version").await.expect("exec");
307345
assert_ne!(result.stderr, "python: command not found\n");
308346
}
@@ -311,23 +349,23 @@ mod tests {
311349
#[tokio::test]
312350
async fn python_can_be_disabled() {
313351
let args = Args::parse_from(["bashkit", "--no-python", "-c", "python --version"]);
314-
let mut bash = build_bash(&args);
352+
let mut bash = build_bash(&args, CliMode::Command);
315353
let result = bash.exec("python --version").await.expect("exec");
316354
assert!(result.stderr.contains("command not found"));
317355
}
318356

319357
#[tokio::test]
320358
async fn git_enabled_by_default() {
321359
let args = Args::parse_from(["bashkit", "-c", "git init /repo"]);
322-
let mut bash = build_bash(&args);
360+
let mut bash = build_bash(&args, CliMode::Command);
323361
let result = bash.exec("git init /repo").await.expect("exec");
324362
assert_eq!(result.exit_code, 0);
325363
}
326364

327365
#[tokio::test]
328366
async fn git_can_be_disabled() {
329367
let args = Args::parse_from(["bashkit", "--no-git", "-c", "git init /repo"]);
330-
let mut bash = build_bash(&args);
368+
let mut bash = build_bash(&args, CliMode::Command);
331369
let result = bash.exec("git init /repo").await.expect("exec");
332370
assert!(result.stderr.contains("not configured"));
333371
}
@@ -336,15 +374,15 @@ mod tests {
336374
async fn http_enabled_by_default() {
337375
// curl should be recognized (not "command not found") even if network fails
338376
let args = Args::parse_from(["bashkit", "-c", "curl --help"]);
339-
let mut bash = build_bash(&args);
377+
let mut bash = build_bash(&args, CliMode::Command);
340378
let result = bash.exec("curl --help").await.expect("exec");
341379
assert!(!result.stderr.contains("command not found"));
342380
}
343381

344382
#[tokio::test]
345383
async fn http_can_be_disabled() {
346384
let args = Args::parse_from(["bashkit", "--no-http", "-c", "curl https://example.com"]);
347-
let mut bash = build_bash(&args);
385+
let mut bash = build_bash(&args, CliMode::Command);
348386
let result = bash.exec("curl https://example.com").await.expect("exec");
349387
assert!(result.stderr.contains("not configured"));
350388
}
@@ -359,7 +397,7 @@ mod tests {
359397
"-c",
360398
"echo works",
361399
]);
362-
let mut bash = build_bash(&args);
400+
let mut bash = build_bash(&args, CliMode::Command);
363401
let result = bash.exec("echo works").await.expect("exec");
364402
assert_eq!(result.stdout, "works\n");
365403
assert_eq!(result.exit_code, 0);
@@ -368,7 +406,7 @@ mod tests {
368406
#[test]
369407
fn run_oneshot_executes_command_on_current_thread_runtime() {
370408
let args = Args::parse_from(["bashkit", "--no-http", "--no-git", "-c", "echo works"]);
371-
let output = run_oneshot(args).expect("run");
409+
let output = run_oneshot(args, CliMode::Command).expect("run");
372410
assert_eq!(output.stdout, "works\n");
373411
assert_eq!(output.stderr, "");
374412
assert_eq!(output.exit_code, 0);
@@ -404,7 +442,7 @@ mod tests {
404442
"-c",
405443
"cat /mnt/data/test.txt",
406444
]);
407-
let mut bash = build_bash(&args);
445+
let mut bash = build_bash(&args, CliMode::Command);
408446
let result = bash.exec("cat /mnt/data/test.txt").await.expect("exec");
409447
assert_eq!(result.stdout, "from host\n");
410448
}
@@ -422,7 +460,7 @@ mod tests {
422460
"-c",
423461
"echo result > /mnt/out/r.txt",
424462
]);
425-
let mut bash = build_bash(&args);
463+
let mut bash = build_bash(&args, CliMode::Command);
426464
bash.exec("echo result > /mnt/out/r.txt")
427465
.await
428466
.expect("exec");

crates/bashkit/src/limits.rs

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -114,6 +114,26 @@ impl ExecutionLimits {
114114
Self::default()
115115
}
116116

117+
/// Relaxed limits for CLI / interactive use.
118+
///
119+
/// Command/loop counters are effectively unlimited — the user chose to run
120+
/// the script, so counting-based limits are unhelpful. Timeout is removed
121+
/// (user has Ctrl-C). Stdout/stderr caps are raised to 10 MB.
122+
///
123+
/// Limits that guard against crashes are kept: function depth, AST depth,
124+
/// parser fuel, parser timeout, input size.
125+
pub fn cli() -> Self {
126+
Self {
127+
max_commands: usize::MAX,
128+
max_loop_iterations: usize::MAX,
129+
max_total_loop_iterations: usize::MAX,
130+
timeout: Duration::from_secs(u64::MAX / 2), // effectively no timeout
131+
max_stdout_bytes: 10_485_760, // 10 MB
132+
max_stderr_bytes: 10_485_760, // 10 MB
133+
..Self::default()
134+
}
135+
}
136+
117137
/// Set maximum command count
118138
pub fn max_commands(mut self, count: usize) -> Self {
119139
self.max_commands = count;

0 commit comments

Comments
 (0)