Harden monitoring transport and key handling

ober

079330bc1b3e3d5df8faa668f85e402607a1d81f

diff --git a/.gitattributes b/.gitattributes
new file mode 100644
index 0000000..24e115f
--- /dev/null
+++ b/.gitattributes
@@ -0,0 +1 @@
+*.patch -whitespace
diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index 6236f7d..76543bc 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -13,7 +13,7 @@ jobs:
   verify:
     runs-on: ubuntu-latest
     steps:
-      - uses: actions/checkout@v4
+      - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1
 
       - name: Install system tools
         run: |
diff --git a/.github/workflows/security-baseline.yml b/.github/workflows/security-baseline.yml
index 28a713e..5381c31 100644
--- a/.github/workflows/security-baseline.yml
+++ b/.github/workflows/security-baseline.yml
@@ -13,7 +13,7 @@ jobs:
   baseline:
     runs-on: ubuntu-latest
     steps:
-      - uses: actions/checkout@v4
+      - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1
 
       - name: Required release files
         run: |
diff --git a/Makefile b/Makefile
index b9225fa..55d3056 100644
--- a/Makefile
+++ b/Makefile
@@ -4,14 +4,16 @@
 # to a Rust crate by the jerboa typed→rust backend, then verified against
 # secmon's own test vectors (tests/*.rs).
 VENDOR ?= $(CURDIR)/vendor
-JERBOA_URL ?= https://git.sr.ht/~lisp/jerboa
-JSQLITE_URL ?= https://git.sr.ht/~lisp/jsqlite
 JERBOA ?= $(VENDOR)/jerboa
 JSQLITE_REPO ?= $(VENDOR)/jsqlite
 JSQLITE_SRC ?= $(JSQLITE_REPO)/src
 JERBOA_NATIVE_FEATURES ?= tls,crypto
 SCHEME ?= $(JERBOA)/.chez/bin/scheme
 BUILD  ?= build/rust
+NATIVE_SHLIB_EXT := $(shell if [ "$$(uname -s)" = Darwin ]; then echo dylib; else echo so; fi)
+JSECMON_DEV_NATIVE ?= 1
+JSECMON_LIB ?= $(abspath $(BUILD)/target/release/libjerboa_typed_generated.$(NATIVE_SHLIB_EXT))
+export JSECMON_DEV_NATIVE JSECMON_LIB
 TYPED  := $(wildcard typed/*.ss)
 DIST_DIR ?= dist/release-evidence
 REPRO_DIR ?= dist/reproducibility
@@ -21,7 +23,7 @@ SOAK_TIMEOUT_SECONDS ?= 300
 SOAK_ITERATIONS ?= 8
 BINARY_SMOKE_TIMEOUT_SECONDS ?= 10
 
-.PHONY: vendor-deps ensure-jerboa ensure-jsqlite 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 daemon-telemetry-check mux-telemetry-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 telemetry binaries clean
+.PHONY: vendor-deps ensure-jerboa ensure-jsqlite 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 daemon-telemetry-check mux-telemetry-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 secure-key-check network-guard-check collector-pull-check agent-server-check checks native-runtime keygen analyze collector agent telemetry binaries clean
 .PHONY: security audit verify sbom reproducibility-report release-evidence soak-check soak-evidence binary-smoke
 # Combined libdir path so sibling libraries `(jsecmon ...)` resolve to ./jsecmon
 # (a second --libdirs would replace, not append, the jerboa one).
@@ -33,20 +35,16 @@ LOADER_ENV := DYLD_LIBRARY_PATH="$(JERBOA)/lib:$$DYLD_LIBRARY_PATH" LD_LIBRARY_P
 vendor-deps: ensure-jerboa ensure-jsqlite
 
 ensure-jerboa:
-	@if [ ! -d "$(JERBOA)/.git" ]; then \
-	  mkdir -p "$(VENDOR)"; \
-	  git clone --depth 1 "$(JERBOA_URL)" "$(JERBOA)"; \
-	fi
+	@test "$(abspath $(JERBOA))" = "$(abspath $(VENDOR)/jerboa)" || { echo "JERBOA override is not permitted by the locked release build" >&2; exit 1; }
+	support/fetch-locked-deps.sh jerboa
 	@if [ ! -x "$(SCHEME)" ]; then \
 	  $(MAKE) -C "$(JERBOA)" chez build; \
 	fi
 	@test -x "$(SCHEME)"
 
 ensure-jsqlite:
-	@if [ ! -f "$(JSQLITE_SRC)/jsqlite/api.ss" ]; then \
-	  mkdir -p "$(VENDOR)"; \
-	  git clone --depth 1 "$(JSQLITE_URL)" "$(JSQLITE_REPO)"; \
-	fi
+	@test "$(abspath $(JSQLITE_REPO))" = "$(abspath $(VENDOR)/jsqlite)" || { echo "JSQLITE override is not permitted by the locked release build" >&2; exit 1; }
+	support/fetch-locked-deps.sh jsqlite
 	@test -f "$(JSQLITE_SRC)/jsqlite/api.ss"
 
 # Generate the Rust crate from the Typed Jerboa kernels, then drop in the
@@ -353,6 +351,12 @@ local-store-check: rust ensure-jsqlite
 	cd $(BUILD) && cargo build --release
 	$(LOADER_ENV) $(SCHEME) --libdirs $(LIBDIRS) --script examples/local_store_check.ss
 
+secure-key-check:
+	$(SCHEME) --libdirs $(LIBDIRS) --script examples/secure_key_check.ss
+
+network-guard-check:
+	$(SCHEME) --libdirs $(LIBDIRS) --script examples/network_guard_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.
@@ -596,6 +600,8 @@ checks: kernels-check ensure-jsqlite
 	$(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/secure_key_check.ss
+	$(SCHEME) --libdirs $(LIBDIRS) --script examples/network_guard_check.ss
 	$(SCHEME) --libdirs $(LIBDIRS) --script examples/collector_pull_check.ss
 	$(LOADER_ENV) $(SCHEME) --libdirs $(LIBDIRS) --script examples/agent_server_check.ss
 	$(SCHEME) --libdirs $(LIBDIRS) --script examples/correlate_check.ss
@@ -707,14 +713,14 @@ native-runtime: ensure-jerboa
 
 keygen: rust ensure-jsqlite
 	cd $(BUILD) && cargo build --release
-	JERBOA_HOME="$(JERBOA)" JSQLITE_SRC="$(JSQLITE_SRC)" JSECMON_BINARY_WPO=0 $(SCHEME) --libdirs $(LIBDIRS) --script build-binary.ss bin/keygen.ss jsecmon-keygen
+	JERBOA_HOME="$(JERBOA)" JSQLITE_SRC="$(JSQLITE_SRC)" SCHEME="$(SCHEME)" JSECMON_BINARY_WPO=0 $(SCHEME) --libdirs $(LIBDIRS) --script build-binary.ss bin/keygen.ss jsecmon-keygen
 
 # secmon-analyze: query/detect/triage/risk over the SQLite store. Statically
 # links the Rust kernels (lolbin/dga scoring, calendar) like keygen; SQLite is
 # provided by vendored jsqlite.
 analyze: rust native-runtime ensure-jsqlite
 	cd $(BUILD) && cargo build --release
-	JERBOA_HOME="$(JERBOA)" JSQLITE_SRC="$(JSQLITE_SRC)" $(SCHEME) --libdirs $(LIBDIRS) --script build-binary.ss bin/analyze.ss jsecmon-analyze
+	JERBOA_HOME="$(JERBOA)" JSQLITE_SRC="$(JSQLITE_SRC)" SCHEME="$(SCHEME)" $(SCHEME) --libdirs $(LIBDIRS) --script build-binary.ss bin/analyze.ss jsecmon-analyze
 
 # secmon-collector: pull/status/watch over the PSK-encrypted protocol; decrypts
 # ECIES events and prints (human/NDJSON) and/or stores them. Statically links the
@@ -722,17 +728,17 @@ analyze: rust native-runtime ensure-jsqlite
 # SQLite is available through vendored jsqlite when --db is used.
 collector: rust native-runtime ensure-jsqlite
 	cd $(BUILD) && cargo build --release
-	JERBOA_HOME="$(JERBOA)" JSQLITE_SRC="$(JSQLITE_SRC)" JSECMON_BINARY_WPO=0 $(SCHEME) --libdirs $(LIBDIRS) --script build-binary.ss bin/collector.ss jsecmon-collector
+	JERBOA_HOME="$(JERBOA)" JSQLITE_SRC="$(JSQLITE_SRC)" SCHEME="$(SCHEME)" JSECMON_BINARY_WPO=0 $(SCHEME) --libdirs $(LIBDIRS) --script build-binary.ss bin/collector.ss jsecmon-collector
 
 # secmon-agent: Linux polling monitors + encrypted event buffer + poll server.
 # It loads the collector public key and PSK at runtime, never embeds secrets.
 agent: rust native-runtime ensure-jsqlite
 	cd $(BUILD) && cargo build --release
-	JERBOA_HOME="$(JERBOA)" JSQLITE_SRC="$(JSQLITE_SRC)" $(SCHEME) --libdirs $(LIBDIRS) --script build-binary.ss bin/agent.ss jsecmon-agent
+	JERBOA_HOME="$(JERBOA)" JSQLITE_SRC="$(JSQLITE_SRC)" SCHEME="$(SCHEME)" $(SCHEME) --libdirs $(LIBDIRS) --script build-binary.ss bin/agent.ss jsecmon-agent
 
 telemetry: rust native-runtime ensure-jsqlite
 	cd $(BUILD) && cargo build --release
-	JERBOA_HOME="$(JERBOA)" JSQLITE_SRC="$(JSQLITE_SRC)" $(SCHEME) --libdirs $(LIBDIRS) --script build-binary.ss bin/telemetry.ss jsecmon-telemetry
+	JERBOA_HOME="$(JERBOA)" JSQLITE_SRC="$(JSQLITE_SRC)" SCHEME="$(SCHEME)" $(SCHEME) --libdirs $(LIBDIRS) --script build-binary.ss bin/telemetry.ss jsecmon-telemetry
 
 # ── Experimental: Typed Jerboa → LLVM IR native backend ──────────────────────
 #
diff --git a/README.md b/README.md
index ff20b13..58705d8 100644
--- a/README.md
+++ b/README.md
@@ -129,11 +129,11 @@ dev-time `.ss` scripts (the test harness, not shipped).
 
 | secmon binary    | jsecmon            | status                          |
 |------------------|--------------------|---------------------------------|
-| `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/keygen`     | `bin/keygen.ss` → `make keygen` | ✅ **compiled binary** — creates ECIES public/private keys and a 32-byte PSK from the OS CSPRNG as exclusive, no-follow, descriptor-validated `0600` files. It refuses existing paths and prints no secret values. |
 | `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** — 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. |
-| daemon telemetry | `bin/telemetry.ss` → `make telemetry` | ✅ **compiled binary** — dedicated mux intake for `jerboa-smtp`/`jerboa-sshd`/httpd-style first-party daemons. Listens on `SECMON_TELEMETRY_LISTEN` (default `0.0.0.0:31338`), opens mux `MSG-ENCRYPTED` frames with the PSK transport key, requires `source` + monotonic `seq`, stores `daemon_telemetry` rows, and replies with encrypted ACK/ERROR frames. Verified over loopback by `make mux-telemetry-check`. |
+| daemon telemetry | `bin/telemetry.ss` → `make telemetry` | ✅ **compiled binary** — dedicated mux intake for `jerboa-smtp`/`jerboa-sshd`/httpd-style first-party daemons. Listens on `SECMON_TELEMETRY_LISTEN` (default `127.0.0.1:31338`), uses a fixed bounded worker pool with absolute pre-auth/frame deadlines and global/per-source budgets, opens mux `MSG-ENCRYPTED` frames with the PSK transport key, and stores validated telemetry. |
 
 ## Port status
 
@@ -194,8 +194,8 @@ then crypto orchestration, then I/O / async / FFI (monitors, server, storage).
 | `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. |
+| `local_store::LocalEventStore` | `jsecmon/local-store.ss` | ✅ **untyped layer** — encrypted SQLite local event store whose 32-byte key is exclusively created or opened no-follow and validated by descriptor for type, owner, link count, exact `0600` mode, and length. Unsafe existing keys fail closed; `secure-key-check` covers permissions, symlink/hardlink/directory, malformed length, wrong-owner policy, and pathname replacement. |
+| `config` | `jsecmon/config.ss` | ✅ **untyped layer** — `AgentConfig` defaults to loopback (`127.0.0.1:31337`), poll `100`ms, and buffer `10000`; an explicit `SECMON_LISTEN` may select a numeric remote interface. |
 | `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. |
diff --git a/bin/agent.ss b/bin/agent.ss
index bb4d12e..9fa23c0 100644
--- a/bin/agent.ss
+++ b/bin/agent.ss
@@ -14,6 +14,7 @@
         (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 secure-key-file) secure-key-read-text)
         (only (jsecmon privdrop) drop-privileges)
         (only (jsecmon stealth) init-stealth start-anti-debug-watchdog)
         (only (jsecmon agent-server)
@@ -44,11 +45,13 @@
   (eprintln "  jsecmon-agent [--no-monitors]\n")
   (eprintln "Environment variables:")
   (eprintln "  SECMON_PUBLIC_KEY   ECIES public key (hex) or path to key file")
+  (eprintln "  SECMON_PUBLIC_KEY_FILE  Preferred ECIES public-key file")
   (eprintln "  SECMON_AGENT_PUBLIC Alias for SECMON_PUBLIC_KEY")
-  (eprintln "  ECIES_PUBLIC_KEY    Alias accepted from keygen output")
+  (eprintln "  ECIES_PUBLIC_KEY    Legacy public-key alias")
   (eprintln "  SECMON_PSK          Pre-shared key (hex) or path to key file")
-  (eprintln "  PSK                 Alias accepted from keygen output")
-  (eprintln "  SECMON_LISTEN       Listen address, default 0.0.0.0:31337")
+  (eprintln "  SECMON_PSK_FILE     Preferred PSK file")
+  (eprintln "  PSK                 Legacy PSK alias")
+  (eprintln "  SECMON_LISTEN       Listen address, default 127.0.0.1:31337")
   (eprintln "  SECMON_POLL_MS      Monitor poll interval, default 100")
   (eprintln "  SECMON_BUFFER_SIZE  Encrypted event buffer cap, default 10000")
   (eprintln "  SECMON_DEBUG=1      Print startup diagnostics"))
@@ -65,32 +68,38 @@
           (#t (loop (cdr ns))))))
 
 (def (path-like-key? v)
-  (and (< (string-length v) 128)
+  (and (< (string-length v) 4096)
        (or (string-prefix? "/" v)
            (string-prefix? "." v)
-           (file-exists? v))))
+           (file-exists? v)
+           (file-symbolic-link? v))))
 
-(def (resolve-key-value v)
+(def (read-key-file path secret?)
+  (if secret?
+      (secure-key-read-text path)
+      (read-file-trimmed path)))
+
+(def (resolve-key-value v secret?)
   (if (path-like-key? v)
-      (try (read-file-trimmed v)
+      (try (read-key-file v secret?)
            (catch (e) (die "failed to read key file " v ": " e)))
       v))
 
 (def (first-existing paths)
   (let loop ((ps paths))
     (cond ((null? ps) #f)
-          ((file-exists? (car ps)) (car ps))
+          ((or (file-exists? (car ps)) (file-symbolic-link? (car ps))) (car ps))
           (#t (loop (cdr ps))))))
 
-(def (load-key-hex label env-names file-env-names default-paths)
+(def (load-key-hex label env-names file-env-names default-paths secret?)
   (cond
-    ((lookup-env env-names) => resolve-key-value)
+    ((lookup-env env-names) => (lambda (v) (resolve-key-value v secret?)))
     ((lookup-env file-env-names)
      => (lambda (p)
-          (try (read-file-trimmed p)
+          (try (read-key-file p secret?)
                (catch (e) (die "failed to read " label " file " p ": " e)))))
     ((first-existing default-paths)
-     => (lambda (p) (read-file-trimmed p)))
+     => (lambda (p) (read-key-file p secret?)))
     (else
      (die label " not set (export hex or point an env var at a key file)"))))
 
@@ -192,12 +201,12 @@
                         "public key"
                         '("SECMON_PUBLIC_KEY" "SECMON_AGENT_PUBLIC" "ECIES_PUBLIC_KEY")
                         '("SECMON_PUBLIC_KEY_FILE" "SECMON_AGENT_PUBLIC_FILE" "ECIES_PUBLIC_KEY_FILE")
-                        '("keys/public.key" "./keys/public.key")))
+                        '("keys/public.key" "./keys/public.key") #f))
            (psk-hex (load-key-hex
                      "PSK"
                      '("SECMON_PSK" "PSK")
                      '("SECMON_PSK_FILE" "PSK_FILE")
-                     '("keys/psk.key" "./keys/psk.key")))
+                     '("keys/psk.key" "./keys/psk.key") #t))
            (public-key (decode-32 "public key" public-hex))
            (psk (decode-32 "PSK" psk-hex))
            (cfg (config-from-env getenv))
@@ -207,6 +216,9 @@
            (max-buffer (cfg-ref cfg 'max-buffer-size))
            (db-path (local-db-path getenv platform))
            (key-path (local-key-path getenv platform))
+           ;; Local key trust failures are fatal.  Continuing after an unsafe
+           ;; owner/mode/link finding would turn an operator's encrypted-store
+           ;; expectation into an unnoticed confidentiality downgrade.
            (local-store
             (try
               (let ((s (local-store-open db-path key-path)))
@@ -214,10 +226,7 @@
                   (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))))
+              (catch (e) (die "local store/key validation failed: " e)))))
       (let-values (((host port) (split-host-port listen)))
         (let* ((mset (maybe-monitor-set (arg? "--no-monitors" argv) platform))
                (event-host (if mset (monitor-set-hostname mset) (fallback-hostname)))
diff --git a/bin/collector.ss b/bin/collector.ss
index b4a1bba..4ab2ab2 100644
--- a/bin/collector.ss
+++ b/bin/collector.ss
@@ -58,6 +58,7 @@
         (only (jsecmon storage)
               store-open store-close store-event get-last-seq update-collector-state)
         (only (jsecmon calendar) format-ts format-ts-iso now-ms)
+        (only (jsecmon secure-key-file) secure-key-read-text)
         (only (jsecmon monitor-process)
               proc-info-pid proc-info-ppid proc-info-uid proc-info-exe
               proc-info-name proc-info-cmdline)
@@ -80,19 +81,31 @@
           (make-message-condition msg))))
 
 ;; ── key loading (secmon load_key, minus the compile-time embedded fallback) ───
-;; env var first; a short value (<64) starting with / or . is a key-file path,
-;; otherwise the literal hex; unset → fatal (jsecmon embeds no secrets).
+;; Explicit *_FILE variables are preferred. For compatibility, an existing or
+;; visibly path-like value in the legacy variable is also treated as a path;
+;; every file read goes through descriptor-pinned credential validation.
 (def (read-file-trimmed path)
-  (string-trim (call-with-input-file path get-string-all)))
-(def (load-key env-var)
-  (let ((v (getenv env-var)))
+  (secure-key-read-text path))
+(def (path-like-key? value)
+  (and (< (string-length value) 4096)
+       (or (string-prefix? "/" value)
+           (string-prefix? "." value)
+           (file-exists? value)
+           (file-symbolic-link? value))))
+
+(def (read-key-path env-var path)
+  (try (read-file-trimmed path)
+       (catch (e) (die "failed to read " env-var " key file " path ": " e))))
+
+(def (load-key env-var file-env-var)
+  (let ((file-value (getenv file-env-var))
+        (value (getenv env-var)))
     (cond
-      ((not v) (die env-var " not set (export the hex key, or a path to a key file)"))
-      ((and (< (string-length v) 64)
-            (or (string-prefix? "/" v) (string-prefix? "." v)))
-       (try (read-file-trimmed v)
-            (catch (e) (die "failed to read key file " v ": " e))))
-      (#t v))))
+      (file-value (read-key-path file-env-var file-value))
+      ((not value)
+       (die env-var " not set (use " file-env-var " for a key file)"))
+      ((path-like-key? value) (read-key-path env-var value))
+      (else value))))
 
 ;; ── host:port split (last colon; default port when none) ──────────────────────
 (def (last-colon s)
@@ -319,8 +332,8 @@
 
 ;; ── subcommand: poll ──────────────────────────────────────────────────────────
 (def (cmd-poll host after-seq format db-path)
-  (let* ((priv-hex (load-key "SECMON_PRIVATE_KEY"))
-         (psk-hex  (load-key "SECMON_PSK"))
+  (let* ((priv-hex (load-key "SECMON_PRIVATE_KEY" "SECMON_PRIVATE_KEY_FILE"))
+         (psk-hex  (load-key "SECMON_PSK" "SECMON_PSK_FILE"))
          (secret   (try (hex-decode priv-hex) (catch (e) (die "Invalid private key: " e))))
          (db (and db-path (try (let ((d (store-open db-path)))
                                  (eprintln "Storing events to " db-path) d)
@@ -339,7 +352,7 @@
 
 ;; ── subcommand: status ────────────────────────────────────────────────────────
 (def (cmd-status host)
-  (let* ((psk-hex (load-key "SECMON_PSK"))
+  (let* ((psk-hex (load-key "SECMON_PSK" "SECMON_PSK_FILE"))
          (client  (try (client-connect host psk-hex) (catch (e) (die "Connection failed: " e))))
          (st (try (client-get-status client)
                   (catch (e) (client-close client) (die "Request failed: " e)))))
@@ -356,8 +369,8 @@
 
 ;; ── subcommand: watch (sequential round-robin sweep) ──────────────────────────
 (def (cmd-watch hosts format db-path)
-  (let* ((priv-hex (load-key "SECMON_PRIVATE_KEY"))
-         (psk-hex  (load-key "SECMON_PSK"))
+  (let* ((priv-hex (load-key "SECMON_PRIVATE_KEY" "SECMON_PRIVATE_KEY_FILE"))
+         (psk-hex  (load-key "SECMON_PSK" "SECMON_PSK_FILE"))
          (secret   (try (hex-decode priv-hex) (catch (e) (die "Invalid private key: " e))))
          (db (and db-path (try (let ((d (store-open db-path)))
                                  (eprintln "Storing events to " db-path) d)
@@ -403,7 +416,9 @@
   (eprintln "  Port defaults to " *default-port* " if not specified.\n")
   (eprintln "Environment variables:")
   (eprintln "  SECMON_PRIVATE_KEY  ECIES private key (hex) or path to key file")
-  (eprintln "  SECMON_PSK          Pre-shared key (hex) or path to key file"))
+  (eprintln "  SECMON_PRIVATE_KEY_FILE  Preferred ECIES private-key file")
+  (eprintln "  SECMON_PSK          Pre-shared key (hex) or path to key file")
+  (eprintln "  SECMON_PSK_FILE     Preferred PSK file"))
 
 ;; --hosts-file VALUE → its contents as hosts (the deferred fs read of parse_hosts_file).
 (def (hosts-file-hosts argv)
diff --git a/bin/keygen.ss b/bin/keygen.ss
index 5d08a86..a98df5f 100644
--- a/bin/keygen.ss
+++ b/bin/keygen.ss
@@ -1,16 +1,5 @@
 #!chezscheme
-;;; jsecmon secmon-keygen — generate the ECIES recipient keypair + transport PSK.
-;;;
-;;; Port of secmon/src/bin/keygen.rs. The X25519 secret and the 32-byte PSK come
-;;; from the OS CSPRNG via (std crypto random); the public key is derived through
-;;; the verified x25519 kernel and all three are hex-encoded by the psk hex
-;;; kernel — so the secret material never leaves vetted code. Takes no arguments,
-;;; like secmon's keygen; prints the keys + deploy notes to stdout.
-;;;
-;;; The key-generation output matches secmon byte-for-byte (same field names,
-;;; lowercase hex). The build-instructions block is adapted to jsecmon: the agent
-;;; loads its public key + PSK from config paths/env at runtime (see config.ss),
-;;; it does not embed them at compile time the way the Rust agent did.
+;;; jsecmon-keygen — create an ECIES keypair and PSK as exclusive 0600 files.
 
 (import (except (scheme)
                 make-hash-table hash-table?
@@ -24,45 +13,71 @@
         (except (jerboa prelude) meta atom?)
         (jsecmon crypto-ecies)
         (jsecmon kernels)
+        (only (jsecmon secure-key-file) secure-key-create-text!)
         (std crypto random))
 
-(define (emit . parts)
-  (for-each display parts)
-  (newline))
+(def (emit . parts) (for-each display parts) (newline))
+(def (eemit . parts)
+  (for-each (lambda (part) (display part (current-error-port))) parts)
+  (newline (current-error-port)))
+(def (die . parts) (apply eemit parts) (exit 1))
 
-(define (keygen)
-  (let* ((kp       (ecies-generate-keypair))   ; (secret public)
-         (secret   (car kp))
-         (public   (cadr kp))
-         (psk      (random-bytes 32))
-         (pub-hex  (hex-encode public))
-         (priv-hex (hex-encode secret))
-         (psk-hex  (hex-encode psk)))
-    (emit "=== SECMON KEY GENERATION ===") (newline)
+(def (usage)
+  (emit "Usage: jsecmon-keygen [--output-dir DIR]")
+  (emit "")
+  (emit "Creates public.key, private.key, and psk.key as exclusive 0600 files.")
+  (emit "Existing paths are never overwritten and secret values are not printed."))
 
-    (emit "# ECIES Keypair (for event encryption)")
-    (emit "# Public key goes in agent (compile-time)")
-    (emit "# Private key stays with collector ONLY") (newline)
-    (emit "ECIES_PUBLIC_KEY=" pub-hex)
-    (emit "ECIES_PRIVATE_KEY=" priv-hex) (newline)
+(def (credential-path dir name)
+  (if (or (string=? dir "")
+          (char=? (string-ref dir (- (string-length dir) 1)) #\/))
+      (string-append dir name)
+      (string-append dir "/" name)))
 
-    (emit "# PSK (for transport authentication)")
-    (emit "# Same key needed on both agent and collector") (newline)
-    (emit "PSK=" psk-hex) (newline)
+(def (parse-output-dir argv)
+  (let loop ((xs argv) (dir "keys"))
+    (cond
+      ((null? xs) dir)
+      ((or (string=? (car xs) "--help") (string=? (car xs) "-h"))
+       (usage)
+       (exit 0))
+      ((string=? (car xs) "--output-dir")
+       (when (null? (cdr xs)) (die "--output-dir requires a directory"))
+       (loop (cddr xs) (cadr xs)))
+      (else (die "unknown argument: " (car xs))))))
 
-    (emit "=== DEPLOY ===") (newline)
-    (emit "1. Give the agent its public key + PSK (config reads these at runtime):")
-    (emit "   mkdir -p keys")
-    (emit "   echo '" pub-hex "' > keys/public.key")
-    (emit "   echo '" psk-hex "' > keys/psk.key") (newline)
-    (emit "2. Keep the private key with the collector ONLY:")
-    (emit "   echo '" priv-hex "' > collector_private.key")
-    (emit "   chmod 600 collector_private.key") (newline)
+(def (refuse-existing! paths)
+  (for-each
+   (lambda (path)
+     (when (or (file-exists? path) (file-symbolic-link? path))
+       (die "refusing to overwrite existing key path: " path)))
+   paths))
 
-    (emit "=== SECURITY NOTES ===") (newline)
-    (emit "- The ECIES_PRIVATE_KEY must NEVER be on the monitored host")
-    (emit "- The agent can only encrypt events, not decrypt them")
-    (emit "- Even if the agent binary is compromised, past events cannot be read")
-    (emit "- The PSK authenticates collectors but doesn't protect event contents")))
+(def (keygen output-dir)
+  (when (string=? output-dir "") (die "output directory must not be empty"))
+  (let* ((public-path (credential-path output-dir "public.key"))
+         (private-path (credential-path output-dir "private.key"))
+         (psk-path (credential-path output-dir "psk.key"))
+         (paths (list public-path private-path psk-path)))
+    (refuse-existing! paths)
+    (let* ((kp (ecies-generate-keypair))
+           (secret (car kp))
+           (public (cadr kp))
+           (psk (random-bytes 32)))
+      ;; Each create uses O_CREAT|O_EXCL|O_NOFOLLOW, fchmod(0600), fsync, and
+      ;; descriptor validation.  No shell redirection or process umask decides
+      ;; credential permissions.
+      (secure-key-create-text! public-path (hex-encode public))
+      (secure-key-create-text! private-path (hex-encode secret))
+      (secure-key-create-text! psk-path (hex-encode psk))
+      (emit "Created a new Secmon credential set (all files mode 0600):")
+      (emit "  agent public key:    " public-path)
+      (emit "  collector private:   " private-path)
+      (emit "  shared transport PSK:" psk-path)
+      (emit "")
+      (emit "Deploy psk.key only to the agent and its authorized collector(s).")
+      (emit "For rotation, generate into a new directory, deploy the new set,")
+      (emit "restart both ends in a coordinated window, then securely remove")
+      (emit "and revoke the old PSK. Never reuse the old directory."))))
 
-(keygen)
+(keygen (parse-output-dir (command-line-arguments)))
diff --git a/bin/telemetry.ss b/bin/telemetry.ss
index d2fa1f1..e6d330b 100644
--- a/bin/telemetry.ss
+++ b/bin/telemetry.ss
@@ -18,12 +18,13 @@
         (only (jsecmon config) local-db-path)
         (only (jsecmon kernels) hex-decode derive-transport-key)
         (only (jsecmon storage) store-open)
+        (only (jsecmon secure-key-file) secure-key-read-text)
         (only (jsecmon mux-telemetry-server)
               make-mux-telemetry-runtime
               mux-telemetry-server-start!
               mux-telemetry-server-port))
 
-(def *default-bind* "0.0.0.0:31338")
+(def *default-bind* "127.0.0.1:31338")
 
 (def (println . parts) (for-each display parts) (newline))
 (def (eprintln . parts)
@@ -38,7 +39,7 @@
   (eprintln "  jsecmon-telemetry [--listen host:port] [--db path]\n")
   (eprintln "Environment variables:")
   (eprintln "  SECMON_PSK                  Pre-shared key (hex) or path to key file")
-  (eprintln "  PSK                         Alias accepted from keygen output")
+  (eprintln "  PSK                         Legacy PSK alias")
   (eprintln "  SECMON_PSK_FILE             Path to PSK hex file")
   (eprintln "  SECMON_TELEMETRY_LISTEN     Listen address, default " *default-bind*)
   (eprintln "  SECMON_DB_PATH              Events DB path"))
@@ -52,7 +53,7 @@
           (else (loop (cdr xs))))))
 
 (def (read-file-trimmed path)
-  (string-trim (call-with-input-file path get-string-all)))
+  (secure-key-read-text path))
 
 (def (lookup-env names)
   (let loop ((ns names))
@@ -61,10 +62,11 @@
           (#t (loop (cdr ns))))))
 
 (def (path-like-key? v)
-  (and (< (string-length v) 128)
+  (and (< (string-length v) 4096)
        (or (string-prefix? "/" v)
            (string-prefix? "." v)
-           (file-exists? v))))
+           (file-exists? v)
+           (file-symbolic-link? v))))
 
 (def (resolve-key-value v)
   (if (path-like-key? v)
diff --git a/build-binary.ss b/build-binary.ss
index 773bf96..156f517 100644
--- a/build-binary.ss
+++ b/build-binary.ss
@@ -43,9 +43,9 @@
                                         (native-transcoder))
     writer))
 
-(define (try-load-shared-object name)
+(define (try-load-system thunk)
   (guard (e [(condition? e) #f])
-    (load-shared-object name)
+    (thunk)
     #t))
 
 (define tracked-foreign-alloc foreign-alloc)
@@ -54,13 +54,47 @@
 (define c-posix-spawnp #f)
 (define c-waitpid #f)
 
+(define (load-process-libc!)
+  (unless (and (foreign-entry? "posix_spawnp")
+               (foreign-entry? "waitpid"))
+    (let ([mt (symbol->string (machine-type))])
+      (cond
+        [(string-suffix? "osx" mt)
+         ;; This trusted install name resolves through dyld's shared cache even
+         ;; when it is not visible to stat(2).
+         (try-load-system
+           (lambda () (load-shared-object "/usr/lib/libSystem.B.dylib")))]
+        [(string-suffix? "fb" mt)
+         (or (try-load-system
+               (lambda () (load-shared-object "/lib/libc.so.7")))
+             (try-load-system
+               (lambda () (load-shared-object "/usr/lib/libc.so.7"))))]
+        [else
+         (or (try-load-system
+               (lambda ()
+                 (load-shared-object "/lib/x86_64-linux-gnu/libc.so.6")))
+             (try-load-system
+               (lambda ()
+                 (load-shared-object "/lib/aarch64-linux-gnu/libc.so.6")))
+             (try-load-system
+               (lambda () (load-shared-object "/lib64/libc.so.6")))
+             (try-load-system
+               (lambda () (load-shared-object "/usr/lib64/libc.so.6")))
+             (try-load-system
+               (lambda ()
+                 (load-shared-object "/lib/libc.musl-x86_64.so.1")))
+             (try-load-system
+               (lambda ()
+                 (load-shared-object "/lib/libc.musl-aarch64.so.1"))))])))
+  (and (foreign-entry? "posix_spawnp")
+       (foreign-entry? "waitpid")))
+
 (define (resolve-process-ffi!)
   (unless *process-ffi-loaded?*
-    (or (try-load-shared-object #f)
-        (try-load-shared-object "/usr/lib/libSystem.B.dylib")
-        (try-load-shared-object "libc.so.6")
-        (try-load-shared-object "libc.so.7")
-        (try-load-shared-object "libc.so"))
+    ;; A successful load-shared-object #f does not imply libc symbols were
+    ;; registered.  Verify the entries and use only trusted absolute system
+    ;; paths before constructing the process-control foreign procedures.
+    (load-process-libc!)
     (set! c-posix-spawnp
           (guard (e [(condition? e) #f])
             (foreign-procedure "posix_spawnp"
@@ -147,7 +181,10 @@
   (if (null? argv)
       1
       (if (not (resolve-process-ffi!))
-          255
+          (begin
+            (fprintf (current-error-port)
+                     "cannot resolve trusted posix_spawnp/waitpid entries\n")
+            255)
           (let ([bundle (alloc-c-argv argv)]
                 [env-bundle (alloc-c-envp)]
                 [pid-ptr (tracked-foreign-alloc (foreign-sizeof 'int))]
@@ -167,7 +204,11 @@
                                  (wait-status->exit-code
                                   (foreign-ref 'int status-ptr 0))
                                  255)))
-                         255)])
+                         (begin
+                           (fprintf (current-error-port)
+                                    "posix_spawnp failed with code ~a for ~s\n"
+                                    spawn-rc (car argv))
+                           255))])
                 (free-c-argv! bundle)
                 (free-c-argv! env-bundle)
                 (foreign-free pid-ptr)
@@ -1130,6 +1171,7 @@
 ;; (std net tcp), so register the libc entries it uses when static mode is on.
 (define static-system-symbols
   '("socket"
+    "open"
     "bind"
     "listen"
     "accept"
@@ -1141,6 +1183,9 @@
     "readlink"
     "stat"
     "fstat"
+    "fchmod"
+    "fsync"
+    "geteuid"
     "lstat"
     "strerror"
     "htons"
@@ -1712,17 +1757,19 @@
         "#define RTLD_DEFAULT NULL"
         "#endif"
         ""))
+    ;; System FFI is required even by binaries that do not bundle the optional
+    ;; Jerboa native dylib (notably keygen's descriptor-pinned credential I/O).
+    (display "static void register_system_ffi_symbols(void) {\n" out)
+    (for-each
+      (lambda (sym)
+        (fprintf out "  void *sys_~a = dlsym(RTLD_DEFAULT, \"~a\");\n" sym sym)
+        (fprintf out "  if (sys_~a) Sforeign_symbol(\"~a\", sys_~a);\n" sym sym sym))
+      static-system-symbols)
+    (display "}\n\n" out)
     (if needs-native?
         (begin
           (fprintf out "static const char *jerboa_native_path = ~a;\n\n"
                    (c-string-literal native-lib))
-          (display "static void register_system_ffi_symbols(void) {\n" out)
-          (for-each
-            (lambda (sym)
-              (fprintf out "  void *sys_~a = dlsym(RTLD_DEFAULT, \"~a\");\n" sym sym)
-              (fprintf out "  if (sys_~a) Sforeign_symbol(\"~a\", sys_~a);\n" sym sym sym))
-            static-system-symbols)
-          (display "}\n\n" out)
           (display "static void register_static_ffi_symbols(void) {\n" out)
           (display "  void *handle = dlopen(jerboa_native_path, RTLD_NOW | RTLD_GLOBAL);\n" out)
           (display "  if (!handle) {\n" out)
@@ -1738,7 +1785,7 @@
               (fprintf out "  Sforeign_symbol(\"~a\", addr_~a);\n" sym sym))
             static-native-symbols)
           (display "}\n\n" out))
-        (display "static void register_static_ffi_symbols(void) { }\n\n" out))
+        (display "static void register_static_ffi_symbols(void) { register_system_ffi_symbols(); }\n\n" out))
     (for-each (lambda (l) (display l out) (newline out))
       (list
         "int main(int argc, char *argv[]) {"
@@ -1755,7 +1802,7 @@
         "  Sregister_boot_file_bytes(\"scheme\", (void*)scheme_boot_data, scheme_boot_size);"
         "  Sregister_boot_file_bytes(\"app\",    (void*)app_boot_data, app_boot_size);"
         "  Sbuild_heap(NULL, NULL);"
-        (if needs-native? "  setenv(\"JERBOA_STATIC\", \"1\", 1);" "  /* no Jerboa native FFI needed */")
+        "  setenv(\"JERBOA_STATIC\", \"1\", 1);"
         "  register_static_ffi_symbols();"
         "  int status = Sscheme_script(prog_path, argc, (const char **)argv);"
         "  unlink(prog_path);"
diff --git a/dependency-lock.tsv b/dependency-lock.tsv
new file mode 100644
index 0000000..ebd3e0b
--- /dev/null
+++ b/dependency-lock.tsv
@@ -0,0 +1,3 @@
+# name	url	commit	tree	path	patch
+jerboa	https://git.sr.ht/~lisp/jerboa	638db04afab57b66a1deb809706f706ca07d1ef0	b12b2557f080703415c8747d59019a7e3918a3f4	vendor/jerboa	dependency-patches/jerboa-fd-libc.patch
+jsqlite	https://git.sr.ht/~lisp/jsqlite	63d737101b3b74bff3b9db75cc35f34bff6df0b7	79bfcf7a52d233076301c5cba9cd56d1b3e07ebf	vendor/jsqlite	dependency-patches/jsqlite-db-close.patch
diff --git a/dependency-patches/jerboa-fd-libc.patch b/dependency-patches/jerboa-fd-libc.patch
new file mode 100644
index 0000000..821a44d
--- /dev/null
+++ b/dependency-patches/jerboa-fd-libc.patch
@@ -0,0 +1,28 @@
+diff --git a/lib/std/os/fd.ss b/lib/std/os/fd.ss
+index 94f9f7b..42a8f9a 100644
+--- a/lib/std/os/fd.ss
++++ b/lib/std/os/fd.ss
+@@ -33,6 +33,23 @@
+   (def STDERR_FILENO 2)
+ 
+   ;; ========== Low-level FFI ==========
++  ;; POSIX fd/process symbols live in libc on Unix and libSystem on macOS.
++  ;; Load them before defining foreign-procedure bindings; otherwise macOS
++  ;; reports "no entry" for symbols such as dup when this library is imported.
++  (def _libc-loaded
++    (or (try (load-shared-object "libc.so.7")
++         (catch (e) #f))
++        (try (load-shared-object "libc.so.6")
++         (catch (e) #f))
++        (try (load-shared-object "libc.so")
++         (catch (e) #f))
++        (try (load-shared-object "/usr/lib/libSystem.B.dylib")
++         (catch (e) #f))
++        (try (load-shared-object "libSystem.dylib")
++         (catch (e) #f))
++        (try (load-shared-object "")
++         (catch (e) #f))))
++
+   (def c-dup    (foreign-procedure "dup" (int) int))
+   (def c-dup2   (foreign-procedure "dup2" (int int) int))
+   (def c-close  (foreign-procedure "close" (int) int))
diff --git a/dependency-patches/jsqlite-db-close.patch b/dependency-patches/jsqlite-db-close.patch
new file mode 100644
index 0000000..20a269c
--- /dev/null
+++ b/dependency-patches/jsqlite-db-close.patch
@@ -0,0 +1,22 @@
+diff --git a/src/jsqlite/api.ss b/src/jsqlite/api.ss
+index f409d55..1adbb87 100644
+--- a/src/jsqlite/api.ss
++++ b/src/jsqlite/api.ss
+@@ -10,7 +10,7 @@
+ (library (jsqlite api)
+   (export
+     sqlite-open sqlite-open-v2 sqlite-open-bytevector sqlite-db->bytevector
+-    sqlite-close sqlite-db?
++    sqlite-close sqlite-db-close sqlite-db?
+     sqlite-prepare sqlite-finalize sqlite-reset sqlite-stmt?
+     sqlite-step
+     sqlite-bind! sqlite-clear-bindings sqlite-bind-parameter-count
+@@ -567,6 +567,8 @@
+     (record-db-result! db SQLITE_OK)
+     SQLITE_OK)
+ 
++  (def sqlite-db-close sqlite-close)
++
+   ;; Catalog of databases visible to a query, in SQLite resolution order:
+   ;; temp (if any), main, then attached databases. Each entry is
+   ;; #(name schema bytes header). The read planner resolves each table reference
diff --git a/docs/deployment-security.md b/docs/deployment-security.md
index 73f443c..f0609ba 100644
--- a/docs/deployment-security.md
+++ b/docs/deployment-security.md
@@ -8,30 +8,40 @@ This guide is the minimum production posture for `jsecmon-agent`,
 Generate keys on the release host or a trusted admin machine:
 
 ```sh
-./jsecmon-keygen
+./jsecmon-keygen --output-dir keys-$(date +%Y%m%d)
 ```
 
+The generator creates each key with exclusive/no-follow descriptor I/O at mode
+`0600`, refuses existing paths, and prints paths rather than secret values.
 Store ECIES private keys and PSKs in files owned by the service account, mode
-`0600`. Avoid passing key hex values directly in shell commands on shared hosts.
+`0600`. Agent, telemetry, and collector startup reject secret files that are
+symlinks, non-regular, multiply linked, owned by another user, not exactly mode
+`0600`, or malformed. Avoid passing key hex values directly in shell commands on shared hosts.
 Use environment variables only from a supervisor secret store or a protected
 environment file.
 
 Recommended split:
 
-- Agent hosts receive `SECMON_PUBLIC_KEY` and `SECMON_PSK`.
-- Collector hosts receive `SECMON_PRIVATE_KEY` and `SECMON_PSK`.
-- Telemetry intake receives `SECMON_PSK` or `PSK`.
+- Agent hosts receive `SECMON_PUBLIC_KEY_FILE` and `SECMON_PSK_FILE`.
+- Collector hosts receive `SECMON_PRIVATE_KEY_FILE` and `SECMON_PSK_FILE`.
+- Telemetry intake receives `SECMON_PSK_FILE` (or the `PSK_FILE` alias).
 
-Use separate PSKs for separate environments. Rotate both PSK and ECIES keypairs
-after host compromise, leaked logs, leaked environment files, or copied test
-fixtures.
+The legacy non-`_FILE` variables may still contain literal hex for supervisor
+secret stores, but command-line shell exports are more likely to leak through
+history, diagnostics, or process environments.
+
+Use separate PSKs for separate environments. Rotate by generating a new
+directory, deploying the new files to both ends, restarting in a coordinated
+window, and then securely removing/revoking the old PSK. Rotate both PSK and
+ECIES keypairs after host compromise, leaked logs, leaked environment files, or
+copied test fixtures.
 
 ## Network Exposure
 
-Default listeners are broad:
+Default listeners are loopback-only:
 
-- Agent: `SECMON_LISTEN`, default `0.0.0.0:31337`.
-- Telemetry: `SECMON_TELEMETRY_LISTEN`, default `0.0.0.0:31338`.
+- Agent: `SECMON_LISTEN`, default `127.0.0.1:31337`.
+- Telemetry: `SECMON_TELEMETRY_LISTEN`, default `127.0.0.1:31338`.
 
 Production deployments should bind to a private management address or
 loopback/VPN address and enforce host firewall rules so only collectors or
@@ -60,7 +70,11 @@ can contain keys and plaintext events.
 The agent and telemetry listener binaries use Secmon's TCP runtime wrapper for
 daemon sockets. Release candidates must preserve the hardening invariant that
 listener and accepted fds are close-on-exec and verified nonblocking before
-background accept loops start.
+background accept loops start. Both listeners use fixed worker pools, bounded
+queues, global/per-source active and rate budgets, five-second absolute
+handshake/frame deadlines, 30-second authenticated idle/frame deadlines, and
+strict maximum frames. A remote bind is an explicit configuration choice and
+still requires a firewall allowlist.
 
 ## Filesystem Layout
 
diff --git a/docs/release-evidence.md b/docs/release-evidence.md
index 85228e4..5e95c5b 100644
--- a/docs/release-evidence.md
+++ b/docs/release-evidence.md
@@ -10,7 +10,7 @@ Run from a clean checkout:
 
 ```sh
 make clean
-make release-evidence JERBOA=/path/to/jerboa JSQLITE_REPO=/path/to/jsqlite
+make release-evidence
 ```
 
 The `release-evidence` target runs `make verify`, lets
@@ -29,6 +29,9 @@ The current binary strategy is explicit:
 - On macOS, the builder links with deterministic Mach-O flags where supported,
   then ad-hoc signs and verifies each executable. Unsigned Apple Silicon
   executables can be killed by the kernel before the runtime starts.
+- `dependency-lock.tsv` pins exact Jerboa/jsqlite commits and trees. The only
+  accepted vendor worktree differences are reviewed patch files whose bytes are
+  checked by `support/fetch-locked-deps.sh`; mutable branch-tip clones fail.
 
 The evidence directory contains:
 
diff --git a/docs/threat-model.md b/docs/threat-model.md
index 8702707..f62ed17 100644
--- a/docs/threat-model.md
+++ b/docs/threat-model.md
@@ -32,8 +32,8 @@ listener and every parsed host artifact as hostile input.
 
 ## Entry Points
 
-- Agent pull server: `SECMON_LISTEN`, default `0.0.0.0:31337`.
-- Daemon telemetry server: `SECMON_TELEMETRY_LISTEN`, default `0.0.0.0:31338`.
+- Agent pull server: `SECMON_LISTEN`, default `127.0.0.1:31337`.
+- Daemon telemetry server: `SECMON_TELEMETRY_LISTEN`, default `127.0.0.1:31338`.
 - Collector connections to one or more `host[:port]` targets.
 - Local key files selected by `SECMON_PUBLIC_KEY`, `SECMON_AGENT_PUBLIC`,
   `ECIES_PUBLIC_KEY`, `SECMON_PRIVATE_KEY`, `SECMON_PSK`, `PSK`, and file-path
@@ -57,7 +57,10 @@ listener and every parsed host artifact as hostile input.
   process supervisor with restart throttling and memory/file-descriptor limits.
   Agent and telemetry listeners must use the Secmon TCP runtime wrapper, which
   verifies nonblocking sockets and sets close-on-exec before background accept
-  loops start.
+  loops start. Fixed worker pools and bounded queues enforce global/per-source
+  connection and rate budgets. Absolute handshake and whole-frame deadlines
+  prevent a peer from extending its lease by dripping one byte per socket
+  timeout.
 - Privilege escalation: the agent should bind privileged resources first, then
   call the post-bind `nobody` drop path. Do not run collector/analyzer as root.
 - Sensitive data exfiltration: databases and release evidence may contain host
@@ -82,6 +85,9 @@ listener and every parsed host artifact as hostile input.
 - Agent and telemetry listeners reject unauthenticated encrypted protocol data.
 - Agent and telemetry listener sockets verify `O_NONBLOCK` and `FD_CLOEXEC`
   before they enter background accept loops.
+- Secret key files are opened with `O_NOFOLLOW`, validated by `fstat` on that
+  descriptor (regular, one link, effective-user owner, exact `0600`, bounded
+  length), and read from the pinned descriptor. New files use `O_EXCL`.
 - Generated `.so`/`.wpo` artifacts are cleaned before WPO release builds so a
   failed build cannot poison the next binary.
 - The release gate runs source security checks, typed Rust vector tests,
diff --git a/examples/agent_server_check.ss b/examples/agent_server_check.ss
index 564d681..551bb3d 100644
--- a/examples/agent_server_check.ss
+++ b/examples/agent_server_check.ss
@@ -6,7 +6,10 @@
         (only (jsecmon agent-server)
               make-agent-runtime agent-store-event! agent-events-after
               agent-buffered-count agent-latest-seq agent-handle-request
-              agent-server-start! agent-server-port agent-server-stop!)
+              agent-server-start! agent-server-host agent-server-port
+              agent-server-active-connections agent-server-stop!)
+        (only (jsecmon network-guard) make-network-policy)
+        (only (jsecmon tcp-runtime) tcp-connect-binary)
         (only (jsecmon crypto-ecies) ecies-generate-keypair ecies-decrypt)
         (only (jsecmon kernels) hex-decode)
         (only (jsecmon protocol)
@@ -84,9 +87,37 @@
   (check "pong tag" (car (cadr pong)) 'pong))
 
 (displayln "server socket start/stop:")
-(def srv (agent-server-start! rt "127.0.0.1" 0))
+(def test-policy (make-network-policy 1 1 1 20 10 1000 250 500))
+(def srv (agent-server-start! rt "127.0.0.1" 0 test-policy))
 (let ((port (agent-server-port srv)))
-  (check "server got ephemeral port" (and (integer? port) (not (= port 0))) #t))
+  (check "server got ephemeral port" (and (integer? port) (not (= port 0))) #t)
+  (check "reported bind host" (agent-server-host srv) "127.0.0.1")
+  (let-values (((slow-in slow-out) (tcp-connect-binary "127.0.0.1" port)))
+    ;; Drip three bytes of the four-byte response length, each comfortably
+    ;; before the absolute deadline. The missing fourth byte must not retain a
+    ;; worker. A second same-source peer is rejected without another worker.
+    (fork-thread
+     (lambda ()
+       (dotimes (i 3)
+         (sleep-ms 60)
+         (try
+           (begin (put-bytevector slow-out #vu8(1))
+                  (flush-output-port slow-out))
+           (catch (e) #f)))))
+    (sleep-ms 50)
+    (check "one byte-drip pre-auth peer is active"
+           (agent-server-active-connections srv) 1)
+    (let-values (((extra-in extra-out) (tcp-connect-binary "127.0.0.1" port)))
+      (sleep-ms 50)
+      (check "per-peer cap rejects extra pre-auth socket"
+             (agent-server-active-connections srv) 1)
+      (try (close-port extra-in) (catch (e) #f))
+      (try (close-port extra-out) (catch (e) #f)))
+    (sleep-ms 300)
+    (check "byte drip cannot extend absolute handshake deadline"
+           (agent-server-active-connections srv) 0)
+    (try (close-port slow-in) (catch (e) #f))
+    (try (close-port slow-out) (catch (e) #f))))
 (agent-server-stop! srv)
 (local-store-close store)
 (for-each rm (list db-path key-path (str db-path "-wal") (str db-path "-shm")))
diff --git a/examples/config_check.ss b/examples/config_check.ss
index 088a9ff..06bb9a0 100644
--- a/examples/config_check.ss
+++ b/examples/config_check.ss
@@ -25,6 +25,7 @@
 (displayln "default:")
 (def d (default-config))
 (check "default bind address" (field d 'listen-addr) *default-bind-addr*)
+(check "default is loopback" *default-bind-addr* "127.0.0.1:31337")
 (check "default poll"   (field d 'poll-interval-ms) 100)
 (check "default buffer" (field d 'max-buffer-size) 10000)
 
diff --git a/examples/mux_telemetry_check.ss b/examples/mux_telemetry_check.ss
index dc85e50..fa577c1 100644
--- a/examples/mux_telemetry_check.ss
+++ b/examples/mux_telemetry_check.ss
@@ -12,6 +12,7 @@
         (jsecmon kernels)
         (jsecmon mux-telemetry)
         (jsecmon mux-telemetry-server)
+        (only (jsecmon network-guard) make-network-policy)
         (jsecmon storage)
         (jsecmon daemon-telemetry)
         (only (jsecmon tcp-runtime) tcp-connect-binary))
@@ -142,7 +143,9 @@
 
 (displayln "tcp listener:")
 (def rt (make-mux-telemetry-runtime db tk))
-(def srv (mux-telemetry-server-start! rt "127.0.0.1" 0))
+(def listener-policy (make-network-policy 1 1 1 20 10 1000 250 500))
+(def srv (mux-telemetry-server-start! rt "127.0.0.1" 0 listener-policy))
+(check "reported bind host" (mux-telemetry-server-host srv) "127.0.0.1")
 (let-values (((in out) (tcp-connect-binary "127.0.0.1" (mux-telemetry-server-port srv))))
   (let ((ev (telemetry 1 "tcp@mx1" "mx1" 900000 "jsmtpd" "protocol_error"
                        "remote_ip" "198.51.100.9"
@@ -157,6 +160,26 @@
       (check "server inserted" (hash-get payload "inserted") #t)))
   (close-port in)
   (close-port out))
+(sleep-ms 50)
+(let-values (((slow-in slow-out)
+              (tcp-connect-binary "127.0.0.1"
+                                  (mux-telemetry-server-port srv))))
+  (fork-thread
+   (lambda ()
+     (dotimes (i 3)
+       (sleep-ms 60)
+       (try
+         (begin (put-bytevector slow-out #vu8(1))
+                (flush-output-port slow-out))
+         (catch (e) #f)))))
+  (sleep-ms 50)
+  (check "byte-drip unauthenticated telemetry peer is bounded"
+         (mux-telemetry-server-active-connections srv) 1)
+  (sleep-ms 300)
+  (check "byte drip cannot extend telemetry handshake deadline"
+         (mux-telemetry-server-active-connections srv) 0)
+  (try (close-port slow-in) (catch (e) #f))
+  (try (close-port slow-out) (catch (e) #f)))
 (mux-telemetry-server-stop! srv)
 (check "tcp listener stored row" (store-count db) 12)
 
diff --git a/examples/network_guard_check.ss b/examples/network_guard_check.ss
new file mode 100644
index 0000000..1ff4d29
--- /dev/null
+++ b/examples/network_guard_check.ss
@@ -0,0 +1,48 @@
+;;; Deterministic admission/rate-limit checks for SCM-001.
+
+(import (jerboa prelude)
+        (jsecmon network-guard))
+
+(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 now (box 10000))
+(def policy (make-network-policy 2 2 1 3 2 1000 200 500))