From 98e3347b4ce238a49f346fe87a9692acdc10ccbc Mon Sep 17 00:00:00 2001 From: claude Date: Wed, 27 May 2026 21:31:36 +0000 Subject: [PATCH] docs: add SPEC.md (original vision) and README.md --- README.md | 239 ++++++++++++++++++++++++++++++++++++++++++++ SPEC.md | 288 ++++++++++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 527 insertions(+) create mode 100644 README.md create mode 100644 SPEC.md diff --git a/README.md b/README.md new file mode 100644 index 0000000..4722115 --- /dev/null +++ b/README.md @@ -0,0 +1,239 @@ +# llmdbg + +A scriptable, stateful debugger REPL with JavaScript as the interface language. +Built from the spec in [`SPEC.md`](./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)` — only `spawn` works today. +- Hardware watchpoints. +- Source-level debugging via DWARF (`gimli`). +- Multi-threaded targets; shared-library symbol resolution. +- Browser visualisation / DOM API. +- Conditional breakpoints. +- The `--summary` mode 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+). + +```bash +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 not + `undefined`), or `error: ...` 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: + +```js +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. + +```js +// 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!`. + +```js +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>>; +│ 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.pc` does a fresh `PTRACE_GETREGS`; + every `state.memory(...)` does a fresh `process_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.** `continue` is a reserved keyword in JS, so + the natural name is a parse error. The spec's `continue()` is `cont()` 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::write` writes a full + word. We `ptrace::read` first, 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, a `disasm` over a bp'd + function shows `int3; ...` at the first byte. +- **PIE load base.** Computed as `mapping_base - mapping_file_offset` of the + *first* mapping of the executable in `/proc//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 to `null` until ld.so awareness lands. +- `finish()` assumes a frame pointer (`-fno-omit-frame-pointer`, which is the + default on most distros). It will throw if `rbp == 0`. +- The sandbox guard-page detection in `state.stack.guard_size` returns 0 on + Linux configurations where the kernel handles guard implicitly without an + anonymous `---p` mapping 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. diff --git a/SPEC.md b/SPEC.md new file mode 100644 index 0000000..ff52464 --- /dev/null +++ b/SPEC.md @@ -0,0 +1,288 @@ +# LLM Debugger — Vision & Spec + +## Why + +Debuggers are built for humans at a terminal. They optimize for interactive, ad-hoc inspection: type a command, read output, think, repeat. LLMs operating as coding agents have fundamentally different needs: + +- **Predictable, terse output** — token efficiency matters; verbose JSON or gdb's human-readable output wastes context window +- **Scriptable observation** — the agent defines *what it wants to see* per step, not just *what to do* +- **Stateful sessions** — the debugger holds state across many tool calls so the LLM's context window doesn't have to +- **Live human visibility** — a human watching the agent work needs a real-time view without being in the loop + +Existing tools don't fit. gdb/lldb optimize for humans. gdb's MI protocol is unstable and incomplete. DAP is well-designed but IDE-shaped, not agent-shaped. There is no debugger built from first principles for an LLM agent as the primary user. + +--- + +## What + +A **stateful debugger REPL process** that: + +- Attaches to or spawns a Linux process via ptrace +- Exposes a rich, lazy `state` object representing the current stopped state +- Embeds a QuickJS interpreter — the LLM sends raw JavaScript, stdout comes back +- Hooks into debugger events (step, breakpoint, watchpoint) by letting the LLM register JS event listeners +- Communicates over stdin/stdout — making it trivially usable as a subprocess from any agent harness, and easily testable in a sandbox + +The LLM is the primary author of scripts. A human may observe via a browser visualization (deferred — see *Not Yet*). + +### The Core Loop + +``` +agent harness + │ raw JS (stdin) + ▼ +debugger REPL process + │ evaluates JS against live state + │ fires registered listeners on events + ▼ +stdout (terse output — tab/newline delimited) +``` + +No custom protocol. No structured command parser. The language is the interface. + +### What the LLM Can Do + +Anything expressible in JS against the `state` object. Examples ranging from trivial to sophisticated: + +```js +// One-liner inspection +state.pc +``` + +```js +// Register a step hook +addEventListener('step', e => { + let dist = e.state.sp - e.state.stack.usable_base; + print(`pc=${hex(e.state.pc)} sp_dist=${dist}`); +}); +``` + +```js +// Stateful tracking across steps via ctx +addEventListener('step', e => { + ctx.min_sp = Math.min(ctx.min_sp ?? e.state.sp, e.state.sp); + print(`pc=${hex(e.state.pc)} lowest_sp=${hex(ctx.min_sp)}`); +}); +``` + +```js +// DOM mutation for human visualization (future) +addEventListener('step', e => { + dom.id('pc').textContent = hex(e.state.pc); + print(hex(e.state.pc)); +}); +``` + +--- + +## How + +### Stack + +| Layer | Technology | Notes | +|---|---|---| +| Debugger backend | `ptrace` (Linux) | Via `nix` crate | +| Host process | Rust | CLI binary | +| Scripting | QuickJS | Via `rquickjs` crate | +| Disassembly | `iced-x86` | x86-64 | +| DWARF / unwinding | `gimli` | Frame unwinding, debug info | +| ELF / symbols | `object` | Symbol table, TLS segments | + +### Process Model + +The REPL is a **CLI daemon** — launched by the agent harness as a subprocess, communicating over stdin/stdout. One REPL process per debug session. State (breakpoints, watchpoints, `ctx`, registered listeners) persists for the lifetime of the process. + +On attach, the REPL: +1. Parses the ELF binary (symbols, TLS layout, section map) +2. Attaches via ptrace +3. Enters the read-eval-print loop + +On detach, the target process is allowed to continue (Linux ptrace default). + +### The `state` Object + +Exposed to every JS evaluation. Access is **lazy** — each property triggers a ptrace/procfs read on demand. Accessed fields are **snapshotted** after each evaluation, making them available as `prev` on the next event. + +```js +state.pc // current instruction pointer +state.sp // stack pointer +state.registers.{rax, rbx, ...} // all GP registers +state.flags.{z, c, n, v, ...} // condition flags +state.instruction.addr // address of current instruction +state.instruction.bytes // raw bytes (hex string) +state.instruction.mnemonic // e.g. "mov rsp, rax" +state.instruction.operands // array of operand strings + +state.memory(addr).u8() // typed memory accessors +state.memory(addr).u32() +state.memory(addr).u64() +state.memory(addr).i64() +state.memory(addr).bytes(n) // raw hex string, n bytes +state.memory(addr).str() // null-terminated C string + +state.disasm(n) // disassemble n instructions from pc +state.symbol_at(addr) // → string | null +state.thread_local(name) // → value (resolved via fs_base + ELF TLS offset) + +state.stack.top // highest valid stack address +state.stack.usable_base // lowest usable address (above guard) +state.stack.guard_size // guard page size in bytes + +state.unwind() // → [{frame, pc, sp, symbol}] + +prev.pc // same shape, previous stop's snapshot +prev.sp +// ... etc +``` + +### The `ctx` Object + +A plain mutable JS object, persistent across all evaluations for the lifetime of the REPL process. The LLM initialises it however it wants. Resets only on REPL restart. + +```js +ctx.my_counter = (ctx.my_counter ?? 0) + 1; +``` + +### Event Listeners + +The REPL drives the QuickJS event loop explicitly — no libuv, no async. On each debugger stop, the REPL: + +1. Determines stop reason (step, breakpoint, watchpoint, signal) +2. Looks up registered listeners for that event type +3. Calls each with an event object `{ state, prev, stop_reason }` +4. Collects printed output, returns it to stdout + +```js +addEventListener('step', handler) +addEventListener('breakpoint', handler) // fires with e.breakpoint_id +addEventListener('watchpoint', handler) // fires with e.watchpoint_id, e.access_type +addEventListener('signal', handler) // fires with e.signal (SIGSEGV etc.) +``` + +### Execution Control (Built-in Globals) + +Control flow and setup are plain JS functions available as globals — no special syntax, no parser. They compose naturally with the rest of the scripting layer. + +```js +step(n?) // step n instructions (default 1) +continue() // resume execution +attach(pid) // attach to running process +spawn(binary, ...args) // spawn and attach +detach() // detach, target continues + +breakpoint.set(addr_or_symbol, name?) // → id +breakpoint.remove(id_or_name) +breakpoint.list() // → [{id, addr, name, hits}] + +watchpoint.set(addr, 'r'|'w'|'rw', name?) +watchpoint.remove(id_or_name) +watchpoint.list() + +thread.list() // → [{tid, state, ...}] +thread.select(tid) + +symbols() // dump loaded symbol table +ctx.reset() // clear ctx object +``` + +Everything is JS. A breakpoint can be set inside a conditional, `step()` can be called in a loop, return values are inspectable. No escape hatch needed. + +### Output Format + +Terse, tab-and-newline delimited. No JSON. Designed for token efficiency: + +``` +pc 0x8020 +sp 0x7ffe0000 +instr mov rsp, rax +dist 4096 +``` + +The LLM author of the script controls output entirely via `print()`. The format above is a convention, not enforced. + +### Error Handling + +- JS exceptions → clean error string on stdout, no panic +- ptrace failures mid-script (bad address, process died) → error string on stdout with context +- Unknown `:command` → error string + +### Symbol & TLS Resolution + +On attach, the REPL parses the ELF binary and any loaded shared libraries (via `/proc/pid/maps`) to build: +- Address → symbol name map +- Symbol name → address map +- TLS variable name → `fs_base` offset map (from ELF TLS segments) + +`state.thread_local("SCHEDULER_SP")` resolves to `*(fs_base + offset)` — a direct memory read, no runtime lookup overhead. + +--- + +## Open Questions + +**Threading model during step/continue** +When multiple threads are running and a non-selected thread hits a breakpoint, does the REPL surface that as an event, silently stop that thread, or let it continue? The right answer probably depends on use case but needs a concrete default. + +**Attach vs spawn lifecycle differences** +On spawn, the REPL owns the process and killing the REPL kills the target. On attach, detach should leave the target running. Are there other meaningful differences in what state is available? + +**Batch stepping efficiency** +`:step 1000` fires the step listener 1000 times. For large step counts this may be slow. A future `--summary` mode (first, last, interesting events) would help, but the right shape of "interesting" is unclear without real usage data. + +**Memory accessor return types** +`state.memory(addr).u64()` returns a JS number, which loses precision above 2^53. For addresses and large register values this matters. Options: BigInt (awkward), hex string (loses arithmetic), or a thin wrapper type. Needs a decision before implementation. + +**Reverse execution** +ptrace doesn't support it natively. Would require record-and-replay (rr-style). Desirable but the implementation cost is high. + +--- + +## What Not + +**gdb/lldb as backend** +gdb MI is unstable across versions, incomplete, and not designed for programmatic use. The wrapper code is always a mess. lldb's C API is clean but pulls in all of LLVM. ptrace is 30 syscalls we own completely — less total work for a cleaner result. + +**DAP (Debug Adapter Protocol)** +Well-designed, but IDE-shaped. Adds significant protocol surface for a benefit (editor integration) that isn't the goal. The LLM doesn't need a DAP client. + +**Python as scripting layer** +Best LLM familiarity, but `pyo3` embedding is heavy, startup is slow, and multi-line string ergonomics in a single tool call are worse than JS. QuickJS wins on the embedded use case. + +**Lua as scripting layer** +Lightweight and excellent for embedding. Loses to JS specifically because DOM manipulation is the other major scripting use case, and `dom.id('x').textContent = y` is native JS idiom. LLMs have also seen vastly more JS than Lua. + +**Emulator/sandbox backend (Unicorn, qemu-user)** +Deterministic and safe, but no real syscalls, no real threads, no signals. The goal is debugging real programs in production-like conditions. + +**Structured command protocol** +A JSON or XML command/response protocol between harness and REPL adds parsing complexity on both ends for no benefit. stdin/stdout with raw JS in and terse text out is simpler and more flexible. + +**Eager state snapshots** +Snapshotting all ptrace-readable state on every stop wastes syscalls when scripts touch 3–5 fields. Lazy access with snapshot-on-read is the right default. + +--- + +## What Not Yet (MVP Scope) + +**Browser visualization / DOM API** +The `dom` object and SSE transport to a browser (Datastar or otherwise) are architecturally clean to add later — the JS scripting layer is already there. Not in MVP. + +**Rich browser inspection surfaces** +The hook author (LLM or human) knows the semantic meaning of memory that the compiler forgot. This opens up a class of visualization far beyond register tables and SP gauges. A pixel buffer becomes a canvas image (`putImageData`). A mesh becomes a Three.js render. A ring buffer becomes a waveform or spectrogram via Web Audio. An actor graph becomes a D3 force layout. The DOM API design should be rich enough to support canvas, WebGL, and Web Audio — not just `textContent` mutations — so this possibility isn't accidentally closed off. Implementation deferred until the basic visualization layer exists. + +**DWARF locals and source-level debugging** +`gimli` can give us source lines and local variable names. High value, significant implementation work. Frame unwinding is in MVP; locals are not. + +**Conditional breakpoints** +`:break --condition "state.sp < 0x1000"` is a natural extension. Defer until the core hook system is proven. + +**Multi-threaded event routing** +Selecting threads and stepping them is in MVP. Simultaneous multi-thread event dispatch (all threads running, any can hit a breakpoint) is a harder scheduling problem — defer. + +**Reverse execution / time-travel** +Requires record-and-replay infrastructure. Significant project in itself. + +**Remote debugging** +gdbserver-style attach over a socket. The stdin/stdout model works locally; network transport is a later concern. + +**Shared library / dynamic linker awareness** +Symbols from dynamically loaded libraries (via `dlopen`) won't be in the initial ELF parse. Watching `ld.so` events to update the symbol table at runtime is deferred.