diff --git a/pentestagent/interface/main.py b/pentestagent/interface/main.py index d831515..51c6564 100644 --- a/pentestagent/interface/main.py +++ b/pentestagent/interface/main.py @@ -111,6 +111,18 @@ Examples: mcp_remove = mcp_subparsers.add_parser("remove", help="Remove an MCP server") mcp_remove.add_argument("name", help="Server name to remove") + # mcp disable + mcp_disable = mcp_subparsers.add_parser( + "disable", help="Disable an MCP server (update config)" + ) + mcp_disable.add_argument("name", help="Server name to disable") + + # mcp enable + mcp_enable = mcp_subparsers.add_parser( + "enable", help="Enable an MCP server (update config)" + ) + mcp_enable.add_argument("name", help="Server name to enable") + # mcp test mcp_test = mcp_subparsers.add_parser("test", help="Test MCP server connection") mcp_test.add_argument("name", help="Server name to test") @@ -259,6 +271,18 @@ def handle_mcp_command(args: argparse.Namespace): else: console.print(f"[red]Server not found: {args.name}[/]") + elif args.mcp_command == "disable": + if manager.set_enabled(args.name, False): + console.print(f"[yellow]Disabled MCP server in config: {args.name}[/]") + else: + console.print(f"[red]Server not found: {args.name}[/]") + + elif args.mcp_command == "enable": + if manager.set_enabled(args.name, True): + console.print(f"[green]Enabled MCP server in config: {args.name}[/]") + else: + console.print(f"[red]Server not found: {args.name}[/]") + elif args.mcp_command == "test": console.print(f"[bold]Testing MCP server: {args.name}[/]\n") diff --git a/pentestagent/interface/tui.py b/pentestagent/interface/tui.py index c839fee..19329e1 100644 --- a/pentestagent/interface/tui.py +++ b/pentestagent/interface/tui.py @@ -7,7 +7,7 @@ import re import textwrap from datetime import datetime from pathlib import Path -from typing import TYPE_CHECKING, Any, Dict, List, Optional +from typing import TYPE_CHECKING, Any, Dict, List, Optional, cast from rich.text import Text from textual import on, work @@ -195,6 +195,95 @@ class HelpScreen(ModalScreen): self.app.pop_screen() +class ToolsScreen(ModalScreen): + """Interactive tools browser — split-pane layout. + + Left pane: tree of tools. Right pane: full description (scrollable). + Selecting another tool replaces the right-pane content. Close returns + to the main screen. + """ + + BINDINGS = [Binding("escape", "dismiss", "Close"), Binding("q", "dismiss", "Close")] + + CSS = """ + ToolsScreen { align: center middle; } + """ + + def __init__(self, tools: List[Any]) -> None: + super().__init__() + self.tools = tools + + def compose(self) -> ComposeResult: + # Build a split view: left tree, right description + with Container(id="tools-container"): + with Horizontal(id="tools-split"): + with Vertical(id="tools-left"): + yield Static("Tools", id="tools-title") + yield Tree("TOOLS", id="tools-tree") + + with Vertical(id="tools-right"): + yield Static("Description", id="tools-desc-title") + yield ScrollableContainer(Static("Select a tool to view details.", id="tools-desc"), id="tools-desc-scroll") + + yield Center(Button("Close", id="tools-close")) + + def on_mount(self) -> None: + try: + tree = self.query_one("#tools-tree", Tree) + except Exception: + return + + root = tree.root + root.allow_expand = True + root.show_root = False + + # Populate tool nodes + for t in self.tools: + name = getattr(t, "name", str(t)) + root.add(name, data={"tool": t}) + + try: + tree.focus() + except Exception: + pass + + @on(Tree.NodeSelected, "#tools-tree") + def on_tool_selected(self, event: Tree.NodeSelected) -> None: + node = event.node + try: + tool = node.data.get("tool") if node.data else None + name = node.label or (getattr(tool, "name", str(tool)) if tool else "Unknown") + + # Prefer Tool.description (registered tools use this), then fall back + desc = None + if tool is not None: + desc = getattr(tool, "description", None) + if not desc: + desc = ( + getattr(tool, "summary", None) + or getattr(tool, "help_text", None) + or getattr(tool, "__doc__", None) + ) + if not desc: + desc = "No description available." + + # Update right-hand description pane + try: + desc_widget = self.query_one("#tools-desc", Static) + text = Text() + text.append(f"{name}\n", style="bold #d4d4d4") + text.append(str(desc), style="#d4d4d4") + desc_widget.update(text) + except Exception: + pass + except Exception: + pass + + @on(Button.Pressed, "#tools-close") + def close_tools(self) -> None: + self.app.pop_screen() + + # ----- Main Chat Message Widgets ----- @@ -903,7 +992,7 @@ class PentestAgentTUI(App): def __init__( self, target: Optional[str] = None, - model: str = None, + model: Optional[str] = None, use_docker: bool = False, **kwargs, ): @@ -924,7 +1013,8 @@ class PentestAgentTUI(App): self._is_running = False self._is_initializing = True # Block input during init self._should_stop = False - self._current_worker = None # Track running worker for cancellation + # Worker handle returned by `@work` or an `asyncio.Task` (keep generic) + self._current_worker: Optional[Any] = None # Track running worker for cancellation self._current_crew = None # Track crew orchestrator for cancellation # Crew mode state @@ -973,7 +1063,8 @@ class PentestAgentTUI(App): async def on_mount(self) -> None: """Initialize on mount""" - self._initialize_agent() + # Call the textual worker - decorator returns a Worker, not a coroutine + _ = cast(Any, self._initialize_agent()) @work(thread=False) async def _initialize_agent(self) -> None: @@ -1048,6 +1139,9 @@ class PentestAgentTUI(App): await self.runtime.start() # LLM + # Ensure types for static analysis: runtime and model are set + assert self.model is not None + assert self.runtime is not None llm = LLM( model=self.model, config=ModelConfig(temperature=0.7), @@ -1055,7 +1149,7 @@ class PentestAgentTUI(App): ) # Tools - self.all_tools = get_all_tools() + self.all_tools = get_all_tools() # Ensure tools are loaded # Agent self.agent = PentestAgentAgent( @@ -1201,7 +1295,7 @@ class PentestAgentTUI(App): for key, value in notes.items(): # Show full value, indent multi-line content if "\n" in value: - indented = value.replace("\n", "\n ") + indented = str(value).replace("\n", "\n ") lines.append(f"\n[{key}]\n {indented}") else: lines.append(f"[{key}] {value}") @@ -1458,7 +1552,8 @@ Be concise. Use the actual data from notes.""" # Use assist mode by default if self.agent and not self._is_running: - self._current_worker = self._run_assist(message) + # Schedule assist run and keep task handle + self._current_worker = asyncio.create_task(self._run_assist(message)) async def _handle_command(self, cmd: str) -> None: """Handle slash commands""" @@ -1476,31 +1571,36 @@ Be concise. Use the actual data from notes.""" self.agent.conversation_history.clear() self._add_system("Chat cleared") elif cmd_lower == "/tools": - from ..runtime.runtime import detect_environment + # Open the interactive tools browser (split-pane). + try: + await self.push_screen(ToolsScreen(tools=self.all_tools)) + except Exception: + # Fallback: list tools in the system area if UI push fails + from ..runtime.runtime import detect_environment - names = [t.name for t in self.all_tools] - msg = f"Tools ({len(names)}): " + ", ".join(names) + names = [t.name for t in self.all_tools] + msg = f"Tools ({len(names)}): " + ", ".join(names) - # Add detected CLI tools - env = detect_environment() - if env.available_tools: - # Group by category - by_category = {} - for tool_info in env.available_tools: - if tool_info.category not in by_category: - by_category[tool_info.category] = [] - by_category[tool_info.category].append(tool_info.name) + # Add detected CLI tools + env = detect_environment() + if env.available_tools: + # Group by category + by_category = {} + for tool_info in env.available_tools: + if tool_info.category not in by_category: + by_category[tool_info.category] = [] + by_category[tool_info.category].append(tool_info.name) - cli_sections = [] - for category in sorted(by_category.keys()): - tools_list = ", ".join(sorted(by_category[category])) - cli_sections.append(f"{category}: {tools_list}") + cli_sections = [] + for category in sorted(by_category.keys()): + tools_list = ", ".join(sorted(by_category[category])) + cli_sections.append(f"{category}: {tools_list}") - msg += f"\n\nCLI Tools ({len(env.available_tools)}):\n" + "\n".join( - cli_sections - ) + msg += f"\n\nCLI Tools ({len(env.available_tools)}):\n" + "\n".join( + cli_sections + ) - self._add_system(msg) + self._add_system(msg) elif cmd_lower in ["/quit", "/exit", "/q"]: self.exit() elif cmd_lower == "/prompt": @@ -1512,7 +1612,8 @@ Be concise. Use the actual data from notes.""" elif cmd_lower == "/notes": await self._show_notes() elif cmd_lower == "/report": - self._run_report_generation() + # Call the textual worker - decorator returns a Worker + _ = cast(Any, self._run_report_generation()) elif cmd_original.startswith("/target"): self._set_target(cmd_original) elif cmd_original.startswith("/agent"): @@ -1549,7 +1650,8 @@ Be concise. Use the actual data from notes.""" self._hide_sidebar() if self.agent and not self._is_running: - self._current_worker = self._run_agent_mode(task) + # Schedule agent mode and keep task handle + self._current_worker = asyncio.create_task(self._run_agent_mode(task)) async def _parse_crew_command(self, cmd: str) -> None: """Parse and execute /crew command""" @@ -1577,7 +1679,8 @@ Be concise. Use the actual data from notes.""" if not self._is_running: self._add_user(f"/crew {target}") self._show_sidebar() - self._current_worker = self._run_crew_mode(target) + # Schedule crew mode and keep handle + self._current_worker = asyncio.create_task(self._run_crew_mode(target)) def _show_sidebar(self) -> None: """Show the sidebar for crew mode.""" @@ -1620,8 +1723,12 @@ Be concise. Use the actual data from notes.""" self._crew_orchestrator_node = tree.root.add( "CREW", data={"type": "crew", "id": "crew"} ) - self._crew_orchestrator_node.expand() - tree.select_node(self._crew_orchestrator_node) + if self._crew_orchestrator_node: + try: + self._crew_orchestrator_node.expand() + tree.select_node(self._crew_orchestrator_node) + except Exception: + pass self._viewing_worker_id = None # Update stats @@ -1723,11 +1830,15 @@ Be concise. Use the actual data from notes.""" try: label = self._format_worker_label(worker_id) - node = self._crew_orchestrator_node.add( - label, data={"type": "worker", "id": worker_id} - ) - self._crew_worker_nodes[worker_id] = node - self._crew_orchestrator_node.expand() + if self._crew_orchestrator_node: + node = self._crew_orchestrator_node.add( + label, data={"type": "worker", "id": worker_id} + ) + self._crew_worker_nodes[worker_id] = node + try: + self._crew_orchestrator_node.expand() + except Exception: + pass self._update_crew_stats() except Exception: pass @@ -1874,6 +1985,10 @@ Be concise. Use the actual data from notes.""" # Build prior context from assist/agent conversation history prior_context = self._build_prior_context() + # Ensure model/runtime are available for static analysis + assert self.model is not None + assert self.runtime is not None + llm = LLM(model=self.model, config=ModelConfig(temperature=0.7)) crew = CrewOrchestrator( @@ -1882,7 +1997,7 @@ Be concise. Use the actual data from notes.""" runtime=self.runtime, on_worker_event=self._handle_worker_event, rag_engine=self.rag_engine, - target=self.target, + target=target, prior_context=prior_context, ) self._current_crew = crew # Track for cancellation @@ -2148,8 +2263,10 @@ Be concise. Use the actual data from notes.""" # Stop any running tasks first if self._is_running: self._should_stop = True - if self._current_worker and not self._current_worker.is_finished: - self._current_worker.cancel() + if self._current_worker and not getattr(self._current_worker, "done", lambda: False)(): + cancel = getattr(self._current_worker, "cancel", None) + if cancel: + cancel() if self._current_crew: # Schedule cancel but don't wait - we're exiting asyncio.create_task(self._cancel_crew()) @@ -2161,8 +2278,10 @@ Be concise. Use the actual data from notes.""" self._add_system("[!] Stopping...") # Cancel the running worker to interrupt blocking awaits - if self._current_worker and not self._current_worker.is_finished: - self._current_worker.cancel() + if self._current_worker and not getattr(self._current_worker, "done", lambda: False)(): + cancel = getattr(self._current_worker, "cancel", None) + if cancel: + cancel() # Cancel crew orchestrator if running if self._current_crew: @@ -2193,7 +2312,8 @@ Be concise. Use the actual data from notes.""" """Reconnect MCP servers after cancellation to restore clean state.""" await asyncio.sleep(0.5) # Brief delay for cancellation to propagate try: - await self.mcp_manager.reconnect_all() + if self.mcp_manager: + await self.mcp_manager.reconnect_all() except Exception: pass # Best effort - don't crash if reconnect fails @@ -2257,7 +2377,7 @@ Be concise. Use the actual data from notes.""" def run_tui( target: Optional[str] = None, - model: str = None, + model: Optional[str] = None, use_docker: bool = False, ): """Run the PentestAgent TUI""" diff --git a/pentestagent/mcp/hexstrike_adapter.py b/pentestagent/mcp/hexstrike_adapter.py index dc74ef5..67dcfa0 100644 --- a/pentestagent/mcp/hexstrike_adapter.py +++ b/pentestagent/mcp/hexstrike_adapter.py @@ -15,6 +15,8 @@ import shutil import sys from pathlib import Path from typing import Optional +import signal +import time try: import aiohttp @@ -88,8 +90,18 @@ class HexstrikeAdapter: env=self.env, stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.STDOUT, + start_new_session=True, ) + # Log PID for debugging and management + try: + pid = getattr(self._process, "pid", None) + if pid: + with LOG_FILE.open("a") as fh: + fh.write(f"[HexstrikeAdapter] started pid={pid}\n") + except Exception: + pass + # Start a background reader task to capture logs loop = asyncio.get_running_loop() self._reader_task = loop.create_task(self._capture_output()) @@ -145,6 +157,61 @@ class HexstrikeAdapter: except Exception: pass + def stop_sync(self, timeout: int = 5) -> None: + """Synchronous stop helper for use during process-exit cleanup. + + This forcefully terminates the underlying subprocess PID if the + async event loop is no longer available. + """ + proc = self._process + if not proc: + return + + # Try to terminate gracefully first + try: + pid = getattr(proc, "pid", None) + if pid: + # Kill the whole process group if possible (handles children) + try: + pgid = os.getpgid(pid) + os.killpg(pgid, signal.SIGTERM) + except Exception: + try: + os.kill(pid, signal.SIGTERM) + except Exception: + pass + + # wait briefly for process to exit + end = time.time() + float(timeout) + while time.time() < end: + ret = getattr(proc, "returncode", None) + if ret is not None: + break + time.sleep(0.1) + + # If still running, force kill the process group + try: + pgid = os.getpgid(pid) + os.killpg(pgid, signal.SIGKILL) + except Exception: + try: + os.kill(pid, signal.SIGKILL) + except Exception: + pass + except Exception: + pass + + def __del__(self): + try: + self.stop_sync() + except Exception: + pass + # Clear references + try: + self._process = None + except Exception: + pass + async def health_check(self, timeout: int = 5) -> bool: """Check the server health endpoint. Returns True if healthy.""" url = f"http://{self.host}:{self.port}/health" diff --git a/pentestagent/mcp/manager.py b/pentestagent/mcp/manager.py index 01e5bb7..102eb44 100644 --- a/pentestagent/mcp/manager.py +++ b/pentestagent/mcp/manager.py @@ -15,6 +15,8 @@ Uses standard MCP configuration format: import asyncio import json import os +import atexit +import signal from dataclasses import dataclass, field from pathlib import Path from typing import Any, Dict, List, Optional @@ -70,7 +72,14 @@ class MCPManager: def __init__(self, config_path: Optional[Path] = None): self.config_path = config_path or self._find_config() self.servers: Dict[str, MCPServer] = {} + # Track adapters we auto-started so we can stop them later + self._started_adapters: Dict[str, object] = {} self._message_id = 0 + # Ensure we attempt to clean up vendored servers on process exit + try: + atexit.register(self._atexit_cleanup) + except Exception: + pass def _find_config(self) -> Path: for path in self.DEFAULT_CONFIG_PATHS: @@ -101,6 +110,29 @@ class MCPManager: start_on_launch=config.get("start_on_launch", False), description=config.get("description", ""), ) + # Allow override via environment variable: LAUNCH_HEXTRIKE or LAUNCH_HEXSTRIKE + # If set to a truthy value (1,true,y), force-enable auto-start for vendored HexStrike. + # If set to a falsy value (0,false,n), force-disable auto-start for vendored HexStrike. + launch_env = os.environ.get("LAUNCH_HEXTRIKE") or os.environ.get("LAUNCH_HEXSTRIKE") + if launch_env is not None: + v = str(launch_env).strip().lower() + enable = v in ("1", "true", "yes", "y") + disable = v in ("0", "false", "no", "n") + + for name, cfg in servers.items(): + lowered = name.lower() if name else "" + is_hex = ( + "hexstrike" in lowered + or (cfg.command and "third_party/hexstrike" in str(cfg.command)) + or any("third_party/hexstrike" in str(a) for a in (cfg.args or [])) + ) + if not is_hex: + continue + if enable: + cfg.start_on_launch = True + elif disable: + cfg.start_on_launch = False + return servers except json.JSONDecodeError as e: print(f"[MCP] Error loading config: {e}") @@ -120,6 +152,75 @@ class MCPManager: self.config_path.parent.mkdir(parents=True, exist_ok=True) self.config_path.write_text(json.dumps(config, indent=2), encoding="utf-8") + def _atexit_cleanup(self): + """Synchronous atexit cleanup that attempts to stop adapters and disconnect servers.""" + try: + # Try to run async shutdown; if an event loop is already running this may fail, + # but it's best-effort to avoid orphaned vendored servers. + asyncio.run(self._stop_started_adapters_and_disconnect()) + except Exception: + # Last-ditch: attempt to stop adapters synchronously. + # If the adapter exposes a blocking `stop()` call, call it. Otherwise, try + # to kill the underlying process by PID to avoid asyncio subprocess + # destructors running after the loop is closed. + for adapter in list(self._started_adapters.values()): + try: + # Prefer adapter-provided synchronous stop hook + stop_sync = getattr(adapter, "stop_sync", None) + if stop_sync: + try: + stop_sync() + continue + except Exception: + pass + + # Fallback: try blocking stop() if present + stop = getattr(adapter, "stop", None) + if stop and not asyncio.iscoroutinefunction(stop): + try: + stop() + continue + except Exception: + pass + + # Final fallback: kill underlying PID if available + pid = None + proc = getattr(adapter, "_process", None) + if proc is not None: + pid = getattr(proc, "pid", None) + if pid: + try: + os.kill(pid, signal.SIGTERM) + except Exception: + try: + os.kill(pid, signal.SIGKILL) + except Exception: + pass + except Exception: + pass + + async def _stop_started_adapters_and_disconnect(self) -> None: + # Stop any adapters we started + for name, adapter in list(self._started_adapters.items()): + try: + stop = getattr(adapter, "stop", None) + if stop: + if asyncio.iscoroutinefunction(stop): + await stop() + else: + # run blocking stop in executor + loop = asyncio.get_running_loop() + await loop.run_in_executor(None, stop) + except Exception: + pass + self._started_adapters.clear() + + # Disconnect any active MCP server connections + try: + await self.disconnect_all() + except Exception: + pass + def add_server( self, name: str, @@ -147,6 +248,18 @@ class MCPManager: return True return False + def set_enabled(self, name: str, enabled: bool) -> bool: + """Enable or disable a configured MCP server in the config file. + + Returns True if the server existed and was updated, False otherwise. + """ + servers = self._load_config() + if name not in servers: + return False + servers[name].enabled = bool(enabled) + self._save_config(servers) + return True + def list_configured_servers(self) -> List[dict]: servers = self._load_config() return [ @@ -164,10 +277,29 @@ class MCPManager: async def connect_all(self) -> List[Any]: servers_config = self._load_config() + # Respect explicit LAUNCH_HEXTRIKE/LAUNCH_HEXSTRIKE env override. + # If set to a falsy value (0/false/no/n) we will skip connecting to vendored HexStrike servers. + launch_env = os.environ.get("LAUNCH_HEXTRIKE") or os.environ.get("LAUNCH_HEXSTRIKE") + launch_disabled = False + if launch_env is not None: + v = str(launch_env).strip().lower() + if v in ("0", "false", "no", "n"): + launch_disabled = True + all_tools = [] for name, config in servers_config.items(): if not config.enabled: continue + # If the user explicitly disabled launching HexStrike, skip hexstrike entries entirely + lowered = name.lower() if name else "" + is_hex = ( + "hexstrike" in lowered + or (config.command and "third_party/hexstrike" in str(config.command)) + or any("third_party/hexstrike" in str(a) for a in (config.args or [])) + ) + if launch_disabled and is_hex: + print(f"[MCP] Skipping auto-connection for {name} due to LAUNCH_HEXTRIKE={launch_env}") + continue # Optionally auto-start vendored servers (e.g., HexStrike subtree) if getattr(config, "start_on_launch", False): try: @@ -181,6 +313,11 @@ class MCPManager: adapter = HexstrikeAdapter() started = await adapter.start() if started: + # remember adapter so we can stop it later + try: + self._started_adapters[name] = adapter + except Exception: + pass print(f"[MCP] Auto-started vendored server for {name}") except Exception as e: print(f"[MCP] Failed to auto-start vendored server {name}: {e}") @@ -281,6 +418,19 @@ class MCPManager: if server: await server.disconnect() del self.servers[name] + # If we started an adapter for this server, stop it as well + adapter = self._started_adapters.pop(name, None) + if adapter: + try: + stop = getattr(adapter, "stop", None) + if stop: + if asyncio.iscoroutinefunction(stop): + await stop() + else: + loop = asyncio.get_running_loop() + await loop.run_in_executor(None, stop) + except Exception: + pass async def disconnect_all(self): for server in list(self.servers.values()): diff --git a/pentestagent/mcp/mcp_servers.json b/pentestagent/mcp/mcp_servers.json index 8354adf..ad6c8f7 100644 --- a/pentestagent/mcp/mcp_servers.json +++ b/pentestagent/mcp/mcp_servers.json @@ -3,14 +3,14 @@ "hexstrike-local": { "command": "python3", "args": [ - "third_party/hexstrike/hexstrike_server.py", - "--port", - "8888" + "third_party/hexstrike/hexstrike_mcp.py", + "--server", + "http://127.0.0.1:8888" ], "description": "HexStrike AI (vendored) - local server", "timeout": 300, - "disabled": false, - "start_on_launch": true + "enabled": true, + "start_on_launch": false } } } diff --git a/pyproject.toml b/pyproject.toml index c3b1cee..51f5ddf 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -70,6 +70,20 @@ rag = [ all = [ "pentestagent[dev,rag]", ] +hexstrike = [ + "flask>=2.3.0,<4.0.0", + "requests>=2.31.0,<3.0.0", + "psutil>=5.9.0,<6.0.0", + "fastmcp>=0.2.0,<1.0.0", + "beautifulsoup4>=4.12.0,<5.0.0", + "selenium>=4.15.0,<5.0.0", + "webdriver-manager>=4.0.0,<5.0.0", + "aiohttp>=3.8.0,<4.0.0", + "mitmproxy>=9.0.0,<11.0.0", + "pwntools>=4.10.0,<5.0.0", + "angr>=9.2.0,<10.0.0", + "bcrypt==4.0.1", +] [project.urls] Homepage = "https://github.com/GH05TCREW/pentestagent" diff --git a/pyrightconfig.json b/pyrightconfig.json new file mode 100644 index 0000000..8209e92 --- /dev/null +++ b/pyrightconfig.json @@ -0,0 +1,4 @@ +{ + "typeCheckingMode": "basic", + "reportMissingImports": true +} diff --git a/requirements-hexstrike.txt b/requirements-hexstrike.txt new file mode 100644 index 0000000..024e8b7 --- /dev/null +++ b/requirements-hexstrike.txt @@ -0,0 +1,3 @@ +# Wrapper requirements file for vendored HexStrike dependencies +# This delegates to the vendored requirements in third_party/hexstrike. +-r third_party/hexstrike/requirements.txt diff --git a/scripts/install_hexstrike_deps.sh b/scripts/install_hexstrike_deps.sh new file mode 100644 index 0000000..14a6cef --- /dev/null +++ b/scripts/install_hexstrike_deps.sh @@ -0,0 +1,42 @@ +#!/usr/bin/env bash +set -euo pipefail + +# Install vendored HexStrike Python dependencies. +# This script will source a local .env if present so any environment +# variables (proxies/indices/LLM keys) are respected during installation. + +HERE=$(dirname "${BASH_SOURCE[0]}") +ROOT=$(cd "$HERE/.." && pwd) + +cd "$ROOT" + +if [ -f ".env" ]; then + echo "Sourcing .env" + # export all vars from .env (ignore comments and blank lines) + set -a + # shellcheck disable=SC1091 + source .env + set +a +fi + +REQ=third_party/hexstrike/requirements.txt + +if [ ! -f "$REQ" ]; then + echo "Cannot find $REQ. Is the HexStrike subtree present?" + exit 1 +fi + +echo "Installing HexStrike requirements from $REQ" + +# Prefer using the active venv python if present +PY=$(which python || true) +if [ -n "${VIRTUAL_ENV:-}" ]; then + PY="$VIRTUAL_ENV/bin/python" +fi + +"$PY" -m pip install --upgrade pip +"$PY" -m pip install -r "$REQ" + +echo "HexStrike dependencies installed. Note: many external tools are not included and must be installed separately as described in third_party/hexstrike/requirements.txt." + +exit 0 diff --git a/scripts/setup.sh b/scripts/setup.sh index 4808520..aba7cbd 100644 --- a/scripts/setup.sh +++ b/scripts/setup.sh @@ -75,6 +75,11 @@ PENTESTAGENT_MODEL=gpt-5 # Settings PENTESTAGENT_DEBUG=false +# Auto-launch vendored HexStrike on connect (true/false) +# If true, the MCP manager will attempt to start vendored HexStrike servers +# that are configured or detected under `third_party/hexstrike`. +LAUNCH_HEXTRIKE=false + # Agent max iterations (regular agent + crew workers, default: 30) # PENTESTAGENT_AGENT_MAX_ITERATIONS=30 @@ -89,6 +94,12 @@ fi mkdir -p loot echo "[OK] Loot directory created" +# Install vendored HexStrike dependencies automatically if present +if [ -f "third_party/hexstrike/requirements.txt" ]; then + echo "Installing vendored HexStrike dependencies..." + bash scripts/install_hexstrike_deps.sh +fi + echo "" echo "==================================================================" echo "Setup complete!"