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
11 changes: 9 additions & 2 deletions crates/bashkit/src/builtins/jobctl.rs
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@

use async_trait::async_trait;

use super::{Builtin, Context};
use super::{Builtin, BuiltinSideEffect, Context};
use crate::error::Result;
use crate::interpreter::{ExecResult, JobInfo, JobState};

Expand Down Expand Up @@ -49,7 +49,7 @@ fn parse_signal(spec: &str) -> Option<i32> {
SIGNALS.iter().find(|(n, _)| *n == name).map(|(_, v)| *v)
}

fn signal_name(n: i32) -> Option<&'static str> {
pub(crate) fn signal_name(n: i32) -> Option<&'static str> {
SIGNALS.iter().find(|(_, v)| *v == n).map(|(name, _)| *name)
}

Expand Down Expand Up @@ -135,8 +135,14 @@ impl Builtin for Kill {

let mut stderr = String::new();
let mut exit_code = 0;
let mut side_effects = Vec::new();
for target in targets {
if target.parse::<u32>() == Ok(SHELL_PID) {
// `kill -SIG $$`: the interpreter runs the trap, or ends the
// script with 128 + signal. `-0` only tests for the process.
if signal != 0 {
side_effects.push(BuiltinSideEffect::SignalSelf(signal));
}
continue;
}
let resolved = ctx.shell.as_ref().and_then(|shell| {
Expand Down Expand Up @@ -174,6 +180,7 @@ impl Builtin for Kill {
Ok(ExecResult {
stderr: stderr.into(),
exit_code,
side_effects,
..Default::default()
})
}
Expand Down
1 change: 1 addition & 0 deletions crates/bashkit/src/builtins/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -210,6 +210,7 @@ pub use iconv::Iconv;
pub use inspect::{File, Less, Stat};
pub use install::Install;
pub use introspect::{Hash, Type, Which};
pub(crate) use jobctl::signal_name;
pub use join::Join;
#[cfg(feature = "jq")]
pub use jq::Jq;
Expand Down
106 changes: 87 additions & 19 deletions crates/bashkit/src/interpreter/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -9947,6 +9947,7 @@ impl Interpreter {
self.refresh_shopt_flags();
let effects = std::mem::take(&mut result.side_effects);
let mut shift_failed = false;
let mut pending_self_signal = None;
for effect in &effects {
match effect {
builtins::BuiltinSideEffect::SetArray { name, elements } => {
Expand All @@ -9966,6 +9967,9 @@ impl Interpreter {
self.insert_array_checked(name.clone(), arr);
}
}
builtins::BuiltinSideEffect::SignalSelf(signal) => {
pending_self_signal = Some(*signal);
}
builtins::BuiltinSideEffect::RemoveArray(name) => {
self.arrays_mut().remove(name);
}
Expand Down Expand Up @@ -10022,6 +10026,41 @@ impl Interpreter {
if shift_failed {
result.exit_code = 1;
}
if let Some(signal) = pending_self_signal {
self.deliver_self_signal(signal, result).await;
}
}

/// `kill -SIG $$`: bash runs that signal's trap if one is set, and
/// otherwise lets the default action end the shell with status
/// 128 + signal. Signals whose default is to be ignored do neither.
async fn deliver_self_signal(&mut self, signal: i32, result: &mut ExecResult) {
if let Some(trap_cmd) = self.signal_trap(signal) {
let mut stdout = std::mem::take(&mut result.stdout);
let mut stderr = std::mem::take(&mut result.stderr);
let flow = self
.run_trap_command(&trap_cmd, &mut stdout, &mut stderr)
.await;
result.stdout = stdout;
result.stderr = stderr;
// An `exit` inside the handler ends the script, as it does in bash.
if let Some((ControlFlow::Exit(code), _)) = flow {
self.last_exit_code = code;
result.exit_code = code;
result.control_flow = ControlFlow::Exit(code);
}
return;
}
if matches!(
builtins::signal_name(signal),
Some("CHLD" | "URG" | "WINCH" | "CONT")
) {
return;
}
let status = 128 + signal;
self.last_exit_code = status;
result.exit_code = status;
result.control_flow = ControlFlow::Exit(status);
}

/// Resolve a path relative to cwd, normalizing `.` and `..` components.
Expand Down Expand Up @@ -11341,6 +11380,53 @@ impl Interpreter {
(crate::StreamData::new(), crate::StreamData::new())
}

/// Run a trap handler's command, merging its output into the result the
/// caller is building.
/// Returns the handler's own control flow (an `exit` inside it), if any.
async fn run_trap_command(
&mut self,
trap_cmd: &str,
stdout: &mut crate::StreamData,
stderr: &mut crate::StreamData,
) -> Option<(ControlFlow, i32)> {
// THREAT[TM-DOS-030]: Propagate interpreter parser limits.
let Ok(trap_script) = Parser::with_limits(
trap_cmd,
self.limits.max_ast_depth,
self.limits.max_parser_operations,
)
.with_execution_budget(self.execution_budget.clone())
.parse() else {
return None;
};
let was_in_trap = self.in_trap;
self.in_trap = true;
let emit_before = self.output_emit_count;
let result = self.execute_command_sequence(&trap_script.commands).await;
self.in_trap = was_in_trap;
let Ok(trap_result) = result else {
return None;
};
self.maybe_emit_output(&trap_result.stdout, &trap_result.stderr, emit_before);
stdout.append(&trap_result.stdout);
stderr.append(&trap_result.stderr);
Some((trap_result.control_flow, trap_result.exit_code))
}

/// The trap set for `signal`, under any of the spellings `trap` accepts
/// (`TERM`, `SIGTERM`, `15`).
fn signal_trap(&self, signal: i32) -> Option<String> {
let name = builtins::signal_name(signal);
let keys = [
name.map(str::to_string),
name.map(|n| format!("SIG{n}")),
Some(signal.to_string()),
];
keys.into_iter()
.flatten()
.find_map(|key| self.scoped.traps.get(&key).cloned())
}

async fn run_err_trap(
&mut self,
stdout: &mut crate::StreamData,
Expand All @@ -11352,25 +11438,7 @@ impl Interpreter {
return;
}
if let Some(trap_cmd) = self.scoped.traps.get("ERR").cloned() {
// THREAT[TM-DOS-030]: Propagate interpreter parser limits
if let Ok(trap_script) = Parser::with_limits(
&trap_cmd,
self.limits.max_ast_depth,
self.limits.max_parser_operations,
)
.with_execution_budget(self.execution_budget.clone())
.parse()
{
self.in_trap = true;
let emit_before = self.output_emit_count;
let result = self.execute_command_sequence(&trap_script.commands).await;
self.in_trap = false;
if let Ok(trap_result) = result {
self.maybe_emit_output(&trap_result.stdout, &trap_result.stderr, emit_before);
stdout.append(&trap_result.stdout);
stderr.append(&trap_result.stderr);
}
}
let _ = self.run_trap_command(&trap_cmd, stdout, stderr).await;
}
}
}
Expand Down
3 changes: 3 additions & 0 deletions crates/bashkit/src/interpreter/state.rs
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,9 @@ pub enum BuiltinSideEffect {
SetLastExitCode(i32),
/// Set a shell variable (respects local scoping via `set_variable`).
SetVariable { name: String, value: String },
/// The shell signalled itself (`kill -SIG $$`): run that signal's trap if
/// one is set, otherwise end the script with status 128 + signal.
SignalSelf(i32),
}

/// Result of executing a bash script.
Expand Down
15 changes: 13 additions & 2 deletions crates/bashkit/tests/integration/limitations_evidence_tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -97,17 +97,28 @@ async fn l_net_002_default_deny_no_resolution() {
#[tokio::test]
async fn l_sig_001_signal_traps_not_delivered() {
let mut bash = Bash::new();
// `kill -SIG $$` is the shell signalling itself, which the shell delivers:
// the trap fires. No host signal can arrive from outside, so nothing else
// ever triggers a handler.
let result = bash
.exec(r#"trap 'echo TRAPPED' INT; kill -INT $$; echo after"#)
.await
.unwrap();
assert!(
!result.stdout.contains("TRAPPED"),
"INT trap must not fire: {}",
result.stdout.contains("TRAPPED"),
"a self-sent INT must fire the trap: {}",
result.stdout
);
assert!(result.stdout.contains("after"));

// Without a handler the default action ends the script with 128 + signal.
let result = bash
.exec(r#"echo body; kill -TERM $$; echo never"#)
.await
.unwrap();
assert_eq!(result.exit_code, 143);
assert!(!result.stdout.contains("never"), "{}", result.stdout);

let result = bash
.exec(r#"trap 'echo EXITED' EXIT; echo body"#)
.await
Expand Down
40 changes: 40 additions & 0 deletions crates/bashkit/tests/spec_cases/bash/kill-self.test.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
### kill_self_status_per_signal
bash -c 'kill -9 $$' 2>/dev/null; echo "kill9=$?"
bash -c 'kill -TERM $$' 2>/dev/null; echo "term=$?"
bash -c 'kill -INT $$' 2>/dev/null; echo "int=$?"
bash -c 'kill -0 $$; echo zero=$?' 2>/dev/null
### expect
kill9=137
term=143
int=130
zero=0
### end

### kill_self_runs_the_trap
trap 'echo caught-USR1' USR1
kill -USR1 $$
echo still here
trap 'echo caught-TERM' TERM
kill -TERM $$
echo "after=$?"
### expect
caught-USR1
still here
caught-TERM
after=0
### end

### kill_self_trap_can_exit
bash -c 'trap "echo bye; exit 143" TERM; kill -TERM $$; echo never'
echo "rc=$?"
### expect
bye
rc=143
### end

### kill_self_ignored_signal
kill -CHLD $$
echo alive
### expect
alive
### end
1 change: 1 addition & 0 deletions knowledge/log.md
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@
* **Fix**: `[[ a < b ]]` and `[[ a > b ]]` compare strings instead of being swallowed as redirections (the conditional grammar has no redirects), so `[[ a < B ]]` is false in byte order like bash. `shopt` listings pad the name to 15 columns and separate the state with a tab, matching bash.
* **Fix**: arithmetic evaluation reports errors instead of yielding 0. `$((1/0))` and `$((08))` now print bash's `line N: EXPR: division by 0 (error token is "0")` / `value too great for base` and abort the command with status 1; `(( ))` and `let` print the same with their `((: ` / `let: ` prefix and fail without aborting the script. A new `arith_error` channel on the interpreter carries the failure out of the `&self` evaluator, reported where `set -u` errors are. Error lines say `bash: line N:` because the interpreter does not track the running script's path (see architecture).
* **Fix**: `{name[i]}` is accepted as an fd-variable redirect, so `exec {COPROC[1]}>&-` closes that descriptor instead of running `{COPROC[1]}` as a command. Fd-variable values resolve through array elements, which is how a coproc hands its descriptors out.
* **Feature**: the shell delivers signals it sends itself. `kill -SIG $$` runs that signal's `trap` handler (under any spelling: `TERM`, `SIGTERM`, `15`), and an `exit` inside the handler ends the script; with no handler the default action ends the script with status 128 + signal, so `kill -9 $$` exits 137 and `kill -TERM $$` 143. Signals whose default is to be ignored (CHLD, URG, WINCH, CONT) do nothing, and `kill -0 $$` still only tests. Carried by a new `SignalSelf` builtin side effect. L-SIG-001 narrowed: no signal can arrive from outside the sandbox, but a self-sent one is delivered.
* **Decision**: CPython's workload priority is agents running `python3` for scripted logic (processing a file, transforming data). Low-use stdlib modules are no longer shipped: `unittest`, `doctest`, profilers, `compileall`/`zipapp`, `dbm`/`shelve`, `plistlib`, `wave`, `netrc`, `cmd`, `tty`/`pty`, and `bz2`/`lzma`/`compression.zstd` (no C codec in the guest anyway). Stdlib zip 3.58 to 3.16 MB. L-CPY-009 updated. See [CPython WebAssembly Runtime](runtimes/cpython-wasm.md#workload-priority).
* **Feature**: `yes` and `seq` stream into pipelines. As a producer stage they write chunks into the pipe as they go (`Context::stdout_stream`), so `yes | head -1` and `seq 1000000 | head -1` stop after the first chunk with `PIPESTATUS` `141 0` instead of running to the output cap and printing a truncation notice. Step 2 of the roadmap (streaming builtin I/O), starting with the generators.
* **Feature**: `sudo` and `busybox`, the top missing commands in the gap replay (`sudo tee /etc/x <<EOF`, `busybox tar --help`). `sudo` skips its options and runs the command (leading `VAR=VALUE` through `env`, `-s`/`-i` through `bash -c`; `-v`/`-k` succeed, `-l` lists ALL); `busybox APPLET` runs the builtin of that name, `--list` lists them (L-SUDO-001). `nohup`/`nice`/`flock` no longer steal the inner command's `--help`.
Expand Down
2 changes: 1 addition & 1 deletion knowledge/operations/limitations.md
Original file line number Diff line number Diff line change
Expand Up @@ -55,7 +55,7 @@ execution model. Evidence is a threat-model ID, a test, or `stance`
| L-FS-003 | On Windows, `RealFs::symlink()` validates the target but creates an empty host file rather than a symlink/reparse point; pre-existing host symlinks and junctions remain readable subject to containment checks | Windows requires choosing file-vs-directory link semantics and may require link privileges; the portable VFS symlink contract does not carry that host metadata | TM-ESC-033 |
| L-NET-001 | No raw network sockets; HTTP only via `curl`/`wget`/`http` builtins | Allowlist-mediated egress is the only network surface | `l_net_001_no_raw_sockets` |
| L-NET-002 | No DNS resolution; hosts must appear in the allowlist | Resolution would bypass allowlist intent | `l_net_002_default_deny_no_resolution` |
| L-SIG-001 | `trap` stores INT/TERM handlers but no signal delivery in virtual mode (EXIT, ERR fire) | No host signals exist inside the sandbox | `l_sig_001_signal_traps_not_delivered` |
| L-SIG-001 | No signal arrives from outside the sandbox, so a handler only runs for a signal the script sends itself (`kill -SIG $$`, plus EXIT, ERR and DEBUG). Without a handler, a self-sent signal ends the script with 128 + signal | No host signals exist inside the sandbox | `l_sig_001_signal_traps_not_delivered` |
| L-SSH-001 | CA-signed SSH host *certificates* are rejected under `strict_host_key_checking` (default), even when the public key they wrap is a configured trusted key; configure the host's public key directly | No CA trust store exists to validate the signature chain, validity window, principals or critical options; matching the embedded key would grant trust never actually verified | TM-SSH-006, `test_strict_rejects_certificate_even_when_inner_key_is_trusted` |
| L-WASM-001 | **Removed:** JS-host timers now drive `sleep`, builtin `timeout`, execution limits, and tool `timeoutMs` | `gloo-timers` bridges the host event-loop clock without threads or cross-origin isolation | [Browser Package](../runtimes/browser-package.md) |
| L-WASM-002 | Browser build: `executeSync()` cannot run async custom builtins (fails with a clear message); use `execute()` | Single-threaded event loop can't settle a JS `Promise` without yielding | `crates/bashkit-wasm/__test__/bashkit-wasm.test.mjs` |
Expand Down
Loading