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.
- System Overview
- Three-Layer FSM
- Layer 1, SeedVineService
- Layer 2, KillChainGovernor
- Layer 3, TaskState
- DSPy Planner Client
- MCP Tool Server
- Semantic Ability Index
- Ability Executor
- PII Shielding
- Adaptive Fact Manager
- Token Pool Manager
- Anthropic Prompt Caching
- REST API and SSE Events
- Vine Factory
- MLflow Integration
- Operation Lifecycle (End to End)
- Known Limitations
┌─────────────────────────────────────────────────────────────┐
│ 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 │
└───────────────────────────────────────────────────────────────┘
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).
File: app/seed_vine_svc.py (~2960 lines)
SeedVineService is the root orchestrator. It owns all shared mutable state and serializes access to it.
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} |
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()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.
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
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.
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 |
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 phasesvalidate_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).
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.
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 = [
"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.
File: app/seed_vine_planner_client.py (~1150 lines)
This module defines all DSPy signatures and the provider rotation wrapper that calls them.
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.
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().
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).
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 YAMLUsed 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.
_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.
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 | 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_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.
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.
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.
File: app/ability_index.py (~970 lines)
AbilityIndex provides the semantic search backbone for the Seed half of the plugin.
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.
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
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.
Abilities are inserted in batches of 500 (_BATCH_SIZE = 500) to avoid ChromaDB's per-call overhead on large collections.
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.
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}
Before injection, all #{trait} placeholders in the ability command are resolved:
- If the value is in
reverse_pseudonym_map, de-sanitize to the real value. - Otherwise, pass the value through literally.
- Any remaining
#{...}tokens after step 2 raise aValueError, silent stripping was identified as a bug that caused command truncation and was fixed (seecontext_builder.py:resolve_facts_in_command).
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
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.
| 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.
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.
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.
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.
File: app/token_pool_manager.py (~790 lines, Singleton)
TokenPoolManager routes LLM calls to the best available provider given current rate-limit state.
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.
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.
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.
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%.
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).
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 |
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).
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".
| 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 |
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.
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.
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.
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.
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) ───│◄────────────────────────┘
| 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). |