Skip to content

chores(cti)!: remove dead infrastructure capabilities and add a CALDERA fact bridge - #28

Merged
HackedRico merged 32 commits into
mainfrom
refactor/remove-range
Aug 28, 2026
Merged

chores(cti)!: remove dead infrastructure capabilities and add a CALDERA fact bridge#28
HackedRico merged 32 commits into
mainfrom
refactor/remove-range

Conversation

@HackedRico

Copy link
Copy Markdown
Collaborator

Description

Removes capabilities that could not run. They resolved against a range plugin
that is not present, so every one of these paths failed at runtime:

  • _range_feature_catalog imported plugins.range.app.onprem_svc, which does
    not resolve
  • deploy_range, wait_for_range, apply_features and
    import_ansible_feature POSTed to /plugin/range/* endpoints that 404
  • The ae-e2e deploy stage raised RuntimeError: OnPremService not found
  • image_candidates was always empty; stage 4 logged "image catalogs absent"
    on every run
  • All 4 tests in test_range_integration.py failed

The topology layer existed to feed those paths. Nothing in CALDERA core reads a
topology object, so it went with them. CTI ingest now produces context for agent
reasoning rather than a deployment blueprint.

Removed

  • The infrastructure MCP tools, REST surface, deploy-spec synthesiser, GUI
    Build Range section, provisioning workflow stages and prompts
  • cti_topology_inference, cti_pipeline_stage4_topology, cti_pipeline_stage3,
    cti_infra_aggregation, cti_refinement, cti_knowledge_graph
  • The custom topology SDO and its host naming, which nothing in CALDERA core
    reads
  • Provisioning controls from the sidebar (identity checkboxes, agent selector,
    CTI entry host) and their prompt plumbing. These had one consumer, the prompt
    formatter, and no tool acted on them.

Added

  • app/utilities/cti_caldera_facts.py maps a STIX bundle onto CALDERA facts
    using the traits stockpile abilities actually consume
  • cti_pipeline_build_source MCP tool

The pipeline is now stage 1 to stage 2: raw to IR to STIX. The cti_pipeline MCP
server exposes 6 tools.

build_source previews by default. remote.host.ip is a live target for
stockpile abilities that nmap and SMB-mount it, and a report names the
attacker's C2 beside the victim estate, so the tool reports which addresses are
publicly routable and requires an explicit commit=true.

Type of change

  • Breaking change (fix or feature that would cause existing functionality to not work as expected)
  • This change requires a documentation update

Breaking: the custom topology SDO, outputs_topology/, the
cti_pipeline_build_topology and cti_pipeline_refine_topology tools, the
stage3-infra and stage4-topology steps, and the range key on
GET /plugin/mcp/features are all removed.

How Has This Been Tested?

Full suite against main in an identical harness, excluding the four files that
need a live CALDERA on localhost:8888:

failed passed
this branch 48 286
main 52 273

Zero regressions. The 4 removed failures are tests/test_range_integration.py,
already failing on main. Remaining failures are the five modules missing from
origin/CTI and are identical on both sides.

17 new tests, all passing.

build_source verified end to end against a running CALDERA: preview returned
25 facts and flagged 2 routable addresses, commit created the source, and
GET /api/v2/sources/<id> confirmed 25 facts with origin IMPORTED. Test source
deleted afterwards.

Also verified: compileall clean, flake8 --select=E9,F63,F7,F821 clean, 26 MCP
tools register, all 25 hook.py routes resolve to live methods, both changed
SFCs compile under magma's own compiler.

Not verified: stage 1 IR extraction end to end, which needs an LLM that is not
configured in this environment.

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

…oint

conf/default.yml shipped a MITRE gateway hostname as the default
api_base for both the llm and cti profiles. Both now ship empty and name
MCP_LLM_API_BASE through a new api_base_env key, mirroring the existing
api_key_env pattern, so no deployment's endpoint is baked into the repo.

An unresolved api_base is now refused rather than passed through.
dspy_lm_kwargs_from_settings drops a falsy api_base entirely, which
leaves LiteLLM routing openai/* models to its built-in
https://api.openai.com/v1, so a deployment that missed the env var would
have quietly sent CTI prompts to a provider it never chose.
resolve_llm_config now raises, matching the api_key check beside it and
the identical check get_llm_provenance already performs on the cti path.

Whitespace is stripped before normalization because
normalize_openai_api_base treats a blank but truthy string as a real URL
and would turn it into the relative path "/v1".

Also removes the hostname from two tests and one code comment.

BREAKING CHANGE: deployments must set MCP_LLM_API_BASE in plugins/mcp/.env
or pin llm.api_base in conf/local.yml. Runs raise ValueError otherwise.
llm_defaults() was welded to the `llm` block while get_llm_provenance()
took a profile argument, so the two paths each grew their own copy of the
api_key_env and api_base_env lookups. LLMClient carried a third, with a
comment noting it existed only because that path bypassed config.py.

resolve_env_indirection() in llm_client.py is now the single
implementation. It reads the *_env indirection for every field named in
ENV_INDIRECT_FIELDS, consumes those keys so they never reach the settings
dict handed to DSPy, strips values before normalization, and coerces
provider with `or` rather than setdefault so an explicit yaml null no
longer survives into the callers that branch on it.

config.py gains profile_defaults(profile) and keeps llm_defaults() as a
thin alias, so the cti profile resolves through exactly the same code as
the llm profile.

The api_key fallback in _openai_compatible_generate is removed: llm_cfg
now arrives resolved, so the workaround it documented no longer applies.
The guard sat inside `if provider == 'openai_compatible'`, so a request
body naming any other provider skipped it while model stayed
openai/gpt-oss-120b. dspy_lm_kwargs_from_settings then dropped the empty
api_base and LiteLLM routed the call, and the deployment's key, to its
built-in https://api.openai.com/v1.

provider reaches resolve_llm_config straight from the /execute request
body with no whitelist, and conf/default.yml locks only model and
api_base, so a lm_config of {"provider": "ollama"} was enough to bypass
the check. Only the normalization is provider-specific; the requirement
is not, and the ollama path needs a base to post to just as much.

Normalizes provider before branching as well, so an explicit yaml null
no longer reads as "not openai_compatible".
The override filter tested `value not in ("", None)` with no strip, so a
field containing only spaces counted as a real override. An api_base of
"   " is truthy, normalize_openai_api_base turns it into the relative
path "/v1", and that satisfies the api_base guard, so the run failed
deep inside the HTTP client instead of at the resolver with an
actionable message. A pasted URL with a trailing space had the same
shape and became "https://host/v1 /v1".

Both modelSelector.vue and the chat sidebar bind these inputs with a
bare v-model, so nothing trims them client side either. The yaml and env
tiers were already stripped in resolve_env_indirection; this makes the
stated invariant true on all three.
All four dspy.LM() constructions in the plugin take their kwargs from
dspy_lm_kwargs_from_settings, which makes it the only place that can
guarantee the fallback never happens:

  dspy_env.py:132              plan_execute.py:60
  workflows/author.py:248      utilities/llm_client.py:172

resolve_llm_config guards the /execute path, but plan_execute and author
fall back to llm_defaults() when no lm_obj is passed, and llm_defaults()
returns an empty api_base by design. That was safe while yaml always
carried a base and is not any more. plan_execute then forwards the empty
DSPY_API_BASE to every spawned MCP subprocess, which repeats it.

Dropping the falsy api_base was what let LiteLLM apply its own default,
so this checks before assembling the kwargs rather than after. The
`and settings.get("api_base")` on custom_llm_provider goes away with it,
since a base is now guaranteed to be present.
llm_configured came from the api_key alone, which was a complete
precondition until api_base moved out of the shipped yaml. Pull this
branch onto a deployment whose .env carries only MCP_LLM_API_KEY and the
splash page shows a green "configured" tag, omits the empty base rather
than showing it blank, suppresses the warning banner, and then every run
fails in the resolver. The suppressed banner also claimed that setting
MCP_LLM_API_KEY was sufficient, which is no longer true.

The page now reports the specific env vars that failed to resolve, so
the diagnostic names the thing the operator has to fix instead of a
fixed string. This is the one page whose stated job is telling the
operator whether the plugin is wired up.
Nothing in the suite imported app/config.py, so both guards could be
deleted with a byte-identical test result. These 26 cases pin the three
merge tiers, the shared env indirection, and the two refusals.

Mutation checked rather than assumed. Re-nesting the api_base guard under
`if provider == "openai_compatible"` fails the three
test_api_base_required_for_every_provider cases; disabling the dspy_env
guard fails all four TestDspyLmKwargs refusal cases; restoring either
makes them pass again.

_load_defaults is stubbed via a fixture so the tests read neither
conf/default.yml nor the developer's .env.
The env var is required now but appeared only in .env.example. Install
step 5 still said "configure model credentials through the UI or
environment/local config" without naming either variable, so a new
operator had no way to reach a working install from the README alone.

The local.yml advice was worse than absent. load_config returns local.yml
INSTEAD of default.yml with no merge, so an operator following the old
"pin llm.api_base in conf/local.yml" wrote a two-line file, lost
api_key_env with it, and was then told to set MCP_LLM_API_KEY, which they
had already set correctly. Both the yaml comment and the README now say
to copy the whole file and spell out that local.yml replaces rather than
merges.

The README sample also nested the llm block under a top-level `mcp:` key
that nothing reads, and omitted the *_env keys entirely. Corrected to
the shape config.py actually loads.
Several comments added on this branch restated what the code already
says or carried three lines of history where one would do. Trimmed to
the reason a reader cannot infer from the line below it. No behaviour
change; tests unchanged and still passing.
Consolidating both fields under one precedence inverted api_key. On main
a named api_key_env always won; the shared resolver let a yaml literal
shadow it, so a key written to conf/local.yml silently pinned itself and
rotating MCP_LLM_API_KEY did nothing.

Secrets now resolve env-first, endpoints stay yaml-first. Verified
against main's semantics across all four cases.

Also covers the yaml-side strip, which no test reached.
The Save button posts api_key with every save and set_config wrote the
payload verbatim, leaving the key in plaintext on disk beside tracked
config. Secrets are stripped from every section on write, so a save also
clears a key an earlier build left behind.

Reload now goes through reload_config: load_config is lru_cached, so the
old call read back the pre-write contents.
globalConfig and every saved endpoint profile were serialized to
localStorage verbatim, so the key sat in plaintext readable by anything
on the origin. Stripping at the storage boundary covers both, and load
purges a key an earlier build already wrote.

The Save payload no longer sends api_key either, since set_config
refuses to persist it.
get_llm_provenance was the last resolver still gating its api_base raise
on openai_compatible, contradicting the invariant the other two now hold
and test for.

author.run() prechecked only api_key, so a partial lm_obj failed at
dspy.LM() after the AsyncExitStack had already spawned every MCP
subprocess. Both credentials are now checked together, before that.

Adds provenance guard tests and the first coverage of the readiness
payload, including that neither leaks the key.
It still described two credentials and claimed the UI never writes to
disk. Now names MCP_LLM_API_BASE, records that set_config strips secrets
from conf/local.yml, and states the per-field precedence.
requirements.txt asked for an unpinned mcp, so a fresh install resolves
to 2.x, which dropped mcp.server.fastmcp in favour of MCPServer. Both
mcp_server.py and app/mcp_server.py still import FastMCP, so every stdio
subprocess died at import and the client reported only "Connection
closed" from the closed pipe.

1.29.1 is the newest release that still ships fastmcp. Pinning restores
the servers without a port; migrating to the 2.x server API is a
separate piece of work.
author.py and plan_execute.py called set_tracking_uri, set_experiment and
dspy.autolog at module scope. set_experiment is a network round trip, so
importing either module blocked whenever the tracking server was down,
which put both workflows out of reach of a test, a linter, or anything
running before MLflow comes up. llm_client.init_mlflow already documents
the rule these two broke.

Both now initialise lazily on the first run() call, which makes the
author credential precheck testable for the first time.
globalConfig.apiBase is restored from localStorage, and applyServerDefaults
only fills it when blank, so a stored value survives forever and ships as a
UI override on every /execute, where it outranks the env-resolved base. A
browser that cached the old vendor endpoint kept reaching it after the URL
was removed from the repo, and editing .env did nothing for that user.

The stored blob now carries a schema version, and loading a pre-v2 one
drops the ambient apiBase so the server default applies again. Named
endpoint profiles keep theirs: those are explicit user artifacts.
The first pass walked only the top level of each section. set_config
accepts arbitrary top-level keys, and a caller posting the documented
{"config": {...}} envelope produces {"config": {"cti": {api_key: ...}}},
which the one-level scrub walked straight past. Confirmed against a real
local.yml that still held a key after a save.

Now recursive through dicts and lists.
The range plugin does not ship with this open-source build, so the three
helpers behind GET /plugin/mcp/features all resolved against a service
that is never registered and an import that cannot succeed.

Removes _range_supported_providers, _range_images_for_provider and
_range_feature_catalog, along with _count_catalog_items, which had no
other caller. The features() response loses its top-level "range" key.

BREAKING CHANGE: GET /plugin/mcp/features no longer returns a "range"
key. No in-repo consumer reads it.
…ng stages

cti_deploy_spec.py existed only to build a POST body for
/plugin/range/onprem/manage/deploy, and read its image catalogs and
Ansible playbooks straight out of plugins/range, which is absent here.

The ae-e2e workflow loses deploy_spec, deploy, meta and sandcat. Those
four stages hardcoded plugins/range paths and a microvm runtime base, so
none of them could run. STAGES is trimmed to match the surviving methods,
since run() dispatches through getattr(self, f"_stage_{name}") and a
stale entry would raise AttributeError at request time.

preflight no longer requires range_svc. The topology stage keeps
build_range_topology but passes an empty image catalog.

BREAKING CHANGE: the ae-e2e workflow no longer provisions infrastructure.
It runs preflight, cti, topology, agents, adversary, operation,
detections and report against an already-running Caldera.
…ne server

Five of the thirteen tools existed only to drive plugins/range over HTTP:
range_capabilities, wait_for_range, import_ansible_feature, deploy_range
and apply_features. Every one targeted a /plugin/range/* endpoint that
does not exist in this build.

Removing them orphans _range_get_json and _range_post_json, so those go
too. The image catalog helpers stay for now because build_topology and
fuse still thread a catalog into build_range_topology.

The server now exposes seven tools: ingest_cti, build_topology, fuse,
refine_topology, run_operation, validate_detections and wait_for_agents.

BREAKING CHANGE: the cti_pipeline MCP server no longer exposes range
provisioning tools.
image_candidates was the only substrate-derived field in the topology
SDO, and it resolved against plugins/range/conf/onprem_images.yml. With
no range plugin the catalog never loaded, so the field was already
always empty and stage 4 logged "image catalogs absent" on every run.

build_range_topology and derive_hosts lose their images_catalog
parameter, and _enrich_topology_with_ae_plan and _process_bundle lose
theirs. The catalog loaders go with them in both stage 4 and the
cti_pipeline MCP server.

Everything else in the topology stays: hosts, subnets, roles, platforms,
services, software, vulnerabilities, attack surface and identities are
all inferred from the STIX bundle and need no deployment substrate.
The planner prompts described a Range execution contract covering
providers, hypervisors, image catalogs, Ansible feature playbooks and
microvm substrate status. None of that context can ever be populated
without the range plugin, so the model was being told to plan against
capabilities that do not exist.

Rewrites the two agent docs and the operation-context formatter around
what this build actually does: ingest CTI, infer topology, build an
adversary, run an operation against agents that have checked in.

The agent_start and identity_options blocks are kept. They read as range
context because of where they sit, but both are generic operator
preferences consumed independently of any deployment substrate.

Replaces tests/test_range_integration.py with
tests/test_discovery_and_tool_merge.py, preserving the generic coverage
of discover_mcp_servers and _safe_load_metadata. The new test asserts the
registry is exactly caldera_core plus cti_pipeline.
The section drove /plugin/range/onprem/providers, /images, /features and
/microvm/substrate-status. With no range plugin every one of those calls
failed and the panel rendered "Range unavailable".

The identity checkboxes and the Agent selector were nested inside
v-if="buildRange" but are not range controls: prompts/plan_execute.py
reads identity_options and agent_start on their own. Both are re-parented
into the CTI section so they stay reachable, and the CTI section is
renamed from "CTI / Range".

Drops the range refs, computed properties, loaders and watchers from the
script, the range keys from the emitted workflow context, and the now
unused .provider-row styles.
Comment-only and documentation change. No executable line moves.

Many extraction rules were justified by naming the range plugin as the
consumer, for example "the range plugin's LLM deploy step needs the
version to pre-stage binaries". The rules themselves are sound and stay
as they are, so the comments now describe what the data is for rather
than which absent plugin wanted it.

The README loses the Range Integration section, the range-aware planning
bullet, the range entry in the example plugin list, and the range
troubleshooting note.
The SDO describes an inferred victim topology, not a deployable range, so
the vocabulary no longer matches what it holds.

  x-cti-range-topology -> x-cti-topology
  x-cti-range-host     -> x-cti-host
  build_range_topology -> build_topology
  host name prefix "range-" -> "host-"

The host prefix had three coupled sites with nothing holding them
together: derive_hosts builds the name, derive_networks independently
rebuilds it to map back to an infrastructure id, and stage 4's _host_key
strips it so AE-plan hostnames match. Drift between them does not raise,
it silently moves every host into "unanchored-net" with a null
anchor_identity, so the topology still looks well formed. Both producers
now read HOST_PREFIX.

_host_key still strips "range-" as well, so topologies written by an
earlier build keep matching.

Adds test_host_names_stay_anchored_to_their_infrastructure_sdo, which
asserts on the identity anchoring rather than on membership. Verified it
fails when the drift is injected and passes when it is not; asserting on
membership alone does not catch this.

BREAKING CHANGE: consumers matching the x-cti-range-topology or
x-cti-range-host type literals, or the "range-" host prefix, must be
updated. The measuring-stick fixture is updated in this commit.
main landed the LLM credential consolidation as a squashed PR (#26) while
this branch carried the same work unsquashed from its base, so every
config conflict was one change in two forms. Main's form supersedes and
was taken for app/config.py, conf/default.yml, app/workflows/author.py,
the secret-scrubbing helpers in app/mcp_api.py, the local.yml overlay
note in README.md, and the three config test files.

Range removal is preserved everywhere the two overlapped. Re-applied on
top of main's versions:
  app/config.py            tool-surface comment drops range
  app/workflows/plan_execute.py  optional_servers drops range
  gui/views/mcp.vue        pipeline prose drops the deploy spec

Also drops a range reference in data/measuring-sticks/README.md that the
earlier rename missed.

Verified after resolution: no range outside Python's range(), the
back-compat prefix strip, and English usage; mcp_server.py still exposes
seven tools; cti_deploy_spec.py and test_range_integration.py stay
deleted; compile and the E9,F63,F7,F821 gate clean. main's own config
tests give identical results before and after the merge.
The topology SDO described machines to stand up. Core CALDERA has nothing
that reads it, so it was a deployment blueprint with no consumer.

Deletes cti_topology_inference, cti_pipeline_stage4_topology,
cti_pipeline_stage3, cti_infra_aggregation, cti_refinement and
cti_knowledge_graph, plus the build_topology and refine_topology MCP
tools. Stage 3 emitted infrastructure hypotheses that only stage 4 read.

The pipeline is now stage 1 to stage 2: raw to IR to STIX. Entity
extraction stays, retargeted at CALDERA facts in the next commit.

BREAKING CHANGE: the x-cti-topology SDO, outputs_topology/, the
cti_pipeline_build_topology and cti_pipeline_refine_topology tools, and
the stage3-infra and stage4-topology pipeline steps are all removed.
Extracted hosts, accounts and domains had no consumer once topology went.
They now become a CALDERA fact source, so an operation runs on values the
report named instead of placeholders.

Traits are the ones stockpile abilities reference: remote.host.fqdn,
remote.host.name, remote.host.ip, domain.user.name, domain.user.password,
host.user.name, target.org.domain.

Two filters keep the output usable. Values that are not hostname shaped
are dropped, since extractors surface prose like "Internet-facing
Exchange server" that would expand into a command and break it. Names
resolved by DNS dedupe case-insensitively; usernames and passwords do
not. Only a password the report actually stated is emitted, never a
redacted one.

bundle_to_facts is pure and testable without CALDERA. build_source and
the cti_pipeline_build_source tool are the adapters that need it.

On the tracked BlackCat bundle this yields 25 facts: 11 hostnames,
7 addresses, 4 domain accounts, 2 local accounts, 1 domain.
The identity checkboxes and the Agent selector were provisioning inputs.
Domain, domain users and local users told a range builder what accounts
to create; the Agent mode and CTI entry host told it which implant to
install and where. With no provisioning step none of it can be acted on.

They had one consumer: the prompt formatter stringified them into
"Agent start preference: ..." and "Identity/domain options: ...". No tool
read them. Keeping them fed the model preferences it could not honour,
which invites it to claim work it did not do.

Also drops provisioning language from the model-facing prose in the RAG
capability description, the shared prompt fragments and the Workflow
docstring.

The workflow context now carries only CTI and RAG selection, which is
what the pipeline is for: better grounding for the agent's reasoning.
Review of the branch turned up four real defects.

build_source could never succeed. SourceSchema's pre_load stamps every
fact with input_data["id"], so a body without one raised KeyError before
validation and returned a 500. The id is now minted client side, and two
tests load the body through CALDERA's own schema.

remote.host.ip is a live target: stockpile nmaps it and SMB-mounts it. A
report names the attacker's C2 and other victims beside the estate, so
the tool now previews by default and reports which addresses are
publicly routable. Committing takes an explicit flag.

start_stage raised a bare ValueError from list.index for an unknown
stage, becoming a 500, while only_stage returned a clear message. Both
paths now match.

Removes provisioning residue the deletions missed: two README bullets
promising infrastructure provisioning and agent placement, a stale REST
docstring documenting four removed parameters, an orphaned comment
block, and the range deploy shape in the measuring stick, which was
reachable by a model through cti_pipeline_fuse.
@HackedRico
HackedRico merged commit c6baf4d into main Aug 28, 2026
3 checks passed
@HackedRico
HackedRico deleted the refactor/remove-range branch August 28, 2026 00:41
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