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
39 changes: 34 additions & 5 deletions crates/bashkit/src/interpreter/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -3596,12 +3596,41 @@ impl Interpreter {
redirects: &[Redirect],
) -> Result<ExecResult> {
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 {
Expand Down
83 changes: 83 additions & 0 deletions crates/bashkit/tests/spec_cases/bash/exec-command.test.sh
Original file line number Diff line number Diff line change
@@ -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
9 changes: 6 additions & 3 deletions crates/bashkit/tests/threat_model_tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion specs/006-threat-model.md
Original file line number Diff line number Diff line change
Expand Up @@ -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** |
Expand Down
Loading