Skip to content

Conversational AI analyst: use the command modules as LLM tools - #288

Open
spierenburg wants to merge 18 commits into
mainfrom
feat/ai-analyst
Open

Conversational AI analyst: use the command modules as LLM tools#288
spierenburg wants to merge 18 commits into
mainfrom
feat/ai-analyst

Conversation

@spierenburg

@spierenburg spierenburg commented Jul 13, 2026

Copy link
Copy Markdown
Collaborator

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's
own 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):

  1. Manual commands are unchanged — people still run @ioc themselves. On startup the
    bot appends the AI bind: self.binds = sorted(set(self.binds + [self.ai.bind])) — it
    never replaces @ioc, @crtsh, @abuseipdb, … In handle_post the AI is a separate
    elif command == self.ai.bind: branch that sits before the existing module-fanout
    else:. Every other command — @ioc included — falls through to the exact same
    type-routed fanout it used before. The AI sits next to the manual commands, not in
    front of them.

  2. Opting a module into the AI doesn't change that module's own behaviour. The seven
    starter modules gained a single @cmdutils.aitool line on process(), next to the
    @cmdutils.handles(...) from Declare accepted indicator types on process(), not in defaults.py #289; their BINDS and declared types are untouched, so
    @ioc 8.8.8.8 still fans out to them identically. Their defaults.py files are
    byte-identical to main. The flag is read only by the AI's registry/executor to
    decide what the AI may call; it is invisible to the direct-command path.

  3. With no AI: config block, the feature does not exist at runtime. self.ai stays
    None, the @ai bind is never registered, ai_analyst.py is never even imported. The
    whole-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 = True in each module's
defaults.py, with a settings.py override. #289 then moved ACCEPTS out of
defaults.py and onto process() 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 that
could 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_modules are the operator's only runtime lever and they can only subtract.
Two tests pin this down: test_the_ai_optin_is_not_settable_from_config fails if
AITOOL reappears in any defaults.py, and the wiring test asserts settings.AITOOL
is 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.py example and a test, neither of which the tree
has today.


3. What is KEPT (unchanged behaviour)

Kept How it stays intact
@ioc and every existing @/! command Route through the unchanged module-fanout else in handle_post; the @ai branch is a separate elif that only matches the AI bind.
The 7 starter modules' own commands Only a @cmdutils.aitool line was added; BINDS/declared types/logic untouched, and their defaults.py files are byte-identical to main.
Indicator type-routing (@cmdutils.handles, #287/#289) Reused as-is; the AI checks the same declaration before calling a module.
User channel ACLs (isallowed_module) The AI calls it too — it can never do a lookup the user themselves couldn't.
Module execution (run_module) The AI calls the same self.run_module the manual path uses (matterbot.py:924 vs :950) — one runner, not two.
Disabled deployments No AI: block → nothing changes, no new log lines, no @ai bind.
Dependencies No new hard dependency. requests (already used) is imported lazily; ai_analyst.py imports on stdlib + commands/cmdutils alone, so the dependency-free CI stays green.

4. What is ADDED

Added Lines What it is
ai_analyst.py (new) +1226 The whole feature, self-contained: indicator extraction & credential redaction, tool-schema building, thread→case-state reconstruction, an OpenAI-compatible LLMClient (thin requests wrapper), the guarded executor _run_tool_call, and the agent loop handle.
matterbot.py (wiring) +154/−13 Reads the per-module AI opt-in off process() into the command registry; constructs an AIAnalyst only when AI.enabled; injects four thin callbacks (_ai_registry, _ai_run_tool, _ai_get_thread, _ai_post); adds the @ai dispatch branch and the --AI config parse. (The 13 deletions are earlier signature extensions, e.g. send_message gaining a props arg — no behaviour removed.)
commands/cmdutils.py +23 @cmdutils.aitool, the developer opt-in decorator, alongside handles() from #289.
config.defaults.yaml +39 An annotated, off-by-default AI: block (enabled: False).
7 × commands/*/command.py +1 each @cmdutils.aitool on abuseipdb, circlpdns, crtsh, ipinfo, malwarebazaar, threatfox, urlhaus — a curated read-only starter set covering every indicator type. Their defaults.py files are unchanged.
README.md +121 User + operator documentation, including the honest prompt-injection blast-radius statement.
tests/ +1905 test_ai_analyst.py, test_matterbot_ai_wiring.py, and AIToolContractTests314 tests total, all green.
pyproject.toml +6/−2 Registers ai_analyst as a module and corrects requires-python to >=3.11 — already true before this change (asyncio.timeout is 3.11+); the AI feature did not newly raise the floor.
docs/superpowers/… +3150 The design spec and implementation plan (reference; not runtime).

5. How an @ai turn ties together (end to end)

user posts "@ai what about 8.8.8.8?"
      |
      v
handle_post -> matches the @ai bind -> AIAnalyst.handle(...)        [matterbot.py]
      |
      v
fetch the thread   (_ai_get_thread -> mattermostdriver.posts.get_thread
                    -> ai_analyst.normalise_thread)                 <- the THREAD is the case
      |
      v
reconstruct case state from the thread alone:                      [ai_analyst.reconstruct]
   authorized indicators . pending proposals . evidence mode . tool budget spent
   (nothing is persisted; a restart/redeploy costs zero session loss)
      |
      v
build tool definitions, narrowed to:  operator-allowed @aitool modules
                                     x indicator types actually in play
      |
      v
LLM loop  (bounded by max_iterations):                             [ai_analyst.handle]
   model -> proposes tool call(s)
        -> _run_tool_call GATE (see section 6) -- refuse --> model told why, no module runs
                    | pass
                    v
             self.run_module(...)   <-- the SAME runner manual @-commands use
                    |
                    v
             redact (sanitize_tool_output) + length-cap
                    |
                    v
             fed back to the model as <untrusted_tool_result> ...
   model -> plain answer -> posted as the analyst narrative (+ raw evidence in `full` mode)

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 the
gate and it is just the manual command path; the gate is the entire security surface.


6. The security model: one guarded door

_run_tool_call is the only path from "the model wants X" to "X happens." Threat-intel
results 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:

  1. Budget — per-turn and per-thread call caps (a refused call costs no budget).
  2. Operator allow/block-listAI.modules / AI.blocked_modules.
  3. @cmdutils.aitool — the module opted in at the developer level at all.
  4. ACLisallowed_module: the AI is never a way around a permission the user lacks.
  5. Typecmdutils.classify produced a real indicator.
  6. Authorization — the analyst named or approved this indicator (see below).
  7. @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

  • An OpenAI-compatible endpoint (Ollama / vLLM / LiteLLM / cloud) with a model that
    supports native function-calling.
  • base_url (incl. /v1), model, api_key ("ollama" for a keyless local endpoint).
  • A running Mattermost + the bot account (Steps 7.3–7.6).
  • The bot's runtime interpreter (has requests/mattermostdriver) — call it PYTHON.
    Not the sys.modules['requests']=None dependency-free check; that is a CI guard.
  • $PYTHON must be 3.11+matterbot.py uses asyncio.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>" | head

7.1 GATE — confirm the model actually tool-calls

cd <repo>
$PYTHON - <<'PY'
from ai_analyst import LLMClient, build_tool_definitions
registry = {'crtsh': {'binds': ['@crtsh'], 'accepts': ['domain'],
                      'help': {'DEFAULT': {'desc': 'Query crt.sh for certificates.'}},
                      'aitool': True}}
tools = build_tool_definitions(registry, {'domain'})
client = LLMClient(base_url='<base_url>', api_key='<api_key>', model='<model>', timeout=60)  # timeout is required
reply = client.chat([{'role':'user','content':'Look up the certificates for evil.example.com.'}], tools)
print('tool_calls:', reply.get('tool_calls'))
assert reply['tool_calls'], 'THIS MODEL DOES NOT TOOL-CALL — pick another one'
print('PASS')
PY
  • Pass: non-empty tool_calls like {'name':'crtsh','arguments':{'query':'evil.example.com'}}.
  • Empty → model can't function-call → unsuitable (hard requirement, no fallback).
  • LLMError → endpoint/auth/URL (base_url needs /v1). ModuleNotFoundError: requests
    → you used the dependency-free interpreter; use the runtime one.
  • Do not touch Mattermost until this prints PASS.

7.2 Point a scratch config at the endpoint

Copy the shipped AI: block from config.defaults.yaml into your config.yaml (native
YAML, same shape as the Matterbot:/Modules: blocks). Set enabled: True and the three
endpoint fields; leave modules: [] so the whole starter set is available.

Never commit credentials — confirm git check-ignore config.yaml prints the path.

Start the bot: $PYTHON matterbot.py (add --debug for console logging).
Expect on startup: AI analyst enabled on bind @ai (model <model>) in matterfeed.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 footer
Queried: circlpdns(8.8.8.8) → ok, …. Log: one ai: tool call module=… arg=8.8.8.8 … per lookup.

7.4 It PROPOSES a pivot, and only queries it after "yes"

  • It should name a newly-discovered indicator and ask, not query it. A self-pivot
    attempt shows ai: DENIED … reason=unauthorized — that denial is the design working.
  • Reply yes → it now queries it (a matching ai: tool call … line).
  • In a fresh thread, reply no, leave it to a proposal → it stays blocked (no tool call).

7.5 full evidence 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 not
replayed into the model's context (token cost doesn't balloon). @ai brief … switches back.

7.6 Long-reply split, guardrail logs, and disabled-inertness

  • Force a reply over Matterbot.msglength; next turn treats the split as one assistant
    message and charges the tool budget once.
  • grep 'ai: ' matterfeed.log | tail -40ai: tool call … per executed call,
    ai: DENIED … reason=unauthorized for blocked pivots, and no API keys / bearer
    tokens / key-bearing URLs anywhere in the log, any post, or any LLM request.
  • Set enabled: False, restart, run @ioc 8.8.8.8 → normal behaviour, no
    AI analyst enabled line, @ai does nothing. (This is the "kept" guarantee, live.)

7.7 Repo checks (for the record)

$PYTHON -m unittest discover -s tests             # Ran 314 tests ... OK
ruff check --select F821 $(git diff --name-only main...HEAD -- '*.py')   # All checks passed!
$PYTHON -c "import sys; sys.modules['requests']=None; import ai_analyst; print('OK')"   # dep-free import

Expected ruff result, so a red line doesn't stop you: the F821 gate above (the one
CI actually enforces, see .github/workflows/f821-delta.yml) passes on every changed file.
A full ruff check ai_analyst.py matterbot.py reports 1 errorF841 Local variable 'username' is assigned to but never used at matterbot.py:535. That is pre-existing on
main
(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

  1. Retarget once Route @ioc lookups by indicator type (foundation for #284) #287 mergesdone: 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 this
    branch is now rebased onto main and targets main.
  2. Section 7 (the live-endpoint run) is still outstanding — see the note at the top
    of that section. Nothing below it should be read as verified against a real model.
  3. Delete the scratch config.yaml credentials, or confirm the file stays untracked.

Stop-and-report (do not merge if seen live)

  • A pivot that runs without a human "yes" → authorization breach.
  • An API key / token visible in any post, log line, or LLM request → redaction breach.

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 at
runtime. To actually enable it:

  1. Check the interpreter. The deployment must run Python 3.11+ (see 7.0). This was
    already true before this PR (asyncio.timeout); pyproject.toml now states it honestly
    instead of claiming >=3.9.
  2. Add the AI: block to the production config.yaml, copying the annotated block from
    config.defaults.yaml and setting enabled: True plus base_url / model / api_key.
    Full walkthrough: README → "Enabling it". Requirements: README → "Requirements".
  3. Keep the key out of git. config.yaml is git-ignored — confirm with
    git check-ignore config.yaml. The key is read from config only; it is never logged and
    never reaches a post (sanitize_tool_output).
  4. Choose the exposure deliberately. modules: [] means every decorated module, which
    is the right setting for verification and probably not for production. Narrow with
    AI.modules, or withhold paid-quota modules with AI.blocked_modules. These are the
    operator's only runtime lever and can only ever subtract. README → "Choosing which
    modules the AI can use"
    .
  5. Set the cost bounds for the deployment: max_tool_calls_per_turn,
    max_tool_calls_per_thread, max_iterations, max_evidence_chars. The shipped defaults
    are conservative; a paid endpoint is the reason they exist.
  6. Restart the bot and confirm AI analyst enabled on bind @ai (model <model>) appears
    in matterfeed.log. Absence of that line means the block was not picked up.
  7. Roll back by setting enabled: False (or deleting the block) and restarting — the
    @ai bind is then never registered and every other command is unchanged.

Evidence-mode behaviour (compact vs full, and the per-thread @ai full / @ai brief
override) is documented in README → "Evidence modes".

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
spierenburg changed the base branch from feat/284-ioc-type-routing to main August 4, 2026 21:01
@spierenburg
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.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant