feat(anomaly-detector): hourly LLM-based journal anomaly detection - #70
Draft
gsanchietti wants to merge 23 commits into
Draft
feat(anomaly-detector): hourly LLM-based journal anomaly detection#70gsanchietti wants to merge 23 commits into
gsanchietti wants to merge 23 commits into
Conversation
Design for an LLM-based journal anomaly detector shipped as an extension of the loki module, alongside the existing syslog and cloud-log-manager forwarders. Hourly systemd timer runs a oneshot script that queries Loki for a digest, a 7-day baseline and a capped set of prefiltered lines, scrubs likely secrets, asks an OpenAI-compatible endpoint for a schema-constrained verdict, and emits findings to the journal plus an optional webhook. The LLM API key lives in state/secrets.env, not in the environment file, which is mirrored into Redis. Assisted-by: Claude Code:claude-opus-5[1m]
Ten-task TDD plan derived from the approved design spec, with five documented deviations and per-task verification against a live node. Assisted-by: Claude Code:claude-opus-5[1m]
Aligned/structured logs pad the assignment separator (key : value), which the keyword regexes required to immediately follow the keyword with no whitespace. That left secret : value, token =value, and Bearer:value unredacted. Allow optional whitespace around the separator without reopening the bare-whitespace false positive the split was meant to fix; add regression coverage for the four reported variants.
Verified google/gemma-4-26b-a4b-it:free honours response_format json_schema with the plan's exact RESPONSE_SCHEMA; the same request without it returns fenced markdown with an invented shape, so the field is load-bearing. Also git-ignore the local credential scratch files so they cannot be swept into a commit. Assisted-by: Claude Code:claude-opus-5[1m]
Assisted-by: Claude Code:claude-sonnet-5
Assisted-by: Claude Code:claude-sonnet-5
Retry() defaulted raise_on_status to True, so urllib3 raised RetryError once retries on 429/500/502/503/504 were exhausted instead of handing back a Response -- ask_llm's status-code branches, and its truncated diagnostic, never ran for the most common gateway failures. Set raise_on_status=False so session.post() always returns the final Response; post_webhook's raise_for_status() still fires on that Response, so its contract is unaffected. Add TestMakeSession.test_retry_config to lock in the Retry configuration, since the existing ask_llm tests bypass urllib3's retry machinery and could not have caught this. Assisted-by: Claude Code:claude-sonnet-5
Endpoints that do not enforce response_format return schema-valid JSON wrapped in a markdown fence. Strip a whole-response fence before parsing, then validate exactly as strictly as before; prose around a fence still fails. Also tell the model that window_assessment must agree with the findings it reports, which two models otherwise contradicted. Assisted-by: Claude Code:claude-opus-5[1m]
The action writes public settings with agent.set_env and keeps the API key and webhook token in state/secrets.env at mode 0600, so neither reaches the Redis-mirrored environment hash. Disabling clears both stores and stops the timer. get-configuration reports detector state and key presence, never the key itself. Assisted-by: Claude Code:claude-opus-5[1m]
Assisted-by: Claude Code:claude-opus-5[1m]
Assisted-by: Claude Code:claude-opus-5[1m]
The stub returns a canned OpenAI-shaped completion so CI needs no network egress and no API key. The suite proves the action stores the key at 0600 outside Redis, that the oneshot run lands in the journal under SyslogIdentifier=<module>/anomaly-detector and reaches Loki with a module_id label, and that disabling clears the stored key. Assisted-by: Claude Code:claude-opus-5[1m]
…boundary Assisted-by: Claude Code:claude-opus-5[1m]
Assisted-by: Claude Code:claude-opus-5[1m]
Scrubbing no longer destroys the signal it exists to protect: systemd templated unit names such as agent@nethvoice2.service were being redacted as e-mail addresses, which stripped the module name from exactly the PRIORITY=3 lines that identify a crash loop, and the base64 rule swallowed long filesystem paths. recall_findings kept the oldest findings rather than the newest: logcli --forward returns entries chronologically, so a noisy run could fill the 200-entry budget with its own records and leave the detector with no memory of what it had just reported. Recalled titles now go through sanitize_line rather than scrub, so a title containing a newline cannot break out of the RECENT_FINDINGS block. Log text is attacker-influenced, and titles are derived from it. Restore no longer leaves a live third-party API key on disk for a detector that can never run: the public settings are restored alongside the secret and the timer is re-enabled, or the orphaned key is discarded. Tests: add the missing main() coverage (wiring, baseline scaling, nominal early exit, flush-before-webhook ordering, dry-run key skip), scrub regression cases, recall ordering, and a Robot case that deterministically exercises the LLM path instead of passing on an empty window. Assisted-by: Claude Code:claude-opus-5[1m]
Assisted-by: Claude Code:claude-opus-5[1m]
A busy cluster can exceed Loki's max_query_series limit on the cluster-wide digest/baseline aggregation. query_metric now logs the error and returns an empty rate map instead of crashing the run, so the prefiltered-lines analysis still proceeds for that window. Assisted-by: Claude Code:claude-sonnet-5
Some OpenAI-compatible models reject any non-default temperature outright (400 unsupported_value), so a fixed temperature=0 breaks the detector against them. Determinism was a nice-to-have, not a requirement; omit the field instead of trying to special-case models. Assisted-by: Claude Code:claude-sonnet-5
parse_findings now sorts by SEVERITIES order (critical..low) before returning, so render_findings, the journal output and the webhook payload all list the most urgent finding first regardless of the order the model answered in. Assisted-by: Claude Code:claude-sonnet-5
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Adds an opt-in hourly anomaly detector to
ns8-loki, alongsidesyslog-forwarderandcloud-log-manager-forwarder. Disabled by default; requires an explicit API key.Implements the approved design in
docs/superpowers/specs/2026-07-29-loki-anomaly-detector-design.md.What it does
A
systemdtimer fires aType=oneshotservice hourly. One run:(module_id, priority)rate digest, a 7-day baseline, up tomax_linesprefiltered lines (PRIORITY < 5 or category="security"), and its own last 10 findings.Skipping the LLM call on an idle window keeps quiet nodes cheap. A summary line is still written every window, so nominal hours are graphable.
Manual test
Install it on an existing machine:
Run using an OpenAI model:
Run using a free model with OpenRouter:
Configuration
base_urlis a plain configured value, so OpenAI, OpenRouter, vLLM, Ollama or a self-hosted gateway all work through one code path.Security
state/secrets.envat mode0600, never throughagent.set_env— that writesstate/environment, which is mirrored into the Redis hashmodule/<id>/environment. Verified on a live node: the Redis leak check returns nothing, andget-configurationreports onlyapi_key_configured.sshdlines reach the prompt by design), so every line is flattened to one line before entering the prompt's fenced blocks. A crafted message cannot forge extra lines or close a fence.SYSLOG_IDENTIFIERbefore the priority filter, so its stderr diagnostics (journald records them atPRIORITY=3) cannot feed back into the next window.Verification
./test-unit.sh) — new pytest harness plus a CI job.SyslogIdentifier=%u/%Nresolving toloki1/anomaly-detector(themodule_idlabel the recall query depends on), the action's secret handling and disable path, andget-configurationoutput.strict: true. The detector unwraps a whole-response fence, then validates exactly as strictly as before; anything that does not validate is logged truncated and exits non-zero rather than emitting a half-understood finding.The Robot suite in
tests/20__anomaly_detector.robothas not run yet — it needs CI. It uses a local stub returning a canned OpenAI-shaped completion, so there is no egress and no cost. Draft until that goes green.Deviations from the spec
Five, each deliberate and documented in the plan: the script is import-safe with a
main()so its pure functions are testable; the digest and baseline use Loki's HTTP instant-query API rather thanlogcli, whose metric output format is not a stable contract; scrub order puts the e-mail rule before the blob rule; the nominal path still emits a summary line; and unit tests use pytest rather than the Robot-based ns8-core script the spec cited.Review
A whole-branch review found five substantive issues, all fixed in
fbc8ab2: the e-mail scrub rule was redacting systemd unit names likeagent@nethvoice2.service, stripping module names from exactly the lines that identify a crash loop;recall_findingskept the oldest findings instead of the newest; recalled titles bypassed line flattening; restore left a live API key on disk for a detector that could never run; and the Robot suite could pass without ever exercising the LLM path.