Skip to content

Latest commit

 

History

History
762 lines (560 loc) · 37.8 KB

ARCHITECTURE.md

File metadata and controls

762 lines (560 loc) · 37.8 KB

Architecture

Seed & Vine is a stateful, event-driven plugin built on top of the CALDERA v4 aiohttp server. This document describes every major component as implemented, including design trade-offs and known constraints.


Table of Contents

  1. System Overview
  2. Three-Layer FSM
  3. Layer 1, SeedVineService
  4. Layer 2, KillChainGovernor
  5. Layer 3, TaskState
  6. DSPy Planner Client
  7. MCP Tool Server
  8. Semantic Ability Index
  9. Ability Executor
  10. PII Shielding
  11. Adaptive Fact Manager
  12. Token Pool Manager
  13. Anthropic Prompt Caching
  14. REST API and SSE Events
  15. Vine Factory
  16. MLflow Integration
  17. Operation Lifecycle (End to End)
  18. Known Limitations

1. System Overview

┌─────────────────────────────────────────────────────────────┐
│                     Operator Browser                        │
│              Vue.js SPA (seed_and_vine.vue)                 │
└──────────────────────┬──────────────────────────────────────┘
                       │  HTTP REST  /  SSE
┌──────────────────────▼──────────────────────────────────────┐
│                   seed_vine_api.py                          │
│              21 routes  ·  SSE stream                       │
└──────────────────────┬──────────────────────────────────────┘
                       │  async method calls
┌──────────────────────▼──────────────────────────────────────┐
│                  seed_vine_svc.py                           │
│           Layer 1 · Operation FSM · ~2960 lines             │
│  asyncio.Lock · asyncio.Event · self.state · SSE broadcast  │
└────────┬──────────────────────────────────────┬─────────────┘
         │ govern()                             │ execute_ability()
┌────────▼──────────┐               ┌───────────▼─────────────┐
│kill_chain_governor│               │   ability_executor.py   │
│  Layer 2 · ~600 ln│               │   link injection        │
│  7-state FSM      │               │   temp op/adversary     │
└────────┬──────────┘               └───────────┬─────────────┘
         │                                       │
┌────────▼──────────┐               ┌───────────▼─────────────┐
│   task_state.py   │               │    caldera_client.py    │
│  Layer 3 · ~376 ln│               │    aiohttp session      │
│  per-task lifecycle│              │    CALDERA REST API     │
└────────┬──────────┘               └─────────────────────────┘
         │ subprocess
┌────────▼──────────────────────────────────────────────────────┐
│                seed_vine_planner_client.py                    │
│   DSPy ReAct  ·  max_iters=10  ·  provider rotation          │
│   SeedVinePlanner / NextTaskReasoner / TaskValidator          │
└────────┬──────────────────────────────────────────────────────┘
         │  stdio MCP
┌────────▼──────────────────────────────────────────────────────┐
│                  seed_vine_server.py                          │
│   FastMCP  ·  11 tools  ·  subprocess isolation              │
│   search_*  ·  execute_existing  ·  propose_new              │
│   store_fact  ·  signal_task_complete  ·  signal_completion  │
└────────┬──────────────────────────────────────────────────────┘
         │
┌────────▼──────────────────────────────────────────────────────┐
│                    ability_index.py                           │
│   ChromaDB + ONNX embeddings  ·  data/.chroma_index/         │
│   2000+ CALDERA abilities  ·  mmap reads  ·  tactic filter   │
└───────────────────────────────────────────────────────────────┘

2. Three-Layer FSM

The operation control plane is divided into three nested finite state machines. Each layer has a distinct responsibility and failure boundary.

Layer 1 (SeedVineService)
  Status: IDLE → INITIALIZED → CONFIGURED → STARTING → RUNNING → PAUSED → COMPLETED | ERROR

  Layer 2 (KillChainGovernor)
    GovernorState: IDLE → REASON → EXECUTING → VALIDATE → ADAPT ⇄ REASON → DONE | FAILED

    Layer 3 (TaskState), one per kill-chain task
      Status: not_started → in_progress → completed | failed | skipped

Why three layers instead of one?

A flat FSM large enough to handle the full operation lifecycle would have dozens of states and hundreds of transitions. The layered approach gives each FSM a bounded scope:

  • Layer 1 handles the operator-facing lifecycle (start, pause, stop, errors, cleanup).
  • Layer 2 handles the AI planning loop (what task to do next, did the task succeed, should we retry or move on).
  • Layer 3 tracks the runtime state of a single task in isolation (in progress, completed, retry count).

3. Layer 1, SeedVineService

File: app/seed_vine_svc.py (~2960 lines)

SeedVineService is the root orchestrator. It owns all shared mutable state and serializes access to it.

State dict (self.state)

All operation data lives in a single dict. seed_vine_api.py returns dict(self.state) (shallow copy) from GET /api/status.

Key Type Description
status str IDLE / INITIALIZED / CONFIGURED / STARTING / RUNNING / PAUSED / COMPLETED / ERROR
config dict Mission parameters: goal, agent PAW, threat_actor, exfil_method, exfil_method_order, target_directory, extensions
current_phase str DISCOVERY / PLANNING / EXECUTION / APPROVAL_GATE / CLEANUP
history list Last 5 execution entries, {loop, decision, result, tool_call}
rejection_memory dict {rejected_approaches: [], constraints: []} fed back into every AI prompt
counters dict {loop_count, time_elapsed}
fact_store dict {trait_name: value}, volatile RAM, used for #{trait} variable resolution
pseudonym_map dict {real_value: pseudonym}, volatile RAM, used for de-sanitizing commands pre-injection
pending_ability dict|None Ability awaiting approval: id, name, tactic, risk_level, command_preview
discovery_context dict Sanitized agent telemetry: hostname→pseudonym, user→pseudonym, os, pwd, executors
governor dict|None Serialized Governor state broadcast to frontend
_sv_temp_ops list Temp CALDERA operation IDs accumulated during execution; deleted at cleanup
_pseudo_counter dict Per-category monotonic counters used by PseudonymManager to generate sequential pseudonyms, {"hostname": 0, "user": 0, "ip": 0, "domain": 0, "credential": 0, "path": 0}

Concurrency controls

self._operation_lock   # asyncio.Lock, no concurrent operations
self._approval_event   # asyncio.Event, cleared when approval required, set when operator responds
self._approval_decision  # str, "APPROVE" or "REJECT"
self._running_task     # asyncio.Task, cancel() called by stop_operation()

Approval gate mechanism

When an ability's tactic is in the high-risk tier (privilege-escalation, credential-access, exfiltration), the flow pauses by calling:

self._approval_event.clear()
self.state["status"] = "PAUSED"
self.state["pending_ability"] = { ... }
await asyncio.wait_for(self._approval_event.wait(), timeout=APPROVAL_TIMEOUT_SEC)

The coroutine suspends at wait() without blocking the event loop. POST /api/approve sets self._approval_decision and calls self._approval_event.set(), resuming the coroutine. Auto-rejection fires after APPROVAL_TIMEOUT_SEC = 1800 seconds.

Operation phases

STARTING
  └─ L1 discovery: execute sv-pre-op-recon, parse output, PII-sanitize
       └─ Ability stdout is truncated to OUTPUT_TRUNCATION_LIMIT (500 chars) before AI context
  └─ Fact loading: GET /api/v2/sources/{id} → populate fact_store + pseudonym_map
PLANNING
  └─ Governor.reason_next_task() → select one task
EXECUTION (per task)
  └─ _execute_react_agent(): spawn MCP subprocess → DSPy ReAct
  └─ Dispatch tool results: execute_ability / propose_ability / signal_*
APPROVAL_GATE (conditional)
  └─ High-risk tactic: block on _approval_event
CLEANUP
  └─ Delete temp operations and adversaries
  └─ Close aiohttp session
  └─ End MLflow run

4. Layer 2, KillChainGovernor

File: app/kill_chain_governor.py (~600 lines)

KillChainGovernor manages the adaptive planning loop. It reasons one task at a time: select a task, execute it, validate the result, then either advance or retry.

GovernorState enum (7 states)

IDLE → REASON → EXECUTING → VALIDATE → ADAPT ⇄ REASON → DONE
                                     ↘ FAILED
State Description
IDLE Waiting to start
REASON Calling SeedVineNextTaskReasoner to select the next kill-chain task
EXECUTING Delegated to Layer 1 for actual execution
VALIDATE Evaluating whether the task objective was met
ADAPT Task failed or succeeded; decide whether to retry or advance
DONE All tasks completed or goal explicitly signalled
FAILED Maximum iterations or backward-move limit exceeded

Safety limits

MAX_TOTAL_ITERATIONS = 50   # Global loop cap; triggers FAILED if exceeded
MAX_TASK_RETRIES     = 2    # Per-task retry before marking failed and moving on
MAX_BACKWARD_MOVES   = 2    # Prevent oscillation between phases

Task validation

validate_task_result() applies a symbolic check first (regex keyword match, HTTP status check, AND condition evaluation). The LLM validator (SeedVineTaskValidator) is only invoked for results that are ambiguous, that is, symbolic checks neither pass nor fail definitively. If all LLM retries are exhausted, the validator returns {"valid": True} (optimistic assumption, documented as a known limitation).

Shared artifacts

merge_shared_artifacts() collects outputs from completed tasks and passes them to subsequent tasks as context. Lists are appended with deduplication; dicts are shallow-merged; scalars are overwritten.


5. Layer 3, TaskState

File: app/task_state.py (~376 lines)

TaskState is a dataclass representing a single kill-chain task. It tracks status, retry count, and the allowed MCP tools for the task's phase.

Kill-chain phases and allowed tools

KILL_CHAIN_PHASES = [
    "discovery",
    "privilege_escalation",
    "defense_evasion",
    "credential_access",
    "collection",
    "exfiltration",
]

PHASE_TOOL_MAPPING = {
    "discovery":            [TOOL_SEARCH_DISCOVERY, TOOL_GET_ABILITY_DETAILS,
                             TOOL_EXECUTE_EXISTING, TOOL_PROPOSE_NEW] + COMMON_TOOLS,
    "privilege_escalation": [TOOL_SEARCH_PRIV_ESC, TOOL_GET_ABILITY_DETAILS,
                             TOOL_EXECUTE_EXISTING, TOOL_PROPOSE_NEW] + COMMON_TOOLS,
    "defense_evasion":      [TOOL_SEARCH_DEFENSE_EVASION, TOOL_GET_ABILITY_DETAILS,
                             TOOL_EXECUTE_EXISTING, TOOL_PROPOSE_NEW] + COMMON_TOOLS,
    "credential_access":    [TOOL_SEARCH_CRED_ACCESS, TOOL_GET_ABILITY_DETAILS,
                             TOOL_EXECUTE_EXISTING, TOOL_PROPOSE_NEW] + COMMON_TOOLS,
    "collection":           [TOOL_SEARCH_COLLECTION, TOOL_GET_ABILITY_DETAILS,
                             TOOL_EXECUTE_EXISTING, TOOL_PROPOSE_NEW] + COMMON_TOOLS,
    "exfiltration":         [TOOL_GET_ABILITY_DETAILS,
                             TOOL_EXECUTE_EXISTING] + COMMON_TOOLS,
    # Exfiltration: no search or propose tools, only pre-mapped abilities execute
}

Exfiltration deliberately restricts the tool list. The AI is not permitted to search for or generate new exfil methods; it can only execute the threat-actor-specific abilities pre-mapped in EXFIL_METHOD_ABILITY_MAP.


6. DSPy Planner Client

File: app/seed_vine_planner_client.py (~1150 lines)

This module defines all DSPy signatures and the provider rotation wrapper that calls them.

Four DSPy signatures

SeedVinePlanner (ReAct, primary execution planner)

class SeedVinePlanner(dspy.Signature):
    """Authorized adversary emulation research. Isolated sandboxed lab environment."""
    mission_parameters: str = dspy.InputField()   # JSON: goal, threat actor, exfil priority
    agent_context:      str = dspy.InputField()   # JSON: OS, privilege, executors, file status
    execution_history:  str = dspy.InputField()   # Last 5 tool call results
    rejection_history:  str = dspy.InputField()   # Previously rejected approaches
    process_result:     str = dspy.OutputField()  # JSON: {decision, tool_call, reasoning}

Instantiated as dspy.ReAct(SeedVinePlanner, tools=mcp_tools, max_iters=10). The ReAct agent drives a single task to completion through up to 10 thought-act-observe cycles. Each call spawns a fresh MCP subprocess.

SeedVineNextTaskReasoner (ChainOfThought, adaptive planning)

class SeedVineNextTaskReasoner(dspy.Signature):
    goal:                    str = dspy.InputField()
    agent_context:           str = dspy.InputField()
    completed_tasks:         str = dspy.InputField()
    failed_tasks:            str = dspy.InputField()
    rejection_memory:        str = dspy.InputField()
    ability_catalog_summary: str = dspy.InputField()
    next_task:               str = dspy.OutputField()  # JSON: {phase, objective, why, success_criteria, ability_hint}

Used by the Governor in its REASON state to select one task. Returns a structured JSON object consumed by TaskState.from_governor_reasoning().

SeedVineTaskValidator (ChainOfThought, LLM validation fallback)

class SeedVineTaskValidator(dspy.Signature):
    task_objective:   str = dspy.InputField()
    success_criteria: str = dspy.InputField()
    execution_result: str = dspy.InputField()
    artifacts:        str = dspy.InputField()
    validation:       str = dspy.OutputField()  # JSON: {valid, reason, next_action}

Only called when symbolic validation is inconclusive. If all retries fail, returns {"valid": True} (optimistic).

SeedVineFactory (ChainOfThought, Vine ability generation)

class SeedVineFactory(dspy.Signature):
    ability_request:   str = dspy.InputField()  # Natural language description
    target_os:         str = dspy.InputField()  # "linux" or "windows"
    reference_ability: str = dspy.InputField()  # Full YAML of nearest existing ability
    generated_ability: str = dspy.OutputField() # Complete CALDERA ability YAML

Used exclusively by seed_vine_factory_client.py. Defined separately because it uses a different few-shot demo set (FACTORY_DEMOS) and has no ReAct wrapper.

Provider rotation

_with_provider_rotation() is a generic async retry loop shared by all planner calls:

async def _with_provider_rotation(
    worker_fn,
    worker_kwargs: dict,
    max_attempts: int,
    services=None,
    on_exhausted=None,
    logger_prefix=...,
    stop_context=...,
):
    for attempt in range(max_attempts):
        if kill_switch_set():
            raise OperationStoppedError
        provider = await pool.get_next_available()
        try:
            return await worker_fn(provider, **worker_kwargs)
        except RateLimitError:
            pool.mark_rate_limited(provider)
            continue
        except TerminalError:
            raise
    if on_exhausted:
        return on_exhausted()

max_attempts is set per call site, not as a universal default: Planner = 10, NextTaskReasoner = 6, TaskValidator = 4.

DSPy wraps LLM calls in ExceptionGroup (Python 3.11+ task groups). unwrap_exception_group() and extract_rate_limit_error() unwrap these to detect HTTP 429 responses before the generic handler sees them.


7. MCP Tool Server

File: app/seed_vine_server.py (~710 lines)

The MCP server runs as a subprocess for each ReAct call. Isolation ensures that a crashed or hung tool call does not corrupt the parent process. The server is implemented with FastMCP("Seed & Vine MCP Server") and exposes 11 tools via stdio transport.

Tool inventory

Tool Category Allowed phases
search_discovery_ability_library Search discovery
search_defense_evasion_ability_library Search defense_evasion
search_privilege_escalation_ability_library Search privilege_escalation
search_collection_ability_library Search collection
search_credential_access_ability_library Search credential_access
get_ability_details Detail all
execute_existing_ability Action all except exfil-restricted
propose_new_ability Vine all except exfil
store_discovered_fact Signal all
signal_task_complete Signal all
signal_completion Signal all

Search implementation

_search_by_tactic(keyword, tactic) calls AbilityIndex.load() (reads from data/.chroma_index/) and runs a cosine similarity query filtered to the specified tactic. Results below MIN_SIMILARITY = 0.25 are excluded. Falls back to CALDERA REST API keyword scan if ChromaDB is unavailable.

Execution bridge

execute_existing_ability in the MCP subprocess cannot use async (MCP stdio transport is synchronous). It uses CalderaRequest (a thin requests.Session wrapper) to call POST /plugin/seed_and_vine/api/execute_ability on the parent process, which then invokes AbilityExecutor asynchronously.

Vine bridge

propose_new_ability (MCP tool) does not call the factory directly. It constructs a draft specification and returns {status: "pending_approval", required_decision: "propose_ability"} immediately. The orchestration layer (_handle_propose_ability() in seed_vine_svc.py) receives this signal, invokes factory_run(), stores the result in state["pending_ability"], and raises the approval gate. After operator approval, _register_vine_ability() registers the new ability via POST /api/v2/abilities (CALDERA REST API), not via data_svc.store(). The ability is also written to _vine_ability_cache so that _resolve_executor_for_ability() can locate its spec before CALDERA's in-memory store has propagated the REST write.


8. Semantic Ability Index

File: app/ability_index.py (~970 lines)

AbilityIndex provides the semantic search backbone for the Seed half of the plugin.

Index structure

Each ability is embedded as a concatenated text string that preserves the most query-relevant fields:

"{platforms} | {name} | {tactic} | {technique_name} | {description}"

The embedding model is ChromaDB's built-in ONNX runtime (all-MiniLM-L6-v2 derivative). No external embedding API calls are made at query time.

Persistence

The index is built once, persisted to data/.chroma_index/, and loaded on subsequent starts. An integrity manifest (_index_manifest.json) stores a SHA-256 fingerprint; if the loaded collection fails the manifest check, the index is rebuilt from scratch.

data/.chroma_index/
├── _index_manifest.json     # SHA-256 + ability count
├── chroma.sqlite3           # ChromaDB metadata store
└── *.parquet                # Embedding vectors

Build trigger

The index is built in a background task during SeedVineService.initialize(). This runs after routes are registered, so the router never freezes waiting for the build. The API returns 503 with a "ChromaDB not ready" message if a search tool is called before the build completes.

Batch insertion

Abilities are inserted in batches of 500 (_BATCH_SIZE = 500) to avoid ChromaDB's per-call overhead on large collections.


9. Ability Executor

File: app/ability_executor.py (~410 lines)

AbilityExecutor translates a semantic ability selection into a running CALDERA link. It uses CALDERA's existing operation and link infrastructure rather than implementing its own execution engine.

Link injection sequence

1. POST /api/v2/adversaries
       body: {name: "sv-temp-{uuid}", atomic_ordering: [ability_id]}
       → temp_adversary_id

2. POST /api/v2/operations
       body: {name: "sv-op-{uuid}", adversary_id: temp_adversary_id,
              planner_id: ATOMIC_PLANNER_ID, source_id: DEFAULT_FACT_SOURCE_ID,
              state: "running"}
       → temp_op_id

3. POST /api/v2/operations/{temp_op_id}/potential-links
       body: {paw: agent_paw, ability_id: ability_id}
       → link_id

4. PATCH /api/v2/operations/{temp_op_id}/links/{link_id}
       body: {status: 0}     # 0 = queued

5. Poll GET /api/v2/operations/{temp_op_id}/links
       until link.finish != -3  (not pending)
       or timeout (EXEC_TIMEOUT_SEC = 120 seconds)

6. Read result: exit_code, output (stdout), link.status

7. DELETE /api/v2/operations/{temp_op_id}
   DELETE /api/v2/adversaries/{temp_adversary_id}

Fact resolution

Before injection, all #{trait} placeholders in the ability command are resolved:

  1. If the value is in reverse_pseudonym_map, de-sanitize to the real value.
  2. Otherwise, pass the value through literally.
  3. Any remaining #{...} tokens after step 2 raise a ValueError, silent stripping was identified as a bug that caused command truncation and was fixed (see context_builder.py:resolve_facts_in_command).

Command normalization

Multi-line shell commands are normalized to valid one-liners before injection:

  • Split on ;, strip each segment, drop empty segments
  • Re-join with ; unless the preceding segment already ends with &, |, &&, or || (chained operators already separate commands)
  • Strip trailing whitespace and newlines

10. PII Shielding

Files: app/pseudonym_manager.py (~233 lines), app/pseudonym_ledger.py (~149 lines), app/discovery_svc.py (~289 lines)

Every LLM prompt contains only pseudonyms, never real host identifiers. This prevents credential and host-name leakage to third-party AI providers.

Three-tier sanitization policy

Tier Data Type Action
L1, Always sanitize Hostname, username, IP address, domain name Replace with Target_HOSTNAME_1, Target_USER_1, Target_IP_1, Target_DOMAIN_1
L2, Structural preserve File paths containing a username Replace only the username component; keep path structure intact
L3, Never sanitize OS name, privilege level, executor list, absolute paths, tool availability Pass to LLM as-is

Exception: Well-known OS-default names (root, administrator, admin, system, local system, nobody, daemon, www-data) are not sanitized; they are public knowledge, and sanitizing them reduces planner accuracy.

Pseudonym generation

PseudonymManager maintains per-category monotonic counters:

self._counters = {"hostname": 0, "user": 0, "ip": 0, "domain": 0, "credential": 0, "path": 0}

encode(value, category) generates Target_HOSTNAME_1 (first occurrence), Target_HOSTNAME_2 (second), etc., and stores the mapping in self.state["pseudonym_map"].

decode(pseudonym) reverses the lookup. Called before link injection to restore real values to shell commands.

Audit ledger

PseudonymLedger records every pseudonym operation (encode / decode / sanitize) as an append-only entry:

{
    "timestamp": "2026-05-08T12:34:56Z",
    "direction": "encode",
    "pseudonyms": {"user1": "Target_USER_1"},
    "context": "Discovery sanitization",
    "channel": "L1"
}

Entries are pushed to the SSE stream as pseudonym_event events and are available via GET /plugin/seed_and_vine/api/pseudonym_ledger.


11. Adaptive Fact Manager

File: app/adaptive_fact_manager.py (~195 lines)

AdaptiveFactManager maintains the volatile fact store and exposes three acquisition channels.

Channel Source Mechanism
Channel 1 (Harvest) CALDERA fact source GET /api/v2/sources/{id} at operation start
Channel 2 (AI) ReAct agent discovery store_discovered_fact MCP tool → POST /api/store_fact bridge
Channel 3 (HITL) Operator via GUI POST /api/settings or inline fact injection

All facts are kept in RAM (self.state["fact_store"]) and do not persist to CALDERA's database. This keeps adversary emulation facts out of the operator's regular red-team fact history.

Facts with low confidence (extracted by regex heuristic rather than directly observed) are flagged and available via GET /plugin/seed_and_vine/api/low_confidence_facts.


12. Token Pool Manager

File: app/token_pool_manager.py (~790 lines, Singleton)

TokenPoolManager routes LLM calls to the best available provider given current rate-limit state.

Dual-mode rate limiting

Each provider entry in conf/llm_providers.yaml can express limits in RPM (requests per minute), TPM (tokens per minute), or RPD (requests per day). The manager selects a gating mode per provider:

  • RPM-gated (max_rpm < 20): request count is the bottleneck. All tracking is against a 60-second sliding window of request timestamps.
  • TPM-gated (max_rpm >= 20): token count is the bottleneck. Tracking uses a sliding window of (timestamp, tokens) tuples.

A SAFETY_MARGIN = 0.90 is applied so providers are not exhausted right to the limit.

Cooldown

When mark_rate_limited(provider) is called (after a 429 response), the provider is blocked for COOLDOWN_SECONDS = 60. After the cooldown, it re-enters the rotation automatically.

Provider selection

get_next_available() iterates providers in priority order (lowest priority number first) and returns the first one with available capacity. If all providers are on cooldown or rate-limited, it returns the provider with the shortest estimated wait time.


13. Anthropic Prompt Caching

File: app/anthropic_caching.py (~340 lines)

Anthropic's prompt cache reduces cost for repeated calls that share large system prompts and tool definitions. Cache reads cost approximately 10% of a full inference; cache writes cost 125%.

Cache hierarchy

The Anthropic API supports cache_control breakpoints that mark the boundary of cacheable content. Seed & Vine sets breakpoints in this order (most stable to least stable):

tools (tool definitions, rarely change)
  → system prompt (per-operation, but large)
    → messages (per-call, not cached)

A cache_control: {type: "ephemeral"} block is appended to the last tool definition and to the system prompt. This instructs the API to cache everything up to that point for 5 minutes (ephemeral TTL).

Minimum token thresholds

Caching only activates when the cacheable section meets Anthropic's minimum:

Model tier Minimum tokens
Opus 4.x 4096
Sonnet 4.x 1024
Haiku 3.x / 4.x 4096

Thinking mode

When extended thinking is configured, the planner client activates it per provider:

Claude: budget_tokens = max(1024, estimated_input × 0.5), capped at 32000. temperature is removed (Anthropic requirement when thinking is enabled).

Gemini: thinkingLevel ("low" / "medium" / "high") mapped by model variant (flash/pro/ultra).


14. REST API and SSE Events

Files: app/seed_vine_api.py (~940 lines), hook.py

Routes are registered in hook.py before initialize() is awaited. This ordering is required: aiohttp freezes the router once the server starts accepting connections, so routes registered after initialize() would panic with "Cannot register a resource into frozen router".

Route table (21 routes)

Method Path Purpose
GET /plugin/seed_and_vine/gui Render Vue SPA
POST /plugin/seed_and_vine/api/mirror Echo test
GET /plugin/seed_and_vine/api/status Return current self.state
GET /plugin/seed_and_vine/api/stream SSE event stream
POST /plugin/seed_and_vine/api/config Set mission parameters
POST /plugin/seed_and_vine/api/start Begin operation
POST /plugin/seed_and_vine/api/approve Approve or reject pending ability
POST /plugin/seed_and_vine/api/stop Kill running operation
POST /plugin/seed_and_vine/api/reindex Rebuild ChromaDB index
GET /plugin/seed_and_vine/api/agents List active CALDERA agents
GET /plugin/seed_and_vine/api/settings Read .env values
POST /plugin/seed_and_vine/api/settings Write .env values
GET /plugin/seed_and_vine/api/diag Debug: operation links
GET /plugin/seed_and_vine/api/diag_agents Debug: agent status
POST /plugin/seed_and_vine/api/execute_ability MCP → CALDERA execution bridge
GET /plugin/seed_and_vine/api/pseudonym_ledger PII audit entries
POST /plugin/seed_and_vine/api/store_fact MCP → fact store bridge
GET /plugin/seed_and_vine/api/low_confidence_facts Facts with low harvest confidence
GET /plugin/seed_and_vine/api/history Operation history list
DELETE /plugin/seed_and_vine/api/history Clear operation history
GET /plugin/seed_and_vine/api/history/{id} Detail for one history entry

SSE event schema

The SSE stream (GET /api/stream) is implemented with one asyncio.Queue per connected client (max 64 events, max 10 concurrent subscribers). Events are JSON-encoded and typed:

Event type Key fields Emitted when
status status, message, current_phase State transitions, stop, errors
thinking message, phase AI reasoning steps, tool results
decision decision, reasoning, tool_call, loop ReAct agent actions
approval ability_id, ability_name, tactic, risk_level, command_preview Approval gate triggered
phase phase, task_id, objective Governor phase transitions
pseudonym_event timestamp, direction, pseudonyms, context, channel PII ledger entries

The reasoning field in decision events is sanitized through PseudonymManager.sanitize() before emission, so real host names never appear in the operator console.


15. Vine Factory

File: app/seed_vine_factory_client.py (~428 lines)

When the ReAct agent calls propose_new_ability and no existing ability suffices, SeedVineFactory generates a new one.

Generation pipeline

1. _infer_tactic(ability_request)
       keyword → tactic heuristic mapping
       (e.g., "disable firewall" → "defense_evasion")

2. _search_reference_ability(tactic, keyword)
       ChromaDB search → most similar existing ability
       → _format_ability_as_reference()  (YAML string)

3. _factory_worker(ability_request, target_os, reference_yaml)
       dspy.ChainOfThought(SeedVineFactory) with FACTORY_DEMOS (3+ examples)
       → generated_ability (complete CALDERA YAML)

4. Register via POST /api/v2/abilities (CALDERA REST API, not data_svc.store())
5. Cache in _vine_ability_cache so _resolve_executor_for_ability() can find the spec before data_svc propagation completes
6. Return {status: "generated", ability: "<yaml>"}

The reference ability serves as a structural template. The LLM adapts the command, description, and executor settings for the requested capability while preserving the CALDERA schema.


16. MLflow Integration

Files: hook.py, app/seed_vine_svc.py

hook.py starts an MLflow server on port 5000 using a local SQLite backend (mlruns.db) if no server is already listening on that port. The startup check is non-destructive, if a server is already running, the plugin attaches to it.

Each operation creates one MLflow run under the seed-and-vine-operation experiment. The run logs:

  • Mission goal
  • Threat actor
  • Exfil method
  • Agent PAW
  • Execution history (compressed)
  • Terminal status (COMPLETED / ERROR / STOPPED)
  • Time elapsed

If the MLflow server is unreachable after startup, operation tracking degrades gracefully, operations proceed without logging rather than failing. MLFLOW_HTTP_REQUEST_MAX_RETRIES is set to 1 in the initialization path to prevent blocking the event loop.


17. Operation Lifecycle (End to End)

Operator                    seed_vine_api            seed_vine_svc
   │                              │                         │
   ├─ POST /api/config ──────────►│── update_config() ─────►│ state.status = CONFIGURED
   ├─ POST /api/start ───────────►│── start_operation() ───►│ asyncio.create_task(run_operation())
   │                              │                         │
   │                              │  STARTING               │
   │                              │  ├─ sv-pre-op-recon executes (AbilityExecutor)
   │                              │  ├─ Output PII-sanitized (PseudonymManager)
   │                              │  └─ state["discovery_context"] populated
   │◄── SSE: thinking(DISCOVERY) ─│◄────────────────────────┤
   │                              │  ├─ Fact source loaded → state["fact_store"]
   │◄── SSE: status(PLANNING) ────│◄────────────────────────┤
   │                              │                         │
   │                              │  Governor.reason_next_task()
   │                              │  ├─ SeedVineNextTaskReasoner called
   │                              │  └─ TaskState created
   │◄── SSE: phase(task) ─────────│◄────────────────────────┤
   │                              │                         │
   │                              │  _execute_react_agent()
   │                              │  ├─ MCP subprocess spawned
   │                              │  └─ DSPy ReAct (max 10 iters)
   │◄── SSE: decision events ─────│◄────────────────────────┤
   │                              │                         │
   │   [if high-risk tactic]      │                         │
   │◄── SSE: approval ────────────│◄────────────────────────┤
   ├─ POST /api/approve ─────────►│── approve_ability() ───►│ _approval_event.set()
   │                              │                         │
   │                              │  Governor.validate_task_result()
   │                              │  ├─ Symbolic check
   │                              │  └─ LLM fallback if ambiguous
   │                              │  (repeat for remaining tasks)
   │                              │                         │
   │                              │  CLEANUP
   │                              │  ├─ DELETE temp operations
   │                              │  ├─ DELETE temp adversaries
   │                              │  ├─ Close aiohttp session
   │                              │  └─ End MLflow run
   │◄── SSE: status(COMPLETED) ───│◄────────────────────────┘

18. Known Limitations

Limitation Detail
Single-agent scope Each operation targets exactly one CALDERA agent. Lateral movement is not implemented.
Volatile state self.state, fact_store, pseudonym_map, and Governor state are all in-process RAM. A CALDERA restart loses all operation history.
Partial LLM streaming SSE events are emitted at the decision level (one event per ReAct iteration), not at the token level. DSPy 3.1.x streamify(async_streaming=True) is known broken (upstream issue #8454).
Optimistic task validation When all LLM validation retries are exhausted, the Governor assumes task success to avoid infinite retry loops.
Manual fact source selection The operator must select the correct CALDERA fact source in the GUI. No auto-discovery is implemented.
Subprocess overhead Each ReAct call spawns a new MCP subprocess (~100-300 ms startup cost).