ui: open interactive ToolsScreen on /tools; fallback to list if push fails

This commit is contained in:
giveen
2026-01-11 18:42:40 -07:00
parent a3822e2cb8
commit 704b5056ea
10 changed files with 484 additions and 49 deletions

View File

@@ -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")

View File

@@ -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"""

View File

@@ -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"

View File

@@ -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()):

View File

@@ -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
}
}
}

View File

@@ -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
View File

@@ -0,0 +1,4 @@
{
"typeCheckingMode": "basic",
"reportMissingImports": true
}

View 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

View 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

View File

@@ -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!"