bsh — the bash executor engine
bsh is the part of the module that actually talks to the operating system.
While the Bash wrapper holds configuration and is
the public interface, this page documents the engine underneath: the
spawning functions, the streaming and timeout mechanics, and the complete
security pattern list.
The engine exposes one public function and two internal spawners:
| Function | Role |
|---|---|
run(timeout_ms, env, pty, command, cwd) |
Validate, then stream the command; dispatches to a spawner. |
spawn_bash(env, cwd, command, timeout_ms) |
The piped (non-PTY) spawner. |
spawn_bash_pty(env, cwd, command, timeout_ms) |
The PTY spawner (Unix only). |
Both spawners return Pin<Box<dyn Stream<Item = Result<SpawnOutput, io::Error>>>> — note the Result per item. run wraps whichever spawner
was chosen and filters out the Err items, so its stream only yields
Ok(SpawnOutput). This is the source of the “stream errors are dropped” note
on the types page.
run — the free function
pub fn run<'a>(
timeout_ms: Option<u64>,
env: &Option<Vec<(String, String)>>,
pty: bool,
command: &'a str,
cwd: &'a str,
) -> Result<Pin<Box<dyn Stream<Item = SpawnOutput> + Send + 'a>>, BashError>
run performs the two pre-spawn guards and fails with a BashError
(text_err set, exec_err None) without executing anything:
Path::new(command).is_absolute()→"absolute command not allowed, use relative path".validate_bash_patterns(command)→"Pattern match found: <regex>"for the first matching pattern.
It then selects the spawner: spawn_bash_pty when pty is true and the
platform is Unix; spawn_bash otherwise (on non-Unix platforms a pty: true
request silently degrades to the piped path). The selected stream’s Err
items are dropped as described above.
Security patterns
critical_bash_patterns() returns a lazily-initialized list of compiled
regexes; validate_bash_patterns runs a command against all of them and
errors on the first match. The list is case-insensitive where it matters. In
full:
| Category | Pattern (normalized) |
|---|---|
| Recursive destruction | rm -…r/f/R/F… /, sudo rm, chmod -R <octal> /, chmod -R <symbolic> /, chown -R <owner> / |
| Fork bomb | :(){ :|: (case-insensitive) |
| Disk / filesystem | > /dev/sd…, mkfs…, dd if=… of=/dev/…, shred /dev/…, cryptsetup |
| System-config writes | > /etc/passwd, > /etc/shadow, > /etc/sudoers, tee … /etc/passwd, tee … /etc/shadow, tee … /etc/sudoers |
| Remote-fetch-then-execute | curl/wget/fetch … | bash/sh/zsh/fish, bash/sh/zsh/source/. <(curl/wget/fetch …), eval $(curl/wget/fetch …), eval `curl/wget/fetch …` |
| Process / host control | kill -9 1, shutdown, poweroff, reboot, halt, init 0 |
| Network-shell exfil | nc … -e/-c … |
A few notes on how they are written:
- The
rm/chmod/chownpatterns demand a literal/right after the flags (\bonly anchors the start of the command word), sorm -rf /andrm -rf /tmp/xare caught whilerm -rf ./vendorpasses —./vendorstarts with., not/. - The
> /etc/…and> /dev/sd…patterns are unanchored: they match any>immediately followed by the path, so both the truncatingecho x > /etc/passwdand the appendingecho x >> /etc/passwdare blocked (the second>of>>satisfies the pattern). curl … | bashis blocked, but a download-then-run in two separate commands (curl -o /tmp/x && bash /tmp/x) is not matched.sudo rmis blocked even without/.
These are tripwires against catastrophic typos, not a sandbox. See the security note on the module page.
spawn_bash — the piped path
spawn_bash builds a tokio::process::Command::new("bash") and runs
bash -c <command> with cwd, stdin from /dev/null, and piped
stdout/stderr. It streams with a tokio::select! loop reading up to
BUFFER_SIZE (4096) bytes from stdout and stderr in parallel.
Ordering and flags:
- Reads are biased toward the timeout arm, so a deadline can preempt a data read that is already available.
- Each chunk is emitted as a
SpawnOutputwith the corresponding buffer set andtruncated: n == 4096. - When both pipes hit EOF, the loop breaks,
child.wait()runs, and the final item carries the exit status:exit_code: status.code()withsignalfromstd::os::unix::process::ExitStatusExt::signal(Unix), orexit_codeonly on non-Unix.
Timeout mechanics
timeout_ms = Some(ms) arms a sleep_until(now + ms) deadline; when it fires
the child is killed and the stream yields a final signal: Some(-1),
exit_code: None, empty-buffers item. The special case Some(0) never reads
output — the child is spawned and killed immediately — so the result is
exactly one deterministic timeout item. (The PTY path short-circuits even
earlier, before spawning; see below.)
With a nonzero timeout, output already read before the deadline is preserved — the timeout item always comes last, after any chunks the child managed to write.
Environment validation
Environment variables are validated against ^[A-Za-z_][A-Za-z0-9_]*$
before the child spawns; an invalid name yields an Err(io::Error::new( ErrorKind::InvalidInput, "invalid env variable name: …")) item and the
stream ends. Because Bash::run drops stream errors, this surfaces as an
empty stream through the wrapper.
spawn_bash_pty — the PTY path
The PTY path uses portable_pty to give the child a real terminal. It differs
from the piped path in several ways:
- Multiplexing. stdout and stderr share the terminal, so every emitted
SpawnOutputhasstderrempty. The terminal line discipline rewrites\nto\r\nand interprets control codes (colors, prompts, progress). - Sizing. The PTY is 24 rows × 80 columns.
- Bridging.
portable_pty’s I/O is synchronous, so the reader runs in atokio::task::spawn_blockingtask and sends chunks through an unboundedmpscchannel — one extra buffer copy per chunk. - Signal mapping.
portable_pty::ExitStatus::signal()returns the libcstrsignal(3)description (e.g."Killed"), not the constant name; a table maps each description back to the numeric signal so PTY and piped paths agree on thesignalfield. Unmapped descriptions fall through toNone. As in the piped path,exit_codeisNonewhen the process died by a signal,Some(n)otherwise.
The self-pipe timeout
Because the reader blocks inside a blocking thread, the async timeout cannot
just abort it. Instead, a self-pipe unblocks the blocking poll(2):
pipe2creates a pipe before the blocking task starts; the read end moves into the blocking task, the write end stays on the async side.- The blocking task’s
poll(2)watches both the PTY master fd and the pipe read end. - When the async timeout fires, it sets a kill flag, writes one byte to the
pipe write end, and closes it.
pollwakes, the blocking task sees the pipe fd readable, breaks out, kills and reaps the child, then sends the finalsignal: Some(-1)item.
The unsafe code is confined to three sites — pipe creation (pipe2), the
timeout-side write + close, and the blocking-side poll/read/close —
and each fd is created once, consumed on one side, and closed exactly once
(verified by inspection in the source).
As in the piped path, Some(0) yields the deterministic single timeout
item — but here the check runs before the process is even spawned, so the
PTY is never opened.
Summary
runvalidates (absolute path, critical patterns) then dispatches to a spawner;Bash::runhides the spawner selection and drops stream errors.- The piped path reads stdout/stderr in parallel in 4096-byte chunks and ends
with one exit-status item;
exit_codeandsignalare mutually exclusive. - Timeouts kill the child and emit a final
signal: -1item;0never reads output; partial output before the deadline is preserved. - The PTY path multiplexes through a 24×80 pseudo-terminal with a blocking reader bridged via a channel, a self-pipe for timeouts, and strsignal→number mapping.