diff --git a/.gitignore b/.gitignore index 7e1a2fa..c066dab 100644 --- a/.gitignore +++ b/.gitignore @@ -61,6 +61,9 @@ build/ build_temp/ *.spec.bak +# Local utility scripts +kill_autarch.bat + # OS files .DS_Store Thumbs.db diff --git a/autarch_public.spec b/autarch_public.spec index 152ae81..435ae89 100644 --- a/autarch_public.spec +++ b/autarch_public.spec @@ -64,6 +64,7 @@ hidden_imports = [ 'core.upnp', 'core.wireshark', 'core.wireguard', 'core.mcp_server', 'core.discovery', 'core.osint_db', 'core.nvd', + 'core.model_router', 'core.rules', 'core.autonomy', # Web routes (Flask blueprints) 'web.app', 'web.auth', @@ -90,6 +91,7 @@ hidden_imports = [ 'web.routes.targets', 'web.routes.encmodules', 'web.routes.llm_trainer', + 'web.routes.autonomy', # Standard library (sometimes missed on Windows) 'email.mime.text', 'email.mime.multipart', diff --git a/autarch_settings.conf b/autarch_settings.conf index a1ba24c..7f18999 100644 --- a/autarch_settings.conf +++ b/autarch_settings.conf @@ -117,3 +117,35 @@ host = 0.0.0.0 port = 17322 auto_start = false +[slm] +enabled = true +backend = local +model_path = +n_ctx = 512 +n_gpu_layers = -1 +n_threads = 2 + +[sam] +enabled = true +backend = local +model_path = +n_ctx = 2048 +n_gpu_layers = -1 +n_threads = 4 + +[lam] +enabled = true +backend = local +model_path = +n_ctx = 4096 +n_gpu_layers = -1 +n_threads = 4 + +[autonomy] +enabled = false +monitor_interval = 3 +rule_eval_interval = 5 +max_concurrent_agents = 3 +threat_threshold_auto_respond = 40 +log_max_entries = 1000 + diff --git a/core/autonomy.py b/core/autonomy.py new file mode 100644 index 0000000..5300c61 --- /dev/null +++ b/core/autonomy.py @@ -0,0 +1,665 @@ +""" +AUTARCH Autonomy Daemon +Background loop that monitors threats, evaluates rules, and dispatches +AI-driven responses across all categories (defense, offense, counter, +analyze, OSINT, simulate). + +The daemon ties together: + - ThreatMonitor (threat data gathering) + - RulesEngine (condition-action evaluation) + - ModelRouter (SLM/SAM/LAM model tiers) + - Agent (autonomous task execution) +""" + +import json +import logging +import threading +import time +import uuid +from collections import deque +from dataclasses import dataclass, field, asdict +from datetime import datetime +from pathlib import Path +from typing import List, Dict, Any, Optional, Deque + +from .config import get_config +from .rules import RulesEngine, Rule +from .model_router import get_model_router, ModelTier + +_logger = logging.getLogger('autarch.autonomy') + + +@dataclass +class ActivityEntry: + """Single entry in the autonomy activity log.""" + id: str + timestamp: str + rule_id: Optional[str] = None + rule_name: Optional[str] = None + tier: Optional[str] = None + action_type: str = '' + action_detail: str = '' + result: str = '' + success: bool = True + duration_ms: Optional[int] = None + + def to_dict(self) -> dict: + return asdict(self) + + +class AutonomyDaemon: + """Background daemon for autonomous threat response. + + Lifecycle: start() -> pause()/resume() -> stop() + """ + + LOG_PATH = Path(__file__).parent.parent / 'data' / 'autonomy_log.json' + + def __init__(self, config=None): + self.config = config or get_config() + self.rules_engine = RulesEngine() + self._router = None # Lazy — get_model_router() on start + + # State + self._thread: Optional[threading.Thread] = None + self._running = False + self._paused = False + self._stop_event = threading.Event() + + # Agent tracking + self._active_agents: Dict[str, threading.Thread] = {} + self._agent_lock = threading.Lock() + + # Activity log (ring buffer) + settings = self.config.get_autonomy_settings() + max_entries = settings.get('log_max_entries', 1000) + self._activity: Deque[ActivityEntry] = deque(maxlen=max_entries) + self._activity_lock = threading.Lock() + + # SSE subscribers + self._subscribers: List = [] + self._sub_lock = threading.Lock() + + # Load persisted log + self._load_log() + + # ------------------------------------------------------------------ + # Lifecycle + # ------------------------------------------------------------------ + + @property + def status(self) -> dict: + """Current daemon status.""" + settings = self.config.get_autonomy_settings() + with self._agent_lock: + active = len(self._active_agents) + return { + 'running': self._running, + 'paused': self._paused, + 'enabled': settings['enabled'], + 'monitor_interval': settings['monitor_interval'], + 'rule_eval_interval': settings['rule_eval_interval'], + 'active_agents': active, + 'max_agents': settings['max_concurrent_agents'], + 'rules_count': len(self.rules_engine.get_all_rules()), + 'activity_count': len(self._activity), + } + + def start(self) -> bool: + """Start the autonomy daemon background thread.""" + if self._running: + _logger.warning('[Autonomy] Already running') + return False + + self._router = get_model_router() + self._running = True + self._paused = False + self._stop_event.clear() + + self._thread = threading.Thread( + target=self._run_loop, + name='AutonomyDaemon', + daemon=True, + ) + self._thread.start() + self._log_activity('system', 'Autonomy daemon started') + _logger.info('[Autonomy] Daemon started') + return True + + def stop(self): + """Stop the daemon and wait for thread exit.""" + if not self._running: + return + self._running = False + self._stop_event.set() + if self._thread and self._thread.is_alive(): + self._thread.join(timeout=10) + self._log_activity('system', 'Autonomy daemon stopped') + _logger.info('[Autonomy] Daemon stopped') + + def pause(self): + """Pause rule evaluation (monitoring continues).""" + self._paused = True + self._log_activity('system', 'Autonomy paused') + _logger.info('[Autonomy] Paused') + + def resume(self): + """Resume rule evaluation.""" + self._paused = False + self._log_activity('system', 'Autonomy resumed') + _logger.info('[Autonomy] Resumed') + + # ------------------------------------------------------------------ + # Main loop + # ------------------------------------------------------------------ + + def _run_loop(self): + """Background loop: gather context, evaluate rules, dispatch.""" + settings = self.config.get_autonomy_settings() + monitor_interval = settings['monitor_interval'] + rule_eval_interval = settings['rule_eval_interval'] + last_rule_eval = 0 + + while self._running and not self._stop_event.is_set(): + try: + # Gather threat context every cycle + context = self._gather_context() + + # Evaluate rules at a slower cadence + now = time.time() + if not self._paused and (now - last_rule_eval) >= rule_eval_interval: + last_rule_eval = now + self._evaluate_and_dispatch(context) + + except Exception as e: + _logger.error(f'[Autonomy] Loop error: {e}') + self._log_activity('error', f'Loop error: {e}', success=False) + + # Sleep in short increments so stop is responsive + self._stop_event.wait(timeout=monitor_interval) + + def _gather_context(self) -> Dict[str, Any]: + """Gather current threat context from ThreatMonitor.""" + try: + from modules.defender_monitor import get_threat_monitor + tm = get_threat_monitor() + except ImportError: + _logger.warning('[Autonomy] ThreatMonitor not available') + return {'timestamp': datetime.now().isoformat()} + + context: Dict[str, Any] = { + 'timestamp': datetime.now().isoformat(), + } + + try: + context['connections'] = tm.get_connections() + context['connection_count'] = len(context['connections']) + except Exception: + context['connections'] = [] + context['connection_count'] = 0 + + try: + context['bandwidth'] = {} + bw = tm.get_bandwidth() + if bw: + total_rx = sum(iface.get('rx_delta', 0) for iface in bw) + total_tx = sum(iface.get('tx_delta', 0) for iface in bw) + context['bandwidth'] = { + 'rx_mbps': (total_rx * 8) / 1_000_000, + 'tx_mbps': (total_tx * 8) / 1_000_000, + 'interfaces': bw, + } + except Exception: + context['bandwidth'] = {'rx_mbps': 0, 'tx_mbps': 0} + + try: + context['arp_alerts'] = tm.check_arp_spoofing() + except Exception: + context['arp_alerts'] = [] + + try: + context['new_ports'] = tm.check_new_listening_ports() + except Exception: + context['new_ports'] = [] + + try: + context['threat_score'] = tm.calculate_threat_score() + except Exception: + context['threat_score'] = {'score': 0, 'level': 'LOW', 'details': []} + + try: + context['ddos'] = tm.detect_ddos() + except Exception: + context['ddos'] = {'under_attack': False} + + try: + context['scan_indicators'] = tm.check_port_scan_indicators() + if isinstance(context['scan_indicators'], list): + context['scan_indicators'] = len(context['scan_indicators']) + except Exception: + context['scan_indicators'] = 0 + + return context + + # ------------------------------------------------------------------ + # Rule evaluation and dispatch + # ------------------------------------------------------------------ + + def _evaluate_and_dispatch(self, context: Dict[str, Any]): + """Evaluate rules and dispatch matching actions.""" + matches = self.rules_engine.evaluate(context) + + for rule, resolved_actions in matches: + for action in resolved_actions: + action_type = action.get('type', '') + _logger.info(f'[Autonomy] Rule "{rule.name}" triggered -> {action_type}') + + if self._is_agent_action(action_type): + self._dispatch_agent(rule, action, context) + else: + self._dispatch_direct(rule, action, context) + + def _is_agent_action(self, action_type: str) -> bool: + """Check if an action requires an AI agent.""" + return action_type in ('run_module', 'counter_scan', 'escalate_to_lam') + + def _dispatch_direct(self, rule: Rule, action: dict, context: dict): + """Execute a simple action directly (no LLM needed).""" + action_type = action.get('type', '') + start = time.time() + success = True + result = '' + + try: + if action_type == 'block_ip': + result = self._action_block_ip(action.get('ip', '')) + + elif action_type == 'unblock_ip': + result = self._action_unblock_ip(action.get('ip', '')) + + elif action_type == 'rate_limit_ip': + result = self._action_rate_limit( + action.get('ip', ''), + action.get('rate', '10/s'), + ) + + elif action_type == 'block_port': + result = self._action_block_port( + action.get('port', ''), + action.get('direction', 'inbound'), + ) + + elif action_type == 'kill_process': + result = self._action_kill_process(action.get('pid', '')) + + elif action_type in ('alert', 'log_event'): + result = action.get('message', 'No message') + + elif action_type == 'run_shell': + result = self._action_run_shell(action.get('command', '')) + + else: + result = f'Unknown action type: {action_type}' + success = False + + except Exception as e: + result = f'Error: {e}' + success = False + + duration = int((time.time() - start) * 1000) + detail = action.get('ip', '') or action.get('port', '') or action.get('message', '')[:80] + self._log_activity( + action_type, detail, + rule_id=rule.id, rule_name=rule.name, + result=result, success=success, duration_ms=duration, + ) + + def _dispatch_agent(self, rule: Rule, action: dict, context: dict): + """Spawn an AI agent to handle a complex action.""" + settings = self.config.get_autonomy_settings() + max_agents = settings['max_concurrent_agents'] + + # Clean finished agents + with self._agent_lock: + self._active_agents = { + k: v for k, v in self._active_agents.items() + if v.is_alive() + } + if len(self._active_agents) >= max_agents: + _logger.warning('[Autonomy] Max agents reached, skipping') + self._log_activity( + action.get('type', 'agent'), 'Skipped: max agents reached', + rule_id=rule.id, rule_name=rule.name, + success=False, + ) + return + + agent_id = str(uuid.uuid4())[:8] + action_type = action.get('type', '') + + # Determine tier + if action_type == 'escalate_to_lam': + tier = ModelTier.LAM + else: + tier = ModelTier.SAM + + t = threading.Thread( + target=self._run_agent, + args=(agent_id, tier, rule, action, context), + name=f'Agent-{agent_id}', + daemon=True, + ) + + with self._agent_lock: + self._active_agents[agent_id] = t + + t.start() + self._log_activity( + action_type, f'Agent {agent_id} spawned ({tier.value})', + rule_id=rule.id, rule_name=rule.name, tier=tier.value, + ) + + def _run_agent(self, agent_id: str, tier: ModelTier, rule: Rule, + action: dict, context: dict): + """Execute an agent task in a background thread.""" + from .agent import Agent + from .tools import get_tool_registry + + action_type = action.get('type', '') + start = time.time() + + # Build task prompt + if action_type == 'run_module': + module = action.get('module', '') + args = action.get('args', '') + task = f'Run the AUTARCH module "{module}" with arguments: {args}' + + elif action_type == 'counter_scan': + target = action.get('target', '') + task = f'Perform a counter-scan against {target}. Gather reconnaissance and identify vulnerabilities.' + + elif action_type == 'escalate_to_lam': + task = action.get('task', 'Analyze the current threat landscape and recommend actions.') + + else: + task = f'Execute action: {action_type} with params: {json.dumps(action)}' + + # Get LLM instance for the tier + router = self._router or get_model_router() + llm_inst = router.get_instance(tier) + + if llm_inst is None or not llm_inst.is_loaded: + # Try fallback + for fallback in (ModelTier.SAM, ModelTier.LAM): + llm_inst = router.get_instance(fallback) + if llm_inst and llm_inst.is_loaded: + tier = fallback + break + else: + self._log_activity( + action_type, f'Agent {agent_id}: no model loaded', + rule_id=rule.id, rule_name=rule.name, + tier=tier.value, success=False, + result='No model available for agent execution', + ) + return + + try: + agent = Agent( + llm=llm_inst, + tools=get_tool_registry(), + max_steps=15, + verbose=False, + ) + result = agent.run(task) + duration = int((time.time() - start) * 1000) + + self._log_activity( + action_type, + f'Agent {agent_id}: {result.summary[:100]}', + rule_id=rule.id, rule_name=rule.name, + tier=tier.value, success=result.success, + result=result.summary, duration_ms=duration, + ) + + except Exception as e: + duration = int((time.time() - start) * 1000) + _logger.error(f'[Autonomy] Agent {agent_id} failed: {e}') + self._log_activity( + action_type, f'Agent {agent_id} failed: {e}', + rule_id=rule.id, rule_name=rule.name, + tier=tier.value, success=False, + result=str(e), duration_ms=duration, + ) + + finally: + with self._agent_lock: + self._active_agents.pop(agent_id, None) + + # ------------------------------------------------------------------ + # Direct action implementations + # ------------------------------------------------------------------ + + def _action_block_ip(self, ip: str) -> str: + if not ip: + return 'No IP specified' + try: + from modules.defender_monitor import get_threat_monitor + tm = get_threat_monitor() + tm.auto_block_ip(ip) + return f'Blocked {ip}' + except Exception as e: + return f'Block failed: {e}' + + def _action_unblock_ip(self, ip: str) -> str: + if not ip: + return 'No IP specified' + try: + import subprocess, platform + if platform.system() == 'Windows': + cmd = f'netsh advfirewall firewall delete rule name="AUTARCH Block {ip}"' + else: + cmd = f'iptables -D INPUT -s {ip} -j DROP 2>/dev/null; iptables -D OUTPUT -d {ip} -j DROP 2>/dev/null' + subprocess.run(cmd, shell=True, capture_output=True, timeout=10) + return f'Unblocked {ip}' + except Exception as e: + return f'Unblock failed: {e}' + + def _action_rate_limit(self, ip: str, rate: str) -> str: + if not ip: + return 'No IP specified' + try: + from modules.defender_monitor import get_threat_monitor + tm = get_threat_monitor() + tm.apply_rate_limit(ip) + return f'Rate limited {ip} at {rate}' + except Exception as e: + return f'Rate limit failed: {e}' + + def _action_block_port(self, port: str, direction: str) -> str: + if not port: + return 'No port specified' + try: + import subprocess, platform + if platform.system() == 'Windows': + d = 'in' if direction == 'inbound' else 'out' + cmd = f'netsh advfirewall firewall add rule name="AUTARCH Block Port {port}" dir={d} action=block protocol=TCP localport={port}' + else: + chain = 'INPUT' if direction == 'inbound' else 'OUTPUT' + cmd = f'iptables -A {chain} -p tcp --dport {port} -j DROP' + subprocess.run(cmd, shell=True, capture_output=True, timeout=10) + return f'Blocked port {port} ({direction})' + except Exception as e: + return f'Block port failed: {e}' + + def _action_kill_process(self, pid: str) -> str: + if not pid: + return 'No PID specified' + try: + import subprocess, platform + if platform.system() == 'Windows': + cmd = f'taskkill /F /PID {pid}' + else: + cmd = f'kill -9 {pid}' + subprocess.run(cmd, shell=True, capture_output=True, timeout=10) + return f'Killed process {pid}' + except Exception as e: + return f'Kill failed: {e}' + + def _action_run_shell(self, command: str) -> str: + if not command: + return 'No command specified' + try: + import subprocess + result = subprocess.run( + command, shell=True, capture_output=True, + text=True, timeout=30, + ) + output = result.stdout[:500] + if result.returncode != 0: + output += f'\n[exit {result.returncode}]' + return output.strip() or '[no output]' + except Exception as e: + return f'Shell failed: {e}' + + # ------------------------------------------------------------------ + # Activity log + # ------------------------------------------------------------------ + + def _log_activity(self, action_type: str, detail: str, *, + rule_id: str = None, rule_name: str = None, + tier: str = None, result: str = '', + success: bool = True, duration_ms: int = None): + """Add an entry to the activity log and notify SSE subscribers.""" + entry = ActivityEntry( + id=str(uuid.uuid4())[:8], + timestamp=datetime.now().isoformat(), + rule_id=rule_id, + rule_name=rule_name, + tier=tier, + action_type=action_type, + action_detail=detail, + result=result, + success=success, + duration_ms=duration_ms, + ) + + with self._activity_lock: + self._activity.append(entry) + + # Notify SSE subscribers + self._notify_subscribers(entry) + + # Persist periodically (every 10 entries) + if len(self._activity) % 10 == 0: + self._save_log() + + def get_activity(self, limit: int = 50, offset: int = 0) -> List[dict]: + """Get recent activity entries.""" + with self._activity_lock: + entries = list(self._activity) + entries.reverse() # Newest first + return [e.to_dict() for e in entries[offset:offset + limit]] + + def get_activity_count(self) -> int: + return len(self._activity) + + # ------------------------------------------------------------------ + # SSE streaming + # ------------------------------------------------------------------ + + def subscribe(self): + """Create an SSE subscriber queue.""" + import queue + q = queue.Queue(maxsize=100) + with self._sub_lock: + self._subscribers.append(q) + return q + + def unsubscribe(self, q): + """Remove an SSE subscriber.""" + with self._sub_lock: + try: + self._subscribers.remove(q) + except ValueError: + pass + + def _notify_subscribers(self, entry: ActivityEntry): + """Push an activity entry to all SSE subscribers.""" + data = json.dumps(entry.to_dict()) + with self._sub_lock: + dead = [] + for q in self._subscribers: + try: + q.put_nowait(data) + except Exception: + dead.append(q) + for q in dead: + try: + self._subscribers.remove(q) + except ValueError: + pass + + # ------------------------------------------------------------------ + # Persistence + # ------------------------------------------------------------------ + + def _save_log(self): + """Persist activity log to JSON file.""" + try: + self.LOG_PATH.parent.mkdir(parents=True, exist_ok=True) + with self._activity_lock: + entries = [e.to_dict() for e in self._activity] + self.LOG_PATH.write_text( + json.dumps({'entries': entries[-200:]}, indent=2), + encoding='utf-8', + ) + except Exception as e: + _logger.error(f'[Autonomy] Failed to save log: {e}') + + def _load_log(self): + """Load persisted activity log.""" + if not self.LOG_PATH.exists(): + return + try: + data = json.loads(self.LOG_PATH.read_text(encoding='utf-8')) + for entry_dict in data.get('entries', []): + entry = ActivityEntry( + id=entry_dict.get('id', str(uuid.uuid4())[:8]), + timestamp=entry_dict.get('timestamp', ''), + rule_id=entry_dict.get('rule_id'), + rule_name=entry_dict.get('rule_name'), + tier=entry_dict.get('tier'), + action_type=entry_dict.get('action_type', ''), + action_detail=entry_dict.get('action_detail', ''), + result=entry_dict.get('result', ''), + success=entry_dict.get('success', True), + duration_ms=entry_dict.get('duration_ms'), + ) + self._activity.append(entry) + _logger.info(f'[Autonomy] Loaded {len(self._activity)} log entries') + except Exception as e: + _logger.error(f'[Autonomy] Failed to load log: {e}') + + +# ------------------------------------------------------------------ +# Singleton +# ------------------------------------------------------------------ + +_daemon_instance: Optional[AutonomyDaemon] = None + + +def get_autonomy_daemon() -> AutonomyDaemon: + """Get the global AutonomyDaemon instance.""" + global _daemon_instance + if _daemon_instance is None: + _daemon_instance = AutonomyDaemon() + return _daemon_instance + + +def reset_autonomy_daemon(): + """Stop and reset the global daemon.""" + global _daemon_instance + if _daemon_instance is not None: + _daemon_instance.stop() + _daemon_instance = None diff --git a/core/config.py b/core/config.py index d89c843..4c229c6 100644 --- a/core/config.py +++ b/core/config.py @@ -87,7 +87,39 @@ class Config: 'host': '0.0.0.0', 'port': '17322', 'auto_start': 'false', - } + }, + 'slm': { + 'enabled': 'true', + 'backend': 'local', + 'model_path': '', + 'n_ctx': '512', + 'n_gpu_layers': '-1', + 'n_threads': '2', + }, + 'sam': { + 'enabled': 'true', + 'backend': 'local', + 'model_path': '', + 'n_ctx': '2048', + 'n_gpu_layers': '-1', + 'n_threads': '4', + }, + 'lam': { + 'enabled': 'true', + 'backend': 'local', + 'model_path': '', + 'n_ctx': '4096', + 'n_gpu_layers': '-1', + 'n_threads': '4', + }, + 'autonomy': { + 'enabled': 'false', + 'monitor_interval': '3', + 'rule_eval_interval': '5', + 'max_concurrent_agents': '3', + 'threat_threshold_auto_respond': '40', + 'log_max_entries': '1000', + }, } def __init__(self, config_path: str = None): @@ -332,6 +364,40 @@ class Config: 'auto_start': self.get_bool('revshell', 'auto_start', False), } + def get_tier_settings(self, tier: str) -> dict: + """Get settings for a model tier (slm, sam, lam).""" + return { + 'enabled': self.get_bool(tier, 'enabled', True), + 'backend': self.get(tier, 'backend', 'local'), + 'model_path': self.get(tier, 'model_path', ''), + 'n_ctx': self.get_int(tier, 'n_ctx', 2048), + 'n_gpu_layers': self.get_int(tier, 'n_gpu_layers', -1), + 'n_threads': self.get_int(tier, 'n_threads', 4), + } + + def get_slm_settings(self) -> dict: + """Get Small Language Model tier settings.""" + return self.get_tier_settings('slm') + + def get_sam_settings(self) -> dict: + """Get Small Action Model tier settings.""" + return self.get_tier_settings('sam') + + def get_lam_settings(self) -> dict: + """Get Large Action Model tier settings.""" + return self.get_tier_settings('lam') + + def get_autonomy_settings(self) -> dict: + """Get autonomy daemon settings.""" + return { + 'enabled': self.get_bool('autonomy', 'enabled', False), + 'monitor_interval': self.get_int('autonomy', 'monitor_interval', 3), + 'rule_eval_interval': self.get_int('autonomy', 'rule_eval_interval', 5), + 'max_concurrent_agents': self.get_int('autonomy', 'max_concurrent_agents', 3), + 'threat_threshold_auto_respond': self.get_int('autonomy', 'threat_threshold_auto_respond', 40), + 'log_max_entries': self.get_int('autonomy', 'log_max_entries', 1000), + } + @staticmethod def get_templates_dir() -> Path: """Get the path to the configuration templates directory.""" diff --git a/core/model_router.py b/core/model_router.py new file mode 100644 index 0000000..b264697 --- /dev/null +++ b/core/model_router.py @@ -0,0 +1,305 @@ +""" +AUTARCH Model Router +Manages concurrent SLM/LAM/SAM model instances for autonomous operation. + +Model Tiers: + SLM (Small Language Model) — Fast classification, routing, yes/no decisions + SAM (Small Action Model) — Quick tool execution, simple automated responses + LAM (Large Action Model) — Complex multi-step agent tasks, strategic planning +""" + +import json +import logging +import threading +from typing import Optional, Dict, Any +from enum import Enum + +from .config import get_config + +_logger = logging.getLogger('autarch.model_router') + + +class ModelTier(Enum): + SLM = 'slm' + SAM = 'sam' + LAM = 'lam' + + +# Fallback chain: if a tier fails, try the next one +_FALLBACK = { + ModelTier.SLM: [ModelTier.SAM, ModelTier.LAM], + ModelTier.SAM: [ModelTier.LAM], + ModelTier.LAM: [], +} + + +class _TierConfigProxy: + """Proxies Config but overrides the backend section for a specific model tier. + + When a tier says backend=local with model_path=X, this proxy makes the LLM + class (which reads [llama]) see the tier's model_path/n_ctx/etc instead. + """ + + def __init__(self, base_config, tier_name: str): + self._base = base_config + self._tier = tier_name + self._overrides: Dict[str, Dict[str, str]] = {} + self._build_overrides() + + def _build_overrides(self): + backend = self._base.get(self._tier, 'backend', 'local') + model_path = self._base.get(self._tier, 'model_path', '') + n_ctx = self._base.get(self._tier, 'n_ctx', '2048') + n_gpu_layers = self._base.get(self._tier, 'n_gpu_layers', '-1') + n_threads = self._base.get(self._tier, 'n_threads', '4') + + if backend == 'local': + self._overrides['llama'] = { + 'model_path': model_path, + 'n_ctx': n_ctx, + 'n_gpu_layers': n_gpu_layers, + 'n_threads': n_threads, + } + elif backend == 'transformers': + self._overrides['transformers'] = { + 'model_path': model_path, + } + # claude and huggingface are API-based — no path override needed + + def get(self, section: str, key: str, fallback=None): + overrides = self._overrides.get(section, {}) + if key in overrides: + return overrides[key] + return self._base.get(section, key, fallback) + + def get_int(self, section: str, key: str, fallback: int = 0) -> int: + overrides = self._overrides.get(section, {}) + if key in overrides: + try: + return int(overrides[key]) + except (ValueError, TypeError): + return fallback + return self._base.get_int(section, key, fallback) + + def get_float(self, section: str, key: str, fallback: float = 0.0) -> float: + overrides = self._overrides.get(section, {}) + if key in overrides: + try: + return float(overrides[key]) + except (ValueError, TypeError): + return fallback + return self._base.get_float(section, key, fallback) + + def get_bool(self, section: str, key: str, fallback: bool = False) -> bool: + overrides = self._overrides.get(section, {}) + if key in overrides: + val = str(overrides[key]).lower() + return val in ('true', '1', 'yes', 'on') + return self._base.get_bool(section, key, fallback) + + # Delegate all settings getters to base (they call self.get internally) + def get_llama_settings(self) -> dict: + from .config import Config + return Config.get_llama_settings(self) + + def get_transformers_settings(self) -> dict: + from .config import Config + return Config.get_transformers_settings(self) + + def get_claude_settings(self) -> dict: + return self._base.get_claude_settings() + + def get_huggingface_settings(self) -> dict: + return self._base.get_huggingface_settings() + + +class ModelRouter: + """Manages up to 3 concurrent LLM instances (SLM, SAM, LAM). + + Each tier can use a different backend (local GGUF, transformers, Claude API, + HuggingFace). The router handles loading, unloading, fallback, and thread-safe + access. + """ + + def __init__(self, config=None): + self.config = config or get_config() + self._instances: Dict[ModelTier, Any] = {} + self._locks: Dict[ModelTier, threading.Lock] = { + tier: threading.Lock() for tier in ModelTier + } + self._load_lock = threading.Lock() + + @property + def status(self) -> Dict[str, dict]: + """Return load status of all tiers.""" + result = {} + for tier in ModelTier: + inst = self._instances.get(tier) + settings = self.config.get_tier_settings(tier.value) + result[tier.value] = { + 'loaded': inst is not None and inst.is_loaded, + 'model_name': inst.model_name if inst and inst.is_loaded else None, + 'backend': settings['backend'], + 'enabled': settings['enabled'], + 'model_path': settings['model_path'], + } + return result + + def load_tier(self, tier: ModelTier, verbose: bool = False) -> bool: + """Load a single tier's model. Thread-safe.""" + settings = self.config.get_tier_settings(tier.value) + + if not settings['enabled']: + _logger.info(f"[Router] Tier {tier.value} is disabled, skipping") + return False + + if not settings['model_path'] and settings['backend'] == 'local': + _logger.warning(f"[Router] No model_path configured for {tier.value}") + return False + + with self._load_lock: + # Unload existing if any + if tier in self._instances: + self.unload_tier(tier) + + try: + inst = self._create_instance(tier, verbose) + self._instances[tier] = inst + _logger.info(f"[Router] Loaded {tier.value}: {inst.model_name}") + return True + except Exception as e: + _logger.error(f"[Router] Failed to load {tier.value}: {e}") + return False + + def unload_tier(self, tier: ModelTier): + """Unload a tier's model and free resources.""" + inst = self._instances.pop(tier, None) + if inst: + try: + inst.unload_model() + _logger.info(f"[Router] Unloaded {tier.value}") + except Exception as e: + _logger.error(f"[Router] Error unloading {tier.value}: {e}") + + def load_all(self, verbose: bool = False) -> Dict[str, bool]: + """Load all enabled tiers. Returns {tier_name: success}.""" + results = {} + for tier in ModelTier: + results[tier.value] = self.load_tier(tier, verbose) + return results + + def unload_all(self): + """Unload all tiers.""" + for tier in list(self._instances.keys()): + self.unload_tier(tier) + + def get_instance(self, tier: ModelTier): + """Get the LLM instance for a tier (may be None if not loaded).""" + return self._instances.get(tier) + + def is_tier_loaded(self, tier: ModelTier) -> bool: + """Check if a tier has a loaded model.""" + inst = self._instances.get(tier) + return inst is not None and inst.is_loaded + + def classify(self, text: str) -> Dict[str, Any]: + """Use SLM to classify/triage an event or task. + + Returns: {'tier': 'sam'|'lam', 'category': str, 'urgency': str, 'reasoning': str} + + Falls back to SAM tier if SLM is not loaded. + """ + classify_prompt = f"""Classify this event/task for autonomous handling. +Respond with ONLY a JSON object, no other text: +{{"tier": "sam" or "lam", "category": "defense|offense|counter|analyze|osint|simulate", "urgency": "high|medium|low", "reasoning": "brief explanation"}} + +Event: {text}""" + + # Try SLM first, then fallback + for tier in [ModelTier.SLM, ModelTier.SAM, ModelTier.LAM]: + inst = self._instances.get(tier) + if inst and inst.is_loaded: + try: + with self._locks[tier]: + response = inst.generate(classify_prompt, max_tokens=200, temperature=0.1) + # Parse JSON from response + response = response.strip() + # Find JSON in response + start = response.find('{') + end = response.rfind('}') + if start >= 0 and end > start: + return json.loads(response[start:end + 1]) + except Exception as e: + _logger.warning(f"[Router] Classification failed on {tier.value}: {e}") + continue + + # Default if all tiers fail + return {'tier': 'sam', 'category': 'defense', 'urgency': 'medium', + 'reasoning': 'Default classification (no model available)'} + + def generate(self, tier: ModelTier, prompt: str, **kwargs) -> str: + """Generate with a specific tier, falling back to higher tiers on failure. + + Fallback chain: SLM -> SAM -> LAM, SAM -> LAM + """ + chain = [tier] + _FALLBACK.get(tier, []) + + for t in chain: + inst = self._instances.get(t) + if inst and inst.is_loaded: + try: + with self._locks[t]: + return inst.generate(prompt, **kwargs) + except Exception as e: + _logger.warning(f"[Router] Generate failed on {t.value}: {e}") + continue + + from .llm import LLMError + raise LLMError(f"All tiers exhausted for generation (started at {tier.value})") + + def _create_instance(self, tier: ModelTier, verbose: bool = False): + """Create an LLM instance from tier config.""" + from .llm import LLM, TransformersLLM, ClaudeLLM, HuggingFaceLLM + + section = tier.value + backend = self.config.get(section, 'backend', 'local') + proxy = _TierConfigProxy(self.config, section) + + if verbose: + model_path = self.config.get(section, 'model_path', '') + _logger.info(f"[Router] Creating {tier.value} instance: backend={backend}, model={model_path}") + + if backend == 'local': + inst = LLM(proxy) + elif backend == 'transformers': + inst = TransformersLLM(proxy) + elif backend == 'claude': + inst = ClaudeLLM(proxy) + elif backend == 'huggingface': + inst = HuggingFaceLLM(proxy) + else: + from .llm import LLMError + raise LLMError(f"Unknown backend '{backend}' for tier {tier.value}") + + inst.load_model(verbose=verbose) + return inst + + +# Singleton +_router_instance = None + + +def get_model_router() -> ModelRouter: + """Get the global ModelRouter instance.""" + global _router_instance + if _router_instance is None: + _router_instance = ModelRouter() + return _router_instance + + +def reset_model_router(): + """Reset the global ModelRouter (unloads all models).""" + global _router_instance + if _router_instance is not None: + _router_instance.unload_all() + _router_instance = None diff --git a/core/rules.py b/core/rules.py new file mode 100644 index 0000000..539b276 --- /dev/null +++ b/core/rules.py @@ -0,0 +1,333 @@ +""" +AUTARCH Automation Rules Engine +Condition-action rules for autonomous threat response. + +Rules are JSON-serializable and stored in data/automation_rules.json. +The engine evaluates conditions against a threat context dict and returns +matching rules with resolved action parameters. +""" + +import json +import logging +import re +import ipaddress +import uuid +from datetime import datetime +from pathlib import Path +from typing import List, Dict, Any, Optional, Tuple +from dataclasses import dataclass, field, asdict + +_logger = logging.getLogger('autarch.rules') + + +@dataclass +class Rule: + """A single automation rule.""" + id: str + name: str + enabled: bool = True + priority: int = 50 # 0=highest, 100=lowest + conditions: List[Dict] = field(default_factory=list) # AND-combined + actions: List[Dict] = field(default_factory=list) + cooldown_seconds: int = 60 + last_triggered: Optional[str] = None # ISO timestamp + created: Optional[str] = None + description: str = '' + + def to_dict(self) -> dict: + return asdict(self) + + @classmethod + def from_dict(cls, d: dict) -> 'Rule': + return cls( + id=d.get('id', str(uuid.uuid4())[:8]), + name=d.get('name', 'Untitled'), + enabled=d.get('enabled', True), + priority=d.get('priority', 50), + conditions=d.get('conditions', []), + actions=d.get('actions', []), + cooldown_seconds=d.get('cooldown_seconds', 60), + last_triggered=d.get('last_triggered'), + created=d.get('created'), + description=d.get('description', ''), + ) + + +class RulesEngine: + """Evaluates automation rules against a threat context.""" + + RULES_PATH = Path(__file__).parent.parent / 'data' / 'automation_rules.json' + + CONDITION_TYPES = { + 'threat_score_above', 'threat_score_below', 'threat_level_is', + 'port_scan_detected', 'ddos_detected', 'ddos_attack_type', + 'connection_from_ip', 'connection_count_above', + 'new_listening_port', 'bandwidth_rx_above_mbps', + 'arp_spoof_detected', 'schedule', 'always', + } + + ACTION_TYPES = { + 'block_ip', 'unblock_ip', 'rate_limit_ip', 'block_port', + 'kill_process', 'alert', 'log_event', 'run_shell', + 'run_module', 'counter_scan', 'escalate_to_lam', + } + + def __init__(self): + self._rules: List[Rule] = [] + self._load() + + def _load(self): + """Load rules from JSON file.""" + if not self.RULES_PATH.exists(): + self._rules = [] + return + try: + data = json.loads(self.RULES_PATH.read_text(encoding='utf-8')) + self._rules = [Rule.from_dict(r) for r in data.get('rules', [])] + _logger.info(f"[Rules] Loaded {len(self._rules)} rules") + except Exception as e: + _logger.error(f"[Rules] Failed to load rules: {e}") + self._rules = [] + + def save(self): + """Save rules to JSON file.""" + self.RULES_PATH.parent.mkdir(parents=True, exist_ok=True) + data = { + 'version': 1, + 'rules': [r.to_dict() for r in self._rules], + } + self.RULES_PATH.write_text(json.dumps(data, indent=2), encoding='utf-8') + + def add_rule(self, rule: Rule) -> Rule: + if not rule.created: + rule.created = datetime.now().isoformat() + self._rules.append(rule) + self._rules.sort(key=lambda r: r.priority) + self.save() + return rule + + def update_rule(self, rule_id: str, updates: dict) -> Optional[Rule]: + for rule in self._rules: + if rule.id == rule_id: + for key, value in updates.items(): + if hasattr(rule, key) and key != 'id': + setattr(rule, key, value) + self._rules.sort(key=lambda r: r.priority) + self.save() + return rule + return None + + def delete_rule(self, rule_id: str) -> bool: + before = len(self._rules) + self._rules = [r for r in self._rules if r.id != rule_id] + if len(self._rules) < before: + self.save() + return True + return False + + def get_rule(self, rule_id: str) -> Optional[Rule]: + for rule in self._rules: + if rule.id == rule_id: + return rule + return None + + def get_all_rules(self) -> List[Rule]: + return list(self._rules) + + def evaluate(self, context: Dict[str, Any]) -> List[Tuple[Rule, List[Dict]]]: + """Evaluate all enabled rules against a threat context. + + Args: + context: Dict with keys from ThreatMonitor / AutonomyDaemon: + - threat_score: {'score': int, 'level': str, 'details': [...]} + - connection_count: int + - connections: [...] + - ddos: {'under_attack': bool, 'attack_type': str, ...} + - new_ports: [{'port': int, 'process': str}, ...] + - arp_alerts: [...] + - bandwidth: {'rx_mbps': float, 'tx_mbps': float} + - scan_indicators: int + - timestamp: str + + Returns: + List of (Rule, resolved_actions) for rules that match and aren't in cooldown. + """ + matches = [] + now = datetime.now() + + for rule in self._rules: + if not rule.enabled: + continue + + # Check cooldown + if rule.last_triggered: + try: + last = datetime.fromisoformat(rule.last_triggered) + if (now - last).total_seconds() < rule.cooldown_seconds: + continue + except (ValueError, TypeError): + pass + + # Evaluate all conditions (AND logic) + if not rule.conditions: + continue + + all_match = all( + self._evaluate_condition(cond, context) + for cond in rule.conditions + ) + + if all_match: + # Resolve action variables + resolved = [self._resolve_variables(a, context) for a in rule.actions] + matches.append((rule, resolved)) + + # Mark triggered + rule.last_triggered = now.isoformat() + + # Save updated trigger times + if matches: + self.save() + + return matches + + def _evaluate_condition(self, condition: dict, context: dict) -> bool: + """Evaluate a single condition against context.""" + ctype = condition.get('type', '') + value = condition.get('value') + + if ctype == 'threat_score_above': + return context.get('threat_score', {}).get('score', 0) > (value or 0) + + elif ctype == 'threat_score_below': + return context.get('threat_score', {}).get('score', 0) < (value or 100) + + elif ctype == 'threat_level_is': + return context.get('threat_score', {}).get('level', 'LOW') == (value or 'HIGH') + + elif ctype == 'port_scan_detected': + return context.get('scan_indicators', 0) > 0 + + elif ctype == 'ddos_detected': + return context.get('ddos', {}).get('under_attack', False) + + elif ctype == 'ddos_attack_type': + return context.get('ddos', {}).get('attack_type', '') == (value or '') + + elif ctype == 'connection_from_ip': + return self._check_ip_match(value, context.get('connections', [])) + + elif ctype == 'connection_count_above': + return context.get('connection_count', 0) > (value or 0) + + elif ctype == 'new_listening_port': + return len(context.get('new_ports', [])) > 0 + + elif ctype == 'bandwidth_rx_above_mbps': + return context.get('bandwidth', {}).get('rx_mbps', 0) > (value or 0) + + elif ctype == 'arp_spoof_detected': + return len(context.get('arp_alerts', [])) > 0 + + elif ctype == 'schedule': + return self._check_cron(condition.get('cron', '')) + + elif ctype == 'always': + return True + + _logger.warning(f"[Rules] Unknown condition type: {ctype}") + return False + + def _check_ip_match(self, pattern: str, connections: list) -> bool: + """Check if any connection's remote IP matches a pattern (IP or CIDR).""" + if not pattern: + return False + try: + network = ipaddress.ip_network(pattern, strict=False) + for conn in connections: + remote = conn.get('remote_addr', '') + if remote and remote not in ('0.0.0.0', '::', '127.0.0.1', '::1', '*'): + try: + if ipaddress.ip_address(remote) in network: + return True + except ValueError: + continue + except ValueError: + # Not a valid IP/CIDR, try exact match + return any(conn.get('remote_addr') == pattern for conn in connections) + return False + + def _check_cron(self, cron_expr: str) -> bool: + """Minimal 5-field cron matcher: minute hour day month weekday. + + Supports * and */N. Does not support ranges or lists. + """ + if not cron_expr: + return False + + parts = cron_expr.strip().split() + if len(parts) != 5: + return False + + now = datetime.now() + current = [now.minute, now.hour, now.day, now.month, now.isoweekday() % 7] + + for field_val, pattern in zip(current, parts): + if pattern == '*': + continue + if pattern.startswith('*/'): + try: + step = int(pattern[2:]) + if step > 0 and field_val % step != 0: + return False + except ValueError: + return False + else: + try: + if field_val != int(pattern): + return False + except ValueError: + return False + + return True + + def _resolve_variables(self, action: dict, context: dict) -> dict: + """Replace $variable placeholders in action parameters with context values.""" + resolved = {} + + # Build variable map from context + variables = { + '$threat_score': str(context.get('threat_score', {}).get('score', 0)), + '$threat_level': context.get('threat_score', {}).get('level', 'LOW'), + } + + # Source IP = top talker (most connections) + connections = context.get('connections', []) + if connections: + ip_counts = {} + for c in connections: + rip = c.get('remote_addr', '') + if rip and rip not in ('0.0.0.0', '::', '127.0.0.1', '::1', '*'): + ip_counts[rip] = ip_counts.get(rip, 0) + 1 + if ip_counts: + variables['$source_ip'] = max(ip_counts, key=ip_counts.get) + + # New port + new_ports = context.get('new_ports', []) + if new_ports: + variables['$new_port'] = str(new_ports[0].get('port', '')) + variables['$suspicious_pid'] = str(new_ports[0].get('pid', '')) + + # DDoS attack type + ddos = context.get('ddos', {}) + if ddos: + variables['$attack_type'] = ddos.get('attack_type', 'unknown') + + # Resolve in all string values + for key, val in action.items(): + if isinstance(val, str): + for var_name, var_val in variables.items(): + val = val.replace(var_name, var_val) + resolved[key] = val + + return resolved diff --git a/web/app.py b/web/app.py index 37f5813..11de41f 100644 --- a/web/app.py +++ b/web/app.py @@ -65,6 +65,7 @@ def create_app(): from web.routes.targets import targets_bp from web.routes.encmodules import encmodules_bp from web.routes.llm_trainer import llm_trainer_bp + from web.routes.autonomy import autonomy_bp app.register_blueprint(auth_bp) app.register_blueprint(dashboard_bp) @@ -89,6 +90,7 @@ def create_app(): app.register_blueprint(targets_bp) app.register_blueprint(encmodules_bp) app.register_blueprint(llm_trainer_bp) + app.register_blueprint(autonomy_bp) # Start network discovery advertising (mDNS + Bluetooth) try: diff --git a/web/routes/autonomy.py b/web/routes/autonomy.py new file mode 100644 index 0000000..3385622 --- /dev/null +++ b/web/routes/autonomy.py @@ -0,0 +1,241 @@ +"""Autonomy routes — daemon control, model management, rules CRUD, activity log.""" + +import json +from flask import Blueprint, render_template, request, jsonify, Response, stream_with_context +from web.auth import login_required + +autonomy_bp = Blueprint('autonomy', __name__, url_prefix='/autonomy') + + +def _get_daemon(): + from core.autonomy import get_autonomy_daemon + return get_autonomy_daemon() + + +def _get_router(): + from core.model_router import get_model_router + return get_model_router() + + +# ==================== PAGES ==================== + +@autonomy_bp.route('/') +@login_required +def index(): + return render_template('autonomy.html') + + +# ==================== DAEMON CONTROL ==================== + +@autonomy_bp.route('/status') +@login_required +def status(): + daemon = _get_daemon() + router = _get_router() + return jsonify({ + 'daemon': daemon.status, + 'models': router.status, + }) + + +@autonomy_bp.route('/start', methods=['POST']) +@login_required +def start(): + daemon = _get_daemon() + ok = daemon.start() + return jsonify({'success': ok, 'status': daemon.status}) + + +@autonomy_bp.route('/stop', methods=['POST']) +@login_required +def stop(): + daemon = _get_daemon() + daemon.stop() + return jsonify({'success': True, 'status': daemon.status}) + + +@autonomy_bp.route('/pause', methods=['POST']) +@login_required +def pause(): + daemon = _get_daemon() + daemon.pause() + return jsonify({'success': True, 'status': daemon.status}) + + +@autonomy_bp.route('/resume', methods=['POST']) +@login_required +def resume(): + daemon = _get_daemon() + daemon.resume() + return jsonify({'success': True, 'status': daemon.status}) + + +# ==================== MODELS ==================== + +@autonomy_bp.route('/models') +@login_required +def models(): + return jsonify(_get_router().status) + + +@autonomy_bp.route('/models/load/', methods=['POST']) +@login_required +def models_load(tier): + from core.model_router import ModelTier + try: + mt = ModelTier(tier) + except ValueError: + return jsonify({'error': f'Invalid tier: {tier}'}), 400 + ok = _get_router().load_tier(mt, verbose=True) + return jsonify({'success': ok, 'models': _get_router().status}) + + +@autonomy_bp.route('/models/unload/', methods=['POST']) +@login_required +def models_unload(tier): + from core.model_router import ModelTier + try: + mt = ModelTier(tier) + except ValueError: + return jsonify({'error': f'Invalid tier: {tier}'}), 400 + _get_router().unload_tier(mt) + return jsonify({'success': True, 'models': _get_router().status}) + + +# ==================== RULES ==================== + +@autonomy_bp.route('/rules') +@login_required +def rules_list(): + daemon = _get_daemon() + rules = daemon.rules_engine.get_all_rules() + return jsonify({'rules': [r.to_dict() for r in rules]}) + + +@autonomy_bp.route('/rules', methods=['POST']) +@login_required +def rules_create(): + from core.rules import Rule + data = request.get_json(silent=True) or {} + rule = Rule.from_dict(data) + daemon = _get_daemon() + daemon.rules_engine.add_rule(rule) + return jsonify({'success': True, 'rule': rule.to_dict()}) + + +@autonomy_bp.route('/rules/', methods=['PUT']) +@login_required +def rules_update(rule_id): + data = request.get_json(silent=True) or {} + daemon = _get_daemon() + rule = daemon.rules_engine.update_rule(rule_id, data) + if rule: + return jsonify({'success': True, 'rule': rule.to_dict()}) + return jsonify({'error': 'Rule not found'}), 404 + + +@autonomy_bp.route('/rules/', methods=['DELETE']) +@login_required +def rules_delete(rule_id): + daemon = _get_daemon() + ok = daemon.rules_engine.delete_rule(rule_id) + return jsonify({'success': ok}) + + +@autonomy_bp.route('/templates') +@login_required +def rule_templates(): + """Pre-built rule templates for common scenarios.""" + templates = [ + { + 'name': 'Auto-Block Port Scanners', + 'description': 'Block IPs that trigger port scan detection', + 'conditions': [{'type': 'port_scan_detected'}], + 'actions': [ + {'type': 'block_ip', 'ip': '$source_ip'}, + {'type': 'alert', 'message': 'Blocked scanner: $source_ip'}, + ], + 'priority': 10, + 'cooldown_seconds': 300, + }, + { + 'name': 'DDoS Auto-Response', + 'description': 'Rate-limit top talkers during DDoS attacks', + 'conditions': [{'type': 'ddos_detected'}], + 'actions': [ + {'type': 'rate_limit_ip', 'ip': '$source_ip', 'rate': '10/s'}, + {'type': 'alert', 'message': 'DDoS mitigated: $attack_type from $source_ip'}, + ], + 'priority': 5, + 'cooldown_seconds': 60, + }, + { + 'name': 'High Threat Alert', + 'description': 'Send alert when threat score exceeds threshold', + 'conditions': [{'type': 'threat_score_above', 'value': 60}], + 'actions': [ + {'type': 'alert', 'message': 'Threat score: $threat_score ($threat_level)'}, + ], + 'priority': 20, + 'cooldown_seconds': 120, + }, + { + 'name': 'New Port Investigation', + 'description': 'Use SAM agent to investigate new listening ports', + 'conditions': [{'type': 'new_listening_port'}], + 'actions': [ + {'type': 'escalate_to_lam', 'task': 'Investigate new listening port $new_port (PID $suspicious_pid). Determine if this is legitimate or suspicious.'}, + ], + 'priority': 30, + 'cooldown_seconds': 300, + }, + { + 'name': 'Bandwidth Spike Alert', + 'description': 'Alert on unusual inbound bandwidth', + 'conditions': [{'type': 'bandwidth_rx_above_mbps', 'value': 100}], + 'actions': [ + {'type': 'alert', 'message': 'Bandwidth spike detected (>100 Mbps RX)'}, + ], + 'priority': 25, + 'cooldown_seconds': 60, + }, + ] + return jsonify({'templates': templates}) + + +# ==================== ACTIVITY LOG ==================== + +@autonomy_bp.route('/activity') +@login_required +def activity(): + limit = request.args.get('limit', 50, type=int) + offset = request.args.get('offset', 0, type=int) + daemon = _get_daemon() + entries = daemon.get_activity(limit=limit, offset=offset) + return jsonify({'entries': entries, 'total': daemon.get_activity_count()}) + + +@autonomy_bp.route('/activity/stream') +@login_required +def activity_stream(): + """SSE stream of live activity entries.""" + daemon = _get_daemon() + q = daemon.subscribe() + + def generate(): + try: + while True: + try: + data = q.get(timeout=30) + yield f'data: {data}\n\n' + except Exception: + # Send keepalive + yield f'data: {{"type":"keepalive"}}\n\n' + finally: + daemon.unsubscribe(q) + + return Response( + stream_with_context(generate()), + mimetype='text/event-stream', + headers={'Cache-Control': 'no-cache', 'X-Accel-Buffering': 'no'}, + ) diff --git a/web/templates/autonomy.html b/web/templates/autonomy.html new file mode 100644 index 0000000..b19d84a --- /dev/null +++ b/web/templates/autonomy.html @@ -0,0 +1,742 @@ +{% extends "base.html" %} +{% block title %}Autonomy - AUTARCH{% endblock %} + +{% block content %} + + + +
+ + + + +
+ + +
+ +
+
+
+ STOPPED +
+ + + + +
+
+ + +
+
+
0
+
Active Agents
+
+
+
0
+
Rules
+
+
+
0
+
Activity Entries
+
+
+ + +
+

Model Tiers

+
+
+
+

SLM Small Language Model

+ +
+

Fast classification, routing, yes/no decisions

+
No model configured
+
+ + +
+
+
+
+

SAM Small Action Model

+ +
+

Quick tool execution, simple automated responses

+
No model configured
+
+ + +
+
+
+
+

LAM Large Action Model

+ +
+

Complex multi-step agent tasks, strategic planning

+
No model configured
+
+ + +
+
+
+
+
+ + + + + + + + + + + + + + + + + + + +{% endblock %} diff --git a/web/templates/base.html b/web/templates/base.html index aa88032..74fd2f9 100644 --- a/web/templates/base.html +++ b/web/templates/base.html @@ -35,6 +35,7 @@