mirror of
https://github.com/GH05TCREW/pentestagent.git
synced 2026-03-06 22:04:08 +00:00
ui: open interactive ToolsScreen on /tools; fallback to list if push fails
This commit is contained in:
@@ -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")
|
||||
|
||||
|
||||
@@ -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"""
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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()):
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
|
||||
4
pyrightconfig.json
Normal file
4
pyrightconfig.json
Normal file
@@ -0,0 +1,4 @@
|
||||
{
|
||||
"typeCheckingMode": "basic",
|
||||
"reportMissingImports": true
|
||||
}
|
||||
3
requirements-hexstrike.txt
Normal file
3
requirements-hexstrike.txt
Normal file
@@ -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
|
||||
42
scripts/install_hexstrike_deps.sh
Normal file
42
scripts/install_hexstrike_deps.sh
Normal file
@@ -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
|
||||
@@ -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!"
|
||||
|
||||
Reference in New Issue
Block a user