diff --git a/index.html b/index.html
index 4a2ffc0..2abf4be 100755
--- a/index.html
+++ b/index.html
@@ -92,7 +92,7 @@
Linux Kernel Ring 0 Visualization
-
+
diff --git a/kernel_ai/api/rest.py b/kernel_ai/api/rest.py
index 027e267..016fc3b 100644
--- a/kernel_ai/api/rest.py
+++ b/kernel_ai/api/rest.py
@@ -29,6 +29,7 @@
("/execution-context", "get_execution_context", h.get_execution_context, None),
("/kernel-dna", "kernel_dna", h.kernel_dna, None),
("/ml-anomalies", "ml_anomalies", h.ml_anomalies, None),
+ ("/ml-drift", "ml_drift", h.ml_drift, None),
("/crypto-realtime", "crypto_realtime", h.crypto_realtime, None),
("/security-realtime", "security_realtime", h.security_realtime, None),
("/processes-realtime", "processes_realtime", h.processes_realtime, None),
diff --git a/kernel_ai/http/api.py b/kernel_ai/http/api.py
index 6642670..61486a2 100644
--- a/kernel_ai/http/api.py
+++ b/kernel_ai/http/api.py
@@ -7,6 +7,7 @@
kernel_data,
kernel_dna,
ml_anomalies,
+ ml_drift,
nginx_files,
process_kernel_map,
sentry_test,
@@ -65,6 +66,7 @@
"kernel_data",
"kernel_dna",
"ml_anomalies",
+ "ml_drift",
"network_stack_realtime",
"nginx_files",
"process_kernel_map",
diff --git a/kernel_ai/http/api_handlers/kernel.py b/kernel_ai/http/api_handlers/kernel.py
index f17db72..42de9f0 100644
--- a/kernel_ai/http/api_handlers/kernel.py
+++ b/kernel_ai/http/api_handlers/kernel.py
@@ -117,6 +117,47 @@ def _payload():
return api_json(_payload)
+def ml_drift():
+ """Latest model-drift verdict + short history for the Kernel DNA UI.
+
+ Read-only: surfaces what the drift monitor / retrain job wrote to the shared
+ store, plus the on-disk model artifact age (a proxy for "model freshness").
+ Degrades to ``available: false`` if the store/model is unreachable.
+ """
+
+ def _payload():
+ import os
+
+ from kernel_ai.ml.config import MLConfig
+ from kernel_ai.ml.store import fetch_drift_status
+
+ try:
+ history = int(request.args.get("history", 48))
+ except (TypeError, ValueError):
+ history = 48
+ history = max(1, min(history, 200))
+
+ cfg = MLConfig()
+ status = fetch_drift_status(cfg.dsn, history=history)
+
+ model_age_sec = None
+ try:
+ mtime = os.path.getmtime(cfg.model_path)
+ model_age_sec = max(0.0, datetime.now().timestamp() - mtime)
+ except OSError:
+ model_age_sec = None
+
+ return {
+ "timestamp": datetime.now().isoformat(),
+ "available": status.get("available", False),
+ "model_age_sec": model_age_sec,
+ "latest": status.get("latest"),
+ "history": status.get("history", []),
+ }
+
+ return api_json(_payload)
+
+
def sentry_test():
"""Temporary endpoint for manual Sentry verification."""
if not current_app.config.get("SENTRY_TEST_ENDPOINT_ENABLED", False):
diff --git a/kernel_ai/ml/config.py b/kernel_ai/ml/config.py
index 0995664..1bf7766 100644
--- a/kernel_ai/ml/config.py
+++ b/kernel_ai/ml/config.py
@@ -13,6 +13,11 @@
# Project root (parent of the ``kernel_ai`` package), used for default paths.
_PROJECT_ROOT = Path(__file__).resolve().parents[2]
+# All ML runtime data (model artifacts, MLflow sqlite + artifacts) lives in one
+# directory owned by the service user (www-data), so the worker and the retrain
+# job can both read/write it. Override with KERNEL_AI_ML_DATA_DIR.
+_DATA_DIR = Path(os.getenv("KERNEL_AI_ML_DATA_DIR", str(_PROJECT_ROOT / "mldata")))
+
def _env_float(name: str, default: float) -> float:
try:
@@ -70,13 +75,18 @@ class MLConfig:
# Saved model artifact the worker loads (decoupled from MLflow).
model_path: str = os.getenv(
"KERNEL_AI_ML_MODEL_PATH",
- str(_PROJECT_ROOT / "models" / "isoforest_latest.joblib"),
+ str(_DATA_DIR / "isoforest_latest.joblib"),
)
# MLflow tracking store. sqlite gives both tracking + a model registry while
# staying file-light (no always-on server). Override on PROD if desired.
mlflow_uri: str = os.getenv(
"KERNEL_AI_MLFLOW_URI",
- f"sqlite:///{_PROJECT_ROOT / 'mlflow.db'}",
+ f"sqlite:///{_DATA_DIR / 'mlflow.db'}",
+ )
+ # Artifact root for MLflow runs (must be writable by the service user).
+ mlflow_artifact_uri: str = os.getenv(
+ "KERNEL_AI_MLFLOW_ARTIFACT_URI",
+ f"file:{_DATA_DIR / 'mlruns'}",
)
mlflow_experiment: str = os.getenv("KERNEL_AI_MLFLOW_EXPERIMENT", "kernel_dna_anomaly")
mlflow_model_name: str = os.getenv("KERNEL_AI_MLFLOW_MODEL", "kernel_dna_isoforest")
@@ -90,6 +100,9 @@ class MLConfig:
# --- Stage 3 (drift + auto-retrain) ---
# Window of recent snapshots used to measure drift (minutes).
drift_window_min: int = _env_int("KERNEL_AI_ML_DRIFT_WINDOW_MIN", 30)
+ # Minimum recent samples before drift verdicts are trusted (avoid declaring
+ # drift off one or two noisy snapshots right after a worker restart).
+ drift_min_recent: int = _env_int("KERNEL_AI_ML_DRIFT_MIN_RECENT", 20)
# Drift trips when the live flag rate exceeds expected (contamination) by
# this multiple, or when the mean per-feature distribution shift (in train
# std units) exceeds the z threshold.
@@ -103,6 +116,36 @@ class MLConfig:
retrain_min_flag_rate: float = _env_float("KERNEL_AI_ML_RETRAIN_MIN_FLAG", 0.001)
retrain_max_flag_rate: float = _env_float("KERNEL_AI_ML_RETRAIN_MAX_FLAG", 0.30)
+ # --- Stage 4 (syscall sequence model: STIDE n-grams) ---
+ # Detects anomalous *sequences* of syscalls rather than per-feature spikes.
+ # Built on sampled /proc//syscall traces (L0), so it is a coarse but
+ # zero-dependency HIDS; eBPF/auditd tracing (L2) is the future upgrade.
+ enable_stage4: bool = os.getenv("KERNEL_AI_ML_STAGE4", "true").lower() == "true"
+ seq_model_path: str = os.getenv(
+ "KERNEL_AI_ML_SEQ_MODEL_PATH",
+ str(_DATA_DIR / "stide_latest.joblib"),
+ )
+ seq_n: int = _env_int("KERNEL_AI_ML_SEQ_N", 3) # n-gram size
+ seq_max_pids: int = _env_int("KERNEL_AI_ML_SEQ_MAX_PIDS", 512) # pids sampled/tick
+ seq_window: int = _env_int("KERNEL_AI_ML_SEQ_WINDOW", 400) # rolling n-grams scored
+ seq_min_window: int = _env_int("KERNEL_AI_ML_SEQ_MIN_WINDOW", 120) # before scoring
+ # 2s tick sampling only catches processes *parked* in a syscall. A short burst
+ # of sub-samples per tick captures real syscall transitions (better sequences).
+ seq_subsamples: int = _env_int("KERNEL_AI_ML_SEQ_SUBSAMPLES", 4)
+ seq_subsample_gap_ms: int = _env_int("KERNEL_AI_ML_SEQ_SUBSAMPLE_GAP_MS", 40)
+ # Minimum distinct n-grams required before a STIDE profile is built (a profile
+ # learned from too little data would flag almost everything).
+ seq_min_vocab: int = _env_int("KERNEL_AI_ML_SEQ_MIN_VOCAB", 50)
+ # Window mismatch fraction (unseen n-grams) that counts as a sequence anomaly.
+ seq_mismatch_warn: float = _env_float("KERNEL_AI_ML_SEQ_MISMATCH_WARN", 0.30)
+ seq_mismatch_crit: float = _env_float("KERNEL_AI_ML_SEQ_MISMATCH_CRIT", 0.55)
+ seq_cooldown_sec: float = _env_float("KERNEL_AI_ML_SEQ_COOLDOWN_SEC", 30.0)
+ # Flush newly observed n-grams to the store every N seconds (profile growth).
+ seq_flush_sec: float = _env_float("KERNEL_AI_ML_SEQ_FLUSH_SEC", 30.0)
+ # Training keeps n-grams seen at least this many times (frequency-based poison
+ # guard: a one-off attack sequence never enters the "normal" profile).
+ seq_min_ngram_count: int = _env_int("KERNEL_AI_ML_SEQ_MIN_COUNT", 3)
+
@property
def alpha(self) -> float:
return 2.0 / (max(2, self.baseline_window) + 1.0)
diff --git a/kernel_ai/ml/drift.py b/kernel_ai/ml/drift.py
index 1c144a3..ee84397 100644
--- a/kernel_ai/ml/drift.py
+++ b/kernel_ai/ml/drift.py
@@ -72,11 +72,13 @@ def compute_drift(cfg: MLConfig | None = None, *, persist: bool = True) -> dict:
n = len(recent)
contamination = float(model.meta.get("contamination", cfg.if_contamination))
- if n == 0:
+ # Too few recent samples (e.g. right after a worker restart) -> a single
+ # noisy point must not be allowed to declare "drift".
+ if n < cfg.drift_min_recent:
result = {
- "available": True, "n_recent": 0, "flag_rate": 0.0,
+ "available": True, "n_recent": n, "flag_rate": 0.0,
"expected_rate": contamination, "feature_drift": 0.0, "drifted": False,
- "detail": {"reason": "no_recent_data"},
+ "detail": {"reason": "insufficient_recent_data", "min_recent": cfg.drift_min_recent},
}
if persist:
insert_drift(cfg.dsn, result)
diff --git a/kernel_ai/ml/sequence.py b/kernel_ai/ml/sequence.py
new file mode 100644
index 0000000..ffc539c
--- /dev/null
+++ b/kernel_ai/ml/sequence.py
@@ -0,0 +1,206 @@
+"""Stage 4: syscall *sequence* anomaly detection (STIDE-style n-grams).
+
+Stages 1-3 score the system on aggregate *features* (rates, pressures). They are
+blind to the **order** of operations. A classic host-IDS insight (Forrest et al.,
+"A Sense of Self for Unix Processes") is that normal programs emit a small, stable
+vocabulary of short syscall *sequences*; intrusions produce sequences never seen
+during normal operation. STIDE (Sequence TIme-Delay Embedding) learns the set of
+normal n-grams and flags windows with a high fraction of unseen n-grams.
+
+Data source (honesty note): we don't have a continuous syscall tracer here, so we
+*sample* ``/proc//syscall`` each worker tick (the syscall a task is currently
+in). This is a coarse L0 signal -- it misses fast transitions -- but it needs zero
+privileges/deps and demonstrates the sequence approach end to end. Swapping in an
+eBPF/auditd tracer (L2) later only changes the sampler, not the model.
+
+Components:
+ SyscallSampler - read current syscall per pid from procfs
+ NgramTracker - per-pid rolling deques -> stream of syscall n-grams
+ StideModel - set of "normal" n-grams + window mismatch scoring
+"""
+
+from __future__ import annotations
+
+import logging
+import os
+from collections import deque
+from dataclasses import dataclass, field
+
+from kernel_ai.services.kernel_maps import SYSCALL_NAMES
+
+logger = logging.getLogger("kernel_ai.ml.sequence")
+
+# Separator for serialising an n-gram tuple into a stable string key.
+_SEP = "|"
+
+
+class SyscallSampler:
+ """Sample the current syscall of running processes from procfs."""
+
+ def __init__(self, max_pids: int = 160) -> None:
+ self.max_pids = max_pids
+
+ def sample(self) -> dict[int, str]:
+ """Return ``{pid: syscall_name}`` for tasks currently in a syscall."""
+ out: dict[int, str] = {}
+ try:
+ pids = sorted((int(d) for d in os.listdir("/proc") if d.isdigit()))
+ except OSError:
+ return out
+ for pid in pids[: self.max_pids]:
+ path = f"/proc/{pid}/syscall"
+ try:
+ with open(path, "r", encoding="utf-8", errors="ignore") as fh:
+ line = fh.read().strip()
+ except (OSError, PermissionError):
+ continue
+ if not line or line == "-1" or line.startswith("running"):
+ continue
+ head = line.split(" ", 1)[0]
+ try:
+ num = int(head)
+ except ValueError:
+ continue
+ if num < 0:
+ continue
+ out[int(pid)] = SYSCALL_NAMES.get(num, f"sys_{num}")
+ return out
+
+
+class NgramTracker:
+ """Maintain per-pid syscall histories and emit n-grams as they complete.
+
+ A new n-gram is produced whenever a pid's rolling history reaches length ``n``.
+ Consecutive identical samples (a task parked in one syscall) naturally form
+ homogeneous n-grams -- those are normal and end up in the profile.
+ """
+
+ def __init__(self, n: int = 3, window: int = 400, max_pids: int = 4096) -> None:
+ self.n = max(2, n)
+ self.window = window
+ self.max_pids = max_pids
+ self._hist: dict[int, deque[str]] = {}
+ # Rolling window of recent n-gram keys used for live scoring.
+ self._recent: deque[str] = deque(maxlen=window)
+ # Counts of every n-gram observed since the last flush (profile growth).
+ self._pending: dict[str, int] = {}
+
+ def update(self, samples: dict[int, str]) -> None:
+ # Drop histories for pids that vanished to bound memory.
+ if len(self._hist) > self.max_pids:
+ for dead in [p for p in self._hist if p not in samples]:
+ self._hist.pop(dead, None)
+
+ for pid, name in samples.items():
+ hist = self._hist.get(pid)
+ if hist is None:
+ hist = deque(maxlen=self.n)
+ self._hist[pid] = hist
+ hist.append(name)
+ if len(hist) == self.n:
+ key = _SEP.join(hist)
+ self._recent.append(key)
+ self._pending[key] = self._pending.get(key, 0) + 1
+
+ def recent(self) -> list[str]:
+ return list(self._recent)
+
+ def drain_pending(self) -> dict[str, int]:
+ """Return + clear n-gram counts accumulated since the last drain."""
+ pending = self._pending
+ self._pending = {}
+ return pending
+
+
+@dataclass
+class StideModel:
+ """A "normal" n-gram vocabulary with window-mismatch scoring."""
+
+ n: int
+ ngrams: set[str] = field(default_factory=set)
+ meta: dict = field(default_factory=dict)
+
+ def score_window(self, window: list[str]) -> tuple[float, int]:
+ """Return ``(mismatch_rate, n_mismatches)`` for a window of n-gram keys.
+
+ mismatch_rate = fraction of n-grams in the window not present in the
+ learned normal vocabulary. High rate -> the system is doing things in an
+ order it never did while the profile was learned.
+ """
+ if not window:
+ return 0.0, 0
+ misses = sum(1 for g in window if g not in self.ngrams)
+ return misses / len(window), misses
+
+ def top_unseen(self, window: list[str], limit: int = 3) -> list[str]:
+ """Most frequent unseen n-grams in the window (for an explainable cause)."""
+ counts: dict[str, int] = {}
+ for g in window:
+ if g not in self.ngrams:
+ counts[g] = counts.get(g, 0) + 1
+ ordered = sorted(counts.items(), key=lambda kv: kv[1], reverse=True)
+ return [g.replace(_SEP, "→") for g, _ in ordered[:limit]]
+
+ def save(self, path: str) -> None:
+ import joblib
+
+ os.makedirs(os.path.dirname(path), exist_ok=True)
+ joblib.dump({"n": self.n, "ngrams": self.ngrams, "meta": self.meta}, path)
+
+ @classmethod
+ def load(cls, path: str) -> "StideModel":
+ import joblib
+
+ obj = joblib.load(path)
+ return cls(n=int(obj["n"]), ngrams=set(obj["ngrams"]), meta=dict(obj.get("meta", {})))
+
+
+def build_profile(cfg) -> dict:
+ """(Re)build the STIDE normal profile from accumulated n-gram counts.
+
+ Frequency-based poison guard: only n-grams seen at least ``seq_min_ngram_count``
+ times enter the profile, so a one-off attack sequence never becomes "normal".
+ Returns a small metrics dict; raises SystemExit if there isn't enough data yet.
+ """
+ from kernel_ai.ml.store import fetch_ngram_counts
+
+ counts = fetch_ngram_counts(cfg.dsn, n=cfg.seq_n)
+ total = len(counts)
+ if total < cfg.seq_min_vocab:
+ raise SystemExit(
+ f"Not enough syscall n-grams to build STIDE profile "
+ f"(have {total}, need >={cfg.seq_min_vocab})"
+ )
+
+ kept = {g for g, c in counts.items() if c >= cfg.seq_min_ngram_count}
+ if not kept:
+ raise SystemExit("STIDE profile empty after frequency filter; lower SEQ_MIN_COUNT or collect more data")
+
+ meta = {
+ "stage": 4,
+ "n": cfg.seq_n,
+ "vocab_total": total,
+ "vocab_kept": len(kept),
+ "min_count": cfg.seq_min_ngram_count,
+ }
+ model = StideModel(n=cfg.seq_n, ngrams=kept, meta=meta)
+ model.save(cfg.seq_model_path)
+ logger.info(
+ "saved STIDE profile -> %s (kept %d/%d n-grams, n=%d)",
+ cfg.seq_model_path, len(kept), total, cfg.seq_n,
+ )
+ return meta
+
+
+def main() -> None:
+ import logging as _logging
+
+ from kernel_ai.ml.config import MLConfig
+
+ _logging.basicConfig(level=_logging.INFO, format="%(asctime)s %(levelname)s %(name)s %(message)s")
+ meta = build_profile(MLConfig())
+ logger.info("STIDE build done: %s", meta)
+
+
+if __name__ == "__main__":
+ main()
diff --git a/kernel_ai/ml/store.py b/kernel_ai/ml/store.py
index c8443af..aad053a 100644
--- a/kernel_ai/ml/store.py
+++ b/kernel_ai/ml/store.py
@@ -62,6 +62,15 @@
detail jsonb
);
CREATE INDEX IF NOT EXISTS ml_drift_ts_idx ON ml_drift (ts);
+
+CREATE TABLE IF NOT EXISTS ml_syscall_ngrams (
+ ngram text PRIMARY KEY,
+ n smallint NOT NULL,
+ count bigint NOT NULL DEFAULT 0,
+ first_seen timestamptz NOT NULL DEFAULT now(),
+ last_seen timestamptz NOT NULL DEFAULT now()
+);
+CREATE INDEX IF NOT EXISTS ml_syscall_ngrams_n_idx ON ml_syscall_ngrams (n);
"""
@@ -122,6 +131,22 @@ def insert_anomalies(self, anomalies: list[dict]) -> None:
],
)
+ def upsert_ngram_counts(self, n: int, counts: dict[str, int]) -> None:
+ """Accumulate observed syscall n-gram counts (profile growth)."""
+ if not counts:
+ return
+ with self.conn.cursor() as cur:
+ cur.executemany(
+ """
+ INSERT INTO ml_syscall_ngrams (ngram, n, count)
+ VALUES (%(ngram)s, %(n)s, %(count)s)
+ ON CONFLICT (ngram) DO UPDATE
+ SET count = ml_syscall_ngrams.count + EXCLUDED.count,
+ last_seen = now()
+ """,
+ [{"ngram": g, "n": n, "count": c} for g, c in counts.items()],
+ )
+
def save_baseline(self, rows: list[dict]) -> None:
if not rows:
return
@@ -237,6 +262,46 @@ def insert_drift(dsn: str, record: dict) -> None:
logger.warning("insert_drift failed: %s", exc)
+def fetch_ngram_counts(dsn: str, *, n: int) -> dict[str, int]:
+ """Read accumulated syscall n-gram counts for STIDE profile building."""
+ with connect(dsn) as conn, conn.cursor() as cur:
+ cur.execute("SELECT ngram, count FROM ml_syscall_ngrams WHERE n = %s", (n,))
+ return {row[0]: int(row[1]) for row in cur.fetchall()}
+
+
+def fetch_drift_status(dsn: str, *, history: int = 48) -> dict:
+ """Read the latest drift verdict plus a short history for the API.
+
+ Returns ``{"available": bool, "latest": {...}|None, "history": [...]}``.
+ Never raises on DB trouble (API must degrade gracefully).
+ """
+ try:
+ with connect(dsn) as conn, conn.cursor() as cur:
+ cur.execute(
+ """
+ SELECT ts, flag_rate, expected_rate, feature_drift, n_recent, drifted
+ FROM ml_drift
+ ORDER BY ts DESC
+ LIMIT %s
+ """,
+ (max(1, int(history)),),
+ )
+ cols = [d.name for d in cur.description]
+ rows = []
+ for row in cur.fetchall():
+ rec = dict(zip(cols, row))
+ if rec.get("ts") is not None:
+ rec["ts"] = rec["ts"].isoformat()
+ rows.append(rec)
+ if not rows:
+ return {"available": True, "latest": None, "history": []}
+ # rows are newest-first; history is returned oldest-first for charting.
+ return {"available": True, "latest": rows[0], "history": list(reversed(rows))}
+ except Exception as exc: # noqa: BLE001 - API must degrade gracefully
+ logger.warning("fetch_drift_status failed: %s", exc)
+ return {"available": False, "latest": None, "history": []}
+
+
def fetch_recent_anomalies(dsn: str, *, since_seconds: int = 120, limit: int = 100) -> list[dict]:
"""Read recent anomalies for the API. Never raises on DB trouble."""
try:
diff --git a/kernel_ai/ml/train.py b/kernel_ai/ml/train.py
index 213ea1d..193392b 100644
--- a/kernel_ai/ml/train.py
+++ b/kernel_ai/ml/train.py
@@ -113,11 +113,22 @@ def train(
# MLflow tracking + registry (best-effort: training must not hard-fail if
# MLflow has a hiccup, the artifact is already saved above).
+ #
+ # MLflow's sqlite store unconditionally mkdir's its default artifact root
+ # (``./mlruns``) relative to the *current working directory* at store-init
+ # time. Under systemd the cwd is the (read-only-to-www-data) project root,
+ # so we run the whole MLflow block from inside the writable data dir.
+ data_dir = os.path.dirname(cfg.model_path)
+ prev_cwd = os.getcwd()
try:
+ os.chdir(data_dir)
import mlflow
import mlflow.sklearn
mlflow.set_tracking_uri(cfg.mlflow_uri)
+ # Ensure the experiment stores artifacts in the service-writable data dir.
+ if mlflow.get_experiment_by_name(cfg.mlflow_experiment) is None:
+ mlflow.create_experiment(cfg.mlflow_experiment, artifact_location=cfg.mlflow_artifact_uri)
mlflow.set_experiment(cfg.mlflow_experiment)
with mlflow.start_run() as run:
mlflow.log_params(
@@ -139,6 +150,22 @@ def train(
logger.info("logged MLflow run %s (experiment=%s)", run.info.run_id, cfg.mlflow_experiment)
except Exception as exc: # noqa: BLE001 - tracking is optional
logger.warning("MLflow logging skipped: %s", exc)
+ finally:
+ os.chdir(prev_cwd)
+
+ # Stage 4: rebuild the syscall-sequence (STIDE) profile from the n-grams the
+ # worker has accumulated. Best-effort: a missing/young vocabulary must not
+ # block IsolationForest training (which is already saved above).
+ if cfg.enable_stage4:
+ try:
+ from kernel_ai.ml.sequence import build_profile
+
+ seq_meta = build_profile(cfg)
+ metrics["seq_vocab_kept"] = float(seq_meta.get("vocab_kept", 0))
+ except SystemExit as exc:
+ logger.info("STIDE profile not rebuilt: %s", exc)
+ except Exception as exc: # noqa: BLE001 - sequence profile is optional
+ logger.warning("STIDE profile build failed: %s", exc)
return metrics
diff --git a/kernel_ai/ml/worker.py b/kernel_ai/ml/worker.py
index fd132fa..4fb0388 100644
--- a/kernel_ai/ml/worker.py
+++ b/kernel_ai/ml/worker.py
@@ -92,6 +92,35 @@ def _build_isoforest_anomaly(score: float, scores: dict[str, Score], cfg: MLConf
}
+def _build_sequence_anomaly(mismatch: float, misses: int, window_len: int,
+ top_unseen: list[str], cfg: MLConfig) -> dict:
+ """Build a mutation from a STIDE syscall-sequence verdict (Stage 4).
+
+ Unlike Stages 1-2 (which judge *magnitudes*), this fires when the recent
+ *order* of syscalls contains a high fraction of sequences never seen while
+ the normal profile was learned -- the classic signature of an intrusion.
+ """
+ severity = "high" if mismatch >= cfg.seq_mismatch_crit else "medium"
+ cause = ("; novel: " + ", ".join(top_unseen)) if top_unseen else ""
+ return {
+ "source": "stage4_sequence",
+ "feature": "syscall_seq",
+ "subsystem": "scheduler",
+ "type": "syscall_sequence",
+ "severity": severity,
+ "score": round(mismatch, 4),
+ "value": float(misses),
+ "baseline_mean": None,
+ "baseline_std": None,
+ "position": 0.22,
+ "message": (
+ f"Unusual syscall sequencing: {mismatch * 100:.0f}% of recent "
+ f"{window_len} n-grams are novel ({misses} unseen){cause}"
+ ),
+ "meta": {"stage": 4, "mismatch": round(mismatch, 4), "window": window_len},
+ }
+
+
class MLWorker:
def __init__(self, cfg: MLConfig | None = None) -> None:
self.cfg = cfg or MLConfig()
@@ -106,6 +135,22 @@ def __init__(self, cfg: MLConfig | None = None) -> None:
self._last_if_emit = 0.0
self._maybe_load_model()
+ # Stage 4 (syscall sequence / STIDE). Sampler + tracker run regardless so
+ # the n-gram vocabulary keeps growing; scoring only happens once a profile
+ # artifact exists (built by training/retrain).
+ self.seq_sampler = None
+ self.seq_tracker = None
+ self.seq_model = None
+ self._seq_model_mtime: float | None = None
+ self._last_seq_emit = 0.0
+ self._last_seq_flush = 0.0
+ if self.cfg.enable_stage4:
+ from kernel_ai.ml.sequence import NgramTracker, SyscallSampler
+
+ self.seq_sampler = SyscallSampler(max_pids=self.cfg.seq_max_pids)
+ self.seq_tracker = NgramTracker(n=self.cfg.seq_n, window=self.cfg.seq_window)
+ self._maybe_load_seq_model()
+
def _maybe_load_model(self) -> None:
"""Load / hot-reload the IsolationForest artifact if present and changed.
@@ -130,6 +175,63 @@ def _maybe_load_model(self) -> None:
except Exception as exc: # noqa: BLE001 - keep running on Stage 1 only
logger.warning("failed to load Stage 2 model: %s", exc)
+ def _maybe_load_seq_model(self) -> None:
+ """Load / hot-reload the STIDE profile artifact if present and changed."""
+ if not self.cfg.enable_stage4:
+ return
+ path = self.cfg.seq_model_path
+ try:
+ mtime = os.path.getmtime(path)
+ except OSError:
+ return # no profile yet -> sequence scoring stays dormant
+ if self._seq_model_mtime is not None and mtime <= self._seq_model_mtime:
+ return
+ try:
+ from kernel_ai.ml.sequence import StideModel
+
+ self.seq_model = StideModel.load(path)
+ self._seq_model_mtime = mtime
+ logger.info("loaded Stage 4 STIDE profile: %s (%s)", path, self.seq_model.meta)
+ except Exception as exc: # noqa: BLE001 - keep running without Stage 4
+ logger.warning("failed to load STIDE profile: %s", exc)
+
+ def _tick_sequence(self) -> dict | None:
+ """Sample syscalls, grow the n-gram vocabulary, and score the window."""
+ if self.seq_sampler is None or self.seq_tracker is None:
+ return None
+ # Burst of rapid sub-samples: parked daemons still yield X,X,X (normal),
+ # while actively-working processes reveal real syscall transitions.
+ bursts = max(1, self.cfg.seq_subsamples)
+ gap = max(0.0, self.cfg.seq_subsample_gap_ms / 1000.0)
+ for i in range(bursts):
+ samples = self.seq_sampler.sample()
+ if samples:
+ self.seq_tracker.update(samples)
+ if i < bursts - 1 and gap:
+ time.sleep(gap)
+
+ # Periodically persist newly observed n-grams so the profile can grow.
+ now = time.time()
+ if (now - self._last_seq_flush) >= self.cfg.seq_flush_sec:
+ pending = self.seq_tracker.drain_pending()
+ if pending:
+ self.store.upsert_ngram_counts(self.cfg.seq_n, pending)
+ self._last_seq_flush = now
+
+ if self.seq_model is None:
+ return None
+ window = self.seq_tracker.recent()
+ if len(window) < self.cfg.seq_min_window:
+ return None
+ mismatch, misses = self.seq_model.score_window(window)
+ if mismatch < self.cfg.seq_mismatch_warn:
+ return None
+ if (now - self._last_seq_emit) < self.cfg.seq_cooldown_sec:
+ return None
+ self._last_seq_emit = now
+ top = self.seq_model.top_unseen(window, limit=3)
+ return _build_sequence_anomaly(mismatch, misses, len(window), top, self.cfg)
+
def stop(self, *_args) -> None:
self._running = False
@@ -154,6 +256,15 @@ def _tick(self) -> int:
anomalies.append(_build_isoforest_anomaly(if_score, scores, self.cfg))
self._last_if_emit = now
+ # Stage 4 second opinion: anomalous *ordering* of syscalls.
+ if self.cfg.enable_stage4:
+ try:
+ seq_anom = self._tick_sequence()
+ if seq_anom is not None:
+ anomalies.append(seq_anom)
+ except Exception as exc: # noqa: BLE001 - never let Stage 4 kill the tick
+ logger.warning("sequence scoring failed: %s", exc)
+
if self.cfg.store_features:
self.store.insert_feature_snapshot(features)
if anomalies:
@@ -188,6 +299,7 @@ def run(self) -> None:
self.store.prune(self.cfg.retain_features_hours, self.cfg.retain_anomalies_hours)
# Pick up a freshly retrained model without a restart.
self._maybe_load_model()
+ self._maybe_load_seq_model()
except Exception as exc: # noqa: BLE001 - keep the loop alive
logger.exception("tick failed: %s", exc)
# Reconnect on DB hiccups rather than dying.
diff --git a/static/js/kernel-dna.js b/static/js/kernel-dna.js
index b090276..718510d 100755
--- a/static/js/kernel-dna.js
+++ b/static/js/kernel-dna.js
@@ -1582,9 +1582,222 @@ class KernelDNAVisualization {
`;
this.container.appendChild(legendDiv);
+ const driftEl = this._ensureDriftIndicator();
+
await this.addProcessSelector();
const selectorEl = this.container.querySelector('.dna-process-selector');
- this._applyStaggeredReveal([titleDiv, devLabel, legendDiv, selectorEl]);
+ this._applyStaggeredReveal([titleDiv, devLabel, legendDiv, driftEl, selectorEl]);
+ }
+
+ /**
+ * Model-freshness indicator: shows whether the ML baseline is "fresh" or has
+ * "drifted", current flag-rate vs the expected contamination, feature drift,
+ * model artifact age, and a small flag-rate history sparkline. Reads from the
+ * read-only /api/ml-drift endpoint that the drift monitor / retrain job feeds.
+ */
+ _ensureDriftStyles() {
+ if (this._driftStylesInjected || typeof document === 'undefined') return;
+ const style = document.createElement('style');
+ style.id = 'kernel-dna-drift-styles';
+ style.textContent = `
+ .dna-drift-indicator {
+ position: absolute;
+ bottom: 26px;
+ left: 20px;
+ z-index: 1001;
+ width: 196px;
+ padding: 11px 13px 12px;
+ font-family: 'Share Tech Mono', monospace;
+ color: #c8ccd4;
+ cursor: default;
+ background:
+ linear-gradient(180deg, rgba(18,26,32,0.78), rgba(10,13,16,0.72));
+ border: 1px solid rgba(103, 200, 224, 0.28);
+ box-shadow: 0 0 18px rgba(103,200,224,0.08),
+ inset 0 0 24px rgba(103,200,224,0.04);
+ backdrop-filter: blur(3px);
+ overflow: hidden;
+ /* clipped "cut corner" -> instrument-panel silhouette */
+ clip-path: polygon(0 0, calc(100% - 12px) 0, 100% 12px, 100% 100%, 12px 100%, 0 calc(100% - 12px));
+ }
+ /* corner tick brackets */
+ .dna-drift-indicator::before,
+ .dna-drift-indicator::after {
+ content: '';
+ position: absolute;
+ width: 9px; height: 9px;
+ border: 1px solid rgba(103,200,224,0.65);
+ pointer-events: none;
+ }
+ .dna-drift-indicator::before { top: 4px; left: 4px; border-width: 1px 0 0 1px; }
+ .dna-drift-indicator::after { bottom: 4px; right: 4px; border-width: 0 1px 1px 0; }
+ /* slow scanline sweep */
+ .dna-drift-scan {
+ position: absolute; left: 0; right: 0; top: 0; height: 28px;
+ background: linear-gradient(180deg, rgba(103,200,224,0.10), rgba(103,200,224,0));
+ pointer-events: none;
+ animation: dna-drift-scan 4.6s linear infinite;
+ }
+ @keyframes dna-drift-scan {
+ 0% { transform: translateY(-30px); opacity: 0; }
+ 12% { opacity: 1; }
+ 88% { opacity: 1; }
+ 100% { transform: translateY(150px); opacity: 0; }
+ }
+ .dna-drift-head { display:flex; align-items:center; gap:7px; margin-bottom:8px; }
+ .dna-drift-led {
+ width:7px; height:7px; border-radius:50%;
+ background:#9aa3ad; box-shadow:0 0 6px rgba(154,163,173,0.8);
+ animation: dna-drift-led 1.8s ease-in-out infinite;
+ }
+ @keyframes dna-drift-led { 0%,100%{opacity:0.45;} 50%{opacity:1;} }
+ .dna-drift-title {
+ font-size:10px; letter-spacing:1.2px; opacity:0.82; flex:1;
+ text-shadow: 0 0 8px rgba(103,200,224,0.25);
+ }
+ .dna-drift-pill {
+ font-size:8.5px; letter-spacing:0.6px; padding:2px 7px;
+ border:1px solid rgba(120,128,138,0.5); color:#9aa3ad;
+ clip-path: polygon(0 0, calc(100% - 5px) 0, 100% 5px, 100% 100%, 5px 100%, 0 calc(100% - 5px));
+ }
+ .dna-drift-metrics { font-size:10px; line-height:1.6; opacity:0.9; }
+ .dna-drift-metrics .k { opacity:0.5; }
+ `;
+ document.head.appendChild(style);
+ this._driftStylesInjected = true;
+ }
+
+ _ensureDriftIndicator() {
+ let panel = this.container.querySelector('.dna-drift-indicator');
+ if (panel) return panel;
+ this._ensureDriftStyles();
+
+ panel = document.createElement('div');
+ panel.className = 'dna-drift-indicator';
+ window.setSafeHtml(panel, `
+
+
+
+ ML MODEL
+ …
+
+
+
+
flags – / exp –
+
feat drift –
+
model –
+
+ `);
+ this.container.appendChild(panel);
+ this._startDriftPolling();
+ return panel;
+ }
+
+ _startDriftPolling() {
+ if (this.driftInterval) return;
+ const poll = async () => {
+ if (!this.isActive) return;
+ try {
+ const resp = await fetch('/api/ml-drift?history=40');
+ const payload = await resp.json();
+ this._renderDriftIndicator(payload);
+ } catch (e) {
+ this._renderDriftIndicator({ available: false });
+ }
+ };
+ poll();
+ this.driftInterval = setInterval(poll, 15000);
+ }
+
+ _fmtAge(sec) {
+ if (sec == null || !isFinite(sec)) return 'n/a';
+ if (sec < 90) return `${Math.round(sec)}s ago`;
+ if (sec < 5400) return `${Math.round(sec / 60)}m ago`;
+ if (sec < 172800) return `${Math.round(sec / 3600)}h ago`;
+ return `${Math.round(sec / 86400)}d ago`;
+ }
+
+ _renderDriftIndicator(payload) {
+ const panel = this.container && this.container.querySelector('.dna-drift-indicator');
+ if (!panel) return;
+ const pill = panel.querySelector('.dna-drift-pill');
+ const led = panel.querySelector('.dna-drift-led');
+ const setText = (sel, txt) => { const el = panel.querySelector(sel); if (el) el.textContent = txt; };
+ const setState = (label, color, border) => {
+ pill.textContent = label;
+ pill.style.color = color;
+ pill.style.borderColor = border;
+ if (led) { led.style.background = color; led.style.boxShadow = `0 0 7px ${color}`; }
+ };
+
+ if (!payload || payload.available === false) {
+ setState('OFFLINE', '#6b7076', 'rgba(107,112,118,0.5)');
+ setText('.dna-drift-flag', '–'); setText('.dna-drift-exp', '–');
+ setText('.dna-drift-feat', '–'); setText('.dna-drift-age', '–');
+ this._drawDriftSparkline([], 0.02);
+ return;
+ }
+
+ const latest = payload.latest;
+ const history = Array.isArray(payload.history) ? payload.history : [];
+ const exp = latest && latest.expected_rate != null ? latest.expected_rate : 0.02;
+
+ if (!latest) {
+ setState('WARMING', '#9aa3ad', 'rgba(120,128,138,0.5)');
+ } else if (latest.drifted) {
+ setState('DRIFTED', '#E0564E', 'rgba(224,86,78,0.6)');
+ } else {
+ setState('FRESH', '#5BD6A0', 'rgba(91,214,160,0.5)');
+ }
+
+ if (latest) {
+ const fr = latest.flag_rate != null ? latest.flag_rate : 0;
+ const fd = latest.feature_drift != null ? latest.feature_drift : 0;
+ setText('.dna-drift-flag', `${(fr * 100).toFixed(1)}%`);
+ setText('.dna-drift-exp', `${(exp * 100).toFixed(1)}%`);
+ setText('.dna-drift-feat', fd > 99 ? '>99σ' : `${fd.toFixed(2)}σ`);
+ }
+ setText('.dna-drift-age', this._fmtAge(payload.model_age_sec));
+ this._drawDriftSparkline(history, exp);
+ }
+
+ _drawDriftSparkline(history, expected) {
+ const svg = this.container && this.container.querySelector('.dna-drift-spark');
+ if (!svg) return;
+ const NS = 'http://www.w3.org/2000/svg';
+ while (svg.firstChild) svg.removeChild(svg.firstChild);
+ const W = 170, H = 30;
+ const vals = history.map(h => Math.max(0, Math.min(1, h && h.flag_rate != null ? h.flag_rate : 0)));
+ // Scale so the expected line and the data are both visible.
+ const peak = Math.max(0.04, expected * 2, ...vals);
+ const y = (v) => H - 2 - (v / peak) * (H - 4);
+ const x = (i, n) => n <= 1 ? 0 : (i / (n - 1)) * W;
+
+ // expected (baseline) reference line
+ const base = document.createElementNS(NS, 'line');
+ base.setAttribute('x1', 0); base.setAttribute('x2', W);
+ base.setAttribute('y1', y(expected)); base.setAttribute('y2', y(expected));
+ base.setAttribute('stroke', 'rgba(150,160,170,0.35)');
+ base.setAttribute('stroke-dasharray', '3 3');
+ base.setAttribute('stroke-width', '1');
+ svg.appendChild(base);
+
+ if (vals.length >= 2) {
+ const pts = vals.map((v, i) => `${x(i, vals.length).toFixed(1)},${y(v).toFixed(1)}`).join(' ');
+ const line = document.createElementNS(NS, 'polyline');
+ line.setAttribute('points', pts);
+ line.setAttribute('fill', 'none');
+ line.setAttribute('stroke', '#67C8E0');
+ line.setAttribute('stroke-width', '1.4');
+ line.setAttribute('stroke-linejoin', 'round');
+ svg.appendChild(line);
+ // highlight the newest point
+ const last = vals[vals.length - 1];
+ const dot = document.createElementNS(NS, 'circle');
+ dot.setAttribute('cx', W); dot.setAttribute('cy', y(last)); dot.setAttribute('r', '2.4');
+ dot.setAttribute('fill', last > expected * 3 ? '#E0564E' : '#67C8E0');
+ svg.appendChild(dot);
+ }
}
appendInDevelopmentLabel(topPx = 52) {
@@ -1814,6 +2027,13 @@ class KernelDNAVisualization {
clearInterval(this.updateInterval);
this.updateInterval = null;
}
+
+ if (this.driftInterval) {
+ clearInterval(this.driftInterval);
+ this.driftInterval = null;
+ }
+ const driftPanel = this.container && this.container.querySelector('.dna-drift-indicator');
+ if (driftPanel && driftPanel.parentNode) driftPanel.parentNode.removeChild(driftPanel);
// Explicitly remove exit button before hiding container
if (this.exitButton && this.exitButton.parentNode) {