llmdbg
A scriptable, stateful debugger REPL with JavaScript as the interface language.
Built from the spec in SPEC.md. This repo holds the v0.1–v0.6
implementation: enough to actually drive a debugged process from an agent
harness with breakpoints, disassembly, and symbol-aware addresses.
The premise: existing debuggers optimise for humans at a terminal. An LLM
agent is a different user — it wants terse output it can parse, lazy state
access, and a scripting layer rich enough that "describe what you want to see
each step" is the natural interface. The full motivation is in SPEC.md.
Status
| Version | What landed |
|---|---|
| v0.1 | Spawn, step, continue, basic state (pc/sp/regs/memory), persistent ctx, print(), JS prelude loaded via include_str! |
| v0.2 | prev register snapshot before each resume; refactored register accessors |
| v0.3 | ELF symbol parsing of main exe; sym(addr) / addr(name) / symbols(); addrstr / here prelude helpers |
| v0.4 | INT3 software breakpoints with by-name/by-id ops; auto disarm-step-rearm; RIP rewind on hit |
| v0.5 | x86-64 disassembly via iced-x86; read_mem_pristine() overlays original bytes through active INT3s; branch targets get symbolised |
| v0.6 | state.stack; until(loc); finish(); show.{disasm,bp,regs} prelude helpers |
Each vN corresponds to one git commit. See git log --oneline.
Not yet implemented
The spec calls for these; they're deliberately deferred:
- Event listener system (
addEventListener('step', ...)etc.) — current model is synchronous send-script-get-output; events come next. attach(pid)— onlyspawnworks today.- Hardware watchpoints.
- Source-level debugging via DWARF (
gimli). - Multi-threaded targets; shared-library symbol resolution.
- Browser visualisation / DOM API.
- Conditional breakpoints.
- The
--summarymode for batch stepping. - Reverse execution.
The complete "what not yet" list is in SPEC.md.
Build
Requires a Linux x86-64 host with ptrace permitted (no yama ptrace_scope
restriction in the way) and a recent stable Rust toolchain (1.87+).
cargo build # debug
cargo build --release # ~3-5x faster
QuickJS builds via rquickjs-sys's pre-generated bindings; no libclang or
bindgen needed on the supported platforms.
Run
The binary is a daemon. It reads JS scripts from stdin and writes results to stdout, with sentinels separating turns:
$ ./target/debug/llmdbg <<'EOF'
spawn("/tmp/testprog")
bp.set("main")
cont()
show.disasm(5)
---END---
EOF
Protocol:
- Read lines until a line containing exactly
---END---. - Evaluate the accumulated buffer as one JS script in the persistent context.
- Print: any
print()output, then the final expression's value (when notundefined), orerror: ...if the eval threw. - Print
---DONE---and flush. - Repeat.
- EOF on stdin exits cleanly. If a target is still alive, it gets SIGKILL'd.
Quick tour
A representative session against a small C program:
spawn("/tmp/testprog")
bp.set("main")
bp.set("add", "add_bp")
show.bp()
// #1 0x55ca2862c1e8 (main) hits=0 enabled
// #2 "add_bp" 0x55ca2862c149 (add) hits=0 enabled
---END---
cont() // -> "breakpoint:1"
here() // -> 0x55ca2862c1e8 (main)
show.disasm(5)
// 0x55ca2862c1e8 f30f1efa endbr64
// 0x55ca2862c1ec 55 push rbp
// ...
---END---
cont() // -> "breakpoint:2"
here() // -> 0x55ca2862c149 (add)
show.regs(["rip","rdi","rsi"]) // (rdi, rsi are the first two args)
// rip=0x55... rdi=0x5 rsi=0x1
---END---
step(5)
finish() // -> "breakpoint:N", back at caller
state.registers.rax & 0xffffffffn // -> 0x6 (the eax return value)
---END---
cont() // -> "exited:0"
---END---
The native surface
Everything below is installed in the global scope by the Rust side. The JS
prelude (src/prelude.js) adds the helpers listed at the bottom.
// Process control
spawn(path, ...args) // -> { pid, status: "stopped_at_entry" }
step(n = 1) // -> stop-reason string
cont() // -> stop-reason string (NOT `continue` — JS keyword)
// Stop reasons (returned as strings):
// "step", "breakpoint:N", "signal:SIGSEGV", "exited:0", "killed:SIGKILL"
// Live state (each property re-reads via ptrace on access)
state.pc // -> BigInt
state.sp // -> BigInt
state.registers // -> { rax, rbx, ..., r15, eflags } all BigInt
state.memory(addr) // -> { u8(), u32(), u64(), i64(), bytes(n) }
state.disasm(n = 8) // -> [{addr, bytes, text, target?, target_sym?}]
state.stack // -> { top, usable_base, guard_size }
// Snapshot before the most recent step/cont — same shape as state for regs.
// Throws if no step has happened yet.
prev.pc / prev.sp / prev.registers
// Symbols (main exe only; shared libs deferred)
sym(addr) // -> "name+0xN" | "name" | null
addr(name) // -> BigInt | null
symbols() // -> { count, load_base, path }
disasm(addr, n = 8) // -> same shape as state.disasm
// Breakpoints (`breakpoint` and `bp` are the same object)
bp.set(addr_or_symbol, name?) // -> id
bp.remove(id_or_name)
bp.list() // -> [{id, addr, name, hits, enabled}]
// User scratchpad (persistent across all evals)
ctx.something = whatever
Prelude (src/prelude.js)
Pure JS, loaded last so it can reference the natives. Edit it freely; it gets
re-embedded into the binary on every cargo build via include_str!.
hex(v) // -> "0x..." for Number or BigInt
unhex(s) // -> BigInt (handles "0x", case, sign)
dump(label, v) // -> print one terse "label\tvalue" line
repr(v) // -> string form; hex for numerics, JSON for objects
addrstr(v) // -> "0xN (sym+0xoff)" when sym is known, else "0xN"
here(label?) // -> print addrstr(state.pc), optionally labelled
until(loc) // one-shot bp at addr-or-symbol, cont, cleanup
finish() // until([rbp+8]) — assumes frame pointer
show.disasm(n=8, [at])
show.bp()
show.regs([keys])
Architecture
src/
├── main.rs ─ stdin read loop, ---END--- framing, SIGKILL on exit
├── target.rs ─ ptrace child management: spawn, step, cont, regs,
│ process_vm_readv, breakpoint patching (read-byte+poke-byte
│ via PEEKDATA/POKEDATA word RMW)
├── symbols.rs ─ ELF parsing via `object`; PIE load-base from /proc/maps;
│ StackRegion from /proc/maps
├── breakpoint.rs ─ BreakpointSet storage (by id, by addr, by name)
├── disasm.rs ─ thin wrapper around iced-x86 FastFormatter; emits records
│ with branch_target for caller symbolisation
├── js.rs ─ Runtime + Context + Rc<RefCell<Option<Target>>>;
│ installs all native globals, then evals prelude.js
└── prelude.js ─ embedded at compile time
A few decisions worth knowing
- BigInt for u64. A JS Number above 2⁵³ loses precision, which is exactly
the range we live in.
state.pc,addr(...), anything 64-bit goes in and out as BigInt.state.memory(addr).u8()and.u32()stay as Number. - Lazy state. Every access to
state.pcdoes a freshPTRACE_GETREGS; everystate.memory(...)does a freshprocess_vm_readv. A script that touches three fields makes three syscalls, not 20. Worth it for scripts; the cache would just be a footgun once you have multi-threaded targets. continue()doesn't exist.continueis a reserved keyword in JS, so the natural name is a parse error. The spec'scontinue()iscont()here.- Single QuickJS context for the daemon's lifetime.
ctx(the JS scratchpad), registered listeners (when they land), and any user-defined helpers all persist. Only resets on daemon restart. - Read-modify-write breakpoint patching.
ptrace::writewrites a full word. Weptrace::readfirst, modify byte 0, write the word back. The other seven bytes stay unchanged because we just read them. read_mem_pristine. Used by the disassembler so it sees the original byte, not 0xCC, at active breakpoints. Without this, adisasmover a bp'd function showsint3; ...at the first byte.- PIE load base. Computed as
mapping_base - mapping_file_offsetof the first mapping of the executable in/proc/<pid>/maps. Earlier versions used the first executable mapping and dropped the file offset, which is wrong on toolchains that split read-only header pages.
Caveats
- Linux x86-64 only. No multi-arch, no other OSes.
- Single-threaded debuggees. A pthread'd binary will behave oddly the moment a non-main thread does anything.
- Symbols only cover the main executable. Shared-library functions (e.g.
printf,malloc) resolve tonulluntil ld.so awareness lands. finish()assumes a frame pointer (-fno-omit-frame-pointer, which is the default on most distros). It will throw ifrbp == 0.- The sandbox guard-page detection in
state.stack.guard_sizereturns 0 on Linux configurations where the kernel handles guard implicitly without an anonymous---pmapping below the stack. That's most modern setups. - No
attach(pid)yet — only spawn.
License
Personal experiment, no license declared. Treat as all-rights-reserved by the author until otherwise noted.