jsecmon: add analyze-cli parse-alert-sinks (collect every --alert-to)

ober

e516e496e35c2268c77a1f36c75c00ca9d5595be

diff --git a/README.md b/README.md
index c9c8a62..5bd0188 100644
--- a/README.md
+++ b/README.md
@@ -129,6 +129,6 @@ then crypto orchestration, then I/O / async / FFI (monitors, server, storage).
 | `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) 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. 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). |
-| `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), 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` are chrono-calendar-coupled display helpers — deferred with the other calendar I/O.) |
+| `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` are chrono-calendar-coupled display helpers — deferred with the other calendar I/O.) |
 | `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. |
 | monitors / server / ebpf / dtrace | —  | ⏳ I/O+async+FFI, last           |
diff --git a/examples/analyze_cli_check.ss b/examples/analyze_cli_check.ss
index cc7202b..c858742 100644
--- a/examples/analyze_cli_check.ss
+++ b/examples/analyze_cli_check.ss
@@ -62,6 +62,27 @@
 (check "unknown sink message" (err-msg (parse-alert-sink "carrier"))
        "unknown alert sink: 'carrier' (expected: stdout, file:/path, webhook:url, syslog[:tag])")
 
+;; ── parse-alert-sinks: collect every --alert-to, short-circuit on bad spec ───
+(displayln "parse-alert-sinks:")
+(check "single sink"  (unwrap (parse-alert-sinks '("--alert-to" "stdout")))
+       '((stdout)))
+(check "two sinks in order"
+       (unwrap (parse-alert-sinks '("--alert-to" "stdout" "--alert-to" "file:/v/a.log")))
+       '((stdout) (file "/v/a.log")))
+(check "interleaved with other args"
+       (unwrap (parse-alert-sinks '("watch" "--alert-to" "syslog:app" "--db" "e.db")))
+       '((syslog "app")))
+(check "no flags -> empty list" (unwrap (parse-alert-sinks '("watch" "--db" "x"))) '())
+;; a bad spec short-circuits with that spec's err message
+(check "bad spec is err"
+       (err? (parse-alert-sinks '("--alert-to" "stdout" "--alert-to" "bogus"))) #t)
+(check "bad spec message"
+       (err-msg (parse-alert-sinks '("--alert-to" "bogus")))
+       "unknown alert sink: 'bogus' (expected: stdout, file:/path, webhook:url, syslog[:tag])")
+;; a trailing --alert-to (no value) is skipped, earlier sinks kept
+(check "trailing --alert-to skipped"
+       (unwrap (parse-alert-sinks '("--alert-to" "stdout" "--alert-to"))) '((stdout)))
+
 ;; ── parse-flag-value: first match yields its successor, else #f ──────────────
 (displayln "parse-flag-value:")
 (check "flag value"        (parse-flag-value '("--top" "20") "--top") "20")
diff --git a/jsecmon/analyze-cli.ss b/jsecmon/analyze-cli.ss
index ebc8932..d483c93 100644
--- a/jsecmon/analyze-cli.ss
+++ b/jsecmon/analyze-cli.ss
@@ -5,6 +5,7 @@
 ;;; CLI plumbing (the dispatch/query/HTTP/syslog side effects stay deferred):
 ;;;   parse-duration-ms : "10m" etc. -> (ok ms) | (err msg)
 ;;;   parse-alert-sink  : "file:/p" etc. -> (ok sink) | (err msg)
+;;;   parse-alert-sinks : args -> (ok (sink…)) | (err msg)  (all --alert-to)
 ;;;   parse-flag-value  : args flag -> next-arg string | #f
 ;;;   has-flag          : args flag -> #t | #f
 ;;;   is-json-format    : args -> #t | #f
@@ -32,7 +33,7 @@
 ;;; every expectation from the Rust source and IS the spec for this port.
 
 (library (jsecmon analyze-cli)
-  (export parse-duration-ms parse-alert-sink
+  (export parse-duration-ms parse-alert-sink parse-alert-sinks
           parse-flag-value has-flag is-json-format)
   (import (except (chezscheme)
                   make-hash-table hash-table?
@@ -91,6 +92,23 @@
       (#t (err (str "unknown alert sink: '" spec
                     "' (expected: stdout, file:/path, webhook:url, syslog[:tag])")))))
 
+  ;; Collect every `--alert-to <spec>` into (ok (sinks…)) | (err msg). Each spec
+  ;; is parse-alert-sink'd and the FIRST err short-circuits (Rust's `?`). An
+  ;; `--alert-to` with a value advances by two; a trailing `--alert-to` with no
+  ;; value is simply skipped (Rust `i += 1`). Order is preserved; no flags ->
+  ;; (ok ()). The watch-time "empty -> [Stdout]" default lives in cmd_watch, not
+  ;; here, so this faithfully returns the empty list.
+  (def (parse-alert-sinks args)
+    (let loop ((rest args) (acc '()))
+      (cond
+        ((null? rest) (ok (reverse acc)))
+        ((string=? (car rest) "--alert-to")
+         (if (pair? (cdr rest))
+             (let ((r (parse-alert-sink (cadr rest))))
+               (if (err? r) r (loop (cddr rest) (cons (unwrap r) acc))))
+             (loop (cdr rest) acc)))
+        (#t (loop (cdr rest) acc)))))
+
   ;; Generic flag scanners over an args list (strings). Faithful corners:
   ;;   * parse-flag-value returns on the FIRST `arg == flag`, yielding the next
   ;;     arg or #f (Rust `args.get(i+1).cloned()` — #f even when flag is last);