diff --git a/crates/bashkit/src/interpreter/mod.rs b/crates/bashkit/src/interpreter/mod.rs index 8d6d03e0..6ed397a4 100644 --- a/crates/bashkit/src/interpreter/mod.rs +++ b/crates/bashkit/src/interpreter/mod.rs @@ -3596,12 +3596,41 @@ impl Interpreter { redirects: &[Redirect], ) -> Result { if !args.is_empty() { - let cmd = args.join(" "); - self.last_exit_code = 127; + // exec cmd args... — execute command and exit with its exit code. + // In a real shell this replaces the process; in VFS we run + exit. + // Build the command as a script string and execute it. + // Single-quote each arg to prevent re-expansion. + let mut script_str = String::new(); + for (i, arg) in args.iter().enumerate() { + if i > 0 { + script_str.push(' '); + } + script_str.push('\''); + script_str.push_str(&arg.replace('\'', "'\\''")); + script_str.push('\''); + } + + let parser = Parser::with_limits( + &script_str, + self.limits.max_ast_depth, + self.limits.max_parser_operations, + ); + let script = match parser.parse() { + Ok(s) => s, + Err(_) => { + return Ok(ExecResult::err( + format!("-bash: exec: {}: command not found\n", args[0]), + 127, + )); + } + }; + + let result = self.execute(&script).await?; + + // Signal exit so subsequent statements don't execute return Ok(ExecResult { - stderr: format!("-bash: exec: {}: command not found\n", cmd), - exit_code: 127, - ..ExecResult::default() + control_flow: ControlFlow::Return(result.exit_code), + ..result }); } for redirect in redirects { diff --git a/crates/bashkit/tests/spec_cases/bash/exec-command.test.sh b/crates/bashkit/tests/spec_cases/bash/exec-command.test.sh new file mode 100644 index 00000000..bb6b65a8 --- /dev/null +++ b/crates/bashkit/tests/spec_cases/bash/exec-command.test.sh @@ -0,0 +1,83 @@ +### exec_replaces_execution +# exec stops subsequent statements +cat > /tmp/greeter.sh <<'SCRIPT' +#!/usr/bin/env bash +echo "hello from greeter" +SCRIPT +chmod +x /tmp/greeter.sh + +cat > /tmp/dispatcher.sh <<'SCRIPT' +#!/usr/bin/env bash +echo "before exec" +exec /tmp/greeter.sh +echo "SHOULD NOT APPEAR" +SCRIPT +chmod +x /tmp/dispatcher.sh + +/tmp/dispatcher.sh +### expect +before exec +hello from greeter +### end + +### exec_propagates_exit_code +# exec propagates exit code from executed command +cat > /tmp/exit-42.sh <<'SCRIPT' +#!/usr/bin/env bash +exit 42 +SCRIPT +chmod +x /tmp/exit-42.sh + +cat > /tmp/exec-it.sh <<'SCRIPT' +#!/usr/bin/env bash +exec /tmp/exit-42.sh +SCRIPT +chmod +x /tmp/exec-it.sh + +/tmp/exec-it.sh +echo $? +### expect +42 +### end + +### exec_with_builtin +# exec with builtin command +cat > /tmp/exec-echo.sh <<'SCRIPT' +#!/usr/bin/env bash +exec echo "via exec" +echo "SHOULD NOT APPEAR" +SCRIPT +chmod +x /tmp/exec-echo.sh + +/tmp/exec-echo.sh +### expect +via exec +### end + +### exec_passes_arguments +# exec passes arguments to command +cat > /tmp/echo-args.sh <<'SCRIPT' +#!/usr/bin/env bash +echo "args: $*" +SCRIPT +chmod +x /tmp/echo-args.sh + +cat > /tmp/exec-args.sh <<'SCRIPT' +#!/usr/bin/env bash +exec /tmp/echo-args.sh one two three +SCRIPT +chmod +x /tmp/exec-args.sh + +/tmp/exec-args.sh +### expect +args: one two three +### end + +### exec_fd_redirections_still_work +# exec without command still does FD redirections +echo "file content" > /tmp/exec-test-file.txt +exec 3< /tmp/exec-test-file.txt +echo "after exec redirect" +### expect +after exec redirect +### end diff --git a/crates/bashkit/tests/threat_model_tests.rs b/crates/bashkit/tests/threat_model_tests.rs index 959636b7..fd8f6e9e 100644 --- a/crates/bashkit/tests/threat_model_tests.rs +++ b/crates/bashkit/tests/threat_model_tests.rs @@ -223,15 +223,18 @@ mod sandbox_escape { // Note: current impl stores command but doesn't execute it } - /// Test exec is not implemented (prevents shell escape) + /// Test exec cannot escape sandbox — only VFS scripts are reachable + /// + /// exec now executes commands within the VFS (run + exit). Since the VFS + /// doesn't contain /bin/bash, exec /bin/bash still fails with exit 127. + /// This preserves the security invariant: no real process replacement. #[tokio::test] async fn threat_exec_not_available() { let mut bash = Bash::new(); let result = bash.exec("exec /bin/bash").await.unwrap(); - // exec should return command not found (exit 127) + // exec tries to run /bin/bash in VFS — doesn't exist, so exit 127 assert_eq!(result.exit_code, 127); - assert!(result.stderr.contains("command not found")); } /// Test external command execution is blocked diff --git a/specs/006-threat-model.md b/specs/006-threat-model.md index 8c2a3b53..0ddef7ea 100644 --- a/specs/006-threat-model.md +++ b/specs/006-threat-model.md @@ -343,7 +343,7 @@ taking ownership via `std::mem::take`. Custom builtins persist across multiple c | ID | Threat | Attack Vector | Mitigation | Status | |----|--------|--------------|------------|--------| -| TM-ESC-005 | Shell escape | `exec /bin/bash` | exec not implemented (returns exit 127) | **MITIGATED** | +| TM-ESC-005 | Shell escape | `exec /bin/bash` | exec runs command within VFS sandbox then exits (no real process replacement); host binaries unreachable | **MITIGATED** | | TM-ESC-006 | Subprocess | `./malicious` | Script execution runs within VFS sandbox (no host shell) | **MITIGATED** | | TM-ESC-007 | Background proc | `malicious &` | Background not implemented | **MITIGATED** | | TM-ESC-008 | eval injection | `eval "$user_input"` | eval runs in sandbox (builtins only) | **MITIGATED** |