docs: add SPEC.md (original vision) and README.md
This commit is contained in:
@@ -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<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.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/<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 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.
|
||||
Reference in New Issue
Block a user