An interactive step-debugger for Bash scripts — built entirely out of things bash already ships.
No patched bash. No ptrace. No set -x archaeology. The debugger side of trapdoor is
~60 lines of pure bash injected through BASH_ENV; it talks to a Rust controller over
/dev/tcp, bash's built-in TCP socket. The DEBUG trap does the rest: every command in
your script pauses and asks permission before it runs.
Born from a wish on Ask HN: What developer tool do you wish existed in 2026?:
"No debugging interface for shell scripts — stop at a specific point in the script, modify any commands and execute the step."
So: breakpoints (conditional ones too), step / next / finish, backtraces, source
listings — and a REPL that evaluates inside the live script, so assignments stick.
Change a variable mid-loop and watch the script take the other branch.
$ trapdoor -b 'demo.sh:13 if (( count == 1 ))' -r examples/demo.sh
breakpoint #1 at demo.sh:13 if (( count == 1 ))
hello, apple
examples/demo.sh:13 [depth 1] ● breakpoint #1
→ count=$((count + 1))
(tdb) p count fruit
declare -- count="1"
declare -- fruit="banana"
(tdb) !count=40
(tdb) c
hello, banana
hello, cherry
processed 43 fruits, total=602That 43 is not a typo — !count=40 rewrote the loop counter in the running script.
New here? The 15-minute tutorial walks from first step to
patching a live script's variables mid-run.
┌─────────────────────┐ TCP 127.0.0.1:<random> ┌──────────────────────────┐
│ trapdoor (Rust) │◄───────────────────────────►│ bash your-script.sh │
│ breakpoints, REPL, │ STOP file:line:cmd ───► │ stub via BASH_ENV: │
│ step logic, source │ ◄─── GO / EVAL / BT │ exec {fd}<>/dev/tcp/… │
│ listings, colors │ │ trap '…' DEBUG │
└─────────────────────┘ └──────────────────────────┘
- The controller binds a random localhost port and launches your script with
BASH_ENVpointing at a tiny stub (src/stub.sh). - The stub opens a socket with
exec {fd}<>/dev/tcp/127.0.0.1/$PORT— nonc, nosocat, no python;/dev/tcpis interpreted by bash itself. - It arms the
DEBUGtrap (withset -o functraceso functions inherit it). Before every simple command, the stub reportsfile:line, call depth and the command text, then blocks until the controller answers. GOruns the command.EVAL <code>runs arbitrary bash in the script's own execution context — that's howp,x,!and conditional breakpoints work.BTwalksFUNCNAME/BASH_SOURCE/BASH_LINENOfor a backtrace.- If the controller dies, the stub disarms the trap and the script runs free. No zombie hostages.
cargo install --path . # or: cargo build --releaseOne static-ish binary, zero crate dependencies (std only). Works anywhere bash is
compiled with /dev/tcp support — Linux distros, macOS, and Git Bash / MSYS2 on
Windows all qualify.
trapdoor [OPTIONS] <script.sh> [script args...]
-b, --break <SPEC> breakpoint: <line> | <file>:<line> | <file>:<line> if <bash-cond>
-r, --run don't stop at the first command; run until a breakpoint
--bash <PATH> which bash to use
--no-color disable ANSI colors
At the (tdb) prompt:
| command | effect |
|---|---|
s / step |
stop at the next command, anywhere (steps into functions) |
n / next |
next command at this depth or shallower (steps over calls) |
f / finish |
run until the current function returns |
c / continue |
run until a breakpoint |
u <line> |
run until that line in the current file (one-shot) |
| enter | repeat the last motion command |
b 13 · b utils.sh:40 |
breakpoint |
b 13 if (( count == 2 )) |
conditional breakpoint — the condition is raw bash run in the script: (( … )), [[ … ]], even grep -q … |
bl / d <id> |
list / delete breakpoints |
w <bash-expr> |
watch: evaluated in the script and shown at every stop (w $count) |
wl / wd <id> |
list / delete watches |
p var… |
declare -p variables (arrays and maps print properly) |
x code / !code |
run bash in the live script — assignments stick |
bt |
backtrace |
l [line] |
source listing around the current (or given) line |
q |
kill the script and quit |
- Commands inside
$( … )command substitutions and pipeline segments run in subshells; trapdoor deliberately stays quiet there (two writers on one socket would corrupt the protocol). You still stop on the enclosing command. - The
DEBUGtrap fires per simple command, so a compound likefor …stops once at the loop head, then at each body command — same asbash -xgranularity. - Scripts that read stdin share it with the debugger REPL. Redirect one of them.
- Requires bash ≥ 4.1 (for
{fd}<>auto-allocation) built with/dev/tcp.
bashdb is venerable and more featureful, but it's a 1MB bash-in-bash interpreter you have to install on the target machine. trapdoor's target-side footprint is one temp file and one socket, injected by the environment — nothing to install where the script runs, and the brains stay in one fast binary.
MIT