From 8cadf2b9ed8052f1e319a59414dd759b642bc986 Mon Sep 17 00:00:00 2001 From: Necco Ceresani Date: Tue, 1 Sep 2026 15:40:48 -0400 Subject: [PATCH] exectop: scoped process-launch monitor Shows every program one application starts, folds repetition into one row per command kind, and ranks anything unusual above the rest. Three sched tracepoints maintain one idea: the set of tgids belonging to a traced application, and the stream of execs they perform. fork propagates membership (and depth) to children, exit reaps it, exec emits when the task is in scope. The fork/exit membership pattern follows agent-lock and omp-jail; cgroup-first scoping follows hotspot's argument that a per-task attach races churn and misses anything spawned after you look. argv is read from mm->arg_start..arg_end, which is already a NUL-separated blob, rather than walking the userspace argv pointer array. That avoids the bounded-loop-over-indexed-userspace-pointers the verifier fights, and the object loads clean on 6.12 arm64 with no verifier complaints. Three scope modes. Launch mode (bin/exectop -- ) parks the target with SIGSTOP until the probe attaches, so the process tree is genuinely complete; it lives in a wrapper script because a yeet isolate deliberately cannot spawn a process. Container mode resolves a name to its root pid and cgroup through the system graph. Pid mode carries an unavoidable attach race, and says so on screen rather than implying otherwise. The outlier pass was designed wrong and real data corrected it. Rarity-first scoring flagged a third of a real npm install, because 11 of 34 distinct commands in an ordinary build run exactly once. Rarity now gates (count <= 3) but never scores: a finding also needs an observed behavior such as fetching from the network, evaluating constructed input, widening permissions, or touching credential paths. Verified across nine real workloads, six benign and three adversarial: the benign ones stay silent, including a build that legitimately curls six times, and the adversarial ones fire. test/heuristics.test.mjs runs the folding and scoring against five recorded captures with no kernel involved. The fixtures are real probe output rather than synthesized: an earlier synthetic version of this suite passed while the heuristics were badly wrong. --- CLAUDE.md | 696 +------------------------------- README.md | 351 ++++++++++++++++ assets/exectop.gif | Bin 0 -> 4140145 bytes bin/exectop | 58 +++ demo/live.sh | 182 +++++++++ demo/record.sh | 69 ++++ demo/replay.mjs | 258 ++++++++++++ demo/run.sh | 15 + demo/showcase.sh | 100 +++++ src/bpf/exectop.bpf.c | 194 +++++++++ src/components/buckets.jsx | 28 ++ src/components/footer.jsx | 24 ++ src/components/outliers.jsx | 43 ++ src/components/titlebar.jsx | 21 + src/components/tree.jsx | 98 +++++ src/components/verdict.jsx | 69 ++++ src/lib/argv.js | 50 +++ src/lib/format.js | 90 +++++ src/lib/model.js | 273 +++++++++++++ src/lib/scope.js | 78 ++++ src/main.jsx | 222 ++++++++++ src/probes/capture.js | 67 +++ src/probes/exec.js | 112 +++++ src/probes/probe.js | 28 ++ test/fixtures-cbuild.jsonl | 31 ++ test/fixtures-legit.jsonl | 10 + test/fixtures-npm-install.jsonl | 115 ++++++ test/fixtures-sneaky.jsonl | 47 +++ test/fixtures-suspicious.jsonl | 11 + test/heuristics.test.mjs | 88 ++++ 30 files changed, 2733 insertions(+), 695 deletions(-) mode change 100644 => 120000 CLAUDE.md create mode 100644 README.md create mode 100644 assets/exectop.gif create mode 100755 bin/exectop create mode 100755 demo/live.sh create mode 100755 demo/record.sh create mode 100755 demo/replay.mjs create mode 100755 demo/run.sh create mode 100755 demo/showcase.sh create mode 100644 src/bpf/exectop.bpf.c create mode 100644 src/components/buckets.jsx create mode 100644 src/components/footer.jsx create mode 100644 src/components/outliers.jsx create mode 100644 src/components/titlebar.jsx create mode 100644 src/components/tree.jsx create mode 100644 src/components/verdict.jsx create mode 100644 src/lib/argv.js create mode 100644 src/lib/format.js create mode 100644 src/lib/model.js create mode 100644 src/lib/scope.js create mode 100644 src/main.jsx create mode 100644 src/probes/capture.js create mode 100644 src/probes/exec.js create mode 100644 src/probes/probe.js create mode 100644 test/fixtures-cbuild.jsonl create mode 100644 test/fixtures-legit.jsonl create mode 100644 test/fixtures-npm-install.jsonl create mode 100644 test/fixtures-sneaky.jsonl create mode 100644 test/fixtures-suspicious.jsonl create mode 100644 test/heuristics.test.mjs diff --git a/CLAUDE.md b/CLAUDE.md deleted file mode 100644 index 1bc4fa1..0000000 --- a/CLAUDE.md +++ /dev/null @@ -1,695 +0,0 @@ -# Building yeet dashboards - -This is a **yeet** script: a reactive JSX TUI that runs in the daemon's V8 -isolate, fed by live kernel data (eBPF + a process/system graph). This file -is the API contract and gotcha list for editing it. For build/run mechanics, -layout, and the `@/`/`#/` aliases, see `README.md` — don't duplicate that here. - -## Mental model - -It reads like React but it is **signals, not a vdom**. No hooks, no -reconciliation, no `useState`. A node re-renders exactly when a signal it -*read* changes — and the only way to "read inside a node" is to pass a -**thunk** (`() => …`) as a prop or child. A plain value is static forever; a -thunk is reactive. - -```jsx -{() => `load ${load.get().toFixed(2)}`} // re-renders on load change -{`load ${load.get()}`} // snapshot, never updates -``` - -Three layers, composed: - -``` -probes/ (BPF-aware) → signals → components/ (pure UI, read signals) - ↑ - graph queries / timers -``` - -`probes/` is the *only* code that touches `yeet:bpf`; it exposes plain -signals. Components never see BPF — they read signals. `lib/` is pure helpers. - -## Build bottom-up: data → component → layout - -Build a dashboard from the inside out. Each layer is verifiable on its own, so -mistakes surface where they're cheap — at the data, not three layers up where a -blank panel could mean anything. - -### 1. Get the data right first, in isolation - -Before any JSX, confirm the kernel actually gives you the fields and types you -think it does. Guard a self-test with `import.meta.main` — it's `true` **only** -when this module is the run entry, so the block runs when you point `yeet run` -at the module and stays dormant once `main.jsx` imports it. - -Verify the **raw source**, not a `from()` signal (a `from()` producer doesn't -run until something watches it — there's no UI here): - -```js -// probes/conns.js -import { BpfObject, RingBuf } from "yeet:bpf"; -import { from } from "yeet:tui"; - -const ctl = await new BpfObject({ exe: "../bin/probe.bpf.o", base: import.meta.dirname }) - .bind("events", { kind: "ringbuf", btf_struct: "conn_event" }) - .start(); -const events = new RingBuf(ctl, "events"); - -export const conns = from((state) => { /* …wrap events into a signal… */ }, []); - -// Standalone correctness probe — dumps real records so you can eyeball field -// names, the btf_struct envelope, and which numbers came back as BigInt. -if (import.meta.main) { - await events.subscribe((w) => console.log(JSON.stringify(w, (_k, v) => - typeof v === "bigint" ? `${v}n` : v))); // JSON.stringify chokes on BigInt -} -``` - -For a graph probe the self-test is a one-shot dump: - -```js -if (import.meta.main) { - const { data } = await yeet.graph.query(QUERY); - console.log(JSON.stringify(data, null, 2)); - yeet.exit(); -} -``` - -Run it directly — `yeet run src/probes/conns.js`. **Caveat:** `@/` and `#/` are -bundle-time aliases, so a standalone module must reach its siblings by relative -path (`./probe.js`), or be bundled as its own entry. Switching `JSON.stringify` -to flag BigInt up front saves you the "why does math give NaN" detour — wrap -64-bit values with `Number(...)` once you've seen them. - -### 2. Build each component against a fake signal - -A component is a pure function of signals, so prove it in isolation with a -hand-fed signal before any real data exists. Mount just the one: - -```jsx -// scratch entry while developing components/gauge.jsx -import { mount, signal } from "yeet:tui"; -import Gauge from "@/components/gauge.jsx"; - -const fake = signal(0.3); -setInterval(() => fake.set(Math.random()), 700); // exercise the reactive path -mount(() => ); -await new Promise(() => {}); -``` - -You're checking one thing: does it repaint when the signal changes, and does it -fit its box? Get sizing and the thunk wiring right here, with data you control, -before it has to share the screen. - -### 3. Layout and routing last - -Only once the pieces work do you compose them. The layout is a single thunk -that reads the size signal (reflow on resize) and a view signal (which panel is -showing) — responsive breakpoints and "routing" are the same branch: - -```jsx -const view = signal("cpu"); -tty.on("keydown", (e) => { - if (e.key === "1") view.set("cpu"); - else if (e.key === "2") view.set("net"); -}); - -const Root = (size) => ( - - - - {() => { - const { cols } = size.get(); - if (cols < 80) return ; // responsive - switch (view.get()) { // routing - case "cpu": return ; - case "net": return ; - } - }} - -