frame: port server/protocol length-prefixed wire framing (untyped)

ober

f8098350d03071c0a0597ca2df318167d9fb9c33

diff --git a/Makefile b/Makefile
index b3aed99..7f3da02 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 lolbin-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 frame-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)"
@@ -252,6 +252,12 @@ event-summary-check:
 ioc-check:
 	$(SCHEME) --libdirs $(LIBDIRS) --script examples/ioc_check.ss
 
+# Length-prefixed wire framing (secmon src/server/protocol.rs): frame_encode
+# (4-byte LE u32 len ++ payload), frame_read_length (LE u32), frame_decode (the
+# two from_bytes guards + payload slice). bincode of the message body is deferred.
+frame-check:
+	$(SCHEME) --libdirs $(LIBDIRS) --script examples/frame_check.ss
+
 # Reverse-shell classifiers (secmon src/monitor/revshell.rs): is_shell /
 # is_revshell_tool (exact name), is_c2_port (17-port set), is_legitimate_service
 # (443/8080/8443 + name substring), extract_addr_from_cmdline (/dev/tcp/ split),
@@ -330,6 +336,7 @@ checks: kernels-check
 	$(SCHEME) --libdirs $(LIBDIRS) --script examples/collector_cli_check.ss
 	$(SCHEME) --libdirs $(LIBDIRS) --script examples/event_summary_check.ss
 	$(SCHEME) --libdirs $(LIBDIRS) --script examples/ioc_check.ss
+	$(SCHEME) --libdirs $(LIBDIRS) --script examples/frame_check.ss
 	$(SCHEME) --libdirs $(LIBDIRS) --script examples/revshell_check.ss
 	$(SCHEME) --libdirs $(LIBDIRS) --script examples/cron_check.ss
 	$(SCHEME) --libdirs $(LIBDIRS) --script examples/logtamper_check.ss
diff --git a/README.md b/README.md
index fba3c60..ba66cb9 100644
--- a/README.md
+++ b/README.md
@@ -54,6 +54,7 @@ make analyze-cli-check # analyze bin: parse_duration_ms + AlertSink::parse + --f
 make collector-cli-check # collector bin: --after/--format/--db + host normalize + hosts-file
 make event-summary-check # storage readers: extract_pid/extract_process_name/build_summary
 make ioc-check       # storage IOC: detect_ioc_type + is_ipv4 + parse_ioc_text + match field routing
+make frame-check     # server protocol: length-prefixed wire framing (encode/read_length/decode)
 make revshell-check  # revshell: is_shell/is_c2_port/is_legitimate + classify_connection
 make cron-check      # cron: per-platform CRON/PERIODIC path tables + systemd/periodic route
 make logtamper-check # logtamper: system-log/history tables + classify-tamper (trunc/mtime)
@@ -126,6 +127,7 @@ then crypto orchestration, then I/O / async / FFI (monitors, server, storage).
 | `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. |
 | `storage` impossible_travel | `jsecmon/threats.ss` | ✅ **untyped layer** — geoip-gated (reads `SECMON_GEOIP_CSV`): pair a user's consecutive successful `auth_event`s, fire `high` when the two source IPs resolve to different countries within `SECMON_TRAVEL_GAP_MIN` (default 30). Private IPs are dropped before pairing. `make geoip-check` proves the fire + the gap/same-country/private/cross-user negatives. |
 | `buffer::ring` (StoredEvent ring buffer) | `jsecmon/buffer.ss` | ✅ **untyped layer** — port of secmon's `src/buffer/ring.rs`: the agent's bounded in-memory event ring. FIFO list + monotonic seq numbering, priority eviction (`event_severity_u8` table, drop lowest-severity oldest-first, oldest-critical last), seq/time-range polling, FIFO delivery-ack (`clear_before`), and the little-endian header codec (`seq u64 ∥ ts i64 ∥ sev u8 ∥ payload`). Pure mechanics, so untyped — the one security step, ECIES payload encryption, is FFI-deferred: the caller hands `buffer-store!` opaque ciphertext bytes. `make buffer-check` reproduces secmon's three ring tests (store/seq, priority eviction, FIFO-oldest) + codec round-trip. |
+| `server::protocol` (length-prefixed framing) | `jsecmon/frame.ss` | ✅ **untyped layer** — the wire framing the collector↔agent transport wraps every `ProtocolMessage` in: `frame_encode` prepends a 4-byte little-endian u32 length to the payload (`to_bytes`'s framing half), `frame_read_length` reads that header as an LE u32 (`read_length`), and `frame_decode` reproduces `from_bytes`'s two guards — `< 4` bytes → `data too short`, `< 4 + declared len` → `incomplete message` — then slices out `data[4 .. 4+len]`. Pure byte mechanics, so untyped; the bincode (de)serialization of the message *body* is the Rust-specific deferred piece (the caller (de)serializes the payload `frame_decode` hands back). LE u32 verified to round-trip `0x12345678` and `0xFFFFFFFF`. `make frame-check` pins the byte layout, the encode∘decode round-trip, both error guards, and the empty/zero-length-payload edge. |
 | `monitor::events::SuspiciousPatterns` (process-spawn classifier) | `jsecmon/suspicious.ss` | ✅ **untyped layer** — `check_suspicious(process, parent)`: shell-from-service, attack-tool-from-service (name exact-match or exe suffix), reverse-shell command-line patterns, and crypto-miner name/cmdline patterns, in secmon's order, returning the same reason string. Pure string classification like triage. Pins two corners the Rust depends on: a missing parent short-circuits to "clean" before any check, and `str::contains` is a *literal* substring test (so `python -c.*socket` is literal, not a regex). `make suspicious-check` reproduces secmon's two events.rs tests + the other three signals + both corners. |
 | `monitor::network::NetworkMonitor` (connection classifier) | `jsecmon/netconn.ss` | ✅ **untyped layer** — `check_suspicious(port, addr, process)`: known reverse-shell/C2/l33t port, ephemeral port (49152..65535) that is a round multiple of 1000, and a web-server process (nginx/apache/httpd/php-fpm) connecting to a non-private address, in secmon's order with the same reason string. Pure metadata classification. secmon hides the web-server names with `obfstr!` (same scheme as `typed/obfuscate.ss`); they decode to these plaintext literals at runtime. Pins the faithfulness quirk that the "private" prefix set is literal `{127. 10. 192.168. 172.}`, so `172.` matches all of 172.x, not just RFC1918 172.16/12. `make netconn-check` reproduces secmon's two network.rs tests + the full bad-port list + the high-port and web-server rules with private-address negatives. |
 | `monitor::kernel::KernelModuleMonitor` (kernel-module classifier) | `jsecmon/kernmod.ss` | ✅ **untyped layer** — `is_suspicious_module(name)`: lower-cased name contains a known-rootkit substring (diamorphine/reptile/hide/rootkit/keylog/…), or a 1-2 char name not on the legitimate-short allow-list (ip dm sd sr nf if), or a >4 char name with no vowel, in secmon's order. Pure string classification like the other classifiers. obfstr!-hidden name lists decode to these plaintext literals. Pins the faithfulness corner that only the substring test lower-cases the name — the short-name and vowel tests use the original case, and the vowel set is both-case `aeiouAEIOU`. `make kernmod-check` reproduces secmon's two kernel.rs tests + each signal exercised independently + the case corners. |
diff --git a/examples/frame_check.ss b/examples/frame_check.ss
new file mode 100644
index 0000000..3ad42c5
--- /dev/null
+++ b/examples/frame_check.ss
@@ -0,0 +1,60 @@
+;;; Parity check for (jsecmon frame) against secmon src/server/protocol.rs.
+;;; Pins the 4-byte LE length prefix layout, the encode/decode round-trip, and
+;;; from_bytes's two error guards.
+;;;
+;;;   scheme --libdirs "$JERBOA/lib:." --script examples/frame_check.ss
+
+(import (jerboa prelude)
+        (jsecmon frame))
+
+(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 (bv . xs) (u8-list->bytevector xs))
+(def (->u8 b) (bytevector->u8-list b))
+
+;; ── frame-encode: 4-byte LE length prefix, then payload ──────────────────────
+(displayln "frame-encode:")
+(check "3-byte payload -> len prefix 03 00 00 00 ++ body"
+       (->u8 (frame-encode (bv 65 66 67)))
+       '(3 0 0 0 65 66 67))
+(check "empty payload -> just zero length"
+       (->u8 (frame-encode (bv)))
+       '(0 0 0 0))
+
+;; ── frame-read-length: little-endian u32 ─────────────────────────────────────
+(displayln "frame-read-length:")
+(check "01 00 00 00 -> 1"     (frame-read-length (bv 1 0 0 0)) 1)
+(check "78 56 34 12 -> 0x12345678" (frame-read-length (bv 120 86 52 18)) 305419896)
+(check "ff ff ff ff -> 2^32-1" (frame-read-length (bv 255 255 255 255)) 4294967295)
+
+;; ── frame-decode: guards + payload slice ─────────────────────────────────────
+(displayln "frame-decode:")
+(check "under 4 bytes -> data too short"
+       (unwrap-err (frame-decode (bv 1 2 3))) "data too short")
+(check "declares more than present -> incomplete message"
+       (unwrap-err (frame-decode (bv 5 0 0 0 65 66))) "incomplete message")
+(check "exact frame -> payload"
+       (let ((r (frame-decode (bv 2 0 0 0 65 66)))) (and (ok? r) (->u8 (unwrap r))))
+       '(65 66))
+(check "trailing bytes past declared len are ignored"
+       (let ((r (frame-decode (bv 2 0 0 0 65 66 99 99)))) (and (ok? r) (->u8 (unwrap r))))
+       '(65 66))
+(check "zero-length frame -> empty payload"
+       (let ((r (frame-decode (bv 0 0 0 0)))) (and (ok? r) (->u8 (unwrap r))))
+       '())
+
+;; ── round-trip: encode then decode is identity on the payload ────────────────
+(displayln "round-trip:")
+(check "encode∘decode round-trips payload"
+       (let ((r (frame-decode (frame-encode (bv 9 8 7 6 5))))) (and (ok? r) (->u8 (unwrap r))))
+       '(9 8 7 6 5))
+
+(newline)
+(if (= fails 0)
+    (displayln "OK: frame matches secmon's protocol.rs length-prefix framing.")
+    (begin (displayln fails " FAILURES") (exit 1)))
diff --git a/jsecmon/frame.ss b/jsecmon/frame.ss
new file mode 100644
index 0000000..09aecd6
--- /dev/null
+++ b/jsecmon/frame.ss
@@ -0,0 +1,59 @@
+#!chezscheme
+;;; jsecmon length-prefixed wire framing (secmon src/server/protocol.rs), untyped.
+;;;
+;;; The collector↔agent protocol frames every message as a 4-byte little-endian
+;;; u32 length prefix followed by that many payload bytes. ProtocolMessage's
+;;; bincode (de)serialization of the message *body* is Rust-specific and stays
+;;; deferred — what is pure and portable is the framing around it:
+;;;   frame-encode      : payload-bv      -> bv  (4-byte LE len ++ payload)
+;;;   frame-read-length : 4-byte header   -> nat (LE u32, like read_length)
+;;;   frame-decode      : data-bv         -> (ok payload-bv) | (err msg)
+;;;
+;;; frame-decode mirrors from_bytes's two guards exactly:
+;;;   < 4 bytes              -> (err "data too short")
+;;;   < 4 + declared length  -> (err "incomplete message")
+;;; and otherwise returns the payload slice data[4 .. 4+len] for the caller to
+;;; deserialize. The length is read as an R6RS little-endian u32, matching Rust's
+;;; u32::to_le_bytes / from_le_bytes (verified to round-trip 0x12345678 and
+;;; 0xFFFFFFFF). frame-encode prepends the length the same way to_bytes does.
+;;;
+;;; examples/frame_check.ss round-trips frames, pins the byte layout, and covers
+;;; both error guards and the empty-payload edge.
+
+(library (jsecmon frame)
+  (export frame-encode frame-read-length frame-decode)
+  (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?))
+
+  ;; to_bytes (framing half): 4-byte LE length prefix ++ payload bytes.
+  (def (frame-encode payload)
+    (let* ((n (bytevector-length payload))
+           (out (make-bytevector (+ 4 n) 0)))
+      (bytevector-u32-set! out 0 n (endianness little))
+      (bytevector-copy! payload 0 out 4 n)
+      out))
+
+  ;; read_length: interpret a 4-byte header as a little-endian u32.
+  (def (frame-read-length header)
+    (bytevector-u32-ref header 0 (endianness little)))
+
+  ;; from_bytes (framing half): validate the prefix, slice out the payload.
+  ;; The bincode deserialization of that payload is the deferred Rust piece.
+  (def (frame-decode data)
+    (let ((n (bytevector-length data)))
+      (if (< n 4)
+          (err "data too short")
+          (let ((len (bytevector-u32-ref data 0 (endianness little))))
+            (if (< n (+ 4 len))
+                (err "incomplete message")
+                (let ((payload (make-bytevector len 0)))
+                  (bytevector-copy! data 4 payload 0 len)
+                  (ok payload))))))))