mirror of
https://github.com/infinition/Bjorn.git
synced 2026-03-17 09:31:04 +00:00
Add LLM configuration and MCP server management UI and backend functionality
- Implemented a new SPA page for LLM Bridge and MCP Server settings in `llm-config.js`. - Added functionality for managing LLM and MCP configurations, including toggling, saving settings, and testing connections. - Created HTTP endpoints in `llm_utils.py` for handling LLM chat, status checks, and MCP server configuration. - Integrated model fetching from LaRuche and Ollama backends. - Enhanced error handling and logging for better debugging and user feedback.
This commit is contained in:
757
llm_orchestrator.py
Normal file
757
llm_orchestrator.py
Normal file
@@ -0,0 +1,757 @@
|
||||
# llm_orchestrator.py
|
||||
# LLM-based orchestration layer for Bjorn.
|
||||
#
|
||||
# Modes (llm_orchestrator_mode in config):
|
||||
# none — disabled (default); LLM has no role in scheduling
|
||||
# advisor — LLM reviews state periodically and injects ONE priority action
|
||||
# autonomous — LLM runs its own agentic cycle, observes via MCP tools, queues actions
|
||||
#
|
||||
# Prerequisites: llm_enabled=True, llm_orchestrator_mode != "none"
|
||||
#
|
||||
# Guard rails:
|
||||
# llm_orchestrator_allowed_actions — whitelist for run_action (empty = mcp_allowed_tools)
|
||||
# llm_orchestrator_max_actions — hard cap on actions per autonomous cycle
|
||||
# llm_orchestrator_interval_s — cooldown between autonomous cycles
|
||||
# Falls back silently when LLM unavailable (no crash, no spam)
|
||||
|
||||
import json
|
||||
import threading
|
||||
import time
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
from logger import Logger
|
||||
|
||||
logger = Logger(name="llm_orchestrator.py", level=20)
|
||||
|
||||
# Priority levels (must stay above normal scheduler/queue to be useful)
|
||||
_ADVISOR_PRIORITY = 85 # advisor > MCP (80) > normal (50) > scheduler (40)
|
||||
_AUTONOMOUS_PRIORITY = 82
|
||||
|
||||
|
||||
class LLMOrchestrator:
|
||||
"""
|
||||
LLM-based orchestration layer.
|
||||
|
||||
advisor mode — called from orchestrator background tasks; LLM suggests one action.
|
||||
autonomous mode — runs its own thread; LLM loops with full tool-calling.
|
||||
"""
|
||||
|
||||
def __init__(self, shared_data):
|
||||
self._sd = shared_data
|
||||
self._thread: Optional[threading.Thread] = None
|
||||
self._stop = threading.Event()
|
||||
self._last_fingerprint: Optional[tuple] = None
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Lifecycle
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def start(self) -> None:
|
||||
mode = self._mode()
|
||||
if mode == "autonomous":
|
||||
if self._thread and self._thread.is_alive():
|
||||
return
|
||||
self._stop.clear()
|
||||
self._thread = threading.Thread(
|
||||
target=self._autonomous_loop, daemon=True, name="LLMOrchestrator"
|
||||
)
|
||||
self._thread.start()
|
||||
logger.info("LLM Orchestrator started (autonomous)")
|
||||
elif mode == "advisor":
|
||||
logger.info("LLM Orchestrator ready (advisor — called from background tasks)")
|
||||
|
||||
def stop(self) -> None:
|
||||
self._stop.set()
|
||||
if self._thread and self._thread.is_alive():
|
||||
self._thread.join(timeout=15)
|
||||
self._thread = None
|
||||
|
||||
def restart_if_mode_changed(self) -> None:
|
||||
"""
|
||||
Call from the orchestrator main loop to react to runtime config changes.
|
||||
Starts/stops the autonomous thread when the mode changes.
|
||||
"""
|
||||
mode = self._mode()
|
||||
running = self._thread is not None and self._thread.is_alive()
|
||||
|
||||
if mode == "autonomous" and not running and self._is_llm_enabled():
|
||||
self.start()
|
||||
elif mode != "autonomous" and running:
|
||||
self.stop()
|
||||
|
||||
def is_active(self) -> bool:
|
||||
return self._thread is not None and self._thread.is_alive()
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Config helpers
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def _mode(self) -> str:
|
||||
return str(self._sd.config.get("llm_orchestrator_mode", "none"))
|
||||
|
||||
def _is_llm_enabled(self) -> bool:
|
||||
return bool(self._sd.config.get("llm_enabled", False))
|
||||
|
||||
def _allowed_actions(self) -> List[str]:
|
||||
"""
|
||||
Bjorn action module names the LLM may queue via run_action.
|
||||
Falls back to all loaded action names if empty.
|
||||
NOTE: These are action MODULE names (e.g. 'NetworkScanner', 'SSHBruteforce'),
|
||||
NOT MCP tool names (get_hosts, run_action, etc.).
|
||||
"""
|
||||
custom = self._sd.config.get("llm_orchestrator_allowed_actions", [])
|
||||
if custom:
|
||||
return list(custom)
|
||||
# Auto-discover from loaded actions
|
||||
try:
|
||||
loaded = getattr(self._sd, 'loaded_action_names', None)
|
||||
if loaded:
|
||||
return list(loaded)
|
||||
except Exception:
|
||||
pass
|
||||
# Fallback: ask the DB for known action names
|
||||
try:
|
||||
rows = self._sd.db.query(
|
||||
"SELECT DISTINCT action_name FROM action_queue ORDER BY action_name"
|
||||
)
|
||||
if rows:
|
||||
return [r["action_name"] for r in rows]
|
||||
except Exception:
|
||||
pass
|
||||
return []
|
||||
|
||||
def _max_actions(self) -> int:
|
||||
return max(1, int(self._sd.config.get("llm_orchestrator_max_actions", 3)))
|
||||
|
||||
def _interval(self) -> int:
|
||||
return max(30, int(self._sd.config.get("llm_orchestrator_interval_s", 60)))
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Advisor mode (called externally from orchestrator background tasks)
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def advise(self) -> Optional[str]:
|
||||
"""
|
||||
Ask the LLM for ONE tactical action recommendation.
|
||||
Returns the action name if one was queued, else None.
|
||||
"""
|
||||
if not self._is_llm_enabled() or self._mode() != "advisor":
|
||||
return None
|
||||
|
||||
try:
|
||||
from llm_bridge import LLMBridge
|
||||
|
||||
allowed = self._allowed_actions()
|
||||
if not allowed:
|
||||
return None
|
||||
|
||||
snapshot = self._build_snapshot()
|
||||
real_ips = snapshot.get("VALID_TARGET_IPS", [])
|
||||
ip_list_str = ", ".join(real_ips) if real_ips else "(none)"
|
||||
|
||||
system = (
|
||||
"You are Bjorn's tactical advisor. Review the current network state "
|
||||
"and suggest ONE action to queue, or nothing if the queue is sufficient. "
|
||||
"Reply ONLY with valid JSON — no markdown, no commentary.\n"
|
||||
'Format when action needed: {"action": "ActionName", "target_ip": "1.2.3.4", "reason": "brief"}\n'
|
||||
'Format when nothing needed: {"action": null}\n'
|
||||
"action must be exactly one of: " + ", ".join(allowed) + "\n"
|
||||
f"target_ip MUST be one of these exact IPs: {ip_list_str}\n"
|
||||
"NEVER use placeholder IPs. Only use IPs from the hosts_alive list."
|
||||
)
|
||||
prompt = (
|
||||
f"Current Bjorn state:\n{json.dumps(snapshot, indent=2)}\n\n"
|
||||
"Suggest one action or null."
|
||||
)
|
||||
|
||||
raw = LLMBridge().complete(
|
||||
[{"role": "user", "content": prompt}],
|
||||
system=system,
|
||||
max_tokens=150,
|
||||
timeout=20,
|
||||
)
|
||||
if not raw:
|
||||
return None
|
||||
|
||||
return self._apply_advisor_response(raw, allowed)
|
||||
|
||||
except Exception as e:
|
||||
logger.debug(f"LLM advisor error: {e}")
|
||||
return None
|
||||
|
||||
def _apply_advisor_response(self, raw: str, allowed: List[str]) -> Optional[str]:
|
||||
"""Parse advisor JSON and queue the suggested action. Returns action name or None."""
|
||||
try:
|
||||
text = raw.strip()
|
||||
# Strip markdown fences if the model added them
|
||||
if "```" in text:
|
||||
parts = text.split("```")
|
||||
text = parts[1] if len(parts) > 1 else parts[0]
|
||||
if text.startswith("json"):
|
||||
text = text[4:]
|
||||
|
||||
data = json.loads(text.strip())
|
||||
action = data.get("action")
|
||||
if not action:
|
||||
logger.debug("LLM advisor: no action suggested this cycle")
|
||||
return None
|
||||
|
||||
if action not in allowed:
|
||||
logger.warning(f"LLM advisor suggested disallowed action '{action}' — ignored")
|
||||
return None
|
||||
|
||||
target_ip = str(data.get("target_ip", "")).strip()
|
||||
reason = str(data.get("reason", "llm_advisor"))[:120]
|
||||
|
||||
mac = self._resolve_mac(target_ip)
|
||||
|
||||
self._sd.db.queue_action(
|
||||
action_name=action,
|
||||
mac=mac,
|
||||
ip=target_ip,
|
||||
priority=_ADVISOR_PRIORITY,
|
||||
trigger="llm_advisor",
|
||||
metadata={
|
||||
"decision_method": "llm_advisor",
|
||||
"decision_origin": "llm",
|
||||
"ai_reason": reason,
|
||||
},
|
||||
)
|
||||
try:
|
||||
self._sd.queue_event.set()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
logger.info(f"[LLM_ADVISOR] → {action} @ {target_ip}: {reason}")
|
||||
return action
|
||||
|
||||
except json.JSONDecodeError:
|
||||
logger.debug(f"LLM advisor: invalid JSON: {raw[:200]}")
|
||||
return None
|
||||
except Exception as e:
|
||||
logger.debug(f"LLM advisor apply error: {e}")
|
||||
return None
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Autonomous mode (own thread)
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def _autonomous_loop(self) -> None:
|
||||
logger.info("LLM Orchestrator autonomous loop starting")
|
||||
while not self._stop.is_set():
|
||||
try:
|
||||
if self._is_llm_enabled() and self._mode() == "autonomous":
|
||||
self._run_autonomous_cycle()
|
||||
else:
|
||||
# Mode was switched off at runtime — stop thread
|
||||
break
|
||||
except Exception as e:
|
||||
logger.error(f"LLM autonomous cycle error: {e}")
|
||||
|
||||
self._stop.wait(self._interval())
|
||||
|
||||
logger.info("LLM Orchestrator autonomous loop stopped")
|
||||
|
||||
def _compute_fingerprint(self) -> tuple:
|
||||
"""
|
||||
Compact state fingerprint: (hosts, vulns, creds, last_completed_queue_id).
|
||||
Only increases are meaningful — a host going offline is not an opportunity.
|
||||
"""
|
||||
try:
|
||||
hosts = int(getattr(self._sd, "target_count", 0))
|
||||
vulns = int(getattr(self._sd, "vuln_count", 0))
|
||||
creds = int(getattr(self._sd, "cred_count", 0))
|
||||
row = self._sd.db.query_one(
|
||||
"SELECT MAX(id) AS mid FROM action_queue WHERE status IN ('success','failed')"
|
||||
)
|
||||
last_id = int(row["mid"]) if row and row["mid"] is not None else 0
|
||||
return (hosts, vulns, creds, last_id)
|
||||
except Exception:
|
||||
return (0, 0, 0, 0)
|
||||
|
||||
def _has_actionable_change(self, fp: tuple) -> bool:
|
||||
"""
|
||||
Return True only if something *increased* since the last cycle:
|
||||
- new host discovered (hosts ↑)
|
||||
- new vulnerability found (vulns ↑)
|
||||
- new credential captured (creds ↑)
|
||||
- an action completed (last_id ↑)
|
||||
A host going offline (hosts ↓) is not an actionable event.
|
||||
"""
|
||||
if self._last_fingerprint is None:
|
||||
return True # first cycle always runs
|
||||
return any(fp[i] > self._last_fingerprint[i] for i in range(len(fp)))
|
||||
|
||||
def _run_autonomous_cycle(self) -> None:
|
||||
"""
|
||||
One autonomous cycle.
|
||||
|
||||
Two paths based on backend capability:
|
||||
A) API backend (Anthropic) → agentic tool-calling loop
|
||||
B) LaRuche / Ollama → snapshot-based JSON prompt (no tool-calling)
|
||||
|
||||
Path B injects the full network state into the prompt and asks the LLM
|
||||
to reply with a JSON array of actions. This works with any text-only LLM.
|
||||
"""
|
||||
# Skip if nothing actionable changed (save tokens)
|
||||
if self._sd.config.get("llm_orchestrator_skip_if_no_change", True):
|
||||
fp = self._compute_fingerprint()
|
||||
if not self._has_actionable_change(fp):
|
||||
logger.debug("LLM autonomous: no actionable change, skipping cycle (no tokens used)")
|
||||
return
|
||||
self._last_fingerprint = fp
|
||||
|
||||
try:
|
||||
from llm_bridge import LLMBridge, _BJORN_TOOLS
|
||||
except ImportError as e:
|
||||
logger.warning(f"LLM Orchestrator: cannot import llm_bridge: {e}")
|
||||
return
|
||||
|
||||
bridge = LLMBridge()
|
||||
allowed = self._allowed_actions()
|
||||
max_act = self._max_actions()
|
||||
|
||||
# Detect if the active backend supports tool-calling
|
||||
backend = self._sd.config.get("llm_backend", "auto")
|
||||
supports_tools = (backend == "api") or (
|
||||
backend == "auto" and not bridge._laruche_url
|
||||
and not self._ollama_reachable()
|
||||
)
|
||||
|
||||
if supports_tools:
|
||||
response = self._cycle_with_tools(bridge, allowed, max_act)
|
||||
else:
|
||||
response = self._cycle_without_tools(bridge, allowed, max_act)
|
||||
|
||||
if response:
|
||||
log_reasoning = self._sd.config.get("llm_orchestrator_log_reasoning", False)
|
||||
prompt_desc = f"Autonomous cycle (tools={'yes' if supports_tools else 'no'})"
|
||||
if log_reasoning:
|
||||
logger.info(f"[LLM_ORCH_REASONING]\n{response}")
|
||||
self._push_to_chat(bridge, prompt_desc, response)
|
||||
else:
|
||||
logger.info(f"[LLM_AUTONOMOUS] {response[:300]}")
|
||||
|
||||
def _ollama_reachable(self) -> bool:
|
||||
"""Quick check if Ollama is up (for backend detection)."""
|
||||
try:
|
||||
base = self._sd.config.get("llm_ollama_url", "http://127.0.0.1:11434").rstrip("/")
|
||||
import urllib.request
|
||||
urllib.request.urlopen(f"{base}/api/tags", timeout=2)
|
||||
return True
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
# ------ Path A: agentic tool-calling (Anthropic API only) ------
|
||||
|
||||
def _cycle_with_tools(self, bridge, allowed: List[str], max_act: int) -> Optional[str]:
|
||||
"""Full agentic loop: LLM calls MCP tools and queues actions."""
|
||||
from llm_bridge import _BJORN_TOOLS
|
||||
|
||||
read_only = {"get_hosts", "get_vulnerabilities", "get_credentials",
|
||||
"get_action_history", "get_status", "query_db"}
|
||||
tools = [
|
||||
t for t in _BJORN_TOOLS
|
||||
if t["name"] in read_only or t["name"] == "run_action"
|
||||
]
|
||||
|
||||
system = self._build_autonomous_system_prompt(allowed, max_act)
|
||||
prompt = (
|
||||
"Start a new orchestration cycle. "
|
||||
"Use get_status and get_hosts to understand the current state. "
|
||||
f"Then queue up to {max_act} high-value action(s) via run_action. "
|
||||
"When done, summarise what you queued and why."
|
||||
)
|
||||
|
||||
return bridge.complete(
|
||||
[{"role": "user", "content": prompt}],
|
||||
system=system,
|
||||
tools=tools,
|
||||
max_tokens=1000,
|
||||
timeout=90,
|
||||
)
|
||||
|
||||
# ------ Path B: snapshot + JSON parsing (LaRuche / Ollama) ------
|
||||
|
||||
def _cycle_without_tools(self, bridge, allowed: List[str], max_act: int) -> Optional[str]:
|
||||
"""
|
||||
No tool-calling: inject state snapshot into prompt, ask LLM for JSON actions.
|
||||
Parse the response and queue actions ourselves.
|
||||
"""
|
||||
snapshot = self._build_snapshot()
|
||||
allowed_str = ", ".join(allowed) if allowed else "none"
|
||||
|
||||
# Extract the real IP list so we can stress it in the prompt
|
||||
real_ips = snapshot.get("VALID_TARGET_IPS", [])
|
||||
ip_list_str = ", ".join(real_ips) if real_ips else "(no hosts discovered yet)"
|
||||
|
||||
# Short system prompt — small models forget long instructions
|
||||
system = (
|
||||
"You are a network security orchestrator. "
|
||||
"You receive network scan data and output a JSON array of actions. "
|
||||
"Output ONLY a JSON array. No explanations, no markdown, no commentary."
|
||||
)
|
||||
|
||||
# Put the real instructions in the user message AFTER the data,
|
||||
# so the model sees them last (recency bias helps small models).
|
||||
prompt = (
|
||||
f"Network state:\n{json.dumps(snapshot, indent=2)}\n\n"
|
||||
"---\n"
|
||||
f"Pick up to {max_act} actions from: {allowed_str}\n"
|
||||
f"Target IPs MUST be from this list: {ip_list_str}\n"
|
||||
"Match actions to open ports. Skip hosts already in pending_queue.\n"
|
||||
"Output ONLY a JSON array like:\n"
|
||||
'[{"action":"ActionName","target_ip":"1.2.3.4","reason":"brief"}]\n'
|
||||
"or [] if nothing needed.\n"
|
||||
"JSON array:"
|
||||
)
|
||||
|
||||
# Use an assistant prefix to force the model into JSON mode.
|
||||
# Many LLMs will continue from this prefix rather than describe.
|
||||
messages = [
|
||||
{"role": "user", "content": prompt},
|
||||
{"role": "assistant", "content": "["},
|
||||
]
|
||||
|
||||
raw = bridge.complete(
|
||||
messages,
|
||||
system=system,
|
||||
max_tokens=500,
|
||||
timeout=60,
|
||||
)
|
||||
|
||||
# Prepend the '[' prefix we forced if the model didn't include it
|
||||
if raw and not raw.strip().startswith("["):
|
||||
raw = "[" + raw
|
||||
|
||||
if not raw:
|
||||
return None
|
||||
|
||||
# Parse and queue actions
|
||||
queued = self._parse_and_queue_actions(raw, allowed, max_act)
|
||||
|
||||
summary = raw.strip()
|
||||
if queued:
|
||||
summary += f"\n\n[Orchestrator queued {len(queued)} action(s): {', '.join(queued)}]"
|
||||
else:
|
||||
summary += "\n\n[Orchestrator: no valid actions parsed from LLM response]"
|
||||
|
||||
return summary
|
||||
|
||||
@staticmethod
|
||||
def _is_valid_ip(ip: str) -> bool:
|
||||
"""Check that ip is a real IPv4 address (no placeholders like 192.168.1.x)."""
|
||||
parts = ip.split(".")
|
||||
if len(parts) != 4:
|
||||
return False
|
||||
for p in parts:
|
||||
try:
|
||||
n = int(p)
|
||||
if n < 0 or n > 255:
|
||||
return False
|
||||
except ValueError:
|
||||
return False # catches 'x', 'xx', etc.
|
||||
return True
|
||||
|
||||
def _parse_and_queue_actions(self, raw: str, allowed: List[str], max_act: int) -> List[str]:
|
||||
"""Parse JSON array from LLM response and queue valid actions. Returns list of queued action names."""
|
||||
queued = []
|
||||
try:
|
||||
text = raw.strip()
|
||||
# Strip markdown fences
|
||||
if "```" in text:
|
||||
parts = text.split("```")
|
||||
text = parts[1] if len(parts) > 1 else parts[0]
|
||||
if text.startswith("json"):
|
||||
text = text[4:]
|
||||
text = text.strip()
|
||||
|
||||
# Try to find JSON array in the text
|
||||
start = text.find("[")
|
||||
end = text.rfind("]")
|
||||
if start == -1 or end == -1:
|
||||
# Check if the model wrote a text description instead of JSON
|
||||
if any(text.lower().startswith(w) for w in ("this ", "here", "the ", "based", "from ", "i ")):
|
||||
logger.warning(
|
||||
"LLM autonomous: model returned a text description instead of JSON array. "
|
||||
"The model may not support structured output. First 120 chars: "
|
||||
+ text[:120]
|
||||
)
|
||||
else:
|
||||
logger.debug(f"LLM autonomous: no JSON array found in response: {text[:120]}")
|
||||
return []
|
||||
|
||||
data = json.loads(text[start:end + 1])
|
||||
if not isinstance(data, list):
|
||||
data = [data]
|
||||
|
||||
for item in data[:max_act]:
|
||||
if not isinstance(item, dict):
|
||||
continue
|
||||
action = item.get("action", "").strip()
|
||||
target_ip = str(item.get("target_ip", "")).strip()
|
||||
reason = str(item.get("reason", "llm_autonomous"))[:120]
|
||||
|
||||
if not action or action not in allowed:
|
||||
logger.debug(f"LLM autonomous: skipping invalid/disallowed action '{action}'")
|
||||
continue
|
||||
if not target_ip:
|
||||
logger.debug(f"LLM autonomous: skipping '{action}' — no target_ip")
|
||||
continue
|
||||
if not self._is_valid_ip(target_ip):
|
||||
logger.warning(
|
||||
f"LLM autonomous: skipping '{action}' — invalid/placeholder IP '{target_ip}' "
|
||||
f"(LLM must use exact IPs from alive_hosts)"
|
||||
)
|
||||
continue
|
||||
|
||||
mac = self._resolve_mac(target_ip)
|
||||
if not mac:
|
||||
logger.warning(
|
||||
f"LLM autonomous: skipping '{action}' @ {target_ip} — "
|
||||
f"IP not found in hosts table (LLM used an IP not in alive_hosts)"
|
||||
)
|
||||
continue
|
||||
|
||||
self._sd.db.queue_action(
|
||||
action_name=action,
|
||||
mac=mac,
|
||||
ip=target_ip,
|
||||
priority=_AUTONOMOUS_PRIORITY,
|
||||
trigger="llm_autonomous",
|
||||
metadata={
|
||||
"decision_method": "llm_autonomous",
|
||||
"decision_origin": "llm",
|
||||
"ai_reason": reason,
|
||||
},
|
||||
)
|
||||
queued.append(f"{action}@{target_ip}")
|
||||
logger.info(f"[LLM_AUTONOMOUS] → {action} @ {target_ip} (mac={mac}): {reason}")
|
||||
|
||||
if queued:
|
||||
try:
|
||||
self._sd.queue_event.set()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
except json.JSONDecodeError as e:
|
||||
logger.debug(f"LLM autonomous: JSON parse error: {e} — raw: {raw[:200]}")
|
||||
except Exception as e:
|
||||
logger.debug(f"LLM autonomous: action queue error: {e}")
|
||||
|
||||
return queued
|
||||
|
||||
def _build_autonomous_system_prompt(self, allowed: List[str], max_act: int) -> str:
|
||||
try:
|
||||
hosts = getattr(self._sd, "target_count", "?")
|
||||
vulns = getattr(self._sd, "vuln_count", "?")
|
||||
creds = getattr(self._sd, "cred_count", "?")
|
||||
mode = getattr(self._sd, "operation_mode", "?")
|
||||
except Exception:
|
||||
hosts = vulns = creds = mode = "?"
|
||||
|
||||
allowed_str = ", ".join(allowed) if allowed else "none"
|
||||
|
||||
lang = ""
|
||||
try:
|
||||
from llm_bridge import LLMBridge
|
||||
lang = LLMBridge()._lang_instruction()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
return (
|
||||
"You are Bjorn's Cyberviking autonomous orchestrator, running on a Raspberry Pi network security tool. "
|
||||
f"Current state: {hosts} hosts discovered, {vulns} vulnerabilities, {creds} credentials. "
|
||||
f"Operation mode: {mode}. "
|
||||
"Your objective: observe the network state via tools, then queue the most valuable actions. "
|
||||
f"Hard limit: at most {max_act} run_action calls per cycle. "
|
||||
f"Only these action names may be queued: {allowed_str}. "
|
||||
"Strategy: prioritise unexplored services, hosts with high port counts, and hosts with no recent scans. "
|
||||
"Do not queue duplicate actions already pending or recently successful. "
|
||||
"Use Norse references occasionally. Be terse and tactical."
|
||||
+ (f" {lang}" if lang else "")
|
||||
)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Shared helpers
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def _push_to_chat(self, bridge, user_prompt: str, assistant_response: str) -> None:
|
||||
"""
|
||||
Inject the LLM's reasoning into the 'llm_orchestrator' chat session
|
||||
so it can be reviewed in chat.html (load session 'llm_orchestrator').
|
||||
Keeps last 40 messages to avoid unbounded memory.
|
||||
"""
|
||||
try:
|
||||
with bridge._hist_lock:
|
||||
hist = bridge._chat_histories.setdefault("llm_orchestrator", [])
|
||||
hist.append({"role": "user", "content": f"[Autonomous cycle]\n{user_prompt}"})
|
||||
hist.append({"role": "assistant", "content": assistant_response})
|
||||
if len(hist) > 40:
|
||||
hist[:] = hist[-40:]
|
||||
except Exception as e:
|
||||
logger.debug(f"LLM reasoning push to chat failed: {e}")
|
||||
|
||||
def _resolve_mac(self, ip: str) -> str:
|
||||
"""Resolve IP → MAC from hosts table. Column is 'ips' (may hold multiple IPs)."""
|
||||
if not ip:
|
||||
return ""
|
||||
try:
|
||||
row = self._sd.db.query_one(
|
||||
"SELECT mac_address FROM hosts WHERE ips LIKE ? LIMIT 1", (f"%{ip}%",)
|
||||
)
|
||||
return row["mac_address"] if row else ""
|
||||
except Exception:
|
||||
return ""
|
||||
|
||||
def _build_snapshot(self) -> Dict[str, Any]:
|
||||
"""
|
||||
Rich state snapshot for advisor / autonomous prompts.
|
||||
|
||||
Includes:
|
||||
- alive_hosts : full host details (ip, mac, hostname, vendor, ports)
|
||||
- services : identified services per host (port, service, product, version)
|
||||
- vulns_found : active vulnerabilities per host
|
||||
- creds_found : captured credentials per host/service
|
||||
- available_actions : what the LLM can queue (name, description, target port/service)
|
||||
- pending_queue : actions already queued
|
||||
- recent_actions: last completed actions (avoid repeats)
|
||||
"""
|
||||
hosts, services, vulns, creds = [], [], [], []
|
||||
actions_catalog, pending, history = [], [], []
|
||||
|
||||
# ── Alive hosts ──
|
||||
try:
|
||||
rows = self._sd.db.query(
|
||||
"SELECT mac_address, ips, hostnames, ports, vendor "
|
||||
"FROM hosts WHERE alive=1 LIMIT 30"
|
||||
)
|
||||
for r in (rows or []):
|
||||
ip = (r.get("ips") or "").split(";")[0].strip()
|
||||
if not ip:
|
||||
continue
|
||||
hosts.append({
|
||||
"ip": ip,
|
||||
"mac": r.get("mac_address", ""),
|
||||
"hostname": (r.get("hostnames") or "").split(";")[0].strip(),
|
||||
"vendor": r.get("vendor", ""),
|
||||
"ports": r.get("ports", ""),
|
||||
})
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# ── Port services (identified services with product/version) ──
|
||||
try:
|
||||
rows = self._sd.db.query(
|
||||
"SELECT mac_address, ip, port, service, product, version "
|
||||
"FROM port_services WHERE is_current=1 AND state='open' "
|
||||
"ORDER BY mac_address, port LIMIT 100"
|
||||
)
|
||||
for r in (rows or []):
|
||||
svc = {"mac": r.get("mac_address", ""), "port": r.get("port")}
|
||||
if r.get("ip"):
|
||||
svc["ip"] = r["ip"]
|
||||
if r.get("service"):
|
||||
svc["service"] = r["service"]
|
||||
if r.get("product"):
|
||||
svc["product"] = r["product"]
|
||||
if r.get("version"):
|
||||
svc["version"] = r["version"]
|
||||
services.append(svc)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# ── Active vulnerabilities ──
|
||||
try:
|
||||
rows = self._sd.db.query(
|
||||
"SELECT ip, port, vuln_id, hostname "
|
||||
"FROM vulnerabilities WHERE is_active=1 LIMIT 30"
|
||||
)
|
||||
vulns = [{"ip": r.get("ip", ""), "port": r.get("port"),
|
||||
"vuln_id": r.get("vuln_id", ""),
|
||||
"hostname": r.get("hostname", "")}
|
||||
for r in (rows or [])]
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# ── Captured credentials ──
|
||||
try:
|
||||
rows = self._sd.db.query(
|
||||
"SELECT service, ip, hostname, port, \"user\" "
|
||||
"FROM creds LIMIT 30"
|
||||
)
|
||||
creds = [{"service": r.get("service", ""), "ip": r.get("ip", ""),
|
||||
"hostname": r.get("hostname", ""), "port": r.get("port"),
|
||||
"user": r.get("user", "")}
|
||||
for r in (rows or [])]
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# ── Available actions catalog (what the LLM can queue) ──
|
||||
allowed = self._allowed_actions()
|
||||
try:
|
||||
if allowed:
|
||||
placeholders = ",".join("?" * len(allowed))
|
||||
rows = self._sd.db.query(
|
||||
f"SELECT b_class, b_description, b_port, b_service "
|
||||
f"FROM actions WHERE b_class IN ({placeholders}) AND b_enabled=1",
|
||||
tuple(allowed)
|
||||
)
|
||||
for r in (rows or []):
|
||||
entry = {"name": r["b_class"]}
|
||||
if r.get("b_description"):
|
||||
entry["description"] = r["b_description"][:100]
|
||||
if r.get("b_port"):
|
||||
entry["target_port"] = r["b_port"]
|
||||
if r.get("b_service"):
|
||||
entry["target_service"] = r["b_service"]
|
||||
actions_catalog.append(entry)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# ── Pending queue ──
|
||||
try:
|
||||
rows = self._sd.db.query(
|
||||
"SELECT action_name, ip, priority FROM action_queue "
|
||||
"WHERE status='pending' ORDER BY priority DESC LIMIT 15"
|
||||
)
|
||||
pending = [{"action": r["action_name"], "ip": r["ip"]} for r in (rows or [])]
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# ── Recent action history ──
|
||||
try:
|
||||
rows = self._sd.db.query(
|
||||
"SELECT action_name, ip, status FROM action_queue "
|
||||
"WHERE status IN ('success','failed') ORDER BY completed_at DESC LIMIT 15"
|
||||
)
|
||||
history = [{"action": r["action_name"], "ip": r["ip"], "result": r["status"]}
|
||||
for r in (rows or [])]
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# Build explicit IP list for emphasis
|
||||
ip_list = [h["ip"] for h in hosts if h.get("ip")]
|
||||
|
||||
result = {
|
||||
"VALID_TARGET_IPS": ip_list,
|
||||
"hosts_alive": hosts,
|
||||
"operation_mode": getattr(self._sd, "operation_mode", "?"),
|
||||
}
|
||||
if services:
|
||||
result["services_detected"] = services
|
||||
if vulns:
|
||||
result["vulnerabilities_found"] = vulns
|
||||
if creds:
|
||||
result["credentials_captured"] = creds
|
||||
if actions_catalog:
|
||||
result["available_actions"] = actions_catalog
|
||||
result["pending_queue"] = pending
|
||||
result["recent_actions"] = history
|
||||
result["summary"] = {
|
||||
"hosts_alive": len(ip_list),
|
||||
"vulns": getattr(self._sd, "vuln_count", 0),
|
||||
"creds": getattr(self._sd, "cred_count", 0),
|
||||
}
|
||||
|
||||
return result
|
||||
Reference in New Issue
Block a user