5 releases
Uses new Rust 2024
| 0.0.6 | Apr 21, 2026 |
|---|---|
| 0.0.5 | Apr 5, 2026 |
| 0.0.3 | Apr 4, 2026 |
| 0.0.2 | Apr 4, 2026 |
| 0.0.1 | Apr 3, 2026 |
#162 in Unix APIs
410KB
10K
SLoC
epsh
Embeddable POSIX shell.
It primarily exists so that Rust-based coding agents can execute shell commands in-process rather than shelling out to bash. It's a library first, binary second.
It's not an interactive shell, but an interactive shell could be built on top of it.
Why not just use bash?
Spawning bash works, but it gives you no control. You can't cancel a runaway command without killing the process group yourself. You can't capture output without pipe gymnastics. You can't run two commands with different working directories in the same process. You can't inspect the AST before execution for permission checking.
epsh gives you all of that as a Rust API.
Why not an existing shell library?
Existing embeddable shells either target interactive use (and carry the complexity of job control, line editing, history, SIGTSTP handling) or are POSIX-incomplete. epsh takes the opposite approach: strip out everything interactive and get the scripting semantics right.
No job control. No prompt. No aliases. No history. No terminal handling. This eliminates entire categories of bugs and reduces the surface to what a coding agent actually needs: run a script, get the output, check the exit code.
At a glance
- dash conformance on the mksh test suite (161/167 dash-passable tests)
- Builder API — configure shell options, sinks, timeouts, cancellation in one chain
- Per-shell working directory — no process-global state, safe for concurrent use
- Cancellation + timeout — kills child process groups within milliseconds
- Output capture via sinks — no pipe wrangling
- Parse-then-execute — inspect the AST between parsing and execution
- Process group isolation — every child gets
setpgid; cancellation kills the tree - Fork-free command substitution —
$(echo ...)runs in-process, 3.6x faster than dash - External command handler — plug in your own process spawner for sandboxing or job control
- Interactive shell primitives —
tcsetpgrp,WUNTRACED,Stoppedfor building interactive shells on top
Usage
use epsh::eval::Shell;
use epsh::parser::Parser;
use std::sync::{Arc, Mutex, atomic::{AtomicBool, Ordering}};
use std::path::PathBuf;
use std::time::Duration;
// Builder API — recommended for embedders
let stdout = Arc::new(Mutex::new(Vec::<u8>::new()));
let cancel = Arc::new(AtomicBool::new(false));
let mut shell = Shell::builder()
.cwd(PathBuf::from("/some/project"))
.errexit(true)
.pipefail(true)
.stdout_sink(stdout.clone())
.cancel_flag(cancel.clone())
.timeout(Duration::from_secs(120))
.build();
// One-shot execution
let exit_code = shell.run_script("cargo test 2>&1");
// Parse once, execute later (for permission checking / AST inspection)
let program = Parser::new("rm -rf /tmp/build").parse().unwrap();
// ... inspect program.commands, check permissions ...
let status = shell.run_program(&program);
// Output capture
let output = String::from_utf8_lossy(&stdout.lock().unwrap());
// Builtin detection (for permission systems)
use epsh::builtins::{is_builtin, BUILTIN_NAMES};
assert!(is_builtin("echo"));
assert!(!is_builtin("rm"));
Builder options
| Method | Description |
|---|---|
.cwd(PathBuf) |
Working directory (default: process cwd) |
.errexit(bool) |
Exit on error (set -e) |
.nounset(bool) |
Error on unset variables (set -u) |
.xtrace(bool) |
Print commands before execution (set -x) |
.pipefail(bool) |
Return highest nonzero pipeline status |
.noglob(bool) |
Disable pathname expansion (set -f) |
.noexec(bool) |
Parse only, do not execute commands (set -n) |
.interactive(bool) |
Enable tcsetpgrp/WUNTRACED for job control |
.stdout_sink(Arc<Mutex<dyn Write + Send>>) |
Capture stdout |
.stderr_sink(Arc<Mutex<dyn Write + Send>>) |
Capture stderr |
.cancel_flag(Arc<AtomicBool>) |
Cancellation flag |
.timeout(Duration) |
Execution deadline |
.env_clear() |
Don't inherit process environment |
.external_handler(ExternalHandler) |
Custom process spawner |
Building an interactive shell on epsh
epsh deliberately excludes interactive features (prompt, history, line editing, job control builtins). However, it provides the minimal primitives needed for an interactive shell to be built on top:
-
external_handler: Replace the default fork+exec with your own process spawner. Your handler receives expanded args and env pairs with redirections already applied to fds. This lets you own the fork/exec/wait cycle for terminal and job control. -
interactivemode: When enabled, pipelines calltcsetpgrpto give the foreground process group the terminal, andwaitpidusesWUNTRACEDto detect stopped processes.ShellError::Stopped { pid, pgid }propagates up with the pipeline's process group ID, so you can save the job and resume it later withkill(pgid, SIGCONT)+tcsetpgrp. -
BUILTIN_NAMES/is_builtin(): For command-word coloring and completion in the prompt.
Everything else — prompt rendering, line editing, history, fg/bg/jobs
builtins, signal mask management — is the interactive shell's responsibility.
epsh handles parsing, expansion, control flow, builtins, and redirections.
CLI
epsh -c 'echo hello' # run a command string
epsh -o pipefail -c 'false | true' # enable pipefail
epsh -f -c 'echo /*' # disable glob expansion
epsh -n script.sh # parse only (syntax check)
epsh script.sh # run a script file
echo 'echo hello' | epsh # read from stdin (pipe)
epsh # no args + tty → prints usage
Performance
Fork-dominated operations (the typical coding agent workload) are at parity with dash:
| Operation | vs dash | Notes |
|---|---|---|
| Builtin command substitution | 3.6x faster | Fork-free $(echo ...) |
| Heredocs | 0.8x | Faster than dash |
| Pipelines | 1.1x | posix_spawn, exec-direct |
| External commands | 1.1x | At parity |
| Tight arithmetic loops | ~28x | String allocation dominated |
Design
epsh's conformance target is dash, the Debian default /bin/sh. The
implementation was built by studying dash's source directly — not by
guessing at POSIX spec wording. Key mechanisms ported from dash:
- Single-pass word tokenization with syntax-context tracking
EV_TESTEDerrexit suppression in conditionals and&&/||in_forked_child/ev_exitto prevent double-fork and enable exec-direct- Heredoc body reading at newlines (not at parse time)
$((..))arithmetic with short-circuitnoevalfor ternary/logical ops- CTLESC escape marker for preserving backslash semantics through expansion
- PUA-based byte encoding for non-UTF-8 data preservation
Where we diverged from dash: typed AST instead of control-character strings,
Result<T, ShellError> instead of setjmp/longjmp, ExitStatus newtype
with private field and type-safe methods.
Not implemented (by design)
These live in the interactive shell layer, not in epsh:
- Job control builtins (
fg,bg,jobs) — useexternal_handler+Stopped - Prompt, history, line editing — use rustyline/reedline/etc.
- Aliases
- Here-strings (
<<<), extended globs, arrays,typeset,select
Testing
cargo test # 314 tests
cargo test --test api_stability # API surface regression tests
cargo test --test embedding # embedding API tests (31)
cargo test --test integration # shell behavior tests (122)
cargo build && perl check.pl \
-p ./target/debug/epsh \
-s check-epsh.t # 161/167 mksh conformance