"""Runtime abstraction for GhostCrew.""" import platform import shutil from abc import ABC, abstractmethod from dataclasses import dataclass, field from pathlib import Path from typing import TYPE_CHECKING, List, Optional if TYPE_CHECKING: from ..mcp import MCPManager # Categorized list of tools to check for INTERESTING_TOOLS = { "network_scan": [ "nmap", "masscan", "rustscan", "naabu", "unicornscan", ], "web_scan": [ "nikto", "gobuster", "dirb", "dirbuster", "ffuf", "feroxbuster", "wpscan", "nuclei", "whatweb", ], "exploitation": [ "msfconsole", "searchsploit", "sqlmap", "commix", ], "password_attacks": [ "hydra", "medusa", "john", "hashcat", "crackmapexec", "thc-hydra", ], "network_analysis": [ "tcpdump", "tshark", "wireshark", "ngrep", ], "tunneling": [ "proxychains", "proxychains4", "socat", "chisel", "ligolo", ], "utilities": [ "curl", "wget", "nc", "netcat", "ncat", "ssh", "git", "docker", "kubectl", "jq", "python3", "perl", "ruby", "gcc", "g++", "make", ], } @dataclass class ToolInfo: """Information about an available tool.""" name: str path: str category: str @dataclass class EnvironmentInfo: """System environment information.""" os: str # "Windows", "Linux", "Darwin" os_version: str shell: str # "powershell", "bash", "zsh", etc. architecture: str # "x86_64", "arm64", etc. available_tools: List[ToolInfo] = field(default_factory=list) def __str__(self) -> str: """Concise string representation for prompts.""" # Group tools by category for cleaner output grouped = {} for tool in self.available_tools: if tool.category not in grouped: grouped[tool.category] = [] grouped[tool.category].append(tool.name) tools_str = "" if grouped: lines = [] for cat, tools in grouped.items(): lines.append(f" - {cat}: {', '.join(tools)}") tools_str = "\n" + "\n".join(lines) else: tools_str = " None" return ( f"{self.os} ({self.architecture}), shell: {self.shell}\n" f"Available CLI Tools:{tools_str}" ) def detect_environment() -> EnvironmentInfo: """Detect the current system environment.""" import os os_name = platform.system() os_version = platform.release() arch = platform.machine() shell = "unknown" # Detect shell and OS nuances if os_name == "Windows": # Better Windows shell detection comspec = os.environ.get("COMSPEC", "").lower() if "powershell" in comspec: shell = "powershell" elif "cmd.exe" in comspec: shell = "cmd" else: # Fallback: check if we are in a PS session via env vars if "PSModulePath" in os.environ: shell = "powershell" else: shell = "cmd" # Default to cmd on Windows if unsure else: # Unix-like shell_path = os.environ.get("SHELL", "/bin/sh") shell = shell_path.split("/")[-1] # WSL Detection if os_name == "Linux": try: with open("/proc/version", "r") as f: if "microsoft" in f.read().lower(): os_name = "Linux (WSL)" except Exception: pass # Detect available tools with categories available_tools = [] for category, tools in INTERESTING_TOOLS.items(): for tool_name in tools: tool_path = shutil.which(tool_name) if tool_path: available_tools.append( ToolInfo(name=tool_name, path=tool_path, category=category) ) return EnvironmentInfo( os=os_name, os_version=os_version, shell=shell, architecture=arch, available_tools=available_tools, ) @dataclass class CommandResult: """Result of a command execution.""" exit_code: int stdout: str stderr: str @property def success(self) -> bool: """Check if the command succeeded.""" return self.exit_code == 0 @property def output(self) -> str: """Get combined output.""" parts = [] if self.stdout: parts.append(self.stdout) if self.stderr: parts.append(self.stderr) return "\n".join(parts) class Runtime(ABC): """Abstract base class for runtime environments.""" _environment: Optional[EnvironmentInfo] = None def __init__(self, mcp_manager: Optional["MCPManager"] = None): """ Initialize the runtime. Args: mcp_manager: Optional MCP manager for tool calls """ self.mcp_manager = mcp_manager self.plan = None # Set by agent for finish tool access @property def environment(self) -> EnvironmentInfo: """Get environment info (cached).""" if Runtime._environment is None: Runtime._environment = detect_environment() return Runtime._environment @abstractmethod async def start(self): """Start the runtime environment.""" pass @abstractmethod async def stop(self): """Stop the runtime environment.""" pass @abstractmethod async def execute_command(self, command: str, timeout: int = 300) -> CommandResult: """ Execute a shell command. Args: command: The command to execute timeout: Timeout in seconds Returns: CommandResult with output """ pass @abstractmethod async def browser_action(self, action: str, **kwargs) -> dict: """ Perform a browser automation action. Args: action: The action to perform **kwargs: Action-specific arguments Returns: Action result """ pass @abstractmethod async def proxy_action(self, action: str, **kwargs) -> dict: """ Perform an HTTP proxy action. Args: action: The action to perform **kwargs: Action-specific arguments Returns: Action result """ pass @abstractmethod async def is_running(self) -> bool: """Check if the runtime is running.""" pass @abstractmethod async def get_status(self) -> dict: """ Get runtime status information. Returns: Status dictionary """ pass class LocalRuntime(Runtime): """Local runtime that executes commands directly on the host.""" def __init__(self, mcp_manager: Optional["MCPManager"] = None): super().__init__(mcp_manager) self._running = False self._browser = None self._browser_context = None self._page = None self._playwright = None self._active_processes: list = [] async def start(self): """Start the local runtime.""" self._running = True # Create organized loot directory structure Path("loot").mkdir(exist_ok=True) Path("loot/reports").mkdir(exist_ok=True) Path("loot/artifacts").mkdir(exist_ok=True) Path("loot/artifacts/screenshots").mkdir(exist_ok=True) async def stop(self): """Stop the local runtime gracefully.""" # Clean up any active subprocesses for proc in self._active_processes: try: if proc.returncode is None: proc.terminate() await proc.wait() except Exception: pass self._active_processes.clear() # Clean up browser await self._cleanup_browser() self._running = False async def _cleanup_browser(self): """Clean up browser resources properly.""" # Close in reverse order of creation if self._page: try: await self._page.close() except Exception: pass self._page = None if self._browser_context: try: await self._browser_context.close() except Exception: pass self._browser_context = None if self._browser: try: await self._browser.close() except Exception: pass self._browser = None if self._playwright: try: await self._playwright.stop() except Exception: pass self._playwright = None async def _ensure_browser(self): """Ensure browser is initialized.""" if self._page is not None: return try: from playwright.async_api import async_playwright except ImportError as e: raise RuntimeError( "Playwright not installed. Install with:\n" " pip install playwright\n" " playwright install chromium" ) from e self._playwright = await async_playwright().start() self._browser = await self._playwright.chromium.launch(headless=True) self._browser_context = await self._browser.new_context( user_agent="Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36" ) self._page = await self._browser_context.new_page() async def execute_command(self, command: str, timeout: int = 300) -> CommandResult: """Execute a command locally.""" import asyncio import os import re import subprocess # Regex to strip ANSI escape codes ansi_escape = re.compile(r"\x1B(?:[@-Z\\-_]|\[[0-?]*[ -/]*[@-~])") # Set environment variables to discourage ANSI output env = os.environ.copy() env["TERM"] = "dumb" env["NO_COLOR"] = "1" try: process = await asyncio.create_subprocess_shell( command, stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.PIPE, stdin=subprocess.DEVNULL, # Prevent interactive prompts env=env, ) stdout, stderr = await asyncio.wait_for( process.communicate(), timeout=timeout ) # Decode and strip ANSI codes stdout_str = stdout.decode(errors="replace") stderr_str = stderr.decode(errors="replace") stdout_clean = ansi_escape.sub("", stdout_str) stderr_clean = ansi_escape.sub("", stderr_str) return CommandResult( exit_code=process.returncode or 0, stdout=stdout_clean, stderr=stderr_clean, ) except asyncio.TimeoutError: return CommandResult( exit_code=-1, stdout="", stderr=f"Command timed out after {timeout} seconds", ) except asyncio.CancelledError: # Handle Ctrl+C gracefully return CommandResult(exit_code=-1, stdout="", stderr="Command cancelled") except Exception as e: return CommandResult(exit_code=-1, stdout="", stderr=str(e)) async def browser_action(self, action: str, **kwargs) -> dict: """Perform browser automation actions using Playwright.""" import asyncio # Enforce a hard timeout on the entire operation to prevent hanging # Add 5 seconds buffer to the requested timeout for browser startup overhead op_timeout = kwargs.get("timeout", 30) + 10 try: return await asyncio.wait_for( self._execute_browser_action(action, **kwargs), timeout=op_timeout ) except asyncio.TimeoutError: return {"error": f"Browser action '{action}' timed out after {op_timeout}s"} except Exception as e: return {"error": str(e)} async def _execute_browser_action(self, action: str, **kwargs) -> dict: """Internal execution of browser action.""" try: await self._ensure_browser() except RuntimeError as e: return {"error": str(e)} timeout = kwargs.get("timeout", 30) * 1000 # Convert to ms try: if action == "navigate": url = kwargs.get("url") if not url: return {"error": "URL is required for navigate action"} await self._page.goto( url, timeout=timeout, wait_until="domcontentloaded" ) if kwargs.get("wait_for"): await self._page.wait_for_selector( kwargs["wait_for"], timeout=timeout ) return {"url": self._page.url, "title": await self._page.title()} elif action == "screenshot": import time import uuid from pathlib import Path # Navigate first if URL provided if kwargs.get("url"): await self._page.goto( kwargs["url"], timeout=timeout, wait_until="domcontentloaded" ) # Save screenshot to loot/artifacts/screenshots/ output_dir = Path("loot/artifacts/screenshots") output_dir.mkdir(parents=True, exist_ok=True) timestamp = int(time.time()) unique_id = uuid.uuid4().hex[:8] filename = f"screenshot_{timestamp}_{unique_id}.png" filepath = output_dir / filename await self._page.screenshot(path=str(filepath), full_page=True) return {"path": str(filepath)} elif action == "get_content": if kwargs.get("url"): await self._page.goto( kwargs["url"], timeout=timeout, wait_until="domcontentloaded" ) content = await self._page.content() # Also get text content for easier reading text_content = await self._page.evaluate( "() => document.body.innerText" ) return { "content": text_content, "html": content[:10000] if len(content) > 10000 else content, } elif action == "get_links": if kwargs.get("url"): await self._page.goto( kwargs["url"], timeout=timeout, wait_until="domcontentloaded" ) links = await self._page.evaluate( """() => { return Array.from(document.querySelectorAll('a[href]')).map(a => ({ href: a.href, text: a.innerText.trim() })); }""" ) return {"links": links} elif action == "get_forms": if kwargs.get("url"): await self._page.goto( kwargs["url"], timeout=timeout, wait_until="domcontentloaded" ) forms = await self._page.evaluate( """() => { return Array.from(document.querySelectorAll('form')).map(form => ({ action: form.action, method: form.method || 'GET', inputs: Array.from(form.querySelectorAll('input, textarea, select')).map(input => ({ name: input.name, type: input.type || 'text', value: input.value })) })); }""" ) return {"forms": forms} elif action == "click": selector = kwargs.get("selector") if not selector: return {"error": "Selector is required for click action"} await self._page.click(selector, timeout=timeout) return {"selector": selector, "clicked": True} elif action == "type": selector = kwargs.get("selector") text = kwargs.get("text", "") if not selector: return {"error": "Selector is required for type action"} await self._page.fill(selector, text, timeout=timeout) return {"selector": selector, "typed": True} elif action == "execute_js": javascript = kwargs.get("javascript") if not javascript: return {"error": "JavaScript code is required"} result = await self._page.evaluate(javascript) return {"result": str(result) if result else ""} else: return {"error": f"Unknown browser action: {action}"} except Exception as e: return {"error": f"Browser action failed: {str(e)}"} async def proxy_action(self, action: str, **kwargs) -> dict: """HTTP proxy actions using httpx.""" try: import httpx except ImportError: return {"error": "httpx not installed. Install with: pip install httpx"} timeout = kwargs.get("timeout", 30) try: async with httpx.AsyncClient( timeout=timeout, follow_redirects=True ) as client: if action == "request": method = kwargs.get("method", "GET").upper() url = kwargs.get("url") headers = kwargs.get("headers", {}) data = kwargs.get("data") if not url: return {"error": "URL is required"} response = await client.request( method, url, headers=headers, data=data ) return { "status_code": response.status_code, "headers": dict(response.headers), "body": ( response.text[:10000] if len(response.text) > 10000 else response.text ), } elif action == "get": url = kwargs.get("url") if not url: return {"error": "URL is required"} response = await client.get(url, headers=kwargs.get("headers", {})) return { "status_code": response.status_code, "headers": dict(response.headers), "body": response.text[:10000], } elif action == "post": url = kwargs.get("url") if not url: return {"error": "URL is required"} response = await client.post( url, headers=kwargs.get("headers", {}), data=kwargs.get("data"), json=kwargs.get("json"), ) return { "status_code": response.status_code, "headers": dict(response.headers), "body": response.text[:10000], } else: return {"error": f"Unknown proxy action: {action}"} except Exception as e: return {"error": f"Proxy action failed: {str(e)}"} async def is_running(self) -> bool: return self._running async def get_status(self) -> dict: return { "type": "local", "running": self._running, "browser_active": self._page is not None, }