Conversational AI analyst: use the command modules as LLM tools - #288
Open
spierenburg wants to merge 18 commits into
Open
Conversational AI analyst: use the command modules as LLM tools#288spierenburg wants to merge 18 commits into
spierenburg wants to merge 18 commits into
Conversation
Stacked on #287 (feat/284-ioc-type-routing): the plan consumes commands/cmdutils.py (classify/accepts/normalise_accepts) and the 'accepts' registry key, neither of which exists on main.
Three seams the AI analyst needs, with no behaviour change: - run_module() executes a module and returns its result dict; call_module() now delegates to it and keeps doing the posting. The AI needs the text back. - send_message() accepts props and stamps a part index on each block of a split message, so a long reply is not replayed as several assistant turns. - pyproject ships ai_analyst as a py-module, and states the 3.11 floor that matterbot's use of asyncio.timeout has already required.
extract_indicators() bridges cmdutils.classify() (one clean token) to the prose an analyst actually writes: commas, markdown links, IOC: labels, defanged URLs with paths. sanitize_tool_output() redacts credentials from ALL module output, success included -- the AI is the first feature that ships that output off-host to a third-party LLM, so #286's exception-text fix is not sufficient here. build_tool_definitions() turns HELP + ACCEPTS into schemas, exposing only AITOOL modules that accept a type in play.
…ir drops, CIDR dismantling
_candidates() only dismantled a token into a bare host when it had a path,
never checking whether the whole token already classified -- so a CIDR's
network address got authorized as a second, independent IP alongside the
CIDR, and neither '/' nor '->'-joined indicator pairs split at all (silently
dropping the second value). Fixed by classifying the whole token first and
only falling back to segment-splitting when it does not classify.
Also added a domain-plausibility gate: cmdutils._HOSTNAME_RE only requires
an alphabetic final label, so ordinary run-together prose ("it.Then") and
filenames ("malware.exe", "config.py") classified as domains just as
readily as real ones, offering the LLM tool calls it should never have been
authorized to make. The gate is bypassed for defanged or labelled tokens,
since an analyst defanging or labelling something is a statement that it is
a real indicator, and a missed real IOC is worse than an admitted filename.
…ty, and sweep Missed real IOCs are the worst failure mode. Added 20+ abuse-prone gTLDs (monster, download, security, stream, review, etc.) that malware/phishing actually use. The TLD gate now errs toward inclusion: a false positive wastes a lookup, but a false negative loses a real indicator. Analysts can still block stray filenames (.zip, .mov) via the file-extension blocklist; genuine .zip/.mov domains are rescuable through defanging (evil[.]zip) or labelling (domain=evil.zip). Added 9 regression tests: - Verify monster/download/security domains now classify - Confirm .zip and .mov filenames remain blocked - Confirm defanged/labelled .zip/.mov domains still bypass the gate
A curated allowlist of "common" gTLDs cannot win against ~1450 real delegated TLDs: a reviewer showed real, in-the-wild-abusable domains (c2.bond, evil.gdn, evil.surf, phish.mom, c2.wang, ...) were being silently dropped by extract_indicators() because they used a real TLD the curated list had never heard of -- with nothing anywhere logging that it happened. _COMMON_TLDS is replaced with _IANA_TLDS, embedded from https://data.iana.org/TLD/tlds-alpha-by-domain.txt (version 2026062302). Membership in that list now decides plausibility on its own, which fixes both directions at once: every real TLD is accepted, and prose/filenames that were never real TLDs to begin with (report.doc, help.desk, oauth.token, etc.) are still rejected without needing per-string special-casing. The 2-letter-ccTLD escape hatch is removed -- every real ccTLD is already in the IANA set, so the escape hatch only ever served to let script.py/run.sh/notes.md through as "domains". _FILE_EXT_BLOCKLIST shrinks to the genuine collisions between common file extensions and real IANA TLDs (py, sh, md, so, pl, rs, ps, zip, mov) -- domain- dominant collisions like .com/.cc/.io/.ai/.co stay valid domains. Also adds a log.debug() breadcrumb at the one place a domain-shaped token gets silently dropped by the TLD gate, so "why didn't the AI look that up?" has something to grep for.
No server-side session: authorized indicators, evidence mode, the pending pivot and the per-thread tool budget are all recomputed from the thread's posts each turn, so a restart loses nothing. Handles the two traps: a long reply that send_message split into several posts is rejoined into ONE assistant turn and its budget charged once, and a hedged 'ok but why...' is not read as approval of a pivot. Raw evidence is excluded from replay; the history cap trims context only, never authorization.
normalise_thread() sorted parts of a split AI reply by (create_at, id); send_message() posts parts back-to-back so create_at ties routinely, and the id tiebreak is not send-ordered, so a reply could rejoin scrambled and feed the model a corrupted version of its own last turn. Sort key is now (create_at, ai_part, id), with ai_part coerced safely to 0 when missing. is_affirmative() treated 'pull it' / 'check it' as approval phrases, so a redirect like "pull it up in VT instead" silently promoted a pending pivot to authorized and ran an unapproved lookup. Removed those two fragments from the affirmative-phrase list and added 'instead' to the negation set.
Thin adapter, no new dependency and no openai SDK. temperature 0 (the same evidence should give the same read), one retry on 429/5xx, requests imported lazily so ai_analyst stays importable under the dependency-free CI runner, and an injectable session so the agent loop is testable with no network. Errors never interpolate the request, which carries the Authorization header.
Local model servers (Ollama, vLLM) serving quantized models are known to emit malformed structured tool-call JSON when generation degrades. chat() only guarded the choices[0].message lookup, then assumed every layer below was a dict and called .get() blindly -- a non-dict message, a non-list tool_calls, a non-dict tool_call entry, or a non-dict function field each raised an unhandled AttributeError that killed the whole AI analyst turn. Add type guards that raise a clean LLMError for a malformed message and skip (with a log.warning) individual malformed tool_call entries in _normalise_tool_calls, so a partially-bad response still yields whatever valid tool calls or text content survived. Error messages carry only a status code or exception type name, never headers/payload/api_key.
Every path from 'the model wants X' to 'X happens' goes through _run_tool_call, which enforces the operator allow-list, ACLs, ACCEPTS, the analyst-authorized indicator set and the call caps in code. The model cannot query an indicator the analyst never named -- it can only propose it -- regardless of what any prompt (or any prompt-injected tool result) tells it. Module output is redacted and capped before it reaches the model or the channel; tool results are delimited as untrusted data; a module failure never puts an exception string in either place.
Three defense-in-depth fixes to AIAnalyst's executor/output path found by adversarial review of task 6/7: - Minor 1: module output was interpolated raw into the <untrusted_tool_result> wrapper, so attacker-controlled text (WHOIS fields, filenames, page content) could contain a literal closing tag and forge a following <system>/<user>/<assistant> block, escaping the wrapper. sanitize_tool_output() now defangs the closing delimiter and role-turn openers with a zero-width break, at the single chokepoint every module result already passes through. - Minor 2: _result_status() decided ok/timed-out/failed/no-data by a loose substring scan over the whole result, so a genuinely successful result whose real content happened to contain "timed out" or "returned no data" was misclassified and silently dropped from full-mode evidence. 'failed'/'no data' are now matched against the exact, fully anchored sentinel shape _run_tool_call itself generates on those two code paths; 'timed out' requires a short, single-line result (the shape of a genuine module timeout notice), not an incidental mention inside a large multi-field blob. - Minor 3: _prepare_output() ran sanitize_tool_output()'s re.sub() directly on whatever run_tool() returned. A module breaking its documented "returns text" contract (dict/list/int/bytes) raised an uncaught TypeError that escaped handle() entirely, leaving the analyst with no reply at all. Non-string output is now coerced to str before redaction.
Loads the per-module AITOOL opt-in, registers the @ai bind only when AI.enabled, and injects the four callbacks (run-tool, get-thread, post, is-allowed) ai_analyst needs. Thread ordering lives in ai_analyst, not here, because matterbot.py cannot be imported under the dep-free test runner. AI.modules / AI.blocked_modules give the operator deployment-level control on top of the developer-level AITOOL flag. With no AI: block, self.ai is None, @ai is never registered, and nothing else changes.
Seven read-only threat-intel lookups (abuseipdb, circlpdns, crtsh, ipinfo, malwarebazaar, threatfox, urlhaus) that between them cover every indicator type the classifier knows. Everything else -- paid-quota, free-text, lolbin, actor lookups -- stays off until an operator opts it in. A contract test enforces that an AITOOL module always declares ACCEPTS, so the model is never told a domain-only API will take a hash.
Config, the hard function-calling-model requirement, the two-switch (developer AITOOL + operator allow-list) tool exposure, evidence modes, and -- explicitly -- what the code-enforced guardrails mean for prompt injection, because the honest answer to 'threat intel is attacker-influenceable' is the blast radius, not a promise about the prompt.
The final whole-branch review flagged that a single 'yes' authorizes all indicators pending that turn, so a prompt-injected model could ride a legitimate approval to get an extra proposed indicator queried. The blast radius stays inside the stated model -- still one read-only, ACL-checked, type-checked, rate-capped lookup -- so this documents the limitation rather than changing the guardrail.
Following #289, the opt-in is declared with @cmdutils.aitool on process() rather than AITOOL in defaults.py, so the README, the AI: config block and the _registry() comment all still described the old shape. Also states the property the move buys, which the old prose did not: the decorator cannot be set from defaults.py/settings.py, so the config lists can only ever subtract from the exposed set -- they cannot widen it past what a developer marked safe.
spierenburg
force-pushed
the
feat/ai-analyst
branch
from
August 4, 2026 20:59
e14e129 to
a6bb431
Compare
spierenburg
marked this pull request as ready for review
August 4, 2026 21:02
Top-ranked candidate from the /scan adw deep-scan (leverage 4, automatability 5). Draft, targets the CODE SDLC node.
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.
MatterBot — Conversational AI Analyst: change overview & merge guide
For the maintainer (Uforia). Branch
feat/ai-analyst, draft PR #288.This document explains what the change is, what it keeps, what it adds, how the pieces
tie together, and how to verify it live before merging. Every file, gate, and log
string below is taken from the code on the branch, not from a design sketch.
1. In one paragraph
MatterBot gains an optional conversational AI analyst. You talk to it in a Mattermost
thread in plain language (
@ai …), and it investigates the case by calling MatterBot'sown existing command modules as tools. It is off by default, additive, and
reuses the existing module machinery rather than reimplementing it. Every action it
can take is gated in code, so a prompt-injected model is confined to read-only, authorized,
ACL-checked, rate-capped lookups.
2. The design principle: additive and opt-in
Three guarantees, each enforced by the code (not by convention):
Manual commands are unchanged — people still run
@iocthemselves. On startup thebot appends the AI bind:
self.binds = sorted(set(self.binds + [self.ai.bind]))— itnever replaces
@ioc,@crtsh,@abuseipdb, … Inhandle_postthe AI is a separateelif command == self.ai.bind:branch that sits before the existing module-fanoutelse:. Every other command —@iocincluded — falls through to the exact sametype-routed fanout it used before. The AI sits next to the manual commands, not in
front of them.
Opting a module into the AI doesn't change that module's own behaviour. The seven
starter modules gained a single
@cmdutils.aitoolline onprocess(), next to the@cmdutils.handles(...)from Declare accepted indicator types on process(), not in defaults.py #289; theirBINDSand declared types are untouched, so@ioc 8.8.8.8still fans out to them identically. Theirdefaults.pyfiles arebyte-identical to
main. The flag is read only by the AI's registry/executor todecide what the AI may call; it is invisible to the direct-command path.
With no
AI:config block, the feature does not exist at runtime.self.aistaysNone, the@aibind is never registered,ai_analyst.pyis never even imported. Thewhole-branch review confirmed: AI-disabled behaviour is byte-for-byte unchanged.
A note on where the opt-in lives (changed after #289)
The branch originally declared the opt-in as
AITOOL = Truein each module'sdefaults.py, with asettings.pyoverride. #289 then movedACCEPTSout ofdefaults.pyand ontoprocess()as@cmdutils.handles(...), on the grounds that"the type a module handles is a code fact, not deployment config." On the rebase the
opt-in followed it, as
@cmdutils.aitool, for the same reason and one more:it is deliberately not readable from
defaults.py/settings.py. A config file thatcould set the flag would be able to widen exposure past what a developer marked safe —
the inverse of how the two-key gate is documented. As written,
AI.modules/AI.blocked_modulesare the operator's only runtime lever and they can only subtract.Two tests pin this down:
test_the_ai_optin_is_not_settable_from_configfails ifAITOOLreappears in anydefaults.py, and the wiring test assertssettings.AITOOLis absent from
matterbot.py.If you'd rather keep a per-module operator override, say so — it's a small change back,
but it would want a shipped
settings.pyexample and a test, neither of which the treehas today.
3. What is KEPT (unchanged behaviour)
@iocand every existing@/!commandelseinhandle_post; the@aibranch is a separateelifthat only matches the AI bind.@cmdutils.aitoolline was added;BINDS/declared types/logic untouched, and theirdefaults.pyfiles are byte-identical tomain.@cmdutils.handles, #287/#289)isallowed_module)run_module)self.run_modulethe manual path uses (matterbot.py:924 vs :950) — one runner, not two.AI:block → nothing changes, no new log lines, no@aibind.requests(already used) is imported lazily;ai_analyst.pyimports on stdlib +commands/cmdutilsalone, so the dependency-free CI stays green.4. What is ADDED
ai_analyst.py(new)LLMClient(thinrequestswrapper), the guarded executor_run_tool_call, and the agent loophandle.matterbot.py(wiring)process()into the command registry; constructs anAIAnalystonly whenAI.enabled; injects four thin callbacks (_ai_registry,_ai_run_tool,_ai_get_thread,_ai_post); adds the@aidispatch branch and the--AIconfig parse. (The 13 deletions are earlier signature extensions, e.g.send_messagegaining apropsarg — no behaviour removed.)commands/cmdutils.py@cmdutils.aitool, the developer opt-in decorator, alongsidehandles()from #289.config.defaults.yamlAI:block (enabled: False).commands/*/command.py@cmdutils.aitoolon abuseipdb, circlpdns, crtsh, ipinfo, malwarebazaar, threatfox, urlhaus — a curated read-only starter set covering every indicator type. Theirdefaults.pyfiles are unchanged.README.mdtests/test_ai_analyst.py,test_matterbot_ai_wiring.py, andAIToolContractTests— 314 tests total, all green.pyproject.tomlai_analystas a module and correctsrequires-pythonto>=3.11— already true before this change (asyncio.timeoutis 3.11+); the AI feature did not newly raise the floor.docs/superpowers/…5. How an
@aiturn ties together (end to end)The one architectural point worth stressing to a reviewer: the AI does not have its own
way to run modules. It goes through the same
run_module, behind a gate. Remove thegate and it is just the manual command path; the gate is the entire security surface.
6. The security model: one guarded door
_run_tool_callis the only path from "the model wants X" to "X happens." Threat-intelresults are attacker-influenceable (WHOIS fields, filenames, page content), so the model is
assumed hostile. Prompting is not a control; these code gates are, applied cheapest-and-most-
restrictive first:
AI.modules/AI.blocked_modules.@cmdutils.aitool— the module opted in at the developer level at all.isallowed_module: the AI is never a way around a permission the user lacks.cmdutils.classifyproduced a real indicator.@cmdutils.handles(...)— the module can actually take that indicator type.Then on the way back: redact (credentials/keys stripped, even on the success path,
because this is the first feature that ships module output off-host to an LLM) and
length-cap.
The authorization guardrail is the heart of it: the AI may only look up indicators the
human named. If it discovers a new one worth pivoting to, it proposes it and waits — a
human "yes" authorizes it on the next turn, a "no"/redirect keeps it blocked. A prompt-
injected model that tries to pivot on its own is denied in code (logged
ai: DENIED ... reason=unauthorized). (Caveat, documented in the README: a bare "yes"approves every indicator proposed that turn — still each a read-only, ACL/type/rate-capped
lookup — so approve deliberately.)
The whole-branch review verified this invariant by execution across 7 multi-turn scenarios;
zero Critical/Important findings.
7. Live verification runbook (Task 11 — do this before merging)
314 stubbed tests cannot prove the one thing the design turns on: that a real model, over
real HTTP, actually emits tool calls. Work top to bottom; Step 1 is a hard gate.
7.0 Prerequisites
supports native function-calling.
base_url(incl./v1),model,api_key("ollama"for a keyless local endpoint).requests/mattermostdriver) — call itPYTHON.Not the
sys.modules['requests']=Nonedependency-free check; that is a CI guard.$PYTHONmust be 3.11+ —matterbot.pyusesasyncio.timeout. Check it explicitly:$PYTHON -c "import sys; assert sys.version_info >= (3,11), sys.version; print(sys.version)".This is worth doing up front because the 7.7 repo checks are stdlib-only and do not
import
matterbot.py: they pass green on 3.9 and the bot then fails to start.Sanity-check reachability first:
curl -sS <base_url>/models -H "Authorization: Bearer <api_key>" | head7.1 GATE — confirm the model actually tool-calls
tool_callslike{'name':'crtsh','arguments':{'query':'evil.example.com'}}.LLMError→ endpoint/auth/URL (base_urlneeds/v1).ModuleNotFoundError: requests→ you used the dependency-free interpreter; use the runtime one.
7.2 Point a scratch config at the endpoint
Copy the shipped
AI:block fromconfig.defaults.yamlinto yourconfig.yaml(nativeYAML, same shape as the
Matterbot:/Modules:blocks). Setenabled: Trueand the threeendpoint fields; leave
modules: []so the whole starter set is available.Start the bot:
$PYTHON matterbot.py(add--debugfor console logging).Expect on startup:
AI analyst enabled on bind @ai (model <model>)inmatterfeed.log.7.3 It investigates a named indicator
@ai what do you make of 8.8.8.8?→ threaded analyst reply ending in a sources footerQueried: circlpdns(8.8.8.8) → ok, …. Log: oneai: tool call module=… arg=8.8.8.8 …per lookup.7.4 It PROPOSES a pivot, and only queries it after "yes"
attempt shows
ai: DENIED … reason=unauthorized— that denial is the design working.yes→ it now queries it (a matchingai: tool call …line).no, leave itto a proposal → it stays blocked (no tool call).7.5
fullevidence mode@ai full what about 1.1.1.1?→ narrative plus raw module tables as follow-up posts.Take another turn → mode stays
full(sticky), and the earlier raw tables are notreplayed into the model's context (token cost doesn't balloon).
@ai brief …switches back.7.6 Long-reply split, guardrail logs, and disabled-inertness
Matterbot.msglength; next turn treats the split as one assistantmessage and charges the tool budget once.
grep 'ai: ' matterfeed.log | tail -40→ai: tool call …per executed call,ai: DENIED … reason=unauthorizedfor blocked pivots, and no API keys / bearertokens / key-bearing URLs anywhere in the log, any post, or any LLM request.
enabled: False, restart, run@ioc 8.8.8.8→ normal behaviour, noAI analyst enabledline,@aidoes nothing. (This is the "kept" guarantee, live.)7.7 Repo checks (for the record)
Expected
ruffresult, so a red line doesn't stop you: theF821gate above (the oneCI actually enforces, see
.github/workflows/f821-delta.yml) passes on every changed file.A full
ruff check ai_analyst.py matterbot.pyreports 1 error —F841 Local variable 'username' is assigned to but never usedatmatterbot.py:535. That is pre-existing onmain(same finding, shifted line number) and is not introduced here; the whole tree has~104 such findings tracked under #180. This PR adds zero new lint findings.
8. Merge path
Retarget once Route @ioc lookups by indicator type (foundation for #284) #287 merges— done: Route @ioc lookups by indicator type (foundation for #284) #287 and Declare accepted indicator types on process(), not in defaults.py #289 have both merged, and thisbranch is now rebased onto
mainand targetsmain.of that section. Nothing below it should be read as verified against a real model.
config.yamlcredentials, or confirm the file stays untracked.Stop-and-report (do not merge if seen live)
These are the two invariants the live run exists to falsify; both held under review, but
only a real endpoint + real Mattermost can confirm them end-to-end.
9. Turning it on in production (after merge)
Section 7 verifies the feature against a scratch config; it is not a deployment guide.
Merging changes nothing on a running bot — with no
AI:block the feature does not exist atruntime. To actually enable it:
already true before this PR (
asyncio.timeout);pyproject.tomlnow states it honestlyinstead of claiming
>=3.9.AI:block to the productionconfig.yaml, copying the annotated block fromconfig.defaults.yamland settingenabled: Trueplusbase_url/model/api_key.Full walkthrough: README → "Enabling it". Requirements: README → "Requirements".
config.yamlis git-ignored — confirm withgit check-ignore config.yaml. The key is read from config only; it is never logged andnever reaches a post (
sanitize_tool_output).modules: []means every decorated module, whichis the right setting for verification and probably not for production. Narrow with
AI.modules, or withhold paid-quota modules withAI.blocked_modules. These are theoperator's only runtime lever and can only ever subtract. README → "Choosing which
modules the AI can use".
max_tool_calls_per_turn,max_tool_calls_per_thread,max_iterations,max_evidence_chars. The shipped defaultsare conservative; a paid endpoint is the reason they exist.
AI analyst enabled on bind @ai (model <model>)appearsin
matterfeed.log. Absence of that line means the block was not picked up.enabled: False(or deleting the block) and restarting — the@aibind is then never registered and every other command is unchanged.Evidence-mode behaviour (
compactvsfull, and the per-thread@ai full/@ai briefoverride) is documented in README → "Evidence modes".