Bring jerboa-secmon to secmon parity

ober

0b06ccd523fb1e21fac97269d375d5f51c20d237

diff --git a/Makefile b/Makefile
index 64d1349..66edeb0 100644
--- a/Makefile
+++ b/Makefile
@@ -8,7 +8,7 @@ SCHEME ?= $(JERBOA)/.chez/bin/scheme
 BUILD  ?= build/rust
 TYPED  := $(wildcard typed/*.ss)
 
-.PHONY: rust test ffi-demo kernels-check triage-check triage-store-check analytics-check detect-check storage-check entity-check threats-check geoip-check sigma-check yaml-rules-check buffer-check dns-sniffer-check suspicious-check netconn-check kernmod-check selinux-check container-check dns-servers-check sensitive-path-check dtrace-parse-check proc-linux-check freebsd-parse-check event-meta-check config-check event-danger-check persistence-check file-change-check webshell-check platform-mounts-check analyze-cli-check collector-cli-check event-summary-check ioc-check frame-check correlate-check revshell-check cron-check logtamper-check detection-rules-check ipaddr-check auth-check lolbin-check dga-check calendar-check monitor-process-check monitor-network-check monitor-files-check monitor-auth-check monitor-kernel-check monitor-cron-check monitor-container-check monitor-rootkit-check monitor-podman-check monitor-selinux-check monitor-lateral-check monitor-webshell-check monitor-revshell-check monitor-persistence-check monitor-logtamper-check monitor-dns-check monitor-manager-check event-json-check collector-check protocol-check event-codec-check collector-pull-check agent-server-check checks native-runtime keygen analyze collector agent binaries clean
+.PHONY: rust test ffi-demo kernels-check triage-check triage-store-check analytics-check detect-check storage-check entity-check threats-check geoip-check sigma-check yaml-rules-check buffer-check dns-sniffer-check suspicious-check netconn-check kernmod-check selinux-check container-check dns-servers-check sensitive-path-check dtrace-parse-check dtrace-runtime-check stealth-check ebpf-events-check ebpf-runtime-check proc-linux-check freebsd-parse-check event-meta-check config-check privdrop-check event-danger-check persistence-check file-change-check webshell-check platform-mounts-check analyze-cli-check collector-cli-check event-summary-check ioc-check frame-check correlate-check revshell-check cron-check logtamper-check detection-rules-check ipaddr-check auth-check lolbin-check dga-check calendar-check monitor-process-check monitor-network-check monitor-files-check monitor-auth-check monitor-kernel-check monitor-cron-check monitor-container-check monitor-rootkit-check monitor-podman-check monitor-selinux-check monitor-lateral-check monitor-webshell-check monitor-revshell-check monitor-persistence-check monitor-logtamper-check monitor-dns-check monitor-manager-check event-json-check collector-check protocol-check event-codec-check local-store-check collector-pull-check agent-server-check checks native-runtime keygen analyze collector agent binaries clean
 # Combined libdir path so sibling libraries `(jsecmon ...)` resolve to ./jsecmon
 # (a second --libdirs would replace, not append, the jerboa one).
 LIBDIRS := "$(JERBOA)/lib:$(CURDIR)"
@@ -167,6 +167,28 @@ sensitive-path-check:
 dtrace-parse-check:
 	$(SCHEME) --libdirs $(LIBDIRS) --script examples/dtrace_parse_check.ss
 
+# DTrace runtime shell (secmon dtrace::{mod,consumer}): support guard, combined
+# D script, direct libdtrace/subprocess entrypoints, and EventParser drain loop.
+dtrace-runtime-check:
+	$(SCHEME) --libdirs $(LIBDIRS) --script examples/dtrace_runtime_check.ss
+
+# Stealth runtime helpers (secmon stealth::{anti_debug,integrity,init}): pure
+# anti-debug parsers/tables and runtime-shell decision helpers. The real ptrace,
+# process-name, mlockall, and watchdog effects stay in jsecmon/stealth.ss.
+stealth-check:
+	$(SCHEME) --libdirs $(LIBDIRS) --script examples/stealth_check.ss
+
+# eBPF userspace event decoder (secmon ebpf/events.rs + loader.rs parse_event):
+# packed Event bytes -> SecurityEvent-shaped rows for the high-fidelity Linux
+# path. Live loading/attaching BPF is covered by the runtime shell target below.
+ebpf-events-check:
+	$(SCHEME) --libdirs $(LIBDIRS) --script examples/ebpf_events_check.ss
+
+# eBPF runtime shell: Linux support guard, libbpf tracepoint attach table, event
+# stream, and perf-byte drain through parse_event. Live attach needs Linux+root.
+ebpf-runtime-check:
+	$(SCHEME) --libdirs $(LIBDIRS) --script examples/ebpf_runtime_check.ss
+
 # Linux /proc parsers (secmon src/platform/linux.rs): parse_stat (ppid+comm),
 # parse_uid, hex_to_state (TCP), parse_ipv4/ipv6/addr (/proc/net hex). Pure
 # text/number parsing — the file reads are the deferred I/O — no native lib.
@@ -205,6 +227,12 @@ event-meta-check:
 config-check:
 	$(SCHEME) --libdirs $(LIBDIRS) --script examples/config_check.ss
 
+# Agent privilege drop (secmon src/bin/agent.rs): passwd lookup and the required
+# setgroups -> setgid -> setuid order. The real POSIX calls are callback-injected
+# so the check never changes the current process credentials.
+privdrop-check:
+	$(SCHEME) --libdirs $(LIBDIRS) --script examples/privdrop_check.ss
+
 # Mount/capability danger predicates (secmon monitor/events.rs): MountEventInfo
 # is_dangerous (sensitive bind-mount sources / mount-over targets) and
 # CapabilityEventInfo dangerous_caps (cap_effective bit set -> cap names). These
@@ -285,6 +313,13 @@ protocol-check:
 event-codec-check:
 	$(SCHEME) --libdirs $(LIBDIRS) --script examples/event_codec_check.ss
 
+# Local encrypted event store (secmon src/local_store.rs): SQLite schema,
+# load-or-generate 32-byte key, metadata columns, AES-GCM encrypted
+# SecurityEvent payloads, ordered polling, and cleanup semantics.
+local-store-check: rust
+	cd $(BUILD) && cargo build --release
+	$(LOADER_ENV) $(SCHEME) --libdirs $(LIBDIRS) --script examples/local_store_check.ss
+
 # End-to-end collector pull (no socket): the exact byte pipeline bin/collector.ss
 # drives -- ECIES + PSK-transport + framed bincode + SecurityEvent codec compose,
 # plus the handshake leg and a wrong-key negative. Uses the rust crypto kernels.
@@ -296,7 +331,7 @@ collector-pull-check: rust
 # buffer storage, and a real loopback collector-style socket exchange.
 agent-server-check: rust
 	cd $(BUILD) && cargo build --release
-	$(SCHEME) --libdirs $(LIBDIRS) --script examples/agent_server_check.ss
+	$(LOADER_ENV) $(SCHEME) --libdirs $(LIBDIRS) --script examples/agent_server_check.ss
 
 # Cross-event correlation rules (secmon storage/mod.rs detect_*), pure cores over
 # pre-shaped rows. Batch 1 GROUP-BY: brute_force/credential_stuffing (≥5 / 10min),
@@ -495,10 +530,15 @@ checks: kernels-check
 	$(SCHEME) --libdirs $(LIBDIRS) --script examples/dns_servers_check.ss
 	$(SCHEME) --libdirs $(LIBDIRS) --script examples/sensitive_path_check.ss
 	$(SCHEME) --libdirs $(LIBDIRS) --script examples/dtrace_parse_check.ss
+	$(SCHEME) --libdirs $(LIBDIRS) --script examples/dtrace_runtime_check.ss
+	$(SCHEME) --libdirs $(LIBDIRS) --script examples/stealth_check.ss
+	$(SCHEME) --libdirs $(LIBDIRS) --script examples/ebpf_events_check.ss
+	$(SCHEME) --libdirs $(LIBDIRS) --script examples/ebpf_runtime_check.ss
 	$(SCHEME) --libdirs $(LIBDIRS) --script examples/proc_linux_check.ss
 	$(SCHEME) --libdirs $(LIBDIRS) --script examples/freebsd_parse_check.ss
 	$(SCHEME) --libdirs $(LIBDIRS) --script examples/event_meta_check.ss
 	$(SCHEME) --libdirs $(LIBDIRS) --script examples/config_check.ss
+	$(SCHEME) --libdirs $(LIBDIRS) --script examples/privdrop_check.ss
 	$(SCHEME) --libdirs $(LIBDIRS) --script examples/event_danger_check.ss
 	$(SCHEME) --libdirs $(LIBDIRS) --script examples/persistence_check.ss
 	$(SCHEME) --libdirs $(LIBDIRS) --script examples/file_change_check.ss
@@ -511,8 +551,9 @@ checks: kernels-check
 	$(SCHEME) --libdirs $(LIBDIRS) --script examples/frame_check.ss
 	$(SCHEME) --libdirs $(LIBDIRS) --script examples/protocol_check.ss
 	$(SCHEME) --libdirs $(LIBDIRS) --script examples/event_codec_check.ss
+	$(LOADER_ENV) $(SCHEME) --libdirs $(LIBDIRS) --script examples/local_store_check.ss
 	$(SCHEME) --libdirs $(LIBDIRS) --script examples/collector_pull_check.ss
-	$(SCHEME) --libdirs $(LIBDIRS) --script examples/agent_server_check.ss
+	$(LOADER_ENV) $(SCHEME) --libdirs $(LIBDIRS) --script examples/agent_server_check.ss
 	$(SCHEME) --libdirs $(LIBDIRS) --script examples/correlate_check.ss
 	$(SCHEME) --libdirs $(LIBDIRS) --script examples/revshell_check.ss
 	$(SCHEME) --libdirs $(LIBDIRS) --script examples/cron_check.ss
diff --git a/README.md b/README.md
index 4cf1f64..8c24cfb 100644
--- a/README.md
+++ b/README.md
@@ -40,11 +40,15 @@ make selinux-check   # SELinux audit-log parser: AVC + boolean/policy/role event
 make container-check # container/jail escape mount classifier (host bind, docker sock)
 make dns-servers-check # resolv.conf nameserver parse + public-resolver union
 make sensitive-path-check # DTrace sensitive-path classifier (passwd/ssh/cron/...)
-make dtrace-parse-check # DTrace SECMON|TYPE|... line parser (exec/exit/connect/...)
+make dtrace-parse-check # DTrace SECMON|TYPE|... parser + stateful EventParser rows
+make dtrace-runtime-check # DTrace direct libdtrace/subprocess runtime shell + EventParser drain
+make stealth-check # stealth init helpers: anti-debug parsers/tables + runtime decisions
+make ebpf-runtime-check # eBPF support guard + libbpf tracepoint/perf stream shell
 make proc-linux-check # Linux /proc parsers: stat ppid+comm, uid, TCP state, net hex IP, environ scan
 make freebsd-parse-check # FreeBSD kldstat/ps/address + sockstat & netstat connection lines
 make event-meta-check # event-type -> display severity + coarse store-priority u8 tables
 make config-check    # AgentConfig defaults + from_env merge + platform db/key paths
+make privdrop-check  # agent post-bind privilege drop: passwd lookup + setgroups/setgid/setuid order
 make event-danger-check # mount is_dangerous + capability dangerous_caps + namespace ns_types
 make persistence-check # classify_path (-> persistence type) + suspicious-content line scan
 make file-change-check # is_suspicious_change: setuid/setgid added, critical files, sensitive dirs
@@ -65,8 +69,8 @@ make auth-check      # auth-log parsers: sshd/sudo/su/pam/useradd/userdel/passwd
 make monitor-process-check # process monitor scan loop: pid diff/classify/emit over fixture provider
 make monitor-network-check # network monitor scan loop: listener/connection dedup+classify over fixture
 make monitor-files-check   # file integrity monitor: detect-change priority + new-file discovery
-make monitor-dns-check     # dns monitor connection-polling fallback: port-53 filter + 5 s dedup window
-make monitor-podman-check  # podman polling fallback: start/remove container deltas
+make monitor-dns-check     # dns monitor capture stream + connection-polling fallback
+make monitor-podman-check  # podman event-stream parser + polling fallback deltas
 make monitor-selinux-check # SELinux monitor loop: mode changes + tailed audit events
 make monitor-lateral-check # lateral monitor loop: internal service use + port scan tracking
 make monitor-webshell-check # webshell monitor loop: suspicious web-server descendants
@@ -74,7 +78,7 @@ make monitor-revshell-check # reverse-shell monitor loop: connection + cmdline s
 make monitor-manager-check # monitor manager: boot + one full polling cycle (fixture)
 make event-json-check      # event JSON serializer: every Rust EventType variant
 make collector-check       # agent collector loop: boot+tick into a real SQLite store, rows queried back
-make agent-server-check    # agent poll server: PSK auth + request loop over a real TCP socket
+make agent-server-check    # agent poll server: PSK auth, dispatcher, local-store status, socket lifecycle
 make checks          # every Jerboa-side check in one shot
 ```
 
@@ -125,7 +129,7 @@ tests stay as dev-time `.ss` scripts (the test harness, not shipped).
 | `bin/keygen`     | `bin/keygen.ss` → `make keygen` | ✅ **compiled binary** — proves the build pipeline (WPO → boot image → static-FFI link). Self-contained native executable; output matches `secmon-keygen` byte-for-byte (ECIES pub/priv + 32-byte PSK as lowercase hex from the OS CSPRNG via the x25519 + hex kernels). Verified to run standalone with the kernel dylib off every search path. |
 | `bin/analyze`    | `bin/analyze.ss` → `make analyze` | ✅ **compiled binary** — assembles `run_detections` itself over `(jsecmon storage)`; summary/query/anomalies/first-seen/timeline/retention all wired to the ported detection + analytics layers. |
 | `bin/collector`  | `bin/collector.ss` → `make collector` | ✅ **compiled binary** — the agent **pull** client: PSK handshake (Challenge → ChallengeResponse) over the transport envelope, then `get_events_after`/`status` requests; each `SerializedEvent` is ECIES-decrypted to a `SecurityEvent` and printed (`--format human`/`json`, secmon-faithful) or persisted to SQLite (`--db`, advancing `collector_state`). `poll`/`status`/`watch` subcommands. Verified end-to-end over a real TCP socket (`examples/fake_agent.ss` loopback) and byte-wise without a socket (`make collector-pull-check`). Loads keys from env/file at runtime, never embedded. |
-| `bin/agent`      | `bin/agent.ss` → `make agent` | ✅ **compiled binary** — Linux polling-agent shell: loads the collector public key + PSK from env/file at runtime, starts the PSK-authenticated pull server, ECIES-encrypts `SecurityEvent` bytes into the priority buffer, emits `agent_start`/`heartbeat`, and drains the full provider-backed polling set (`process`, `network`, `files`, `auth`, `kernel`, `scheduled`, `container`, `rootkit`, `podman`, `selinux`, `persistence`, `lateral`, `logtamper`, `webshell`, `revshell`, `dns`) when `/proc` is available. Verified with `jsecmon-collector status`/`poll` against the compiled agent on a real loopback TCP socket. |
+| `bin/agent`      | `bin/agent.ss` → `make agent` | ✅ **compiled binary** — runtime shell: loads the collector public key + PSK from env/file, opens the local encrypted event store when configured, starts the PSK-authenticated pull server, ECIES-encrypts `SecurityEvent` bytes into the priority buffer, persists plaintext events locally, emits `agent_start`/`heartbeat`, initializes stealth before key/config work, and drops to `nobody` after privileged resources are open. On Linux it prefers the eBPF stream and falls back to the provider-backed polling set (`process`, `network`, `files`, `auth`, `kernel`, `scheduled`, `container`, `rootkit`, `podman`, `selinux`, `persistence`, `lateral`, `logtamper`, `webshell`, `revshell`, `dns`). On FreeBSD it wires the DTrace direct/subprocess stream before privilege drop. Verified with `jsecmon-collector status`/`poll` against the compiled agent on a real loopback TCP socket; `agent-server-check` also pins the local-store status fields. |
 
 ## Port status
 
@@ -149,7 +153,7 @@ then crypto orchestration, then I/O / async / FFI (monitors, server, storage).
 | `storage::detect_kill_chain` (chain core) | `jsecmon/analytics.ss` | ✅ **untyped layer** — the pure multi-phase kill-chain detector: given `(host ts-ms event-type)` rows pre-sorted by host then ts (SQL fetch deferred), slide from each i over the same-host run with `ts ≤ ts_i + window-ms`, map each type to an ATT&CK-ish phase via `event-type->attack-phase` (also exported; unmapped types skipped), and when the **distinct** phases reach `min-phases` (3) emit a chain then skip past it (Rust `i = j`), else advance one. Emits `((host …) (window-start …) (window-end …) (phases …) (event-types …))`; Rust collects phases from an unordered `HashSet`, so `phases` is canonicalized to first-seen order (treat as a set) while `event-types` keeps phase-mapped types in order. `make analytics-check` adds the classifier table, three-phases, two-distinct-only, unmapped-skip, host-boundary, window-edge, past-edge, two-chains-after-skip, and empty cases. |
 | `storage::detect_off_hours` (predicate) | `jsecmon/analytics.ss` | ✅ **untyped layer** — `off-hours?`, the decision rule factored out of the SQL `WHERE`: a critical/high event is off-hours on a weekend or outside 08:00–18:00 UTC (`weekday` = strftime `%w` 0=Sun…6=Sat, `hour` = `%H` 0–23). The timestamp→(weekday,hour) decomposition is calendar-deferred. `make analytics-check` adds weekend, midday, and the 08:00/17:00/18:00 boundaries. |
 | `storage::anomaly_rule_attack` (tactic tagger) | `jsecmon/analytics.ss` | ✅ **untyped layer** — the pure rule-name→ATT&CK-tactic table `detect_anomalies` stamps onto each anomaly: `kill_chain`→TA0001/TA0008/TA0010, `off_hours`→TA0005, every other rule→none. `make analytics-check` covers both tagged rules plus untagged/unknown. |
-| `storage::detect_lolbin_cmdline` + `detect_dga_domain` | `jsecmon/detect.ss` | ✅ **untyped layer** — the kernel-driven detection rules: score every process_start cmdline (lolbin) / dns_query (dga) into anomalies above threshold. `make detect-check` runs the full events→detect→analytics pipeline; all three scoring kernels fire. This is the *live* pipeline shape (event hash-tables → anomaly hash-tables for `(jsecmon analytics)`); the faithful storage-Rule ports against secmon's `test_detect_*` vectors — with the per-pattern lolbin label breakdown and the label-level DGA dedup + reason list — live in `jsecmon/lolbin.ss` and `jsecmon/dga.ss`. The `dga-label` kernel that label-level dedup needed now exists, so this pipeline's coarser full-query-name dedup can be upgraded to match. |
+| `storage::detect_lolbin_cmdline` + `detect_dga_domain` | `jsecmon/detect.ss` | ✅ **untyped layer** — the kernel-driven detection rules: score every process_start cmdline (lolbin) / dns_query (dga) into anomalies above threshold. `make detect-check` runs the full events→detect→analytics pipeline; all three scoring kernels fire. This is the *live* pipeline shape (event hash-tables → anomaly hash-tables for `(jsecmon analytics)`); the faithful storage-Rule ports against secmon's `test_detect_*` vectors — with the per-pattern lolbin label breakdown and the label-level DGA dedup + reason list — live in `jsecmon/lolbin.ss` and `jsecmon/dga.ss`. The live DGA path now keys dedup by `(host, process_name, dga-label)`, matching the Rust storage rule instead of full query name. |
 | `triage` classifiers      | `typed/triage.ss`  | ✅ pure predicates (transient-unit?, phantom-rootkit-race?); vectors pass |
 | `triage` engine (rules + dispatch) | `jsecmon/triage.ss` | ✅ **untyped layer** — all 18 false-positive rules + first-match engine, in secmon's exact RULES order, dispatch in ordinary Jerboa delegating byte/string classification to the typed kernels; 40 triage vectors pass (`make triage-check`), incl. the security-relevant negatives (non-sshd reading host keys, systemd impersonated from /tmp, unknown daemon reading passwd). |
 | `triage::compute_triaged_ids` (triage-aware mode) | `jsecmon/triage-store.ss` | ✅ **untyped layer** — the bridge above storage+triage: query every in-scope event, triage each, return the sorted benign/expected ID set to drop into a filter's `exclude_event_ids`. `make triage-store-check` proves the round-trip — detection then sees only the real attacks. |
@@ -158,11 +162,12 @@ then crypto orchestration, then I/O / async / FFI (monitors, server, storage).
 | `psk::constant_time_eq`  | `typed/psk.ss`     | ✅ ported, vectors pass         |
 | `psk::from_hex` (hex codec) | `typed/psk.ss`  | ✅ hex encode + decode + 32-byte precondition; vectors pass (decode∘encode identity over all 256 byte values) |
 | `stealth::obfuscate` (compile-time string XOR) | `typed/obfuscate.ss` | ✅ **typed kernel** — keeps sensitive strings out of the binary in plaintext. Lowers secmon's `obfuscate!`/`obfuscate_bytes!` scheme: length-derived wrapping-u8 key (`len*31+42` / `len*37+13`) + XOR. Since XOR preserves length, decode recomputes the key from the buffer — a clean involutive pair, no stored key. `make test` reproduces secmon's `test_obfuscate_roundtrip` + `test_obfuscated_not_plaintext` plus key-derivation and all-256-byte round-trip vectors. |
+| `stealth::{anti_debug,integrity,init}` | `jsecmon/stealth.ss` | ✅ **untyped runtime shell** — TracerPid parsing/status-integrity comparison, parent debugger/tool detection tables, timing baseline rule, breakpoint-byte predicate, environment sanitization list, PID-stable process-name masquerade, `mlockall`, anti-debug watchdog, and best-effort self-integrity hashes are ported. `bin/agent.ss` initializes stealth before key/config work and starts the watchdog after privilege drop. `make stealth-check` pins the pure helpers/tables; runtime effects are best-effort through libc FFI and no-op when the platform primitive is unavailable. |
 | `crypto` primitives (SHA256/HMAC/HKDF/x25519/AES-256-GCM) | `typed/crypto.ss` | ✅ **typed kernel** — FFI-delegated to vetted RustCrypto crates (sha2/hmac/hkdf/x25519-dalek/aes-gcm), never reimplemented; the backend emits the `use … ;` block per primitive and content-scans the Cargo deps. Vector-verified against NIST SP 800-38D GCM, RFC 7748/5869/4231, FIPS 180-4 (`make test`). |
 | `psk` key-derivation + proof + transport (`from_bytes`, `compute_proof`, `encrypt_transport`) | `typed/psk.ss` + `jsecmon/crypto-psk.ss` | ✅ **typed kernel + untyped orchestration** — HKDF-derived auth/transport keys, the SHA256 challenge proof + constant-time `verify_proof`, and AES-256-GCM transport (12-byte nonce prepended) are typed kernels (`make test`); `(jsecmon crypto-psk)` adds the effects they omit — `transport-encrypt` draws a random nonce, `generate-challenge`/`respond-to-challenge`/`verify-response` add the random challenge nonce + clock + freshness check (`now` injected for testability). `make crypto-psk-check` pins the FFI path to the `psk_vectors.rs` digests then exercises round-trip / randomization / tamper→#f / wrong-key→#f / stale→#f. |
-| `crypto::ecies` (x25519 ECDH + HKDF + AES-GCM) | `typed/ecies.ss` + `jsecmon/crypto-ecies.ss` | ✅ **typed kernel + untyped orchestration** — deterministic `ecies-seal`/`-open` are typed kernels (parity vs an independent Python impl, `ecies_vectors.rs`); `(jsecmon crypto-ecies)` adds `ecies-generate-keypair` + `ecies-encrypt` (random ephemeral keypair + nonce) + `ecies-decrypt`. Frame = `ephemeral_public(32) ‖ nonce(12) ‖ ct` (jsecmon's own concat, not secmon's bincode — wire-compat not required). `make crypto-ecies-check` pins x25519 to RFC 7748 + ecies-seal to the reference vector, then round-trip / randomization / wrong-recipient→#f / tamper→#f. |
+| `crypto::ecies` (x25519 ECDH + HKDF + AES-GCM) | `typed/ecies.ss` + `jsecmon/crypto-ecies.ss` | ✅ **typed kernel + untyped orchestration** — deterministic `ecies-seal`/`-open` are typed kernels (parity vs an independent Python impl, `ecies_vectors.rs`); `(jsecmon crypto-ecies)` adds `ecies-generate-keypair` + `ecies-encrypt` (random ephemeral keypair + nonce) + `ecies-decrypt`. Frame = secmon's bincode `EncryptedPayload` layout: `ephemeral_public(32) ‖ nonce(12) ‖ ciphertext_len(u64le) ‖ ciphertext`; decrypt also accepts the older jsecmon bare concat for local backward compatibility. `make crypto-ecies-check` pins x25519 to RFC 7748 + ecies-seal to the reference vector, then bincode-frame layout / round-trip / randomization / wrong-recipient→#f / tamper→#f. |
 | `storage` (events table, store/query/filters) | `jsecmon/storage.ss` | ✅ **untyped layer** — SQLite event store on `(std db sqlite-native)` (rusqlite): secmon's schema (events + indexes + collector_state), `store-event` INSERT-OR-IGNORE dedup, and the full EventFilter WHERE builder (host/type/severity/since/until/pid/process_name LIKE/search/exclude_event_ids). `query-events` returns row hashes with `data` parsed from JSON, so detect/triage/analytics consume them directly. `make storage-check` round-trips store→query→detect→analytics (host risk 30, same as `detect-check`). `entity-where`/`entity-timeline` port `storage::entity_timeline`: the per-category WHERE-group builder (Process = `process_name LIKE` + six json fields with LIKE `%value%`; Ip/User/Domain = exact `json_extract` `=` over their field sets) plus the assembled oldest-first query, with the filter clauses continuing the positional `?N` numbering after the category binds (`build-where` gained an optional start index). `make entity-check` pins each category's exact SQL fragment + binds and routes/orders/narrows/limits over a live store (18 cases). |
-| `storage` SQL-aggregation detectors (brute_force, credential_stuffing, dns_tunnel, suspicious_cron, recon_port_scan, data_exfil) | `jsecmon/threats.ss` | ✅ **untyped layer** — secmon's `run_detections` family: the time-bucket GROUP BY/HAVING rules and the two 5-min sliding-window rules, run as SQL (json_extract) over a `(jsecmon storage)` handle. `make threats-check` reproduces secmon's six detection-rule test vectors. Remaining: the sequence/kill-chain rules (priv_escalation_chain, persistence_after_access, log_cover, lateral_after_shell, impossible_travel) + frequency/severity/off-hours aggregates. |
+| `storage` SQL-aggregation detectors (brute_force, credential_stuffing, dns_tunnel, suspicious_cron, recon_port_scan, data_exfil) | `jsecmon/threats.ss` | ✅ **untyped layer** — secmon's `run_detections` family: the time-bucket GROUP BY/HAVING rules and the two 5-min sliding-window rules, run as SQL (json_extract) over a `(jsecmon storage)` handle. `make threats-check` reproduces secmon's six detection-rule test vectors; the companion sequence/statistical families are covered by the rows below. |
 | `storage` sequence/chain detectors (priv_escalation_chain, persistence_after_access, log_cover, lateral_after_shell) | `jsecmon/threats.ss` | ✅ **untyped layer** — secmon's `detect_sequence_pair` family: event A then event B within a window on the same host (auth-success→priv-esc /5min, reverse-shell/webshell→persistence /1h, any-critical→log-tampering /1h, shell→lateral /1h). Reproduces secmon's chain test vectors incl. the outside-window negative. |
 | `storage` time-window aggregates (frequency_spike, severity_cluster, off_hours, kill_chain) | `jsecmon/threats.ss` | ✅ **untyped layer** — secmon's full `detect_anomalies` family: per-(host,event_type) hour count 3x above its own average, 5+ crit/high on a host /5min, crit/high outside 08:00-18:00 UTC weekday (SQLite `strftime`), and 3+ distinct kill-chain phases /1h. `run-anomaly-detections` is the dispatcher (frequency_spike first, as secmon runs it). `make threats-check` covers each with threshold/negative cases. |
 | `geoip` (CSV GeoIP/ASN, IPv4+IPv6, binary-search range lookup, is_private) | `jsecmon/geoip.ss` | ✅ **untyped layer** — full port of secmon's `src/geoip.rs`: parse `start,end,country,asn,name` CSV rows (v4 + one-`::`-expanding v6), sort-by-start + binary-search lookup, RFC1918/loopback/link-local/multicast/ULA/CGNAT → synthetic `PRIVATE`. Pure parsing + integer math + file read, so untyped. `make geoip-check` runs secmon's geoip vectors. |
@@ -177,17 +182,21 @@ then crypto orchestration, then I/O / async / FFI (monitors, server, storage).
 | `monitor::container::ContainerEscapeMonitor` (mount classifier) | `jsecmon/container.ss` | ✅ **untyped layer** — `is_suspicious_mount(mount)`: a mount that starts with `/host` or `/mnt/host`, is exactly `/`, or contains `/var/run/docker` / `/run/docker` / `devd.pipe` (FreeBSD jail), flagging a container/jail escape. Pure string classification like the other monitor classifiers, so untyped; obfstr!-hidden patterns decode to these plaintext literals. `make container-check` reproduces secmon's `test_suspicious_mount_detection` + each escape signal + negatives. (Isolation detection and mount/path/cap polling are provider-driven I/O — the deferred monitor loop.) |
 | `monitor::dns::read_dns_servers` (resolver-set builder) | `jsecmon/dns-servers.ss` | ✅ **untyped layer** — `parse_dns_servers(content)`: collect each `nameserver <ip>` entry from resolv.conf text (the 2nd whitespace field of a trimmed line starting with `nameserver`) and union with the fixed public-resolver set (Google/Cloudflare/Quad9/OpenDNS). Pure parsing, so untyped; the `/etc/resolv.conf` read is the deferred I/O wrapper (split off like the selinux log tail). Folds tabs/CR to spaces to match Rust's `split_whitespace`. `make dns-servers-check` reproduces secmon's `test_read_dns_servers` (publics always present) + the nameserver parsing with multi-space/tab/indented lines and dedup. |
 | `dtrace::scripts::is_sensitive_path` (sensitive-path classifier) | `jsecmon/sensitive-path.ss` | ✅ **untyped layer** — `is_sensitive_path(path)`: a sensitive prefix (passwd/shadow/sudoers/ssh dirs/cron/periodic/ld.so.preload/`/boot/`/…), with `/home/` special-cased to only `/.ssh/` subpaths, else `authorized_keys` anywhere, else `/cron` or `/periodic`. Pure string classification, so untyped; obfstr!-hidden literals decode to these plaintexts. Ported with secmon's loop-with-early-return so the **load-bearing corner** holds: a `/home/` path short-circuits before the `authorized_keys` check, so `/home/user/authorized_keys` (no `/.ssh/`) is **not** sensitive. `make sensitive-path-check` reproduces secmon's `test_sensitive_path_detection` + each signal + that corner. |
-| `dtrace::consumer::EventParser` (DTrace line parser) | `jsecmon/dtrace-parse.ss` | ✅ **untyped layer** — `parse_dtrace_line(line)`: split a `SECMON\|TYPE\|…` DTrace line on `\|` and dispatch on `parts[1]` into a per-type structured record (EXEC/EXIT/CONNECT/LISTEN/OPEN/WRITE) with each handler's exact field extraction; a <2-field / unknown-type / too-few-fields line yields no record (`#f`), matching secmon's `return Ok(())` no-ops. A text parser yielding a structured record, like the DNS/SELinux parsers, so untyped (alist, since the per-type fields are disjoint). Numeric fields use `.parse().unwrap_or(0)` (u32 rejects negatives → 0; exit code is i32), and the EXEC cmdline is `split_whitespace`. **Composes** `(jsecmon sensitive-path)` for the OPEN `sensitive?` gate. `make dtrace-parse-check` reproduces secmon's `test_parse_exec_line` / `test_parse_exit_line` + the other four formats + the no-event and `unwrap_or(0)` corners. (The stateful parts — process cache, suspicious-exec dispatch, channel send — are the deferred consumer loop.) |
+| `dtrace::consumer::EventParser` (DTrace line parser) | `jsecmon/dtrace-parse.ss` | ✅ **untyped layer** — `parse_dtrace_line(line)`: split a `SECMON\|TYPE\|…` DTrace line on `\|` and dispatch on `parts[1]` into a per-type structured record (EXEC/EXIT/CONNECT/LISTEN/OPEN/WRITE) with each handler's exact field extraction; a <2-field / unknown-type / too-few-fields line yields no record (`#f`), matching secmon's `return Ok(())` no-ops. `dtrace-process-line` ports the stateful `EventParser`: process cache, parent lookup, suspicious EXEC dispatch, process_exit cache removal, CONNECT/LISTEN event rows, and OPEN/WRITE no-event behavior. Numeric fields use `.parse().unwrap_or(0)` (u32 rejects negatives → 0; exit code is i32), and the EXEC cmdline is `split_whitespace`. **Composes** `(jsecmon sensitive-path)` for the OPEN `sensitive?` gate. `make dtrace-parse-check` reproduces secmon's parser tests plus the stateful process-cache/suspicious/network/exit behavior; runtime startup/work loops live in `(jsecmon dtrace-runtime)`. |
+| `dtrace::{mod,consumer}` runtime shell | `jsecmon/dtrace-runtime.ss` | ✅ **untyped runtime shell** — ports the FreeBSD DTrace runtime around `EventParser`: secmon's combined D script, support-check guard ordering (root, `/dev/dtrace/dtrace`, smoke test), direct libdtrace open/setopt/compile/exec/go/work-loop path, buffered callback line queue, stop/close cleanup, subprocess `dtrace -n <script>` fallback, stdout line pump, EOF marker, and `SECMON|...` drain through `dtrace-process-line`. `make-freebsd-monitor-set` runs the same preference order before privilege drop. `make dtrace-runtime-check` verifies the guard order, exported entrypoints, script probes, noise filtering, and stream-to-event rows; the direct libdtrace path still needs FreeBSD root/libdtrace for live verification. |
 | `platform::{linux,freebsd}` (`is_dangerous_path` + `get_mounts` parsers) | `jsecmon/platform-mounts.ss` | ✅ **untyped layer** — the pure halves of each `IsolationProvider`, reads stripped: `is_dangerous_path` is **exact** membership in the platform's dangerous-path set (Linux 12 entries incl. `/proc/kcore`, `/dev/mem`, the docker/crio/containerd sockets; FreeBSD 6 incl. `/dev/io`, `devd.pipe`; unknown platforms empty, per the `UnsupportedProvider` default), and `parse_mounts` reproduces each `get_mounts` line loop — Linux `split_whitespace` keeping field[1], FreeBSD `split(" on ")` keeping piece[1] minus a trailing ` (opts)`. Pins that membership is exact not prefix (`/host/foo` is clean), and that the FreeBSD split takes piece[1] of a multi-`" on "` split (not everything-after-first). obfstr!-hidden lists decode to these plaintexts. Pure — the `/proc/self/mounts` read / `mount` exec is the deferred I/O — no native lib; secmon has no `#[test]` here so `make platform-mounts-check` asserts against the Rust source. (The `container.rs` test-only mock `is_dangerous_path` is `#[cfg(test)]` scaffolding, not ported.) |
 | `platform::linux` (/proc parsers) | `jsecmon/proc-linux.ss` | ✅ **untyped layer** — the pure parsing helpers with the file reads stripped: `parse_stat` (comm between first `(` and **last** `)`, ppid the 2nd field after `") "`), `parse_uid` (first `Uid:` line, 2nd field), `hex_to_state` (TCP state table → `UNKNOWN`), `parse_ipv4` (little-endian hex → dotted quad), `parse_ipv6` (32-hex → 8 groups), `parse_addr` (`HEXADDR:HEXPORT`, ipv6 when protocol contains `6`). Pure text/number parsing, so untyped. `parse_stat`/`parse_uid` use `.parse::<u32>().ok()` so failure is `#f` (not 0) and negatives are rejected; `parse_ipv4` rejects >`0xFFFFFFFF`; ports are u16. Also `parse_security_environ` (from `ebpf/loader.rs`, but functionally a `/proc/[pid]/environ` parser): splits the NUL-separated blob, drops empty and non-UTF-8 entries (faithful to `std::str::from_utf8(..).ok()`, reproduced exception-free by a lossy-decode→re-encode→compare round trip), and keeps any var whose name `starts_with` one of 19 security-relevant prefixes **or** whose `to_uppercase()` does — so a lower-cased `path=`/`ld_…` still matches the upper-cased prefixes, while `http_proxy`/`https_proxy` match only literally-lower vars. Also `parse_module_line` (a `/proc/modules` row: ≥4 ws fields → the same `KernelModuleInfo` alist as freebsd's `parse_kldstat_line`, name `parts[0]`, size `parts[1]` as DECIMAL u64 via `.parse().ok()` so a non-u64 size is `#f` but the row still parses, action always Loaded, no loader pid). `make proc-linux-check` reproduces secmon's five linux.rs tests + ipv6/parse-addr + a comm-with-paren corner + 10 environ-scan cases (invalid-UTF-8 drop, uppercase-arm, trailing NUL) + the module-line rows. (The `/proc` reads and inode→pid scan are the deferred I/O.) |
+| `ebpf::{events, loader::parse_event}` | `jsecmon/ebpf-events.ss` | ✅ **untyped layer** — decodes the packed userspace eBPF `Event` layout (`kind`, `pid`, `uid`, `data1`, `data2`, `comm[16]`, `filename[32]`) and maps every Rust `parse_event` kind into the same SecurityEvent-shaped row: exec, exit, connect/accept, file_open, setuid, kmod, ptrace, namespace clone/enter/unshare, mount, and capset. DNS kind stays `#f` like Rust because DNS is handled by the sniffer; unknown/zero-address connect cases are skipped. `make ebpf-events-check` pins packed-field decoding, invalid UTF-8 handling, ptrace request names, IP byte order, and all event-kind mappings. |
+| `ebpf::loader` runtime shell | `jsecmon/ebpf-runtime.ss` | ✅ **untyped runtime shell** — Linux support guard (`/proc/version`, root/CAP_BPF approximation, BTF warning parity), eBPF object search, the full 18-program tracepoint attach table, libbpf object load/attach, perf-buffer callback queue, stop cleanup, and drain-through-`parse_event` are ported. `make-linux-monitor-set` tries this stream first; while active, `monitor-tick` drains eBPF process/network/file-open events and suppresses the process/network polling monitors, matching secmon's high-fidelity mode shape while keeping the auxiliary monitors. `make ebpf-runtime-check` pins the guard/table/stream-drain pieces; live attach still requires Linux, root/CAP_BPF, libbpf, and a `secmon.bpf.o` object. |
 | `platform::freebsd` (parsers) | `jsecmon/freebsd-parse.ss` | ✅ **untyped layer** — the pure parsing helpers with the command/file reads stripped: `parse_kldstat_line` (≥5 whitespace fields, name is `parts[4]`, size is `parts[3]` as hex with optional `0x`, size `None` on non-hex via `.ok()`, action always `Loaded`) and `parse_address` (`addr:port` split at the **last** `:`, `[ipv6]:port` split at the first `]`, `*` address → `0.0.0.0`, `*` port → `0`). Ports here are **DECIMAL** u16 (`.parse()`), unlike Linux's hex `/proc/net`. Plus `parse_ps_line` (the `ps -axo pid,ppid,uid,comm,args` fallback parser: ≥5 ws fields, `pid`/`ppid`/`uid` as u32 via `.parse().ok()?` so a non-u32 field rejects the whole line, `comm` is field[3], `args` is field[4..] re-joined with single spaces). Pure text/number parsing, so untyped. Also `parse_sockstat_line` (cols `USER COMMAND PID FD PROTO LOCAL FOREIGN`, ≥7, `pid` as u32-or-reject, protocol lower-cased, a `FOREIGN` of exactly `*:*` short-circuits to `("0.0.0.0" . 0)` **without** `parse_address`, `state` = LISTEN when remote is `0.0.0.0`/`::`/port 0 else ESTABLISHED) and `parse_netstat_line` (cols `Proto Recv-Q Send-Q LOCAL FOREIGN [state]`, ≥5, here `*:*` **does** go through `parse_address`, `state` = `parts[5]` or `UNKNOWN`, no pid/name) — both producing a connection alist mirroring `ConnectionInfo`. Plus `parse_freebsd_status` (the procfs `/proc/[pid]/status` columns: ≥13 ws fields → `(name ppid uid)`, name is `parts[0]`, ppid is `parts[2]`, uid is `parts[12]` = ruid, both u32-or-reject, trailing group columns ignored). `make freebsd-parse-check` reproduces secmon's three freebsd.rs tests + ipv6/wildcard/negatives + the ps-line cases + the sockstat/netstat rows + the status columns traced from source. (The `kldstat`/`sockstat`/`netstat`/`ps`/`status` command/file reads are the deferred I/O.) |
 | `event_json` + `local_store` (tables) | `jsecmon/event-meta.ss` | ✅ **untyped layer** — the pure classification tables lifted out of the payload-carrying `EventType` enum: `event_json.rs` `get_event_json_data`'s **display severity** (25 constant arms as a name→severity table, + the 7 payload-dependent arms as named helpers taking the deciding field — `auth`/`privilege_change`/`mount`/`capability`/`podman`/`selinux`/`lateral_movement`), and `local_store.rs` `event_severity_u8`'s **coarse store priority** 0..3, which is an *independent* scale (e.g. `privilege_escalation` is `critical` for display but `0` for the store). secmon has no `#[test]` here, so `make event-meta-check` asserts both full tables arm-for-arm against the Rust source. (The JSON payload bodies stay with the I/O layer that owns the event structs.) |
+| `local_store::LocalEventStore` | `jsecmon/local-store.ss` | ✅ **untyped layer** — secmon-compatible encrypted SQLite local event store: creates the `local_events` schema and indexes, loads or generates a 32-byte AES-GCM key file with `0600` permissions, stores event metadata plus encrypted bincode `SecurityEvent` payloads, preserves `INSERT OR IGNORE`, returns ordered `get_events_after`, and implements both cleanup operations. `make local-store-check` covers key generation/load, schema operations, decrypt round-trip, duplicate ignore, wrong-key failure, and cleanup. |
 | `config` | `jsecmon/config.ss` | ✅ **untyped layer** — `AgentConfig`'s pure parts: the defaults (`0.0.0.0:31337`, poll `100`ms, buffer `10000`), the `from_env` merge (overwrites `listen_addr` on any present value but only overwrites poll/buffer when the value parses as strict u64 — a bad value **keeps the default**, it is not zeroed), and `local_db_path`/`local_key_path` (env override, else `/opt/secmon/{events.db,local.key}` on linux+freebsd, else the `./secmon_*` cwd fallback). Parameterized over a `getenv` callback + a `platform` symbol so the env reads stay deferred I/O; the build.rs-embedded secrets (`get_public_key`/`get_psk`/`is_debug_mode`) belong to the build/FFI phase, not this layer. secmon has no tests here, so `make config-check` asserts the behaviour against the Rust source. |
 | `monitor/events` (danger predicates) | `jsecmon/event-danger.ss` | ✅ **untyped layer** — the payload predicates that drive a mount/capability event's severity, lifted off their structs: `MountEventInfo::is_dangerous` (`mount-danger-reason source target` → reason string, with the faithful corner that the `/` source entry's prefix is `//` so a plain `/foo` is **not** flagged, and dangerous *targets* match exact-only) and `CapabilityEventInfo::dangerous_caps` (`cap_effective` bits → cap names in the Rust push order, full u64 so bits 38/39 work). These compute the booleans `event-meta`'s mount/capability severity helpers consume. Plus `NamespaceEventInfo::ns_types` (`ns-types ns-flags` → the namespace names whose `CLONE_NEW*` mask is set, in Rust push order mnt/uts/ipc/user/pid/net/cgroup/time — masks 0x20000/0x04000000…0x40000000/0x80/0x100, not bit indices). Pure, no native lib; `make event-danger-check` asserts against the Rust source. |
 | `monitor/persistence` (helpers) | `jsecmon/persistence.ss` | ✅ **untyped layer** — `classify_path` (path → `PersistenceType` symbol via an ordered first-match substring chain; `systemd` before `cron`, `.timer` vs service, and the shell-profile arm == the default) and `extract_suspicious_content` (first line matching `SUSPICIOUS_PATTERNS`, returned in original case, truncated to 200 chars + `...`). Faithfully preserves secmon's dead-pattern bug: the line is lowercased before `contains`, so the uppercase patterns `NOPASSWD`/`ALL=(ALL)` can never match. Pure — the directory walk + baseline hashing are the deferred I/O — no native lib; `make persistence-check` asserts against the Rust source. |
 | `monitor/files` (`FileIntegrityMonitor::is_suspicious_change`) | `jsecmon/file-change.ss` | ✅ **untyped layer** — the deciding logic with stat/hashing stripped (modes + change-type + platform passed in): ordered first-match — setuid then setgid bit *added* (both modes known), exact platform critical file, `authorized_keys`/`cron` substrings, then a platform sensitive dir on `created` only. Pins the order corner that the `cron` substring precedes the sensitive-dir step, so a created `/etc/cron.d/x` reports "Cron configuration modified", never the sensitive-dir message; the critical-files/sensitive-dirs sets switch on `cfg!(target_os)` (linux/freebsd/other). Pure — the `stat`/SHA-256 baseline is the deferred I/O — no native lib; secmon has no `#[test]` here so `make file-change-check` asserts against the Rust source. |
 | `monitor/webshell` (`WebshellMonitor` classifiers) | `jsecmon/webshell.ss` | ✅ **untyped layer** — the three pure deciders with the `/proc` scan + parent/child PID walk + event emission stripped: `is_web_server` (lower-cased name **substring** vs the server list, so `php-fpm` matches `php`), `is_suspicious_child` (process name by **exact** lower-cased equality — `bashx` is clean — OR the joined+lowercased cmdline **substring**-matched against the pattern list), and `get_detection_reason` (scans only the cmdline patterns, **in list order**, first match → `Suspicious command pattern: {pat}`, else the default `Web server spawned suspicious process: {name}` with the **original-case** name). obfstr!-hidden lists decode to these plaintext literals. Pins the corner that the reason is chosen by pattern-list order, not cmdline-token order, and that a name-only hit yields the default reason. Pure — the PID walk is the deferred monitor loop — no native lib; secmon has no `#[test]` here so `make webshell-check` asserts against the Rust source. |
-| `monitor::dns_sniffer` (DNS wire parser + dedup) | `jsecmon/dns-sniffer.ss` | ✅ **untyped layer** — the platform-independent half of secmon's `src/monitor/dns_sniffer.rs`: the DNS wire-format parser (QNAME decoding with compression-pointer chasing capped at 128 steps, QTYPE→string, question + A/AAAA answer-RR extraction), the `parse_ip_udp_dns` IPv4+UDP header peel (version/IHL/protocol checks, port-53 server/response classification → DNS payload), and the 5s dedup / 30s cleanup state machine. Every bounds check is preserved — a truncated/malformed/looping packet yields `#f`, never a bad read. Pure byte parsing → untyped, like geoip. Only the AF_PACKET raw-socket capture + `/proc` PID lookup stay for the monitor I/O driver. `make dns-sniffer-check` reproduces secmon's parser + dedup tests (+ AAAA, qtype table, pointer-loop/qdcount guards, and the IP/UDP peel with version/protocol/port negatives). |
+| `monitor::dns_sniffer` (DNS wire parser + live capture queue) | `jsecmon/dns-sniffer.ss` | ✅ **untyped runtime shell** — the platform-independent half of secmon's `src/monitor/dns_sniffer.rs`: DNS wire-format parser (QNAME compression-pointer cap, QTYPE table, question + A/AAAA answers), IPv4+UDP header peel for AF_PACKET/SOCK_DGRAM-style IP bytes, Ethernet/VLAN peel for Jerboa pcap bytes, 5s dedup / 30s cleanup, local-port→PID/name callback, and a best-effort pcap-backed live capture stream feeding the monitor. Every bounds check is preserved: truncated/malformed/looping packets yield `#f`. `make dns-sniffer-check` reproduces parser/dedup tests; `make monitor-dns-check` verifies the stream-to-monitor path. |
 | `bin/analyze` (CLI parse helpers) | `jsecmon/analyze-cli.ss` | ✅ **untyped layer** — the pure argument parsers of the `analyze` binary, returning the prelude Result (ok/err) to mirror Rust's `Result<_, String>` **including the exact error text**: `parse_duration_ms` (`10m`/`2h`/`1d`/bare-seconds → ms; splits leading ASCII digits from the unit; empty → `empty duration`, bad number/leading-non-digit → `invalid duration: {s}`, bad unit like `m5` → `unknown duration unit: …`; the number must fit i64) and `AlertSink::parse` (`stdout` / `file:PATH` / `webhook:URL` / `syslog` / `syslog:TAG`, first-match in order, remainder taken verbatim so `file:` → empty path) and `parse_alert_sinks` (collect every `--alert-to <spec>`, parsing each and short-circuiting on the first bad spec like Rust's `?`; a trailing `--alert-to` with no value is skipped, and no flags → the empty list — the watch-time default-to-stdout lives in `cmd_watch`), plus the generic `--flag` scanners shared across the CLI (`parse_flag_value` → the arg after the **first** `flag`, or `#f` even when the flag is last; `has_flag` → membership; `is_json_format` → the first `--format` that has a value decides, a trailing `--format` is skipped). Pure string→Result/bool; the sink dispatch (stdout/file append/curl webhook/`logger` syslog) and query dispatch are the deferred I/O. secmon has no `#[test]` here so `make analyze-cli-check` asserts against the Rust source. (`format_ts`/`format_ts_iso` display helpers now live in `jsecmon/calendar.ss`; only the actual stdout/file/webhook/syslog emission stays deferred.) |
 | `bin/collector` (CLI/hosts parse helpers) | `jsecmon/collector-cli.ss` | ✅ **untyped layer** — the pure argument/hosts parsing of the `collector` binary, with the async polling + ECIES/PSK key loading + SQLite I/O deferred: `parse_after_seq` (first `--after` value as u64, `unwrap_or(0)` so junk/negative/≥2⁶⁴ → 0), `parse_format` (→ `'json`/`'human`/`'quiet`; a per-index scan where an unknown `--format` value does **not** consume the value — differs from analyze's `is_json_format` — and the no-flag default is `quiet` when a `--db` is present else `human`), `parse_db_path`, `normalize_host` (append `:31337` unless the host already contains **any** `:`, so bare IPv6 is left as-is, faithfully), `collect_positional_hosts` (skip the four value flags **and** their values, drop other `--` args, normalize the rest), and `parse_hosts_file`'s pure contents→hosts core (trim, drop blanks/`#` comments, normalize). secmon has no `#[test]` here so `make collector-cli-check` asserts against the Rust source. |
 | `storage` event readers (`extract_pid` / `extract_process_name` / `build_summary`) | `jsecmon/event-summary.ss` | ✅ **untyped layer** — the pure readers that turn an event's flat JSON `data` (a hash table, as `string->json-object` yields) back into a pid / process name / one-line summary, with the SQLite query + serde plumbing left to storage. Each field is read through a **typed** getter so only a JSON value of the right type counts (`as_u64`/`as_i64`/`as_str`/`as_bool`); `extract_pid` walks pid→source_pid→spawned_pid→web_server_pid and truncates the first hit to **u32** (Rust `v as u32`, so ≥2³² wraps, and pid 0 is a real hit); `extract_process_name` walks process_name→name→exe→source_process→spawned_process. `build_summary` reproduces every per-type format with the exact `unwrap_or` defaults (`"?"`/`0`), the `process_exit` exit-code *option* (Some(0) still prints `(0)`), the nested `selinux_event` perm/class/path-vs-message branches with the 80-char message cap, and the catch-all that scans values **in sorted key order** (serde's default BTreeMap) for the first string longer than 3 chars (capped at 80) else the event type. `make event-summary-check` (43 cases) reproduces secmon's `test_extract_helpers` + `test_build_summary` and adds every per-type / typed-getter corner derived from the source. |
@@ -202,10 +211,12 @@ then crypto orchestration, then I/O / async / FFI (monitors, server, storage).
 | `monitor::process::ProcessMonitor` scan loop | `jsecmon/monitor-process.ss` | ✅ **untyped layer** — secmon's process-monitor scan in pure Jerboa over an injected `mon-provider` seam (list-pids / get-process / hostname), fixture-tested: diff pid sets → classify each new spawn (check-suspicious delegates to `(jsecmon suspicious)`) → emit process_start/suspicious_exec; exited pids → process_exit; list-pids error → no-op. Faithfully preserves secmon's `continue` before insert (a failed get-process is not added to known and is retried), the parent-cache lookup, and the 1000-entry cache prune. `make-linux-provider` is the thin /proc shell via `(jsecmon proc-linux)` parsers plus live `/proc/[pid]/{exe,cwd}` readlinks. `make monitor-process-check` (30 cases). |
 | `monitor::network::NetworkMonitor` scan loop | `jsecmon/monitor-network.ss` | ✅ **untyped layer** — the network-monitor scan over an injected `net-provider` seam: listeners → listening_port/info (de-dup key `proto:laddr:lport`); connections → `(jsecmon netconn)` connection-suspicious? → suspicious_connection/high or network_connection/info (de-dup key `proto:laddr:lport:raddr:rport:state`). Provider Err skips that half; never ages out (secmon no-op TODO). `make-linux-network-provider` reads /proc/net/{tcp,udp,tcp6,udp6} via proc-linux parsers and resolves socket inode → `(pid . process-name)` by scanning `/proc/*/fd`, enabling the live web-server outbound check. `make monitor-network-check` (13 cases). |
 | `monitor::files::FileIntegrityMonitor` scan loop | `jsecmon/monitor-files.ss` | ✅ **untyped layer** — file-integrity monitor over an injected `file-provider` seam: `baseline-files` records initial state; `detect-change` produces the ordered diff (deleted > created > permission-changed > owner-changed > modified) — pinned secmon quirk: owner-changed reports `old_mode = new_mode = new.mode`; `scan-files` re-stats tracked paths + discovers new files in watched dirs (Created), classifying via `(jsecmon file-change)` is-suspicious-change. `make-linux-file-provider` now uses POSIX stat mode/uid/gid/mtime and SHA-256 for regular files under 10 MiB; `linux-monitored-paths` = secmon's Linux PLATFORM_PATHS. `make monitor-files-check` (18 cases). |
-| `monitor::dns::DnsMonitor` connection-polling fallback | `jsecmon/monitor-dns.ss` | ✅ **untyped layer** — the polling fallback: reuse net-provider's list-connections; keep only remote-port==53; dedup key `pid:remote_addr:protocol:local_port` (pid defaults to 0); skip if seen <5000 ms ago; emit dns_query/info (query_name `"<unknown>"`, query_type UDP/TCP, server_addr=remote-addr, response_addrs=[]). `cleanup-dns-queries` ages entries ≥30 s. Shares net-provider with the network monitor exactly as secmon. `make monitor-dns-check` (14 cases). |
-| `monitor::mod::MonitorManager` (boot + poll cycle) | `jsecmon/monitor-manager.ss` | ✅ **untyped layer** — `monitor-boot` baselines stateful monitors and emits agent_start; `monitor-tick` runs the full polling set in a fixed order and returns the merged event stream; cleanup-dns-queries runs after tick. `make-linux-monitor-set` wires the live Linux providers, sharing process/network providers where the Rust monitors do. `make monitor-manager-check` drives the full cycle end to end with fixture providers. |
+| `monitor::dns::DnsMonitor` sniffer + connection fallback | `jsecmon/dns-sniffer.ss`, `jsecmon/monitor-dns.ss` | ✅ **untyped runtime shell** — tries the live packet-capture stream first, feeds captured DNS packets through `DnsSnifferState` for real query names/answers + local-port PID/name lookup, and uses the connection-polling fallback when capture is unavailable/ended. The fallback reuses net-provider's list-connections; keeps only remote-port==53; dedups by `pid:remote_addr:protocol:local_port`; emits `query_name="<unknown>"`; and ages entries ≥30 s. `make dns-sniffer-check` pins the wire parser; `make monitor-dns-check` covers sniffer delivery/lookup, fallback suppression, fallback polling, dedup, and cleanup. |
+| `monitor::podman::PodmanMonitor` | `jsecmon/monitor-podman.ss` | ✅ **untyped runtime shell** — ports the preferred `podman events --format json` stream (`create`/`start`/`stop`/`kill`/`remove`/`cleanup`/`exec`/`attach`, container-only, malformed lines ignored), starts it with the live Linux provider, queues event lines into the synchronous agent tick, and falls back to `podman ps -a` polling only after spawn failure or stream EOF. `make monitor-podman-check` covers stream parser actions/ignores, queued stream delivery, EOF-to-polling fallback baseline, and start/remove polling deltas. |
+| `monitor::mod::MonitorManager` (boot + poll cycle) | `jsecmon/monitor-manager.ss` | ✅ **untyped layer** — `monitor-boot` baselines stateful monitors and emits agent_start; `monitor-tick` runs the full polling set in a fixed order and returns the merged event stream; cleanup-dns-queries runs after tick. `make-linux-monitor-set` wires the live Linux providers, sharing process/network providers where the Rust monitors do, and prefers the live eBPF stream when available (skipping only process/network polling in that mode). `make-freebsd-monitor-set` wires the DTrace direct/subprocess stream and suppresses process/network polling while it is active, matching the high-fidelity mode shape. `make monitor-manager-check` drives the fallback cycle end to end with fixture providers and the DTrace stream drain. |
 | `event_json.rs get_event_json_data` | `jsecmon/event-json.ss` | ✅ **untyped layer** — the flat JSON `data` body for every Rust `EventType` variant, matching secmon's json!{...} field SETS exactly: suspicious_exec drops cwd/name; listening_port keeps only local side; suspicious_connection drops state/process_name; mount_event drops target/fstype but computes `dangerous`; namespace/capability derive `ns_types`/`dangerous_capabilities`; all enum-ish fields use Rust Debug spelling. Option::None → JSON null via `(if #f #f)` void sentinel; parent process → nested {pid,name,exe} or null. `make event-json-check` drives monitor events plus representative eBPF/podman/SELinux/revshell/lateral/webshell rows through the serializer, parses back, and asserts the tricky corners. |
 | `monitor::events::SecurityEvent` bincode | `jsecmon/event-codec.ss` | ✅ **untyped layer** — byte-exact bincode reader/writer for the top-level `SecurityEvent` and all 32 `EventType` discriminants, with golden secmon bytes for the process/network base cases and round-trip coverage for every remaining variant. This is the compiled agent's event payload codec feeding the encrypted buffer and collector pull path. |
-| `bin/agent` storage path (collector loop) | `jsecmon/collector.ss` | ✅ **untyped layer** — ties monitor runtime to storage: `collect-event!` derives the full storage row (data = event-data-json, summary = build-summary, pid/pname = extract-pid/extract-process-name), assigns a monotonic seq, INSERT-OR-IGNORE dedup; `collect-events!` drains a batch; `run-boot!`/`run-tick!` tie monitor-boot/monitor-tick to the store. `run-agent` is the thin live shell (real ms clock, Linux monitor-set, poll/sleep). `make collector-check` drives boot+tick of the full fixture monitor set into a real temp SQLite store, queries rows back to assert event count, derived pid/name/summary, parsed data, and dedup. |
-| `server::PollServer` | `jsecmon/agent-server.ss` | ✅ **untyped layer** — server-side pull protocol: creates PSK challenge, verifies ChallengeResponse freshness/proof, handles `get_events_after`, `get_events_in_range`, `status`, `acknowledge`, and `ping`, and serializes ECIES-sealed `SerializedEvent`s from the priority buffer. `make agent-server-check` covers direct buffer decrypt plus a real TCP loopback request sequence. |
-| ebpf / dtrace I/O / remaining platform shells | —  | ⏳ deferred high-fidelity capture — the provider-backed polling agent path is wired; remaining parity is eBPF/DTrace syscall capture for file-open/ptrace/namespace/mount/capability events, AF_PACKET DNS capture, Podman event-stream mode (polling fallback is ported), local encrypted store parity, and Rust `EncryptedPayload` bincode framing only if wire compatibility with the Rust agent is required. |
+| `bin/agent` storage path (collector loop) | `jsecmon/collector.ss` | ✅ **untyped layer** — ties monitor runtime to storage: `collect-event!` derives the full storage row (data = event-data-json, summary = build-summary, pid/pname = extract-pid/extract-process-name), assigns a monotonic seq, INSERT-OR-IGNORE dedup; `collect-events!` drains a batch; `run-boot!`/`run-tick!` tie monitor-boot/monitor-tick to the store. `run-agent` is the thin live shell (real ms clock, platform monitor-set, poll/sleep). `make collector-check` drives boot+tick of the full fixture monitor set into a real temp SQLite store, queries rows back to assert event count, derived pid/name/summary, parsed data, and dedup. |
+| `server::PollServer` | `jsecmon/agent-server.ss` | ✅ **untyped layer** — server-side pull protocol: creates PSK challenge, verifies ChallengeResponse freshness/proof, handles `get_events_after`, `get_events_in_range`, `status`, `acknowledge`, and `ping`, serializes ECIES-sealed `SerializedEvent`s from the priority buffer, assigns monotonic event IDs, persists to the optional local encrypted store, and reports local DB count/latest seq in status. `make agent-server-check` covers direct buffer decrypt, dispatcher requests, local-store status, and socket start/stop lifecycle. |
+| `bin/agent` privilege drop | `jsecmon/privdrop.ss`, `bin/agent.ss` | ✅ **untyped runtime shell** — after binding the poll server, the agent resolves `nobody` from passwd text and calls `setgroups`, `setgid`, then `setuid`, matching secmon's required order. `make privdrop-check` verifies lookup strictness and call ordering without changing the test process credentials. |
+| live-platform verification limits | —  | ⚠️ The provider-backed polling agent path, wire protocol, encrypted local store, ECIES bincode frame, eBPF libbpf runtime shell, DTrace direct/subprocess runtime shells, live DNS capture stream/PID association, live Podman event stream/fallback, stealth init/watchdog, and post-bind `nobody` privilege drop are ported, wired into the agent, and fixture-checked. This macOS workspace cannot prove the privileged Linux root/libbpf path or FreeBSD root/libdtrace path live; final live parity needs those target OS/root environments. |
diff --git a/bin/agent.ss b/bin/agent.ss
index 05e70c3..bf1068e 100644
--- a/bin/agent.ss
+++ b/bin/agent.ss
@@ -11,14 +11,18 @@
                 partition
                 make-date make-time)
         (except (jerboa prelude) meta atom?)
-        (only (jsecmon config) config-from-env)
+        (only (jsecmon config) config-from-env local-db-path local-key-path)
         (only (jsecmon kernels) hex-decode)
+        (only (jsecmon local-store) local-store-open local-store-count)
+        (only (jsecmon privdrop) drop-privileges)
+        (only (jsecmon stealth) init-stealth start-anti-debug-watchdog)
         (only (jsecmon agent-server)
               make-agent-runtime agent-store-event! agent-store-events!
               agent-buffered-count agent-uptime-secs
               agent-server-start! agent-server-port)
         (only (jsecmon monitor-manager)
-              make-linux-monitor-set monitor-boot monitor-tick monitor-set-hostname)
+              make-linux-monitor-set make-freebsd-monitor-set
+              monitor-boot monitor-tick monitor-set-hostname)
         (only (jsecmon calendar) now-ms))
 
 (def *version* "jsecmon-jerboa")
@@ -136,13 +140,25 @@
     (hash-put! h "events_buffered" (agent-buffered-count rt))
     h))
 
-(def (maybe-linux-monitor-set no-monitors?)
+(def (runtime-platform)
+  (cond ((file-exists? "/dev/dtrace/dtrace") 'freebsd)
+        ((file-exists? "/proc/version") 'linux)
+        (else 'other)))
+
+(def (maybe-monitor-set no-monitors? platform)
   (and (not no-monitors?)
-       (file-directory? "/proc")
-       (try (make-linux-monitor-set)
-            (catch (e)
-              (when (debug?) (eprintln "[debug] monitors unavailable: " e))
-              #f))))
+       (case platform
+         ((freebsd)
+          (try (make-freebsd-monitor-set)
+               (catch (e)
+                 (when (debug?) (eprintln "[debug] FreeBSD monitors unavailable: " e))
+                 #f)))
+         ((linux)
+          (try (make-linux-monitor-set)
+               (catch (e)
+                 (when (debug?) (eprintln "[debug] Linux monitors unavailable: " e))
+                 #f)))
+         (else #f))))
 
 (def (run-agent-loop rt mset poll-ms host)
   (let ((boot-ms (now-ms)))
@@ -168,6 +184,10 @@
     (when (or (arg? "--help" argv) (arg? "-h" argv))
       (usage)
       (exit 0))
+    (unless (debug?)
+      (let ((r (init-stealth)))
+        (unless (and (pair? r) (eq? (car r) 'ok))
+          (exit 0))))
     (let* ((public-hex (load-key-hex
                         "public key"
                         '("SECMON_PUBLIC_KEY" "SECMON_AGENT_PUBLIC" "ECIES_PUBLIC_KEY")
@@ -181,19 +201,41 @@
            (public-key (decode-32 "public key" public-hex))
            (psk (decode-32 "PSK" psk-hex))
            (cfg (config-from-env getenv))
+           (platform (runtime-platform))
            (listen (cfg-ref cfg 'listen-addr))
            (poll-ms (cfg-ref cfg 'poll-interval-ms))
-           (max-buffer (cfg-ref cfg 'max-buffer-size)))
+           (max-buffer (cfg-ref cfg 'max-buffer-size))
+           (db-path (local-db-path getenv platform))
+           (key-path (local-key-path getenv platform))
+           (local-store
+            (try
+              (let ((s (local-store-open db-path key-path)))
+                (when (debug?)
+                  (eprintln "[debug] Local store opened: "
+                            (local-store-count s) " events, db=" db-path))
+                s)
+              (catch (e)
+                (eprintln "[warn] Local store unavailable: " e
+                          " (events will only be buffered in memory)")
+                #f))))
       (let-values (((host port) (split-host-port listen)))
-        (let* ((mset (maybe-linux-monitor-set (arg? "--no-monitors" argv)))
+        (let* ((mset (maybe-monitor-set (arg? "--no-monitors" argv) platform))
                (event-host (if mset (monitor-set-hostname mset) (fallback-hostname)))
-               (rt (make-agent-runtime public-key psk max-buffer event-host))
+               (rt (make-agent-runtime public-key psk max-buffer event-host local-store))
                (srv (agent-server-start! rt host port)))
+          (let ((drop-result (drop-privileges "nobody")))
+            (if (and (pair? drop-result) (eq? (car drop-result) 'ok))
+                (eprintln "[info] Dropped privileges to 'nobody'")
+                (eprintln "[warn] Could not drop privileges: "
+                          (if (and (pair? drop-result) (pair? (cdr drop-result)))
+                              (cadr drop-result)
+                              drop-result))))
+          (start-anti-debug-watchdog)
           (when (debug?)
             (eprintln "[debug] jsecmon-agent listening on " host ":" (agent-server-port srv)
                       " poll_ms=" poll-ms
                       " buffer=" max-buffer
-                      " monitors=" (if mset "linux" "off")))
+                      " monitors=" (if mset (symbol->string platform) "off")))
           (run-agent-loop rt mset poll-ms event-host))))))
 
 (main)
diff --git a/bin/collector.ss b/bin/collector.ss
index e15d4ca..64cb1a9 100644
--- a/bin/collector.ss
+++ b/bin/collector.ss
@@ -17,10 +17,9 @@
 ;;;         encrypted under the PSK-derived transport key.
 ;;; Handshake: recv Challenge → send ChallengeResponse(respond-to-challenge).
 ;;;
-;;; Divergence from secmon (deliberate, documented in crypto-ecies.ss): an event's
-;;; encrypted_data IS the bare ECIES frame ephemeral(32)‖nonce(12)‖ct — there is
-;;; no bincode EncryptedPayload wrapper — so this collector pairs with the jsecmon
-;;; agent. The SecurityEvent bincode inside is byte-faithful to secmon regardless.
+;;; Events' encrypted_data is secmon's bincode EncryptedPayload frame:
+;;; ephemeral_pubkey[32] ‖ nonce[12] ‖ ciphertext_len:u64le ‖ ciphertext.
+;;; The SecurityEvent bincode inside is byte-faithful to secmon as well.
 ;;; Keys load from env (SECMON_PRIVATE_KEY / SECMON_PSK): a value <64 chars that
 ;;; starts with / or . is a key-file path, else the literal hex; no compile-time
 ;;; embed (jsecmon never bakes secrets into the binary — see keygen.ss).
diff --git a/examples/agent_server_check.ss b/examples/agent_server_check.ss
index 22513bf..564d681 100644
--- a/examples/agent_server_check.ss
+++ b/examples/agent_server_check.ss
@@ -1,22 +1,21 @@
-;;; Agent poll-server check: encrypted buffer + real TCP loopback.
+;;; Agent poll-server check: encrypted buffer + request dispatch.
 ;;;
 ;;;   scheme --libdirs "$JERBOA/lib:." --script examples/agent_server_check.ss
 
 (import (jerboa prelude)
-        (only (std net tcp) tcp-connect-binary)
         (only (jsecmon agent-server)
               make-agent-runtime agent-store-event! agent-events-after
-              agent-buffered-count agent-latest-seq
+              agent-buffered-count agent-latest-seq agent-handle-request
               agent-server-start! agent-server-port agent-server-stop!)
         (only (jsecmon crypto-ecies) ecies-generate-keypair ecies-decrypt)
-        (only (jsecmon crypto-psk)
-              transport-encrypt transport-decrypt respond-to-challenge)
-        (only (jsecmon kernels) hex-decode derive-auth-key derive-transport-key)
-        (only (jsecmon frame) frame-encode frame-read-length)
+        (only (jsecmon kernels) hex-decode)
         (only (jsecmon protocol)
-              message->bytes message-from-bytes
               serialized-event-seq serialized-event-encrypted-data)
-        (only (jsecmon event-codec) decode-security-event))
+        (only (jsecmon event-codec) decode-security-event)
+        (only (jsecmon local-store)
+              local-store-open local-store-close
+              local-store-count local-store-latest-seq)
+        (only (std security taint) safe-delete-file))
 
 (def fails 0)
 (def (check name got want)
@@ -27,12 +26,17 @@
 
 (def psk-hex "4242424242424242424242424242424242424242424242424242424242424242")
 (def psk (hex-decode psk-hex))
-(def auth-key (derive-auth-key psk))
-(def tk (derive-transport-key psk))
 (def kp (ecies-generate-keypair))
 (def secret (car kp))
 (def public (cadr kp))
 
+(def stamp (str (time-second (current-time)) "-" (random 1000000000)))
+(def db-path (str "/tmp/jsec-agent-server-" stamp ".db"))
+(def key-path (str "/tmp/jsec-agent-server-" stamp ".key"))
+(def (rm path)
+  (when (file-exists? path)
+    (safe-delete-file path)))
+
 (def (event type . kvs)
   (let ((h (make-hash-table)))
     (hash-put! h "type" type)
@@ -45,73 +49,47 @@
         (loop (cddr xs))))
     h))
 
-(def (read-exact in n)
-  (let ((buf (make-bytevector n 0)))
-    (let loop ((off 0))
-      (if (>= off n) buf
-          (let ((chunk (get-bytevector-n in (- n off))))
-            (when (or (eof-object? chunk) (not (bytevector? chunk))
-                      (= (bytevector-length chunk) 0))
-              (error 'read-exact "connection closed"))
-            (let ((k (bytevector-length chunk)))
-              (bytevector-copy! chunk 0 buf off k)
-              (loop (+ off k))))))))
-
-(def (send-msg out msg)
-  (put-bytevector out (frame-encode (transport-encrypt tk (message->bytes msg))))
-  (flush-output-port out))
-
-(def (recv-msg in)
-  (let* ((len (frame-read-length (read-exact in 4)))
-         (dec (transport-decrypt tk (read-exact in len))))
-    (unwrap (message-from-bytes dec))))
-
-(def (connect-client port)
-  (let-values (((in out) (tcp-connect-binary "127.0.0.1" port)))
-    (let ((ch (recv-msg in)))
-      (check "challenge received" (car ch) 'challenge)
-      (send-msg out (list 'challenge-response (respond-to-challenge auth-key (cadr ch))))
-      (values in out))))
-
-(def (request in out req)
-  (send-msg out (list 'request req))
-  (recv-msg in))
-
 (displayln "agent runtime stores ECIES events:")
-(def rt (make-agent-runtime public psk 10 "agent-test"))
+(def store (local-store-open db-path key-path))
+(def rt (make-agent-runtime public psk 10 "agent-test" store))
 (check "first seq" (agent-store-event! rt (event "heartbeat" "uptime_secs" 1 "events_buffered" 0)) 0)
 (check "second seq" (agent-store-event! rt (event "heartbeat" "uptime_secs" 2 "events_buffered" 1)) 1)
 (check "buffered" (agent-buffered-count rt) 2)
 (check "latest seq" (agent-latest-seq rt) 2)
+(check "local count" (local-store-count store) 2)
+(check "local latest event id" (local-store-latest-seq store) 1)
 (let* ((evs (agent-events-after rt -1))
        (pt (ecies-decrypt secret (serialized-event-encrypted-data (car evs))))
        (ev (decode-security-event pt)))
   (check "direct decrypt type" (hash-get ev "type") "heartbeat")
   (check "direct decrypt host" (hash-get ev "host") "agent-test"))
 
-(displayln "agent server loopback:")
-(def srv (agent-server-start! rt "127.0.0.1" 0))
-(sleep-ms 50)
-(let-values (((in out) (connect-client (agent-server-port srv))))
-  (let ((status (request in out (list 'status))))
-    (check "status tag" (car status) 'response)
-    (check "status buffered" (cadr (cadr status)) 2)
-    (check "status latest" (caddr (cadr status)) 2))
-  (let ((resp (request in out (list 'get-events-after 0))))
-    (check "events response" (car (cadr resp)) 'events)
-    (let ((evs (cdr (cadr resp))))
-      (check "after 0 returns seq 1 only" (map serialized-event-seq evs) '(1))
-      (let* ((pt (ecies-decrypt secret (serialized-event-encrypted-data (car evs))))
-             (ev (decode-security-event pt)))
-        (check "wire decrypt uptime" (hash-get ev "uptime_secs") 2))))
-  (let ((ack (request in out (list 'acknowledge 0))))
-    (check "ack" ack (list 'response (list 'acked 0))))
-  (let ((pong (request in out (list 'ping))))
-    (check "pong tag" (car (cadr pong)) 'pong))
-  (close-port in)
-  (close-port out))
+(displayln "agent request dispatcher:")
+(let ((status (agent-handle-request rt (list 'status))))
+  (check "status tag" (car status) 'response)
+  (check "status buffered" (cadr (cadr status)) 2)
+  (check "status latest" (caddr (cadr status)) 2)
+  (check "status local count" (list-ref (cadr status) 4) 2)
+  (check "status local latest" (list-ref (cadr status) 5) 1))
+(let ((resp (agent-handle-request rt (list 'get-events-after 0))))
+  (check "events response" (car (cadr resp)) 'events)
+  (let ((evs (cdr (cadr resp))))
+    (check "after 0 returns seq 1 only" (map serialized-event-seq evs) '(1))
+    (let* ((pt (ecies-decrypt secret (serialized-event-encrypted-data (car evs))))
+           (ev (decode-security-event pt)))
+      (check "dispatcher decrypt uptime" (hash-get ev "uptime_secs") 2))))
+(let ((ack (agent-handle-request rt (list 'acknowledge 0))))
+  (check "ack" ack (list 'response (list 'acked 0))))
+(let ((pong (agent-handle-request rt (list 'ping))))
+  (check "pong tag" (car (cadr pong)) 'pong))
 
+(displayln "server socket start/stop:")
+(def srv (agent-server-start! rt "127.0.0.1" 0))
+(let ((port (agent-server-port srv)))
+  (check "server got ephemeral port" (and (integer? port) (not (= port 0))) #t))
 (agent-server-stop! srv)
+(local-store-close store)
+(for-each rm (list db-path key-path (str db-path "-wal") (str db-path "-shm")))
 
 (newline)
 (if (= fails 0)
diff --git a/examples/collector_check.ss b/examples/collector_check.ss
index f65b79c..9381104 100644
--- a/examples/collector_check.ss
+++ b/examples/collector_check.ss
@@ -184,7 +184,10 @@
            (make-logtamper-monitor lp) lp
            (make-webshell-monitor "host")
            (make-revshell-monitor "host")
-           (make-dns-monitor '("8.8.8.8") "host")))
+           (make-dns-monitor '("8.8.8.8") "host")
+           #f
+           #f
+           #f))
 
 (def col (open-collector db "agent-1" "host"))
 
diff --git a/examples/collector_pull_check.ss b/examples/collector_pull_check.ss
index ce03513..e464d09 100644
--- a/examples/collector_pull_check.ss
+++ b/examples/collector_pull_check.ss
@@ -58,7 +58,7 @@
             "server_addr" "1.1.1.1" "pid" 55 "pname" "curl")
         (mk "heartbeat" "uptime_secs" 3600 "events_buffered" 0)))
 
-;; SerializedEvent.encrypted_data = the bare ECIES frame (jsecmon framing).
+;; SerializedEvent.encrypted_data = secmon's bincode EncryptedPayload frame.
 (def serialized
   (let loop ((evs events-in) (seq 10) (acc '()))
     (if (null? evs) (reverse acc)
diff --git a/examples/crypto_ecies_check.ss b/examples/crypto_ecies_check.ss
index e089a65..66efe46 100644
--- a/examples/crypto_ecies_check.ss
+++ b/examples/crypto_ecies_check.ss
@@ -60,8 +60,12 @@
 (check "both decrypt" (ecies-decrypt sk frame2) msg)
 ;; a fresh ephemeral keypair + nonce per call -> two encryptions differ
 (check "ephemeral randomized" (equal? frame1 frame2) #f)
-;; the frame carries ephemeral_public(32) ‖ nonce(12) ‖ ciphertext‖tag(16+)
-(check "frame >= 32+12+tag" (>= (bytevector-length frame1) (+ 32 12 16)) #t)
+;; the frame matches secmon's bincode EncryptedPayload layout:
+;; ephemeral_public(32) ‖ nonce(12) ‖ ciphertext_len:u64le ‖ ciphertext‖tag.
+(check "frame >= 32+12+len+tag" (>= (bytevector-length frame1) (+ 32 12 8 16)) #t)
+(check "ciphertext length field"
+       (bytevector-u64-ref frame1 44 (endianness little))
+       (- (bytevector-length frame1) 52))
 ;; a different recipient secret cannot open it
 (def other (ecies-generate-keypair))
 (check "wrong recipient -> #f" (ecies-decrypt (car other) frame1) #f)
diff --git a/examples/dns_sniffer_check.ss b/examples/dns_sniffer_check.ss
index 50d0eed..6d7df28 100644
--- a/examples/dns_sniffer_check.ss
+++ b/examples/dns_sniffer_check.ss
@@ -31,6 +31,8 @@
                       (string-split name #\.))
           (list 0)))
 (def (u16-bytes n) (list (quotient n 256) (modulo n 256)))
+(def (bv->list bv)
+  (map (lambda (i) (bytevector-u8-ref bv i)) (iota (bytevector-length bv))))
 
 (def (build-query name qtype)
   (bytes (append (list #xAB #xCD #x01 #x00  #x00 #x01 #x00 #x00 #x00 #x00 #x00 #x00)
@@ -133,16 +135,39 @@
 (check "neither port 53 -> #f"
        (parse-ip-udp-dns (ip-udp-wrap '(1 2 3 4) '(5 6 7 8) 1000 2000 (build-query "x.com" 1))) #f)
 
+(displayln "parse-link-ip-udp-dns (IP, Ethernet, VLAN):")
+(let* ((ip (ip-udp-wrap '(10 0 0 1) '(8 8 4 4) 53000 53 (build-query "link.example" 1)))
+       (eth (bytes (append '(0 1 2 3 4 5 6 7 8 9 10 11 #x08 #x00) (bv->list ip))))
+       (vlan (bytes (append '(0 1 2 3 4 5 6 7 8 9 10 11 #x81 #x00 0 7 #x08 #x00)
+                            (bv->list ip)))))
+  (check "  direct IP" (captured-dns-query-name (parse-link-ip-udp-dns ip)) "link.example")
+  (check "  ethernet" (captured-dns-query-name (parse-link-ip-udp-dns eth)) "link.example")
+  (check "  vlan" (captured-dns-query-name (parse-link-ip-udp-dns vlan)) "link.example"))
+
 ;; ── test_sniffer_state_dedup ─────────────────────────────────────────────────
 (displayln "dedup state (5s window):")
 (let ((st  (make-dns-sniffer "test-host"))
       (cap (make-captured-dns "example.com" "A" "8.8.8.8" 12345 '() #f)))
-  (check "first time emits"      (and (dns-sniffer-process st cap 1000) #t) #t)
+  (let ((ev (dns-sniffer-process st cap 1000)))
+    (check "first time emits"      (and ev #t) #t)
+    (check "event type"            (hash-get ev "type") "dns_query")
+    (check "event query"           (hash-get ev "query_name") "example.com")
+    (check "no lookup pid"         (hash-get ev "pid") #f))
   (check "within 5s deduped"     (dns-sniffer-process st cap 2000) #f)
   (check "after 5s re-emits"     (and (dns-sniffer-process st cap 7000) #t) #t)
   (dns-sniffer-cleanup st 40000)               ;; >30s since last (7000) -> dropped
   (check "cleanup then re-emits" (and (dns-sniffer-process st cap 41000) #t) #t))
 
+(displayln "PID lookup callback:")
+(let* ((st (make-dns-sniffer "test-host"))
+       (cap (make-captured-dns "lookup.test" "AAAA" "1.1.1.1" 53000 '("::1") #t))
+       (ev (dns-sniffer-process/lookup
+            st cap 9000
+            (lambda (port) (and (= port 53000) (cons 77 "dig"))))))
+  (check "lookup pid" (hash-get ev "pid") 77)
+  (check "lookup pname" (hash-get ev "pname") "dig")
+  (check "response addrs" (hash-get ev "response_addrs") '("::1")))
+
 (newline)
 (if (= fails 0)
     (displayln "OK: dns-sniffer matches secmon's dns_sniffer.rs behaviour.")
diff --git a/examples/dtrace_parse_check.ss b/examples/dtrace_parse_check.ss
index becb81c..022a555 100644
--- a/examples/dtrace_parse_check.ss
+++ b/examples/dtrace_parse_check.ss
@@ -5,7 +5,10 @@
 ;;;   scheme --libdirs "$JERBOA/lib:." --script examples/dtrace_parse_check.ss
 
 (import (jerboa prelude)
-        (jsecmon dtrace-parse))
+        (jsecmon dtrace-parse)
+        (only (jsecmon monitor-process) proc-info-name proc-info-ppid)
+        (only (jsecmon monitor-network)
+              conn-info-state conn-info-remote-addr conn-info-process-name))
 
 (def fails 0)
 (def (check name got want)
@@ -80,6 +83,30 @@
 (check "negative pid -> 0 (u32)"
        (field (parse-dtrace-line "SECMON|EXIT|-5|0") 'pid) 0)
 
+;; ── stateful EventParser behavior ───────────────────────────────────────────
+(displayln "stateful EventParser row emission:")
+(def parser (make-dtrace-parser "freebsd-host"))
+(def ev-parent (dtrace-process-line parser "SECMON|EXEC|100|1|0|gitea|gitea web" 1000))
+(check "parent event type" (hash-get ev-parent "type") "process_start")
+(check "parent cached name" (proc-info-name (hash-get (dtrace-parser-process-cache parser) 100)) "gitea")
+(def ev-child (dtrace-process-line parser "SECMON|EXEC|101|100|0|bash|bash -i" 1001))
+(check "child suspicious type" (hash-get ev-child "type") "suspicious_exec")
+(check "child parent name" (proc-info-name (hash-get ev-child "parent")) "gitea")
+(check "child reason"
+       (hash-get ev-child "reason")
+       "Shell 'bash' spawned by service 'gitea'")
+(def ev-conn (dtrace-process-line parser "SECMON|CONNECT|42|1000|curl|7" 1002))
+(check "connect event type" (hash-get ev-conn "type") "network_connection")
+(check "connect remote" (conn-info-remote-addr (hash-get ev-conn "connection")) "unknown")
+(check "connect state" (conn-info-state (hash-get ev-conn "connection")) "CONNECTING")
+(def ev-listen (dtrace-process-line parser "SECMON|LISTEN|99|0|nginx|3" 1003))
+(check "listen event type" (hash-get ev-listen "type") "listening_port")
+(check "listen pname" (conn-info-process-name (hash-get ev-listen "connection")) "nginx")
+(def ev-exited (dtrace-process-line parser "SECMON|EXIT|100|0" 1004))
+(check "exit event type" (hash-get ev-exited "type") "process_exit")
+(check "exit removed cache" (hash-get (dtrace-parser-process-cache parser) 100) #f)
+(check "open emits no event" (dtrace-process-line parser "SECMON|OPEN|5|0|cat|/etc/shadow|3" 1005) #f)
+
 (newline)
 (if (= fails 0)
     (displayln "OK: dtrace-parse matches secmon's consumer.rs behaviour.")
diff --git a/examples/dtrace_runtime_check.ss b/examples/dtrace_runtime_check.ss
new file mode 100644
index 0000000..c6b8625
--- /dev/null
+++ b/examples/dtrace_runtime_check.ss
@@ -0,0 +1,63 @@
+;;; Behaviour check for the DTrace runtime shell.
+
+(import (jerboa prelude)
+        (jsecmon dtrace-parse)
+        (jsecmon dtrace-runtime)
+        (only (jsecmon monitor-process) proc-info-name))
+
+(def fails 0)
+(def (check label got want)
+  (let ((ok (equal? got want)))
+    (unless ok (set! fails (+ fails 1)))
+    (displayln (if ok "  ok   " "  FAIL ") label " => " got
+               (if ok "" (str "  (want " want ")")))))
+
+(displayln "support check mirrors secmon's init guard order:")
+(check "non-root"
+       (dtrace-support-check* (lambda () 1000) (lambda (p) #t) (lambda () #t))
+       '(err "DTrace requires root privileges"))
+(check "missing device"
+       (dtrace-support-check* (lambda () 0) (lambda (p) #f) (lambda () #t))
+       '(err "DTrace device not available"))
+(check "test failure"
+       (dtrace-support-check* (lambda () 0) (lambda (p) #t) (lambda () #f))
+       '(err "DTrace test failed"))
+(check "ok"
+       (dtrace-support-check* (lambda () 0) (lambda (p) #t) (lambda () #t))
+       '(ok))
+(check "direct libdtrace entrypoint exported"
+       (procedure? start-dtrace-libdtrace-stream)
+       #t)
+(check "subprocess fallback entrypoint exported"
+       (procedure? start-dtrace-subprocess-stream)
+       #t)
+(check "FreeBSD preference wrapper exported"
+       (procedure? start-freebsd-dtrace-event-stream)
+       #t)
+
+(displayln "line stream drains SECMON records through EventParser:")
+(def stream (make-dtrace-line-stream))
+(def parser (make-dtrace-parser "freebsd-host"))
+(dtrace-line-stream-put-line! stream "noise from dtrace")
+(dtrace-line-stream-put-line! stream "SECMON|EXEC|123|1|1000|sh|sh -c id")
+(dtrace-line-stream-put-line! stream "SECMON|CONNECT|123|1000|sh|7")
+(dtrace-line-stream-put-line! stream "SECMON|EXIT|123|0")
+(def events (dtrace-drain-stream parser stream 9000))
+(check "event types" (map (lambda (e) (hash-get e "type")) events)
+       '("process_start" "network_connection" "process_exit"))
+(check "exec process name"
+       (proc-info-name (hash-get (car events) "process"))
+       "sh")
+(check "exit code" (hash-get (caddr events) "exit_code") 0)
+(dtrace-line-stream-end! stream)
+(check "EOF drains silent" (dtrace-drain-stream parser stream 9100) '())
+
+(displayln "combined script contains the secmon probes:")
+(check "exec probe" (and (string-contains *combined-dtrace-script* "proc:::exec") #t) #t)
+(check "connect output" (and (string-contains *combined-dtrace-script* "SECMON|CONNECT") #t) #t)
+(check "open output" (and (string-contains *combined-dtrace-script* "SECMON|OPEN") #t) #t)
+
+(newline)
+(if (= fails 0)
+    (displayln "OK: DTrace runtime shell drains secmon DTrace lines.")
+    (begin (displayln fails " FAILURES") (exit 1)))
diff --git a/examples/ebpf_events_check.ss b/examples/ebpf_events_check.ss
new file mode 100644
index 0000000..87933a2
--- /dev/null
+++ b/examples/ebpf_events_check.ss
@@ -0,0 +1,113 @@
+;;; Parity check for (jsecmon ebpf-events) against secmon ebpf/events.rs and
+;;; loader.rs parse_event.
+
+(import (jerboa prelude)
+        (jsecmon ebpf-events)
+        (only (jsecmon monitor-process) proc-info-pid proc-info-ppid proc-info-name proc-info-exe)
+        (only (jsecmon monitor-network) conn-info-remote-addr conn-info-remote-port conn-info-state))
+
+(def fails 0)
+(def (check label got want)
+  (let ((ok (equal? got want)))
+    (unless ok (set! fails (+ fails 1)))
+    (displayln (if ok "  ok   " "  FAIL ") label
+               (if ok "" (str "\n         got  " got "\n         want " want)))))
+
+(def (put-ascii! bv off len s)
+  (let ((bs (string->utf8 s)))
+    (let loop ((i 0))
+      (when (and (< i len) (< i (bytevector-length bs)))
+        (bytevector-u8-set! bv (+ off i) (bytevector-u8-ref bs i))
+        (loop (+ i 1))))))
+
+(def (event-bytes kind pid uid data1 data2 comm filename)
+  (let ((bv (make-bytevector *ebpf-event-size* 0)))
+    (bytevector-u8-set! bv 0 kind)
+    (bytevector-u32-set! bv 1 pid (endianness little))
+    (bytevector-u32-set! bv 5 uid (endianness little))
+    (bytevector-u32-set! bv 9 data1 (endianness little))
+    (bytevector-u32-set! bv 13 data2 (endianness little))
+    (put-ascii! bv 17 16 comm)
+    (put-ascii! bv 33 32 filename)
+    bv))
+
+(def (row kind pid uid data1 data2 comm filename)
+  (ebpf-event->row (parse-ebpf-event (event-bytes kind pid uid data1 data2 comm filename))
+                   "linux-host" 1700000000000))
+
+(displayln "packed Event decode:")
+(check "short -> #f" (parse-ebpf-event (make-bytevector 10 0)) #f)
+(let ((e (parse-ebpf-event (event-bytes 1 42 1000 1 0 "bash" "/bin/bash"))))
+  (check "kind" (hash-get e "kind") 1)
+  (check "pid" (hash-get e "pid") 42)
+  (check "comm" (hash-get e "comm") "bash")
+  (check "filename" (hash-get e "filename") "/bin/bash"))
+
+(displayln "parse_event mapping:")
+(let* ((ev (row 1 42 1000 1 0 "bash" "/bin/bash"))
+       (p (hash-get ev "process")))
+  (check "exec type" (hash-get ev "type") "process_start")
+  (check "exec pid" (proc-info-pid p) 42)
+  (check "exec ppid" (proc-info-ppid p) 1)
+  (check "exec name" (proc-info-name p) "bash")
+  (check "exec exe" (proc-info-exe p) "/bin/bash"))
+
+(check "exit wraps i32"
+       (hash-get (row 2 42 0 #xffffffff 0 "bash" "") "exit_code")
+       -1)
+
+(let* ((ev (row 3 42 0 4444 #x0100007f "curl" ""))
+       (c (hash-get ev "connection")))
+  (check "connect type" (hash-get ev "type") "network_connection")
+  (check "connect remote addr" (conn-info-remote-addr c) "127.0.0.1")
+  (check "connect remote port" (conn-info-remote-port c) 4444)
+  (check "connect state" (conn-info-state c) "connecting"))
+(check "zero connect skipped" (row 3 42 0 0 0 "curl" "") #f)
+(check "accept state"
+       (conn-info-state (hash-get (row 4 42 0 2222 #x0100000a "sshd" "") "connection"))
+       "accepted")
+(check "dns kind skipped" (row 5 42 0 0 0 "dig" "") #f)
+
+(let ((ev (row 6 50 1000 3 0 "cat" "/etc/shadow")))
+  (check "file open type" (hash-get ev "type") "sensitive_file_access")
+  (check "file open path" (hash-get ev "path") "/etc/shadow")
+  (check "file open flags" (hash-get ev "flags") 3))
+
+(let ((ev (row 7 51 1000 0 1 "su" "")))
+  (check "setuid type" (hash-get ev "type") "privilege_change")
+  (check "setuid new_id" (hash-get ev "new_id") 0)
+  (check "setgid marker" (hash-get ev "is_gid") #t))
+
+(let ((ev (row 8 52 0 0 4096 "insmod" "")))
+  (check "kmod name" (hash-get ev "name") "<loaded via init_module>")
+  (check "kmod size" (hash-get ev "size") 4096))
+
+(let ((ev (row 9 53 0 16 99 "gdb" "")))
+  (check "ptrace request" (hash-get ev "request") 'attach)
+  (check "ptrace target" (hash-get ev "target_pid") 99))
+(check "ptrace other" (ptrace-request-from-u32 7) (cons 'other 7))
+
+(let ((ev (row 10 54 0 #x00020000 #x1 "unshare" "")))
+  (check "clone op" (hash-get ev "operation") 'clone)
+  (check "clone flags" (hash-get ev "ns_flags") (+ (ash #x1 32) #x00020000)))
+(let ((ev (row 11 55 0 0 #x40000000 "setns" "")))
+  (check "setns op" (hash-get ev "operation") 'enter)
+  (check "setns flags" (hash-get ev "ns_flags") #x40000000))
+(check "unshare op" (hash-get (row 12 56 0 #x10000 0 "unshare" "") "operation") 'unshare)
+
+(let ((ev (row 13 57 0 32 0 "mount" "/host")))
+  (check "mount type" (hash-get ev "type") "mount_event")
+  (check "mount source" (hash-get ev "source") "/host")
+  (check "mount flags" (hash-get ev "flags") 32))
+
+(let ((ev (row 14 58 0 21 22 "capsh" "")))
+  (check "cap type" (hash-get ev "type") "capability_event")
+  (check "cap effective" (hash-get ev "cap_effective") 21)
+  (check "cap permitted" (hash-get ev "cap_permitted") 22))
+
+(check "unknown kind skipped" (row 99 1 0 0 0 "x" "") #f)
+
+(newline)
+(if (= fails 0)
+    (displayln "OK: eBPF packed events map like secmon loader.rs parse_event.")
+    (begin (displayln fails " FAILURES") (exit 1)))
diff --git a/examples/ebpf_runtime_check.ss b/examples/ebpf_runtime_check.ss
new file mode 100644
index 0000000..dabb1cf
--- /dev/null
+++ b/examples/ebpf_runtime_check.ss
@@ -0,0 +1,86 @@
+;;; Behaviour check for (jsecmon ebpf-runtime): support guard, tracepoint attach
+;;; table, queued perf-buffer bytes, and drain through the eBPF event decoder.
+
+(import (jerboa prelude)
+        (jsecmon ebpf-events)
+        (jsecmon ebpf-runtime)
+        (only (jsecmon monitor-network) conn-info-remote-addr))
+
+(def fails 0)
+(def (check label got want)
+  (let ((ok (equal? got want)))
+    (unless ok (set! fails (+ fails 1)))
+    (displayln (if ok "  ok   " "  FAIL ") label
+               (if ok "" (str "\n         got  " got "\n         want " want)))))
+
+(def (put-ascii! bv off len s)
+  (let ((bs (string->utf8 s)))
+    (let loop ((i 0))
+      (when (and (< i len) (< i (bytevector-length bs)))
+        (bytevector-u8-set! bv (+ off i) (bytevector-u8-ref bs i))
+        (loop (+ i 1))))))
+
+(def (event-bytes kind pid uid data1 data2 comm filename)
+  (let ((bv (make-bytevector *ebpf-event-size* 0)))
+    (bytevector-u8-set! bv 0 kind)
+    (bytevector-u32-set! bv 1 pid (endianness little))
+    (bytevector-u32-set! bv 5 uid (endianness little))
+    (bytevector-u32-set! bv 9 data1 (endianness little))
+    (bytevector-u32-set! bv 13 data2 (endianness little))
+    (put-ascii! bv 17 16 comm)
+    (put-ascii! bv 33 32 filename)
+    bv))
+
+(displayln "kernel support guard:")
+(check "parse 6.8"
+       (parse-kernel-version "Linux version 6.8.0-31-generic (builder)")
+       (cons 6 8))
+(check "parse distro suffix"
+       (parse-kernel-version "Linux version 5.15.133-custom #1 SMP")
+       (cons 5 15))
+(check "bad version"
+       (parse-kernel-version "Linux nope")
+       #f)
+(check "too old"
+       (check-ebpf-support* "Linux version 4.14.99 x" #t 0)
+       '(err "kernel 4.14 too old (need 4.15+)"))
+(check "non-root"
+       (check-ebpf-support* "Linux version 6.1.0 x" #t 1000)
+       '(err "need root or CAP_BPF capability"))
+(check "ok with btf"
+       (check-ebpf-support* "Linux version 6.1.0 x" #t 0)
+       '(ok btf))
+(check "ok without btf"
+       (check-ebpf-support* "Linux version 6.1.0 x" #f 0)
+       '(ok no-btf))
+
+(displayln "tracepoint attach table:")
+(check "tracepoint count" (length *ebpf-tracepoints*) 18)
+(check "first tracepoint" (car *ebpf-tracepoints*)
+       '("trace_fork" "sched" "sched_process_fork"))
+(check "last tracepoint" (list-ref *ebpf-tracepoints* 17)
+       '("trace_capset" "syscalls" "sys_enter_capset"))
+(check "object path fallback tail" (list-ref *ebpf-object-paths* 3) "./secmon.bpf.o")
+
+(displayln "event stream drain:")
+(let ((stream (make-ebpf-event-stream)))
+  (ebpf-event-stream-put-bytes! stream
+                                (event-bytes 2 42 1000 7 0 "bash" ""))
+  (ebpf-event-stream-put-bytes! stream
+                                (event-bytes 3 43 1000 4444 #x0100007f "curl" ""))
+  (ebpf-event-stream-put-bytes! stream
+                                (event-bytes 5 44 1000 53 #x08080808 "dig" ""))
+  (let ((rows (ebpf-drain-stream stream "linux-host" 1700000000000)))
+    (check "drained types" (map (lambda (e) (hash-get e "type")) rows)
+           '("process_exit" "network_connection"))
+    (check "exit code" (hash-get (car rows) "exit_code") 7)
+    (check "connect remote"
+           (conn-info-remote-addr (hash-get (cadr rows) "connection"))
+           "127.0.0.1"))
+  (ebpf-event-stream-end! stream)
+  (check "EOF silent" (ebpf-drain-stream stream "linux-host" 1700000000100) '()))
+
+(newline)
+(if (= fails 0)
+    (displayln "OK: eBPF runtime shell drains queued perf bytes through parse_event.")
+    (begin (displayln fails " FAILURES") (exit 1)))
diff --git a/examples/local_store_check.ss b/examples/local_store_check.ss
new file mode 100644
index 0000000..18221a0
--- /dev/null
+++ b/examples/local_store_check.ss
@@ -0,0 +1,121 @@
+;;; Parity check for (jsecmon local-store) against secmon src/local_store.rs.
+;;;
+;;; Exercises the Rust LocalEventStore contract: 32-byte local key
+;;; load/generate, SQLite schema and indexes, INSERT OR IGNORE, metadata columns
+;;; (seq/timestamp/severity/event_type), AES-256-GCM encrypted SecurityEvent
+;;; payloads, ordered get_events_after, and both cleanup operations.
+;;;
+;;;   scheme --libdirs "$JERBOA/lib:." --script examples/local_store_check.ss
+
+(import (jerboa prelude)
+        (jsecmon local-store)
+        (only (jsecmon event-codec) encode-security-event)
+        (std db sqlite-native))
+
+(def fails 0)
+(def (check name got want)
+  (let ((ok (equal? got want)))
+    (unless ok (set! fails (+ fails 1)))
+    (displayln (if ok "  ok   " "  FAIL ") name
+               (if ok "" (str "\n         got  " got "\n         want " want)))))
+
+(def (rm path)
+  (when (file-exists? path) (delete-file path)))
+
+(def stamp (str (time-second (current-time)) "-" (random 1000000000)))
+(def db-path (str "/tmp/jsec-local-store-" stamp ".db"))
+(def key-path (str "/tmp/jsec-local-store-" stamp ".key"))
+
+(def (mk type id ts . kvs)
+  (let ((h (make-hash-table)))
+    (hash-put! h "id" id)
+    (hash-put! h "ts" ts)
+    (hash-put! h "host" "agent-1")
+    (hash-put! h "type" type)
+    (let loop ((xs kvs))
+      (when (pair? xs)
+        (hash-put! h (car xs) (cadr xs))
+        (loop (cddr xs))))
+    h))
+
+(def agent-start
+  (mk "agent_start" 41 1000 "version" "1.2.3"))
+(def heartbeat
+  (mk "heartbeat" 42 2000 "uptime_secs" 3600 "events_buffered" 7))
+(def auth-fail
+  (mk "auth_event" 43 3000
+      "auth_type" 'failed-login
+      "username" "mallory"
+      "uid" #f
+      "tty" #f
+      "remote_host" "203.0.113.7"
+      "success" #f
+      "message" "Failed password"))
+
+(displayln "key generation/load:")
+(let ((key (load-or-generate-local-key key-path)))
+  (check "generated key is 32 bytes" (bytevector-length key) 32)
+  (check "key file exists" (file-exists? key-path) #t)
+  (check "reload returns same key" (load-or-generate-local-key key-path) key))
+
+(displayln "open/persist/query/decrypt:")
+(let ((store (local-store-open db-path key-path)))
+  (dynamic-wind
+    (lambda () #t)
+    (lambda ()
+      (check "initial count" (local-store-count store) 0)
+      (check "initial latest_seq" (local-store-latest-seq store) 0)
+
+      (check "persist returns seq" (local-store-persist store agent-start) 41)
+      (check "count after first" (local-store-count store) 1)
+      (check "latest after first" (local-store-latest-seq store) 41)
+
+      (let* ((rows (local-store-events-after store 0))
+             (row (car rows)))
+        (check "events_after count" (length rows) 1)
+        (check "row seq" (local-stored-event-seq row) 41)
+        (check "row timestamp" (local-stored-event-timestamp-ms row) 1000)
+        (check "row severity" (local-stored-event-severity row) 0)
+        (check "row event_type" (local-stored-event-event-type row) "Discriminant(30)")
+        (check "nonce len" (bytevector-length (local-stored-event-nonce row)) 12)
+        (check "encrypted differs from plaintext"
+               (equal? (local-stored-event-encrypted row) (encode-security-event agent-start))
+               #f)
+        (let ((decoded (local-stored-event-decrypt (local-store-key store) row)))
+          (check "decrypted type" (hash-get decoded "type") "agent_start")
+          (check "decrypted id" (hash-get decoded "id") 41)
+          (check "decrypted host" (hash-get decoded "host") "agent-1")
+          (check "decrypted version" (hash-get decoded "version") "1.2.3"))
+        (check "wrong key fails"
+               (local-stored-event-decrypt (make-bytevector 32 0) row)
+               #f))
+
+      (local-store-persist store heartbeat)
+      (local-store-persist store auth-fail)
+      (local-store-persist store heartbeat)       ;; duplicate primary key ignored
+      (check "count after duplicate ignored" (local-store-count store) 3)
+      (check "latest after three" (local-store-latest-seq store) 43)
+
+      (let ((rows (local-store-events-after store 41)))
+        (check "after(41) seqs" (map local-stored-event-seq rows) '(42 43))