Skip to content

refactor(cti)!: one CTI store, and the whole bundle in context - #42

Merged
HackedRico merged 12 commits into
mitre:mainfrom
HackedRico:refactor/cti-grounding
Sep 3, 2026
Merged

HackedRico merged 12 commits into
mitre:mainfrom
HackedRico:refactor/cti-grounding

Conversation

@HackedRico

Copy link
Copy Markdown
Collaborator

Description

Four separate breaks between the CTI pipeline and the workflows that consume
it, all of which failed silently.

Author could not see pipeline output. Its picker globbed data/*.json;
the pipeline writes data/outputs_stix/. The list was empty on every install,
and the only file that would ever appear there was data/sessions.json, the
plugin's own chat transcript.

The report's own words never reached the bundle. Grounding stores each
technique's source sentence as x_cti_evidence; the STIX builder read it as
evidence and dropped it. Two unrelated intrusions citing the same technique
produced identical grounding text.

Retrieval discarded most of the rest. A bundle holds tens of objects, not
thousands, so embedding it to pick three threw the rest away for no gain, and
the query was the operator's raw prompt, which rarely contains technique
language. Published long-context work puts the point where retrieval starts to
pay at the reader's context window, far above one report, so the crossover is
now a token budget rather than a k.

Measured on the same APT29 bundle:

before after
objects reaching the prompt 3 of 18 18 of 18
objects carrying ATT&CK ids 1 18
duplicate text in context 49% 0
techniques with report evidence 0 5
embedding model required removed
empty corpus truthy sentinel reached the model falsy, run goes ungrounded
enrich failure logged, run finished green stops the run

The sidebar had two incompatible halves of one feature, each mounted for
one workflow and hidden from the other. Now one CTI section, gated on the
workflow declaring the capability, which both do. Attaching is the whole
configuration, so the capability checkbox is gone: it never gated the panel and
selecting files enabled the capability regardless.

Author also declared optional_servers=[], so the CTI Pipeline server was
filtered out of its sidebar entirely. Optional now, which makes the coverage
loop work: build_adversary reports what the stockpile cannot cover, and that
is what Author should write abilities for.

Two fixes alongside, because grounding is pointless if the output cannot run:
executor name/platform were transposed in both ability builders so nothing
Author created could match an agent, and technique_id now states that an
untagged ability is invisible to build_adversary.

Finally denied_tools on Workflow. Author's signature says not to run
operations while caldera_core hands it core_create_operation, so a
docstring was the entire boundary. Tools are now dropped as the run assembles
its list.

Type of change

  • Bug fix (non-breaking change which fixes an issue)
  • New feature (non-breaking change which adds functionality)
  • Breaking change (fix or feature that would cause existing functionality to not work as expected)
  • This change requires a documentation update

Breaking: capability id rag becomes cti (stored selections prune once);
/plugin/mcp/rag/upload and /rag/list deleted, /stix/* already served the
right directory; payload shape is now {cti: {cti_files: [...]}};
data/stix_cti is no longer a lookup candidate.

How Has This Been Tested?

  • pytest plugins/mcp/tests: 470 passed, 42 of them live-endpoint tests
    against a running Caldera. Suite confirmed inert: data/ count and
    conf/local.yml absence identical before and after.
  • pyflakes clean. npm run build in magma, bundle grepped for the new
    strings, package-lock.json restored.
  • New tests/test_workflow_tool_scope.py, proven to fail by removing
    cti_pipeline_run_operation from the declaration.
  • The capability against a bundle this branch's pipeline produced: 18 objects,
    18 ATT&CK ids, 5 with report evidence, ~7,105 tokens inside the 24k budget.
    Budget 800 trims and announces. Empty corpus returns ''. A bundle with no
    behavioural objects raises.
  • A static check diffing template bindings against script declarations across
    every chat component.

The executor fix was verified against app/objects/c_agent.py in core rather
than by dispatching an ability.

Checklist:

  • My code follows the style guidelines of this project
  • I have performed a self-review of my own code
  • I have made corresponding changes to the documentation
  • I have added tests that prove my fix is effective or that my feature works

cti_technique_grounding anchors each technique against the source text and
stores the surrounding sentence as x_cti_evidence, plus x_cti_confidence for
how strong the anchor was. make_attack_pattern looked for those under the bare
names `evidence` and `confidence`, never found them, and the guards that write
them onto the SDO never fired.

The effect was that a bundle carried only MITRE encyclopedia prose, identical
for every report citing the same technique, and nothing of what the report
actually said. Two reports describing completely different intrusions produced
the same grounding text.

Both spellings are accepted so a hand-written IR keeps working.
create_windows_ability built {"name": "windows", "platform": "psh"} and
create_linux_ability built {"name": "linux", "platform": "sh"}. Both are the
wrong way round.

Caldera matches an ability to an agent with
ability.find_executors(self.executors, self.platform) in
app/objects/c_agent.py, whose signature is find_executors(names, platform):
the name is the runner the agent supports, the platform is its OS. Transposed,
no agent matches, so every ability the author workflow created was
undispatchable no matter how good the command was.

Verified by reading core, not by running an operation.
A bundle this pipeline produces holds tens of objects, not thousands. The
blackcat fixture yields 35 across 137 STIX objects, about 13k tokens, which
fits a modern context window several times over. Embedding that to retrieve
three chunks threw away the other thirty-two, and the query it retrieved
against was the operator's raw prompt, which rarely contains technique
language, so the three it kept were close to arbitrary. Published long-context
evaluations put the point where retrieval starts to pay at the reader's
effective context window, far above a single report.

The rag capability becomes the cti capability: read every attack-pattern and
threat-actor the attached bundles carry, render them in document order, and
hand the lot over. Retrieval, the embedding model, its endpoint, its
credentials and topk are gone, and so is the sentinel string the empty-corpus
path returned, which was truthy and reached the model as intelligence.

A selection larger than cti.context_budget_tokens is trimmed from the end,
keeping objects whole, and the prompt states how many were dropped so the
agent can call a CTI tool for the rest.

Two behaviours change beyond the swap:

- The ATT&CK id now reaches the prompt. The old chunk was "name | description"
  and left external_id in external_references, so the agent could not cite or
  pass an id it had just been shown. 34 of 35 objects in the fixture carry one.
- A capability the operator enabled no longer fails open. Every enrich
  exception was logged at WARNING while the run finished green and ungrounded,
  which looked identical to a grounded run. It now stops the run.

author also gains cti_pipeline as an optional server. It declared
optional_servers=[], so the CTI tools were filtered out of its sidebar and it
was structurally cut off from the pipeline whose output it is meant to build
from.
Author's picker was backed by GET /plugin/mcp/rag/list, which globs
plugins/mcp/data/*.json. The pipeline writes to data/outputs_stix/, so the
picker listed a directory the pipeline never touches and Author's file list
was empty on every install. The only file that would ever appear there is
data/sessions.json, the plugin's own chat transcript.

GET /plugin/mcp/stix/list already served the right directory with better
metadata, so the duplicate is deleted rather than repointed, along with
/plugin/mcp/rag/upload, which wrote bundles into the same unreachable place.
Nothing globs data/*.json now, so sessions.json stops being offered as intel.

The sidebar had grown two incompatible halves of one feature: a Capabilities
checkbox plus a RAG panel for Author, a CTI section plus an embedding-config
block for Plan and Execute, each mounted for one workflow and hidden from the
other. Both are replaced by a single Intel section gated on the workflow
declaring the cti capability, which both do.

Gone with it: the embedding model, endpoint, key, TLS toggle and TopK inputs,
in both copies. They configured a retrieval step that no longer exists. The
Temperature, Tool calls and Max tokens inputs went too; they reached the
payload and were read by nobody.

Attaching is now the whole configuration, so the capability checkbox is gone
as well. It never gated the panel, ticking it alone did nothing, and selecting
files enabled the capability whether it was ticked or not, so it taught a
control relationship that did not exist.

The nine cti_rag_* keys the sidebar emitted are replaced by one attached_intel
list. The old emit was refreshed by a watch that observed two of its nine
sources, and the panel holding the other seven only rendered after a bundle
was selected, so those inputs were stale by construction.

The listing now reports how many attack-pattern and threat-actor objects each
bundle carries, and the picker shows the total for what is attached. A
grounded run and an ungrounded one were previously indistinguishable.
The Author signature says "Do NOT run operations or deploy infrastructure",
but caldera_core is a required server and exposes core_create_operation and
core_add_link_to_operation, and cti_pipeline is now optional there and exposes
cti_pipeline_run_operation. Server scoping is all-or-nothing, so Author needed
most of caldera_core while having no way to refuse three of its tools. That
made the docstring the entire boundary between an authoring run and a live
operation on a real agent.

denied_tools on Workflow closes it. The tool is dropped while the run assembles
its list, so the agent is never offered it and cannot call what it cannot see.
Filtering happens before the name-collision check so a denied name does not
occupy the namespace either.

Author denies the three operation starters and keeps everything it needs,
including cti_pipeline_build_adversary: authoring an adversary is its job,
running one is not. Plan and Execute denies nothing, which is the point of it.

Read via getattr in mcp_svc, since a workflow contributed by another plugin
may predate the field.

A denylist fails open, so the test asserts coverage rather than a fixed list:
it reads both servers for tools matching the operation-starting naming
convention and fails if Author can reach one it does not deny. Verified it
fails by removing cti_pipeline_run_operation from the declaration, which named
exactly that tool.
Three things the refactor left behind, found reviewing its own diff.

context_budget_tokens was readable but not writable. profile_defaults does not
apply the allowlist so the capability read it fine, but set_config gates the
cti section on WORKLOAD_OVERRIDABLE and the key was not in it. A deployment on
a short-window model could not lower the budget, which the comment in
default.yml told them to do.

A bundle deleted on the CTI page stayed attached. ChatRagPanel used to drop
selections missing from the listing; nothing did after it was deleted, so the
name survived in localStorage and the next run failed in _enrich with
"attached CTI bundle not found". The listing now prunes what is gone.

The sidebar emitted attached_intel into workflowContext while ChatWorkflow
derived the same value from the session ref and overwrote it. That is the
two-sources-for-one-value shape this branch removed, reintroduced in
miniature, so the emit and the whole workflowContext channel go.

Also removed with them: embed_model, rag_topk and rag_embed_model from the
config allowlists, the embed_model fallback in llm_client, and the block in
mcp.vue that seeded capabilitySettings.rag from /defaults keys that no longer
exist. All configured a capability that is gone.
CTI is the vocabulary operators already use, so the section that holds
attached bundles carries it on both workflow sidebars. The controls inside
still act on intel, which is the object: you attach intel, in the CTI section.

The heading is what a rebuild of magma changes, so this needs one.
author declared its own cti_context description while plan_execute used the
shared CTI_CONTEXT_DESC, so the two workflows were told different things about
the same field. author's copy said only "relevant CTI information including
attack patterns, techniques, and threat actor behaviors" and dropped the
guardrails: that the field is technique-level only and never carries hosts,
accounts, domains, software or infrastructure, and that it is grounding rather
than permission to invent what is missing.

Author is the workflow that writes commands into abilities, so it is the one
that most needs to be told not to read targeting facts out of a report.
The two grounded signatures were still DSPyCalderaFactoryClientWithRAG and
DSPyCalderaPlannerClientWithRAG, and the stage tag still read "executing
DSPy ReAct with RAG", for a code path that no longer retrieves anything. The
MLflow stage is operator-visible, so it was telling them the run did something
it did not.

data/stix_cti also survived in three _resolve_pipeline_file candidate tuples.
No code writes it: upload was redirected to outputs_stix with a comment saying
writing there made every bundle invisible to list, get, download and delete.
Keeping it as a lookup candidate meant a stale copy in a directory nothing
maintains could win over pipeline output.

The 'inputs' candidate goes with it. No such directory exists.
technique_id is optional on both ability builders and nothing derives it from
technique_name, so the model can create an ability with no ATT&CK id at all.
cti_pipeline_build_adversary matches stockpile abilities on technique_id, so
an untagged ability is invisible to it: the operator authors an ability to
cover T1059.001, re-runs coverage, and still sees T1059.001 unmatched.

Stated in the field description rather than enforced. The id belongs to the
technique, and the model has it whenever the id came from cti_context, so this
gives it the reason without hardcoding a requirement that would break the
cases where no technique is known.
format_plan_execute_context still read workflow_context["selected_stix_files"]
and "cti_uses_rag", but the GUI stopped sending either when the nine emitted
cti_rag_* keys collapsed to one attached_intel list. Neither key arrives now,
so operation_context was always empty and the planner was never told what the
operator attached. It could not name a source or pass a real filename to
cti_pipeline_build_adversary, so it would have had to guess one.

Dropped cti_uses_rag rather than renaming it. It announced that bundles were
"also enabled as RAG context", describing a retrieval step that no longer
exists and a mode the operator could not turn off.

Note that the suite did not catch this: nothing asserts the GUI's
workflow_context keys against what the prompt formatter reads, and both sides
were internally consistent.
Replacing three sidebar sections with one removed a contiguous block that also
held eight definitions the surviving template still references. Vue resolves a
missing binding to undefined and renders nothing, silently, so neither pyflakes
nor the suite could see it and the section looked correct until an operator
attached a bundle.

Broken by it:

- timesIcon, so the detach button on an attached bundle and the picker's close
  button rendered as empty squares. This is what the operator hit.
- folderOpenIcon on the Attach button and collapseIcon on the rail toggle.
- modelToggleIcon, endpointProfiles, selectedEndpointProfileName,
  endpointProfileDraftName and modelConfigSummary, which between them are the
  whole LLM Endpoint section that Plan and Execute renders: the profile
  dropdown, the draft-name input and the summary label.

All eight are restored verbatim from 02ad21a^, since none of them had anything
to do with retrieval.

Found by diffing template bindings against script declarations rather than by
reading, because reading is what missed them the first time.
@HackedRico
HackedRico merged commit 560c738 into mitre:main Sep 3, 2026
3 checks passed
@HackedRico
HackedRico deleted the refactor/cti-grounding branch September 3, 2026 16:33
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