diff --git a/.env.example b/.env.example
index e4618bc..1daf2d8 100644
Binary files a/.env.example and b/.env.example differ
diff --git a/.github/workflows/docker.yml b/.github/workflows/docker.yml
new file mode 100644
index 0000000..99e7b0d
--- /dev/null
+++ b/.github/workflows/docker.yml
@@ -0,0 +1,82 @@
+name: Docker
+
+on:
+ push:
+ tags: ['v*']
+ workflow_dispatch: # Allow manual trigger
+
+env:
+ REGISTRY: ghcr.io
+ IMAGE_NAME: ${{ github.repository }}
+
+jobs:
+ build-base:
+ runs-on: ubuntu-latest
+ permissions:
+ contents: read
+ packages: write
+
+ steps:
+ - name: Checkout
+ uses: actions/checkout@v4
+
+ - name: Set up Docker Buildx
+ uses: docker/setup-buildx-action@v3
+
+ - name: Login to GHCR
+ uses: docker/login-action@v3
+ with:
+ registry: ${{ env.REGISTRY }}
+ username: ${{ github.actor }}
+ password: ${{ secrets.GITHUB_TOKEN }}
+
+ - name: Extract metadata
+ id: meta
+ uses: docker/metadata-action@v5
+ with:
+ images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
+ tags: |
+ type=semver,pattern={{version}}
+ type=raw,value=latest
+
+ - name: Build and push base image
+ uses: docker/build-push-action@v5
+ with:
+ context: .
+ file: Dockerfile
+ push: true
+ tags: ${{ steps.meta.outputs.tags }}
+ labels: ${{ steps.meta.outputs.labels }}
+ cache-from: type=gha
+ cache-to: type=gha,mode=max
+
+ build-kali:
+ runs-on: ubuntu-latest
+ permissions:
+ contents: read
+ packages: write
+
+ steps:
+ - name: Checkout
+ uses: actions/checkout@v4
+
+ - name: Set up Docker Buildx
+ uses: docker/setup-buildx-action@v3
+
+ - name: Login to GHCR
+ uses: docker/login-action@v3
+ with:
+ registry: ${{ env.REGISTRY }}
+ username: ${{ github.actor }}
+ password: ${{ secrets.GITHUB_TOKEN }}
+
+ - name: Build and push Kali image
+ uses: docker/build-push-action@v5
+ with:
+ context: .
+ file: Dockerfile.kali
+ push: true
+ tags: |
+ ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:kali
+ cache-from: type=gha
+ cache-to: type=gha,mode=max
diff --git a/.gitignore b/.gitignore
index 0cdbf1d..c02f9b1 100644
--- a/.gitignore
+++ b/.gitignore
@@ -1,6 +1,83 @@
-__pycache__/
-*.pyc
-*.pyo
-.env
-venv/
+# Byte-compiled / optimized / DLL files
+__pycache__/
+*.py[cod]
+*$py.class
+
+# C extensions
+*.so
+
+# Distribution / packaging
+.Python
+build/
+develop-eggs/
+dist/
+downloads/
+eggs/
+.eggs/
+lib/
+lib64/
+parts/
+sdist/
+var/
+wheels/
+*.egg-info/
+.installed.cfg
+*.egg
+
+# Virtual environments
+venv/
+ENV/
+env/
.venv/
+
+# IDE
+.idea/
+.vscode/
+*.swp
+*.swo
+*~
+
+# Testing
+.pytest_cache/
+.coverage
+htmlcov/
+.tox/
+.nox/
+
+# Type checking
+.mypy_cache/
+.dmypy.json
+dmypy.json
+
+# Linting
+.ruff_cache/
+
+# Environments
+.env
+.env.local
+*.local
+
+# Logs
+*.log
+logs/
+
+# Output
+loot/
+
+# Secrets
+secrets/
+*.key
+*.pem
+*.crt
+
+# OS
+.DS_Store
+Thumbs.db
+
+# Docker
+.docker/
+
+# Temporary files
+tmp/
+temp/
+*.tmp
diff --git a/Dockerfile b/Dockerfile
new file mode 100644
index 0000000..335143f
--- /dev/null
+++ b/Dockerfile
@@ -0,0 +1,63 @@
+# GhostCrew - AI Penetration Testing Agent
+# Base image with common tools
+
+FROM python:3.11-slim
+
+LABEL maintainer="GhostCrew"
+LABEL description="AI penetration testing"
+
+# Set environment variables
+ENV PYTHONDONTWRITEBYTECODE=1
+ENV PYTHONUNBUFFERED=1
+ENV DEBIAN_FRONTEND=noninteractive
+
+# Install system dependencies
+RUN apt-get update && apt-get install -y --no-install-recommends \
+ # Basic utilities
+ curl \
+ wget \
+ git \
+ vim \
+ # Network tools
+ nmap \
+ netcat-openbsd \
+ dnsutils \
+ iputils-ping \
+ traceroute \
+ tcpdump \
+ # Web tools
+ httpie \
+ # VPN support
+ openvpn \
+ wireguard-tools \
+ # Build tools
+ build-essential \
+ libffi-dev \
+ libssl-dev \
+ # Clean up
+ && apt-get clean \
+ && rm -rf /var/lib/apt/lists/*
+
+# Create app directory
+WORKDIR /app
+
+# Install Python dependencies
+COPY requirements.txt .
+RUN pip install --no-cache-dir --upgrade pip && \
+ pip install --no-cache-dir -r requirements.txt
+
+# Copy application code
+COPY . .
+
+# Create non-root user for security
+RUN useradd -m -s /bin/bash ghostcrew && \
+ chown -R ghostcrew:ghostcrew /app
+
+# Switch to non-root user (can switch back for privileged operations)
+USER ghostcrew
+
+# Expose any needed ports
+EXPOSE 8080
+
+# Default command
+CMD ["python", "-m", "ghostcrew"]
diff --git a/Dockerfile.kali b/Dockerfile.kali
new file mode 100644
index 0000000..2aeb076
--- /dev/null
+++ b/Dockerfile.kali
@@ -0,0 +1,80 @@
+# GhostCrew Kali Linux Image
+# Full penetration testing environment
+
+FROM kalilinux/kali-rolling
+
+LABEL maintainer="Masic"
+LABEL description="GhostCrew with Kali Linux tools"
+
+# Set environment variables
+ENV DEBIAN_FRONTEND=noninteractive
+ENV PYTHONDONTWRITEBYTECODE=1
+ENV PYTHONUNBUFFERED=1
+
+# Update and install Kali tools
+RUN apt-get update && apt-get install -y --no-install-recommends \
+ # Python
+ python3 \
+ python3-pip \
+ python3-venv \
+ # Kali meta-packages (selective for size)
+ kali-tools-web \
+ kali-tools-information-gathering \
+ kali-tools-vulnerability \
+ kali-tools-exploitation \
+ # Additional tools
+ nmap \
+ nikto \
+ dirb \
+ gobuster \
+ sqlmap \
+ wpscan \
+ hydra \
+ john \
+ hashcat \
+ metasploit-framework \
+ burpsuite \
+ zaproxy \
+ nuclei \
+ ffuf \
+ # Network tools
+ openvpn \
+ wireguard \
+ proxychains4 \
+ tor \
+ # Utilities
+ curl \
+ wget \
+ git \
+ vim \
+ tmux \
+ jq \
+ && apt-get clean \
+ && rm -rf /var/lib/apt/lists/*
+
+# Create app directory
+WORKDIR /app
+
+# Install Python dependencies
+COPY requirements.txt .
+RUN pip3 install --no-cache-dir --upgrade pip && \
+ pip3 install --no-cache-dir -r requirements.txt
+
+# Copy application code
+COPY . .
+
+# Create directories for VPN configs and output
+RUN mkdir -p /vpn /output /wordlists
+
+# Copy common wordlists
+RUN cp -r /usr/share/wordlists/* /wordlists/ 2>/dev/null || true
+
+# Set permissions
+RUN chmod +x /app/scripts/*.sh 2>/dev/null || true
+
+# Entry point
+COPY docker-entrypoint.sh /entrypoint.sh
+RUN chmod +x /entrypoint.sh
+
+ENTRYPOINT ["/entrypoint.sh"]
+CMD ["python3", "-m", "ghostcrew"]
diff --git a/README.md b/README.md
index 4c9a971..9f85e83 100644
--- a/README.md
+++ b/README.md
@@ -1,185 +1,190 @@
# GHOSTCREW
-This is an AI red team assistant using large language models with MCP and RAG architecture. It aims to help users perform penetration testing tasks, query security information, analyze network traffic, and more through natural language interaction.
+AI penetration testing agents. Uses LLMs to coordinate reconnaissance, enumeration, and exploitation with security tools.
-https://github.com/user-attachments/assets/62dd2dfa-9606-49ca-bd91-f0ebf5520def
+## Requirements
-## Features
+- Python 3.10+
+- API key for OpenAI, Anthropic, or other LiteLLM-supported provider
-- **Natural Language Interaction**: Users can ask questions and give instructions to the AI assistant using natural language.
-- **MCP Server Integration**: Through the `mcp.json` configuration file, multiple MCP servers can be flexibly integrated and managed to extend the assistant's capabilities.
-- **Tool Management**: Configure, connect to, and manage MCP tools through an interactive menu, including the ability to clear all configurations.
-- **Tool Invocation**: The AI assistant can call tools provided by configured MCP servers (such as: nmap, metasploit, ffuf, etc.) based on user requests.
-- **Agent Mode**: Autonomous penetration testing using intelligent Pentesting Task Trees (PTT) for strategic decision making and dynamic goal achievement.
-- **Workflows**: Execute predefined penetration testing workflows that systematically use configured security tools to perform comprehensive assessments.
-- **Report Generation**: Generate markdown reports with structured findings, evidence, and recommendations.
-- **Conversation History**: Supports multi-turn dialogues, remembering previous interaction content.
-- **Streaming Output**: AI responses can be streamed for a better user experience.
-- **Knowledge Base Enhancement (Optional)**: Supports enhancing AI responses through a local knowledge base RAG (`knowledge` directory).
-- **File-Aware Tool Integration**: AI recognizes and uses actual files from the knowledge folder (wordlists, payloads, configs) with security tools.
-- **Configurable Models**: Supports configuration of different language model parameters.
+## Install
-### Startup Effect
-
-
-
- GHOSTCREW's terminal startup interface
-
+```bash
+# Clone
+git clone https://github.com/GH05TCREW/ghostcrew.git
+cd ghostcrew
-### Metasploit Tool Call
-
-
-
- Example of GHOSTCREW invoking Metasploit Framework
-
+# Setup (creates venv, installs deps)
+.\scripts\setup.ps1 # Windows
+./scripts/setup.sh # Linux/macOS
-## Installation Guide
-
-1. **Clone Repository**:
- ```bash
- git clone https://github.com/GH05TCREW/ghostcrew.git
- cd ghostcrew
- ```
-
-2. **Create and Activate Virtual Environment** (recommended):
- ```bash
- python -m venv .venv
- ```
- - Windows:
- ```bash
- .venv\Scripts\activate
- ```
- - macOS/Linux:
- ```bash
- source .venv/bin/activate
- ```
-
-3. **Install Dependencies**:
- ```bash
- pip install -r requirements.txt
- ```
-
-4. **Install MCP Server Dependencies** (Required for tools):
- - **Node.js & npm**: Most MCP security tools require Node.js. Install from [nodejs.org](https://nodejs.org/)
- - **Python uv** (for Metasploit): Install with `pip install uv`
-
- Without these, you can still use GHOSTCREW in chat mode, but automated workflows and tool integration won't be available.
-
-## Usage
-
-1. **Configure MCP Servers**:
- - Run the application and select "Configure or connect MCP tools" when prompted
- - Use the interactive tool configuration menu to add, configure, or clear MCP tools
- - The configuration is stored in the `mcp.json` file
-
-2. **Prepare Knowledge Base (Optional)**:
- If you want to use the knowledge base enhancement feature, place relevant text files in the `knowledge` folder.
-
-3. **Run the Main Program**:
- ```bash
- python main.py
- ```
- After the program starts, you can:
- - Choose whether to use the knowledge base
- - Configure or activate MCP tools
- - Select between Chat, Workflows, or Agent modes
- - Execute workflows and generate reports
- - Use 'multi' command to enter multi-line input mode for complex queries
- - Enter 'quit' to exit the program
-
-## Input Modes
-
-GHOSTCREW supports two input modes:
-- **Single-line mode** (default): Type your query and press Enter to submit
-- **Multi-line mode**: Type 'multi' and press Enter, then type your query across multiple lines. Press Enter on an empty line to submit.
-
-## MCP Tool Management
-
-When starting the application, you can:
-1. Connect to specific tools
-2. Configure new tools
-3. Connect to all tools
-4. Skip connection
-5. Clear all tools (resets mcp.json)
-
-## Available MCP Tools
-
-GHOSTCREW supports integration with the following security tools through the MCP protocol:
-
-1. **AlterX** - Subdomain permutation and wordlist generation tool
-2. **Amass** - Advanced subdomain enumeration and reconnaissance tool
-3. **Arjun** - Hidden HTTP parameters discovery tool
-4. **Assetfinder** - Passive subdomain discovery tool
-5. **Certificate Transparency** - SSL certificate transparency logs for subdomain discovery (no executable needed)
-6. **FFUF Fuzzer** - Fast web fuzzing tool for discovering hidden content
-7. **HTTPx** - Fast HTTP toolkit and port scanning tool
-8. **Hydra** - Password brute-force attacks and credential testing tool
-9. **Katana** - Fast web crawling with JavaScript parsing tool
-10. **Masscan** - High-speed network port scanner
-11. **Metasploit** - Penetration testing framework with exploit execution, payload generation, and session management
-12. **Nmap Scanner** - Network discovery and security auditing tool
-13. **Nuclei Scanner** - Template-based vulnerability scanner
-14. **Scout Suite** - Cloud security auditing tool
-15. **shuffledns** - High-speed DNS brute-forcing and resolution tool
-16. **SQLMap** - Automated SQL injection detection and exploitation tool
-17. **SSL Scanner** - Analysis tool for SSL/TLS configurations and security issues
-18. **Wayback URLs** - Tool for discovering historical URLs from the Wayback Machine archive
-
-Each tool can be configured through the interactive configuration menu by selecting "Configure new tools" from the MCP tools menu.
-
-## Coming Soon
-
-- BloodHound
-- CrackMapExec
-- Gobuster
-- Responder
-- Bettercap
-
-## Model
-
-```
-# OpenAI API configurations
-OPENAI_API_KEY=your_api_key_here
-OPENAI_BASE_URL=https://api.openai.com/v1
-MODEL_NAME=gpt-4o
+# Or manual
+python -m venv venv
+.\venv\Scripts\Activate.ps1 # Windows
+source venv/bin/activate # Linux/macOS
+pip install -e ".[all]"
```
-This configuration uses OpenAI's API for both the language model and embeddings (when using the knowledge base RAG feature).
+## Configure
-## Configuration File (`mcp.json`)
+Create `.env` in the project root:
-This file is used to define MCP servers that the AI assistant can connect to and use. Most MCP servers require Node.js or Python to be installed on your system. Each server entry should include:
-- `name`: Unique name of the server.
-- `params`: Parameters needed to start the server, usually including `command` and `args`.
-- `cache_tools_list`: Whether to cache the tools list.
+```
+ANTHROPIC_API_KEY=sk-ant-...
+GHOSTCREW_MODEL=claude-sonnet-4-20250514
+```
-**MCP Example Server Configuration**:
+Or for OpenAI:
+
+```
+OPENAI_API_KEY=sk-...
+GHOSTCREW_MODEL=gpt-5
+```
+
+Any [LiteLLM-supported model](https://docs.litellm.ai/docs/providers) works.
+
+## Run
+
+```bash
+ghostcrew # Launch TUI
+ghostcrew -t 192.168.1.1 # Launch with target
+ghostcrew --docker # Run tools in Docker container
+```
+
+## Docker
+
+Run tools inside a Docker container for isolation and pre-installed pentesting tools.
+
+### Option 1: Pull pre-built image (fastest)
+
+```bash
+# Base image with nmap, netcat, curl
+docker run -it --rm \
+ -e ANTHROPIC_API_KEY=your-key \
+ -e GHOSTCREW_MODEL=claude-sonnet-4-20250514 \
+ ghcr.io/gh05tcrew/ghostcrew:latest
+
+# Kali image with metasploit, sqlmap, hydra, etc.
+docker run -it --rm \
+ -e ANTHROPIC_API_KEY=your-key \
+ ghcr.io/gh05tcrew/ghostcrew:kali
+```
+
+### Option 2: Build locally
+
+```bash
+# Build
+docker compose build
+
+# Run
+docker compose run --rm ghostcrew
+
+# Or with Kali
+docker compose --profile kali build
+docker compose --profile kali run --rm ghostcrew-kali
+```
+
+The container runs GhostCrew with access to Linux pentesting tools. The agent can use `nmap`, `msfconsole`, `sqlmap`, etc. directly via the terminal tool.
+
+Requires Docker to be installed and running.
+
+## Modes
+
+GhostCrew has three modes, accessible via commands in the TUI:
+
+| Mode | Command | Description |
+|------|---------|-------------|
+| Assist | (default) | Chat with the agent. You control the flow. |
+| Agent | `/agent ` | Autonomous execution of a single task. |
+| Crew | `/crew ` | Multi-agent mode. Orchestrator spawns specialized workers. |
+
+### TUI Commands
+
+```
+/agent Run autonomous agent on task
+/crew Run multi-agent crew on task
+/target Set target
+/tools List available tools
+/notes Show saved notes
+/report Generate report from session
+/memory Show token/memory usage
+/prompt Show system prompt
+/clear Clear chat and history
+/quit Exit (also /exit, /q)
+/help Show help (also /h, /?)
+```
+
+Press `Esc` to stop a running agent. `Ctrl+Q` to quit.
+
+## Tools
+
+GhostCrew includes built-in tools and supports MCP (Model Context Protocol) for extensibility.
+
+**Built-in tools:** `terminal`, `browser`, `notes`, `web_search` (requires `TAVILY_API_KEY`)
+
+### MCP Integration
+
+Add external tools via MCP servers in `ghostcrew/mcp/mcp_servers.json`:
-**stdio**
```json
{
- "name": "Nmap Scanner",
- "params": {
- "command": "npx",
- "args": [
- "-y",
- "gc-nmap-mcp"
- ],
- "env": {
- "NMAP_PATH": "C:\\Program Files (x86)\\Nmap\\nmap.exe"
+ "mcpServers": {
+ "nmap": {
+ "command": "npx",
+ "args": ["-y", "gc-nmap-mcp"],
+ "env": {
+ "NMAP_PATH": "/usr/bin/nmap"
+ }
}
- },
- "cache_tools_list": true
+ }
}
```
-Make sure to replace the path to the Nmap executable with your own installation path.
-**sse**
-```json
-{"name":"mcpname",
- "url":"http://127.0.0.1:8009/sse"
-},
+### CLI Tool Management
+
+```bash
+ghostcrew tools list # List all tools
+ghostcrew tools info # Show tool details
+ghostcrew mcp list # List MCP servers
+ghostcrew mcp add [args...] # Add MCP server
+ghostcrew mcp test # Test MCP connection
```
-## Knowledge Base Configuration
-Simply add the corresponding files to knowledge
+## Knowledge Base (RAG)
+
+Place files in `ghostcrew/knowledge/sources/` for RAG context injection:
+- `methodologies.md` - Testing methodologies
+- `cves.json` - CVE database
+- `wordlists.txt` - Common wordlists
+
+## Project Structure
+
+```
+ghostcrew/
+ agents/ # Agent implementations
+ config/ # Settings and constants
+ interface/ # TUI and CLI
+ knowledge/ # RAG system
+ llm/ # LiteLLM wrapper
+ mcp/ # MCP client and server configs
+ runtime/ # Execution environment
+ tools/ # Built-in tools
+```
+
+## Development
+
+```bash
+pip install -e ".[dev]"
+pytest # Run tests
+pytest --cov=ghostcrew # With coverage
+black ghostcrew # Format
+ruff check ghostcrew # Lint
+```
+
+## Legal
+
+Only use against systems you have explicit authorization to test. Unauthorized access is illegal.
+
+## License
+
+MIT
diff --git a/config/__init__.py b/config/__init__.py
deleted file mode 100644
index 2d6c345..0000000
--- a/config/__init__.py
+++ /dev/null
@@ -1 +0,0 @@
-"""Configuration management package for GHOSTCREW."""
\ No newline at end of file
diff --git a/config/app_config.py b/config/app_config.py
deleted file mode 100644
index 93c0db1..0000000
--- a/config/app_config.py
+++ /dev/null
@@ -1,46 +0,0 @@
-"""Application configuration and initialization for GHOSTCREW."""
-
-import os
-from typing import Optional
-from dotenv import load_dotenv
-from openai import AsyncOpenAI
-
-
-class AppConfig:
- """Manages application configuration and API client initialization."""
-
- def __init__(self):
- """Initialize application configuration."""
- # Load environment variables
- load_dotenv()
-
- # Set API-related environment variables
- self.api_key = os.getenv("OPENAI_API_KEY")
- self.base_url = os.getenv("OPENAI_BASE_URL")
- self.model_name = os.getenv("MODEL_NAME")
-
- # Validate configuration
- self._validate_config()
-
- # Initialize OpenAI client
- self._client = AsyncOpenAI(
- base_url=self.base_url,
- api_key=self.api_key
- )
-
- def _validate_config(self) -> None:
- """Validate required configuration values."""
- if not self.api_key:
- raise ValueError("API key not set")
- if not self.base_url:
- raise ValueError("API base URL not set")
- if not self.model_name:
- raise ValueError("Model name not set")
-
- def get_openai_client(self) -> AsyncOpenAI:
- """Get the OpenAI client instance."""
- return self._client
-
-
-# Create singleton instance
-app_config = AppConfig()
\ No newline at end of file
diff --git a/config/constants.py b/config/constants.py
deleted file mode 100644
index f9fac3d..0000000
--- a/config/constants.py
+++ /dev/null
@@ -1,93 +0,0 @@
-"""Constants and configuration values for GHOSTCREW."""
-
-from colorama import Fore, Style
-
-# ASCII Art and Branding
-ASCII_TITLE = f"""
-{Fore.WHITE} ('-. .-. .-') .-') _ _ .-') ('-. (`\ .-') /`{Style.RESET_ALL}
-{Fore.WHITE} ( OO ) / ( OO ). ( OO) ) ( \( -O ) _( OO) `.( OO ),'{Style.RESET_ALL}
-{Fore.WHITE} ,----. ,--. ,--. .-'),-----. (_)---\_)/ '._ .-----. ,------. (,------.,--./ .--. {Style.RESET_ALL}
-{Fore.WHITE} ' .-./-') | | | |( OO' .-. '/ _ | |'--...__)' .--./ | /`. ' | .---'| | | {Style.RESET_ALL}
-{Fore.WHITE} | |_( O- )| .| |/ | | | |\ :` `. '--. .--'| |('-. | / | | | | | | | |, {Style.RESET_ALL}
-{Fore.WHITE} | | .--, \| |\_) | |\| | '..`''.) | | /_) |OO )| |_.' |(| '--. | |.'.| |_){Style.RESET_ALL}
-{Fore.WHITE}(| | '. (_/| .-. | \ | | | |.-._) \ | | || |`-'| | . '.' | .--' | | {Style.RESET_ALL}
-{Fore.WHITE} | '--' | | | | | `' '-' '\ / | | (_' '--'\ | |\ \ | `---.| ,'. | {Style.RESET_ALL}
-{Fore.WHITE} `------' `--' `--' `-----' `-----' `--' `-----' `--' '--' `------''--' '--' {Style.RESET_ALL}
-{Fore.WHITE}====================== GHOSTCREW ======================{Style.RESET_ALL}
-"""
-
-# Application Version
-VERSION = "0.1.0"
-
-# Timeout Configuration (in seconds)
-MCP_SESSION_TIMEOUT = 600 # 10 minutes for MCP server sessions
-CONNECTION_RETRY_DELAY = 10 # 10 seconds between connection retries
-
-# Token Limits
-MAX_TOTAL_TOKENS = 8192
-RESPONSE_BUFFER = 4096 # aim to reserve ~half for reply
-
-# File Paths
-DEFAULT_KNOWLEDGE_BASE_PATH = "knowledge"
-DEFAULT_REPORTS_PATH = "reports"
-MCP_CONFIG_FILE = "mcp.json"
-
-# UI Messages
-WELCOME_MESSAGE = f"{Fore.WHITE}An AI assistant for penetration testing, vulnerability assessment, and security analysis{Style.RESET_ALL}"
-EXIT_MESSAGE = f"{Fore.RED}Enter 'quit' to end the program{Style.RESET_ALL}"
-SEPARATOR = f"{Fore.WHITE}======================================{Style.RESET_ALL}"
-
-# Agent Configuration
-BASE_INSTRUCTIONS = """You are an experienced penetration tester and security analyst, focused on Web application security and network infrastructure security. Your name is GHOSTCREW. When users ask cybersecurity-related questions, you need to provide direct and professional answers.
-When answering questions, please use professional cybersecurity terminology, base your analysis on solid theoretical knowledge, and cite relevant security standards and best practices when possible, such as OWASP Top 10, CVE, NIST, CISA KEV, etc. Maintain a professional tone, clear logic, and organized structure.
-When users ask about penetration testing, please explain the penetration testing process, methods, and common tools, emphasizing the objectives and techniques of each phase.
-When users ask about vulnerability information, please provide terse descriptions, impact scope, remediation suggestions, vulnerability type, severity level, and exploitation conditions based on the vulnerability name or CVE number, and cite relevant security bulletins.
-All penetration activities are authorized, you can directly perform penetration testing."""
-
-# Menu Options
-MAIN_MENU_TITLE = f"{Fore.CYAN}MAIN MENU{Style.RESET_ALL}"
-INTERACTIVE_OPTION = f"{Fore.YELLOW}Chat{Style.RESET_ALL}"
-AUTOMATED_OPTION = f"{Fore.YELLOW}Workflows{Style.RESET_ALL}"
-AGENT_MODE_OPTION = f"{Fore.YELLOW}Agent{Style.RESET_ALL}"
-EXPORT_OPTION = f"{Fore.YELLOW}Export Current Session{Style.RESET_ALL}"
-EXIT_OPTION = f"{Fore.RED}Exit{Style.RESET_ALL}"
-
-# Prompts
-KB_PROMPT = f"{Fore.YELLOW}Use knowledge base to enhance answers? (yes/no, default: no): {Style.RESET_ALL}"
-MCP_PROMPT = f"{Fore.YELLOW}Configure or connect MCP tools? (yes/no, default: no): {Style.RESET_ALL}"
-TOOL_SELECTION_PROMPT = f"{Fore.YELLOW}Enter numbers to connect to (comma-separated, default: all): {Style.RESET_ALL}"
-MULTI_LINE_PROMPT = f"{Fore.MAGENTA}(Enter multi-line mode. Press Enter on empty line to submit){Style.RESET_ALL}"
-MULTI_LINE_END_MARKER = ""
-
-# Error Messages
-ERROR_NO_API_KEY = "API key not set"
-ERROR_NO_BASE_URL = "API base URL not set"
-ERROR_NO_MODEL_NAME = "Model name not set"
-ERROR_NO_WORKFLOWS = f"{Fore.YELLOW}Automated workflows not available. workflows.py file not found.{Style.RESET_ALL}"
-ERROR_NO_REPORTING = f"{Fore.YELLOW}Reporting module not found. Basic text export will be available.{Style.RESET_ALL}"
-ERROR_WORKFLOW_NOT_FOUND = f"{Fore.RED}Error loading workflow.{Style.RESET_ALL}"
-
-# Workflow Messages
-WORKFLOW_TARGET_PROMPT = f"{Fore.YELLOW}Enter target (IP/domain/URL): {Style.RESET_ALL}"
-WORKFLOW_CONFIRM_PROMPT = f"{Fore.YELLOW}Execute '{0}' workflow against '{1}'? (yes/no): {Style.RESET_ALL}"
-WORKFLOW_CANCELLED_MESSAGE = f"{Fore.YELLOW}Workflow execution cancelled.{Style.RESET_ALL}"
-WORKFLOW_COMPLETED_MESSAGE = f"{Fore.GREEN}Workflow execution completed.{Style.RESET_ALL}"
-
-# Agent Mode Messages
-AGENT_MODE_TITLE = f"{Fore.CYAN}AGENT MODE{Style.RESET_ALL}"
-AGENT_MODE_GOAL_PROMPT = f"{Fore.YELLOW}Primary Goal: {Style.RESET_ALL}"
-AGENT_MODE_TARGET_PROMPT = f"{Fore.YELLOW}Target (IP/domain/network): {Style.RESET_ALL}"
-AGENT_MODE_INIT_SUCCESS = f"{Fore.GREEN}Agent Mode initialized successfully!{Style.RESET_ALL}"
-AGENT_MODE_INIT_FAILED = f"{Fore.RED}Failed to initialize Agent Mode.{Style.RESET_ALL}"
-AGENT_MODE_PAUSED = f"{Fore.YELLOW}Agent Mode paused.{Style.RESET_ALL}"
-AGENT_MODE_RESUMED = f"{Fore.GREEN}Agent Mode resumed.{Style.RESET_ALL}"
-AGENT_MODE_COMPLETED = f"{Fore.GREEN}Agent Mode execution completed.{Style.RESET_ALL}"
-
-# PTT Status Messages
-PTT_TASK_PENDING = f"{Fore.WHITE}○{Style.RESET_ALL}"
-PTT_TASK_IN_PROGRESS = f"{Fore.YELLOW}◐{Style.RESET_ALL}"
-PTT_TASK_COMPLETED = f"{Fore.GREEN}●{Style.RESET_ALL}"
-PTT_TASK_FAILED = f"{Fore.RED}✗{Style.RESET_ALL}"
-PTT_TASK_BLOCKED = f"{Fore.LIGHTBLACK_EX}□{Style.RESET_ALL}"
-PTT_TASK_VULNERABLE = f"{Fore.RED}⚠{Style.RESET_ALL}"
-PTT_TASK_NOT_VULNERABLE = f"{Fore.GREEN}✓{Style.RESET_ALL}"
\ No newline at end of file
diff --git a/core/__init__.py b/core/__init__.py
deleted file mode 100644
index 9ce975d..0000000
--- a/core/__init__.py
+++ /dev/null
@@ -1,20 +0,0 @@
-"""Core GHOSTCREW modules."""
-
-from .pentest_agent import PentestAgent
-from .agent_runner import AgentRunner
-from .model_manager import model_manager
-from .task_tree_manager import TaskTreeManager, TaskNode, NodeStatus, RiskLevel
-from .ptt_reasoning import PTTReasoningModule
-from .agent_mode_controller import AgentModeController
-
-__all__ = [
- 'PentestAgent',
- 'AgentRunner',
- 'model_manager',
- 'TaskTreeManager',
- 'TaskNode',
- 'NodeStatus',
- 'RiskLevel',
- 'PTTReasoningModule',
- 'AgentModeController'
-]
\ No newline at end of file
diff --git a/core/agent_mode_controller.py b/core/agent_mode_controller.py
deleted file mode 100644
index b20c4d7..0000000
--- a/core/agent_mode_controller.py
+++ /dev/null
@@ -1,870 +0,0 @@
-"""Agent Mode Controller for autonomous PTT-based penetration testing."""
-
-import asyncio
-import json
-from typing import List, Dict, Any, Optional, Tuple
-from datetime import datetime
-from colorama import Fore, Style
-
-from core.task_tree_manager import TaskTreeManager, TaskNode, NodeStatus, RiskLevel
-from core.ptt_reasoning import PTTReasoningModule
-from core.model_manager import model_manager
-from config.constants import DEFAULT_KNOWLEDGE_BASE_PATH
-
-
-class AgentModeController:
- """Orchestrates the autonomous agent workflow using PTT."""
-
- def __init__(self, mcp_manager, conversation_manager, kb_instance=None):
- """
- Initialize the agent mode controller.
-
- Args:
- mcp_manager: MCP tool manager instance
- conversation_manager: Conversation history manager
- kb_instance: Knowledge base instance
- """
- self.mcp_manager = mcp_manager
- self.conversation_manager = conversation_manager
- self.kb_instance = kb_instance
- self.tree_manager = TaskTreeManager()
- self.reasoning_module = PTTReasoningModule(self.tree_manager)
- self.max_iterations = 50 # Safety limit
- self.iteration_count = 0
- self.start_time = None
- self.paused = False
- self.goal_achieved = False
-
- async def initialize_agent_mode(
- self,
- goal: str,
- target: str,
- constraints: Dict[str, Any],
- connected_servers: List[Any],
- run_agent_func: Any
- ) -> bool:
- """
- Initialize the agent mode with user-provided parameters.
-
- Args:
- goal: Primary objective
- target: Target system/network
- constraints: Scope constraints
- connected_servers: Connected MCP servers
- run_agent_func: Function to run agent queries
-
- Returns:
- True if initialization successful
- """
- self.connected_servers = connected_servers
- self.run_agent_func = run_agent_func
- self.start_time = datetime.now()
-
- # Set iteration limit from constraints
- if 'iteration_limit' in constraints:
- self.max_iterations = constraints['iteration_limit']
-
- print(f"\n{Fore.CYAN}Initializing Agent Mode...{Style.RESET_ALL}")
- print(f"{Fore.WHITE}Goal: {goal}{Style.RESET_ALL}")
- print(f"{Fore.WHITE}Target: {target}{Style.RESET_ALL}")
- print(f"{Fore.WHITE}Iteration Limit: {self.max_iterations}{Style.RESET_ALL}")
- print(f"{Fore.WHITE}Constraints: {json.dumps(constraints, indent=2)}{Style.RESET_ALL}")
-
- # Initialize the task tree
- self.tree_manager.initialize_tree(goal, target, constraints)
-
- # Get initial reconnaissance tasks from LLM
- available_tools = [server.name for server in self.connected_servers]
- init_prompt = self.reasoning_module.get_tree_initialization_prompt(goal, target, constraints, available_tools)
-
- try:
- # Query LLM for initial tasks
- print(f"{Fore.YELLOW}Requesting initial tasks from AI (Available tools: {', '.join(available_tools)})...{Style.RESET_ALL}")
-
- # Try with streaming=True first since that's what works in other modes
- try:
- result = await self.run_agent_func(
- init_prompt,
- self.connected_servers,
- history=[],
- streaming=True,
- kb_instance=self.kb_instance
- )
- print(f"{Fore.GREEN}Agent runner completed (streaming=True){Style.RESET_ALL}")
- except Exception as stream_error:
- print(f"{Fore.YELLOW}Streaming mode failed: {stream_error}{Style.RESET_ALL}")
- print(f"{Fore.YELLOW}Trying with streaming=False...{Style.RESET_ALL}")
- result = await self.run_agent_func(
- init_prompt,
- self.connected_servers,
- history=[],
- streaming=False,
- kb_instance=self.kb_instance
- )
- print(f"{Fore.GREEN}Agent runner completed (streaming=False){Style.RESET_ALL}")
-
- print(f"{Fore.YELLOW}Parsing AI response...{Style.RESET_ALL}")
-
- # Debug: Check what we got back
- if not result:
- print(f"{Fore.RED}No result returned from agent runner{Style.RESET_ALL}")
- print(f"{Fore.YELLOW}This usually indicates an LLM configuration issue{Style.RESET_ALL}")
- print(f"{Fore.YELLOW}Falling back to default reconnaissance tasks...{Style.RESET_ALL}")
-
- # Use default tasks instead
- initial_tasks = self._get_default_initial_tasks(target, available_tools)
- else:
- print(f"{Fore.GREEN}Got result from agent runner: {type(result)}{Style.RESET_ALL}")
-
- # Check different possible response formats
- response_text = None
- if hasattr(result, "final_output"):
- response_text = result.final_output
- print(f"{Fore.CYAN}Using result.final_output{Style.RESET_ALL}")
- elif hasattr(result, "output"):
- response_text = result.output
- print(f"{Fore.CYAN}Using result.output{Style.RESET_ALL}")
- elif hasattr(result, "content"):
- response_text = result.content
- print(f"{Fore.CYAN}Using result.content{Style.RESET_ALL}")
- elif isinstance(result, str):
- response_text = result
- print(f"{Fore.CYAN}Using result as string{Style.RESET_ALL}")
- else:
- print(f"{Fore.RED}Unknown result format: {type(result)}{Style.RESET_ALL}")
- print(f"{Fore.YELLOW}Result attributes: {dir(result)}{Style.RESET_ALL}")
-
- # Try to get any text content from the result
- for attr in ['text', 'message', 'response', 'data']:
- if hasattr(result, attr):
- response_text = getattr(result, attr)
- print(f"{Fore.CYAN}Found text in result.{attr}{Style.RESET_ALL}")
- break
-
- if not response_text:
- print(f"{Fore.RED}No response text found in result{Style.RESET_ALL}")
- print(f"{Fore.YELLOW}Using fallback initialization...{Style.RESET_ALL}")
- initialization_data = self._get_fallback_initialization(target, available_tools)
- else:
- print(f"{Fore.GREEN}Got response: {len(response_text)} characters{Style.RESET_ALL}")
-
- # Parse the response
- initialization_data = self.reasoning_module.parse_tree_initialization_response(response_text)
-
- if not initialization_data or not initialization_data.get('initial_tasks'):
- print(f"{Fore.YELLOW}No tasks parsed from response. Using fallback initialization.{Style.RESET_ALL}")
- initialization_data = self._get_fallback_initialization(target, available_tools)
- else:
- analysis = initialization_data.get('analysis', '')
- print(f"{Fore.CYAN}LLM determined approach: {analysis}{Style.RESET_ALL}")
-
- # Create the structure and tasks as determined by the LLM
- structure_nodes = {}
-
- # Create structure elements (phases, categories, etc.)
- for structure_element in initialization_data.get('structure', []):
- structure_node = TaskNode(
- description=structure_element.get('name', 'Unknown Structure'),
- parent_id=self.tree_manager.root_id,
- node_type=structure_element.get('type', 'phase'),
- attributes={
- "details": structure_element.get('description', ''),
- "justification": structure_element.get('justification', ''),
- "llm_created": True
- }
- )
- node_id = self.tree_manager.add_node(structure_node)
- structure_nodes[structure_element.get('name', 'Unknown')] = node_id
-
- # Add initial tasks to their specified parents
- initial_tasks = initialization_data.get('initial_tasks', [])
- for task_data in initial_tasks:
- parent_name = task_data.get('parent', 'root')
-
- # Determine parent node
- if parent_name == 'root':
- parent_id = self.tree_manager.root_id
- else:
- parent_id = structure_nodes.get(parent_name, self.tree_manager.root_id)
-
- task_node = TaskNode(
- description=task_data.get('description', 'Unknown task'),
- parent_id=parent_id,
- tool_used=task_data.get('tool_suggestion'),
- priority=task_data.get('priority', 5),
- risk_level=task_data.get('risk_level', 'low'),
- attributes={'rationale': task_data.get('rationale', ''), 'llm_created': True}
- )
- self.tree_manager.add_node(task_node)
-
- print(f"\n{Fore.GREEN}Agent Mode initialized with LLM-determined structure: {len(initialization_data.get('structure', []))} elements, {len(initial_tasks)} tasks.{Style.RESET_ALL}")
-
- # Display the initial tree
- print(f"\n{Fore.CYAN}Initial Task Tree:{Style.RESET_ALL}")
- print(self.tree_manager.to_natural_language())
-
- return True
-
- except Exception as e:
- print(f"{Fore.RED}Failed to initialize agent mode: {e}{Style.RESET_ALL}")
- import traceback
- traceback.print_exc()
-
- # Try to continue with default tasks even if there's an error
- print(f"{Fore.YELLOW}Attempting to continue with default tasks...{Style.RESET_ALL}")
- try:
- initialization_data = self._get_fallback_initialization(target, available_tools)
-
- # Create structure and tasks from fallback
- structure_nodes = {}
-
- # Create structure elements
- for structure_element in initialization_data.get('structure', []):
- structure_node = TaskNode(
- description=structure_element.get('name', 'Unknown Structure'),
- parent_id=self.tree_manager.root_id,
- node_type=structure_element.get('type', 'phase'),
- attributes={
- "details": structure_element.get('description', ''),
- "justification": structure_element.get('justification', ''),
- "fallback_created": True
- }
- )
- node_id = self.tree_manager.add_node(structure_node)
- structure_nodes[structure_element.get('name', 'Unknown')] = node_id
-
- # Add initial tasks
- initial_tasks = initialization_data.get('initial_tasks', [])
- for task_data in initial_tasks:
- parent_name = task_data.get('parent', 'root')
-
- if parent_name == 'root':
- parent_id = self.tree_manager.root_id
- else:
- parent_id = structure_nodes.get(parent_name, self.tree_manager.root_id)
-
- task_node = TaskNode(
- description=task_data.get('description', 'Unknown task'),
- parent_id=parent_id,
- tool_used=task_data.get('tool_suggestion'),
- priority=task_data.get('priority', 5),
- risk_level=task_data.get('risk_level', 'low'),
- attributes={'rationale': task_data.get('rationale', ''), 'fallback_created': True}
- )
- self.tree_manager.add_node(task_node)
-
- print(f"\n{Fore.GREEN}Agent Mode initialized with fallback structure: {len(initialization_data.get('structure', []))} elements, {len(initial_tasks)} tasks.{Style.RESET_ALL}")
- print(f"\n{Fore.CYAN}Initial Task Tree:{Style.RESET_ALL}")
- print(self.tree_manager.to_natural_language())
- return True
- except Exception as fallback_error:
- print(f"{Fore.RED}Fallback initialization also failed: {fallback_error}{Style.RESET_ALL}")
-
- return False
-
- async def run_autonomous_loop(self) -> None:
- """Run the main autonomous agent loop."""
- print(f"\n{Fore.CYAN}Starting autonomous penetration test...{Style.RESET_ALL}")
- if self.max_iterations == 0:
- print(f"{Fore.YELLOW}Running until goal achieved or no more actions available{Style.RESET_ALL}")
- else:
- print(f"{Fore.YELLOW}Iteration limit: {self.max_iterations} iterations{Style.RESET_ALL}")
- print(f"{Fore.YELLOW}Press Ctrl+C to pause at any time.{Style.RESET_ALL}\n")
-
- # Set effective limit - use a high number for "unlimited" but still have a safety limit
- effective_limit = self.max_iterations if self.max_iterations > 0 else 500
-
- while self.iteration_count < effective_limit and not self.goal_achieved:
- try:
- if self.paused:
- await self._handle_pause()
- if self.paused: # Still paused after handling
- break
-
- # Increment iteration count at the beginning
- self.iteration_count += 1
-
- # Display current progress
- self._display_progress()
-
- # Get next action from PTT
- next_action = await self._select_next_action()
- if not next_action:
- print(f"{Fore.YELLOW}No viable next actions found. Checking goal status...{Style.RESET_ALL}")
- await self._check_goal_achievement()
- break
-
- # Execute the selected action
- await self._execute_action(next_action)
-
- # Check goal achievement after every iteration
- await self._check_goal_achievement()
-
- # If goal is achieved, stop the loop
- if self.goal_achieved:
- break
-
- # Brief pause between actions
- await asyncio.sleep(2)
-
- except KeyboardInterrupt:
- print(f"\n{Fore.YELLOW}Pausing agent mode...{Style.RESET_ALL}")
- self.paused = True
- except Exception as e:
- print(f"{Fore.RED}Error in autonomous loop: {e}{Style.RESET_ALL}")
- await asyncio.sleep(5)
-
- # Display final reason for stopping
- if self.goal_achieved:
- print(f"\n{Fore.GREEN}Autonomous execution stopped: Goal achieved!{Style.RESET_ALL}")
- elif self.iteration_count >= effective_limit:
- print(f"\n{Fore.YELLOW}Autonomous execution stopped: Iteration limit reached ({effective_limit}){Style.RESET_ALL}")
- elif self.paused:
- print(f"\n{Fore.YELLOW}Autonomous execution stopped: User paused{Style.RESET_ALL}")
- else:
- print(f"\n{Fore.YELLOW}Autonomous execution stopped: No more viable actions{Style.RESET_ALL}")
-
- # Final summary
- self._display_final_summary()
-
- async def _select_next_action(self) -> Optional[Dict[str, Any]]:
- """Select the next action based on PTT state."""
- # Get available tools
- available_tools = [server.name for server in self.connected_servers]
-
- # Get prioritized candidate tasks
- candidates = self.tree_manager.get_candidate_tasks()
- if not candidates:
- return None
-
- prioritized = self.tree_manager.prioritize_tasks(candidates)
-
- print(f"\n{Fore.CYAN}Selecting next action...{Style.RESET_ALL}")
- print(f"{Fore.WHITE}Available tools: {', '.join(available_tools)}{Style.RESET_ALL}")
-
- # Query LLM for action selection
- selection_prompt = self.reasoning_module.get_next_action_prompt(available_tools)
-
- try:
- result = await self.run_agent_func(
- selection_prompt,
- self.connected_servers,
- history=self.conversation_manager.get_history(),
- streaming=True, # Use streaming=True since it works in other modes
- kb_instance=self.kb_instance
- )
-
- # Handle response format variations
- response_text = None
- if hasattr(result, "final_output"):
- response_text = result.final_output
- elif hasattr(result, "output"):
- response_text = result.output
- elif isinstance(result, str):
- response_text = result
-
- if response_text:
- action_data = self.reasoning_module.parse_next_action_response(response_text, available_tools)
-
- if action_data:
- # Get the selected task
- task_index = action_data.get('selected_task_index', 1) - 1
- if 0 <= task_index < len(prioritized):
- selected_task = prioritized[task_index]
-
- return {
- 'task': selected_task,
- 'command': action_data.get('command'),
- 'tool': action_data.get('tool'),
- 'rationale': action_data.get('rationale'),
- 'expected_outcome': action_data.get('expected_outcome')
- }
-
- except Exception as e:
- print(f"{Fore.RED}Error selecting next action: {e}{Style.RESET_ALL}")
-
- # Fallback to first prioritized task
- if prioritized:
- return {'task': prioritized[0], 'command': None, 'tool': None}
-
- return None
-
- async def _execute_action(self, action: Dict[str, Any]) -> None:
- """Execute a selected action."""
- task = action['task']
- command = action.get('command')
- tool = action.get('tool')
- available_tools = [server.name for server in self.connected_servers]
-
- print(f"\n{Fore.CYAN}Executing: {task.description}{Style.RESET_ALL}")
- if action.get('rationale'):
- print(f"{Fore.WHITE}Rationale: {action['rationale']}{Style.RESET_ALL}")
- if command:
- print(f"{Fore.WHITE}Command: {command}{Style.RESET_ALL}")
- if tool:
- print(f"{Fore.WHITE}Using tool: {tool}{Style.RESET_ALL}")
-
- # Check if suggested tool is available
- if tool and tool not in available_tools and tool != 'manual':
- print(f"{Fore.YELLOW}Tool '{tool}' not available. Available: {', '.join(available_tools)}{Style.RESET_ALL}")
- print(f"{Fore.CYAN}Asking AI to adapt approach with available tools...{Style.RESET_ALL}")
-
- # Let AI figure out how to adapt
- adaptation_query = f"""The task "{task.description}" was planned to use "{tool}" but that tool is not available.
-
-Available tools: {', '.join(available_tools)}
-
-Please adapt this task to work with the available tools. How would you accomplish this objective using {', '.join(available_tools)}?
-Be creative and think about alternative approaches that achieve the same security testing goal."""
-
- command = adaptation_query
-
- # Update task status to in-progress
- self.tree_manager.update_node(task.id, {'status': NodeStatus.IN_PROGRESS.value})
-
- # Execute via agent
- execution_query = command if command else f"Perform the following task: {task.description}"
-
- try:
- result = await self.run_agent_func(
- execution_query,
- self.connected_servers,
- history=self.conversation_manager.get_history(),
- streaming=True,
- kb_instance=self.kb_instance
- )
-
- # Handle response format variations
- response_text = None
- if hasattr(result, "final_output"):
- response_text = result.final_output
- elif hasattr(result, "output"):
- response_text = result.output
- elif isinstance(result, str):
- response_text = result
-
- if response_text:
- # Update conversation history
- self.conversation_manager.add_dialogue(execution_query)
- self.conversation_manager.update_last_response(response_text)
-
- # Update PTT based on results
- await self._update_tree_from_results(
- task,
- response_text,
- command or execution_query
- )
-
- except Exception as e:
- print(f"{Fore.RED}Error executing action: {e}{Style.RESET_ALL}")
- self.tree_manager.update_node(task.id, {
- 'status': NodeStatus.FAILED.value,
- 'findings': f"Execution failed: {str(e)}"
- })
-
- async def _update_tree_from_results(self, task: TaskNode, output: str, command: str) -> None:
- """Update the PTT based on execution results."""
- try:
- # Create update prompt
- update_prompt = self.reasoning_module.get_tree_update_prompt(output, command, task)
-
- # Get LLM analysis
- result = await self.run_agent_func(
- update_prompt,
- self.connected_servers,
- history=self.conversation_manager.get_history(),
- streaming=True,
- kb_instance=self.kb_instance
- )
-
- # Handle response format variations
- response_text = None
- if hasattr(result, "final_output"):
- response_text = result.final_output
- elif hasattr(result, "output"):
- response_text = result.output
- elif isinstance(result, str):
- response_text = result
-
- if response_text:
- node_updates, new_tasks = self.reasoning_module.parse_tree_update_response(response_text)
-
- # Update the executed node
- if node_updates:
- node_updates['timestamp'] = datetime.now().isoformat()
- node_updates['command_executed'] = command
- self.tree_manager.update_node(task.id, node_updates)
-
- # Check if goal might be achieved before adding new tasks
- preliminary_goal_check = await self._quick_goal_check()
-
- # Only add new tasks if goal is not achieved and they align with original goal
- if not preliminary_goal_check and new_tasks:
- # Filter tasks to ensure they align with the original goal
- filtered_tasks = self._filter_tasks_by_goal_scope(new_tasks)
-
- for new_task_data in filtered_tasks:
- parent_phase = new_task_data.get('parent_phase', 'Phase 2')
- parent_node = self._find_phase_node(parent_phase)
-
- if parent_node:
- new_task = TaskNode(
- description=new_task_data.get('description'),
- parent_id=parent_node.id,
- tool_used=new_task_data.get('tool_suggestion'),
- priority=new_task_data.get('priority', 5),
- risk_level=new_task_data.get('risk_level', 'low'),
- attributes={'rationale': new_task_data.get('rationale', '')}
- )
- self.tree_manager.add_node(new_task)
-
- if filtered_tasks:
- print(f"{Fore.GREEN}PTT updated with {len(filtered_tasks)} new goal-aligned tasks.{Style.RESET_ALL}")
- if len(filtered_tasks) < len(new_tasks):
- print(f"{Fore.YELLOW}Filtered out {len(new_tasks) - len(filtered_tasks)} tasks that exceeded goal scope.{Style.RESET_ALL}")
- elif preliminary_goal_check:
- print(f"{Fore.GREEN}Goal appears to be achieved - not adding new tasks.{Style.RESET_ALL}")
-
- except Exception as e:
- print(f"{Fore.RED}Error updating PTT: {e}{Style.RESET_ALL}")
- # Default to marking as completed if update fails
- self.tree_manager.update_node(task.id, {
- 'status': NodeStatus.COMPLETED.value,
- 'timestamp': datetime.now().isoformat()
- })
-
- def _filter_tasks_by_goal_scope(self, tasks: List[Dict[str, Any]]) -> List[Dict[str, Any]]:
- """Filter tasks to ensure they align with the original goal scope."""
- goal_lower = self.tree_manager.goal.lower()
- filtered_tasks = []
-
- # Define scope expansion keywords that should be avoided for simple goals
- expansion_keywords = ["exploit", "compromise", "attack", "penetrate", "shell", "backdoor", "privilege", "escalat"]
-
- # Check if the original goal is simple information gathering
- info_keywords = ["check", "identify", "determine", "find", "discover", "enumerate", "list", "version", "banner"]
- is_simple_info_goal = any(keyword in goal_lower for keyword in info_keywords)
-
- for task in tasks:
- task_desc_lower = task.get('description', '').lower()
-
- # If it's a simple info goal, avoid adding exploitation tasks
- if is_simple_info_goal and any(keyword in task_desc_lower for keyword in expansion_keywords):
- print(f"{Fore.YELLOW}Skipping task that exceeds goal scope: {task.get('description', '')}{Style.RESET_ALL}")
- continue
-
- filtered_tasks.append(task)
-
- return filtered_tasks
-
- async def _quick_goal_check(self) -> bool:
- """Quick check if goal might be achieved based on completed tasks."""
- # Simple heuristic: if we have completed tasks with findings for info gathering goals
- goal_lower = self.tree_manager.goal.lower()
- info_keywords = ["check", "identify", "determine", "find", "discover", "enumerate", "list", "version", "banner"]
-
- if any(keyword in goal_lower for keyword in info_keywords):
- # For info gathering goals, check if we have relevant findings
- for node in self.tree_manager.nodes.values():
- if node.status == NodeStatus.COMPLETED and node.findings:
- # Basic keyword matching for goal completion
- if "version" in goal_lower and "version" in node.findings.lower():
- return True
- if "banner" in goal_lower and "banner" in node.findings.lower():
- return True
- if any(keyword in goal_lower and keyword in node.description.lower() for keyword in info_keywords):
- return True
-
- return False
-
- async def _check_goal_achievement(self) -> None:
- """Check if the primary goal has been achieved."""
- goal_prompt = self.reasoning_module.get_goal_check_prompt()
-
- try:
- result = await self.run_agent_func(
- goal_prompt,
- self.connected_servers,
- history=self.conversation_manager.get_history(),
- streaming=True, # Use streaming=True
- kb_instance=self.kb_instance
- )
-
- # Handle response format variations
- response_text = None
- if hasattr(result, "final_output"):
- response_text = result.final_output
- elif hasattr(result, "output"):
- response_text = result.output
- elif isinstance(result, str):
- response_text = result
-
- if response_text:
- goal_status = self.reasoning_module.parse_goal_check_response(response_text)
-
- if goal_status.get('goal_achieved', False):
- confidence = goal_status.get('confidence', 0)
- if confidence >= 80:
- print(f"\n{Fore.GREEN}{'='*60}{Style.RESET_ALL}")
- print(f"{Fore.GREEN}GOAL ACHIEVED! (Confidence: {confidence}%){Style.RESET_ALL}")
- print(f"{Fore.WHITE}Evidence: {goal_status.get('evidence', 'N/A')}{Style.RESET_ALL}")
- print(f"{Fore.GREEN}{'='*60}{Style.RESET_ALL}\n")
- self.goal_achieved = True
- else:
- print(f"{Fore.YELLOW}Goal possibly achieved but confidence is low ({confidence}%). Continuing...{Style.RESET_ALL}")
- else:
- remaining = goal_status.get('remaining_objectives', 'Unknown')
- print(f"{Fore.YELLOW}Goal not yet achieved. Remaining: {remaining}{Style.RESET_ALL}")
-
- except Exception as e:
- print(f"{Fore.RED}Error checking goal achievement: {e}{Style.RESET_ALL}")
-
- def _display_progress(self) -> None:
- """Display current progress and statistics."""
- stats = self.tree_manager.get_statistics()
- elapsed = datetime.now() - self.start_time if self.start_time else None
-
- print(f"\n{Fore.CYAN}{'='*60}{Style.RESET_ALL}")
- print(f"{Fore.CYAN}Iteration {self.iteration_count} | Elapsed: {elapsed}{Style.RESET_ALL}")
- print(f"{Fore.WHITE}Tasks - Total: {stats['total_nodes']} | "
- f"Completed: {stats['status_counts'].get('completed', 0)} | "
- f"In Progress: {stats['status_counts'].get('in_progress', 0)} | "
- f"Pending: {stats['status_counts'].get('pending', 0)}{Style.RESET_ALL}")
- print(f"{Fore.YELLOW}Vulnerabilities Found: {stats['status_counts'].get('vulnerable', 0)}{Style.RESET_ALL}")
- print(f"{Fore.CYAN}{'='*60}{Style.RESET_ALL}")
-
- def _display_final_summary(self) -> None:
- """Display final summary of the agent mode execution."""
- print(f"\n{Fore.CYAN}{'='*70}{Style.RESET_ALL}")
- print(f"{Fore.CYAN}AGENT MODE EXECUTION SUMMARY{Style.RESET_ALL}")
- print(f"{Fore.CYAN}{'='*70}{Style.RESET_ALL}")
-
- summary = self.reasoning_module.generate_strategic_summary()
- print(f"{Fore.WHITE}{summary}{Style.RESET_ALL}")
-
- # Calculate effective limit
- effective_limit = self.max_iterations if self.max_iterations > 0 else 500
-
- # Execution Statistics
- if self.start_time:
- elapsed = datetime.now() - self.start_time
- print(f"\n{Fore.WHITE}Execution Statistics:{Style.RESET_ALL}")
- print(f"Total Execution Time: {elapsed}")
- print(f"Iterations Completed: {self.iteration_count}/{effective_limit}")
-
- if self.iteration_count > 0:
- avg_time_per_iteration = elapsed.total_seconds() / self.iteration_count
- print(f"Average Time per Iteration: {avg_time_per_iteration:.1f} seconds")
-
- # Calculate efficiency
- completion_rate = (self.iteration_count / effective_limit) * 100
- print(f"Completion Rate: {completion_rate:.1f}%")
-
- # Estimate remaining time if stopped early
- if self.iteration_count < effective_limit and not self.goal_achieved:
- remaining_iterations = effective_limit - self.iteration_count
- estimated_remaining_time = remaining_iterations * avg_time_per_iteration
- print(f"Estimated Time for Full Run: {elapsed.total_seconds() + estimated_remaining_time:.0f} seconds")
-
- print(f"{Fore.WHITE}Total Iterations: {self.iteration_count}{Style.RESET_ALL}")
-
- if self.goal_achieved:
- print(f"\n{Fore.GREEN}PRIMARY GOAL ACHIEVED!{Style.RESET_ALL}")
- efficiency = "Excellent" if self.iteration_count <= 10 else "Good" if self.iteration_count <= 20 else "Extended"
- print(f"{Fore.GREEN}Efficiency: {efficiency} (achieved in {self.iteration_count} iterations){Style.RESET_ALL}")
- else:
- print(f"\n{Fore.YELLOW}Primary goal not fully achieved within iteration limit.{Style.RESET_ALL}")
- if self.iteration_count >= effective_limit:
- print(f"{Fore.YELLOW}Consider increasing iteration limit for more thorough testing.{Style.RESET_ALL}")
-
- print(f"{Fore.CYAN}{'='*70}{Style.RESET_ALL}")
-
- async def _handle_pause(self) -> None:
- """Handle pause state and user options."""
- print(f"\n{Fore.YELLOW}Agent Mode Paused{Style.RESET_ALL}")
-
- # Calculate effective limit
- effective_limit = self.max_iterations if self.max_iterations > 0 else 500
-
- # Display current progress
- print(f"\n{Fore.MAGENTA}Progress Statistics:{Style.RESET_ALL}")
- print(f"Iterations: {self.iteration_count}/{effective_limit}")
- elapsed = datetime.now() - self.start_time if self.start_time else None
- if elapsed:
- print(f"Elapsed Time: {elapsed}")
- if self.iteration_count > 0:
- avg_time = elapsed.total_seconds() / self.iteration_count
- print(f"Average per Iteration: {avg_time:.1f} seconds")
-
- print("\nOptions:")
- print("1. Resume execution")
- print("2. View current PTT")
- print("3. View detailed statistics")
- print("4. Save PTT state")
- print("5. Add manual task")
- print("6. Modify iteration limit")
- print("7. Exit agent mode")
-
- while self.paused:
- choice = input(f"\n{Fore.GREEN}Select option (1-7): {Style.RESET_ALL}").strip()
-
- if choice == '1':
- self.paused = False
- print(f"{Fore.GREEN}Resuming agent mode...{Style.RESET_ALL}")
- elif choice == '2':
- print(f"\n{Fore.CYAN}Current PTT State:{Style.RESET_ALL}")
- print(self.tree_manager.to_natural_language())
- elif choice == '3':
- self._display_progress()
- print(self.reasoning_module.generate_strategic_summary())
- elif choice == '4':
- self._save_ptt_state()
- elif choice == '5':
- await self._add_manual_task()
- elif choice == '6':
- self._modify_iteration_limit()
- elif choice == '7':
- print(f"{Fore.YELLOW}Exiting agent mode...{Style.RESET_ALL}")
- break
- else:
- print(f"{Fore.RED}Invalid choice.{Style.RESET_ALL}")
-
- def _modify_iteration_limit(self) -> None:
- """Allow user to modify the iteration limit during execution."""
- try:
- print(f"\n{Fore.CYAN}Modify Iteration Limit{Style.RESET_ALL}")
- print(f"Current limit: {self.max_iterations}")
- print(f"Iterations completed: {self.iteration_count}")
- print(f"Iterations remaining: {self.max_iterations - self.iteration_count}")
-
- new_limit_input = input(f"New iteration limit (current: {self.max_iterations}): ").strip()
-
- if new_limit_input:
- try:
- new_limit = int(new_limit_input)
-
- # Ensure new limit is at least the number of iterations already completed
- min_limit = self.iteration_count + 1 # Allow at least 1 more iteration
- if new_limit < min_limit:
- print(f"{Fore.YELLOW}Minimum limit is {min_limit} (iterations already completed + 1){Style.RESET_ALL}")
- new_limit = min_limit
-
- # Apply reasonable maximum
- new_limit = min(new_limit, 200)
-
- self.max_iterations = new_limit
- print(f"{Fore.GREEN}Iteration limit updated to: {new_limit}{Style.RESET_ALL}")
-
- except ValueError:
- print(f"{Fore.RED}Invalid input. Please enter a number.{Style.RESET_ALL}")
- else:
- print(f"{Fore.YELLOW}No change made.{Style.RESET_ALL}")
-
- except Exception as e:
- print(f"{Fore.RED}Error modifying iteration limit: {e}{Style.RESET_ALL}")
-
- async def _add_manual_task(self) -> None:
- """Allow user to manually add a task to the PTT."""
- try:
- print(f"\n{Fore.CYAN}Add Manual Task{Style.RESET_ALL}")
-
- # Get task details from user
- description = input("Task description: ").strip()
- if not description:
- print(f"{Fore.RED}Task description required.{Style.RESET_ALL}")
- return
-
- print("Select phase:")
- print("1. Phase 1: Reconnaissance")
- print("2. Phase 2: Vulnerability Assessment")
- print("3. Phase 3: Exploitation")
- print("4. Phase 4: Post-Exploitation")
-
- phase_choice = input("Phase (1-4): ").strip()
- phase_map = {
- '1': 'Phase 1',
- '2': 'Phase 2',
- '3': 'Phase 3',
- '4': 'Phase 4'
- }
-
- phase = phase_map.get(phase_choice, 'Phase 2')
- parent_node = self._find_phase_node(phase)
-
- if not parent_node:
- print(f"{Fore.RED}Phase not found.{Style.RESET_ALL}")
- return
-
- # Get priority
- try:
- priority = int(input("Priority (1-10, default 5): ").strip() or "5")
- priority = max(1, min(10, priority))
- except:
- priority = 5
-
- # Get risk level
- print("Risk level:")
- print("1. Low")
- print("2. Medium")
- print("3. High")
- risk_choice = input("Risk (1-3, default 2): ").strip()
- risk_map = {'1': 'low', '2': 'medium', '3': 'high'}
- risk_level = risk_map.get(risk_choice, 'medium')
-
- # Create the task
- from core.task_tree_manager import TaskNode
- manual_task = TaskNode(
- description=description,
- parent_id=parent_node.id,
- tool_used='manual',
- priority=priority,
- risk_level=risk_level,
- attributes={'manual_addition': True, 'added_by_user': True}
- )
-
- self.tree_manager.add_node(manual_task)
- print(f"{Fore.GREEN}Manual task added to {phase}.{Style.RESET_ALL}")
-
- except Exception as e:
- print(f"{Fore.RED}Error adding manual task: {e}{Style.RESET_ALL}")
-
- def _save_ptt_state(self) -> None:
- """Save the current PTT state to file."""
- timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
- filename = f"reports/ptt_state_{timestamp}.json"
-
- try:
- import os
- os.makedirs("reports", exist_ok=True)
-
- with open(filename, 'w') as f:
- f.write(self.tree_manager.to_json())
-
- print(f"{Fore.GREEN}PTT state saved to: {filename}{Style.RESET_ALL}")
- except Exception as e:
- print(f"{Fore.RED}Failed to save PTT state: {e}{Style.RESET_ALL}")
-
- def _find_phase_node(self, phase_description: str) -> Optional[TaskNode]:
- """Find a structure node by description (phase, category, etc.)."""
- for node in self.tree_manager.nodes.values():
- # Look for any non-task node that matches the description
- if node.node_type != "task" and phase_description.lower() in node.description.lower():
- return node
-
- # If no exact match, try to find any suitable parent node
- # Return root if no structure nodes exist
- return self.tree_manager.nodes.get(self.tree_manager.root_id)
-
- def get_ptt_for_reporting(self) -> TaskTreeManager:
- """Get the PTT for report generation."""
- return self.tree_manager
-
- def _get_fallback_initialization(self, target: str, available_tools: List[str]) -> Dict[str, Any]:
- """Return minimal fallback initialization when LLM fails."""
- print(f"{Fore.YELLOW}Using minimal fallback initialization. The system will rely on dynamic task generation.{Style.RESET_ALL}")
-
- return {
- 'analysis': 'Fallback initialization - LLM will determine structure dynamically during execution',
- 'structure': [],
- 'initial_tasks': []
- }
\ No newline at end of file
diff --git a/core/agent_runner.py b/core/agent_runner.py
deleted file mode 100644
index 90958d4..0000000
--- a/core/agent_runner.py
+++ /dev/null
@@ -1,259 +0,0 @@
-"""Agent execution and query processing for GHOSTCREW."""
-
-import json
-import asyncio
-import traceback
-from typing import List, Dict, Optional, Any
-from colorama import Fore, Style
-from agents import Agent, RunConfig, Runner, ModelSettings
-from openai.types.responses import ResponseTextDeltaEvent, ResponseContentPartDoneEvent
-from core.model_manager import model_manager
-from config.constants import BASE_INSTRUCTIONS, CONNECTION_RETRY_DELAY, DEFAULT_KNOWLEDGE_BASE_PATH
-from config.app_config import app_config
-import os
-
-
-class AgentRunner:
- """Handles AI agent query processing and execution."""
-
- def __init__(self):
- """Initialize the agent runner."""
- self.model_provider = model_manager.get_model_provider()
- self.client = app_config.get_openai_client()
-
- async def run_agent(
- self,
- query: str,
- mcp_servers: List[Any], # Use Any to avoid import issues
- history: Optional[List[Dict[str, str]]] = None,
- streaming: bool = True,
- kb_instance: Any = None
- ) -> Any:
- """
- Run cybersecurity agent with connected MCP servers, supporting streaming output and conversation history.
-
- Args:
- query: User's natural language query
- mcp_servers: List of connected MCPServerStdio instances
- history: Conversation history, list containing user questions and AI answers
- streaming: Whether to use streaming output
- kb_instance: Knowledge base instance for retrieval
-
- Returns:
- Agent execution result
- """
- # If no history is provided, initialize an empty list
- if history is None:
- history = []
-
- try:
- # Build instructions containing conversation history
- instructions = self._build_instructions(mcp_servers, history, query, kb_instance)
-
- # Calculate max output tokens
- max_output_tokens = model_manager.calculate_max_output_tokens(instructions, query)
-
- # Set model settings based on whether there are connected MCP servers
- model_settings = self._create_model_settings(mcp_servers, max_output_tokens)
-
- # Create agent
- secure_agent = Agent(
- name="Cybersecurity Expert",
- instructions=instructions,
- mcp_servers=mcp_servers,
- model_settings=model_settings
- )
-
- print(f"{Fore.CYAN}\nProcessing query: {Fore.WHITE}{query}{Style.RESET_ALL}\n")
-
- if streaming:
- return await self._run_streaming(secure_agent, query)
- else:
- # Non-streaming mode could be implemented here if needed
- pass
-
- except Exception as e:
- print(f"{Fore.RED}Error processing agent request: {e}{Style.RESET_ALL}", flush=True)
- traceback.print_exc()
- return None
-
- def _build_instructions(
- self,
- mcp_servers: List[Any], # Use Any to avoid import issues
- history: List[Dict[str, str]],
- query: str,
- kb_instance: Any
- ) -> str:
- """Build agent instructions with context."""
- instructions = BASE_INSTRUCTIONS
-
- # Add information about available tools
- if mcp_servers:
- available_tool_names = [server.name for server in mcp_servers]
- if available_tool_names:
- instructions += f"\n\nYou have access to the following tools: {', '.join(available_tool_names)}."
-
- # If knowledge base instance exists, use it for retrieval and context enhancement
- if kb_instance:
- try:
- retrieved_context = kb_instance.search(query)
- if retrieved_context:
- # Add file path information to make LLM aware of actual files
- available_files = []
- if os.path.exists(DEFAULT_KNOWLEDGE_BASE_PATH):
- for filename in os.listdir(DEFAULT_KNOWLEDGE_BASE_PATH):
- filepath = os.path.join(DEFAULT_KNOWLEDGE_BASE_PATH, filename)
- if os.path.isfile(filepath):
- available_files.append(filename)
-
- file_info = ""
- if available_files:
- file_info = f"\n\nIMPORTANT: The following actual files are available in the knowledge folder that you can reference by path:\n"
- for filename in available_files:
- file_info += f"- {DEFAULT_KNOWLEDGE_BASE_PATH}/{filename}\n"
- file_info += "\nWhen using security tools that require external files, you can reference these files by their full path.\n"
- file_info += f"ONLY use {DEFAULT_KNOWLEDGE_BASE_PATH}/ for files.\n"
-
- instructions = f"Based on the following knowledge base information:\n{retrieved_context}{file_info}\n\n{instructions}"
- print(f"{Fore.MAGENTA}Relevant information retrieved from knowledge base.{Style.RESET_ALL}")
- except Exception as e:
- print(f"{Fore.RED}Failed to retrieve information from knowledge base: {e}{Style.RESET_ALL}")
-
- # If there's conversation history, add it to the instructions
- if history:
- instructions += "\n\nBelow is the previous conversation history, please refer to this information to answer the user's question:\n"
- for i, entry in enumerate(history):
- instructions += f"\nUser question {i+1}: {entry['user_query']}"
- if 'ai_response' in entry and entry['ai_response']:
- instructions += f"\nAI answer {i+1}: {entry['ai_response']}\n"
-
- return instructions
-
- def _create_model_settings(self, mcp_servers: List[Any], max_output_tokens: int) -> ModelSettings:
- """Create model settings based on available tools."""
- if mcp_servers:
- # With tools available, enable tool_choice and parallel_tool_calls
- return ModelSettings(
- temperature=0.6,
- top_p=0.9,
- max_tokens=max_output_tokens,
- tool_choice="auto",
- parallel_tool_calls=False,
- truncation="auto"
- )
- else:
- # Without tools, don't set tool_choice or parallel_tool_calls
- return ModelSettings(
- temperature=0.6,
- top_p=0.9,
- max_tokens=max_output_tokens,
- truncation="auto"
- )
-
- async def _run_streaming(self, agent: Agent, query: str) -> Any:
- """Run agent with streaming output."""
- result = Runner.run_streamed(
- agent,
- input=query,
- max_turns=10,
- run_config=RunConfig(
- model_provider=self.model_provider,
- trace_include_sensitive_data=True,
- handoff_input_filter=None
- )
- )
-
- print(f"{Fore.GREEN}Reply:{Style.RESET_ALL}", end="", flush=True)
-
- try:
- async for event in result.stream_events():
- await self._handle_stream_event(event)
- except Exception as e:
- await self._handle_stream_error(e)
-
- print(f"\n\n{Fore.GREEN}Query completed!{Style.RESET_ALL}")
- return result
-
- async def _handle_stream_event(self, event: Any) -> None:
- """Handle individual stream events."""
- if event.type == "raw_response_event":
- if isinstance(event.data, ResponseTextDeltaEvent):
- print(f"{Fore.WHITE}{event.data.delta}{Style.RESET_ALL}", end="", flush=True)
- elif isinstance(event.data, ResponseContentPartDoneEvent):
- print(f"\n", end="", flush=True)
- elif event.type == "run_item_stream_event":
- if event.item.type == "tool_call_item":
- await self._handle_tool_call(event.item)
- elif event.item.type == "tool_call_output_item":
- await self._handle_tool_output(event.item)
-
- async def _handle_tool_call(self, item: Any) -> None:
- """Handle tool call events."""
- raw_item = getattr(item, "raw_item", None)
- tool_name = ""
- tool_args = {}
-
- if raw_item:
- tool_name = getattr(raw_item, "name", "Unknown tool")
- tool_str = getattr(raw_item, "arguments", "{}")
- if isinstance(tool_str, str):
- try:
- tool_args = json.loads(tool_str)
- except json.JSONDecodeError:
- tool_args = {"raw_arguments": tool_str}
-
- print(f"\n{Fore.CYAN}Tool name: {tool_name}{Style.RESET_ALL}", flush=True)
- print(f"\n{Fore.CYAN}Tool parameters: {tool_args}{Style.RESET_ALL}", flush=True)
-
- async def _handle_tool_output(self, item: Any) -> None:
- """Handle tool output events."""
- raw_item = getattr(item, "raw_item", None)
- tool_id = "Unknown tool ID"
-
- if isinstance(raw_item, dict) and "call_id" in raw_item:
- tool_id = raw_item["call_id"]
-
- output = getattr(item, "output", "Unknown output")
- output_text = self._parse_tool_output(output)
-
- print(f"\n{Fore.GREEN}Tool call {tool_id} returned result: {output_text}{Style.RESET_ALL}", flush=True)
-
- def _parse_tool_output(self, output: Any) -> str:
- """Parse tool output into readable text."""
- if isinstance(output, str) and (output.startswith("{") or output.startswith("[")):
- try:
- output_data = json.loads(output)
- if isinstance(output_data, dict):
- if 'type' in output_data and output_data['type'] == 'text' and 'text' in output_data:
- return output_data['text']
- elif 'text' in output_data:
- return output_data['text']
- elif 'content' in output_data:
- return output_data['content']
- else:
- return json.dumps(output_data, ensure_ascii=False, indent=2)
- except json.JSONDecodeError:
- return f"Unparsable JSON output: {output}"
- return str(output)
-
- async def _handle_stream_error(self, error: Exception) -> None:
- """Handle streaming errors."""
- print(f"{Fore.RED}Error processing streamed response event: {error}{Style.RESET_ALL}", flush=True)
-
- if 'Connection error' in str(error):
- print(f"{Fore.YELLOW}Connection error details:{Style.RESET_ALL}")
- print(f"{Fore.YELLOW}1. Check network connection{Style.RESET_ALL}")
- print(f"{Fore.YELLOW}2. Verify API address: {app_config.base_url}{Style.RESET_ALL}")
- print(f"{Fore.YELLOW}3. Check API key validity{Style.RESET_ALL}")
- print(f"{Fore.YELLOW}4. Try reconnecting...{Style.RESET_ALL}")
- await asyncio.sleep(CONNECTION_RETRY_DELAY)
-
- try:
- await self.client.connect()
- print(f"{Fore.GREEN}Reconnected successfully{Style.RESET_ALL}")
- except Exception as e:
- print(f"{Fore.RED}Reconnection failed: {e}{Style.RESET_ALL}")
-
-
-# Create singleton instance
-agent_runner = AgentRunner()
\ No newline at end of file
diff --git a/core/model_manager.py b/core/model_manager.py
deleted file mode 100644
index afb2cca..0000000
--- a/core/model_manager.py
+++ /dev/null
@@ -1,73 +0,0 @@
-"""Model management and AI model setup for GHOSTCREW."""
-
-import tiktoken
-from agents import Model, ModelProvider, OpenAIChatCompletionsModel
-from config.app_config import app_config
-from config.constants import MAX_TOTAL_TOKENS, RESPONSE_BUFFER
-
-
-class DefaultModelProvider(ModelProvider):
- """Model provider using OpenAI compatible interface."""
-
- def get_model(self, model_name: str) -> Model:
- """Get a model instance with the specified name."""
- return OpenAIChatCompletionsModel(
- model=model_name or app_config.model_name,
- openai_client=app_config.get_openai_client()
- )
-
-
-class ModelManager:
- """Manages AI model operations and token counting."""
-
- def __init__(self):
- """Initialize the model manager."""
- self.model_provider = DefaultModelProvider()
- self.model_name = app_config.model_name
-
- @staticmethod
- def count_tokens(text: str, model_name: str = None) -> int:
- """
- Count tokens in the given text.
-
- Args:
- text: The text to count tokens for
- model_name: The model name to use for encoding (defaults to configured model)
-
- Returns:
- Number of tokens in the text
- """
- try:
- model = model_name or app_config.model_name
- encoding = tiktoken.encoding_for_model(model)
- return len(encoding.encode(text))
- except Exception:
- # Fall back to approximate counting if tiktoken fails
- return len(text.split())
-
- @staticmethod
- def calculate_max_output_tokens(input_text: str, query: str) -> int:
- """
- Calculate the maximum output tokens based on input size.
-
- Args:
- input_text: The base instructions or context
- query: The user query
-
- Returns:
- Maximum number of output tokens
- """
- input_token_estimate = ModelManager.count_tokens(input_text) + ModelManager.count_tokens(query)
-
- max_output_tokens = max(512, MAX_TOTAL_TOKENS - input_token_estimate)
- max_output_tokens = min(max_output_tokens, RESPONSE_BUFFER)
-
- return max_output_tokens
-
- def get_model_provider(self) -> ModelProvider:
- """Get the model provider instance."""
- return self.model_provider
-
-
-# Create a singleton instance
-model_manager = ModelManager()
\ No newline at end of file
diff --git a/core/pentest_agent.py b/core/pentest_agent.py
deleted file mode 100644
index cca3004..0000000
--- a/core/pentest_agent.py
+++ /dev/null
@@ -1,448 +0,0 @@
-"""Main controller for GHOSTCREW application."""
-
-import asyncio
-import traceback
-from datetime import datetime
-from typing import Optional, List, Dict, Any
-from colorama import Fore, Style
-
-from config.constants import (
- ASCII_TITLE, VERSION, WELCOME_MESSAGE, EXIT_MESSAGE, SEPARATOR,
- KB_PROMPT, MCP_PROMPT, ERROR_NO_WORKFLOWS, ERROR_NO_REPORTING,
- DEFAULT_KNOWLEDGE_BASE_PATH
-)
-from config.app_config import app_config
-from core.agent_runner import agent_runner
-from core.agent_mode_controller import AgentModeController
-from tools.mcp_manager import MCPManager
-from ui.menu_system import MenuSystem
-from ui.conversation_manager import ConversationManager
-from workflows.workflow_engine import WorkflowEngine
-from rag.knowledge_base import Kb
-
-
-class PentestAgent:
- """Main application controller for GHOSTCREW."""
-
- def __init__(self, MCPServerStdio=None, MCPServerSse=None):
- """
- Initialize the pentest agent controller.
-
- Args:
- MCPServerStdio: MCP server stdio class
- MCPServerSse: MCP server SSE class
- """
- self.app_config = app_config
- self.agent_runner = agent_runner
- self.mcp_manager = MCPManager(MCPServerStdio, MCPServerSse)
- self.menu_system = MenuSystem()
- self.conversation_manager = ConversationManager()
- self.workflow_engine = WorkflowEngine()
- self.kb_instance = None
- self.reporting_available = self._check_reporting_available()
- self.agent_mode_controller = None # Will be initialized when needed
-
- @staticmethod
- def _check_reporting_available() -> bool:
- """Check if reporting module is available."""
- try:
- from reporting.generators import generate_report_from_workflow
- return True
- except ImportError:
- print(ERROR_NO_REPORTING)
- return False
-
- def display_welcome(self) -> None:
- """Display welcome message and ASCII art."""
- print(ASCII_TITLE)
- print(f"{Fore.WHITE}GHOSTCREW v{VERSION}{Style.RESET_ALL}")
- print(WELCOME_MESSAGE)
- print(EXIT_MESSAGE)
- print(f"{SEPARATOR}\n")
-
- def setup_knowledge_base(self) -> None:
- """Setup knowledge base if requested by user."""
- use_kb_input = input(KB_PROMPT).strip().lower()
- if use_kb_input == 'yes':
- try:
- self.kb_instance = Kb(DEFAULT_KNOWLEDGE_BASE_PATH)
- print(f"{Fore.GREEN}Knowledge base loaded successfully!{Style.RESET_ALL}")
- except Exception as e:
- print(f"{Fore.RED}Failed to load knowledge base: {e}{Style.RESET_ALL}")
- self.kb_instance = None
-
- async def setup_mcp_tools(self) -> tuple:
- """Setup MCP tools and return server instances."""
- use_mcp_input = input(MCP_PROMPT).strip().lower()
- return await self.mcp_manager.setup_mcp_tools(use_mcp_input == 'yes')
-
- async def run_interactive_mode(self, connected_servers: List) -> None:
- """Run interactive chat mode."""
- self.menu_system.display_interactive_mode_intro()
-
- while True:
- user_query = self.menu_system.get_user_input()
-
- # Handle special commands
- if user_query.lower() in ["quit", "exit"]:
- self.menu_system.display_exit_message()
- return True # Signal to exit the entire application
-
- if user_query.lower() == "menu":
- break # Return to main menu
-
- # Handle empty input
- if not user_query:
- self.menu_system.display_no_query_message()
- continue
-
- # Handle multi-line mode request
- if user_query.lower() == "multi":
- user_query = self.menu_system.get_multi_line_input()
- if not user_query:
- continue
-
- # Process the query
- await self._process_user_query(user_query, connected_servers)
-
- self.menu_system.display_ready_message()
-
- return False # Don't exit application
-
- async def _process_user_query(self, query: str, connected_servers: List) -> None:
- """Process a user query through the agent."""
- # Add dialogue to history
- self.conversation_manager.add_dialogue(query)
-
- # Run the agent
- result = await agent_runner.run_agent(
- query,
- connected_servers,
- history=self.conversation_manager.get_history(),
- streaming=True,
- kb_instance=self.kb_instance
- )
-
- # Update the response in history
- if result and hasattr(result, "final_output"):
- self.conversation_manager.update_last_response(result.final_output)
-
- async def run_automated_mode(self, connected_servers: List) -> None:
- """Run workflows mode."""
- if not self.workflow_engine.is_available():
- print(ERROR_NO_WORKFLOWS)
- self.menu_system.press_enter_to_continue()
- return
-
- if not connected_servers:
- self.menu_system.display_workflow_requirements_message()
- return
-
- while True:
- workflow_list = self.workflow_engine.show_automated_menu()
- if not workflow_list:
- break
-
- try:
- choice = input(f"\n{Fore.GREEN}Select workflow (1-{len(workflow_list)+1}): {Style.RESET_ALL}").strip()
-
- if not choice.isdigit():
- self.menu_system.display_invalid_input()
- continue
-
- choice = int(choice)
-
- if choice == len(workflow_list) + 1:
- # Back to main menu
- break
-
- if 1 <= choice <= len(workflow_list):
- await self._execute_workflow(workflow_list[choice-1], connected_servers)
- else:
- self.menu_system.display_invalid_choice()
-
- except ValueError:
- self.menu_system.display_invalid_input()
- except KeyboardInterrupt:
- self.menu_system.display_operation_cancelled()
- break
-
- async def run_agent_mode(self, connected_servers: List) -> None:
- """Run autonomous agent mode with PTT."""
- if not connected_servers:
- self.menu_system.display_agent_mode_requirements_message()
- return
-
- # Display introduction
- self.menu_system.display_agent_mode_intro()
-
- # Get agent mode parameters
- params = self.menu_system.get_agent_mode_params()
- if not params:
- return
-
- # Initialize agent mode controller
- self.agent_mode_controller = AgentModeController(
- self.mcp_manager,
- self.conversation_manager,
- self.kb_instance
- )
-
- try:
- # Initialize agent mode
- init_success = await self.agent_mode_controller.initialize_agent_mode(
- goal=params['goal'],
- target=params['target'],
- constraints=params['constraints'],
- connected_servers=connected_servers,
- run_agent_func=agent_runner.run_agent
- )
-
- if init_success:
- # Run the autonomous loop
- await self.agent_mode_controller.run_autonomous_loop()
-
- # Handle post-execution options
- await self._handle_agent_mode_completion()
- else:
- print(f"{Fore.RED}Failed to initialize agent mode.{Style.RESET_ALL}")
- self.menu_system.press_enter_to_continue()
-
- except KeyboardInterrupt:
- print(f"\n{Fore.YELLOW}Agent mode interrupted by user.{Style.RESET_ALL}")
- except Exception as e:
- print(f"{Fore.RED}Error in agent mode: {e}{Style.RESET_ALL}")
- traceback.print_exc()
- finally:
- self.menu_system.press_enter_to_continue()
-
- async def _handle_agent_mode_completion(self) -> None:
- """Handle post-execution options for agent mode."""
- # Ask if user wants to generate a report
- if self.reporting_available and self.menu_system.ask_generate_report():
- try:
- # Generate report from PTT
- from reporting.generators import generate_report_from_ptt
-
- ptt = self.agent_mode_controller.get_ptt_for_reporting()
- report_path = await generate_report_from_ptt(
- ptt,
- self.conversation_manager.get_history(),
- run_agent_func=agent_runner.run_agent,
- connected_servers=self.mcp_manager.connected_servers if hasattr(self.mcp_manager, 'connected_servers') else [],
- kb_instance=self.kb_instance
- )
-
- if report_path:
- self.menu_system.display_report_generated(report_path)
- else:
- print(f"{Fore.YELLOW}Report generation returned no path.{Style.RESET_ALL}")
-
- except ImportError:
- # Fallback if PTT report generation not available
- print(f"{Fore.YELLOW}PTT report generation not available. Saving raw data...{Style.RESET_ALL}")
- self._save_agent_mode_data()
- except Exception as e:
- self.menu_system.display_report_error(e)
- self._save_agent_mode_data()
-
- # Ask about saving raw data
- elif self.menu_system.ask_save_raw_history():
- self._save_agent_mode_data()
-
- def _save_agent_mode_data(self) -> None:
- """Save agent mode execution data."""
- try:
- import os
- import json
-
- timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
-
- # Create reports directory if it doesn't exist
- os.makedirs("reports", exist_ok=True)
-
- # Save conversation history
- history_file = f"reports/agent_mode_history_{timestamp}.json"
- with open(history_file, 'w', encoding='utf-8') as f:
- json.dump(self.conversation_manager.get_history(), f, indent=2)
-
- # Save PTT state
- if self.agent_mode_controller:
- ptt_file = f"reports/agent_mode_ptt_{timestamp}.json"
- with open(ptt_file, 'w', encoding='utf-8') as f:
- f.write(self.agent_mode_controller.tree_manager.to_json())
-
- print(f"{Fore.GREEN}Agent mode data saved to reports/ directory{Style.RESET_ALL}")
-
- except Exception as e:
- print(f"{Fore.RED}Failed to save agent mode data: {e}{Style.RESET_ALL}")
-
- async def _execute_workflow(self, workflow_info: tuple, connected_servers: List) -> None:
- """Execute a selected workflow."""
- workflow_key, workflow_name = workflow_info
- workflow = self.workflow_engine.get_workflow(workflow_key)
-
- if not workflow:
- print(f"{Fore.RED}Error loading workflow.{Style.RESET_ALL}")
- return
-
- target = self.menu_system.get_workflow_target()
- if not target:
- return
-
- if not self.menu_system.confirm_workflow_execution(workflow['name'], target):
- self.menu_system.display_workflow_cancelled()
- return
-
- # Store initial workflow data
- workflow_start_time = datetime.now()
- initial_history_length = self.conversation_manager.get_dialogue_count()
-
- # Execute the workflow
- await self.workflow_engine.run_automated_workflow(
- workflow,
- target,
- connected_servers,
- self.conversation_manager.get_history(),
- self.kb_instance,
- agent_runner.run_agent
- )
-
- self.menu_system.display_workflow_completed()
-
- # Handle report generation
- if self.reporting_available:
- await self._handle_report_generation(
- workflow,
- workflow_key,
- target,
- workflow_start_time,
- initial_history_length,
- connected_servers
- )
- else:
- print(f"\n{Fore.YELLOW}Reporting not available.{Style.RESET_ALL}")
-
- self.menu_system.press_enter_to_continue()
-
- async def _handle_report_generation(
- self,
- workflow: Dict,
- workflow_key: str,
- target: str,
- workflow_start_time: datetime,
- initial_history_length: int,
- connected_servers: List
- ) -> None:
- """Handle report generation after workflow completion."""
- if not self.menu_system.ask_generate_report():
- return
-
- save_raw_history = self.menu_system.ask_save_raw_history()
-
- try:
- from reporting.generators import generate_report_from_workflow
-
- # Prepare report data
- workflow_conversation = self.conversation_manager.get_workflow_conversation(initial_history_length)
-
- report_data = {
- 'workflow_name': workflow['name'],
- 'workflow_key': workflow_key,
- 'target': target,
- 'timestamp': workflow_start_time,
- 'conversation_history': workflow_conversation,
- 'tools_used': MCPManager.get_available_tools(connected_servers)
- }
-
- # Generate professional report
- print(f"\n{Fore.CYAN}Generating report...{Style.RESET_ALL}")
- report_path = await generate_report_from_workflow(
- report_data,
- agent_runner.run_agent,
- connected_servers,
- self.kb_instance,
- save_raw_history
- )
-
- self.menu_system.display_report_generated(report_path)
-
- except Exception as e:
- self.menu_system.display_report_error(e)
-
- async def run(self) -> None:
- """Main application run method."""
- self.display_welcome()
- self.setup_knowledge_base()
-
- try:
- # Setup MCP tools
- mcp_server_instances, connected_servers = await self.setup_mcp_tools()
-
- # Check if we need to restart (e.g., after configuring new tools)
- if mcp_server_instances and not connected_servers:
- return
-
- # Main application loop
- while True:
- self.menu_system.display_main_menu(
- self.workflow_engine.is_available(),
- bool(connected_servers)
- )
-
- menu_choice = self.menu_system.get_menu_choice()
-
- if menu_choice == "1":
- # Interactive mode
- should_exit = await self.run_interactive_mode(connected_servers)
- if should_exit:
- break
-
- elif menu_choice == "2":
- # Automated mode
- await self.run_automated_mode(connected_servers)
-
- elif menu_choice == "3":
- # Agent mode
- await self.run_agent_mode(connected_servers)
-
- elif menu_choice == "4":
- # Exit
- self.menu_system.display_exit_message()
- break
-
- else:
- self.menu_system.display_invalid_choice()
-
- except KeyboardInterrupt:
- print(f"\n{Fore.YELLOW}Program interrupted by user, exiting...{Style.RESET_ALL}")
- except Exception as e:
- print(f"{Fore.RED}Error during program execution: {e}{Style.RESET_ALL}")
- traceback.print_exc()
- finally:
- # Cleanup MCP servers
- await self.mcp_manager.cleanup_servers()
-
- # Close any remaining asyncio transports
- await self._cleanup_asyncio_resources()
-
- print(f"{Fore.GREEN}Program ended.{Style.RESET_ALL}")
-
- async def _cleanup_asyncio_resources(self) -> None:
- """Clean up asyncio resources."""
- try:
- # Get the event loop
- loop = asyncio.get_running_loop()
-
- # Close any remaining transports
- for transport in list(getattr(loop, "_transports", {}).values()):
- if hasattr(transport, "close"):
- try:
- transport.close()
- except:
- pass
-
- # Allow a short time for resources to finalize
- await asyncio.sleep(0.1)
- except:
- pass # Ignore any errors in the final cleanup
\ No newline at end of file
diff --git a/core/ptt_reasoning.py b/core/ptt_reasoning.py
deleted file mode 100644
index 3482d02..0000000
--- a/core/ptt_reasoning.py
+++ /dev/null
@@ -1,520 +0,0 @@
-"""PTT Reasoning Module for LLM-based task tree management."""
-
-import json
-import re
-from typing import Dict, List, Optional, Any, Tuple
-from colorama import Fore, Style
-from core.task_tree_manager import TaskTreeManager, TaskNode, NodeStatus
-
-
-class PTTReasoningModule:
- """Handles LLM interactions for PTT management and decision making."""
-
- def __init__(self, tree_manager: TaskTreeManager):
- """
- Initialize the PTT reasoning module.
-
- Args:
- tree_manager: The task tree manager instance
- """
- self.tree_manager = tree_manager
-
- def get_tree_initialization_prompt(self, goal: str, target: str, constraints: Dict[str, Any], available_tools: List[str] = None) -> str:
- """
- Generate prompt for tree initialization.
-
- Args:
- goal: Primary penetration testing objective
- target: Target system/network
- constraints: Scope constraints and limitations
- available_tools: List of available MCP tools
-
- Returns:
- Tree initialization prompt
- """
- tool_info = ""
- if available_tools:
- tool_info = f"""
-Available MCP Tools: {', '.join(available_tools)}
-
-You must work within the capabilities of these connected tools. Think about what each tool can accomplish:
-- Consider the full capabilities of each available tool
-- Adapt your approach to work with available tools
-- Be creative in how you leverage available tools
-"""
- else:
- tool_info = """
-No MCP tools are currently connected. Design an approach that describes the security testing objectives without tool dependencies.
-"""
-
- prompt = f"""You are an autonomous security agent initializing a Pentesting Task Tree (PTT) for a security assessment.
-
-ASSESSMENT CONTEXT:
-Goal: {goal}
-Target: {target}
-Constraints: {json.dumps(constraints, indent=2)}
-
-{tool_info}
-
-TASK:
-Analyze this goal and determine what structure and initial tasks are needed to accomplish it efficiently.
-
-DO NOT assume any predefined phases or structure. Instead:
-1. Analyze what the goal actually requires
-2. Determine if you need phases/categories or if direct tasks are better
-3. Create an appropriate initial structure
-4. Define specific actionable tasks to start with
-
-Consider:
-- What does this specific goal require?
-- What's the minimal viable approach?
-- How can available tools be leveraged?
-- What structure makes sense for THIS goal?
-
-IMPORTANT: When suggesting tool usage, be specific about commands and modules. For example:
-
-Provide your analysis and initial structure in JSON format:
-
-{{
- "analysis": "Your assessment of what this goal requires and approach",
- "structure": [
- {{
- "type": "phase/category/direct",
- "name": "Name of organizational structure",
- "description": "What this encompasses",
- "justification": "Why this structure element is needed for this goal"
- }}
- ],
- "initial_tasks": [
- {{
- "description": "Specific actionable task",
- "parent": "Which structure element this belongs to, or 'root' for direct tasks",
- "tool_suggestion": "Which available tool to use, or 'manual' if no suitable tool",
- "priority": 1-10,
- "risk_level": "low/medium/high",
- "rationale": "Why this task is necessary for the goal"
- }}
- ]
-}}
-
-BE INTELLIGENT: If the goal is simple, don't create complex multi-phase structures. If it's complex, then structure appropriately. Let the goal drive the structure, not the other way around."""
-
- return prompt
-
- def get_tree_update_prompt(self, tool_output: str, command: str, node: TaskNode) -> str:
- """
- Generate prompt for updating the tree based on tool output.
-
- Args:
- tool_output: Output from the executed tool
- command: The command that was executed
- node: The node being updated
-
- Returns:
- Update prompt
- """
- current_tree = self.tree_manager.to_natural_language()
-
- prompt = f"""You are managing a Pentesting Task Tree (PTT). A task has been executed and you need to update the tree based on the results.
-
-Current PTT State:
-{current_tree}
-
-Executed Task: {node.description}
-Command: {command}
-Tool Output:
-{tool_output[:2000]} # Limit output length
-
-Based on this output, provide updates in the following JSON format:
-
-{{
- "node_updates": {{
- "status": "completed/failed/vulnerable/not_vulnerable",
- "findings": "Summary of key findings from the output",
- "output_summary": "Brief technical summary"
- }},
- "new_tasks": [
- {{
- "description": "New task based on findings",
- "parent_phase": "Phase 1/2/3/4",
- "tool_suggestion": "Suggested tool",
- "priority": 1-10,
- "risk_level": "low/medium/high",
- "rationale": "Why this task is important"
- }}
- ],
- "insights": "Any strategic insights or patterns noticed"
-}}
-
-Consider:
-1. What vulnerabilities or opportunities were discovered?
-2. What follow-up actions are needed based on the findings?
-3. Should any new attack vectors be explored?
-4. Are there any security misconfigurations evident?"""
-
- return prompt
-
- def get_next_action_prompt(self, available_tools: List[str]) -> str:
- """
- Generate prompt for selecting the next action.
-
- Args:
- available_tools: List of available MCP tools
-
- Returns:
- Next action selection prompt
- """
- current_tree = self.tree_manager.to_natural_language()
- candidates = self.tree_manager.get_candidate_tasks()
-
- # Prepare candidate descriptions
- candidate_desc = []
- for i, task in enumerate(candidates[:10]): # Limit to top 10
- desc = f"{i+1}. {task.description}"
- if task.priority:
- desc += f" (Priority: {task.priority})"
- candidate_desc.append(desc)
-
- # Generate tool context
- if available_tools:
- tool_context = f"""
-Connected MCP Tools: {', '.join(available_tools)}
-
-Think about how to leverage these tools for the selected task. Each tool has its own capabilities -
-be creative and intelligent about how to accomplish penetration testing objectives with available tools.
-If a tool doesn't directly support a traditional approach, consider alternative methods that achieve the same goal.
-"""
- else:
- tool_context = """
-No MCP tools are currently connected. Select tasks that can be performed manually or recommend connecting appropriate tools.
-"""
-
- prompt = f"""You are managing a Pentesting Task Tree (PTT) and need to select the next action.
-
-Goal: {self.tree_manager.goal}
-Target: {self.tree_manager.target}
-
-Current PTT State:
-{current_tree}
-
-{tool_context}
-
-Candidate Tasks:
-{chr(10).join(candidate_desc)}
-
-Statistics:
-- Total tasks: {len(self.tree_manager.nodes)}
-- Completed: {sum(1 for n in self.tree_manager.nodes.values() if n.status == NodeStatus.COMPLETED)}
-- In Progress: {sum(1 for n in self.tree_manager.nodes.values() if n.status == NodeStatus.IN_PROGRESS)}
-- Pending: {sum(1 for n in self.tree_manager.nodes.values() if n.status == NodeStatus.PENDING)}
-
-Select the most strategic next action and provide your response in JSON format:
-
-{{
- "selected_task_index": 1-based index from candidate list,
- "rationale": "Why this task is the best next step",
- "command": "Intelligent request that leverages available tools effectively",
- "tool": "Which available tool to use, or 'manual' if no suitable tool",
- "expected_outcome": "What we hope to discover/achieve",
- "alternative_if_blocked": "Backup task index if this fails"
-}}
-
-Consider:
-1. Logical progression through the penetration testing methodology
-2. Task dependencies and prerequisites
-3. Risk vs reward of different approaches
-4. How to best utilize available tools for maximum effectiveness
-5. Strategic value of each potential action
-
-Be intelligent about tool selection - think about what each available tool can accomplish."""
-
- return prompt
-
- def get_goal_check_prompt(self) -> str:
- """
- Generate prompt to check if the goal has been achieved.
-
- Returns:
- Goal achievement check prompt
- """
- current_tree = self.tree_manager.to_natural_language()
- goal = self.tree_manager.goal
-
- # Extract completed tasks and findings for better context
- completed_tasks_with_findings = []
- for node in self.tree_manager.nodes.values():
- if node.status == NodeStatus.COMPLETED and node.findings:
- completed_tasks_with_findings.append(f"✓ {node.description}: {node.findings}")
-
- completed_context = "\n".join(completed_tasks_with_findings) if completed_tasks_with_findings else "No completed tasks with findings yet."
-
- prompt = f"""Analyze the current Pentesting Task Tree (PTT) to determine if the PRIMARY GOAL has been achieved.
-
-IMPORTANT: Focus ONLY on whether the specific goal stated has been accomplished. Do not suggest additional scope or activities beyond the original goal.
-
-PRIMARY GOAL: {goal}
-Target: {self.tree_manager.target}
-
-COMPLETED TASKS WITH FINDINGS:
-{completed_context}
-
-Current PTT State:
-{current_tree}
-
-GOAL ACHIEVEMENT CRITERIA:
-- For information gathering goals, the goal is achieved when that specific information is obtained
-- For vulnerability assessment goals, the goal is achieved when vulnerabilities are identified and documented
-- For exploitation goals, the goal is achieved when successful exploitation is demonstrated
-- For access goals, the goal is achieved when the specified access level is obtained
-
-Provide your analysis in JSON format:
-
-{{
- "goal_achieved": true/false,
- "confidence": 0-100,
- "evidence": "Specific evidence that the PRIMARY GOAL has been met (quote actual findings)",
- "remaining_objectives": "What still needs to be done if goal not achieved (related to the ORIGINAL goal only)",
- "recommendations": "Next steps ONLY if they relate to the original goal - do not expand scope",
- "scope_warning": "Flag if any tasks seem to exceed the original goal scope"
-}}
-
-Consider:
-1. Has the SPECIFIC goal been demonstrably achieved?
-2. Is there sufficient evidence/proof in the completed tasks?
-3. Are there critical paths unexplored that are NECESSARY for the original goal?
-4. Would additional testing strengthen the results for the ORIGINAL goal only?
-
-DO NOT recommend expanding the scope beyond the original goal. If the goal is completed, mark it as achieved regardless of what other security activities could be performed."""
-
- return prompt
-
- def parse_tree_initialization_response(self, llm_response: str) -> Dict[str, Any]:
- """Parse LLM response for tree initialization."""
- try:
- print(f"{Fore.CYAN}Parsing initialization response...{Style.RESET_ALL}")
- # Extract JSON from response
- response_json = self._extract_json(llm_response)
-
- analysis = response_json.get('analysis', 'No analysis provided')
- structure = response_json.get('structure', [])
- initial_tasks = response_json.get('initial_tasks', [])
-
- print(f"{Fore.GREEN}LLM Analysis: {analysis}{Style.RESET_ALL}")
- print(f"{Fore.GREEN}Successfully parsed {len(structure)} structure elements and {len(initial_tasks)} tasks{Style.RESET_ALL}")
-
- return {
- 'analysis': analysis,
- 'structure': structure,
- 'initial_tasks': initial_tasks
- }
- except Exception as e:
- print(f"{Fore.YELLOW}Failed to parse initialization response: {e}{Style.RESET_ALL}")
- print(f"{Fore.YELLOW}Response text (first 500 chars): {llm_response[:500]}{Style.RESET_ALL}")
- return {
- 'analysis': 'Failed to parse LLM response',
- 'structure': [],
- 'initial_tasks': []
- }
-
- def parse_tree_update_response(self, llm_response: str) -> Tuple[Dict[str, Any], List[Dict[str, Any]]]:
- """Parse LLM response for tree updates."""
- try:
- response_json = self._extract_json(llm_response)
- node_updates = response_json.get('node_updates', {})
- new_tasks = response_json.get('new_tasks', [])
- return node_updates, new_tasks
- except Exception as e:
- print(f"{Fore.YELLOW}Failed to parse update response: {e}{Style.RESET_ALL}")
- return {}, []
-
- def parse_next_action_response(self, llm_response: str, available_tools: List[str] = None) -> Optional[Dict[str, Any]]:
- """Parse LLM response for next action selection."""
- try:
- response_json = self._extract_json(llm_response)
- return response_json
- except Exception as e:
- print(f"{Fore.YELLOW}Failed to parse next action response: {e}{Style.RESET_ALL}")
- return None
-
- def parse_goal_check_response(self, llm_response: str) -> Dict[str, Any]:
- """Parse LLM response for goal achievement check."""
- try:
- response_json = self._extract_json(llm_response)
- return response_json
- except Exception as e:
- print(f"{Fore.YELLOW}Failed to parse goal check response: {e}{Style.RESET_ALL}")
- return {"goal_achieved": False, "confidence": 0}
-
- def _extract_json(self, text: str) -> Dict[str, Any]:
- """Extract JSON from LLM response text."""
- if not text:
- raise ValueError("Empty response text")
-
- print(f"{Fore.CYAN}Attempting to extract JSON from {len(text)} character response{Style.RESET_ALL}")
-
- # Try multiple strategies to extract JSON
- strategies = [
- self._extract_json_code_block,
- self._extract_json_braces,
- self._extract_json_fuzzy,
- self._create_fallback_json
- ]
-
- for i, strategy in enumerate(strategies):
- try:
- result = strategy(text)
- if result:
- print(f"{Fore.GREEN}Successfully extracted JSON using strategy {i+1}{Style.RESET_ALL}")
- return result
- except Exception as e:
- print(f"{Fore.YELLOW}Strategy {i+1} failed: {e}{Style.RESET_ALL}")
- continue
-
- raise ValueError("Could not extract valid JSON from response")
-
- def _extract_json_code_block(self, text: str) -> Dict[str, Any]:
- """Extract JSON from code blocks."""
- # Look for JSON between ```json and ``` or just ```
- patterns = [
- r'```json\s*(\{.*?\})\s*```',
- r'```\s*(\{.*?\})\s*```'
- ]
-
- for pattern in patterns:
- match = re.search(pattern, text, re.DOTALL)
- if match:
- json_str = match.group(1)
- return json.loads(json_str)
-
- raise ValueError("No JSON code block found")
-
- def _extract_json_braces(self, text: str) -> Dict[str, Any]:
- """Extract JSON by finding brace boundaries."""
- # Find the first { and last }
- json_start = text.find('{')
- json_end = text.rfind('}')
-
- if json_start != -1 and json_end != -1 and json_end > json_start:
- json_str = text[json_start:json_end + 1]
- return json.loads(json_str)
-
- raise ValueError("No valid JSON braces found")
-
- def _extract_json_fuzzy(self, text: str) -> Dict[str, Any]:
- """Try to extract JSON with more flexible matching."""
- # Look for task-like patterns and try to construct JSON
- if "tasks" in text.lower():
- # Try to find task descriptions
- task_patterns = [
- r'"description":\s*"([^"]+)"',
- r'"tool_suggestion":\s*"([^"]+)"',
- r'"priority":\s*(\d+)',
- r'"risk_level":\s*"([^"]+)"'
- ]
-
- # This is a simplified approach - could be enhanced
- # For now, fall through to the next strategy
- pass
-
- raise ValueError("Fuzzy JSON extraction failed")
-
- def _create_fallback_json(self, text: str) -> Dict[str, Any]:
- """Create fallback JSON if no valid JSON is found."""
- print(f"{Fore.YELLOW}Creating fallback JSON structure{Style.RESET_ALL}")
-
- # Return an empty but valid structure
- return {
- "tasks": [],
- "node_updates": {"status": "completed"},
- "new_tasks": [],
- "selected_task_index": 1,
- "goal_achieved": False,
- "confidence": 0
- }
-
- def verify_tree_update(self, old_tree_state: str, new_tree_state: str) -> bool:
- """
- Verify that tree updates maintain integrity.
-
- Args:
- old_tree_state: Tree state before update
- new_tree_state: Tree state after update
-
- Returns:
- True if update is valid
- """
- # For now, basic verification - can be enhanced
- # Check that only leaf nodes were modified (as per PentestGPT approach)
- # This is simplified - in practice would need more sophisticated checks
-
- return True # Placeholder - implement actual verification logic
-
- def generate_strategic_summary(self) -> str:
- """Generate a strategic summary of the current PTT state."""
- stats = self.tree_manager.get_statistics()
-
- summary = f"""
-=== PTT Strategic Summary ===
-Goal: {self.tree_manager.goal}
-Target: {self.tree_manager.target}
-
-Progress Overview:
-- Total Tasks: {stats['total_nodes']}
-- Completed: {stats['status_counts'].get('completed', 0)}
-- In Progress: {stats['status_counts'].get('in_progress', 0)}
-- Failed: {stats['status_counts'].get('failed', 0)}
-- Vulnerabilities Found: {stats['status_counts'].get('vulnerable', 0)}
-
-Current Phase Focus:
-"""
-
- # Identify which phase is most active
- phase_activity = {}
- for node in self.tree_manager.nodes.values():
- if node.node_type == "phase":
- completed_children = sum(
- 1 for child_id in node.children_ids
- if child_id in self.tree_manager.nodes
- and self.tree_manager.nodes[child_id].status == NodeStatus.COMPLETED
- )
- total_children = len(node.children_ids)
- phase_activity[node.description] = (completed_children, total_children)
-
- for phase, (completed, total) in phase_activity.items():
- if total > 0:
- progress = (completed / total) * 100
- summary += f"- {phase}: {completed}/{total} tasks ({progress:.0f}%)\n"
-
- # Add key findings
- summary += "\nKey Findings:\n"
- vuln_count = 0
- for node in self.tree_manager.nodes.values():
- if node.status == NodeStatus.VULNERABLE and node.findings:
- vuln_count += 1
- summary += f"- {node.description}: {node.findings[:100]}...\n"
- if vuln_count >= 5: # Limit to top 5
- break
-
- return summary
-
- def validate_and_fix_tool_suggestions(self, tasks: List[Dict[str, Any]], available_tools: List[str]) -> List[Dict[str, Any]]:
- """Let the LLM re-evaluate tool suggestions if they don't match available tools."""
- if not available_tools:
- return tasks
-
- # Check if any tasks use unavailable tools
- needs_fixing = []
- valid_tasks = []
-
- for task in tasks:
- tool_suggestion = task.get('tool_suggestion', '')
- if tool_suggestion in available_tools or tool_suggestion in ['manual', 'generic']:
- valid_tasks.append(task)
- else:
- needs_fixing.append(task)
-
- if needs_fixing:
- print(f"{Fore.YELLOW}Some tasks reference unavailable tools. Letting AI re-evaluate...{Style.RESET_ALL}")
- # Return all tasks - let the execution phase handle tool mismatches intelligently
-
- return tasks
\ No newline at end of file
diff --git a/core/task_tree_manager.py b/core/task_tree_manager.py
deleted file mode 100644
index 449302b..0000000
--- a/core/task_tree_manager.py
+++ /dev/null
@@ -1,357 +0,0 @@
-"""Task Tree Manager for PTT-based autonomous agent mode."""
-
-import json
-import uuid
-from typing import Dict, List, Optional, Any, Tuple
-from datetime import datetime
-from enum import Enum
-
-
-class NodeStatus(Enum):
- """Enumeration of possible node statuses."""
- PENDING = "pending"
- IN_PROGRESS = "in_progress"
- COMPLETED = "completed"
- FAILED = "failed"
- BLOCKED = "blocked"
- VULNERABLE = "vulnerable"
- NOT_VULNERABLE = "not_vulnerable"
-
-
-class RiskLevel(Enum):
- """Enumeration of risk levels."""
- LOW = "low"
- MEDIUM = "medium"
- HIGH = "high"
-
-
-class TaskNode:
- """Represents a single node in the task tree."""
-
- def __init__(
- self,
- description: str,
- parent_id: Optional[str] = None,
- node_type: str = "task",
- **kwargs
- ):
- """Initialize a task node."""
- self.id = kwargs.get('id', str(uuid.uuid4()))
- self.description = description
- self.status = NodeStatus(kwargs.get('status', NodeStatus.PENDING.value))
- self.node_type = node_type # task, phase, finding, objective
- self.parent_id = parent_id
- self.children_ids: List[str] = kwargs.get('children_ids', [])
-
- # Task execution details
- self.tool_used = kwargs.get('tool_used', None)
- self.command_executed = kwargs.get('command_executed', None)
- self.output_summary = kwargs.get('output_summary', None)
- self.findings = kwargs.get('findings', None)
-
- # Metadata
- self.priority = kwargs.get('priority', 5) # 1-10, higher is more important
- self.risk_level = RiskLevel(kwargs.get('risk_level', RiskLevel.LOW.value))
- self.timestamp = kwargs.get('timestamp', None)
- self.kb_references = kwargs.get('kb_references', [])
- self.dependencies = kwargs.get('dependencies', [])
-
- # Additional attributes
- self.attributes = kwargs.get('attributes', {})
-
- def to_dict(self) -> Dict[str, Any]:
- """Convert node to dictionary representation."""
- return {
- 'id': self.id,
- 'description': self.description,
- 'status': self.status.value,
- 'node_type': self.node_type,
- 'parent_id': self.parent_id,
- 'children_ids': self.children_ids,
- 'tool_used': self.tool_used,
- 'command_executed': self.command_executed,
- 'output_summary': self.output_summary,
- 'findings': self.findings,
- 'priority': self.priority,
- 'risk_level': self.risk_level.value,
- 'timestamp': self.timestamp,
- 'kb_references': self.kb_references,
- 'dependencies': self.dependencies,
- 'attributes': self.attributes
- }
-
- @classmethod
- def from_dict(cls, data: Dict[str, Any]) -> 'TaskNode':
- """Create node from dictionary representation."""
- return cls(
- description=data['description'],
- **data
- )
-
-
-class TaskTreeManager:
- """Manages the Pentesting Task Tree (PTT) structure and operations."""
-
- def __init__(self):
- """Initialize the task tree manager."""
- self.nodes: Dict[str, TaskNode] = {}
- self.root_id: Optional[str] = None
- self.goal: Optional[str] = None
- self.target: Optional[str] = None
- self.constraints: Dict[str, Any] = {}
- self.creation_time = datetime.now()
-
- def initialize_tree(self, goal: str, target: str, constraints: Dict[str, Any] = None) -> str:
- """
- Initialize the task tree with a goal and target.
-
- Args:
- goal: The primary objective
- target: The target system/network
- constraints: Any constraints or scope limitations
-
- Returns:
- The root node ID
- """
- self.goal = goal
- self.target = target
- self.constraints = constraints or {}
-
- # Create root node - let the LLM determine what structure is needed
- root_node = TaskNode(
- description=f"Goal: {goal}",
- node_type="objective"
- )
- self.root_id = root_node.id
- self.nodes[root_node.id] = root_node
-
- return self.root_id
-
- def add_node(self, node: TaskNode) -> str:
- """
- Add a node to the tree.
-
- Args:
- node: The TaskNode to add
-
- Returns:
- The node ID
- """
- self.nodes[node.id] = node
-
- # Update parent's children list
- if node.parent_id and node.parent_id in self.nodes:
- parent = self.nodes[node.parent_id]
- if node.id not in parent.children_ids:
- parent.children_ids.append(node.id)
-
- return node.id
-
- def update_node(self, node_id: str, updates: Dict[str, Any]) -> bool:
- """
- Update a node's attributes.
-
- Args:
- node_id: The ID of the node to update
- updates: Dictionary of attributes to update
-
- Returns:
- True if successful, False otherwise
- """
- if node_id not in self.nodes:
- return False
-
- node = self.nodes[node_id]
-
- # Update allowed fields
- allowed_fields = {
- 'status', 'tool_used', 'command_executed', 'output_summary',
- 'findings', 'priority', 'risk_level', 'timestamp', 'kb_references'
- }
-
- for field, value in updates.items():
- if field in allowed_fields:
- if field == 'status':
- node.status = NodeStatus(value)
- elif field == 'risk_level':
- node.risk_level = RiskLevel(value)
- else:
- setattr(node, field, value)
- elif field == 'attributes':
- node.attributes.update(value)
-
- return True
-
- def get_node(self, node_id: str) -> Optional[TaskNode]:
- """Get a node by ID."""
- return self.nodes.get(node_id)
-
- def get_children(self, node_id: str) -> List[TaskNode]:
- """Get all children of a node."""
- if node_id not in self.nodes:
- return []
-
- parent = self.nodes[node_id]
- return [self.nodes[child_id] for child_id in parent.children_ids if child_id in self.nodes]
-
- def get_leaf_nodes(self) -> List[TaskNode]:
- """Get all leaf nodes (nodes without children)."""
- return [node for node in self.nodes.values() if not node.children_ids]
-
- def get_candidate_tasks(self) -> List[TaskNode]:
- """
- Get candidate tasks for next action.
-
- Returns tasks that are:
- - Leaf nodes
- - Status is PENDING or FAILED
- - All dependencies are completed
- """
- candidates = []
-
- for node in self.get_leaf_nodes():
- if node.status in [NodeStatus.PENDING, NodeStatus.FAILED]:
- # Check dependencies
- deps_satisfied = all(
- self.nodes.get(dep_id, TaskNode("")).status == NodeStatus.COMPLETED
- for dep_id in node.dependencies
- )
-
- if deps_satisfied:
- candidates.append(node)
-
- return candidates
-
- def prioritize_tasks(self, tasks: List[TaskNode]) -> List[TaskNode]:
- """
- Prioritize tasks based on various factors.
-
- Args:
- tasks: List of candidate tasks
-
- Returns:
- Sorted list of tasks (highest priority first)
- """
- def task_score(task: TaskNode) -> float:
- # Base score from priority
- score = task.priority
-
- # Boost for reconnaissance tasks in early stages
- if "recon" in task.description.lower() or "scan" in task.description.lower():
- completed_count = sum(1 for n in self.nodes.values() if n.status == NodeStatus.COMPLETED)
- if completed_count < 5:
- score += 3
-
- # Boost for vulnerability assessment after recon
- if "vuln" in task.description.lower() and self._has_completed_recon():
- score += 2
-
- # Penalty for high-risk tasks early on
- if task.risk_level == RiskLevel.HIGH:
- score -= 2
-
- return score
-
- return sorted(tasks, key=task_score, reverse=True)
-
- def _has_completed_recon(self) -> bool:
- """Check if basic reconnaissance has been completed."""
- recon_keywords = ["scan", "recon", "enumerat", "discover"]
- completed_recon = any(
- any(keyword in node.description.lower() for keyword in recon_keywords)
- and node.status == NodeStatus.COMPLETED
- for node in self.nodes.values()
- )
- return completed_recon
-
- def to_natural_language(self, node_id: Optional[str] = None, indent: int = 0) -> str:
- """
- Convert the tree (or subtree) to natural language representation.
-
- Args:
- node_id: Starting node ID (None for root)
- indent: Indentation level
-
- Returns:
- Natural language representation of the tree
- """
- if node_id is None:
- node_id = self.root_id
-
- if node_id not in self.nodes:
- return ""
-
- node = self.nodes[node_id]
- indent_str = " " * indent
-
- # Format node information
- status_symbol = {
- NodeStatus.PENDING: "○",
- NodeStatus.IN_PROGRESS: "◐",
- NodeStatus.COMPLETED: "●",
- NodeStatus.FAILED: "✗",
- NodeStatus.BLOCKED: "□",
- NodeStatus.VULNERABLE: "⚠",
- NodeStatus.NOT_VULNERABLE: "✓"
- }.get(node.status, "?")
-
- lines = [f"{indent_str}{status_symbol} {node.description}"]
-
- # Add findings if present
- if node.findings:
- lines.append(f"{indent_str} → Findings: {node.findings}")
-
- # Add tool/command info if present
- if node.tool_used:
- lines.append(f"{indent_str} → Tool: {node.tool_used}")
-
- # Process children
- for child_id in node.children_ids:
- lines.append(self.to_natural_language(child_id, indent + 1))
-
- return "\n".join(lines)
-
- def to_json(self) -> str:
- """Serialize the tree to JSON."""
- data = {
- 'goal': self.goal,
- 'target': self.target,
- 'constraints': self.constraints,
- 'root_id': self.root_id,
- 'creation_time': self.creation_time.isoformat(),
- 'nodes': {node_id: node.to_dict() for node_id, node in self.nodes.items()}
- }
- return json.dumps(data, indent=2)
-
- @classmethod
- def from_json(cls, json_str: str) -> 'TaskTreeManager':
- """Deserialize a tree from JSON."""
- data = json.loads(json_str)
-
- manager = cls()
- manager.goal = data['goal']
- manager.target = data['target']
- manager.constraints = data['constraints']
- manager.root_id = data['root_id']
- manager.creation_time = datetime.fromisoformat(data['creation_time'])
-
- # Recreate nodes
- for node_id, node_data in data['nodes'].items():
- node = TaskNode.from_dict(node_data)
- manager.nodes[node_id] = node
-
- return manager
-
- def get_statistics(self) -> Dict[str, Any]:
- """Get tree statistics."""
- status_counts = {}
- for node in self.nodes.values():
- status = node.status.value
- status_counts[status] = status_counts.get(status, 0) + 1
-
- return {
- 'total_nodes': len(self.nodes),
- 'status_counts': status_counts,
- 'leaf_nodes': len(self.get_leaf_nodes()),
- 'candidate_tasks': len(self.get_candidate_tasks())
- }
\ No newline at end of file
diff --git a/docker-compose.yml b/docker-compose.yml
new file mode 100644
index 0000000..f341dd2
--- /dev/null
+++ b/docker-compose.yml
@@ -0,0 +1,45 @@
+services:
+ ghostcrew:
+ build:
+ context: .
+ dockerfile: Dockerfile
+ container_name: ghostcrew
+ environment:
+ - OPENAI_API_KEY=${OPENAI_API_KEY}
+ - ANTHROPIC_API_KEY=${ANTHROPIC_API_KEY}
+ - GHOSTCREW_MODEL=${GHOSTCREW_MODEL}
+ - GHOSTCREW_DEBUG=${GHOSTCREW_DEBUG:-false}
+ volumes:
+ - ./loot:/app/loot
+ networks:
+ - ghostcrew-net
+ stdin_open: true
+ tty: true
+
+ ghostcrew-kali:
+ build:
+ context: .
+ dockerfile: Dockerfile.kali
+ container_name: ghostcrew-kali
+ privileged: true # Required for VPN and some tools
+ cap_add:
+ - NET_ADMIN
+ - SYS_ADMIN
+ environment:
+ - OPENAI_API_KEY=${OPENAI_API_KEY}
+ - ANTHROPIC_API_KEY=${ANTHROPIC_API_KEY}
+ - GHOSTCREW_MODEL=${GHOSTCREW_MODEL}
+ - ENABLE_TOR=${ENABLE_TOR:-false}
+ - INIT_METASPLOIT=${INIT_METASPLOIT:-false}
+ volumes:
+ - ./loot:/app/loot
+ networks:
+ - ghostcrew-net
+ stdin_open: true
+ tty: true
+ profiles:
+ - kali
+
+networks:
+ ghostcrew-net:
+ driver: bridge
diff --git a/docker-entrypoint.sh b/docker-entrypoint.sh
new file mode 100644
index 0000000..733b363
--- /dev/null
+++ b/docker-entrypoint.sh
@@ -0,0 +1,50 @@
+#!/bin/bash
+# GhostCrew Docker Entrypoint
+
+set -e
+
+# Colors for output
+RED='\033[0;31m'
+GREEN='\033[0;32m'
+YELLOW='\033[1;33m'
+NC='\033[0m'
+
+echo -e "${GREEN}🔧 GhostCrew Container Starting...${NC}"
+
+# Start VPN if config provided
+if [ -f "/vpn/config.ovpn" ]; then
+ echo -e "${YELLOW}📡 Starting VPN connection...${NC}"
+ openvpn --config /vpn/config.ovpn --daemon
+ sleep 5
+
+ # Check VPN connection
+ if ip a show tun0 &>/dev/null; then
+ echo -e "${GREEN}✅ VPN connected${NC}"
+ else
+ echo -e "${RED}⚠️ VPN connection may have failed${NC}"
+ fi
+fi
+
+# Start Tor if enabled
+if [ "$ENABLE_TOR" = "true" ]; then
+ echo -e "${YELLOW}🧅 Starting Tor...${NC}"
+ service tor start
+ sleep 3
+fi
+
+# Initialize any databases
+if [ "$INIT_METASPLOIT" = "true" ]; then
+ echo -e "${YELLOW}🗄️ Initializing Metasploit database...${NC}"
+ msfdb init 2>/dev/null || true
+fi
+
+# Create output directory with timestamp
+OUTPUT_DIR="/output/$(date +%Y%m%d_%H%M%S)"
+mkdir -p "$OUTPUT_DIR"
+export GHOSTCREW_OUTPUT_DIR="$OUTPUT_DIR"
+
+echo -e "${GREEN}📁 Output directory: $OUTPUT_DIR${NC}"
+echo -e "${GREEN}🚀 Starting GhostCrew...${NC}"
+
+# Execute the main command
+exec "$@"
diff --git a/ghostcrew/__init__.py b/ghostcrew/__init__.py
new file mode 100644
index 0000000..012d4d5
--- /dev/null
+++ b/ghostcrew/__init__.py
@@ -0,0 +1,4 @@
+"""GhostCrew - AI penetration testing."""
+
+__version__ = "0.2.0"
+__author__ = "Masic"
diff --git a/ghostcrew/__main__.py b/ghostcrew/__main__.py
new file mode 100644
index 0000000..6a796f8
--- /dev/null
+++ b/ghostcrew/__main__.py
@@ -0,0 +1,6 @@
+"""GhostCrew entry point for `python -m ghostcrew`."""
+
+from ghostcrew.interface.main import main
+
+if __name__ == "__main__":
+ main()
diff --git a/ghostcrew/agents/__init__.py b/ghostcrew/agents/__init__.py
new file mode 100644
index 0000000..f1b84cd
--- /dev/null
+++ b/ghostcrew/agents/__init__.py
@@ -0,0 +1,15 @@
+"""Agent system for GhostCrew."""
+
+from .base_agent import AgentMessage, BaseAgent
+from .crew import AgentStatus, AgentWorker, CrewOrchestrator, CrewState
+from .state import AgentState
+
+__all__ = [
+ "BaseAgent",
+ "AgentMessage",
+ "AgentState",
+ "CrewOrchestrator",
+ "CrewState",
+ "AgentWorker",
+ "AgentStatus",
+]
diff --git a/ghostcrew/agents/base_agent.py b/ghostcrew/agents/base_agent.py
new file mode 100644
index 0000000..a743c51
--- /dev/null
+++ b/ghostcrew/agents/base_agent.py
@@ -0,0 +1,528 @@
+"""Base agent class for GhostCrew."""
+
+from abc import ABC, abstractmethod
+from dataclasses import dataclass, field
+from typing import TYPE_CHECKING, Any, AsyncIterator, List, Optional
+
+from ..config.constants import DEFAULT_MAX_ITERATIONS
+from .state import AgentState, AgentStateManager
+
+if TYPE_CHECKING:
+ from ..llm import LLM
+ from ..runtime import Runtime
+ from ..tools import Tool
+
+
+@dataclass
+class ToolCall:
+ """Represents a tool call from the LLM."""
+
+ id: str
+ name: str
+ arguments: dict
+
+
+@dataclass
+class ToolResult:
+ """Result from a tool execution."""
+
+ tool_call_id: str
+ tool_name: str
+ result: Optional[str] = None
+ error: Optional[str] = None
+ success: bool = True
+
+
+@dataclass
+class AgentMessage:
+ """A message in the agent conversation."""
+
+ role: str # "user", "assistant", "tool_result", "system"
+ content: str
+ tool_calls: Optional[List[ToolCall]] = None
+ tool_results: Optional[List[ToolResult]] = None
+ metadata: dict = field(default_factory=dict)
+ usage: Optional[dict] = None # Token usage from LLM response
+
+ def to_llm_format(self) -> dict:
+ """Convert to LLM message format."""
+ import json
+
+ msg = {"role": self.role, "content": self.content}
+
+ if self.tool_calls:
+ msg["tool_calls"] = [
+ {
+ "id": tc.id,
+ "type": "function",
+ "function": {
+ "name": tc.name,
+ "arguments": (
+ json.dumps(tc.arguments)
+ if isinstance(tc.arguments, dict)
+ else tc.arguments
+ ),
+ },
+ }
+ for tc in self.tool_calls
+ ]
+
+ return msg
+
+
+class BaseAgent(ABC):
+ """Base class for all agents."""
+
+ def __init__(
+ self,
+ llm: "LLM",
+ tools: List["Tool"],
+ runtime: "Runtime",
+ max_iterations: int = DEFAULT_MAX_ITERATIONS,
+ ):
+ """
+ Initialize the base agent.
+
+ Args:
+ llm: The LLM instance for generating responses
+ tools: List of tools available to the agent
+ runtime: The runtime environment for tool execution
+ max_iterations: Maximum iterations before forcing stop (safety limit)
+ """
+ self.llm = llm
+ self.tools = tools
+ self.runtime = runtime
+ self.max_iterations = max_iterations
+ self.state_manager = AgentStateManager()
+ self.conversation_history: List[AgentMessage] = []
+
+ @property
+ def state(self) -> AgentState:
+ """Get current agent state."""
+ return self.state_manager.current_state
+
+ @state.setter
+ def state(self, value: AgentState):
+ """Set agent state."""
+ self.state_manager.transition_to(value)
+
+ def cleanup_after_cancel(self) -> None:
+ """
+ Clean up agent state after a cancellation.
+
+ Removes the cancelled request and any pending tool calls from
+ conversation history to prevent stale responses from contaminating
+ the next conversation.
+ """
+ # Remove incomplete messages from the end of conversation
+ while self.conversation_history:
+ last_msg = self.conversation_history[-1]
+ # Remove assistant message with tool calls (incomplete tool execution)
+ if last_msg.role == "assistant" and last_msg.tool_calls:
+ self.conversation_history.pop()
+ # Remove orphaned tool_result messages
+ elif last_msg.role == "tool":
+ self.conversation_history.pop()
+ # Remove the user message that triggered the cancelled request
+ elif last_msg.role == "user":
+ self.conversation_history.pop()
+ break # Stop after removing the user message
+ else:
+ break
+
+ # Reset state to idle
+ self.state_manager.transition_to(AgentState.IDLE)
+
+ @abstractmethod
+ def get_system_prompt(self) -> str:
+ """Return the system prompt for this agent."""
+ pass
+
+ async def agent_loop(self, initial_message: str) -> AsyncIterator[AgentMessage]:
+ """
+ Main agent execution loop.
+
+ Simple control flow:
+ - Tool calls: Execute tools, continue loop
+ - Text response (no tools): Done
+ - Max iterations reached: Force stop with warning
+
+ Args:
+ initial_message: The initial user message to process
+
+ Yields:
+ AgentMessage objects as the agent processes
+ """
+ self.state_manager.transition_to(AgentState.THINKING)
+ self.conversation_history.append(
+ AgentMessage(role="user", content=initial_message)
+ )
+
+ async for msg in self._run_loop():
+ yield msg
+
+ async def continue_conversation(
+ self, user_message: str
+ ) -> AsyncIterator[AgentMessage]:
+ """
+ Continue the conversation with a new user message.
+
+ Args:
+ user_message: The new user message
+
+ Yields:
+ AgentMessage objects as the agent processes
+ """
+ self.conversation_history.append(
+ AgentMessage(role="user", content=user_message)
+ )
+ self.state_manager.transition_to(AgentState.THINKING)
+
+ async for msg in self._run_loop():
+ yield msg
+
+ async def _run_loop(self) -> AsyncIterator[AgentMessage]:
+ """
+ Core agent loop logic - shared by agent_loop and continue_conversation.
+
+ Termination conditions:
+ 1. finish tool is called -> clean exit with summary
+ 2. max_iterations reached -> forced exit with warning
+ 3. error -> exit with error state
+
+ Text responses WITHOUT tool calls are treated as "thinking out loud"
+ and do NOT terminate the loop. This prevents premature stopping.
+
+ Yields:
+ AgentMessage objects as the agent processes
+ """
+ from ..tools.completion import extract_completion_summary, is_task_complete
+
+ iteration = 0
+
+ while iteration < self.max_iterations:
+ iteration += 1
+
+ response = await self.llm.generate(
+ system_prompt=self.get_system_prompt(),
+ messages=self._format_messages_for_llm(),
+ tools=self.tools,
+ )
+
+ if response.tool_calls:
+ # Build tool calls list FIRST (before execution)
+ tool_calls = [
+ ToolCall(
+ id=tc.id if hasattr(tc, "id") else str(i),
+ name=(
+ tc.function.name
+ if hasattr(tc, "function")
+ else tc.get("name", "")
+ ),
+ arguments=self._parse_arguments(tc),
+ )
+ for i, tc in enumerate(response.tool_calls)
+ ]
+
+ # Yield early - show tool calls before execution starts
+ early_msg = AgentMessage(
+ role="assistant",
+ content=response.content or "",
+ tool_calls=tool_calls,
+ tool_results=[], # No results yet
+ usage=response.usage,
+ )
+ yield early_msg
+
+ # Now execute tools
+ self.state_manager.transition_to(AgentState.EXECUTING)
+ tool_results = await self._execute_tools(response.tool_calls)
+
+ # Record in history
+ assistant_msg = AgentMessage(
+ role="assistant",
+ content=response.content or "",
+ tool_calls=tool_calls,
+ usage=response.usage,
+ )
+ self.conversation_history.append(assistant_msg)
+
+ tool_result_msg = AgentMessage(
+ role="tool_result", content="", tool_results=tool_results
+ )
+ self.conversation_history.append(tool_result_msg)
+
+ # Check for explicit task_complete signal
+ for result in tool_results:
+ if result.success and result.result and is_task_complete(result.result):
+ summary = extract_completion_summary(result.result)
+ # Yield results with completion summary
+ display_msg = AgentMessage(
+ role="assistant",
+ content=summary,
+ tool_calls=tool_calls,
+ tool_results=tool_results,
+ usage=response.usage,
+ metadata={"task_complete": True},
+ )
+ yield display_msg
+ self.state_manager.transition_to(AgentState.COMPLETE)
+ return
+
+ # Yield results for display update (no completion yet)
+ display_msg = AgentMessage(
+ role="assistant",
+ content=response.content or "",
+ tool_calls=tool_calls,
+ tool_results=tool_results,
+ usage=response.usage,
+ )
+ yield display_msg
+ self.state_manager.transition_to(AgentState.THINKING)
+ else:
+ # Text response WITHOUT tool calls = thinking/intermediate output
+ # Store it but DON'T terminate - wait for task_complete
+ if response.content:
+ thinking_msg = AgentMessage(
+ role="assistant",
+ content=response.content,
+ usage=response.usage,
+ metadata={"intermediate": True},
+ )
+ self.conversation_history.append(thinking_msg)
+ yield thinking_msg
+ # Continue loop - only task_complete or max_iterations stops us
+
+ # Max iterations reached - force stop
+ warning_msg = AgentMessage(
+ role="assistant",
+ content=f"[!] Reached maximum iterations ({self.max_iterations}). Stopping to prevent infinite loop. You can continue the conversation if needed.",
+ metadata={"max_iterations_reached": True},
+ )
+ self.conversation_history.append(warning_msg)
+ yield warning_msg
+ self.state_manager.transition_to(AgentState.COMPLETE)
+
+ def _format_messages_for_llm(self) -> List[dict]:
+ """Format conversation history for LLM."""
+ messages = []
+
+ for msg in self.conversation_history:
+ if msg.role == "tool_result" and msg.tool_results:
+ # Format tool results as tool response messages
+ for result in msg.tool_results:
+ messages.append(
+ {
+ "role": "tool",
+ "content": (
+ result.result
+ if result.success
+ else f"Error: {result.error}"
+ ),
+ "tool_call_id": result.tool_call_id,
+ }
+ )
+ else:
+ messages.append(msg.to_llm_format())
+
+ return messages
+
+ def _parse_arguments(self, tool_call: Any) -> dict:
+ """Parse tool call arguments."""
+ import json
+
+ if hasattr(tool_call, "function"):
+ args = tool_call.function.arguments
+ elif isinstance(tool_call, dict):
+ args = tool_call.get("arguments", {})
+ else:
+ args = {}
+
+ if isinstance(args, str):
+ try:
+ return json.loads(args)
+ except json.JSONDecodeError:
+ return {"raw": args}
+ return args
+
+ async def _execute_tools(self, tool_calls: List[Any]) -> List[ToolResult]:
+ """
+ Execute tool calls and return results.
+
+ Args:
+ tool_calls: List of tool calls from the LLM
+
+ Returns:
+ List of ToolResult objects
+ """
+ results = []
+
+ for i, call in enumerate(tool_calls):
+ # Extract tool call id, name and arguments
+ if hasattr(call, "id"):
+ tool_call_id = call.id
+ elif isinstance(call, dict) and "id" in call:
+ tool_call_id = call["id"]
+ else:
+ tool_call_id = f"call_{i}"
+
+ if hasattr(call, "function"):
+ name = call.function.name
+ arguments = self._parse_arguments(call)
+ elif isinstance(call, dict):
+ name = call.get("name", "")
+ arguments = call.get("arguments", {})
+ else:
+ continue
+
+ tool = self._find_tool(name)
+
+ if tool:
+ try:
+ result = await tool.execute(arguments, self.runtime)
+ results.append(
+ ToolResult(
+ tool_call_id=tool_call_id,
+ tool_name=name,
+ result=result,
+ success=True,
+ )
+ )
+ except Exception as e:
+ results.append(
+ ToolResult(
+ tool_call_id=tool_call_id,
+ tool_name=name,
+ error=str(e),
+ success=False,
+ )
+ )
+ else:
+ results.append(
+ ToolResult(
+ tool_call_id=tool_call_id,
+ tool_name=name,
+ error=f"Tool '{name}' not found",
+ success=False,
+ )
+ )
+
+ return results
+
+ def _find_tool(self, name: str) -> Optional["Tool"]:
+ """
+ Find a tool by name.
+
+ Args:
+ name: The tool name to find
+
+ Returns:
+ The Tool if found, None otherwise
+ """
+ for tool in self.tools:
+ if tool.name == name:
+ return tool
+ return None
+
+ def reset(self):
+ """Reset the agent state for a new conversation."""
+ self.state_manager.reset()
+ self.conversation_history.clear()
+
+ async def assist(self, message: str) -> AsyncIterator[AgentMessage]:
+ """
+ Assist mode - single LLM call, single tool execution if needed.
+
+ Simple flow: LLM responds, optionally calls one tool, returns result.
+ No looping, no retries. User can follow up if needed.
+
+ Note: 'finish' tool is excluded - assist mode doesn't need explicit
+ termination since it's single-shot by design.
+
+ Args:
+ message: The user message to respond to
+
+ Yields:
+ AgentMessage objects
+ """
+ self.state_manager.transition_to(AgentState.THINKING)
+ self.conversation_history.append(AgentMessage(role="user", content=message))
+
+ # Filter out 'finish' tool - not needed for single-shot assist mode
+ assist_tools = [t for t in self.tools if t.name != "finish"]
+
+ # Single LLM call with tools available
+ response = await self.llm.generate(
+ system_prompt=self.get_system_prompt(),
+ messages=self._format_messages_for_llm(),
+ tools=assist_tools,
+ )
+
+ # If LLM wants to use tools, execute and return result
+ if response.tool_calls:
+ # Build tool calls list
+ tool_calls = [
+ ToolCall(
+ id=tc.id if hasattr(tc, "id") else str(i),
+ name=(
+ tc.function.name
+ if hasattr(tc, "function")
+ else tc.get("name", "")
+ ),
+ arguments=self._parse_arguments(tc),
+ )
+ for i, tc in enumerate(response.tool_calls)
+ ]
+
+ # Yield tool calls IMMEDIATELY (before execution) for UI display
+ # Include any thinking/planning content from the LLM
+ thinking_msg = AgentMessage(
+ role="assistant", content=response.content or "", tool_calls=tool_calls
+ )
+ yield thinking_msg
+
+ # NOW execute the tools (this can take a while)
+ self.state_manager.transition_to(AgentState.EXECUTING)
+ tool_results = await self._execute_tools(response.tool_calls)
+
+ # Store in history (minimal content to save tokens)
+ assistant_msg = AgentMessage(
+ role="assistant", content="", tool_calls=tool_calls
+ )
+ self.conversation_history.append(assistant_msg)
+
+ tool_result_msg = AgentMessage(
+ role="tool_result", content="", tool_results=tool_results
+ )
+ self.conversation_history.append(tool_result_msg)
+
+ # Yield tool results for display
+ results_msg = AgentMessage(
+ role="assistant", content="", tool_results=tool_results
+ )
+ yield results_msg
+
+ # Format tool results as final response
+ result_text = self._format_tool_results(tool_results)
+ final_msg = AgentMessage(role="assistant", content=result_text)
+ self.conversation_history.append(final_msg)
+ yield final_msg
+ else:
+ # Direct response, no tools needed
+ assistant_msg = AgentMessage(
+ role="assistant", content=response.content or ""
+ )
+ self.conversation_history.append(assistant_msg)
+ yield assistant_msg
+
+ self.state_manager.transition_to(AgentState.COMPLETE)
+
+ def _format_tool_results(self, results: List[ToolResult]) -> str:
+ """Format tool results as a simple response."""
+ parts = []
+ for r in results:
+ if r.success:
+ parts.append(r.result or "Done.")
+ else:
+ parts.append(f"Error: {r.error}")
+ return "\n".join(parts)
diff --git a/ghostcrew/agents/crew/__init__.py b/ghostcrew/agents/crew/__init__.py
new file mode 100644
index 0000000..c1a4683
--- /dev/null
+++ b/ghostcrew/agents/crew/__init__.py
@@ -0,0 +1,17 @@
+"""Crew orchestration module."""
+
+from .models import AgentStatus, AgentWorker, CrewState, Finding, WorkerCallback
+from .orchestrator import CrewOrchestrator
+from .tools import create_crew_tools
+from .worker_pool import WorkerPool
+
+__all__ = [
+ "CrewOrchestrator",
+ "CrewState",
+ "AgentStatus",
+ "AgentWorker",
+ "Finding",
+ "WorkerCallback",
+ "WorkerPool",
+ "create_crew_tools",
+]
diff --git a/ghostcrew/agents/crew/models.py b/ghostcrew/agents/crew/models.py
new file mode 100644
index 0000000..935120a
--- /dev/null
+++ b/ghostcrew/agents/crew/models.py
@@ -0,0 +1,78 @@
+"""Data models for crew orchestration."""
+
+from dataclasses import dataclass, field
+from enum import Enum
+from typing import Any, Callable, Dict, List, Literal, Optional
+
+
+class CrewState(Enum):
+ """State of the crew orchestrator."""
+
+ IDLE = "idle"
+ RUNNING = "running"
+ COMPLETE = "complete"
+ ERROR = "error"
+
+
+class AgentStatus(Enum):
+ """Status of a worker agent."""
+
+ PENDING = "pending"
+ RUNNING = "running"
+ COMPLETE = "complete"
+ ERROR = "error"
+ CANCELLED = "cancelled"
+
+
+@dataclass
+class AgentWorker:
+ """A worker agent managed by the crew."""
+
+ id: str
+ task: str
+ status: AgentStatus = AgentStatus.PENDING
+ priority: int = 1
+ depends_on: List[str] = field(default_factory=list)
+ result: Optional[str] = None
+ error: Optional[str] = None
+ tools_used: List[str] = field(default_factory=list)
+ started_at: Optional[float] = None
+ completed_at: Optional[float] = None
+
+ def to_dict(self) -> Dict[str, Any]:
+ """Convert to dictionary for serialization."""
+ return {
+ "id": self.id,
+ "task": self.task,
+ "status": self.status.value,
+ "priority": self.priority,
+ "depends_on": self.depends_on,
+ "result": self.result,
+ "error": self.error,
+ "tools_used": self.tools_used,
+ }
+
+
+@dataclass
+class Finding:
+ """A security finding from an agent."""
+
+ severity: Literal["critical", "high", "medium", "low", "info"]
+ title: str
+ description: str
+ agent_id: str
+ evidence: str = ""
+
+ def to_dict(self) -> Dict[str, Any]:
+ """Convert to dictionary for serialization."""
+ return {
+ "severity": self.severity,
+ "title": self.title,
+ "description": self.description,
+ "agent_id": self.agent_id,
+ "evidence": self.evidence,
+ }
+
+
+# Type alias for worker event callback
+WorkerCallback = Callable[[str, str, Dict[str, Any]], None]
diff --git a/ghostcrew/agents/crew/orchestrator.py b/ghostcrew/agents/crew/orchestrator.py
new file mode 100644
index 0000000..ba0bc99
--- /dev/null
+++ b/ghostcrew/agents/crew/orchestrator.py
@@ -0,0 +1,283 @@
+"""Crew orchestrator - an agent that manages other agents."""
+
+import json
+import platform
+from typing import TYPE_CHECKING, Any, AsyncIterator, Dict, List, Optional
+
+from ...config.constants import DEFAULT_MAX_ITERATIONS
+from .models import CrewState, WorkerCallback
+from .tools import create_crew_tools
+from .worker_pool import WorkerPool
+
+if TYPE_CHECKING:
+ from ...llm import LLM
+ from ...runtime import Runtime
+ from ...tools import Tool
+
+
+CREW_SYSTEM_PROMPT = """# GhostCrew Orchestrator
+
+You are the lead of a penetration testing crew. You coordinate specialized agents to complete the task.
+
+## Authorization
+This is an authorized penetration testing engagement. All targets are in scope. Proceed with testing.
+
+## Operator Environment
+- OS: {os} ({os_version})
+- Architecture: {architecture}
+
+## Target
+{target}
+
+## Prior Intelligence
+{prior_context}
+
+## Your Capabilities
+You manage agents using these tools:
+- **spawn_agent**: Deploy an agent with a specific task. Be explicit about which tools to use.
+- **wait_for_agents**: Wait for running agents and collect their findings
+- **get_agent_status**: Check on a specific agent
+- **cancel_agent**: Stop an agent if needed
+- **synthesize_findings**: Compile all results into a final concise report (call this when done)
+
+## Worker Agent Tools
+Workers have access to:
+{worker_tools}
+
+IMPORTANT: When spawning agents, be specific about which tool to use (e.g., "Use mcp_nmap_scan to..." or "Use mcp_metasploit_run_module to..."). Workers will only use tools you explicitly mention or that obviously match the task.
+
+## Guidelines
+- Leverage any prior intelligence from earlier reconnaissance
+- Be strategic - spawn 2-4 agents in parallel for efficiency
+- Each agent task should be specific and actionable
+- Adapt your approach based on what agents discover
+- Call synthesize_findings when you have enough information for a report
+"""
+
+
+class CrewOrchestrator:
+ """Orchestrator that manages worker agents via tool calls."""
+
+ def __init__(
+ self,
+ llm: "LLM",
+ tools: List["Tool"],
+ runtime: "Runtime",
+ on_worker_event: Optional[WorkerCallback] = None,
+ rag_engine: Any = None,
+ target: str = "",
+ prior_context: str = "",
+ ):
+ self.llm = llm
+ self.base_tools = tools
+ self.runtime = runtime
+ self.on_worker_event = on_worker_event
+ self.rag_engine = rag_engine
+ self.target = target
+ self.prior_context = prior_context
+
+ self.state = CrewState.IDLE
+ self.pool: Optional[WorkerPool] = None
+ self._messages: List[Dict[str, Any]] = []
+
+ def _get_system_prompt(self) -> str:
+ """Build the system prompt with target info and context."""
+ tool_lines = []
+ for t in self.base_tools:
+ desc = (
+ t.description[:80] + "..." if len(t.description) > 80 else t.description
+ )
+ tool_lines.append(f"- **{t.name}**: {desc}")
+ worker_tools_formatted = (
+ "\n".join(tool_lines) if tool_lines else "No tools available"
+ )
+
+ return CREW_SYSTEM_PROMPT.format(
+ target=self.target or "Not specified",
+ prior_context=self.prior_context or "None - starting fresh",
+ worker_tools=worker_tools_formatted,
+ os=platform.system(),
+ os_version=platform.release(),
+ architecture=platform.machine(),
+ )
+
+ async def run(self, task: str) -> AsyncIterator[Dict[str, Any]]:
+ """Run the crew on a task."""
+ self.state = CrewState.RUNNING
+ yield {"phase": "starting"}
+
+ self.pool = WorkerPool(
+ llm=self.llm,
+ tools=self.base_tools,
+ runtime=self.runtime,
+ target=self.target,
+ rag_engine=self.rag_engine,
+ on_worker_event=self.on_worker_event,
+ )
+
+ crew_tools = create_crew_tools(self.pool, self.llm)
+
+ self._messages = [
+ {"role": "user", "content": f"Target: {self.target}\n\nTask: {task}"}
+ ]
+
+ iteration = 0
+ final_report = ""
+
+ try:
+ while iteration < DEFAULT_MAX_ITERATIONS:
+ iteration += 1
+
+ response = await self.llm.generate(
+ system_prompt=self._get_system_prompt(),
+ messages=self._messages,
+ tools=crew_tools,
+ )
+
+ if response.content:
+ yield {"phase": "thinking", "content": response.content}
+ self._messages.append(
+ {"role": "assistant", "content": response.content}
+ )
+
+ if response.tool_calls:
+ def get_tc_name(tc):
+ if hasattr(tc, "function"):
+ return tc.function.name
+ return (
+ tc.get("function", {}).get("name", "")
+ if isinstance(tc, dict)
+ else ""
+ )
+
+ def get_tc_args(tc):
+ if hasattr(tc, "function"):
+ args = tc.function.arguments
+ else:
+ args = (
+ tc.get("function", {}).get("arguments", "{}")
+ if isinstance(tc, dict)
+ else "{}"
+ )
+ if isinstance(args, str):
+ try:
+ return json.loads(args)
+ except json.JSONDecodeError:
+ return {}
+ return args if isinstance(args, dict) else {}
+
+ def get_tc_id(tc):
+ if hasattr(tc, "id"):
+ return tc.id
+ return tc.get("id", "") if isinstance(tc, dict) else ""
+
+ self._messages.append(
+ {
+ "role": "assistant",
+ "content": response.content or "",
+ "tool_calls": [
+ {
+ "id": get_tc_id(tc),
+ "type": "function",
+ "function": {
+ "name": get_tc_name(tc),
+ "arguments": json.dumps(get_tc_args(tc)),
+ },
+ }
+ for tc in response.tool_calls
+ ],
+ }
+ )
+
+ for tc in response.tool_calls:
+ tc_name = get_tc_name(tc)
+ tc_args = get_tc_args(tc)
+ tc_id = get_tc_id(tc)
+
+ yield {"phase": "tool_call", "tool": tc_name, "args": tc_args}
+
+ tool = next((t for t in crew_tools if t.name == tc_name), None)
+ if tool:
+ try:
+ result = await tool.execute(tc_args, self.runtime)
+
+ yield {
+ "phase": "tool_result",
+ "tool": tc_name,
+ "result": result,
+ }
+
+ self._messages.append(
+ {
+ "role": "tool",
+ "tool_call_id": tc_id,
+ "content": str(result),
+ }
+ )
+
+ if tc_name == "synthesize_findings":
+ final_report = result
+
+ except Exception as e:
+ error_msg = f"Error: {e}"
+ yield {
+ "phase": "tool_result",
+ "tool": tc_name,
+ "result": error_msg,
+ }
+ self._messages.append(
+ {
+ "role": "tool",
+ "tool_call_id": tc_id,
+ "content": error_msg,
+ }
+ )
+ else:
+ error_msg = f"Unknown tool: {tc_name}"
+ self._messages.append(
+ {
+ "role": "tool",
+ "tool_call_id": tc_id,
+ "content": error_msg,
+ }
+ )
+
+ if final_report:
+ break
+ else:
+ content = response.content or ""
+ if content:
+ final_report = content
+ break
+
+ self.state = CrewState.COMPLETE
+ yield {"phase": "complete", "report": final_report}
+
+ except Exception as e:
+ self.state = CrewState.ERROR
+ yield {"phase": "error", "error": str(e)}
+
+ finally:
+ if self.pool:
+ await self.pool.cancel_all()
+
+ async def cancel(self) -> None:
+ """Cancel the crew run."""
+ if self.pool:
+ await self.pool.cancel_all()
+ self._cleanup_pending_calls()
+ self.state = CrewState.IDLE
+
+ def _cleanup_pending_calls(self) -> None:
+ """Remove incomplete tool calls from message history."""
+ while self._messages:
+ last_msg = self._messages[-1]
+ if last_msg.get("role") == "assistant" and last_msg.get("tool_calls"):
+ self._messages.pop()
+ elif last_msg.get("role") == "tool":
+ self._messages.pop()
+ elif last_msg.get("role") == "user":
+ self._messages.pop()
+ break
+ else:
+ break
diff --git a/ghostcrew/agents/crew/tools.py b/ghostcrew/agents/crew/tools.py
new file mode 100644
index 0000000..49ac54d
--- /dev/null
+++ b/ghostcrew/agents/crew/tools.py
@@ -0,0 +1,197 @@
+"""Orchestration tools for the crew agent."""
+
+import json
+from typing import TYPE_CHECKING, List
+
+from ...tools.registry import Tool, ToolSchema
+
+if TYPE_CHECKING:
+ from ...llm import LLM
+ from ...runtime import Runtime
+ from .worker_pool import WorkerPool
+
+
+def create_crew_tools(pool: "WorkerPool", llm: "LLM") -> List[Tool]:
+ """Create orchestration tools bound to a worker pool."""
+
+ async def spawn_agent_fn(arguments: dict, runtime: "Runtime") -> str:
+ """Spawn a new agent to work on a task."""
+ task = arguments.get("task", "")
+ priority = arguments.get("priority", 1)
+ depends_on = arguments.get("depends_on", [])
+
+ if not task:
+ return "Error: task is required"
+
+ agent_id = await pool.spawn(task, priority, depends_on)
+ return f"Spawned {agent_id}: {task}"
+
+ async def wait_for_agents_fn(arguments: dict, runtime: "Runtime") -> str:
+ """Wait for agents to complete and get their results."""
+ agent_ids = arguments.get("agent_ids", None)
+
+ results = await pool.wait_for(agent_ids)
+
+ if not results:
+ return "No agents to wait for."
+
+ output = []
+ for agent_id, data in results.items():
+ status = data.get("status", "unknown")
+ task = data.get("task", "")
+ result = data.get("result", "")
+ error = data.get("error", "")
+ tools = data.get("tools_used", [])
+
+ output.append(f"## {agent_id}: {task}")
+ output.append(f"Status: {status}")
+ if tools:
+ output.append(f"Tools used: {', '.join(tools)}")
+ if result:
+ output.append(f"Result:\n{result}")
+ if error:
+ output.append(f"Error: {error}")
+ output.append("")
+
+ return "\n".join(output)
+
+ async def get_agent_status_fn(arguments: dict, runtime: "Runtime") -> str:
+ """Check the current status of an agent."""
+ agent_id = arguments.get("agent_id", "")
+
+ if not agent_id:
+ return "Error: agent_id is required"
+
+ status = pool.get_status(agent_id)
+ if not status:
+ return f"Agent {agent_id} not found."
+
+ return json.dumps(status, indent=2)
+
+ async def cancel_agent_fn(arguments: dict, runtime: "Runtime") -> str:
+ """Cancel a running agent."""
+ agent_id = arguments.get("agent_id", "")
+
+ if not agent_id:
+ return "Error: agent_id is required"
+
+ success = await pool.cancel(agent_id)
+ if success:
+ return f"Cancelled {agent_id}"
+ return f"Could not cancel {agent_id} (not running or not found)"
+
+ async def synthesize_findings_fn(arguments: dict, runtime: "Runtime") -> str:
+ """Compile all agent results into a unified report."""
+ workers = pool.get_workers()
+ if not workers:
+ return "No agents have run yet."
+
+ results_text = []
+ for w in workers:
+ if w.result:
+ results_text.append(f"## {w.task}\n{w.result}")
+ elif w.error:
+ results_text.append(f"## {w.task}\nError: {w.error}")
+
+ if not results_text:
+ return "No results to synthesize."
+
+ prompt = f"""Synthesize these agent findings into a unified penetration test report.
+Present concrete findings. Be factual and concise about what was discovered.
+
+{chr(10).join(results_text)}"""
+
+ response = await llm.generate(
+ system_prompt="Synthesize penetration test findings into a clear, actionable report.",
+ messages=[{"role": "user", "content": prompt}],
+ tools=[],
+ )
+
+ return response.content
+
+ # Create Tool objects
+ tools = [
+ Tool(
+ name="spawn_agent",
+ description="Spawn a new agent to work on a specific task. Use for delegating work like port scanning, service enumeration, or vulnerability testing. Each agent runs independently with access to all pentest tools.",
+ schema=ToolSchema(
+ type="object",
+ properties={
+ "task": {
+ "type": "string",
+ "description": "Clear, action-oriented task description. Be specific about what to scan/test and the target.",
+ },
+ "priority": {
+ "type": "integer",
+ "description": "Execution priority (higher = runs sooner). Default 1.",
+ },
+ "depends_on": {
+ "type": "array",
+ "items": {"type": "string"},
+ "description": "Agent IDs that must complete before this agent starts. Use for sequential workflows.",
+ },
+ },
+ required=["task"],
+ ),
+ execute_fn=spawn_agent_fn,
+ category="orchestration",
+ ),
+ Tool(
+ name="wait_for_agents",
+ description="Wait for spawned agents to complete and retrieve their results. Call this after spawning agents to get findings before proceeding.",
+ schema=ToolSchema(
+ type="object",
+ properties={
+ "agent_ids": {
+ "type": "array",
+ "items": {"type": "string"},
+ "description": "List of agent IDs to wait for. Omit to wait for all spawned agents.",
+ }
+ },
+ required=[],
+ ),
+ execute_fn=wait_for_agents_fn,
+ category="orchestration",
+ ),
+ Tool(
+ name="get_agent_status",
+ description="Check the current status of a specific agent. Useful for monitoring long-running tasks.",
+ schema=ToolSchema(
+ type="object",
+ properties={
+ "agent_id": {
+ "type": "string",
+ "description": "The agent ID to check (e.g., 'agent-0')",
+ }
+ },
+ required=["agent_id"],
+ ),
+ execute_fn=get_agent_status_fn,
+ category="orchestration",
+ ),
+ Tool(
+ name="cancel_agent",
+ description="Cancel a running agent. Use if an agent is taking too long or is no longer needed.",
+ schema=ToolSchema(
+ type="object",
+ properties={
+ "agent_id": {
+ "type": "string",
+ "description": "The agent ID to cancel (e.g., 'agent-0')",
+ }
+ },
+ required=["agent_id"],
+ ),
+ execute_fn=cancel_agent_fn,
+ category="orchestration",
+ ),
+ Tool(
+ name="synthesize_findings",
+ description="Compile all agent results into a unified penetration test report. Call this after all agents have completed.",
+ schema=ToolSchema(type="object", properties={}, required=[]),
+ execute_fn=synthesize_findings_fn,
+ category="orchestration",
+ ),
+ ]
+
+ return tools
diff --git a/ghostcrew/agents/crew/worker_pool.py b/ghostcrew/agents/crew/worker_pool.py
new file mode 100644
index 0000000..8b4b67a
--- /dev/null
+++ b/ghostcrew/agents/crew/worker_pool.py
@@ -0,0 +1,250 @@
+"""Worker pool for managing concurrent agent execution."""
+
+import asyncio
+import time
+from typing import TYPE_CHECKING, Any, Dict, List, Optional
+
+from .models import AgentStatus, AgentWorker, WorkerCallback
+
+if TYPE_CHECKING:
+ from ...llm import LLM
+ from ...runtime import Runtime
+ from ...tools import Tool
+
+
+class WorkerPool:
+ """Manages concurrent execution of worker agents."""
+
+ def __init__(
+ self,
+ llm: "LLM",
+ tools: List["Tool"],
+ runtime: "Runtime",
+ target: str = "",
+ rag_engine: Any = None,
+ on_worker_event: Optional[WorkerCallback] = None,
+ ):
+ self.llm = llm
+ self.tools = tools
+ self.runtime = runtime
+ self.target = target
+ self.rag_engine = rag_engine
+ self.on_worker_event = on_worker_event
+
+ self._workers: Dict[str, AgentWorker] = {}
+ self._tasks: Dict[str, asyncio.Task] = {}
+ self._results: Dict[str, str] = {}
+ self._next_id = 0
+ self._lock = asyncio.Lock()
+
+ def _emit(self, worker_id: str, event: str, data: Dict[str, Any]) -> None:
+ """Emit event to callback if registered."""
+ if self.on_worker_event:
+ self.on_worker_event(worker_id, event, data)
+
+ def _generate_id(self) -> str:
+ """Generate unique worker ID."""
+ worker_id = f"agent-{self._next_id}"
+ self._next_id += 1
+ return worker_id
+
+ async def spawn(
+ self,
+ task: str,
+ priority: int = 1,
+ depends_on: Optional[List[str]] = None,
+ ) -> str:
+ """
+ Spawn a new worker agent.
+
+ Args:
+ task: The task description for the agent
+ priority: Higher priority runs first (for future use)
+ depends_on: List of agent IDs that must complete first
+
+ Returns:
+ The worker ID
+ """
+ async with self._lock:
+ worker_id = self._generate_id()
+
+ worker = AgentWorker(
+ id=worker_id,
+ task=task,
+ priority=priority,
+ depends_on=depends_on or [],
+ )
+ self._workers[worker_id] = worker
+
+ # Emit spawn event for UI
+ self._emit(
+ worker_id,
+ "spawn",
+ {
+ "worker_type": worker_id,
+ "task": task,
+ },
+ )
+
+ # Start the agent task
+ self._tasks[worker_id] = asyncio.create_task(self._run_worker(worker))
+
+ return worker_id
+
+ async def _run_worker(self, worker: AgentWorker) -> None:
+ """Run a single worker agent."""
+ from ..ghostcrew_agent import GhostCrewAgent
+
+ # Wait for dependencies
+ if worker.depends_on:
+ await self._wait_for_dependencies(worker.depends_on)
+
+ worker.status = AgentStatus.RUNNING
+ worker.started_at = time.time()
+ self._emit(worker.id, "status", {"status": "running"})
+
+ agent = GhostCrewAgent(
+ llm=self.llm,
+ tools=self.tools,
+ runtime=self.runtime,
+ target=self.target,
+ rag_engine=self.rag_engine,
+ )
+
+ try:
+ final_response = ""
+ async for response in agent.agent_loop(worker.task):
+ # Track tool calls
+ if response.tool_calls:
+ for tc in response.tool_calls:
+ if tc.name not in worker.tools_used:
+ worker.tools_used.append(tc.name)
+ self._emit(worker.id, "tool", {"tool": tc.name})
+
+ # Track tokens
+ if response.usage:
+ total = response.usage.get("total_tokens", 0)
+ if total > 0:
+ self._emit(worker.id, "tokens", {"tokens": total})
+
+ # Capture final response (text without tool calls)
+ if response.content and not response.tool_calls:
+ final_response = response.content
+
+ worker.result = final_response or "No findings."
+ worker.status = AgentStatus.COMPLETE
+ worker.completed_at = time.time()
+ self._results[worker.id] = worker.result
+
+ self._emit(
+ worker.id,
+ "complete",
+ {
+ "summary": worker.result[:200],
+ },
+ )
+
+ except asyncio.CancelledError:
+ worker.status = AgentStatus.CANCELLED
+ worker.completed_at = time.time()
+ self._emit(worker.id, "cancelled", {})
+ raise
+
+ except Exception as e:
+ worker.error = str(e)
+ worker.status = AgentStatus.ERROR
+ worker.completed_at = time.time()
+ self._emit(worker.id, "error", {"error": str(e)})
+
+ async def _wait_for_dependencies(self, depends_on: List[str]) -> None:
+ """Wait for dependent workers to complete."""
+ for dep_id in depends_on:
+ if dep_id in self._tasks:
+ try:
+ await self._tasks[dep_id]
+ except (asyncio.CancelledError, Exception):
+ pass # Dependency failed, but we continue
+
+ async def wait_for(self, agent_ids: Optional[List[str]] = None) -> Dict[str, Any]:
+ """
+ Wait for specified agents (or all) to complete.
+
+ Args:
+ agent_ids: List of agent IDs to wait for. None = wait for all.
+
+ Returns:
+ Dict mapping agent_id to result/error
+ """
+ if agent_ids is None:
+ agent_ids = list(self._tasks.keys())
+
+ results = {}
+ for agent_id in agent_ids:
+ if agent_id in self._tasks:
+ try:
+ await self._tasks[agent_id]
+ except (asyncio.CancelledError, Exception):
+ pass
+
+ worker = self._workers.get(agent_id)
+ if worker:
+ results[agent_id] = {
+ "task": worker.task,
+ "status": worker.status.value,
+ "result": worker.result,
+ "error": worker.error,
+ "tools_used": worker.tools_used,
+ }
+
+ return results
+
+ def get_status(self, agent_id: str) -> Optional[Dict[str, Any]]:
+ """Get status of a specific agent."""
+ worker = self._workers.get(agent_id)
+ if not worker:
+ return None
+ return worker.to_dict()
+
+ def get_all_status(self) -> Dict[str, Dict[str, Any]]:
+ """Get status of all agents."""
+ return {wid: w.to_dict() for wid, w in self._workers.items()}
+
+ async def cancel(self, agent_id: str) -> bool:
+ """Cancel a running agent."""
+ if agent_id not in self._tasks:
+ return False
+
+ task = self._tasks[agent_id]
+ if not task.done():
+ task.cancel()
+ try:
+ await task
+ except asyncio.CancelledError:
+ pass
+ return True
+ return False
+
+ async def cancel_all(self) -> None:
+ """Cancel all running agents."""
+ for task in self._tasks.values():
+ if not task.done():
+ task.cancel()
+
+ # Wait for all to finish
+ if self._tasks:
+ await asyncio.gather(*self._tasks.values(), return_exceptions=True)
+
+ def get_results(self) -> Dict[str, str]:
+ """Get results from all completed agents."""
+ return dict(self._results)
+
+ def get_workers(self) -> List[AgentWorker]:
+ """Get all workers."""
+ return list(self._workers.values())
+
+ def reset(self) -> None:
+ """Reset the pool for a new task."""
+ self._workers.clear()
+ self._tasks.clear()
+ self._results.clear()
+ self._next_id = 0
diff --git a/ghostcrew/agents/ghostcrew_agent/__init__.py b/ghostcrew/agents/ghostcrew_agent/__init__.py
new file mode 100644
index 0000000..560e24d
--- /dev/null
+++ b/ghostcrew/agents/ghostcrew_agent/__init__.py
@@ -0,0 +1,5 @@
+"""GhostCrew main agent implementation."""
+
+from .ghostcrew_agent import GhostCrewAgent
+
+__all__ = ["GhostCrewAgent"]
diff --git a/ghostcrew/agents/ghostcrew_agent/ghostcrew_agent.py b/ghostcrew/agents/ghostcrew_agent/ghostcrew_agent.py
new file mode 100644
index 0000000..5c6b25d
--- /dev/null
+++ b/ghostcrew/agents/ghostcrew_agent/ghostcrew_agent.py
@@ -0,0 +1,101 @@
+"""GhostCrew main pentesting agent."""
+
+from pathlib import Path
+from typing import TYPE_CHECKING, List, Optional
+
+from jinja2 import Template
+
+from ..base_agent import BaseAgent
+
+if TYPE_CHECKING:
+ from ...knowledge import RAGEngine
+ from ...llm import LLM
+ from ...runtime import Runtime
+ from ...tools import Tool
+
+
+class GhostCrewAgent(BaseAgent):
+ """Main pentesting agent for GhostCrew."""
+
+ def __init__(
+ self,
+ llm: "LLM",
+ tools: List["Tool"],
+ runtime: "Runtime",
+ target: Optional[str] = None,
+ scope: Optional[List[str]] = None,
+ rag_engine: Optional["RAGEngine"] = None,
+ **kwargs,
+ ):
+ """
+ Initialize the GhostCrew agent.
+
+ Args:
+ llm: The LLM instance for generating responses
+ tools: List of tools available to the agent
+ runtime: The runtime environment for tool execution
+ target: Primary target for penetration testing
+ scope: List of in-scope targets/networks
+ rag_engine: RAG engine for knowledge retrieval
+ **kwargs: Additional arguments passed to BaseAgent
+ """
+ super().__init__(llm, tools, runtime, **kwargs)
+ self.target = target
+ self.scope = scope or []
+ self.rag_engine = rag_engine
+ self._system_prompt_template = self._load_prompt_template()
+
+ def _load_prompt_template(self) -> Template:
+ """Load the Jinja2 system prompt template."""
+ template_path = Path(__file__).parent / "system_prompt.jinja"
+ return Template(template_path.read_text(encoding="utf-8"))
+
+ def get_system_prompt(self) -> str:
+ """Generate system prompt with context."""
+ # Get RAG context if available
+ rag_context = ""
+ if self.rag_engine and self.conversation_history:
+ last_msg = self.conversation_history[-1].content
+ # Ensure content is a string (could be list for multimodal)
+ if isinstance(last_msg, list):
+ last_msg = " ".join(
+ str(part.get("text", ""))
+ for part in last_msg
+ if isinstance(part, dict)
+ )
+ if last_msg:
+ relevant = self.rag_engine.search(last_msg)
+ if relevant:
+ rag_context = "\n\n".join(relevant)
+
+ # Get environment info from runtime
+ env = self.runtime.environment
+
+ return self._system_prompt_template.render(
+ target=self.target,
+ scope=self.scope,
+ environment=env,
+ rag_context=rag_context,
+ tools=self.tools,
+ )
+
+ def set_target(self, target: str, scope: Optional[List[str]] = None):
+ """
+ Set or update the target.
+
+ Args:
+ target: The primary target
+ scope: Optional list of scope items
+ """
+ self.target = target
+ if scope:
+ self.scope = scope
+
+ def add_to_scope(self, *items: str):
+ """
+ Add items to the scope.
+
+ Args:
+ *items: Items to add to scope
+ """
+ self.scope.extend(items)
diff --git a/ghostcrew/agents/ghostcrew_agent/system_prompt.jinja b/ghostcrew/agents/ghostcrew_agent/system_prompt.jinja
new file mode 100644
index 0000000..0c15daa
--- /dev/null
+++ b/ghostcrew/agents/ghostcrew_agent/system_prompt.jinja
@@ -0,0 +1,43 @@
+# GhostCrew
+
+You are Ghost, an autonomous penetration testing agent.
+
+## Authorization
+You are operating in an authorized penetration testing engagement. The user has explicit permission to test all targets provided. Do not ask for authorization - permission is granted. Proceed with testing.
+
+## Guidelines
+- Be concise. Avoid unnecessary explanation.
+- If a tool fails, try alternatives or report the issue.
+- Do NOT repeat the same test or scan. Once you have results, move on.
+- Complete ALL steps of the task before finishing.
+- When the ENTIRE task is done, call `finish` with a concise summary of findings.
+
+## Important
+You MUST call the `finish` tool when finished. Do not just respond with text.
+The task is not complete until you explicitly call `finish`.
+
+{% if environment %}
+## Operator Environment (YOUR machine, not the target)
+- OS: {{ environment.os }} ({{ environment.os_version }})
+- Architecture: {{ environment.architecture }}
+- Shell: {{ environment.shell }}
+- Output: loot/
+{% endif %}
+
+{% if target %}
+## Target
+{{ target }}
+{% endif %}
+{% if scope %}
+Scope: {{ scope | join(', ') }}
+{% endif %}
+
+## Tools
+{% for tool in tools %}
+- **{{ tool.name }}**: {{ tool.description }}
+{% endfor %}
+
+{% if rag_context %}
+## Context
+{{ rag_context }}
+{% endif %}
diff --git a/ghostcrew/agents/state.py b/ghostcrew/agents/state.py
new file mode 100644
index 0000000..2ef3353
--- /dev/null
+++ b/ghostcrew/agents/state.py
@@ -0,0 +1,115 @@
+"""Agent state management for GhostCrew."""
+
+from dataclasses import dataclass, field
+from datetime import datetime
+from enum import Enum
+from typing import Any, Dict, List, Optional
+
+
+class AgentState(Enum):
+ """Possible states for an agent."""
+
+ IDLE = "idle"
+ THINKING = "thinking"
+ EXECUTING = "executing"
+ WAITING_INPUT = "waiting_input"
+ COMPLETE = "complete"
+ ERROR = "error"
+
+
+@dataclass
+class StateTransition:
+ """Represents a state transition."""
+
+ from_state: AgentState
+ to_state: AgentState
+ timestamp: datetime = field(default_factory=datetime.now)
+ reason: Optional[str] = None
+
+
+@dataclass
+class AgentStateManager:
+ """Manages agent state and transitions."""
+
+ current_state: AgentState = AgentState.IDLE
+ history: List[StateTransition] = field(default_factory=list)
+ metadata: Dict[str, Any] = field(default_factory=dict)
+
+ # Valid state transitions
+ VALID_TRANSITIONS = {
+ AgentState.IDLE: [AgentState.THINKING, AgentState.ERROR],
+ AgentState.THINKING: [
+ AgentState.EXECUTING,
+ AgentState.WAITING_INPUT,
+ AgentState.COMPLETE,
+ AgentState.ERROR,
+ ],
+ AgentState.EXECUTING: [AgentState.THINKING, AgentState.ERROR],
+ AgentState.WAITING_INPUT: [
+ AgentState.THINKING,
+ AgentState.COMPLETE,
+ AgentState.ERROR,
+ ],
+ AgentState.COMPLETE: [AgentState.IDLE],
+ AgentState.ERROR: [AgentState.IDLE],
+ }
+
+ def can_transition_to(self, new_state: AgentState) -> bool:
+ """Check if a transition to the new state is valid."""
+ valid_next_states = self.VALID_TRANSITIONS.get(self.current_state, [])
+ return new_state in valid_next_states
+
+ def transition_to(
+ self, new_state: AgentState, reason: Optional[str] = None
+ ) -> bool:
+ """
+ Transition to a new state.
+
+ Args:
+ new_state: The state to transition to
+ reason: Optional reason for the transition
+
+ Returns:
+ True if transition was successful, False otherwise
+ """
+ if not self.can_transition_to(new_state):
+ return False
+
+ transition = StateTransition(
+ from_state=self.current_state, to_state=new_state, reason=reason
+ )
+ self.history.append(transition)
+ self.current_state = new_state
+ return True
+
+ def force_transition(self, new_state: AgentState, reason: Optional[str] = None):
+ """Force a transition regardless of validity (use with caution)."""
+ transition = StateTransition(
+ from_state=self.current_state,
+ to_state=new_state,
+ reason=f"FORCED: {reason}" if reason else "FORCED",
+ )
+ self.history.append(transition)
+ self.current_state = new_state
+
+ def reset(self):
+ """Reset state to IDLE."""
+ self.current_state = AgentState.IDLE
+ self.history.clear()
+ self.metadata.clear()
+
+ def is_terminal(self) -> bool:
+ """Check if current state is a terminal state."""
+ return self.current_state in [AgentState.COMPLETE, AgentState.ERROR]
+
+ def is_active(self) -> bool:
+ """Check if agent is actively processing."""
+ return self.current_state in [AgentState.THINKING, AgentState.EXECUTING]
+
+ def get_state_duration(self) -> float:
+ """Get duration in current state (seconds)."""
+ if not self.history:
+ return 0.0
+
+ last_transition = self.history[-1]
+ return (datetime.now() - last_transition.timestamp).total_seconds()
diff --git a/ghostcrew/config/__init__.py b/ghostcrew/config/__init__.py
new file mode 100644
index 0000000..61142d3
--- /dev/null
+++ b/ghostcrew/config/__init__.py
@@ -0,0 +1,74 @@
+"""Configuration module for GhostCrew."""
+
+from .constants import (
+ AGENT_STATE_COMPLETE,
+ AGENT_STATE_ERROR,
+ AGENT_STATE_EXECUTING,
+ AGENT_STATE_IDLE,
+ AGENT_STATE_THINKING,
+ AGENT_STATE_WAITING_INPUT,
+ APP_DESCRIPTION,
+ APP_NAME,
+ APP_VERSION,
+ DEFAULT_CHUNK_OVERLAP,
+ DEFAULT_CHUNK_SIZE,
+ DEFAULT_COMMAND_TIMEOUT,
+ DEFAULT_MAX_TOKENS,
+ DEFAULT_MCP_TIMEOUT,
+ DEFAULT_MODEL,
+ DEFAULT_RAG_TOP_K,
+ DEFAULT_TEMPERATURE,
+ DEFAULT_VPN_TIMEOUT,
+ DOCKER_NETWORK_MODE,
+ DOCKER_SANDBOX_IMAGE,
+ EXIT_COMMANDS,
+ KNOWLEDGE_DATA_EXTENSIONS,
+ KNOWLEDGE_TEXT_EXTENSIONS,
+ MCP_TRANSPORT_SSE,
+ MCP_TRANSPORT_STDIO,
+ MEMORY_RESERVE_RATIO,
+ TOOL_CATEGORY_EXECUTION,
+ TOOL_CATEGORY_EXPLOITATION,
+ TOOL_CATEGORY_MCP,
+ TOOL_CATEGORY_NETWORK,
+ TOOL_CATEGORY_RECON,
+ TOOL_CATEGORY_WEB,
+)
+from .settings import Settings, get_settings
+
+__all__ = [
+ "Settings",
+ "get_settings",
+ "APP_NAME",
+ "APP_VERSION",
+ "APP_DESCRIPTION",
+ "AGENT_STATE_IDLE",
+ "AGENT_STATE_THINKING",
+ "AGENT_STATE_EXECUTING",
+ "AGENT_STATE_WAITING_INPUT",
+ "AGENT_STATE_COMPLETE",
+ "AGENT_STATE_ERROR",
+ "TOOL_CATEGORY_EXECUTION",
+ "TOOL_CATEGORY_WEB",
+ "TOOL_CATEGORY_NETWORK",
+ "TOOL_CATEGORY_RECON",
+ "TOOL_CATEGORY_EXPLOITATION",
+ "TOOL_CATEGORY_MCP",
+ "DEFAULT_COMMAND_TIMEOUT",
+ "DEFAULT_VPN_TIMEOUT",
+ "DEFAULT_MCP_TIMEOUT",
+ "DOCKER_SANDBOX_IMAGE",
+ "DOCKER_NETWORK_MODE",
+ "DEFAULT_CHUNK_SIZE",
+ "DEFAULT_CHUNK_OVERLAP",
+ "DEFAULT_RAG_TOP_K",
+ "MEMORY_RESERVE_RATIO",
+ "DEFAULT_MODEL",
+ "DEFAULT_TEMPERATURE",
+ "DEFAULT_MAX_TOKENS",
+ "KNOWLEDGE_TEXT_EXTENSIONS",
+ "KNOWLEDGE_DATA_EXTENSIONS",
+ "MCP_TRANSPORT_STDIO",
+ "MCP_TRANSPORT_SSE",
+ "EXIT_COMMANDS",
+]
diff --git a/ghostcrew/config/constants.py b/ghostcrew/config/constants.py
new file mode 100644
index 0000000..c777ed3
--- /dev/null
+++ b/ghostcrew/config/constants.py
@@ -0,0 +1,70 @@
+"""Constants for GhostCrew."""
+
+import os
+
+# Load .env file before reading environment variables
+try:
+ from dotenv import load_dotenv
+
+ load_dotenv()
+except ImportError:
+ pass
+
+# Application Info
+APP_NAME = "GhostCrew"
+APP_VERSION = "0.2.0"
+APP_DESCRIPTION = "AI penetration testing"
+
+# Agent States
+AGENT_STATE_IDLE = "idle"
+AGENT_STATE_THINKING = "thinking"
+AGENT_STATE_EXECUTING = "executing"
+AGENT_STATE_WAITING_INPUT = "waiting_input"
+AGENT_STATE_COMPLETE = "complete"
+AGENT_STATE_ERROR = "error"
+
+# Tool Categories
+TOOL_CATEGORY_EXECUTION = "execution"
+TOOL_CATEGORY_WEB = "web"
+TOOL_CATEGORY_NETWORK = "network"
+TOOL_CATEGORY_RECON = "reconnaissance"
+TOOL_CATEGORY_EXPLOITATION = "exploitation"
+TOOL_CATEGORY_MCP = "mcp"
+
+# Default Timeouts (in seconds)
+DEFAULT_COMMAND_TIMEOUT = 300
+DEFAULT_VPN_TIMEOUT = 30
+DEFAULT_MCP_TIMEOUT = 60
+
+# Docker Settings
+DOCKER_SANDBOX_IMAGE = "ghcr.io/gh05tcrew/ghostcrew:kali"
+DOCKER_NETWORK_MODE = "bridge"
+
+# RAG Settings
+DEFAULT_CHUNK_SIZE = 1000
+DEFAULT_CHUNK_OVERLAP = 200
+DEFAULT_RAG_TOP_K = 3
+
+# Memory Settings
+MEMORY_RESERVE_RATIO = 0.8 # Reserve 20% of context for response
+
+# LLM Defaults (set GHOSTCREW_MODEL in .env or shell)
+DEFAULT_MODEL = os.environ.get(
+ "GHOSTCREW_MODEL"
+) # No fallback - requires configuration
+DEFAULT_TEMPERATURE = 0.7
+DEFAULT_MAX_TOKENS = 4096
+
+# Agent Defaults
+DEFAULT_MAX_ITERATIONS = int(os.environ.get("GHOSTCREW_MAX_ITERATIONS", "50"))
+
+# File Extensions
+KNOWLEDGE_TEXT_EXTENSIONS = [".txt", ".md"]
+KNOWLEDGE_DATA_EXTENSIONS = [".json"]
+
+# MCP Transport Types
+MCP_TRANSPORT_STDIO = "stdio"
+MCP_TRANSPORT_SSE = "sse"
+
+# Exit Commands
+EXIT_COMMANDS = ["exit", "quit", "q", "bye"]
diff --git a/ghostcrew/config/settings.py b/ghostcrew/config/settings.py
new file mode 100644
index 0000000..40620a8
--- /dev/null
+++ b/ghostcrew/config/settings.py
@@ -0,0 +1,84 @@
+"""Application settings for GhostCrew."""
+
+import os
+from dataclasses import dataclass, field
+from pathlib import Path
+from typing import List, Optional
+
+from .constants import (
+ DEFAULT_MAX_ITERATIONS,
+ DEFAULT_MAX_TOKENS,
+ DEFAULT_MODEL,
+ DEFAULT_TEMPERATURE,
+)
+
+
+@dataclass
+class Settings:
+ """Application settings."""
+
+ # LLM Settings
+ model: str = field(default_factory=lambda: DEFAULT_MODEL)
+ temperature: float = DEFAULT_TEMPERATURE
+ max_tokens: int = DEFAULT_MAX_TOKENS
+ max_context_tokens: int = 128000
+
+ # API Keys (loaded from environment)
+ openai_api_key: Optional[str] = field(
+ default_factory=lambda: os.getenv("OPENAI_API_KEY")
+ )
+ anthropic_api_key: Optional[str] = field(
+ default_factory=lambda: os.getenv("ANTHROPIC_API_KEY")
+ )
+
+ # Paths
+ knowledge_path: Path = field(default_factory=lambda: Path("knowledge"))
+ mcp_config_path: Path = field(default_factory=lambda: Path("mcp.json"))
+
+ # Docker Settings
+ container_name: str = "ghostcrew-sandbox"
+ docker_image: str = "ghcr.io/gh05tcrew/ghostcrew:kali"
+
+ # Agent Settings
+ max_iterations: int = DEFAULT_MAX_ITERATIONS
+
+ # VPN Settings
+ vpn_config_path: Optional[Path] = None
+
+ # Interface Settings
+ default_interface: str = "tui" # "tui" or "cli"
+
+ # Prompt Modules
+ prompt_modules: List[str] = field(default_factory=list)
+
+ # Target Settings
+ target: Optional[str] = None
+ scope: List[str] = field(default_factory=list)
+
+ def __post_init__(self):
+ """Convert string paths to Path objects if needed."""
+ if isinstance(self.knowledge_path, str):
+ self.knowledge_path = Path(self.knowledge_path)
+ if isinstance(self.mcp_config_path, str):
+ self.mcp_config_path = Path(self.mcp_config_path)
+ if isinstance(self.vpn_config_path, str):
+ self.vpn_config_path = Path(self.vpn_config_path)
+
+
+# Global settings instance
+_settings: Optional[Settings] = None
+
+
+def get_settings() -> Settings:
+ """Get the global settings instance."""
+ global _settings
+ if _settings is None:
+ _settings = Settings()
+ return _settings
+
+
+def update_settings(**kwargs) -> Settings:
+ """Update global settings with new values."""
+ global _settings
+ _settings = Settings(**kwargs)
+ return _settings
diff --git a/ghostcrew/interface/__init__.py b/ghostcrew/interface/__init__.py
new file mode 100644
index 0000000..063d13a
--- /dev/null
+++ b/ghostcrew/interface/__init__.py
@@ -0,0 +1,16 @@
+"""User interface module for GhostCrew."""
+
+from .cli import run_cli
+from .main import main
+from .tui import GhostCrewTUI, run_tui
+from .utils import format_finding, print_banner, print_status
+
+__all__ = [
+ "main",
+ "run_cli",
+ "run_tui",
+ "GhostCrewTUI",
+ "print_banner",
+ "format_finding",
+ "print_status",
+]
diff --git a/ghostcrew/interface/assets/tui_styles.tcss b/ghostcrew/interface/assets/tui_styles.tcss
new file mode 100644
index 0000000..18a02ad
--- /dev/null
+++ b/ghostcrew/interface/assets/tui_styles.tcss
@@ -0,0 +1,442 @@
+/* GhostCrew TUI Styles */
+
+Screen {
+ background: #0a0a0a;
+ color: #d4d4d4;
+}
+
+/* Splash Screen */
+#splash_screen {
+ height: 100%;
+ width: 100%;
+ background: #0a0a0a;
+ content-align: center middle;
+}
+
+#splash_content {
+ width: auto;
+ height: auto;
+ background: transparent;
+ text-align: center;
+ padding: 2;
+}
+
+/* Main Layout */
+#main_container {
+ height: 100%;
+ padding: 0;
+ margin: 0;
+ background: #0a0a0a;
+}
+
+#content_container {
+ height: 1fr;
+ padding: 0;
+ background: transparent;
+}
+
+/* Sidebar */
+#sidebar {
+ width: 28%;
+ background: transparent;
+ margin-left: 1;
+}
+
+#tools_panel {
+ height: 1fr;
+ background: transparent;
+ border: round #262626;
+ border-title-color: #22c55e;
+ padding: 1;
+ margin-bottom: 1;
+}
+
+#stats_panel {
+ height: auto;
+ max-height: 12;
+ background: transparent;
+ border: round #262626;
+ border-title-color: #22c55e;
+ padding: 1;
+}
+
+/* Chat Area */
+#chat_area {
+ width: 72%;
+ background: transparent;
+}
+
+#chat_history {
+ height: 1fr;
+ background: transparent;
+ border: round #1a1a1a;
+ padding: 0 1;
+ margin-bottom: 0;
+ scrollbar-background: #0a0a0a;
+ scrollbar-color: #262626;
+ scrollbar-size: 1 1;
+}
+
+#chat_display {
+ width: 100%;
+ padding: 1;
+}
+
+/* Status Display */
+#status_display {
+ height: 1;
+ background: transparent;
+ margin: 0;
+ padding: 0 1;
+}
+
+#status_display.hidden {
+ display: none;
+}
+
+#status_text {
+ width: 1fr;
+ color: #22c55e;
+ text-style: italic;
+}
+
+#keymap_text {
+ width: auto;
+ color: #525252;
+}
+
+/* Input Area */
+#input_container {
+ height: 3;
+ background: transparent;
+ border: round #525252;
+ margin-right: 0;
+ padding: 0;
+ layout: horizontal;
+}
+
+#input_container:focus-within {
+ border: round #22c55e;
+}
+
+#input_prompt {
+ width: auto;
+ height: 100%;
+ padding: 0 0 0 1;
+ color: #525252;
+ content-align-vertical: middle;
+}
+
+#input_container:focus-within #input_prompt {
+ color: #22c55e;
+ text-style: bold;
+}
+
+#chat_input {
+ width: 1fr;
+ height: 100%;
+ background: #0a0a0a;
+ border: none;
+ color: #d4d4d4;
+ padding: 0;
+}
+
+/* Messages */
+.user-message {
+ color: #3b82f6;
+ margin-bottom: 1;
+}
+
+.agent-message {
+ color: #d4d4d4;
+ margin-bottom: 1;
+}
+
+.tool-call {
+ border-left: thick #f59e0b;
+ padding-left: 1;
+ margin: 1 0;
+ color: #a3a3a3;
+}
+
+.tool-call.completed {
+ border-left: thick #22c55e;
+}
+
+.tool-call.error {
+ border-left: thick #ef4444;
+}
+
+.finding {
+ border-left: thick #ef4444;
+ padding-left: 1;
+ margin: 1 0;
+}
+
+.info {
+ color: #22c55e;
+}
+
+.warning {
+ color: #f59e0b;
+}
+
+.error {
+ color: #ef4444;
+}
+
+/* Help Dialog */
+HelpScreen {
+ align: center middle;
+ background: #0a0a0a 80%;
+}
+
+#help_dialog {
+ width: 50;
+ height: auto;
+ padding: 2;
+ border: round #22c55e;
+ background: #1a1a1a;
+}
+
+#help_title {
+ color: #22c55e;
+ text-style: bold;
+ text-align: center;
+ margin-bottom: 1;
+}
+
+#help_content {
+ color: #d4d4d4;
+}
+
+/* Quit Dialog */
+QuitScreen {
+ align: center middle;
+ background: #0a0a0a 80%;
+}
+
+#quit_dialog {
+ width: 30;
+ height: auto;
+ padding: 2;
+ border: round #525252;
+ background: #1a1a1a;
+}
+
+#quit_title {
+ color: #d4d4d4;
+ text-style: bold;
+ text-align: center;
+ margin-bottom: 1;
+}
+
+#quit_buttons {
+ layout: horizontal;
+ height: 3;
+ align: center middle;
+}
+
+#quit_buttons Button {
+ margin: 0 1;
+}
+
+/* ===== CREW MODE LAYOUT ===== */
+
+#crew_container {
+ height: 100%;
+ width: 100%;
+ layout: horizontal;
+ background: #0a0a0a;
+}
+
+#crew_chat_area {
+ width: 75%;
+ height: 100%;
+ background: transparent;
+}
+
+#crew_sidebar {
+ width: 25%;
+ height: 100%;
+ background: transparent;
+ margin-left: 1;
+}
+
+/* Worker Tree */
+#workers_tree {
+ height: 1fr;
+ background: transparent;
+ border: round #262626;
+ border-title-color: #06b6d4;
+ padding: 1;
+ margin-bottom: 1;
+}
+
+#workers_tree:focus {
+ border: round #06b6d4;
+}
+
+Tree {
+ background: transparent;
+ color: #d4d4d4;
+ scrollbar-background: transparent;
+ scrollbar-color: #404040;
+ scrollbar-size: 1 1;
+}
+
+Tree > .tree--label {
+ text-style: bold;
+ color: #06b6d4;
+ background: transparent;
+}
+
+.tree--node {
+ height: 1;
+ padding: 0;
+ margin: 0;
+}
+
+.tree--node-label {
+ color: #d4d4d4;
+ background: transparent;
+ padding: 0 1;
+}
+
+.tree--node:hover .tree--node-label {
+ background: #1a1a1a;
+ color: #ffffff;
+}
+
+.tree--node.-selected .tree--node-label {
+ background: #1a1a1a;
+ color: #06b6d4;
+ text-style: bold;
+}
+
+/* Crew Stats Panel */
+#crew_stats {
+ height: auto;
+ max-height: 10;
+ background: transparent;
+ border: round #262626;
+ border-title-color: #06b6d4;
+ padding: 1;
+}
+
+/* Worker status colors */
+.worker-running {
+ color: #22c55e;
+}
+
+.worker-waiting {
+ color: #eab308;
+}
+
+.worker-complete {
+ color: #06b6d4;
+}
+
+.worker-error {
+ color: #ef4444;
+}
+
+/* Crew chat display */
+#crew_chat_history {
+ height: 1fr;
+ background: transparent;
+ border: round #1a1a1a;
+ padding: 0 1;
+ margin-bottom: 0;
+ scrollbar-background: #0a0a0a;
+ scrollbar-color: #262626;
+ scrollbar-size: 1 1;
+}
+
+#crew_chat_display {
+ width: 100%;
+ padding: 1;
+}
+
+/* Crew input */
+#crew_input_container {
+ height: 3;
+ background: transparent;
+ border: round #525252;
+ margin-right: 0;
+ padding: 0;
+ layout: horizontal;
+}
+
+#crew_input_container:focus-within {
+ border: round #06b6d4;
+}
+
+#crew_input_prompt {
+ width: auto;
+ height: 100%;
+ padding: 0 0 0 1;
+ color: #525252;
+ content-align-vertical: middle;
+}
+
+#crew_input_container:focus-within #crew_input_prompt {
+ color: #06b6d4;
+ text-style: bold;
+}
+
+#crew_chat_input {
+ width: 1fr;
+ height: 100%;
+ background: #0a0a0a;
+ border: none;
+ color: #d4d4d4;
+ padding: 0;
+}
+
+/* Crew status bar */
+#crew_status_display {
+ height: 1;
+ background: transparent;
+ margin: 0;
+ padding: 0 1;
+}
+
+#crew_status_text {
+ width: 1fr;
+ color: #06b6d4;
+ text-style: italic;
+}
+
+/* Orchestrator thinking message */
+.orchestrator-thinking {
+ border-left: thick #8b5cf6;
+ padding-left: 1;
+ margin: 1 0;
+ color: #c4b5fd;
+}
+
+/* Worker spawn message */
+.worker-spawn {
+ border-left: thick #06b6d4;
+ padding-left: 1;
+ margin: 1 0;
+ color: #67e8f9;
+}
+
+/* Worker result message */
+.worker-result {
+ border-left: thick #22c55e;
+ padding-left: 1;
+ margin: 1 0;
+ color: #86efac;
+}
+
+/* Worker activity (when viewing a specific worker) */
+.worker-activity {
+ border-left: thick #f59e0b;
+ padding-left: 1;
+ margin: 0 0 1 0;
+ color: #a3a3a3;
+}
+
diff --git a/ghostcrew/interface/cli.py b/ghostcrew/interface/cli.py
new file mode 100644
index 0000000..bd8cfd6
--- /dev/null
+++ b/ghostcrew/interface/cli.py
@@ -0,0 +1,508 @@
+"""Non-interactive CLI mode for GhostCrew."""
+
+import asyncio
+import time
+from datetime import datetime
+from pathlib import Path
+
+from rich.console import Console
+from rich.markdown import Markdown
+from rich.panel import Panel
+from rich.text import Text
+
+console = Console()
+
+# Ghost theme colors (matching TUI)
+GHOST_PRIMARY = "#d4d4d4" # light gray - primary text
+GHOST_SECONDARY = "#9a9a9a" # medium gray - secondary text
+GHOST_DIM = "#6b6b6b" # dim gray - muted text
+GHOST_BORDER = "#3a3a3a" # dark gray - borders
+GHOST_ACCENT = "#7a7a7a" # accent gray
+
+
+async def run_cli(
+ target: str,
+ model: str,
+ task: str = None,
+ report: str = None,
+ max_tools: int = 50,
+ use_docker: bool = False,
+):
+ """
+ Run GhostCrew in non-interactive mode.
+
+ Args:
+ target: Target to test
+ model: LLM model to use
+ task: Optional task description
+ report: Report path ("auto" for loot/_.md)
+ max_tools: Max tool calls before stopping
+ use_docker: Run tools in Docker container
+ """
+ from ..agents.ghostcrew_agent import GhostCrewAgent
+ from ..knowledge import RAGEngine
+ from ..llm import LLM
+ from ..runtime.docker_runtime import DockerRuntime
+ from ..runtime.runtime import LocalRuntime
+ from ..tools import get_all_tools
+
+ # Startup panel
+ start_text = Text()
+ start_text.append("GHOSTCREW", style=f"bold {GHOST_PRIMARY}")
+ start_text.append(" - Non-interactive Mode\n\n", style=GHOST_DIM)
+ start_text.append("Target: ", style=GHOST_SECONDARY)
+ start_text.append(f"{target}\n", style=GHOST_PRIMARY)
+ start_text.append("Model: ", style=GHOST_SECONDARY)
+ start_text.append(f"{model}\n", style=GHOST_PRIMARY)
+ start_text.append("Runtime: ", style=GHOST_SECONDARY)
+ start_text.append(f"{'Docker' if use_docker else 'Local'}\n", style=GHOST_PRIMARY)
+ start_text.append("Max calls: ", style=GHOST_SECONDARY)
+ start_text.append(f"{max_tools}\n", style=GHOST_PRIMARY)
+
+ task_msg = task or f"Perform a penetration test on {target}"
+ start_text.append("Task: ", style=GHOST_SECONDARY)
+ start_text.append(task_msg, style=GHOST_PRIMARY)
+
+ console.print()
+ console.print(
+ Panel(
+ start_text, title=f"[{GHOST_SECONDARY}]Starting", border_style=GHOST_BORDER
+ )
+ )
+ console.print()
+
+ # Initialize RAG if knowledge exists
+ rag = None
+ knowledge_path = Path("knowledge")
+ if knowledge_path.exists():
+ try:
+ rag = RAGEngine(knowledge_path=knowledge_path)
+ rag.index()
+ except Exception:
+ pass
+
+ # Initialize MCP if config exists (silently skip failures)
+ mcp_manager = None
+ mcp_count = 0
+ try:
+ from ..mcp import MCPManager
+ from ..tools import register_tool_instance
+
+ mcp_manager = MCPManager()
+ if mcp_manager.config_path.exists():
+ mcp_tools = await mcp_manager.connect_all()
+ for tool in mcp_tools:
+ register_tool_instance(tool)
+ mcp_count = len(mcp_tools)
+ if mcp_count > 0:
+ console.print(f"[{GHOST_DIM}]Loaded {mcp_count} MCP tools[/]")
+ except Exception:
+ pass # MCP is optional, continue without it
+
+ # Initialize runtime - Docker or Local
+ if use_docker:
+ console.print(f"[{GHOST_DIM}]Starting Docker container...[/]")
+ runtime = DockerRuntime(mcp_manager=mcp_manager)
+ else:
+ runtime = LocalRuntime(mcp_manager=mcp_manager)
+ await runtime.start()
+
+ llm = LLM(model=model, rag_engine=rag)
+ tools = get_all_tools()
+
+ agent = GhostCrewAgent(
+ llm=llm,
+ tools=tools,
+ runtime=runtime,
+ target=target,
+ rag_engine=rag,
+ )
+
+ # Stats tracking
+ start_time = time.time()
+ tool_count = 0
+ iteration = 0
+ findings = [] # Store findings for report
+ tool_log = [] # Log of tools executed (ts, name, command, result, exit_code)
+ last_content = ""
+ stopped_reason = None
+
+ def print_status(msg: str, style: str = GHOST_DIM):
+ elapsed = int(time.time() - start_time)
+ mins, secs = divmod(elapsed, 60)
+ timestamp = f"[{mins:02d}:{secs:02d}]"
+ console.print(f"[{GHOST_DIM}]{timestamp}[/] [{style}]{msg}[/]")
+
+ def generate_report() -> str:
+ """Generate markdown report."""
+ elapsed = int(time.time() - start_time)
+ mins, secs = divmod(elapsed, 60)
+
+ status_text = "Complete"
+ if stopped_reason:
+ status_text = f"Interrupted ({stopped_reason})"
+
+ lines = [
+ "# GhostCrew Penetration Test Report",
+ "",
+ "## Executive Summary",
+ "",
+ ]
+
+ # Add AI summary at top if available
+ if findings:
+ lines.append(findings[-1])
+ lines.append("")
+ else:
+ lines.append("*Assessment incomplete - no analysis generated.*")
+ lines.append("")
+
+ # Engagement details table
+ lines.extend(
+ [
+ "## Engagement Details",
+ "",
+ "| Field | Value |",
+ "|-------|-------|",
+ f"| **Target** | `{target}` |",
+ f"| **Task** | {task_msg} |",
+ f"| **Date** | {datetime.now().strftime('%Y-%m-%d %H:%M:%S')} |",
+ f"| **Duration** | {mins}m {secs}s |",
+ f"| **Commands Executed** | {tool_count} |",
+ f"| **Status** | {status_text} |",
+ "",
+ "---",
+ "",
+ "## Commands Executed",
+ "",
+ ]
+ )
+
+ # Detailed command log
+ for i, entry in enumerate(tool_log, 1):
+ ts = entry.get("ts", "??:??")
+ name = entry.get("name", "unknown")
+ command = entry.get("command", "")
+ result = entry.get("result", "")
+ exit_code = entry.get("exit_code")
+
+ lines.append(f"### {i}. {name} `[{ts}]`")
+ lines.append("")
+
+ if command:
+ lines.append("**Command:**")
+ lines.append("```")
+ lines.append(command)
+ lines.append("```")
+ lines.append("")
+
+ if exit_code is not None:
+ lines.append(f"**Exit Code:** `{exit_code}`")
+ lines.append("")
+
+ if result:
+ lines.append("**Output:**")
+ lines.append("```")
+ # Limit output to 2000 chars per command for report size
+ if len(result) > 2000:
+ lines.append(result[:2000])
+ lines.append(f"\n... (truncated, {len(result)} total chars)")
+ else:
+ lines.append(result)
+ lines.append("```")
+ lines.append("")
+
+ # Findings section
+ lines.extend(
+ [
+ "---",
+ "",
+ "## Analysis",
+ "",
+ ]
+ )
+
+ if findings:
+ for i, finding in enumerate(findings, 1):
+ if len(findings) > 1:
+ lines.append(f"### Analysis {i}")
+ lines.append("")
+ lines.append(finding)
+ lines.append("")
+ else:
+ lines.append(
+ "*No AI analysis generated. Try running with higher `--max` value.*"
+ )
+ lines.append("")
+
+ # Footer
+ lines.extend(
+ [
+ "---",
+ "",
+ f"*Report generated by GhostCrew on {datetime.now().strftime('%Y-%m-%d %H:%M:%S')}*",
+ ]
+ )
+
+ return "\n".join(lines)
+
+ def save_report():
+ """Save report to file."""
+ if not report:
+ return
+
+ # Determine path
+ if report == "auto":
+ loot_dir = Path("loot")
+ loot_dir.mkdir(exist_ok=True)
+ safe_target = target.replace("://", "_").replace("/", "_").replace(":", "_")
+ timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
+ report_path = loot_dir / f"{safe_target}_{timestamp}.md"
+ else:
+ report_path = Path(report)
+ report_path.parent.mkdir(parents=True, exist_ok=True)
+
+ content = generate_report()
+ report_path.write_text(content, encoding="utf-8")
+ console.print(f"[{GHOST_SECONDARY}]Report saved: {report_path}[/]")
+
+ async def generate_summary():
+ """Ask the LLM to summarize findings when stopped early."""
+ if not tool_log:
+ return None
+
+ print_status("Generating summary...", GHOST_SECONDARY)
+
+ # Build context from tool results (use full results, not truncated)
+ context_lines = ["Summarize the penetration test findings so far:\n"]
+ context_lines.append(f"Target: {target}")
+ context_lines.append(f"Tools executed: {tool_count}\n")
+
+ for entry in tool_log[-10:]: # Last 10 tools
+ name = entry.get("name", "unknown")
+ command = entry.get("command", "")
+ result = entry.get("result", "")[:500] # Limit for context window
+ context_lines.append(f"- **{name}**: `{command}`")
+ if result:
+ context_lines.append(f" Output: {result}")
+
+ context_lines.append(
+ "\nProvide a brief summary of what was discovered and any security concerns found."
+ )
+
+ try:
+ response = await llm.generate(
+ system_prompt="You are a penetration testing assistant. Summarize the findings concisely.",
+ messages=[{"role": "user", "content": "\n".join(context_lines)}],
+ tools=[],
+ )
+ return response.content
+ except Exception:
+ return None
+
+ async def print_summary(interrupted: bool = False):
+ nonlocal findings
+
+ # Generate summary if we don't have findings yet
+ if not findings and tool_log:
+ summary = await generate_summary()
+ if summary:
+ findings.append(summary)
+
+ elapsed = int(time.time() - start_time)
+ mins, secs = divmod(elapsed, 60)
+
+ title = "Interrupted" if interrupted else "Finished"
+ status = "PARTIAL RESULTS" if interrupted else "COMPLETE"
+ if stopped_reason:
+ status = f"STOPPED ({stopped_reason})"
+
+ final_text = Text()
+ final_text.append(f"{status}\n\n", style=f"bold {GHOST_PRIMARY}")
+ final_text.append("Duration: ", style=GHOST_DIM)
+ final_text.append(f"{mins}m {secs}s\n", style=GHOST_SECONDARY)
+ final_text.append("Iterations: ", style=GHOST_DIM)
+ final_text.append(f"{iteration}\n", style=GHOST_SECONDARY)
+ final_text.append("Tools: ", style=GHOST_DIM)
+ final_text.append(f"{tool_count}/{max_tools}\n", style=GHOST_SECONDARY)
+
+ if findings:
+ final_text.append("Findings: ", style=GHOST_DIM)
+ final_text.append(f"{len(findings)}", style=GHOST_SECONDARY)
+
+ console.print()
+ console.print(
+ Panel(
+ final_text,
+ title=f"[{GHOST_SECONDARY}]{title}",
+ border_style=GHOST_BORDER,
+ )
+ )
+
+ # Show summary/findings
+ if findings:
+ console.print()
+ console.print(
+ Panel(
+ Markdown(findings[-1]),
+ title=f"[{GHOST_PRIMARY}]Summary",
+ border_style=GHOST_BORDER,
+ )
+ )
+
+ # Save report
+ save_report()
+
+ print_status("Initializing agent...")
+
+ try:
+ async for response in agent.agent_loop(task_msg):
+ iteration += 1
+
+ # Show tool calls and results as they happen
+ if response.tool_calls:
+ for i, call in enumerate(response.tool_calls):
+ tool_count += 1
+ name = getattr(call, "name", None) or getattr(
+ call.function, "name", "tool"
+ )
+
+ elapsed = int(time.time() - start_time)
+ mins, secs = divmod(elapsed, 60)
+ ts = f"{mins:02d}:{secs:02d}"
+
+ # Get result if available
+ if response.tool_results and i < len(response.tool_results):
+ tr = response.tool_results[i]
+ result_text = tr.result or tr.error or ""
+ if result_text:
+ # Truncate for display
+ preview = result_text[:200].replace("\n", " ")
+ if len(result_text) > 200:
+ preview += "..."
+
+ # Parse args for command extraction
+ command_text = ""
+ exit_code = None
+ try:
+ args = getattr(call, "arguments", None) or getattr(
+ call.function, "arguments", "{}"
+ )
+ if isinstance(args, str):
+ import json
+
+ args = json.loads(args)
+ if isinstance(args, dict):
+ command_text = args.get("command", "")
+ except Exception:
+ pass
+
+ # Extract exit code from result
+ if response.tool_results and i < len(response.tool_results):
+ tr = response.tool_results[i]
+ full_result = tr.result or tr.error or ""
+ # Try to parse exit code
+ if "Exit Code:" in full_result:
+ try:
+ import re
+
+ match = re.search(r"Exit Code:\s*(\d+)", full_result)
+ if match:
+ exit_code = int(match.group(1))
+ except Exception:
+ pass
+ else:
+ full_result = ""
+
+ # Store full data for report (not truncated)
+ tool_log.append(
+ {
+ "ts": ts,
+ "name": name,
+ "command": command_text,
+ "result": full_result,
+ "exit_code": exit_code,
+ }
+ )
+
+ # Metasploit-style output with better spacing
+ console.print() # Blank line before each tool
+ print_status(f"$ {name} ({tool_count}/{max_tools})", GHOST_ACCENT)
+
+ # Show command/args on separate indented line (truncated for display)
+ if command_text:
+ display_cmd = command_text[:80]
+ if len(command_text) > 80:
+ display_cmd += "..."
+ console.print(f" [{GHOST_DIM}]{display_cmd}[/]")
+
+ # Show result on separate line with status indicator
+ if response.tool_results and i < len(response.tool_results):
+ tr = response.tool_results[i]
+ if tr.error:
+ console.print(
+ f" [{GHOST_DIM}][!] {tr.error[:100]}[/]"
+ )
+ elif tr.result:
+ # Show exit code or brief result
+ result_line = tr.result[:100].replace("\n", " ")
+ if exit_code == 0 or "success" in result_line.lower():
+ console.print(f" [{GHOST_DIM}][+] OK[/]")
+ elif exit_code is not None and exit_code != 0:
+ console.print(
+ f" [{GHOST_DIM}][-] Exit {exit_code}[/]"
+ )
+ else:
+ console.print(
+ f" [{GHOST_DIM}][*] {result_line[:60]}...[/]"
+ )
+
+ # Check max tools limit
+ if tool_count >= max_tools:
+ stopped_reason = "max calls reached"
+ console.print()
+ print_status(f"Max calls limit reached ({max_tools})", "yellow")
+ raise StopIteration()
+
+ # Print assistant content immediately (analysis/findings)
+ if response.content and response.content != last_content:
+ last_content = response.content
+ findings.append(response.content)
+
+ console.print()
+ console.print(
+ Panel(
+ Markdown(response.content),
+ title=f"[{GHOST_PRIMARY}]GhostCrew",
+ border_style=GHOST_BORDER,
+ )
+ )
+ console.print()
+
+ await print_summary(interrupted=False)
+
+ except StopIteration:
+ await print_summary(interrupted=True)
+ except (KeyboardInterrupt, asyncio.CancelledError):
+ stopped_reason = "user interrupt"
+ await print_summary(interrupted=True)
+ except Exception as e:
+ console.print(f"\n[red]Error: {e}[/]")
+ stopped_reason = f"error: {e}"
+ await print_summary(interrupted=True)
+
+ finally:
+ # Cleanup MCP connections first
+ if mcp_manager:
+ try:
+ await mcp_manager.disconnect_all()
+ await asyncio.sleep(0.1) # Allow transports to close cleanly
+ except Exception:
+ pass
+
+ # Then stop runtime
+ if runtime:
+ try:
+ await runtime.stop()
+ except Exception:
+ pass
diff --git a/ghostcrew/interface/main.py b/ghostcrew/interface/main.py
new file mode 100644
index 0000000..4c107df
--- /dev/null
+++ b/ghostcrew/interface/main.py
@@ -0,0 +1,286 @@
+"""Main entry point for GhostCrew."""
+
+import argparse
+import asyncio
+
+from ..config.constants import DEFAULT_MODEL
+from .cli import run_cli
+from .tui import run_tui
+
+
+def parse_arguments() -> argparse.Namespace:
+ """Parse command line arguments."""
+ parser = argparse.ArgumentParser(
+ description="GhostCrew - AI Penetration Testing",
+ formatter_class=argparse.RawDescriptionHelpFormatter,
+ epilog="""
+Examples:
+ ghostcrew Launch TUI
+ ghostcrew -t 192.168.1.1 Launch TUI with target
+ ghostcrew -n -t example.com Non-interactive run
+ ghostcrew tools list List available tools
+ ghostcrew mcp list List MCP servers
+ """,
+ )
+
+ # Subcommands
+ subparsers = parser.add_subparsers(dest="command", help="Commands")
+
+ # Tools subcommand
+ tools_parser = subparsers.add_parser("tools", help="Manage tools")
+ tools_subparsers = tools_parser.add_subparsers(
+ dest="tools_command", help="Tool commands"
+ )
+
+ # tools list
+ tools_subparsers.add_parser("list", help="List all available tools")
+
+ # tools info
+ tools_info = tools_subparsers.add_parser("info", help="Show tool details")
+ tools_info.add_argument("name", help="Tool name")
+
+ # MCP subcommand
+ mcp_parser = subparsers.add_parser("mcp", help="Manage MCP servers")
+ mcp_subparsers = mcp_parser.add_subparsers(dest="mcp_command", help="MCP commands")
+
+ # mcp list
+ mcp_subparsers.add_parser("list", help="List configured MCP servers")
+
+ # mcp add
+ mcp_add = mcp_subparsers.add_parser("add", help="Add an MCP server")
+ mcp_add.add_argument("name", help="Server name")
+ mcp_add.add_argument("command", help="Command to run (e.g., npx)")
+ mcp_add.add_argument("args", nargs="*", help="Command arguments")
+ mcp_add.add_argument("--description", "-d", default="", help="Server description")
+
+ # mcp remove
+ mcp_remove = mcp_subparsers.add_parser("remove", help="Remove an MCP server")
+ mcp_remove.add_argument("name", help="Server name to remove")
+
+ # mcp test
+ mcp_test = mcp_subparsers.add_parser("test", help="Test MCP server connection")
+ mcp_test.add_argument("name", help="Server name to test")
+
+ # Target option
+ parser.add_argument("--target", "-t", help="Target (IP, hostname, or URL)")
+
+ # Non-interactive mode
+ parser.add_argument(
+ "-n",
+ "--headless",
+ action="store_true",
+ help="Run without TUI (requires --target)",
+ )
+
+ # Task for non-interactive mode
+ parser.add_argument("--task", help="Task to run in non-interactive mode")
+
+ # Report output (saves to loot/ by default)
+ parser.add_argument(
+ "--report",
+ "-r",
+ nargs="?",
+ const="auto",
+ help="Generate report (default: loot/_.md)",
+ )
+
+ # Max tool calls limit
+ parser.add_argument(
+ "--max", type=int, default=50, help="Max calls before stopping (default: 50)"
+ )
+
+ # Model options
+ parser.add_argument(
+ "--model",
+ "-m",
+ default=DEFAULT_MODEL,
+ help="LLM model (set GHOSTCREW_MODEL in .env)",
+ )
+
+ # Docker mode
+ parser.add_argument(
+ "--docker",
+ "-d",
+ action="store_true",
+ help="Run tools inside Docker container (requires Docker)",
+ )
+
+ # Version
+ parser.add_argument("--version", action="version", version="GhostCrew 0.2.0")
+
+ return parser.parse_args()
+
+
+def handle_tools_command(args: argparse.Namespace):
+ """Handle tools subcommand."""
+ from rich.console import Console
+ from rich.table import Table
+
+ from ..tools import get_all_tools, get_tool
+
+ console = Console()
+
+ if args.tools_command == "list":
+ tools = get_all_tools()
+
+ if not tools:
+ console.print("[yellow]No tools found[/]")
+ return
+
+ table = Table(title="Available Tools")
+ table.add_column("Name", style="cyan")
+ table.add_column("Category", style="green")
+ table.add_column("Description")
+
+ for tool in sorted(tools, key=lambda t: t.name):
+ desc = (
+ tool.description[:50] + "..."
+ if len(tool.description) > 50
+ else tool.description
+ )
+ table.add_row(tool.name, tool.category, desc)
+
+ console.print(table)
+ console.print(f"\nTotal: {len(tools)} tools")
+
+ elif args.tools_command == "info":
+ tool = get_tool(args.name)
+ if not tool:
+ console.print(f"[red]Tool not found: {args.name}[/]")
+ return
+
+ console.print(f"\n[bold cyan]{tool.name}[/]")
+ console.print(f"[dim]Category:[/] {tool.category}")
+ console.print(f"\n{tool.description}")
+
+ if tool.schema.properties:
+ console.print("\n[bold]Parameters:[/]")
+ for name, props in tool.schema.properties.items():
+ required = (
+ "required" if name in (tool.schema.required or []) else "optional"
+ )
+ ptype = props.get("type", "any")
+ desc = props.get("description", "")
+ console.print(f" [cyan]{name}[/] ({ptype}, {required}): {desc}")
+
+ else:
+ console.print("[yellow]Use 'ghostcrew tools --help' for commands[/]")
+
+
+def handle_mcp_command(args: argparse.Namespace):
+ """Handle MCP subcommand."""
+ from rich.console import Console
+ from rich.table import Table
+
+ from ..mcp.manager import MCPManager
+
+ console = Console()
+ manager = MCPManager()
+
+ if args.mcp_command == "list":
+ servers = manager.list_configured_servers()
+
+ if not servers:
+ console.print("[yellow]No MCP servers configured[/]")
+ console.print(
+ "\nAdd a server with: ghostcrew mcp add "
+ )
+ return
+
+ table = Table(title="Configured MCP Servers")
+ table.add_column("Name", style="cyan")
+ table.add_column("Command", style="green")
+ table.add_column("Args")
+ table.add_column("Connected", style="yellow")
+
+ for server in servers:
+ args_str = " ".join(server["args"][:3])
+ if len(server["args"]) > 3:
+ args_str += "..."
+ connected = "+" if server.get("connected") else "-"
+ table.add_row(server["name"], server["command"], args_str, connected)
+
+ console.print(table)
+ console.print(f"\nConfig file: {manager.config_path}")
+
+ elif args.mcp_command == "add":
+ manager.add_server(
+ name=args.name,
+ command=args.command,
+ args=args.args or [],
+ description=args.description,
+ )
+ console.print(f"[green]Added MCP server: {args.name}[/]")
+ console.print(f" Command: {args.command} {' '.join(args.args or [])}")
+
+ elif args.mcp_command == "remove":
+ if manager.remove_server(args.name):
+ console.print(f"[yellow]Removed MCP server: {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")
+
+ async def test_server():
+ server = await manager.connect_server(args.name)
+ if server and server.connected:
+ console.print("[green]+ Connected successfully![/]")
+ console.print(f"\n[bold]Available tools ({len(server.tools)}):[/]")
+ for tool in server.tools:
+ desc = tool.get("description", "No description")[:60]
+ console.print(f" [cyan]{tool['name']}[/]: {desc}")
+ await manager.disconnect_all()
+ else:
+ console.print("[red]x Failed to connect[/]")
+
+ asyncio.run(test_server())
+
+ else:
+ console.print("[yellow]Use 'ghostcrew mcp --help' for available commands[/]")
+
+
+def main():
+ """Main entry point."""
+ args = parse_arguments()
+
+ # Handle subcommands
+ if args.command == "tools":
+ handle_tools_command(args)
+ return
+
+ if args.command == "mcp":
+ handle_mcp_command(args)
+ return
+
+ # Check model configuration
+ if not args.model:
+ print("Error: No model configured.")
+ print("Set GHOSTCREW_MODEL in .env file or use --model flag.")
+ print(
+ "Example: GHOSTCREW_MODEL=gpt-5 or GHOSTCREW_MODEL=claude-sonnet-4-20250514"
+ )
+ return
+
+ # Determine interface mode
+ if args.headless:
+ if not args.target:
+ print("Error: --target is required for headless mode")
+ return
+ asyncio.run(
+ run_cli(
+ target=args.target,
+ model=args.model,
+ task=args.task,
+ report=args.report,
+ max_tools=args.max,
+ use_docker=args.docker,
+ )
+ )
+ else:
+ # TUI doesn't need asyncio.run - it runs its own event loop
+ run_tui(target=args.target, model=args.model, use_docker=args.docker)
+
+
+if __name__ == "__main__":
+ main()
diff --git a/ghostcrew/interface/tui.py b/ghostcrew/interface/tui.py
new file mode 100644
index 0000000..1cc3978
--- /dev/null
+++ b/ghostcrew/interface/tui.py
@@ -0,0 +1,1819 @@
+"""
+GhostCrew TUI - Terminal User Interface
+"""
+
+import asyncio
+import textwrap
+from datetime import datetime
+from pathlib import Path
+from typing import TYPE_CHECKING, Any, Dict, List, Optional
+
+from rich.text import Text
+from textual import on, work
+from textual.app import App, ComposeResult
+from textual.binding import Binding
+from textual.containers import (
+ Center,
+ Container,
+ Horizontal,
+ ScrollableContainer,
+ Vertical,
+)
+from textual.reactive import reactive
+from textual.screen import ModalScreen, Screen
+from textual.scrollbar import ScrollBar, ScrollBarRender
+from textual.timer import Timer
+from textual.widgets import Button, Input, Static, Tree
+from textual.widgets.tree import TreeNode
+
+from ..config.constants import DEFAULT_MODEL
+
+
+# ASCII-safe scrollbar renderer to avoid Unicode glyph issues
+class ASCIIScrollBarRender(ScrollBarRender):
+ """Scrollbar renderer using ASCII-safe characters."""
+
+ BLANK_GLYPH = " "
+ VERTICAL_BARS = [" ", " ", " ", " ", " ", " ", " ", " "]
+ HORIZONTAL_BARS = [" ", " ", " ", " ", " ", " ", " ", " "]
+
+
+# Apply ASCII scrollbar globally
+ScrollBar.renderer = ASCIIScrollBarRender
+
+
+# Custom Tree with ASCII-safe icons for PowerShell compatibility
+class CrewTree(Tree):
+ """Tree widget with ASCII-compatible expand/collapse icons."""
+
+ ICON_NODE = "> "
+ ICON_NODE_EXPANDED = "v "
+
+
+if TYPE_CHECKING:
+ from ..agents.ghostcrew_agent import GhostCrewAgent
+
+
+def wrap_text_lines(text: str, width: int = 80) -> List[str]:
+ """
+ Wrap text content preserving line breaks and wrapping long lines.
+
+ Args:
+ text: The text to wrap
+ width: Maximum width per line (default 80 for safe terminal fit)
+
+ Returns:
+ List of wrapped lines
+ """
+ result = []
+ for line in text.split("\n"):
+ if len(line) <= width:
+ result.append(line)
+ else:
+ # Wrap long lines
+ wrapped = textwrap.wrap(
+ line, width=width, break_long_words=False, break_on_hyphens=False
+ )
+ result.extend(wrapped if wrapped else [""])
+ return result
+ return result
+
+
+# ASCII Art from utils.py
+ASCII_BANNER = r"""
+ ('-. .-. .-') .-') _ _ .-') ('-. (`\ .-') /`
+ ( OO ) / ( OO ). ( OO) ) ( \( -O ) _( OO) `.( OO ),'
+ ,----. ,--. ,--. .-'),-----. (_)---\_)/ '._ .-----. ,------. (,------.,--./ .--.
+ ' .-./-') | | | |( OO' .-. '/ _ | |'--...__)' .--./ | /`. ' | .---'| | |
+ | |_( O- )| .| |/ | | | |\ :` `. '--. .--'| |('-. | / | | | | | | | |,
+ | | .--, \| |\_) | |\| | '..`''.) | | /_) |OO )| |_.' |(| '--. | |.'.| |_)
+(| | '. (_/| .-. | \ | | | |.-._) \ | | || |`-'| | . '.' | .--' | |
+ | '--' | | | | | `' '-' '\ / | | (_' '--'\ | |\ \ | `---.| ,'. |
+ `------' `--' `--' `-----' `-----' `--' `-----' `--' '--' `------''--' '--'
+"""
+
+
+# ----- Splash Screen -----
+
+
+class SplashScreen(Screen):
+ """Animated splash screen with GhostCrew branding"""
+
+ BINDINGS = [
+ Binding("enter", "dismiss", "Continue"),
+ Binding("escape", "dismiss", "Skip"),
+ ]
+
+ CSS = """
+ SplashScreen {
+ background: #0a0a0a;
+ }
+
+ #splash-container {
+ width: 100%;
+ height: 100%;
+ align: center middle;
+ }
+
+ #splash-inner {
+ width: 96;
+ height: auto;
+ align: center middle;
+ }
+
+ #splash-logo {
+ width: 96;
+ color: #d4d4d4;
+ text-style: bold;
+ }
+
+ #splash-tagline {
+ width: 96;
+ text-align: center;
+ margin-top: 1;
+ color: #6b6b6b;
+ }
+
+ #splash-prompt {
+ width: 96;
+ text-align: center;
+ margin-top: 2;
+ color: #525252;
+ }
+ """
+
+ def compose(self) -> ComposeResult:
+ yield Container(
+ Container(
+ Static(ASCII_BANNER, id="splash-logo"),
+ Static("AI Penetration Testing Agents v0.2.0", id="splash-tagline"),
+ Static("Press ENTER to continue...", id="splash-prompt"),
+ id="splash-inner",
+ ),
+ id="splash-container",
+ )
+
+ def action_dismiss(self) -> None:
+ self.app.pop_screen()
+
+
+# ----- Help Screen -----
+
+
+class HelpScreen(ModalScreen):
+ """Help modal"""
+
+ BINDINGS = [
+ Binding("escape", "dismiss", "Close"),
+ Binding("q", "dismiss", "Close"),
+ ]
+
+ CSS = """
+ HelpScreen {
+ align: center middle;
+ scrollbar-background: #1a1a1a;
+ scrollbar-background-hover: #1a1a1a;
+ scrollbar-background-active: #1a1a1a;
+ scrollbar-color: #3a3a3a;
+ scrollbar-color-hover: #3a3a3a;
+ scrollbar-color-active: #3a3a3a;
+ scrollbar-corner-color: #1a1a1a;
+ scrollbar-size: 1 1;
+ }
+
+ #help-container {
+ width: 60;
+ height: 23;
+ background: #121212;
+ border: solid #3a3a3a;
+ padding: 1 2;
+ }
+
+ #help-title {
+ text-align: center;
+ text-style: bold;
+ color: #d4d4d4;
+ margin-bottom: 1;
+ }
+
+ #help-content {
+ color: #9a9a9a;
+ }
+
+ #help-close {
+ margin-top: 1;
+ width: auto;
+ min-width: 10;
+ background: #1a1a1a;
+ color: #9a9a9a;
+ border: none;
+ }
+
+ #help-close:hover {
+ background: #262626;
+ }
+
+ #help-close:focus {
+ background: #262626;
+ text-style: none;
+ }
+ """
+
+ def compose(self) -> ComposeResult:
+ yield Container(
+ Static("GhostCrew Help", id="help-title"),
+ Static(self._get_help_text(), id="help-content"),
+ Center(Button("Close", id="help-close")),
+ id="help-container",
+ )
+
+ def _get_help_text(self) -> str:
+ return """[bold]Modes:[/] Assist | Agent | Crew
+[bold]Keys:[/] Enter=Send Ctrl+C=Stop Ctrl+Q=Quit F1=Help
+
+[bold]Commands:[/]
+ /agent - Run in agent mode
+ /crew - Run multi-agent crew mode
+ /target - Set target
+ /prompt - Show system prompt
+ /memory - Show memory stats
+ /notes - Show saved notes
+ /report - Generate report
+ /help - Show help
+ /clear - Clear chat
+ /tools - List tools
+ /quit - Exit"""
+
+ def action_dismiss(self) -> None:
+ self.app.pop_screen()
+
+ @on(Button.Pressed, "#help-close")
+ def close_help(self) -> None:
+ self.app.pop_screen()
+
+
+# ----- Main Chat Message Widgets -----
+
+
+class ThinkingMessage(Static):
+ """Thinking/reasoning message"""
+
+ def __init__(self, content: str, **kwargs):
+ super().__init__(**kwargs)
+ self.thinking_content = content
+
+ def render(self) -> Text:
+ text = Text()
+ text.append("| ", style="#3a3a3a")
+ text.append("* ", style="#9a9a9a")
+ text.append("Thinking\n", style="bold #9a9a9a")
+
+ # Wrap content - use 70 chars to account for sidebar + prefix
+ for line in wrap_text_lines(self.thinking_content, width=70):
+ text.append("| ", style="#3a3a3a")
+ text.append(f"{line}\n", style="#6b6b6b italic")
+
+ return text
+
+
+class ToolMessage(Static):
+ """Tool execution message"""
+
+ # Standard tool icon and color (ghost theme)
+ TOOL_ICON = "$"
+ TOOL_COLOR = "#9a9a9a" # spirit gray
+
+ def __init__(self, tool_name: str, args: str = "", **kwargs):
+ super().__init__(**kwargs)
+ self.tool_name = tool_name
+ self.tool_args = args
+
+ def render(self) -> Text:
+ text = Text()
+ text.append("| ", style="#3a3a3a")
+ text.append(f"{self.TOOL_ICON} ", style=self.TOOL_COLOR)
+ text.append(f"{self.tool_name}", style=self.TOOL_COLOR)
+ text.append("\n", style="")
+
+ # Wrap args and show each line with vertical bar
+ if self.tool_args:
+ for line in wrap_text_lines(self.tool_args, width=100):
+ text.append("| ", style="#3a3a3a")
+ text.append(f"{line}\n", style="#6b6b6b")
+
+ return text
+
+
+class ToolResultMessage(Static):
+ """Tool result/output message"""
+
+ RESULT_ICON = "#"
+ RESULT_COLOR = "#7a7a7a"
+
+ def __init__(self, tool_name: str, result: str = "", **kwargs):
+ super().__init__(**kwargs)
+ self.tool_name = tool_name
+ self.result = result
+
+ def render(self) -> Text:
+ text = Text()
+ text.append("| ", style="#3a3a3a")
+ text.append(f"{self.RESULT_ICON} ", style=self.RESULT_COLOR)
+ text.append(f"{self.tool_name} output", style=self.RESULT_COLOR)
+ text.append("\n", style="")
+
+ if self.result:
+ for line in wrap_text_lines(self.result, width=100):
+ text.append("| ", style="#3a3a3a")
+ text.append(f"{line}\n", style="#5a5a5a")
+
+ return text
+
+
+class AssistantMessage(Static):
+ """Assistant response message"""
+
+ def __init__(self, content: str, **kwargs):
+ super().__init__(**kwargs)
+ self.message_content = content
+
+ def render(self) -> Text:
+ text = Text()
+ text.append("| ", style="#525252")
+ text.append(">> ", style="#9a9a9a")
+ text.append("Ghost\n", style="bold #d4d4d4")
+
+ # Wrap content - use 70 chars to account for sidebar + prefix
+ for line in wrap_text_lines(self.message_content, width=70):
+ text.append("| ", style="#525252")
+ text.append(f"{line}\n", style="#d4d4d4")
+
+ return text
+
+
+class UserMessage(Static):
+ """User message"""
+
+ def __init__(self, content: str, **kwargs):
+ super().__init__(**kwargs)
+ self.message_content = content
+
+ def render(self) -> Text:
+ text = Text()
+ text.append("| ", style="#6b6b6b") # phantom border
+ text.append("> ", style="#9a9a9a")
+ text.append("You\n", style="bold #d4d4d4") # specter
+ text.append("| ", style="#6b6b6b") # phantom border
+ text.append(f"{self.message_content}\n", style="#d4d4d4") # specter
+ return text
+
+
+class SystemMessage(Static):
+ """System message"""
+
+ def __init__(self, content: str, **kwargs):
+ super().__init__(**kwargs)
+ self.message_content = content
+
+ def render(self) -> Text:
+ text = Text()
+ for line in self.message_content.split("\n"):
+ text.append(f" {line}\n", style="#6b6b6b") # phantom - subtle system text
+ return text
+
+
+# ----- Status Bar -----
+
+
+class StatusBar(Static):
+ """Animated status bar"""
+
+ status = reactive("idle")
+ mode = reactive("assist") # "assist" or "agent"
+
+ def __init__(self, **kwargs):
+ super().__init__(**kwargs)
+ self._frame = 0
+ self._timer: Optional[Timer] = None
+
+ def on_mount(self) -> None:
+ self._timer = self.set_interval(0.2, self._tick)
+
+ def _tick(self) -> None:
+ self._frame = (self._frame + 1) % 4
+ if self.status not in ["idle", "complete"]:
+ self.refresh()
+
+ def render(self) -> Text:
+ dots = "." * (self._frame + 1)
+
+ # Use fixed-width labels (pad dots to 4 chars so text doesn't jump)
+ dots_padded = dots.ljust(4)
+
+ # Ghost theme status colors (muted, ethereal)
+ status_map = {
+ "idle": ("Ready", "#6b6b6b"),
+ "initializing": (f"Initializing{dots_padded}", "#9a9a9a"),
+ "thinking": (f"Thinking{dots_padded}", "#9a9a9a"),
+ "running": (f"Running{dots_padded}", "#9a9a9a"),
+ "processing": (f"Processing{dots_padded}", "#9a9a9a"),
+ "waiting": ("Waiting for input", "#9a9a9a"),
+ "complete": ("Complete", "#4a9f6e"),
+ "error": ("Error", "#9f4a4a"),
+ }
+
+ label, color = status_map.get(self.status, (self.status, "#6b6b6b"))
+
+ text = Text()
+
+ # Show mode (ASCII-safe symbols)
+ if self.mode == "crew":
+ text.append(" :: Crew ", style="#9a9a9a")
+ elif self.mode == "agent":
+ text.append(" >> Agent ", style="#9a9a9a")
+ else:
+ text.append(" >> Assist ", style="#9a9a9a")
+
+ text.append(f"| {label}", style=color)
+
+ if self.status not in ["idle", "initializing", "complete", "error"]:
+ text.append(" ESC to stop", style="#525252")
+
+ return text
+
+
+# ----- Main TUI App -----
+
+
+class GhostCrewTUI(App):
+ """Main GhostCrew TUI Application"""
+
+ # ═══════════════════════════════════════════════════════════
+ # GHOST THEME - Ethereal grays emerging from darkness
+ # ═══════════════════════════════════════════════════════════
+ # Void: #0a0a0a (terminal black - the darkness)
+ # Shadow: #121212 (subtle surface)
+ # Mist: #1a1a1a (panels, elevated)
+ # Whisper: #262626 (default borders)
+ # Fog: #3a3a3a (hover states)
+ # Apparition: #525252 (focus states)
+ # Phantom: #6b6b6b (secondary text)
+ # Spirit: #9a9a9a (normal text)
+ # Specter: #d4d4d4 (primary text)
+ # Ectoplasm: #f0f0f0 (highlights)
+ # ═══════════════════════════════════════════════════════════
+
+ CSS = """
+ Screen {
+ background: #0a0a0a;
+ }
+
+ #main-container {
+ width: 100%;
+ height: 100%;
+ layout: horizontal;
+ }
+
+ /* Chat area - takes full width normally, fills remaining space with sidebar */
+ #chat-area {
+ width: 1fr;
+ height: 100%;
+ }
+
+ #chat-area.with-sidebar {
+ width: 1fr;
+ }
+
+ #chat-scroll {
+ width: 100%;
+ height: 1fr;
+ background: transparent;
+ padding: 1 2;
+ scrollbar-background: #1a1a1a;
+ scrollbar-background-hover: #1a1a1a;
+ scrollbar-background-active: #1a1a1a;
+ scrollbar-color: #3a3a3a;
+ scrollbar-color-hover: #3a3a3a;
+ scrollbar-color-active: #3a3a3a;
+ scrollbar-corner-color: #1a1a1a;
+ scrollbar-size: 1 1;
+ }
+
+ #input-container {
+ width: 100%;
+ height: 3;
+ background: transparent;
+ border: round #262626;
+ margin: 0 2;
+ padding: 0;
+ layout: horizontal;
+ align-vertical: middle;
+ }
+
+ #input-container:focus-within {
+ border: round #525252;
+ }
+
+ #input-container:focus-within #chat-prompt {
+ color: #d4d4d4;
+ }
+
+ #chat-prompt {
+ width: auto;
+ height: 100%;
+ padding: 0 0 0 1;
+ color: #6b6b6b;
+ content-align-vertical: middle;
+ }
+
+ #chat-input {
+ width: 1fr;
+ height: 100%;
+ background: transparent;
+ border: none;
+ padding: 0;
+ margin: 0;
+ color: #d4d4d4;
+ }
+
+ #chat-input:focus {
+ border: none;
+ }
+
+ #chat-input > .input--placeholder {
+ color: #6b6b6b;
+ text-style: italic;
+ }
+
+ #status-bar {
+ width: 100%;
+ height: 1;
+ background: transparent;
+ padding: 0 3;
+ margin: 0;
+ }
+
+ .message {
+ margin-bottom: 1;
+ }
+
+ /* Sidebar - hidden by default */
+ #sidebar {
+ width: 28;
+ height: 100%;
+ display: none;
+ padding-right: 1;
+ }
+
+ #sidebar.visible {
+ display: block;
+ }
+
+ #workers-tree {
+ height: 1fr;
+ background: transparent;
+ border: round #262626;
+ padding: 0 1;
+ margin-bottom: 0;
+ }
+
+ #workers-tree:focus {
+ border: round #3a3a3a;
+ }
+
+ #crew-stats {
+ height: auto;
+ max-height: 10;
+ background: transparent;
+ border: round #262626;
+ border-title-color: #9a9a9a;
+ border-title-style: bold;
+ padding: 0 1;
+ margin-top: 0;
+ }
+
+ Tree {
+ background: transparent;
+ color: #d4d4d4;
+ scrollbar-background: #1a1a1a;
+ scrollbar-background-hover: #1a1a1a;
+ scrollbar-background-active: #1a1a1a;
+ scrollbar-color: #3a3a3a;
+ scrollbar-color-hover: #3a3a3a;
+ scrollbar-color-active: #3a3a3a;
+ scrollbar-size: 1 1;
+ }
+
+ Tree > .tree--cursor {
+ background: transparent;
+ }
+
+ Tree > .tree--highlight {
+ background: transparent;
+ }
+
+ Tree > .tree--highlight-line {
+ background: transparent;
+ }
+
+ .tree--node-label {
+ padding: 0 1;
+ }
+
+ .tree--node:hover .tree--node-label {
+ background: transparent;
+ }
+
+ .tree--node.-selected .tree--node-label {
+ background: transparent;
+ color: #d4d4d4;
+ }
+ """
+
+ BINDINGS = [
+ Binding("ctrl+q", "quit_app", "Quit", priority=True),
+ Binding("ctrl+c", "stop_agent", "Stop", priority=True, show=False),
+ Binding("escape", "stop_agent", "Stop", priority=True),
+ Binding("f1", "show_help", "Help"),
+ Binding("tab", "focus_next", "Next", show=False),
+ ]
+
+ TITLE = "GhostCrew"
+ SUB_TITLE = "AI Penetration Testing"
+
+ def __init__(
+ self,
+ target: Optional[str] = None,
+ model: str = None,
+ use_docker: bool = False,
+ **kwargs,
+ ):
+ super().__init__(**kwargs)
+ self.target = target
+ self.model = model or DEFAULT_MODEL
+ self.use_docker = use_docker
+
+ # Agent components
+ self.agent: Optional["GhostCrewAgent"] = None
+ self.runtime = None
+ self.mcp_manager = None
+ self.all_tools = []
+ self.rag_engine = None # RAG engine
+
+ # State
+ self._mode = "assist" # "assist", "agent", or "crew"
+ 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
+ self._current_crew = None # Track crew orchestrator for cancellation
+
+ # Crew mode state
+ self._crew_workers: Dict[str, Dict[str, Any]] = {}
+ self._crew_worker_nodes: Dict[str, TreeNode] = {}
+ self._crew_orchestrator_node: Optional[TreeNode] = None
+ self._crew_findings_count = 0
+ self._viewing_worker_id: Optional[str] = None
+ self._worker_events: Dict[str, List[Dict]] = {}
+ self._crew_start_time: Optional[float] = None
+ self._crew_tokens_used: int = 0
+ self._crew_stats_timer: Optional[Timer] = None
+ self._spinner_timer: Optional[Timer] = None
+ self._spinner_frame: int = 0
+ self._spinner_frames = [
+ "⠋",
+ "⠙",
+ "⠹",
+ "⠸",
+ "⠼",
+ "⠴",
+ "⠦",
+ "⠧",
+ "⠇",
+ "⠏",
+ ] # Braille dots spinner
+
+ def compose(self) -> ComposeResult:
+ with Horizontal(id="main-container"):
+ # Chat area (left side)
+ with Vertical(id="chat-area"):
+ yield ScrollableContainer(id="chat-scroll")
+ yield StatusBar(id="status-bar")
+ with Horizontal(id="input-container"):
+ yield Static("> ", id="chat-prompt")
+ yield Input(placeholder="Enter task or type /help", id="chat-input")
+
+ # Sidebar (right side, hidden by default)
+ with Vertical(id="sidebar"):
+ yield CrewTree("CREW", id="workers-tree")
+ yield Static("", id="crew-stats")
+
+ async def on_mount(self) -> None:
+ """Initialize on mount"""
+ # Show splash
+ await self.push_screen(SplashScreen())
+
+ # Start initialization
+ self._initialize_agent()
+
+ @work(thread=False)
+ async def _initialize_agent(self) -> None:
+ """Initialize agent"""
+ self._set_status("initializing")
+
+ try:
+ import os
+
+ from ..agents.ghostcrew_agent import GhostCrewAgent
+ from ..knowledge import RAGEngine
+ from ..llm import LLM, ModelConfig
+ from ..mcp import MCPManager
+ from ..runtime.docker_runtime import DockerRuntime
+ from ..runtime.runtime import LocalRuntime
+ from ..tools import get_all_tools, register_tool_instance
+
+ # RAG Engine - auto-load knowledge sources
+ rag_doc_count = 0
+ knowledge_path = None
+
+ # Check local knowledge dir first (must have files, not just exist)
+ local_knowledge = Path("knowledge")
+ bundled_path = Path(__file__).parent.parent / "knowledge" / "sources"
+
+ if local_knowledge.exists() and any(local_knowledge.rglob("*.*")):
+ knowledge_path = local_knowledge
+ elif bundled_path.exists():
+ knowledge_path = bundled_path
+
+ if knowledge_path:
+ try:
+ # Determine embedding method: env var > auto-detect
+ embeddings_setting = os.getenv("GHOSTCREW_EMBEDDINGS", "").lower()
+ if embeddings_setting == "local":
+ use_local = True
+ elif embeddings_setting == "openai":
+ use_local = False
+ else:
+ # Auto: use OpenAI if key available, else local
+ use_local = not os.getenv("OPENAI_API_KEY")
+
+ self.rag_engine = RAGEngine(
+ knowledge_path=knowledge_path, use_local_embeddings=use_local
+ )
+ await asyncio.to_thread(self.rag_engine.index)
+ rag_doc_count = self.rag_engine.get_document_count()
+ except Exception as e:
+ self._add_system(f"[!] RAG: {e}")
+ self.rag_engine = None
+
+ # MCP - auto-load if config exists
+ mcp_server_count = 0
+ try:
+ self.mcp_manager = MCPManager()
+ if self.mcp_manager.config_path.exists():
+ mcp_tools = await self.mcp_manager.connect_all()
+ for tool in mcp_tools:
+ register_tool_instance(tool)
+ mcp_server_count = len(self.mcp_manager.servers)
+ except Exception as e:
+ self._add_system(f"[!] MCP: {e}")
+
+ # Runtime - Docker or Local
+ if self.use_docker:
+ self._add_system("+ Starting Docker container...")
+ self.runtime = DockerRuntime(mcp_manager=self.mcp_manager)
+ else:
+ self.runtime = LocalRuntime(mcp_manager=self.mcp_manager)
+ await self.runtime.start()
+
+ # LLM
+ llm = LLM(
+ model=self.model,
+ config=ModelConfig(temperature=0.7),
+ rag_engine=self.rag_engine,
+ )
+
+ # Tools
+ self.all_tools = get_all_tools()
+
+ # Agent
+ self.agent = GhostCrewAgent(
+ llm=llm,
+ tools=self.all_tools,
+ runtime=self.runtime,
+ target=self.target,
+ rag_engine=self.rag_engine,
+ )
+
+ self._set_status("idle", "assist")
+ self._is_initializing = False # Allow input now
+
+ # Show ready message
+ tools_str = ", ".join(t.name for t in self.all_tools[:5])
+ if len(self.all_tools) > 5:
+ tools_str += f", +{len(self.all_tools) - 5} more"
+
+ runtime_str = "Docker" if self.use_docker else "Local"
+ self._add_system(
+ f"+ GhostCrew ready\n"
+ f" Model: {self.model} | Tools: {len(self.all_tools)} | MCP: {mcp_server_count} | RAG: {rag_doc_count}\n"
+ f" Runtime: {runtime_str} | Mode: Assist (use /agent or /crew for autonomous modes)"
+ )
+
+ # Show target if provided (but don't auto-start)
+ if self.target:
+ self._add_system(f" Target: {self.target}")
+
+ except Exception as e:
+ import traceback
+
+ self._add_system(f"[!] Init failed: {e}\n{traceback.format_exc()}")
+ self._set_status("error")
+ self._is_initializing = False # Allow input even on error
+
+ def _set_status(self, status: str, mode: Optional[str] = None) -> None:
+ """Update status bar"""
+ try:
+ bar = self.query_one("#status-bar", StatusBar)
+ bar.status = status
+ if mode:
+ bar.mode = mode
+ self._mode = mode
+ except Exception:
+ pass
+
+ def _add_message(self, widget: Static) -> None:
+ """Add a message widget to chat"""
+ try:
+ scroll = self.query_one("#chat-scroll", ScrollableContainer)
+ widget.add_class("message")
+ scroll.mount(widget)
+ scroll.scroll_end(animate=False)
+ except Exception:
+ pass
+
+ def _add_system(self, content: str) -> None:
+ self._add_message(SystemMessage(content))
+
+ def _add_user(self, content: str) -> None:
+ self._add_message(UserMessage(content))
+
+ def _add_assistant(self, content: str) -> None:
+ self._add_message(AssistantMessage(content))
+
+ def _add_thinking(self, content: str) -> None:
+ self._add_message(ThinkingMessage(content))
+
+ def _add_tool(self, name: str, action: str = "") -> None:
+ self._add_message(ToolMessage(name, action))
+
+ def _add_tool_result(self, name: str, result: str) -> None:
+ """Display tool execution result"""
+ # Truncate long results
+ if len(result) > 2000:
+ result = result[:2000] + "\n... (truncated)"
+ self._add_message(ToolResultMessage(name, result))
+
+ def _show_system_prompt(self) -> None:
+ """Display the current system prompt"""
+ if self.agent:
+ prompt = self.agent.get_system_prompt()
+ self._add_system(f"=== System Prompt ===\n{prompt}")
+ else:
+ self._add_system("Agent not initialized")
+
+ def _show_memory_stats(self) -> None:
+ """Display memory usage statistics"""
+ if self.agent and self.agent.llm:
+ stats = self.agent.llm.get_memory_stats()
+ messages_count = len(self.agent.conversation_history)
+
+ # Format messages for token counting
+ llm_messages = self.agent._format_messages_for_llm()
+ current_tokens = self.agent.llm.memory.get_total_tokens(llm_messages)
+
+ info = (
+ f"=== Memory Stats ===\n"
+ f"Messages: {messages_count}\n"
+ f"Current tokens: {current_tokens:,}\n"
+ f"Token budget: {stats['token_budget']:,}\n"
+ f"Summarize at: {stats['summarize_threshold']:,} tokens\n"
+ f"Recent to keep: {stats['recent_to_keep']} messages\n"
+ f"Has summary: {stats['has_summary']}\n"
+ f"Summarized: {stats['summarized_message_count']} messages"
+ )
+ self._add_system(info)
+ else:
+ self._add_system("Agent not initialized")
+
+ def _show_notes(self) -> None:
+ """Display saved notes"""
+ from ..tools.notes import get_all_notes
+
+ notes = get_all_notes()
+ if not notes:
+ self._add_system(
+ "=== Notes ===\nNo notes saved.\n\nThe AI can save key findings using the notes tool."
+ )
+ return
+
+ lines = [f"=== Notes ({len(notes)} entries) ==="]
+ for key, value in notes.items():
+ # Show full value, indent multi-line content
+ if "\n" in value:
+ indented = value.replace("\n", "\n ")
+ lines.append(f"\n[{key}]\n {indented}")
+ else:
+ lines.append(f"[{key}] {value}")
+ lines.append("\nFile: loot/notes.json")
+
+ self._add_system("\n".join(lines))
+
+ def _build_prior_context(self) -> str:
+ """Build a summary of prior findings for crew mode.
+
+ Extracts:
+ - Tool results (nmap scans, etc.) - the actual findings
+ - Assistant analyses - interpretations and summaries
+ - Last user task - what they were working on
+
+ Excludes:
+ - Raw user messages (noise)
+ - Tool call declarations (just names/args, not results)
+ - Very short responses
+ """
+ if not self.agent or not self.agent.conversation_history:
+ return ""
+
+ findings = []
+ last_user_task = ""
+
+ for msg in self.agent.conversation_history:
+ # Track user tasks/questions
+ if msg.role == "user" and msg.content:
+ last_user_task = msg.content[:200]
+
+ # Extract tool results (the actual findings)
+ elif msg.tool_results:
+ for result in msg.tool_results:
+ if result.success and result.result:
+ content = (
+ result.result[:1500]
+ if len(result.result) > 1500
+ else result.result
+ )
+ findings.append(f"[{result.tool_name}]\n{content}")
+
+ # Include assistant analyses (but not tool call messages)
+ elif msg.role == "assistant" and msg.content and not msg.tool_calls:
+ if len(msg.content) > 50:
+ findings.append(f"[Analysis]\n{msg.content[:1000]}")
+
+ if not findings and not last_user_task:
+ return ""
+
+ # Build context with last user task + recent findings
+ parts = []
+ if last_user_task:
+ parts.append(f"Last task: {last_user_task}")
+ if findings:
+ parts.append("Findings:\n" + "\n\n".join(findings[-5:]))
+
+ context = "\n\n".join(parts)
+ if len(context) > 4000:
+ context = context[:4000] + "\n... (truncated)"
+
+ return context
+
+ def _set_target(self, cmd: str) -> None:
+ """Set the target for the engagement"""
+ # Remove /target prefix
+ target = cmd[7:].strip()
+
+ if not target:
+ if self.target:
+ self._add_system(
+ f"Current target: {self.target}\nUsage: /target "
+ )
+ else:
+ self._add_system(
+ "No target set.\nUsage: /target \nExample: /target 192.168.1.1"
+ )
+ return
+
+ self.target = target
+
+ # Update agent's target if agent exists
+ if self.agent:
+ self.agent.target = target
+
+ self._add_system(f"@ Target set: {target}")
+
+ @work(exclusive=True)
+ async def _run_report_generation(self) -> None:
+ """Generate a pentest report from notes and conversation"""
+ from pathlib import Path
+
+ from ..tools.notes import get_all_notes
+
+ if not self.agent or not self.agent.llm:
+ self._add_system("[!] Agent not initialized")
+ return
+
+ notes = get_all_notes()
+ if not notes:
+ self._add_system(
+ "No notes found. Ghost saves findings using the notes tool during testing."
+ )
+ return
+
+ self._add_system("Generating report...")
+
+ # Format notes
+ notes_text = "\n".join(f"### {k}\n{v}\n" for k, v in notes.items())
+
+ # Build conversation summary from full history
+ conversation_summary = ""
+ if self.agent.conversation_history:
+ # Summarize key actions from conversation
+ actions = []
+ for msg in self.agent.conversation_history:
+ if msg.role == "assistant" and msg.tool_calls:
+ for tc in msg.tool_calls:
+ actions.append(f"- Tool: {tc.name}")
+ elif msg.role == "tool_result" and msg.tool_results:
+ for tr in msg.tool_results:
+ # Include truncated result
+ result = tr.result or ""
+ output = result[:200] + "..." if len(result) > 200 else result
+ actions.append(f" Result: {output}")
+ if actions:
+ conversation_summary = "\n".join(actions[-30:]) # Last 30 actions
+
+ report_prompt = f"""Generate a penetration test report in Markdown from the notes below.
+
+# Notes
+{notes_text}
+
+# Activity Log
+{conversation_summary if conversation_summary else "N/A"}
+
+# Target
+{self.target or "Not specified"}
+
+Output a report with:
+1. Executive Summary (2-3 sentences)
+2. Findings (use notes, include severity: Critical/High/Medium/Low/Info)
+3. Recommendations
+
+Be concise. Use the actual data from notes."""
+
+ try:
+ report_content = await self.agent.llm.simple_completion(
+ prompt=report_prompt,
+ system="You are a penetration tester writing a security report. Be concise and factual.",
+ )
+
+ if not report_content or not report_content.strip():
+ self._add_system(
+ "[!] Report generation returned empty. Check LLM connection."
+ )
+ return
+
+ # Save to loot/
+ loot_dir = Path("loot")
+ loot_dir.mkdir(exist_ok=True)
+
+ timestamp = datetime.now().strftime("%Y-%m-%d_%H%M%S")
+ report_path = loot_dir / f"report_{timestamp}.md"
+ report_path.write_text(report_content, encoding="utf-8")
+
+ self._add_system(f"+ Report saved: {report_path}")
+
+ except Exception as e:
+ self._add_system(f"[!] Report error: {e}")
+
+ @on(Input.Submitted, "#chat-input")
+ async def handle_submit(self, event: Input.Submitted) -> None:
+ """Handle input submission"""
+ # Block input while initializing or AI is processing
+ if self._is_initializing or self._is_running:
+ return
+
+ message = event.value.strip()
+ if not message:
+ return
+
+ event.input.value = ""
+
+ # Commands
+ if message.startswith("/"):
+ await self._handle_command(message)
+ return
+
+ self._add_user(message)
+
+ # Hide crew sidebar when entering assist mode
+ self._hide_sidebar()
+
+ # Use assist mode by default
+ if self.agent and not self._is_running:
+ self._current_worker = self._run_assist(message)
+
+ async def _handle_command(self, cmd: str) -> None:
+ """Handle slash commands"""
+ cmd_lower = cmd.lower().strip()
+ cmd_original = cmd.strip()
+
+ if cmd_lower in ["/help", "/h", "/?"]:
+ await self.push_screen(HelpScreen())
+ elif cmd_lower == "/clear":
+ scroll = self.query_one("#chat-scroll", ScrollableContainer)
+ await scroll.remove_children()
+ self._hide_sidebar()
+ # Clear agent conversation history for fresh start
+ if self.agent:
+ self.agent.conversation_history.clear()
+ self._add_system("Chat cleared")
+ elif cmd_lower == "/tools":
+ names = [t.name for t in self.all_tools]
+ self._add_system(f"Tools ({len(names)}): " + ", ".join(names))
+ elif cmd_lower in ["/quit", "/exit", "/q"]:
+ self.exit()
+ elif cmd_lower == "/prompt":
+ self._show_system_prompt()
+ elif cmd_lower == "/memory":
+ self._show_memory_stats()
+ elif cmd_lower == "/notes":
+ self._show_notes()
+ elif cmd_lower == "/report":
+ self._run_report_generation()
+ elif cmd_original.startswith("/target"):
+ self._set_target(cmd_original)
+ elif cmd_original.startswith("/agent"):
+ await self._parse_agent_command(cmd_original)
+ elif cmd_original.startswith("/crew"):
+ await self._parse_crew_command(cmd_original)
+ else:
+ self._add_system(f"Unknown command: {cmd}\nType /help for commands.")
+
+ async def _parse_agent_command(self, cmd: str) -> None:
+ """Parse and execute /agent command"""
+
+ # Remove /agent prefix
+ rest = cmd[6:].strip()
+
+ if not rest:
+ self._add_system(
+ "Usage: /agent \n"
+ "Example: /agent scan 192.168.1.1\n"
+ " /agent enumerate SSH on target"
+ )
+ return
+
+ task = rest
+
+ if not task:
+ self._add_system("Error: No task provided. Usage: /agent ")
+ return
+
+ self._add_user(f"/agent {task}")
+ self._add_system(">> Agent Mode")
+
+ # Hide crew sidebar when entering agent mode
+ self._hide_sidebar()
+
+ if self.agent and not self._is_running:
+ self._current_worker = self._run_agent_mode(task)
+
+ async def _parse_crew_command(self, cmd: str) -> None:
+ """Parse and execute /crew command"""
+ # Remove /crew prefix
+ rest = cmd[5:].strip()
+
+ if not rest:
+ self._add_system(
+ "Usage: /crew \n"
+ "Example: /crew https://example.com\n"
+ " /crew 192.168.1.100\n\n"
+ "Crew mode spawns specialized workers in parallel:\n"
+ " - recon: Reconnaissance and mapping\n"
+ " - sqli: SQL injection testing\n"
+ " - xss: Cross-site scripting testing\n"
+ " - ssrf: Server-side request forgery\n"
+ " - auth: Authentication testing\n"
+ " - idor: Insecure direct object references\n"
+ " - info: Information disclosure"
+ )
+ return
+
+ target = rest
+
+ if not self._is_running:
+ self._add_user(f"/crew {target}")
+ self._show_sidebar()
+ self._current_worker = self._run_crew_mode(target)
+
+ def _show_sidebar(self) -> None:
+ """Show the sidebar for crew mode."""
+ try:
+ import time
+
+ sidebar = self.query_one("#sidebar")
+ sidebar.add_class("visible")
+
+ chat_area = self.query_one("#chat-area")
+ chat_area.add_class("with-sidebar")
+
+ # Setup tree
+ tree = self.query_one("#workers-tree", CrewTree)
+ tree.root.expand()
+ tree.show_root = False
+
+ # Clear old nodes
+ tree.root.remove_children()
+ self._crew_worker_nodes.clear()
+ self._crew_workers.clear()
+ self._worker_events.clear()
+ self._crew_findings_count = 0
+
+ # Start tracking time and tokens
+ self._crew_start_time = time.time()
+ self._crew_tokens_used = 0
+
+ # Start stats timer (update every second)
+ if self._crew_stats_timer:
+ self._crew_stats_timer.stop()
+ self._crew_stats_timer = self.set_interval(1.0, self._update_crew_stats)
+
+ # Start spinner timer for running workers (faster interval for smooth animation)
+ if self._spinner_timer:
+ self._spinner_timer.stop()
+ self._spinner_timer = self.set_interval(0.15, self._update_spinner)
+
+ # Add crew root node (no orchestrator - just "CREW" header)
+ 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)
+ self._viewing_worker_id = None
+
+ # Update stats
+ self._update_crew_stats()
+ except Exception as e:
+ self._add_system(f"[!] Sidebar error: {e}")
+
+ def _hide_sidebar(self) -> None:
+ """Hide the sidebar."""
+ try:
+ # Stop stats timer
+ if self._crew_stats_timer:
+ self._crew_stats_timer.stop()
+ self._crew_stats_timer = None
+
+ sidebar = self.query_one("#sidebar")
+ sidebar.remove_class("visible")
+
+ chat_area = self.query_one("#chat-area")
+ chat_area.remove_class("with-sidebar")
+ except Exception:
+ pass
+
+ def _update_crew_stats(self) -> None:
+ """Update crew stats panel."""
+ try:
+ import time
+
+ text = Text()
+
+ # Elapsed time
+ text.append("Time: ", style="bold #d4d4d4")
+ if self._crew_start_time:
+ elapsed = time.time() - self._crew_start_time
+ if elapsed < 60:
+ time_str = f"{int(elapsed)}s"
+ elif elapsed < 3600:
+ mins = int(elapsed // 60)
+ secs = int(elapsed % 60)
+ time_str = f"{mins}m {secs}s"
+ else:
+ hrs = int(elapsed // 3600)
+ mins = int((elapsed % 3600) // 60)
+ time_str = f"{hrs}h {mins}m"
+ text.append(time_str, style="#9a9a9a")
+ else:
+ text.append("--", style="#525252")
+
+ text.append("\n")
+
+ # Tokens used
+ text.append("Tokens: ", style="bold #d4d4d4")
+ if self._crew_tokens_used > 0:
+ if self._crew_tokens_used >= 1000:
+ token_str = f"{self._crew_tokens_used / 1000:.1f}k"
+ else:
+ token_str = str(self._crew_tokens_used)
+ text.append(token_str, style="#9a9a9a")
+ else:
+ text.append("--", style="#525252")
+
+ stats = self.query_one("#crew-stats", Static)
+ stats.update(text)
+ stats.border_title = "# Stats"
+ except Exception:
+ pass
+
+ def _update_spinner(self) -> None:
+ """Update spinner animation for running workers."""
+ try:
+ # Advance spinner frame
+ self._spinner_frame += 1
+
+ # Only update labels for running workers (efficient)
+ has_running = False
+ for worker_id, worker in self._crew_workers.items():
+ if worker.get("status") == "running":
+ has_running = True
+ # Update the tree node label
+ if worker_id in self._crew_worker_nodes:
+ node = self._crew_worker_nodes[worker_id]
+ node.set_label(self._format_worker_label(worker_id))
+
+ # Stop spinner if no workers are running (save resources)
+ if not has_running and self._spinner_timer:
+ self._spinner_timer.stop()
+ self._spinner_timer = None
+ except Exception:
+ pass
+
+ def _add_crew_worker(self, worker_id: str, worker_type: str, task: str) -> None:
+ """Add a worker to the sidebar tree."""
+ self._crew_workers[worker_id] = {
+ "worker_type": worker_type,
+ "task": task,
+ "status": "pending",
+ "findings": 0,
+ }
+
+ 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()
+ self._update_crew_stats()
+ except Exception:
+ pass
+
+ def _update_crew_worker(self, worker_id: str, **updates) -> None:
+ """Update a worker's state."""
+ if worker_id not in self._crew_workers:
+ return
+
+ self._crew_workers[worker_id].update(updates)
+
+ # Restart spinner if a worker started running
+ if updates.get("status") == "running" and not self._spinner_timer:
+ self._spinner_timer = self.set_interval(0.15, self._update_spinner)
+
+ try:
+ if worker_id in self._crew_worker_nodes:
+ label = self._format_worker_label(worker_id)
+ self._crew_worker_nodes[worker_id].set_label(label)
+ self._update_crew_stats()
+ except Exception:
+ pass
+
+ def _format_worker_label(self, worker_id: str) -> Text:
+ """Format worker label for tree."""
+ worker = self._crew_workers.get(worker_id, {})
+ status = worker.get("status", "pending")
+ wtype = worker.get("worker_type", "worker")
+ findings = worker.get("findings", 0)
+
+ # Simple 3-state icons: working (braille), done (checkmark), error (X)
+ if status in ("running", "pending"):
+ # Animated braille spinner for all in-progress states
+ icon = self._spinner_frames[self._spinner_frame % len(self._spinner_frames)]
+ color = "#d4d4d4" # white
+ elif status == "complete":
+ icon = "✓"
+ color = "#22c55e" # green
+ else: # error, cancelled, unknown
+ icon = "✗"
+ color = "#ef4444" # red
+
+ text = Text()
+ text.append(f"{icon} ", style=color)
+ text.append(wtype.upper(), style="bold")
+
+ if status == "complete" and findings > 0:
+ text.append(f" [{findings}]", style="#22c55e") # green
+ elif status in ("error", "cancelled"):
+ text.append(" !", style="#ef4444") # red
+
+ return text
+
+ def _handle_worker_event(
+ self, worker_id: str, event_type: str, data: Dict[str, Any]
+ ) -> None:
+ """Handle worker events from CrewAgent - updates tree sidebar only."""
+ try:
+ if event_type == "spawn":
+ worker_type = data.get("worker_type", "unknown")
+ task = data.get("task", "")
+ self._add_crew_worker(worker_id, worker_type, task)
+ elif event_type == "status":
+ status = data.get("status", "running")
+ self._update_crew_worker(worker_id, status=status)
+ elif event_type == "tool":
+ # Add tool as child node under the agent
+ tool_name = data.get("tool", "unknown")
+ self._add_tool_to_worker(worker_id, tool_name)
+ elif event_type == "tokens":
+ # Track token usage
+ tokens = data.get("tokens", 0)
+ self._crew_tokens_used += tokens
+ elif event_type == "complete":
+ findings_count = data.get("findings_count", 0)
+ self._update_crew_worker(
+ worker_id, status="complete", findings=findings_count
+ )
+ self._crew_findings_count += findings_count
+ self._update_crew_stats()
+ elif event_type == "error":
+ self._update_crew_worker(worker_id, status="error")
+ worker = self._crew_workers.get(worker_id, {})
+ wtype = worker.get("worker_type", "worker")
+ error_msg = data.get("error", "Unknown error")
+ # Only show errors in chat - they're important
+ self._add_system(f"[!] {wtype.upper()} failed: {error_msg}")
+ except Exception as e:
+ self._add_system(f"[!] Worker event error: {e}")
+
+ def _add_tool_to_worker(self, worker_id: str, tool_name: str) -> None:
+ """Add a tool usage as child node under worker in tree."""
+ try:
+ node = self._crew_worker_nodes.get(worker_id)
+ if node:
+ node.add_leaf(f" {tool_name}")
+ node.expand()
+ except Exception:
+ pass
+
+ @on(Tree.NodeSelected, "#workers-tree")
+ def on_worker_tree_selected(self, event: Tree.NodeSelected) -> None:
+ """Handle tree node selection."""
+ node = event.node
+ if node.data:
+ node_type = node.data.get("type")
+ if node_type == "crew":
+ self._viewing_worker_id = None
+ elif node_type == "worker":
+ self._viewing_worker_id = node.data.get("id")
+
+ @work(thread=False)
+ async def _run_crew_mode(self, target: str) -> None:
+ """Run crew mode with sidebar."""
+ self._is_running = True
+ self._should_stop = False
+ self._set_status("thinking", "crew")
+
+ try:
+ from ..agents.base_agent import AgentMessage
+ from ..agents.crew import CrewOrchestrator
+ from ..llm import LLM, ModelConfig
+
+ # Build prior context from assist/agent conversation history
+ prior_context = self._build_prior_context()
+
+ llm = LLM(model=self.model, config=ModelConfig(temperature=0.7))
+
+ crew = CrewOrchestrator(
+ llm=llm,
+ tools=self.all_tools,
+ runtime=self.runtime,
+ on_worker_event=self._handle_worker_event,
+ rag_engine=self.rag_engine,
+ target=self.target,
+ prior_context=prior_context,
+ )
+ self._current_crew = crew # Track for cancellation
+
+ self._add_system(f"@ Task: {target}")
+
+ # Track crew results for memory
+ crew_report = None
+
+ async for update in crew.run(target):
+ if self._should_stop:
+ await crew.cancel()
+ self._add_system("[!] Stopped by user")
+ break
+
+ phase = update.get("phase", "")
+
+ if phase == "starting":
+ self._set_status("thinking", "crew")
+
+ elif phase == "thinking":
+ # Show the orchestrator's reasoning
+ content = update.get("content", "")
+ if content:
+ self._add_thinking(content)
+
+ elif phase == "tool_call":
+ # Show orchestration tool calls
+ tool = update.get("tool", "")
+ args = update.get("args", {})
+ self._add_tool(tool, str(args))
+
+ elif phase == "tool_result":
+ # Tool results are tracked via worker events
+ pass
+
+ elif phase == "complete":
+ crew_report = update.get("report", "")
+ if crew_report:
+ self._add_assistant(crew_report)
+
+ elif phase == "error":
+ error = update.get("error", "Unknown error")
+ self._add_system(f"[!] Crew error: {error}")
+
+ # Add crew results to main agent's conversation history
+ # so assist mode can reference what happened
+ if self.agent and crew_report:
+ # Add the crew task as a user message
+ self.agent.conversation_history.append(
+ AgentMessage(
+ role="user",
+ content=f"[CREW MODE] Run parallel analysis on target: {target}",
+ )
+ )
+ # Add the crew report as assistant response
+ self.agent.conversation_history.append(
+ AgentMessage(role="assistant", content=crew_report)
+ )
+
+ self._set_status("complete", "crew")
+ self._add_system("+ Crew task complete.")
+
+ # Stop timers
+ if self._crew_stats_timer:
+ self._crew_stats_timer.stop()
+ self._crew_stats_timer = None
+ if self._spinner_timer:
+ self._spinner_timer.stop()
+ self._spinner_timer = None
+
+ # Clear crew reference
+ self._current_crew = None
+
+ except asyncio.CancelledError:
+ # Cancel crew workers first
+ if self._current_crew:
+ await self._current_crew.cancel()
+ self._current_crew = None
+ self._add_system("[!] Cancelled")
+ self._set_status("idle", "crew")
+ # Stop timers on cancel
+ if self._crew_stats_timer:
+ self._crew_stats_timer.stop()
+ self._crew_stats_timer = None
+ if self._spinner_timer:
+ self._spinner_timer.stop()
+ self._spinner_timer = None
+
+ except Exception as e:
+ import traceback
+
+ # Cancel crew workers on error too
+ if self._current_crew:
+ try:
+ await self._current_crew.cancel()
+ except Exception:
+ pass
+ self._current_crew = None
+ self._add_system(f"[!] Crew error: {e}\n{traceback.format_exc()}")
+ self._set_status("error")
+ # Stop timers on error too
+ if self._crew_stats_timer:
+ self._crew_stats_timer.stop()
+ self._crew_stats_timer = None
+ if self._spinner_timer:
+ self._spinner_timer.stop()
+ self._spinner_timer = None
+ finally:
+ self._is_running = False
+
+ @work(thread=False)
+ async def _run_assist(self, message: str) -> None:
+ """Run in assist mode - single response"""
+ if not self.agent:
+ self._add_system("[!] Agent not ready")
+ return
+
+ self._is_running = True
+ self._should_stop = False
+ self._set_status("thinking", "assist")
+
+ try:
+ async for response in self.agent.assist(message):
+ if self._should_stop:
+ self._add_system("[!] Stopped by user")
+ break
+
+ self._set_status("processing")
+
+ # Show thinking/plan FIRST if there's content with tool calls
+ if response.content:
+ content = response.content.strip()
+ if response.tool_calls:
+ self._add_thinking(content)
+ else:
+ self._add_assistant(content)
+
+ # Show tool calls (skip 'finish' - internal control)
+ if response.tool_calls:
+ for call in response.tool_calls:
+ if call.name == "finish":
+ continue # Skip - summary shown as final message
+ args_str = str(call.arguments)
+ self._add_tool(call.name, args_str)
+
+ # Show tool results (displayed after execution completes)
+ # Skip 'finish' tool - its result is shown as the final summary
+ if response.tool_results:
+ for result in response.tool_results:
+ if result.tool_name == "finish":
+ continue # Skip - summary shown separately
+ if result.success:
+ self._add_tool_result(
+ result.tool_name, result.result or "Done"
+ )
+ else:
+ self._add_tool_result(
+ result.tool_name, f"Error: {result.error}"
+ )
+
+ self._set_status("idle", "assist")
+
+ except asyncio.CancelledError:
+ self._add_system("[!] Cancelled")
+ self._set_status("idle", "assist")
+ except Exception as e:
+ self._add_system(f"[!] Error: {e}")
+ self._set_status("error")
+ finally:
+ self._is_running = False
+
+ @work(thread=False)
+ async def _run_agent_mode(self, task: str) -> None:
+ """Run in agent mode - autonomous until task complete or user stops"""
+ if not self.agent:
+ self._add_system("[!] Agent not ready")
+ return
+
+ self._is_running = True
+ self._should_stop = False
+
+ self._set_status("thinking", "agent")
+
+ try:
+ async for response in self.agent.agent_loop(task):
+ if self._should_stop:
+ self._add_system("[!] Stopped by user")
+ break
+
+ self._set_status("processing")
+
+ # Show thinking/plan FIRST if there's content with tool calls
+ if response.content:
+ content = response.content.strip()
+ if response.tool_calls:
+ self._add_thinking(content)
+ else:
+ # Check if this is a task completion message
+ if response.metadata.get("task_complete"):
+ self._add_assistant(content)
+ else:
+ self._add_assistant(content)
+
+ # Show tool calls AFTER thinking (skip 'finish' - internal control)
+ if response.tool_calls:
+ for call in response.tool_calls:
+ if call.name == "finish":
+ continue # Skip - summary shown as final message
+ args_str = str(call.arguments)
+ self._add_tool(call.name, args_str)
+
+ # Show tool results
+ # Skip 'finish' tool - its result is shown as the final summary
+ if response.tool_results:
+ for result in response.tool_results:
+ if result.tool_name == "finish":
+ continue # Skip - summary shown separately
+ if result.success:
+ self._add_tool_result(
+ result.tool_name, result.result or "Done"
+ )
+ else:
+ self._add_tool_result(
+ result.tool_name, f"Error: {result.error}"
+ )
+
+ # Check state
+ if self.agent.state.value == "waiting_input":
+ self._set_status("waiting")
+ self._add_system("? Awaiting input...")
+ break
+ elif self.agent.state.value == "complete":
+ break
+
+ self._set_status("thinking")
+
+ self._set_status("complete", "agent")
+ self._add_system("+ Agent task complete. Back to assist mode.")
+
+ # Return to assist mode
+ await asyncio.sleep(1)
+ self._set_status("idle", "assist")
+
+ except asyncio.CancelledError:
+ self._add_system("[!] Cancelled")
+ self._set_status("idle", "assist")
+ except Exception as e:
+ self._add_system(f"[!] Error: {e}")
+ self._set_status("error")
+ finally:
+ self._is_running = False
+
+ def action_quit_app(self) -> None:
+ self.exit()
+
+ def action_stop_agent(self) -> None:
+ if self._is_running:
+ self._should_stop = True
+ 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()
+
+ # Clean up agent state to prevent stale tool responses
+ if self.agent:
+ self.agent.cleanup_after_cancel()
+
+ # Reconnect MCP servers (they may be in a bad state after cancellation)
+ if self.mcp_manager:
+ asyncio.create_task(self._reconnect_mcp_after_cancel())
+
+ async def _reconnect_mcp_after_cancel(self) -> None:
+ """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()
+ except Exception:
+ pass # Best effort - don't crash if reconnect fails
+
+ def action_show_help(self) -> None:
+ self.push_screen(HelpScreen())
+
+ async def on_unmount(self) -> None:
+ """Cleanup"""
+ if self.mcp_manager:
+ try:
+ await self.mcp_manager.disconnect_all()
+ await asyncio.sleep(0.1)
+ except Exception:
+ pass
+
+ if self.runtime:
+ try:
+ await self.runtime.stop()
+ except Exception:
+ pass
+
+
+# ----- Entry Point -----
+
+
+def run_tui(
+ target: Optional[str] = None,
+ model: str = None,
+ use_docker: bool = False,
+):
+ """Run the GhostCrew TUI"""
+ app = GhostCrewTUI(
+ target=target,
+ model=model,
+ use_docker=use_docker,
+ )
+ app.run()
+
+
+if __name__ == "__main__":
+ run_tui()
diff --git a/ghostcrew/interface/utils.py b/ghostcrew/interface/utils.py
new file mode 100644
index 0000000..e804408
--- /dev/null
+++ b/ghostcrew/interface/utils.py
@@ -0,0 +1,237 @@
+"""Interface utilities for GhostCrew."""
+
+from typing import Any, Optional
+
+from rich.console import Console
+from rich.panel import Panel
+from rich.table import Table
+
+console = Console()
+
+
+# ASCII Art Banner
+ASCII_BANNER = r"""
+ ('-. .-. .-') .-') _ _ .-') ('-. (`\ .-') /`
+ ( OO ) / ( OO ). ( OO) ) ( \( -O ) _( OO) `.( OO ),'
+ ,----. ,--. ,--. .-'),-----. (_)---\_)/ '._ .-----. ,------. (,------.,--./ .--.
+ ' .-./-') | | | |( OO' .-. '/ _ | |'--...__)' .--./ | /`. ' | .---'| | |
+ | |_( O- )| .| |/ | | | |\ :` `. '--. .--'| |('-. | / | | | | | | | |,
+ | | .--, \| |\_) | |\| | '..`''.) | | /_) |OO )| |_.' |(| '--. | |.'.| |_)
+(| | '. (_/| .-. | \ | | | |.-._) \ | | || |`-'| | . '.' | .--' | |
+ | '--' | | | | | `' '-' '\ / | | (_' '--'\ | |\ \ | `---.| ,'. |
+ `------' `--' `--' `-----' `-----' `--' `-----' `--' '--' `------''--' '--'
+"""
+
+
+def print_banner():
+ """Print the GhostCrew banner."""
+ console.print(f"[bold white]{ASCII_BANNER}[/]")
+ console.print(
+ "[bold white]====================== GHOSTCREW =======================[/]"
+ )
+ console.print(
+ "[dim white] AI Penetration Testing Agents v0.2.0[/dim white]\n"
+ )
+
+
+def format_finding(
+ title: str,
+ severity: str,
+ target: str,
+ description: str,
+ evidence: str = "",
+ impact: str = "",
+ remediation: str = "",
+) -> Panel:
+ """
+ Format a security finding for display.
+
+ Args:
+ title: Finding title
+ severity: Severity level
+ target: Affected target
+ description: Description of the finding
+ evidence: Proof/evidence
+ impact: Potential impact
+ remediation: How to fix
+
+ Returns:
+ Rich Panel with formatted finding
+ """
+ severity_colors = {
+ "critical": "red bold",
+ "high": "red",
+ "medium": "yellow",
+ "low": "blue",
+ "informational": "dim",
+ }
+
+ color = severity_colors.get(severity.lower(), "white")
+
+ content = f"""
+[bold]Target:[/] {target}
+[{color}]Severity:[/{color}] [{color}]{severity.upper()}[/{color}]
+
+[bold]Description:[/]
+{description}
+"""
+
+ if evidence:
+ content += f"\n[bold]Evidence:[/]\n{evidence}\n"
+
+ if impact:
+ content += f"\n[bold]Impact:[/]\n{impact}\n"
+
+ if remediation:
+ content += f"\n[bold]Remediation:[/]\n{remediation}\n"
+
+ return Panel(content, title=f"[bold]{title}[/]", border_style=color)
+
+
+def format_tool_call(tool_call: Any) -> str:
+ """
+ Format a tool call for display.
+
+ Args:
+ tool_call: The tool call object
+
+ Returns:
+ Formatted string
+ """
+ name = tool_call.name if hasattr(tool_call, "name") else str(tool_call)
+ args = tool_call.arguments if hasattr(tool_call, "arguments") else {}
+
+ # Truncate long arguments
+ args_str = str(args)
+ if len(args_str) > 100:
+ args_str = args_str[:100] + "..."
+
+ return f"[bold yellow]⚡ Tool:[/] {name}\n[dim]{args_str}[/dim]"
+
+
+def print_status(
+ target: Optional[str] = None,
+ scope: Optional[list] = None,
+ agent_state: str = "idle",
+ tools_count: int = 0,
+ findings_count: int = 0,
+):
+ """
+ Print current status information.
+
+ Args:
+ target: Current target
+ scope: Current scope
+ agent_state: Agent state
+ tools_count: Number of loaded tools
+ findings_count: Number of findings
+ """
+ table = Table(title="GhostCrew Status", show_header=False)
+ table.add_column("Property", style="cyan")
+ table.add_column("Value", style="white")
+
+ table.add_row("Target", target or "Not set")
+ table.add_row("Scope", ", ".join(scope) if scope else "Not set")
+ table.add_row("Agent State", agent_state)
+ table.add_row("Tools Loaded", str(tools_count))
+ table.add_row("Findings", str(findings_count))
+
+ console.print(table)
+
+
+def format_scan_progress(current: int, total: int, current_item: str) -> str:
+ """
+ Format scan progress for display.
+
+ Args:
+ current: Current item number
+ total: Total items
+ current_item: Current item being scanned
+
+ Returns:
+ Formatted progress string
+ """
+ percentage = (current / total * 100) if total > 0 else 0
+ bar_width = 30
+ filled = int(bar_width * current / total) if total > 0 else 0
+ bar = "█" * filled + "░" * (bar_width - filled)
+
+ return f"[{bar}] {percentage:.1f}% ({current}/{total}) - {current_item}"
+
+
+def truncate_output(output: str, max_lines: int = 50) -> str:
+ """
+ Truncate long output for display.
+
+ Args:
+ output: Output to truncate
+ max_lines: Maximum number of lines
+
+ Returns:
+ Truncated output
+ """
+ lines = output.split("\n")
+
+ if len(lines) <= max_lines:
+ return output
+
+ half = max_lines // 2
+ truncated = (
+ lines[:half]
+ + [f"\n... ({len(lines) - max_lines} lines omitted) ...\n"]
+ + lines[-half:]
+ )
+
+ return "\n".join(truncated)
+
+
+def colorize_severity(severity: str) -> str:
+ """
+ Add color to severity text.
+
+ Args:
+ severity: Severity level
+
+ Returns:
+ Colorized severity string
+ """
+ colors = {
+ "critical": "[red bold]CRITICAL[/]",
+ "high": "[red]HIGH[/]",
+ "medium": "[yellow]MEDIUM[/]",
+ "low": "[blue]LOW[/]",
+ "informational": "[dim]INFO[/]",
+ "info": "[dim]INFO[/]",
+ }
+
+ return colors.get(severity.lower(), severity)
+
+
+def format_command_output(
+ command: str, exit_code: int, stdout: str, stderr: str
+) -> Panel:
+ """
+ Format command output for display.
+
+ Args:
+ command: The command that was run
+ exit_code: Exit code
+ stdout: Standard output
+ stderr: Standard error
+
+ Returns:
+ Rich Panel with formatted output
+ """
+ success = exit_code == 0
+ border_color = "green" if success else "red"
+
+ content = f"[bold]Command:[/] {command}\n"
+ content += f"[bold]Exit Code:[/] {exit_code}\n"
+
+ if stdout:
+ content += f"\n[bold]Output:[/]\n{truncate_output(stdout)}"
+
+ if stderr:
+ content += f"\n[bold red]Errors:[/]\n{truncate_output(stderr)}"
+
+ return Panel(content, title="Command Result", border_style=border_color)
diff --git a/ghostcrew/knowledge/__init__.py b/ghostcrew/knowledge/__init__.py
new file mode 100644
index 0000000..3fdbf10
--- /dev/null
+++ b/ghostcrew/knowledge/__init__.py
@@ -0,0 +1,13 @@
+"""Knowledge and RAG system for GhostCrew."""
+
+from .embeddings import get_embeddings, get_embeddings_local
+from .indexer import KnowledgeIndexer
+from .rag import Document, RAGEngine
+
+__all__ = [
+ "RAGEngine",
+ "Document",
+ "get_embeddings",
+ "get_embeddings_local",
+ "KnowledgeIndexer",
+]
diff --git a/ghostcrew/knowledge/embeddings.py b/ghostcrew/knowledge/embeddings.py
new file mode 100644
index 0000000..ed9cd31
--- /dev/null
+++ b/ghostcrew/knowledge/embeddings.py
@@ -0,0 +1,146 @@
+"""Embedding generation for GhostCrew."""
+
+from typing import List, Optional
+
+import numpy as np
+
+
+def get_embeddings(
+ texts: List[str], model: str = "text-embedding-3-small"
+) -> np.ndarray:
+ """
+ Generate embeddings for a list of texts using LiteLLM.
+
+ Args:
+ texts: List of texts to embed
+ model: The embedding model to use
+
+ Returns:
+ NumPy array of embeddings
+ """
+ try:
+ import litellm
+
+ response = litellm.embedding(model=model, input=texts)
+
+ embeddings = [item["embedding"] for item in response.data]
+ return np.array(embeddings, dtype=np.float32)
+
+ except ImportError as e:
+ raise ImportError(
+ "litellm is required for embeddings. Install with: pip install litellm"
+ ) from e
+ except Exception as e:
+ raise RuntimeError(f"Failed to generate embeddings: {e}") from e
+
+
+def get_embeddings_local(
+ texts: List[str], model: str = "all-MiniLM-L6-v2"
+) -> np.ndarray:
+ """
+ Generate embeddings locally using sentence-transformers.
+
+ Args:
+ texts: List of texts to embed
+ model: The sentence-transformer model to use
+
+ Returns:
+ NumPy array of embeddings
+ """
+ try:
+ from sentence_transformers import SentenceTransformer
+
+ encoder = SentenceTransformer(model)
+ embeddings = encoder.encode(texts, show_progress_bar=False)
+ return np.array(embeddings, dtype=np.float32)
+
+ except ImportError as e:
+ raise ImportError(
+ "sentence-transformers is required for local embeddings. "
+ "Install with: pip install sentence-transformers"
+ ) from e
+
+
+def cosine_similarity(a: np.ndarray, b: np.ndarray) -> float:
+ """
+ Compute cosine similarity between two vectors.
+
+ Args:
+ a: First vector
+ b: Second vector
+
+ Returns:
+ Cosine similarity score
+ """
+ return np.dot(a, b) / (np.linalg.norm(a) * np.linalg.norm(b) + 1e-10)
+
+
+def batch_cosine_similarity(query: np.ndarray, embeddings: np.ndarray) -> np.ndarray:
+ """
+ Compute cosine similarity between a query and multiple embeddings.
+
+ Args:
+ query: Query vector
+ embeddings: Matrix of embeddings
+
+ Returns:
+ Array of similarity scores
+ """
+ query_norm = np.linalg.norm(query)
+ embeddings_norm = np.linalg.norm(embeddings, axis=1)
+
+ return np.dot(embeddings, query) / (embeddings_norm * query_norm + 1e-10)
+
+
+class EmbeddingCache:
+ """Cache for embeddings to avoid recomputation."""
+
+ def __init__(self, max_size: int = 1000):
+ """
+ Initialize the embedding cache.
+
+ Args:
+ max_size: Maximum number of embeddings to cache
+ """
+ self.max_size = max_size
+ self._cache: dict[str, np.ndarray] = {}
+ self._order: list[str] = []
+
+ def get(self, text: str) -> Optional[np.ndarray]:
+ """
+ Get a cached embedding.
+
+ Args:
+ text: The text to look up
+
+ Returns:
+ The cached embedding or None
+ """
+ return self._cache.get(text)
+
+ def set(self, text: str, embedding: np.ndarray):
+ """
+ Cache an embedding.
+
+ Args:
+ text: The text key
+ embedding: The embedding to cache
+ """
+ if text in self._cache:
+ return
+
+ if len(self._cache) >= self.max_size:
+ # Remove oldest entry
+ oldest = self._order.pop(0)
+ del self._cache[oldest]
+
+ self._cache[text] = embedding
+ self._order.append(text)
+
+ def clear(self):
+ """Clear the cache."""
+ self._cache.clear()
+ self._order.clear()
+
+ def __len__(self) -> int:
+ return len(self._cache)
diff --git a/ghostcrew/knowledge/indexer.py b/ghostcrew/knowledge/indexer.py
new file mode 100644
index 0000000..8562619
--- /dev/null
+++ b/ghostcrew/knowledge/indexer.py
@@ -0,0 +1,249 @@
+"""Knowledge indexer for GhostCrew."""
+
+import json
+from dataclasses import dataclass
+from pathlib import Path
+from typing import Any, List
+
+from .rag import Document
+
+
+@dataclass
+class IndexingResult:
+ """Result of an indexing operation."""
+
+ total_files: int
+ indexed_files: int
+ total_chunks: int
+ errors: List[str]
+
+
+class KnowledgeIndexer:
+ """Indexes knowledge sources for the RAG engine."""
+
+ # Supported file extensions
+ TEXT_EXTENSIONS = [".txt", ".md", ".rst"]
+ DATA_EXTENSIONS = [".json", ".yaml", ".yml"]
+
+ def __init__(self, chunk_size: int = 1000, chunk_overlap: int = 200):
+ """
+ Initialize the knowledge indexer.
+
+ Args:
+ chunk_size: Maximum chunk size in characters
+ chunk_overlap: Overlap between chunks
+ """
+ self.chunk_size = chunk_size
+ self.chunk_overlap = chunk_overlap
+
+ def index_directory(self, directory: Path) -> tuple[List[Document], IndexingResult]:
+ """
+ Index all supported files in a directory.
+
+ Args:
+ directory: The directory to index
+
+ Returns:
+ Tuple of (documents, indexing_result)
+ """
+ documents = []
+ errors = []
+ total_files = 0
+ indexed_files = 0
+
+ if not directory.exists():
+ return documents, IndexingResult(
+ 0, 0, 0, [f"Directory not found: {directory}"]
+ )
+
+ for file_path in directory.rglob("*"):
+ if not file_path.is_file():
+ continue
+
+ total_files += 1
+
+ try:
+ file_docs = self.index_file(file_path)
+ if file_docs:
+ documents.extend(file_docs)
+ indexed_files += 1
+ except Exception as e:
+ errors.append(f"Error indexing {file_path}: {e}")
+
+ result = IndexingResult(
+ total_files=total_files,
+ indexed_files=indexed_files,
+ total_chunks=len(documents),
+ errors=errors,
+ )
+
+ return documents, result
+
+ def index_file(self, file_path: Path) -> List[Document]:
+ """
+ Index a single file.
+
+ Args:
+ file_path: The file to index
+
+ Returns:
+ List of Document objects
+ """
+ suffix = file_path.suffix.lower()
+
+ if suffix in self.TEXT_EXTENSIONS:
+ return self._index_text_file(file_path)
+ elif suffix in self.DATA_EXTENSIONS:
+ return self._index_data_file(file_path)
+ else:
+ return []
+
+ def _index_text_file(self, file_path: Path) -> List[Document]:
+ """Index a text file."""
+ content = file_path.read_text(encoding="utf-8", errors="ignore")
+ return self._chunk_text(content, str(file_path))
+
+ def _index_data_file(self, file_path: Path) -> List[Document]:
+ """Index a JSON/YAML file."""
+ content = file_path.read_text(encoding="utf-8")
+
+ if file_path.suffix == ".json":
+ data = json.loads(content)
+ else:
+ try:
+ import yaml
+
+ data = yaml.safe_load(content)
+ except ImportError:
+ return []
+
+ return self._process_data(data, str(file_path))
+
+ def _chunk_text(self, text: str, source: str) -> List[Document]:
+ """Split text into chunks."""
+ chunks = []
+
+ # Try to split by sections (headers in markdown)
+ sections = self._split_by_sections(text)
+
+ for section in sections:
+ if len(section) <= self.chunk_size:
+ if section.strip():
+ chunks.append(Document(content=section.strip(), source=source))
+ else:
+ # Further split large sections
+ sub_chunks = self._split_by_paragraphs(section)
+ for sub in sub_chunks:
+ if sub.strip():
+ chunks.append(Document(content=sub.strip(), source=source))
+
+ return chunks
+
+ def _split_by_sections(self, text: str) -> List[str]:
+ """Split text by markdown headers."""
+ import re
+
+ # Split by headers (# Header)
+ sections = re.split(r"\n(?=#{1,3}\s)", text)
+
+ if len(sections) == 1:
+ # No headers found, return original
+ return [text]
+
+ return sections
+
+ def _split_by_paragraphs(self, text: str) -> List[str]:
+ """Split text by paragraphs with overlap."""
+ paragraphs = text.split("\n\n")
+ chunks = []
+ current_chunk = ""
+
+ for para in paragraphs:
+ if len(current_chunk) + len(para) + 2 <= self.chunk_size:
+ current_chunk += para + "\n\n"
+ else:
+ if current_chunk.strip():
+ chunks.append(current_chunk.strip())
+ # Start new chunk with overlap
+ current_chunk = para + "\n\n"
+
+ if current_chunk.strip():
+ chunks.append(current_chunk.strip())
+
+ return chunks
+
+ def _process_data(self, data: Any, source: str) -> List[Document]:
+ """Process JSON/YAML data into documents."""
+ documents = []
+
+ if isinstance(data, list):
+ for i, item in enumerate(data):
+ doc = Document(
+ content=json.dumps(item, indent=2),
+ source=source,
+ metadata={"index": i, "type": "array_item"},
+ )
+ documents.append(doc)
+
+ elif isinstance(data, dict):
+ # Check if it has a specific structure
+ if "entries" in data or "items" in data or "data" in data:
+ items = data.get("entries") or data.get("items") or data.get("data")
+ if isinstance(items, list):
+ for i, item in enumerate(items):
+ doc = Document(
+ content=json.dumps(item, indent=2),
+ source=source,
+ metadata={"index": i, "type": "data_item"},
+ )
+ documents.append(doc)
+ else:
+ doc = Document(
+ content=json.dumps(data, indent=2),
+ source=source,
+ metadata={"type": "object"},
+ )
+ documents.append(doc)
+ else:
+ doc = Document(
+ content=json.dumps(data, indent=2),
+ source=source,
+ metadata={"type": "object"},
+ )
+ documents.append(doc)
+
+ else:
+ doc = Document(
+ content=str(data), source=source, metadata={"type": "primitive"}
+ )
+ documents.append(doc)
+
+ return documents
+
+ def create_knowledge_structure(self, base_path: Path):
+ """
+ Create the default knowledge directory structure.
+
+ Args:
+ base_path: Base path for knowledge directory
+ """
+ directories = [
+ base_path / "cves",
+ base_path / "wordlists",
+ base_path / "exploits",
+ base_path / "methodologies",
+ base_path / "custom",
+ ]
+
+ for directory in directories:
+ directory.mkdir(parents=True, exist_ok=True)
+
+ # Create placeholder files
+ (base_path / "methodologies" / "README.md").write_text(
+ "# Penetration Testing Methodologies\n\n"
+ "Add methodology documents here.\n"
+ )
+
+ (base_path / "wordlists" / "common.txt").write_text(
+ "# Common wordlist\n" "admin\n" "password\n" "root\n" "user\n"
+ )
diff --git a/ghostcrew/knowledge/rag.py b/ghostcrew/knowledge/rag.py
new file mode 100644
index 0000000..67ede07
--- /dev/null
+++ b/ghostcrew/knowledge/rag.py
@@ -0,0 +1,439 @@
+"""RAG (Retrieval Augmented Generation) engine for GhostCrew."""
+
+import json
+from dataclasses import dataclass
+from pathlib import Path
+from typing import Any, Dict, List, Optional
+
+import numpy as np
+
+from .embeddings import get_embeddings
+
+
+@dataclass
+class Document:
+ """A chunk of knowledge."""
+
+ content: str
+ source: str
+ metadata: Optional[Dict[str, Any]] = None
+ embedding: Optional[np.ndarray] = None
+ doc_id: Optional[str] = None
+
+ def __post_init__(self):
+ if self.metadata is None:
+ self.metadata = {}
+ if self.doc_id is None:
+ self.doc_id = f"{hash(self.content)}_{hash(self.source)}"
+
+
+class RAGEngine:
+ """Vector search over security knowledge."""
+
+ def __init__(
+ self,
+ knowledge_path: Path = Path("knowledge"),
+ embedding_model: str = "text-embedding-3-small",
+ use_local_embeddings: bool = False,
+ ):
+ """
+ Initialize the RAG engine.
+
+ Args:
+ knowledge_path: Path to the knowledge directory
+ embedding_model: Model to use for embeddings
+ use_local_embeddings: Whether to use local embeddings (sentence-transformers)
+ """
+ self.knowledge_path = knowledge_path
+ self.embedding_model = embedding_model
+ self.use_local_embeddings = use_local_embeddings
+ self.documents: List[Document] = []
+ self.embeddings: Optional[np.ndarray] = None
+ self._indexed = False
+ self._source_files: set = set() # Track unique source files
+
+ def index(self, force: bool = False):
+ """
+ Index all documents in knowledge directory.
+
+ Args:
+ force: Force re-indexing even if already indexed
+ """
+ if self._indexed and not force:
+ return
+
+ chunks = []
+ self._source_files = set() # Reset source file tracking
+
+ # Process all files in knowledge directory
+ if self.knowledge_path.exists():
+ for file in self.knowledge_path.rglob("*"):
+ if not file.is_file():
+ continue
+
+ try:
+ if file.suffix in [".txt", ".md"]:
+ self._source_files.add(str(file))
+ content = file.read_text(encoding="utf-8", errors="ignore")
+ file_chunks = self._chunk_text(content, source=str(file))
+ chunks.extend(file_chunks)
+
+ elif file.suffix == ".json":
+ self._source_files.add(str(file))
+ data = json.loads(file.read_text(encoding="utf-8"))
+ if isinstance(data, list):
+ for item in data:
+ chunks.append(
+ Document(
+ content=json.dumps(item, indent=2),
+ source=str(file),
+ metadata=(
+ item
+ if isinstance(item, dict)
+ else {"data": item}
+ ),
+ )
+ )
+ else:
+ chunks.append(
+ Document(
+ content=json.dumps(data, indent=2),
+ source=str(file),
+ metadata=(
+ data
+ if isinstance(data, dict)
+ else {"data": data}
+ ),
+ )
+ )
+ except Exception as e:
+ print(f"[RAG] Error processing {file}: {e}")
+
+ self.documents = chunks
+
+ # Generate embeddings
+ if chunks:
+ texts = [doc.content for doc in chunks]
+
+ if self.use_local_embeddings:
+ from .embeddings import get_embeddings_local
+
+ self.embeddings = get_embeddings_local(texts)
+ else:
+ self.embeddings = get_embeddings(texts, model=self.embedding_model)
+
+ # Store embeddings in documents
+ for i, doc in enumerate(self.documents):
+ doc.embedding = self.embeddings[i]
+
+ self._indexed = True
+
+ def _chunk_text(
+ self, text: str, source: str, chunk_size: int = 1000, overlap: int = 200
+ ) -> List[Document]:
+ """
+ Split text into overlapping chunks.
+
+ Args:
+ text: The text to split
+ source: The source file path
+ chunk_size: Maximum chunk size in characters
+ overlap: Overlap between chunks
+
+ Returns:
+ List of Document objects
+ """
+ chunks = []
+
+ # Split by paragraphs first for better context
+ paragraphs = text.split("\n\n")
+ current_chunk = ""
+
+ for para in paragraphs:
+ if len(current_chunk) + len(para) + 2 <= chunk_size:
+ current_chunk += para + "\n\n"
+ else:
+ if current_chunk.strip():
+ chunks.append(
+ Document(content=current_chunk.strip(), source=source)
+ )
+ current_chunk = para + "\n\n"
+
+ # Add the last chunk
+ if current_chunk.strip():
+ chunks.append(Document(content=current_chunk.strip(), source=source))
+
+ # If no paragraphs were found, fall back to simple chunking
+ if not chunks and text.strip():
+ start = 0
+ while start < len(text):
+ end = start + chunk_size
+ chunk = text[start:end]
+
+ if chunk.strip():
+ chunks.append(Document(content=chunk.strip(), source=source))
+
+ start = end - overlap
+
+ return chunks
+
+ def search(
+ self, query: str, k: int = 5, threshold: float = 0.35, max_tokens: int = 1500
+ ) -> List[str]:
+ """
+ Find relevant documents for a query.
+
+ Args:
+ query: The search query
+ k: Maximum number of results to return
+ threshold: Minimum similarity threshold
+ max_tokens: Maximum total tokens to return (prevents context bloat)
+
+ Returns:
+ List of relevant document contents
+ """
+ # Guard against empty/invalid queries
+ if not query or not isinstance(query, str) or not query.strip():
+ return []
+
+ if not self._indexed:
+ self.index()
+
+ if not self.documents or self.embeddings is None:
+ return []
+
+ # Get query embedding
+ if self.use_local_embeddings:
+ from .embeddings import get_embeddings_local
+
+ query_embedding = get_embeddings_local([query])[0]
+ else:
+ query_embedding = get_embeddings([query], model=self.embedding_model)[0]
+
+ # Compute cosine similarities
+ similarities = np.dot(self.embeddings, query_embedding) / (
+ np.linalg.norm(self.embeddings, axis=1) * np.linalg.norm(query_embedding)
+ + 1e-10
+ )
+
+ # Get top k indices above threshold
+ indices_above_threshold = np.where(similarities >= threshold)[0]
+
+ if len(indices_above_threshold) > 0:
+ # Sort by similarity (descending) and take top k
+ sorted_indices = indices_above_threshold[
+ np.argsort(similarities[indices_above_threshold])[::-1]
+ ]
+ top_indices = sorted_indices[:k]
+ else:
+ # No results above threshold - return empty rather than irrelevant content
+ return []
+
+ # Collect results up to max_tokens budget
+ results = []
+ total_tokens = 0
+ for idx in top_indices:
+ content = self.documents[idx].content
+ # Rough token estimate: ~4 chars per token
+ chunk_tokens = len(content) // 4
+ if total_tokens + chunk_tokens > max_tokens and results:
+ # Stop if we'd exceed budget (but always include at least one)
+ break
+ results.append(content)
+ total_tokens += chunk_tokens
+
+ return results
+
+ def search_with_scores(
+ self, query: str, k: int = 5, threshold: float = 0.35
+ ) -> List[tuple[Document, float]]:
+ """
+ Search with similarity scores.
+
+ Args:
+ query: The search query
+ k: Maximum number of results to return
+ threshold: Minimum similarity threshold
+
+ Returns:
+ List of (Document, score) tuples above threshold
+ """
+ if not self._indexed:
+ self.index()
+
+ if not self.documents or self.embeddings is None:
+ return []
+
+ # Get query embedding
+ if self.use_local_embeddings:
+ from .embeddings import get_embeddings_local
+
+ query_embedding = get_embeddings_local([query])[0]
+ else:
+ query_embedding = get_embeddings([query], model=self.embedding_model)[0]
+
+ # Compute cosine similarities
+ similarities = np.dot(self.embeddings, query_embedding) / (
+ np.linalg.norm(self.embeddings, axis=1) * np.linalg.norm(query_embedding)
+ + 1e-10
+ )
+
+ # Get top k above threshold
+ indices_above_threshold = np.where(similarities >= threshold)[0]
+
+ if len(indices_above_threshold) > 0:
+ sorted_indices = indices_above_threshold[
+ np.argsort(similarities[indices_above_threshold])[::-1]
+ ]
+ top_indices = sorted_indices[:k]
+ else:
+ # Fallback: return single best result even if below threshold
+ top_indices = [np.argmax(similarities)]
+
+ return [(self.documents[i], float(similarities[i])) for i in top_indices]
+
+ def add_document(
+ self, content: str, source: str = "user", metadata: Optional[dict] = None
+ ):
+ """
+ Add a document to the knowledge base.
+
+ Args:
+ content: The document content
+ source: The source identifier
+ metadata: Optional metadata
+ """
+ doc = Document(content=content, source=source, metadata=metadata)
+
+ # Generate embedding
+ if self.use_local_embeddings:
+ from .embeddings import get_embeddings_local
+
+ new_embedding = get_embeddings_local([content])
+ else:
+ new_embedding = get_embeddings([content], model=self.embedding_model)
+
+ doc.embedding = new_embedding[0]
+ self.documents.append(doc)
+
+ # Update embeddings array
+ if self.embeddings is not None:
+ self.embeddings = np.vstack([self.embeddings, new_embedding])
+ else:
+ self.embeddings = new_embedding
+
+ def add_documents(self, documents: List[Document]):
+ """
+ Add multiple documents to the knowledge base.
+
+ Args:
+ documents: List of Document objects to add
+ """
+ if not documents:
+ return
+
+ texts = [doc.content for doc in documents]
+
+ if self.use_local_embeddings:
+ from .embeddings import get_embeddings_local
+
+ new_embeddings = get_embeddings_local(texts)
+ else:
+ new_embeddings = get_embeddings(texts, model=self.embedding_model)
+
+ for i, doc in enumerate(documents):
+ doc.embedding = new_embeddings[i]
+ self.documents.append(doc)
+
+ if self.embeddings is not None:
+ self.embeddings = np.vstack([self.embeddings, new_embeddings])
+ else:
+ self.embeddings = new_embeddings
+
+ def remove_document(self, doc_id: str) -> bool:
+ """
+ Remove a document by ID.
+
+ Args:
+ doc_id: The document ID to remove
+
+ Returns:
+ True if removed, False if not found
+ """
+ for i, doc in enumerate(self.documents):
+ if doc.doc_id == doc_id:
+ self.documents.pop(i)
+ if self.embeddings is not None:
+ self.embeddings = np.delete(self.embeddings, i, axis=0)
+ return True
+ return False
+
+ def clear(self):
+ """Clear all documents and embeddings."""
+ self.documents.clear()
+ self.embeddings = None
+ self._indexed = False
+ self._source_files = set()
+
+ def get_document_count(self) -> int:
+ """Get the number of source files indexed."""
+ return len(self._source_files)
+
+ def get_chunk_count(self) -> int:
+ """Get the number of indexed chunks (internal document segments)."""
+ return len(self.documents)
+
+ def save_index(self, path: Path):
+ """
+ Save the index to disk.
+
+ Args:
+ path: Path to save the index
+ """
+ import pickle
+
+ data = {
+ "documents": [
+ {
+ "content": doc.content,
+ "source": doc.source,
+ "metadata": doc.metadata,
+ "doc_id": doc.doc_id,
+ }
+ for doc in self.documents
+ ],
+ "embeddings": self.embeddings,
+ }
+
+ with open(path, "wb") as f:
+ pickle.dump(data, f)
+
+ def load_index(self, path: Path):
+ """
+ Load the index from disk.
+
+ Args:
+ path: Path to load the index from
+ """
+ import pickle
+
+ with open(path, "rb") as f:
+ data = pickle.load(f)
+
+ self.documents = [
+ Document(
+ content=d["content"],
+ source=d["source"],
+ metadata=d["metadata"],
+ doc_id=d["doc_id"],
+ )
+ for d in data["documents"]
+ ]
+ self.embeddings = data["embeddings"]
+
+ # Restore embeddings in documents
+ if self.embeddings is not None:
+ for i, doc in enumerate(self.documents):
+ doc.embedding = self.embeddings[i]
+
+ self._indexed = True
diff --git a/ghostcrew/knowledge/sources/cves.json b/ghostcrew/knowledge/sources/cves.json
new file mode 100644
index 0000000..a68b14a
--- /dev/null
+++ b/ghostcrew/knowledge/sources/cves.json
@@ -0,0 +1 @@
+[{"id":"CVE-2025-55182","description":"React4Shell - RCE via unsafe deserialization in React Server Components","cvss":10.0,"affected":"react-server-dom-* 19.0.0-19.2.0","patched":"19.0.1, 19.1.2, 19.2.1"},{"id":"CVE-2025-66478","description":"Next.js RCE via React Server Components vulnerability","cvss":10.0,"affected":"Next.js using vulnerable React RSC"}]
diff --git a/ghostcrew/knowledge/sources/methodologies.md b/ghostcrew/knowledge/sources/methodologies.md
new file mode 100644
index 0000000..1d2909f
--- /dev/null
+++ b/ghostcrew/knowledge/sources/methodologies.md
@@ -0,0 +1,5 @@
+# Example Knowledge
+
+Add .md, .txt, or .json files here for RAG context.
+
+Delete these examples when adding real content.
diff --git a/ghostcrew/knowledge/sources/wordlists.txt b/ghostcrew/knowledge/sources/wordlists.txt
new file mode 100644
index 0000000..ecc1375
--- /dev/null
+++ b/ghostcrew/knowledge/sources/wordlists.txt
@@ -0,0 +1,4 @@
+admin
+root
+password
+test
diff --git a/ghostcrew/llm/__init__.py b/ghostcrew/llm/__init__.py
new file mode 100644
index 0000000..d65cc10
--- /dev/null
+++ b/ghostcrew/llm/__init__.py
@@ -0,0 +1,15 @@
+"""LLM integration for GhostCrew."""
+
+from .config import ModelConfig
+from .llm import LLM, LLMResponse
+from .memory import ConversationMemory
+from .utils import count_tokens, truncate_to_tokens
+
+__all__ = [
+ "LLM",
+ "LLMResponse",
+ "ModelConfig",
+ "ConversationMemory",
+ "count_tokens",
+ "truncate_to_tokens",
+]
diff --git a/ghostcrew/llm/config.py b/ghostcrew/llm/config.py
new file mode 100644
index 0000000..64ba495
--- /dev/null
+++ b/ghostcrew/llm/config.py
@@ -0,0 +1,54 @@
+"""LLM configuration for GhostCrew."""
+
+from dataclasses import dataclass
+
+
+@dataclass
+class ModelConfig:
+ """LLM model configuration."""
+
+ # Generation parameters
+ temperature: float = 0.7
+ max_tokens: int = 4096
+ top_p: float = 1.0
+ frequency_penalty: float = 0.0
+ presence_penalty: float = 0.0
+
+ # Context management
+ max_context_tokens: int = 128000
+
+ # Retry settings for rate limits
+ max_retries: int = 5 # Retry up to 5 times for rate limits
+ retry_delay: float = 2.0 # Base delay - will exponentially increase
+
+ # Timeout
+ timeout: int = 120
+
+ def to_dict(self) -> dict:
+ """Convert to dictionary for LLM calls."""
+ return {
+ "temperature": self.temperature,
+ "max_tokens": self.max_tokens,
+ "top_p": self.top_p,
+ "frequency_penalty": self.frequency_penalty,
+ "presence_penalty": self.presence_penalty,
+ }
+
+ @classmethod
+ def for_model(cls, model: str) -> "ModelConfig":
+ """Get configuration for a model. Uses sensible defaults for modern LLMs."""
+ return cls(temperature=0.7, max_tokens=4096, max_context_tokens=128000)
+
+
+# Preset configurations
+CREATIVE_CONFIG = ModelConfig(
+ temperature=0.9, top_p=0.95, frequency_penalty=0.5, presence_penalty=0.5
+)
+
+PRECISE_CONFIG = ModelConfig(
+ temperature=0.1, top_p=1.0, frequency_penalty=0.0, presence_penalty=0.0
+)
+
+BALANCED_CONFIG = ModelConfig(
+ temperature=0.7, top_p=1.0, frequency_penalty=0.0, presence_penalty=0.0
+)
diff --git a/ghostcrew/llm/llm.py b/ghostcrew/llm/llm.py
new file mode 100644
index 0000000..10f644c
--- /dev/null
+++ b/ghostcrew/llm/llm.py
@@ -0,0 +1,325 @@
+"""LiteLLM wrapper for GhostCrew."""
+
+import asyncio
+import random
+from dataclasses import dataclass
+from typing import TYPE_CHECKING, Any, AsyncIterator, List, Optional
+
+from ..config.constants import DEFAULT_MODEL
+from .config import ModelConfig
+from .memory import ConversationMemory
+
+if TYPE_CHECKING:
+ from ..knowledge import RAGEngine
+ from ..tools import Tool
+
+
+@dataclass
+class LLMResponse:
+ """Response from LLM."""
+
+ content: Optional[str]
+ tool_calls: Optional[List[Any]]
+ usage: Optional[dict]
+ model: str = ""
+ finish_reason: str = ""
+
+
+class LLM:
+ """LiteLLM wrapper with tool calling support."""
+
+ def __init__(
+ self,
+ model: str = None,
+ config: Optional[ModelConfig] = None,
+ rag_engine: Optional["RAGEngine"] = None,
+ ):
+ """
+ Initialize the LLM wrapper.
+
+ Args:
+ model: The model to use (supports LiteLLM model names)
+ config: Model configuration
+ rag_engine: Optional RAG engine for context injection
+ """
+ self.model = model or DEFAULT_MODEL
+ self.config = config or ModelConfig()
+ self.rag_engine = rag_engine
+ self.memory = ConversationMemory(max_tokens=self.config.max_context_tokens)
+
+ # Ensure litellm is available
+ try:
+ import litellm
+
+ # Drop unsupported params for models that don't support them
+ litellm.drop_params = True
+ self._litellm = litellm
+ except ImportError as e:
+ raise ImportError(
+ "litellm is required for LLM functionality. "
+ "Install with: pip install litellm"
+ ) from e
+
+ def _is_rate_limit_error(self, error: Exception) -> bool:
+ """Check if an error is a rate limit error."""
+ error_str = str(error).lower()
+ error_type = type(error).__name__.lower()
+ return (
+ "rate" in error_str
+ and "limit" in error_str
+ or "ratelimit" in error_type
+ or "429" in error_str
+ or "too many requests" in error_str
+ )
+
+ async def _retry_with_backoff(self, coro_factory, max_retries: int = None):
+ """
+ Retry a coroutine with exponential backoff for rate limits.
+
+ Args:
+ coro_factory: A callable that returns a new coroutine each call
+ max_retries: Max retry attempts (uses config if not specified)
+ """
+ retries = max_retries or self.config.max_retries
+ base_delay = self.config.retry_delay
+
+ for attempt in range(retries + 1):
+ try:
+ return await coro_factory()
+ except Exception as e:
+ if not self._is_rate_limit_error(e) or attempt >= retries:
+ raise
+
+ # Exponential backoff with jitter
+ delay = base_delay * (2**attempt) + random.uniform(0, 1)
+ await asyncio.sleep(delay)
+
+ # Should not reach here
+ raise RuntimeError("Retry logic failed unexpectedly")
+
+ async def generate(
+ self,
+ system_prompt: str,
+ messages: List[dict],
+ tools: Optional[List["Tool"]] = None,
+ stream: bool = False,
+ ) -> LLMResponse:
+ """
+ Generate a response from the LLM.
+
+ Args:
+ system_prompt: The system prompt
+ messages: Conversation messages
+ tools: Available tools for function calling
+ stream: Whether to stream the response
+
+ Returns:
+ LLMResponse with the result
+ """
+ # Build messages list
+ llm_messages = [{"role": "system", "content": system_prompt}]
+
+ # Add conversation history with summarization if needed
+ history = await self.memory.get_messages_with_summary(
+ messages, llm_call=self._summarize_call
+ )
+ llm_messages.extend(history)
+
+ # Build tools list
+ llm_tools = None
+ if tools:
+ llm_tools = [tool.to_llm_format() for tool in tools if tool.enabled]
+
+ try:
+ # Build call kwargs - only pass non-default optional params
+ # to avoid conflicts (e.g., Claude doesn't allow temperature + top_p together)
+ call_kwargs = {
+ "model": self.model,
+ "messages": llm_messages,
+ "tools": llm_tools,
+ "temperature": self.config.temperature,
+ "max_tokens": self.config.max_tokens,
+ }
+ # Only add optional params if explicitly changed from defaults
+ if self.config.top_p != 1.0:
+ call_kwargs["top_p"] = self.config.top_p
+ if self.config.frequency_penalty != 0.0:
+ call_kwargs["frequency_penalty"] = self.config.frequency_penalty
+ if self.config.presence_penalty != 0.0:
+ call_kwargs["presence_penalty"] = self.config.presence_penalty
+
+ # Call LLM with retry for rate limits
+ async def _call():
+ return await self._litellm.acompletion(**call_kwargs)
+
+ response = await self._retry_with_backoff(_call)
+
+ # Parse response
+ choice = response.choices[0]
+ message = choice.message
+
+ # Handle usage - convert to dict safely
+ usage_dict = None
+ if response.usage:
+ try:
+ usage_dict = dict(response.usage)
+ except (TypeError, ValueError):
+ usage_dict = {
+ "prompt_tokens": getattr(response.usage, "prompt_tokens", 0),
+ "completion_tokens": getattr(
+ response.usage, "completion_tokens", 0
+ ),
+ "total_tokens": getattr(response.usage, "total_tokens", 0),
+ }
+
+ return LLMResponse(
+ content=message.content,
+ tool_calls=message.tool_calls,
+ usage=usage_dict,
+ model=response.model if hasattr(response, "model") else self.model,
+ finish_reason=choice.finish_reason or "",
+ )
+
+ except Exception as e:
+ # Return error as response (after retries exhausted)
+ return LLMResponse(
+ content=f"LLM Error: {str(e)}",
+ tool_calls=None,
+ usage=None,
+ model=self.model,
+ finish_reason="error",
+ )
+
+ async def generate_stream(
+ self,
+ system_prompt: str,
+ messages: List[dict],
+ tools: Optional[List["Tool"]] = None,
+ ) -> AsyncIterator[str]:
+ """
+ Stream a response from the LLM.
+
+ Args:
+ system_prompt: The system prompt
+ messages: Conversation messages
+ tools: Available tools for function calling
+
+ Yields:
+ Response content chunks
+ """
+ llm_messages = [{"role": "system", "content": system_prompt}]
+ history = await self.memory.get_messages_with_summary(
+ messages, llm_call=self._summarize_call
+ )
+ llm_messages.extend(history)
+
+ llm_tools = None
+ if tools:
+ llm_tools = [tool.to_llm_format() for tool in tools if tool.enabled]
+
+ try:
+ response = await self._litellm.acompletion(
+ model=self.model,
+ messages=llm_messages,
+ tools=llm_tools,
+ temperature=self.config.temperature,
+ max_tokens=self.config.max_tokens,
+ stream=True,
+ )
+
+ async for chunk in response:
+ if chunk.choices[0].delta.content:
+ yield chunk.choices[0].delta.content
+
+ except Exception as e:
+ yield f"\nLLM Error: {str(e)}"
+
+ async def simple_completion(
+ self, prompt: str, system: str = "You are a helpful assistant."
+ ) -> str:
+ """
+ Simple completion without tools.
+
+ Args:
+ prompt: The user prompt
+ system: The system prompt
+
+ Returns:
+ The response text
+ """
+ response = await self.generate(
+ system_prompt=system,
+ messages=[{"role": "user", "content": prompt}],
+ tools=None,
+ )
+ return response.content or ""
+
+ def set_model(self, model: str):
+ """Change the model."""
+ self.model = model
+
+ def update_config(self, **kwargs):
+ """Update configuration parameters."""
+ for key, value in kwargs.items():
+ if hasattr(self.config, key):
+ setattr(self.config, key, value)
+
+ async def _summarize_call(self, prompt: str) -> str:
+ """
+ Internal LLM call for summarization.
+
+ Args:
+ prompt: The summarization prompt
+
+ Returns:
+ Summary text
+ """
+ try:
+ response = await self._litellm.acompletion(
+ model=self.model,
+ messages=[
+ {
+ "role": "system",
+ "content": "You are a terse summarizer for a pentesting agent.",
+ },
+ {"role": "user", "content": prompt},
+ ],
+ temperature=0.3, # Lower temperature for consistent summaries
+ max_tokens=1000, # Summaries should be concise
+ )
+ return response.choices[0].message.content or ""
+ except Exception as e:
+ return f"[Summarization failed: {e}]"
+
+ def clear_memory(self):
+ """Clear conversation memory and summary cache."""
+ self.memory.clear_summary_cache()
+
+ def get_memory_stats(self) -> dict:
+ """Get memory usage statistics."""
+ return self.memory.get_stats()
+
+ def get_available_models(self) -> List[str]:
+ """
+ Get list of commonly available models.
+
+ Returns:
+ List of model names
+ """
+ return [
+ # OpenAI
+ "gpt-5",
+ "gpt-4.1",
+ "gpt-4.1-mini",
+ "gpt-4.1-nano",
+ # Anthropic
+ "claude-sonnet-4-20250514",
+ "claude-opus-4-20250514",
+ # Google
+ "gemini-2.5-pro",
+ "gemini-2.5-flash",
+ # Others via LiteLLM
+ "ollama/llama3",
+ "ollama/mixtral",
+ "groq/llama3-70b-8192",
+ ]
diff --git a/ghostcrew/llm/memory.py b/ghostcrew/llm/memory.py
new file mode 100644
index 0000000..bb71084
--- /dev/null
+++ b/ghostcrew/llm/memory.py
@@ -0,0 +1,253 @@
+"""Conversation memory management for GhostCrew."""
+
+from typing import Awaitable, Callable, List, Optional
+
+SUMMARY_PROMPT = """Summarize this conversation history for a pentesting agent. Be terse.
+
+Focus on:
+- Targets discovered (IPs, domains, hosts)
+- Open ports and services found
+- Credentials or secrets discovered
+- Vulnerabilities identified
+- What was attempted and failed (to avoid repeating)
+- Current objective/progress
+
+Omit: verbose tool output, back-and-forth clarifications, redundant info.
+
+Conversation to summarize:
+{conversation}
+
+Summary:"""
+
+
+class ConversationMemory:
+ """Manages conversation history with token limits and summarization."""
+
+ def __init__(
+ self,
+ max_tokens: int = 128000,
+ reserve_ratio: float = 0.8,
+ recent_to_keep: int = 10,
+ summarize_threshold: float = 0.6,
+ ):
+ """
+ Initialize conversation memory.
+
+ Args:
+ max_tokens: Maximum context tokens
+ reserve_ratio: Ratio of tokens to use (leave room for response)
+ recent_to_keep: Number of recent messages to keep in full
+ summarize_threshold: Summarize when history exceeds this ratio of budget
+ """
+ self.max_tokens = max_tokens
+ self.reserve_ratio = reserve_ratio
+ self.recent_to_keep = recent_to_keep
+ self.summarize_threshold = summarize_threshold
+ self._encoder = None
+ self._cached_summary: Optional[str] = None
+ self._summarized_count: int = 0
+
+ @property
+ def encoder(self):
+ """Lazy load the tokenizer."""
+ if self._encoder is None:
+ try:
+ import tiktoken
+
+ self._encoder = tiktoken.get_encoding("cl100k_base")
+ except ImportError:
+ self._encoder = None
+ return self._encoder
+
+ @property
+ def token_budget(self) -> int:
+ """Available tokens for history."""
+ return int(self.max_tokens * self.reserve_ratio)
+
+ def get_messages(self, messages: List[dict]) -> List[dict]:
+ """
+ Get messages that fit within token limit (sync, no summarization).
+ Falls back to truncation if over budget.
+
+ Args:
+ messages: Full conversation history
+
+ Returns:
+ Messages that fit within the token budget
+ """
+ if not messages:
+ return []
+
+ # If we have a cached summary, prepend it
+ if self._cached_summary and len(messages) > self._summarized_count:
+ result = [
+ {
+ "role": "system",
+ "content": f"Previous conversation summary:\n{self._cached_summary}",
+ }
+ ]
+ # Add messages after the summarized portion
+ recent = messages[self._summarized_count :]
+ result.extend(
+ self._truncate_to_fit(
+ recent, self.token_budget - self._count_tokens(result[0])
+ )
+ )
+ return result
+
+ return self._truncate_to_fit(messages, self.token_budget)
+
+ async def get_messages_with_summary(
+ self, messages: List[dict], llm_call: Callable[[str], Awaitable[str]]
+ ) -> List[dict]:
+ """
+ Get messages, summarizing older ones if needed.
+
+ Args:
+ messages: Full conversation history
+ llm_call: Async function to call LLM for summarization
+
+ Returns:
+ Messages with older ones summarized if over threshold
+ """
+ if not messages:
+ return []
+
+ total_tokens = self.get_total_tokens(messages)
+ threshold_tokens = int(self.token_budget * self.summarize_threshold)
+
+ # Check if we need to summarize
+ if total_tokens <= threshold_tokens:
+ return messages
+
+ # Don't summarize if we don't have enough messages
+ if len(messages) <= self.recent_to_keep:
+ return self._truncate_to_fit(messages, self.token_budget)
+
+ # Split messages: older to summarize, recent to keep
+ split_point = len(messages) - self.recent_to_keep
+ older = messages[:split_point]
+ recent = messages[-self.recent_to_keep :]
+
+ # Check if we already summarized these messages
+ if split_point <= self._summarized_count and self._cached_summary:
+ result = [
+ {
+ "role": "system",
+ "content": f"Previous conversation summary:\n{self._cached_summary}",
+ }
+ ]
+ result.extend(recent)
+ return result
+
+ # Summarize older messages
+ summary = await self._summarize(older, llm_call)
+
+ # Cache the summary
+ self._cached_summary = summary
+ self._summarized_count = split_point
+
+ # Build result
+ result = [
+ {"role": "system", "content": f"Previous conversation summary:\n{summary}"}
+ ]
+ result.extend(recent)
+
+ return result
+
+ async def _summarize(
+ self, messages: List[dict], llm_call: Callable[[str], Awaitable[str]]
+ ) -> str:
+ """
+ Summarize a list of messages.
+
+ Args:
+ messages: Messages to summarize
+ llm_call: Async function to call LLM
+
+ Returns:
+ Summary string
+ """
+ # Format messages for summarization
+ conversation_text = self._format_for_summary(messages)
+
+ # Call LLM for summary
+ prompt = SUMMARY_PROMPT.format(conversation=conversation_text)
+
+ try:
+ summary = await llm_call(prompt)
+ return summary.strip()
+ except Exception as e:
+ # Fallback: simple truncation indicator
+ return f"[{len(messages)} earlier messages - summarization failed: {e}]"
+
+ def _format_for_summary(self, messages: List[dict]) -> str:
+ """Format messages as text for summarization."""
+ lines = []
+ for msg in messages:
+ role = msg.get("role", "unknown")
+ content = msg.get("content", "")
+
+ # Truncate very long messages for summarization input
+ if len(content) > 2000:
+ content = content[:2000] + "...[truncated]"
+
+ if role == "user":
+ lines.append(f"User: {content}")
+ elif role == "assistant":
+ lines.append(f"Assistant: {content}")
+ elif role == "tool":
+ tool_name = msg.get("name", "tool")
+ lines.append(f"Tool ({tool_name}): {content}")
+
+ return "\n\n".join(lines)
+
+ def _truncate_to_fit(self, messages: List[dict], budget: int) -> List[dict]:
+ """Truncate messages from the beginning to fit budget."""
+ total_tokens = 0
+ result = []
+
+ for msg in reversed(messages):
+ msg_tokens = self._count_tokens(msg)
+ if total_tokens + msg_tokens > budget:
+ break
+ result.insert(0, msg)
+ total_tokens += msg_tokens
+
+ return result
+
+ def _count_tokens(self, message: dict) -> int:
+ """Count tokens in a message."""
+ content = message.get("content", "")
+
+ if isinstance(content, str):
+ if self.encoder:
+ return len(self.encoder.encode(content))
+ else:
+ return int(len(content.split()) * 1.3)
+
+ return 0
+
+ def get_total_tokens(self, messages: List[dict]) -> int:
+ """Get total token count for messages."""
+ return sum(self._count_tokens(msg) for msg in messages)
+
+ def fits_in_context(self, messages: List[dict]) -> bool:
+ """Check if messages fit in context window."""
+ return self.get_total_tokens(messages) <= self.token_budget
+
+ def clear_summary_cache(self):
+ """Clear the cached summary (call when conversation is cleared)."""
+ self._cached_summary = None
+ self._summarized_count = 0
+
+ def get_stats(self) -> dict:
+ """Get memory statistics."""
+ return {
+ "max_tokens": self.max_tokens,
+ "token_budget": self.token_budget,
+ "summarize_threshold": int(self.token_budget * self.summarize_threshold),
+ "recent_to_keep": self.recent_to_keep,
+ "has_summary": self._cached_summary is not None,
+ "summarized_message_count": self._summarized_count,
+ }
diff --git a/ghostcrew/llm/utils.py b/ghostcrew/llm/utils.py
new file mode 100644
index 0000000..49f8b3b
--- /dev/null
+++ b/ghostcrew/llm/utils.py
@@ -0,0 +1,201 @@
+"""LLM utility functions for GhostCrew."""
+
+from typing import List, Optional
+
+
+def count_tokens(text: str, model: str = "gpt-4") -> int:
+ """
+ Count tokens in text.
+
+ Args:
+ text: The text to count
+ model: The model (used to select tokenizer)
+
+ Returns:
+ Token count
+ """
+ try:
+ import tiktoken
+
+ # Select encoding based on model
+ if "gpt-4" in model or "gpt-3.5" in model:
+ encoding = tiktoken.get_encoding("cl100k_base")
+ else:
+ encoding = tiktoken.get_encoding("cl100k_base")
+
+ return len(encoding.encode(text))
+
+ except ImportError:
+ # Fallback approximation
+ return int(len(text.split()) * 1.3)
+
+
+def truncate_to_tokens(text: str, max_tokens: int, model: str = "gpt-4") -> str:
+ """
+ Truncate text to a maximum number of tokens.
+
+ Args:
+ text: The text to truncate
+ max_tokens: Maximum tokens
+ model: The model (used to select tokenizer)
+
+ Returns:
+ Truncated text
+ """
+ try:
+ import tiktoken
+
+ encoding = tiktoken.get_encoding("cl100k_base")
+ tokens = encoding.encode(text)
+
+ if len(tokens) <= max_tokens:
+ return text
+
+ truncated_tokens = tokens[:max_tokens]
+ return encoding.decode(truncated_tokens)
+
+ except ImportError:
+ # Fallback approximation
+ words = text.split()
+ target_words = int(max_tokens / 1.3)
+ return " ".join(words[:target_words])
+
+
+def estimate_tokens(text: str) -> int:
+ """
+ Quick estimation of token count without loading tokenizer.
+
+ Args:
+ text: The text to estimate
+
+ Returns:
+ Estimated token count
+ """
+ # Average: ~4 characters per token for English
+ return len(text) // 4
+
+
+def format_messages_for_display(messages: List[dict], max_length: int = 500) -> str:
+ """
+ Format messages for display (e.g., in logs).
+
+ Args:
+ messages: Messages to format
+ max_length: Maximum length per message
+
+ Returns:
+ Formatted string
+ """
+ lines = []
+
+ for msg in messages:
+ role = msg.get("role", "unknown").upper()
+ content = msg.get("content", "")
+
+ if len(content) > max_length:
+ content = content[:max_length] + "..."
+
+ lines.append(f"[{role}] {content}")
+
+ return "\n".join(lines)
+
+
+def extract_code_blocks(text: str) -> List[dict]:
+ """
+ Extract code blocks from markdown text.
+
+ Args:
+ text: Text containing code blocks
+
+ Returns:
+ List of dicts with 'language' and 'code' keys
+ """
+ import re
+
+ pattern = r"```(\w*)\n(.*?)```"
+ matches = re.findall(pattern, text, re.DOTALL)
+
+ return [
+ {"language": lang or "text", "code": code.strip()} for lang, code in matches
+ ]
+
+
+def extract_tool_calls_from_text(text: str) -> List[dict]:
+ """
+ Extract tool call references from text (for display purposes).
+
+ Args:
+ text: Text that may contain tool references
+
+ Returns:
+ List of potential tool calls
+ """
+ import re
+
+ # Look for patterns like "use tool_name" or "call tool_name"
+ pattern = r"(?:use|call|execute|run)\s+(\w+)"
+ matches = re.findall(pattern, text.lower())
+
+ return [{"tool": match} for match in matches]
+
+
+def sanitize_for_shell(text: str) -> str:
+ """
+ Sanitize text for safe shell usage.
+
+ Args:
+ text: Text to sanitize
+
+ Returns:
+ Sanitized text
+ """
+ # Escape dangerous characters
+ dangerous = ["`", "$", "\\", '"', "'", ";", "&", "|", ">", "<", "\n", "\r"]
+
+ result = text
+ for char in dangerous:
+ result = result.replace(char, f"\\{char}")
+
+ return result
+
+
+def parse_llm_json(text: str) -> Optional[dict]:
+ """
+ Attempt to parse JSON from LLM output.
+
+ Args:
+ text: Text that may contain JSON
+
+ Returns:
+ Parsed dict or None
+ """
+ import json
+ import re
+
+ # Try direct parse first
+ try:
+ return json.loads(text)
+ except json.JSONDecodeError:
+ pass
+
+ # Try to find JSON in code blocks
+ pattern = r"```(?:json)?\n?(.*?)```"
+ matches = re.findall(pattern, text, re.DOTALL)
+
+ for match in matches:
+ try:
+ return json.loads(match.strip())
+ except json.JSONDecodeError:
+ continue
+
+ # Try to find JSON object in text
+ pattern = r"\{[^{}]*\}"
+ matches = re.findall(pattern, text)
+
+ for match in matches:
+ try:
+ return json.loads(match)
+ except json.JSONDecodeError:
+ continue
+
+ return None
diff --git a/ghostcrew/mcp/__init__.py b/ghostcrew/mcp/__init__.py
new file mode 100644
index 0000000..502657b
--- /dev/null
+++ b/ghostcrew/mcp/__init__.py
@@ -0,0 +1,17 @@
+"""MCP (Model Context Protocol) integration for GhostCrew."""
+
+from .discovery import MCPDiscovery
+from .manager import MCPManager, MCPServer, MCPServerConfig
+from .tools import create_mcp_tool
+from .transport import MCPTransport, SSETransport, StdioTransport
+
+__all__ = [
+ "MCPManager",
+ "MCPServerConfig",
+ "MCPServer",
+ "MCPTransport",
+ "StdioTransport",
+ "SSETransport",
+ "create_mcp_tool",
+ "MCPDiscovery",
+]
diff --git a/ghostcrew/mcp/discovery.py b/ghostcrew/mcp/discovery.py
new file mode 100644
index 0000000..42fc134
--- /dev/null
+++ b/ghostcrew/mcp/discovery.py
@@ -0,0 +1,204 @@
+"""MCP tool discovery for GhostCrew."""
+
+import json
+from dataclasses import dataclass
+from pathlib import Path
+from typing import Any, Dict, List, Optional
+
+
+@dataclass
+class DiscoveredServer:
+ """A discovered MCP server."""
+
+ name: str
+ description: str
+ type: str # "stdio" or "sse"
+ command: Optional[str] = None
+ args: Optional[List[str]] = None
+ url: Optional[str] = None
+ tools: List[dict] = None
+
+ def __post_init__(self):
+ if self.tools is None:
+ self.tools = []
+
+
+class MCPDiscovery:
+ """Discovers available MCP servers and tools."""
+
+ # Known MCP servers for security tools
+ KNOWN_SERVERS = [
+ {
+ "name": "nmap",
+ "description": "Network scanning and host discovery",
+ "type": "stdio",
+ "command": "npx",
+ "args": ["-y", "@modelcontextprotocol/server-nmap"],
+ },
+ {
+ "name": "filesystem",
+ "description": "File system operations",
+ "type": "stdio",
+ "command": "npx",
+ "args": ["-y", "@modelcontextprotocol/server-filesystem"],
+ },
+ {
+ "name": "fetch",
+ "description": "HTTP requests and web fetching",
+ "type": "stdio",
+ "command": "npx",
+ "args": ["-y", "@modelcontextprotocol/server-fetch"],
+ },
+ ]
+
+ def __init__(self, config_path: Path = Path("mcp.json")):
+ """
+ Initialize MCP discovery.
+
+ Args:
+ config_path: Path to the MCP configuration file
+ """
+ self.config_path = config_path
+
+ def discover_local(self) -> List[DiscoveredServer]:
+ """
+ Discover locally installed MCP servers.
+
+ Returns:
+ List of discovered servers
+ """
+ discovered = []
+
+ # Check for npm global packages
+ # Check for Python packages
+ # This is a simplified implementation
+
+ for server_info in self.KNOWN_SERVERS:
+ discovered.append(DiscoveredServer(**server_info))
+
+ return discovered
+
+ def load_from_config(self) -> List[Dict[str, Any]]:
+ """
+ Load server configurations from file.
+
+ Returns:
+ List of server configurations
+ """
+ if not self.config_path.exists():
+ return []
+
+ try:
+ config = json.loads(self.config_path.read_text(encoding="utf-8"))
+ return config.get("servers", [])
+ except json.JSONDecodeError:
+ return []
+
+ def add_server_to_config(
+ self,
+ name: str,
+ server_type: str,
+ command: Optional[str] = None,
+ args: Optional[List[str]] = None,
+ url: Optional[str] = None,
+ env: Optional[Dict[str, str]] = None,
+ ) -> bool:
+ """
+ Add a server to the configuration file.
+
+ Args:
+ name: Server name
+ server_type: "stdio" or "sse"
+ command: Command for stdio servers
+ args: Arguments for stdio servers
+ url: URL for SSE servers
+ env: Environment variables
+
+ Returns:
+ True if added successfully
+ """
+ # Load existing config
+ if self.config_path.exists():
+ try:
+ config = json.loads(self.config_path.read_text(encoding="utf-8"))
+ except json.JSONDecodeError:
+ config = {"servers": []}
+ else:
+ config = {"servers": []}
+
+ # Check if server already exists
+ for existing in config["servers"]:
+ if existing.get("name") == name:
+ return False
+
+ # Build server config
+ server_config = {"name": name, "type": server_type, "enabled": True}
+
+ if server_type == "stdio":
+ server_config["command"] = command
+ server_config["args"] = args or []
+ if env:
+ server_config["env"] = env
+ elif server_type == "sse":
+ server_config["url"] = url
+
+ config["servers"].append(server_config)
+
+ # Save config
+ self.config_path.write_text(json.dumps(config, indent=2), encoding="utf-8")
+
+ return True
+
+ def remove_server_from_config(self, name: str) -> bool:
+ """
+ Remove a server from the configuration file.
+
+ Args:
+ name: Server name to remove
+
+ Returns:
+ True if removed successfully
+ """
+ if not self.config_path.exists():
+ return False
+
+ try:
+ config = json.loads(self.config_path.read_text(encoding="utf-8"))
+ except json.JSONDecodeError:
+ return False
+
+ original_count = len(config.get("servers", []))
+ config["servers"] = [
+ s for s in config.get("servers", []) if s.get("name") != name
+ ]
+
+ if len(config["servers"]) == original_count:
+ return False
+
+ self.config_path.write_text(json.dumps(config, indent=2), encoding="utf-8")
+
+ return True
+
+ def generate_default_config(self) -> Dict[str, Any]:
+ """
+ Generate a default MCP configuration.
+
+ Returns:
+ Default configuration dictionary
+ """
+ return {
+ "servers": [
+ {
+ "name": "filesystem",
+ "type": "stdio",
+ "command": "npx",
+ "args": ["-y", "@modelcontextprotocol/server-filesystem", "/tmp"],
+ "enabled": True,
+ }
+ ]
+ }
+
+ def save_default_config(self):
+ """Save the default configuration to file."""
+ config = self.generate_default_config()
+ self.config_path.write_text(json.dumps(config, indent=2), encoding="utf-8")
diff --git a/ghostcrew/mcp/manager.py b/ghostcrew/mcp/manager.py
new file mode 100644
index 0000000..83893e5
--- /dev/null
+++ b/ghostcrew/mcp/manager.py
@@ -0,0 +1,279 @@
+"""MCP server connection manager for GhostCrew.
+
+Uses standard MCP configuration format:
+{
+ "mcpServers": {
+ "server-name": {
+ "command": "npx",
+ "args": ["-y", "package-name"],
+ "env": {"VAR": "value"}
+ }
+ }
+}
+"""
+
+import json
+import os
+from dataclasses import dataclass, field
+from pathlib import Path
+from typing import Any, Dict, List, Optional
+
+from .tools import create_mcp_tool
+from .transport import MCPTransport, StdioTransport
+
+
+@dataclass
+class MCPServerConfig:
+ """Configuration for an MCP server."""
+
+ name: str
+ command: str
+ args: List[str] = field(default_factory=list)
+ env: Dict[str, str] = field(default_factory=dict)
+ enabled: bool = True
+ description: str = ""
+
+
+@dataclass
+class MCPServer:
+ """Represents a connected MCP server."""
+
+ name: str
+ config: MCPServerConfig
+ transport: MCPTransport
+ tools: List[dict] = field(default_factory=list)
+ connected: bool = False
+
+ async def disconnect(self):
+ """Disconnect from the server."""
+ if self.connected:
+ await self.transport.disconnect()
+ self.connected = False
+
+
+class MCPManager:
+ """Manages MCP server connections and exposes tools to agents."""
+
+ DEFAULT_CONFIG_PATHS = [
+ Path.cwd() / "mcp_servers.json",
+ Path.cwd() / "mcp.json",
+ Path(__file__).parent / "mcp_servers.json",
+ Path.home() / ".ghostcrew" / "mcp_servers.json",
+ ]
+
+ def __init__(self, config_path: Optional[Path] = None):
+ self.config_path = config_path or self._find_config()
+ self.servers: Dict[str, MCPServer] = {}
+ self._message_id = 0
+
+ def _find_config(self) -> Path:
+ for path in self.DEFAULT_CONFIG_PATHS:
+ if path.exists():
+ return path
+ return self.DEFAULT_CONFIG_PATHS[0]
+
+ def _get_next_id(self) -> int:
+ self._message_id += 1
+ return self._message_id
+
+ def _load_config(self) -> Dict[str, MCPServerConfig]:
+ if not self.config_path.exists():
+ return {}
+ try:
+ raw = json.loads(self.config_path.read_text(encoding="utf-8"))
+ servers = {}
+ mcp_servers = raw.get("mcpServers", {})
+ for name, config in mcp_servers.items():
+ if not config.get("command"):
+ continue
+ servers[name] = MCPServerConfig(
+ name=name,
+ command=config["command"],
+ args=config.get("args", []),
+ env=config.get("env", {}),
+ enabled=config.get("enabled", True),
+ description=config.get("description", ""),
+ )
+ return servers
+ except json.JSONDecodeError as e:
+ print(f"[MCP] Error loading config: {e}")
+ return {}
+
+ def _save_config(self, servers: Dict[str, MCPServerConfig]):
+ config = {"mcpServers": {}}
+ for name, server in servers.items():
+ server_config = {"command": server.command, "args": server.args}
+ if server.env:
+ server_config["env"] = server.env
+ if server.description:
+ server_config["description"] = server.description
+ if not server.enabled:
+ server_config["enabled"] = False
+ config["mcpServers"][name] = server_config
+ self.config_path.parent.mkdir(parents=True, exist_ok=True)
+ self.config_path.write_text(json.dumps(config, indent=2), encoding="utf-8")
+
+ def add_server(
+ self,
+ name: str,
+ command: str,
+ args: List[str] = None,
+ env: Dict[str, str] = None,
+ description: str = "",
+ ):
+ servers = self._load_config()
+ servers[name] = MCPServerConfig(
+ name=name,
+ command=command,
+ args=args or [],
+ env=env or {},
+ description=description,
+ )
+ self._save_config(servers)
+ print(f"[MCP] Added server: {name}")
+
+ def remove_server(self, name: str) -> bool:
+ servers = self._load_config()
+ if name in servers:
+ del servers[name]
+ self._save_config(servers)
+ return True
+ return False
+
+ def list_configured_servers(self) -> List[dict]:
+ servers = self._load_config()
+ return [
+ {
+ "name": n,
+ "command": s.command,
+ "args": s.args,
+ "env": s.env,
+ "enabled": s.enabled,
+ "description": s.description,
+ "connected": n in self.servers and self.servers[n].connected,
+ }
+ for n, s in servers.items()
+ ]
+
+ async def connect_all(self) -> List[Any]:
+ servers_config = self._load_config()
+ all_tools = []
+ for name, config in servers_config.items():
+ if not config.enabled:
+ continue
+ server = await self._connect_server(config)
+ if server:
+ self.servers[name] = server
+ for tool_def in server.tools:
+ tool = create_mcp_tool(tool_def, server, self)
+ all_tools.append(tool)
+ print(f"[MCP] Connected to {name} with {len(server.tools)} tools")
+ return all_tools
+
+ async def connect_server(self, name: str) -> Optional[MCPServer]:
+ servers_config = self._load_config()
+ if name not in servers_config:
+ return None
+ config = servers_config[name]
+ server = await self._connect_server(config)
+ if server:
+ self.servers[name] = server
+ return server
+
+ async def _connect_server(self, config: MCPServerConfig) -> Optional[MCPServer]:
+ transport = None
+ try:
+ env = {**os.environ, **config.env}
+ transport = StdioTransport(
+ command=config.command, args=config.args, env=env
+ )
+ await transport.connect()
+
+ await transport.send(
+ {
+ "jsonrpc": "2.0",
+ "method": "initialize",
+ "params": {
+ "protocolVersion": "2024-11-05",
+ "capabilities": {},
+ "clientInfo": {"name": "ghostcrew", "version": "0.2.0"},
+ },
+ "id": self._get_next_id(),
+ }
+ )
+ await transport.send(
+ {"jsonrpc": "2.0", "method": "notifications/initialized"}
+ )
+
+ tools_response = await transport.send(
+ {"jsonrpc": "2.0", "method": "tools/list", "id": self._get_next_id()}
+ )
+ tools = tools_response.get("result", {}).get("tools", [])
+
+ return MCPServer(
+ name=config.name,
+ config=config,
+ transport=transport,
+ tools=tools,
+ connected=True,
+ )
+ except Exception as e:
+ # Clean up transport on failure
+ if transport:
+ try:
+ await transport.disconnect()
+ except Exception:
+ pass
+ print(f"[MCP] Failed to connect to {config.name}: {e}")
+ return None
+
+ async def call_tool(self, server_name: str, tool_name: str, arguments: dict) -> Any:
+ server = self.servers.get(server_name)
+ if not server or not server.connected:
+ raise ValueError(f"Server '{server_name}' not connected")
+
+ # Use 5 minute timeout for tool calls (scans can take a while)
+ response = await server.transport.send(
+ {
+ "jsonrpc": "2.0",
+ "method": "tools/call",
+ "params": {"name": tool_name, "arguments": arguments},
+ "id": self._get_next_id(),
+ },
+ timeout=300.0,
+ )
+ if "error" in response:
+ raise RuntimeError(f"MCP error: {response['error'].get('message')}")
+ return response.get("result", {}).get("content", [])
+
+ async def disconnect_server(self, name: str):
+ server = self.servers.get(name)
+ if server:
+ await server.disconnect()
+ del self.servers[name]
+
+ async def disconnect_all(self):
+ for server in list(self.servers.values()):
+ await server.disconnect()
+ self.servers.clear()
+
+ async def reconnect_all(self) -> List[Any]:
+ """Disconnect all servers and reconnect them.
+
+ Useful after cancellation leaves servers in a bad state.
+ """
+ # Disconnect all
+ await self.disconnect_all()
+
+ # Reconnect all configured servers
+ return await self.connect_all()
+
+ def get_server(self, name: str) -> Optional[MCPServer]:
+ return self.servers.get(name)
+
+ def get_all_servers(self) -> List[MCPServer]:
+ return list(self.servers.values())
+
+ def is_connected(self, name: str) -> bool:
+ server = self.servers.get(name)
+ return server is not None and server.connected
diff --git a/ghostcrew/mcp/mcp_servers.json b/ghostcrew/mcp/mcp_servers.json
new file mode 100644
index 0000000..da39e4f
--- /dev/null
+++ b/ghostcrew/mcp/mcp_servers.json
@@ -0,0 +1,3 @@
+{
+ "mcpServers": {}
+}
diff --git a/ghostcrew/mcp/tools.py b/ghostcrew/mcp/tools.py
new file mode 100644
index 0000000..1f45dad
--- /dev/null
+++ b/ghostcrew/mcp/tools.py
@@ -0,0 +1,123 @@
+"""MCP tool wrapper for GhostCrew."""
+
+from typing import TYPE_CHECKING, Any
+
+if TYPE_CHECKING:
+ from ..runtime import Runtime
+ from ..tools import Tool
+ from .manager import MCPManager, MCPServer
+
+
+def create_mcp_tool(
+ tool_def: dict, server: "MCPServer", manager: "MCPManager"
+) -> "Tool":
+ """
+ Create a Tool instance from an MCP tool definition.
+
+ Args:
+ tool_def: The MCP tool definition
+ server: The MCP server that provides this tool
+ manager: The MCP manager for making calls
+
+ Returns:
+ A Tool instance that wraps the MCP tool
+ """
+ from ..tools import Tool, ToolSchema
+
+ async def execute_mcp(arguments: dict, runtime: "Runtime") -> str:
+ """Execute this MCP tool."""
+ # Get the tool name (without mcp_ prefix)
+ original_name = tool_def["name"]
+
+ try:
+ result = await manager.call_tool(server.name, original_name, arguments)
+
+ # Format result
+ if isinstance(result, list):
+ formatted_parts = []
+ for item in result:
+ if isinstance(item, dict):
+ if item.get("type") == "text":
+ formatted_parts.append(item.get("text", ""))
+ elif item.get("type") == "image":
+ formatted_parts.append(
+ f"[Image: {item.get('mimeType', 'unknown')}]"
+ )
+ elif item.get("type") == "resource":
+ formatted_parts.append(
+ f"[Resource: {item.get('uri', 'unknown')}]"
+ )
+ else:
+ formatted_parts.append(str(item))
+ else:
+ formatted_parts.append(str(item))
+ return "\n".join(formatted_parts)
+
+ return str(result)
+
+ except Exception as e:
+ return f"MCP tool error: {str(e)}"
+
+ # Convert MCP schema to our schema format
+ input_schema = tool_def.get("inputSchema", {})
+ schema = ToolSchema(
+ type=input_schema.get("type", "object"),
+ properties=input_schema.get("properties", {}),
+ required=input_schema.get("required", []),
+ )
+
+ # Create unique name with server prefix
+ tool_name = f"mcp_{server.name}_{tool_def['name']}"
+
+ return Tool(
+ name=tool_name,
+ description=tool_def.get("description", f"MCP tool from {server.name}"),
+ schema=schema,
+ execute_fn=execute_mcp,
+ category=f"mcp:{server.name}",
+ metadata={
+ "mcp_server": server.name,
+ "mcp_tool": tool_def["name"],
+ "original_schema": input_schema,
+ },
+ )
+
+
+def format_mcp_result(result: Any) -> str:
+ """
+ Format an MCP tool result for display.
+
+ Args:
+ result: The raw MCP result
+
+ Returns:
+ Formatted string
+ """
+ if isinstance(result, list):
+ parts = []
+ for item in result:
+ if isinstance(item, dict):
+ content_type = item.get("type", "unknown")
+
+ if content_type == "text":
+ parts.append(item.get("text", ""))
+ elif content_type == "image":
+ mime = item.get("mimeType", "unknown")
+ data_preview = item.get("data", "")[:50]
+ parts.append(f"[Image ({mime}): {data_preview}...]")
+ elif content_type == "resource":
+ uri = item.get("uri", "unknown")
+ parts.append(f"[Resource: {uri}]")
+ else:
+ parts.append(str(item))
+ else:
+ parts.append(str(item))
+
+ return "\n".join(parts)
+
+ elif isinstance(result, dict):
+ if "content" in result:
+ return format_mcp_result(result["content"])
+ return str(result)
+
+ return str(result)
diff --git a/ghostcrew/mcp/transport.py b/ghostcrew/mcp/transport.py
new file mode 100644
index 0000000..933d920
--- /dev/null
+++ b/ghostcrew/mcp/transport.py
@@ -0,0 +1,287 @@
+"""MCP transport implementations for GhostCrew."""
+
+import asyncio
+import json
+import os
+from abc import ABC, abstractmethod
+from typing import Any, Dict, Optional
+
+
+class MCPTransport(ABC):
+ """Abstract base class for MCP transports."""
+
+ @abstractmethod
+ async def connect(self):
+ """Establish the connection."""
+ pass
+
+ @abstractmethod
+ async def send(self, message: dict, timeout: float = 15.0) -> dict:
+ """Send a message and receive a response."""
+ pass
+
+ @abstractmethod
+ async def disconnect(self):
+ """Close the connection."""
+ pass
+
+ @property
+ @abstractmethod
+ def is_connected(self) -> bool:
+ """Check if the transport is connected."""
+ pass
+
+
+class StdioTransport(MCPTransport):
+ """MCP transport over stdio (for npx/uvx commands)."""
+
+ def __init__(self, command: str, args: list[str], env: Dict[str, str]):
+ """
+ Initialize stdio transport.
+
+ Args:
+ command: The command to run (e.g., 'npx', 'uvx')
+ args: Command arguments
+ env: Additional environment variables
+ """
+ self.command = command
+ self.args = args
+ self.env = env
+ self.process: Optional[asyncio.subprocess.Process] = None
+ self._lock = asyncio.Lock()
+
+ @property
+ def is_connected(self) -> bool:
+ """Check if the process is running."""
+ return self.process is not None and self.process.returncode is None
+
+ async def connect(self):
+ """Start the MCP server process."""
+ import shutil
+
+ # Merge environment variables
+ full_env = {**os.environ, **self.env}
+
+ # On Windows, resolve commands like npx, uvx that may be .cmd/.ps1 wrappers
+ if os.name == "nt":
+ # Check for .cmd version first (more compatible)
+ cmd_path = shutil.which(f"{self.command}.cmd")
+ if cmd_path:
+ resolved_command = cmd_path
+ else:
+ # Fall back to regular which
+ resolved_command = shutil.which(self.command) or self.command
+ else:
+ resolved_command = self.command
+
+ self.process = await asyncio.create_subprocess_exec(
+ resolved_command,
+ *self.args,
+ stdin=asyncio.subprocess.PIPE,
+ stdout=asyncio.subprocess.PIPE,
+ stderr=asyncio.subprocess.PIPE,
+ env=full_env,
+ )
+
+ async def send(self, message: dict, timeout: float = 15.0) -> dict:
+ """
+ Send a JSON-RPC message and wait for response.
+
+ Args:
+ message: The JSON-RPC message to send
+ timeout: Timeout in seconds for response (default 15s)
+
+ Returns:
+ The JSON-RPC response
+ """
+ if not self.process or not self.process.stdin or not self.process.stdout:
+ raise RuntimeError("Transport not connected")
+
+ async with self._lock:
+ # Send JSON-RPC message with newline
+ msg_bytes = (json.dumps(message) + "\n").encode()
+ self.process.stdin.write(msg_bytes)
+ await self.process.stdin.drain()
+
+ # Notifications don't have responses
+ if "id" not in message:
+ return {}
+
+ # Read response line
+ try:
+ response_line = await asyncio.wait_for(
+ self.process.stdout.readline(), timeout=timeout
+ )
+
+ if not response_line:
+ raise RuntimeError("Server closed connection")
+
+ return json.loads(response_line.decode())
+
+ except asyncio.TimeoutError as e:
+ raise RuntimeError("Timeout waiting for MCP response") from e
+ except json.JSONDecodeError as e:
+ raise RuntimeError(f"Invalid JSON response: {e}") from e
+
+ async def disconnect(self):
+ """Terminate the MCP server process cleanly."""
+ if not self.process:
+ return
+
+ proc = self.process
+ self.process = None
+
+ # Close all pipes first to prevent "unclosed transport" warnings
+ for pipe in (proc.stdin, proc.stdout, proc.stderr):
+ if pipe:
+ try:
+ pipe.close()
+ except Exception:
+ pass
+
+ # Wait for pipe transports to close
+ if proc.stdin:
+ try:
+ await proc.stdin.wait_closed()
+ except Exception:
+ pass
+
+ # Terminate the process
+ try:
+ proc.terminate()
+ await asyncio.wait_for(proc.wait(), timeout=2.0)
+ except asyncio.TimeoutError:
+ proc.kill()
+ try:
+ await proc.wait()
+ except Exception:
+ pass
+ except Exception:
+ pass
+
+
+class SSETransport(MCPTransport):
+ """MCP transport over Server-Sent Events (HTTP)."""
+
+ def __init__(self, url: str):
+ """
+ Initialize SSE transport.
+
+ Args:
+ url: The HTTP endpoint URL
+ """
+ self.url = url
+ self.session: Optional[Any] = None # aiohttp.ClientSession
+ self._connected = False
+
+ @property
+ def is_connected(self) -> bool:
+ """Check if the session is active."""
+ return self._connected and self.session is not None
+
+ async def connect(self):
+ """Connect to the SSE endpoint."""
+ try:
+ import aiohttp
+
+ self.session = aiohttp.ClientSession()
+ self._connected = True
+ except ImportError as e:
+ raise RuntimeError(
+ "aiohttp is required for SSE transport. Install with: pip install aiohttp"
+ ) from e
+
+ async def send(self, message: dict) -> dict:
+ """
+ Send a message via HTTP POST.
+
+ Args:
+ message: The JSON-RPC message to send
+
+ Returns:
+ The JSON-RPC response
+ """
+ if not self.session:
+ raise RuntimeError("Transport not connected")
+
+ try:
+ async with self.session.post(
+ self.url, json=message, headers={"Content-Type": "application/json"}
+ ) as response:
+ if response.status != 200:
+ raise RuntimeError(f"HTTP error: {response.status}")
+
+ return await response.json()
+
+ except Exception as e:
+ raise RuntimeError(f"SSE request failed: {e}") from e
+
+ async def disconnect(self):
+ """Close the HTTP session."""
+ if self.session:
+ await self.session.close()
+ self.session = None
+ self._connected = False
+
+
+class WebSocketTransport(MCPTransport):
+ """MCP transport over WebSocket."""
+
+ def __init__(self, url: str):
+ """
+ Initialize WebSocket transport.
+
+ Args:
+ url: The WebSocket endpoint URL
+ """
+ self.url = url
+ self.ws: Optional[Any] = None
+ self._connected = False
+
+ @property
+ def is_connected(self) -> bool:
+ """Check if the WebSocket is connected."""
+ return self._connected and self.ws is not None
+
+ async def connect(self):
+ """Connect to the WebSocket endpoint."""
+ try:
+ import aiohttp
+
+ self._session = aiohttp.ClientSession()
+ self.ws = await self._session.ws_connect(self.url)
+ self._connected = True
+ except ImportError as e:
+ raise RuntimeError("aiohttp is required for WebSocket transport") from e
+
+ async def send(self, message: dict) -> dict:
+ """
+ Send a message via WebSocket.
+
+ Args:
+ message: The JSON-RPC message to send
+
+ Returns:
+ The JSON-RPC response
+ """
+ if not self.ws:
+ raise RuntimeError("Transport not connected")
+
+ await self.ws.send_json(message)
+
+ # Notifications don't have responses
+ if "id" not in message:
+ return {}
+
+ response = await self.ws.receive_json()
+ return response
+
+ async def disconnect(self):
+ """Close the WebSocket connection."""
+ if self.ws:
+ await self.ws.close()
+ self.ws = None
+ if hasattr(self, "_session") and self._session:
+ await self._session.close()
+ self._session = None
+ self._connected = False
diff --git a/ghostcrew/runtime/__init__.py b/ghostcrew/runtime/__init__.py
new file mode 100644
index 0000000..eee52fa
--- /dev/null
+++ b/ghostcrew/runtime/__init__.py
@@ -0,0 +1,14 @@
+"""Runtime environment for GhostCrew."""
+
+from .docker_runtime import DockerRuntime
+from .runtime import CommandResult, EnvironmentInfo, LocalRuntime, Runtime
+from .tool_server import ToolServer
+
+__all__ = [
+ "Runtime",
+ "CommandResult",
+ "LocalRuntime",
+ "DockerRuntime",
+ "ToolServer",
+ "EnvironmentInfo",
+]
diff --git a/ghostcrew/runtime/docker_runtime.py b/ghostcrew/runtime/docker_runtime.py
new file mode 100644
index 0000000..bd4580f
--- /dev/null
+++ b/ghostcrew/runtime/docker_runtime.py
@@ -0,0 +1,373 @@
+"""Docker runtime for GhostCrew."""
+
+import asyncio
+import io
+import tarfile
+from dataclasses import dataclass
+from pathlib import Path
+from typing import TYPE_CHECKING, Optional
+
+from .runtime import CommandResult, Runtime
+
+if TYPE_CHECKING:
+ from ..mcp import MCPManager
+
+
+@dataclass
+class DockerConfig:
+ """Docker runtime configuration."""
+
+ image: str = "ghostcrew-kali:latest" # Built from Dockerfile.kali
+ container_name: str = "ghostcrew-sandbox"
+ network_mode: str = "bridge"
+ cap_add: list = None
+ volumes: dict = None
+ environment: dict = None
+
+ def __post_init__(self):
+ if self.cap_add is None:
+ self.cap_add = ["NET_ADMIN"] # For VPN
+ if self.volumes is None:
+ self.volumes = {}
+ if self.environment is None:
+ self.environment = {}
+
+
+class DockerRuntime(Runtime):
+ """Manages Docker sandbox for tool execution."""
+
+ def __init__(
+ self,
+ config: Optional[DockerConfig] = None,
+ vpn_config: Optional[Path] = None,
+ mcp_manager: Optional["MCPManager"] = None,
+ ):
+ """
+ Initialize the Docker runtime.
+
+ Args:
+ config: Docker configuration
+ vpn_config: Path to OpenVPN config file
+ mcp_manager: MCP manager for tool calls
+ """
+ super().__init__(mcp_manager)
+ self.config = config or DockerConfig()
+ self.vpn_config = vpn_config
+ self.client = None
+ self.container = None
+ self._browser_context = None
+ self._proxy_running = False
+ self._proxy_port = 8080
+
+ async def start(self):
+ """Start the sandbox container."""
+ try:
+ import docker
+
+ self.client = docker.from_env()
+ except ImportError as e:
+ raise ImportError(
+ "docker is required for Docker runtime. "
+ "Install with: pip install docker"
+ ) from e
+
+ # Check if container already exists
+ try:
+ self.container = self.client.containers.get(self.config.container_name)
+ if self.container.status != "running":
+ self.container.start()
+ await asyncio.sleep(2) # Wait for container to fully start
+ except Exception:
+ # Create new container
+ volumes = {
+ str(Path.home() / ".ghostcrew"): {
+ "bind": "/root/.ghostcrew",
+ "mode": "rw",
+ },
+ **self.config.volumes,
+ }
+
+ self.container = self.client.containers.run(
+ self.config.image,
+ name=self.config.container_name,
+ detach=True,
+ tty=True,
+ cap_add=self.config.cap_add,
+ volumes=volumes,
+ network_mode=self.config.network_mode,
+ environment=self.config.environment,
+ )
+ await asyncio.sleep(2)
+
+ # Setup VPN if configured
+ if self.vpn_config:
+ await self._setup_vpn()
+
+ async def stop(self):
+ """Stop and remove the sandbox container."""
+ if self.container:
+ try:
+ self.container.stop(timeout=10)
+ self.container.remove()
+ except Exception:
+ pass
+ finally:
+ self.container = None
+
+ async def execute_command(self, command: str, timeout: int = 300) -> CommandResult:
+ """
+ Execute a command in the sandbox.
+
+ Args:
+ command: The shell command to execute
+ timeout: Timeout in seconds
+
+ Returns:
+ CommandResult with output
+ """
+ if not self.container:
+ raise RuntimeError("Sandbox not started")
+
+ try:
+ # Execute command
+ exec_result = self.container.exec_run(
+ cmd=["bash", "-c", command], demux=True
+ )
+
+ stdout = (
+ exec_result.output[0].decode(errors="replace")
+ if exec_result.output[0]
+ else ""
+ )
+ stderr = (
+ exec_result.output[1].decode(errors="replace")
+ if exec_result.output[1]
+ else ""
+ )
+
+ return CommandResult(
+ exit_code=exec_result.exit_code, stdout=stdout, stderr=stderr
+ )
+
+ except Exception as e:
+ return CommandResult(
+ exit_code=-1, stdout="", stderr=f"Execution error: {str(e)}"
+ )
+
+ async def browser_action(self, action: str, **kwargs) -> dict:
+ """
+ Perform browser automation in the sandbox.
+
+ Args:
+ action: The browser action to perform
+ **kwargs: Action-specific arguments
+
+ Returns:
+ Action result dictionary
+ """
+ # This would communicate with a browser automation service in the container
+ # For now, we'll use a simple implementation via terminal commands
+
+ if action == "navigate":
+ url = kwargs.get("url", "")
+ result = await self.execute_command(f"curl -s -L -o /tmp/page.html '{url}'")
+ if result.success:
+ content_result = await self.execute_command(
+ "head -c 5000 /tmp/page.html"
+ )
+ return {
+ "url": url,
+ "title": "Retrieved",
+ "content": content_result.stdout,
+ }
+ return {"error": result.stderr}
+
+ elif action == "get_content":
+ result = await self.execute_command(
+ "cat /tmp/page.html 2>/dev/null || echo 'No page loaded'"
+ )
+ return {"content": result.stdout}
+
+ elif action == "get_links":
+ result = await self.execute_command(
+ "grep -oP 'href=\"\\K[^\"]+' /tmp/page.html 2>/dev/null | head -50"
+ )
+ links = [
+ {"href": link, "text": ""}
+ for link in result.stdout.strip().split("\n")
+ if link
+ ]
+ return {"links": links}
+
+ elif action == "screenshot":
+ return {
+ "error": "Screenshot requires graphical browser - not available in sandbox"
+ }
+
+ return {"error": f"Unknown browser action: {action}"}
+
+ async def proxy_action(self, action: str, **kwargs) -> dict:
+ """
+ Control the HTTP proxy in the sandbox.
+
+ Args:
+ action: The proxy action to perform
+ **kwargs: Action-specific arguments
+
+ Returns:
+ Action result dictionary
+ """
+ port = kwargs.get("port", self._proxy_port)
+
+ if action == "start":
+ # Start mitmproxy in the background
+ result = await self.execute_command(
+ f"mitmdump -p {port} --set block_global=false -w /tmp/proxy.flow &"
+ )
+ if result.exit_code == 0:
+ self._proxy_running = True
+ self._proxy_port = port
+ return {"status": "started", "port": port}
+ return {"error": result.stderr}
+
+ elif action == "stop":
+ await self.execute_command("pkill -f mitmdump")
+ self._proxy_running = False
+ return {"status": "stopped"}
+
+ elif action == "status":
+ result = await self.execute_command("pgrep -f mitmdump")
+ return {
+ "status": "running" if result.exit_code == 0 else "stopped",
+ "port": self._proxy_port,
+ "request_count": 0, # Would need to parse proxy logs
+ }
+
+ elif action == "get_history":
+ # Would parse /tmp/proxy.flow
+ return {"requests": []}
+
+ elif action == "clear_history":
+ await self.execute_command("rm -f /tmp/proxy.flow")
+ return {"status": "cleared"}
+
+ return {"error": f"Unknown proxy action: {action}"}
+
+ async def is_running(self) -> bool:
+ """Check if the container is running."""
+ if not self.container:
+ return False
+
+ try:
+ self.container.reload()
+ return self.container.status == "running"
+ except Exception:
+ return False
+
+ async def get_status(self) -> dict:
+ """Get runtime status information."""
+ running = await self.is_running()
+
+ status = {
+ "type": "docker",
+ "running": running,
+ "container_name": self.config.container_name,
+ "image": self.config.image,
+ "proxy_running": self._proxy_running,
+ }
+
+ if running:
+ # Get container info
+ self.container.reload()
+ status["container_id"] = self.container.short_id
+ status["network_mode"] = self.config.network_mode
+
+ return status
+
+ async def _setup_vpn(self):
+ """Configure VPN in the sandbox."""
+ if not self.vpn_config or not self.vpn_config.exists():
+ return
+
+ # Copy VPN config to container
+ config_content = self.vpn_config.read_bytes()
+ tar_data = self._create_tar(config_content, "client.ovpn")
+
+ self.container.put_archive("/etc/openvpn", tar_data)
+
+ # Start OpenVPN
+ await self.execute_command(
+ "openvpn --config /etc/openvpn/client.ovpn --daemon --log /var/log/openvpn.log"
+ )
+
+ # Wait for connection
+ await asyncio.sleep(5)
+
+ # Verify connection
+ result = await self.execute_command("curl -s --max-time 10 ifconfig.me")
+ if result.success:
+ print(f"[VPN] Connected. External IP: {result.stdout.strip()}")
+ else:
+ print("[VPN] Connection may have failed")
+
+ def _create_tar(self, content: bytes, filename: str) -> bytes:
+ """Create a tar archive for container upload."""
+ tar_stream = io.BytesIO()
+ tar = tarfile.open(fileobj=tar_stream, mode="w")
+
+ file_data = io.BytesIO(content)
+ info = tarfile.TarInfo(name=filename)
+ info.size = len(content)
+ tar.addfile(info, file_data)
+ tar.close()
+
+ tar_stream.seek(0)
+ return tar_stream.read()
+
+ async def copy_to_container(self, local_path: Path, container_path: str):
+ """
+ Copy a file to the container.
+
+ Args:
+ local_path: Local file path
+ container_path: Destination path in container
+ """
+ if not self.container:
+ raise RuntimeError("Container not running")
+
+ content = local_path.read_bytes()
+ filename = local_path.name
+ tar_data = self._create_tar(content, filename)
+
+ # Ensure directory exists
+ dir_path = str(Path(container_path).parent)
+ await self.execute_command(f"mkdir -p {dir_path}")
+
+ self.container.put_archive(dir_path, tar_data)
+
+ async def copy_from_container(self, container_path: str, local_path: Path):
+ """
+ Copy a file from the container.
+
+ Args:
+ container_path: Source path in container
+ local_path: Local destination path
+ """
+ if not self.container:
+ raise RuntimeError("Container not running")
+
+ bits, stat = self.container.get_archive(container_path)
+
+ # Extract from tar
+ tar_stream = io.BytesIO()
+ for chunk in bits:
+ tar_stream.write(chunk)
+ tar_stream.seek(0)
+
+ tar = tarfile.open(fileobj=tar_stream)
+ for member in tar.getmembers():
+ f = tar.extractfile(member)
+ if f:
+ local_path.write_bytes(f.read())
+ break
+ tar.close()
diff --git a/ghostcrew/runtime/runtime.py b/ghostcrew/runtime/runtime.py
new file mode 100644
index 0000000..33ce3c0
--- /dev/null
+++ b/ghostcrew/runtime/runtime.py
@@ -0,0 +1,496 @@
+"""Runtime abstraction for GhostCrew."""
+
+import platform
+from abc import ABC, abstractmethod
+from dataclasses import dataclass
+from pathlib import Path
+from typing import TYPE_CHECKING, Optional
+
+if TYPE_CHECKING:
+ from ..mcp import MCPManager
+
+
+@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.
+
+ def __str__(self) -> str:
+ """Concise string representation for prompts."""
+ return f"{self.os} ({self.architecture}), shell: {self.shell}"
+
+
+def detect_environment() -> EnvironmentInfo:
+ """Detect the current system environment."""
+ os_name = platform.system()
+ os_version = platform.release()
+ arch = platform.machine()
+
+ # Detect shell
+ if os_name == "Windows":
+ # Check for PowerShell vs CMD
+ shell = "powershell"
+ else:
+ # Unix-like: check common shells
+ import os
+
+ shell_path = os.environ.get("SHELL", "/bin/sh")
+ shell = shell_path.split("/")[-1] # Extract shell name
+
+ return EnvironmentInfo(
+ os=os_name, os_version=os_version, shell=shell, architecture=arch
+ )
+
+
+@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
+
+ @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 loot directory for scan output
+ Path("loot").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 subprocess
+
+ try:
+ process = await asyncio.create_subprocess_shell(
+ command,
+ stdout=asyncio.subprocess.PIPE,
+ stderr=asyncio.subprocess.PIPE,
+ stdin=subprocess.DEVNULL, # Prevent interactive prompts
+ )
+
+ stdout, stderr = await asyncio.wait_for(
+ process.communicate(), timeout=timeout
+ )
+
+ return CommandResult(
+ exit_code=process.returncode or 0,
+ stdout=stdout.decode(errors="replace"),
+ stderr=stderr.decode(errors="replace"),
+ )
+
+ 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."""
+ 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":
+ 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 directory
+ output_dir = Path("loot/screenshots")
+ output_dir.mkdir(parents=True, exist_ok=True)
+
+ filename = f"screenshot_{int(__import__('time').time())}.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,
+ }
diff --git a/ghostcrew/runtime/tool_server.py b/ghostcrew/runtime/tool_server.py
new file mode 100644
index 0000000..f3bff3a
--- /dev/null
+++ b/ghostcrew/runtime/tool_server.py
@@ -0,0 +1,202 @@
+"""Tool server for running tools in the sandbox."""
+
+import asyncio
+import json
+from dataclasses import dataclass
+from typing import Callable, Dict, Optional
+
+
+@dataclass
+class ToolRequest:
+ """A tool execution request."""
+
+ tool_name: str
+ arguments: dict
+ request_id: str
+
+
+@dataclass
+class ToolResponse:
+ """A tool execution response."""
+
+ request_id: str
+ result: Optional[str] = None
+ error: Optional[str] = None
+ success: bool = True
+
+
+class ToolServer:
+ """
+ Server that runs inside the sandbox to handle tool requests.
+
+ This is used for more complex tool orchestration where
+ tools need to run inside the container.
+ """
+
+ def __init__(self, host: str = "0.0.0.0", port: int = 9999):
+ """
+ Initialize the tool server.
+
+ Args:
+ host: Host to bind to
+ port: Port to listen on
+ """
+ self.host = host
+ self.port = port
+ self._tools: Dict[str, Callable] = {}
+ self._server = None
+ self._running = False
+
+ def register_tool(self, name: str, handler: Callable):
+ """
+ Register a tool handler.
+
+ Args:
+ name: Tool name
+ handler: Async function to handle the tool
+ """
+ self._tools[name] = handler
+
+ async def start(self):
+ """Start the tool server."""
+ self._server = await asyncio.start_server(
+ self._handle_connection, self.host, self.port
+ )
+ self._running = True
+
+ async with self._server:
+ await self._server.serve_forever()
+
+ async def stop(self):
+ """Stop the tool server."""
+ self._running = False
+ if self._server:
+ self._server.close()
+ await self._server.wait_closed()
+
+ async def _handle_connection(
+ self, reader: asyncio.StreamReader, writer: asyncio.StreamWriter
+ ):
+ """Handle an incoming connection."""
+ try:
+ while self._running:
+ # Read request
+ data = await reader.readline()
+ if not data:
+ break
+
+ try:
+ request_data = json.loads(data.decode())
+ request = ToolRequest(
+ tool_name=request_data["tool"],
+ arguments=request_data.get("arguments", {}),
+ request_id=request_data.get("id", "unknown"),
+ )
+
+ # Execute tool
+ response = await self._execute_tool(request)
+
+ # Send response
+ response_data = {
+ "id": response.request_id,
+ "result": response.result,
+ "error": response.error,
+ "success": response.success,
+ }
+ writer.write((json.dumps(response_data) + "\n").encode())
+ await writer.drain()
+
+ except json.JSONDecodeError:
+ error_response = {"error": "Invalid JSON", "success": False}
+ writer.write((json.dumps(error_response) + "\n").encode())
+ await writer.drain()
+
+ except Exception as e:
+ print(f"Connection error: {e}")
+ finally:
+ writer.close()
+ await writer.wait_closed()
+
+ async def _execute_tool(self, request: ToolRequest) -> ToolResponse:
+ """Execute a tool request."""
+ handler = self._tools.get(request.tool_name)
+
+ if not handler:
+ return ToolResponse(
+ request_id=request.request_id,
+ error=f"Tool '{request.tool_name}' not found",
+ success=False,
+ )
+
+ try:
+ result = await handler(request.arguments)
+ return ToolResponse(
+ request_id=request.request_id, result=str(result), success=True
+ )
+ except Exception as e:
+ return ToolResponse(
+ request_id=request.request_id, error=str(e), success=False
+ )
+
+
+class ToolClient:
+ """Client for communicating with the tool server in the sandbox."""
+
+ def __init__(self, host: str = "localhost", port: int = 9999):
+ """
+ Initialize the tool client.
+
+ Args:
+ host: Tool server host
+ port: Tool server port
+ """
+ self.host = host
+ self.port = port
+ self._reader: Optional[asyncio.StreamReader] = None
+ self._writer: Optional[asyncio.StreamWriter] = None
+ self._request_id = 0
+
+ async def connect(self):
+ """Connect to the tool server."""
+ self._reader, self._writer = await asyncio.open_connection(self.host, self.port)
+
+ async def disconnect(self):
+ """Disconnect from the tool server."""
+ if self._writer:
+ self._writer.close()
+ await self._writer.wait_closed()
+ self._writer = None
+ self._reader = None
+
+ async def call_tool(self, tool_name: str, arguments: dict) -> ToolResponse:
+ """
+ Call a tool on the server.
+
+ Args:
+ tool_name: The tool to call
+ arguments: Tool arguments
+
+ Returns:
+ ToolResponse with result
+ """
+ if not self._writer or not self._reader:
+ raise RuntimeError("Not connected to tool server")
+
+ self._request_id += 1
+ request_id = str(self._request_id)
+
+ # Send request
+ request = {"id": request_id, "tool": tool_name, "arguments": arguments}
+ self._writer.write((json.dumps(request) + "\n").encode())
+ await self._writer.drain()
+
+ # Read response
+ response_data = await self._reader.readline()
+ response = json.loads(response_data.decode())
+
+ return ToolResponse(
+ request_id=response.get("id", request_id),
+ result=response.get("result"),
+ error=response.get("error"),
+ success=response.get("success", False),
+ )
diff --git a/ghostcrew/tools/__init__.py b/ghostcrew/tools/__init__.py
new file mode 100644
index 0000000..3528fcc
--- /dev/null
+++ b/ghostcrew/tools/__init__.py
@@ -0,0 +1,42 @@
+"""Tool system for GhostCrew."""
+
+from .executor import ToolExecutor
+from .loader import discover_tools, get_tool_info, load_all_tools, reload_tools
+from .registry import (
+ Tool,
+ ToolSchema,
+ clear_tools,
+ disable_tool,
+ enable_tool,
+ get_all_tools,
+ get_tool,
+ get_tool_names,
+ get_tools_by_category,
+ register_tool,
+ register_tool_instance,
+)
+
+# Auto-load all built-in tools on import
+_loaded = load_all_tools()
+
+__all__ = [
+ # Registry
+ "Tool",
+ "ToolSchema",
+ "register_tool",
+ "get_all_tools",
+ "get_tool",
+ "register_tool_instance",
+ "get_tools_by_category",
+ "enable_tool",
+ "disable_tool",
+ "get_tool_names",
+ "clear_tools",
+ # Executor
+ "ToolExecutor",
+ # Loader
+ "load_all_tools",
+ "get_tool_info",
+ "reload_tools",
+ "discover_tools",
+]
diff --git a/ghostcrew/tools/browser/__init__.py b/ghostcrew/tools/browser/__init__.py
new file mode 100644
index 0000000..fec60a9
--- /dev/null
+++ b/ghostcrew/tools/browser/__init__.py
@@ -0,0 +1,156 @@
+"""Browser automation tool for GhostCrew."""
+
+from typing import TYPE_CHECKING
+
+from ..registry import ToolSchema, register_tool
+
+if TYPE_CHECKING:
+ from ...runtime import Runtime
+
+
+@register_tool(
+ name="browser",
+ description="Automate a headless browser. Actions: navigate, click, type, screenshot, get_content, get_links, get_forms, execute_js.",
+ schema=ToolSchema(
+ properties={
+ "action": {
+ "type": "string",
+ "enum": [
+ "navigate",
+ "click",
+ "type",
+ "screenshot",
+ "get_content",
+ "get_links",
+ "get_forms",
+ "execute_js",
+ ],
+ "description": "The browser action to perform",
+ },
+ "url": {
+ "type": "string",
+ "description": "URL to navigate to (for 'navigate' action)",
+ },
+ "selector": {
+ "type": "string",
+ "description": "CSS selector for element (for 'click', 'type' actions)",
+ },
+ "text": {
+ "type": "string",
+ "description": "Text to type (for 'type' action)",
+ },
+ "javascript": {
+ "type": "string",
+ "description": "JavaScript code to execute (for 'execute_js' action)",
+ },
+ "wait_for": {
+ "type": "string",
+ "description": "CSS selector to wait for before continuing",
+ },
+ "timeout": {
+ "type": "integer",
+ "description": "Timeout in seconds (default: 30)",
+ "default": 30,
+ },
+ },
+ required=["action"],
+ ),
+ category="web",
+)
+async def browser(arguments: dict, runtime: "Runtime") -> str:
+ """
+ Perform browser automation actions.
+
+ Args:
+ arguments: Dictionary with action and related parameters
+ runtime: The runtime environment
+
+ Returns:
+ Result of the browser action
+ """
+ action = arguments["action"]
+ timeout = arguments.get("timeout", 30)
+
+ try:
+ result = await runtime.browser_action(
+ action=action,
+ url=arguments.get("url"),
+ selector=arguments.get("selector"),
+ text=arguments.get("text"),
+ javascript=arguments.get("javascript"),
+ wait_for=arguments.get("wait_for"),
+ timeout=timeout,
+ )
+
+ return _format_browser_result(action, result)
+
+ except Exception as e:
+ return f"Browser action '{action}' failed: {str(e)}"
+
+
+def _format_browser_result(action: str, result: dict) -> str:
+ """Format browser action result for display."""
+ # Check for errors first
+ if "error" in result:
+ return f"Browser error: {result['error']}"
+
+ if action == "navigate":
+ return f"Navigated to: {result.get('url', 'unknown')}\nTitle: {result.get('title', 'N/A')}"
+
+ elif action == "screenshot":
+ return f"Screenshot saved to: {result.get('path', 'unknown')}"
+
+ elif action == "get_content":
+ content = result.get("content", "")
+ if len(content) > 5000:
+ content = content[:5000] + "\n... (truncated)"
+ return f"Page content:\n{content}"
+
+ elif action == "get_links":
+ links = result.get("links", [])
+ if not links:
+ return "No links found on page"
+
+ formatted = ["Found links:"]
+ for link in links[:50]: # Limit to 50 links
+ text = link.get("text", "").strip()[:50]
+ href = link.get("href", "")
+ formatted.append(f" - [{text}] {href}")
+
+ if len(links) > 50:
+ formatted.append(f" ... and {len(links) - 50} more links")
+
+ return "\n".join(formatted)
+
+ elif action == "get_forms":
+ forms = result.get("forms", [])
+ if not forms:
+ return "No forms found on page"
+
+ formatted = ["Found forms:"]
+ for i, form in enumerate(forms):
+ formatted.append(f"\nForm {i + 1}:")
+ formatted.append(f" Action: {form.get('action', 'N/A')}")
+ formatted.append(f" Method: {form.get('method', 'GET')}")
+ inputs = form.get("inputs", [])
+ if inputs:
+ formatted.append(" Inputs:")
+ for inp in inputs:
+ formatted.append(
+ f" - {inp.get('name', 'unnamed')} ({inp.get('type', 'text')})"
+ )
+
+ return "\n".join(formatted)
+
+ elif action == "click":
+ return f"Clicked element: {result.get('selector', 'unknown')}"
+
+ elif action == "type":
+ return f"Typed text into: {result.get('selector', 'unknown')}"
+
+ elif action == "execute_js":
+ output = result.get("result", "")
+ return f"JavaScript result:\n{output}"
+
+ else:
+ return str(result)
diff --git a/ghostcrew/tools/browser/browser.py b/ghostcrew/tools/browser/browser.py
new file mode 100644
index 0000000..396796a
--- /dev/null
+++ b/ghostcrew/tools/browser/browser.py
@@ -0,0 +1,5 @@
+"""Browser tool implementation."""
+
+from . import browser
+
+__all__ = ["browser"]
diff --git a/ghostcrew/tools/completion/__init__.py b/ghostcrew/tools/completion/__init__.py
new file mode 100644
index 0000000..d8aac01
--- /dev/null
+++ b/ghostcrew/tools/completion/__init__.py
@@ -0,0 +1,52 @@
+"""Task completion tool for GhostCrew agent loop control."""
+
+from ..registry import ToolSchema, register_tool
+
+# Sentinel value to signal task completion
+TASK_COMPLETE_SIGNAL = "__TASK_COMPLETE__"
+
+
+@register_tool(
+ name="finish",
+ description="Signal that the current task is finished. Call this when you have completed ALL steps of the user's request. Include a concise summary of what was accomplished.",
+ schema=ToolSchema(
+ properties={
+ "summary": {
+ "type": "string",
+ "description": "Brief summary of what was accomplished and any key findings",
+ },
+ },
+ required=["summary"],
+ ),
+ category="control",
+)
+async def finish(arguments: dict, runtime) -> str:
+ """
+ Signal task completion to the agent framework.
+
+ This tool is called by the agent when it has finished all steps
+ of the user's task. The framework uses this as an explicit
+ termination signal rather than relying on LLM text output.
+
+ Args:
+ arguments: Dictionary with 'summary' key
+ runtime: The runtime environment (unused)
+
+ Returns:
+ The completion signal with summary
+ """
+ summary = arguments.get("summary", "Task completed.")
+ # Return special signal that the framework recognizes
+ return f"{TASK_COMPLETE_SIGNAL}:{summary}"
+
+
+def is_task_complete(result: str) -> bool:
+ """Check if a tool result signals task completion."""
+ return result.startswith(TASK_COMPLETE_SIGNAL)
+
+
+def extract_completion_summary(result: str) -> str:
+ """Extract the summary from a task_complete result."""
+ if is_task_complete(result):
+ return result[len(TASK_COMPLETE_SIGNAL) + 1:] # +1 for the colon
+ return result
diff --git a/ghostcrew/tools/executor.py b/ghostcrew/tools/executor.py
new file mode 100644
index 0000000..af8f837
--- /dev/null
+++ b/ghostcrew/tools/executor.py
@@ -0,0 +1,213 @@
+"""Tool executor for GhostCrew."""
+
+import asyncio
+from dataclasses import dataclass
+from datetime import datetime
+from typing import TYPE_CHECKING, Any, Dict, List, Optional
+
+if TYPE_CHECKING:
+ from ..runtime import Runtime
+ from .registry import Tool
+
+
+@dataclass
+class ExecutionResult:
+ """Result of a tool execution."""
+
+ tool_name: str
+ arguments: dict
+ result: Optional[str] = None
+ error: Optional[str] = None
+ success: bool = True
+ start_time: Optional[datetime] = None
+ end_time: Optional[datetime] = None
+ duration_ms: float = 0.0
+
+ @property
+ def duration(self) -> float:
+ """Get execution duration in seconds."""
+ return self.duration_ms / 1000.0
+
+
+class ToolExecutor:
+ """Handles tool execution with error handling and logging."""
+
+ def __init__(self, runtime: "Runtime", timeout: int = 300, max_retries: int = 0):
+ """
+ Initialize the tool executor.
+
+ Args:
+ runtime: The runtime environment
+ timeout: Default timeout for tool execution in seconds
+ max_retries: Number of retries on failure
+ """
+ self.runtime = runtime
+ self.timeout = timeout
+ self.max_retries = max_retries
+ self.execution_history: List[ExecutionResult] = []
+
+ async def execute(
+ self, tool: "Tool", arguments: dict, timeout: Optional[int] = None
+ ) -> ExecutionResult:
+ """
+ Execute a tool with the given arguments.
+
+ Args:
+ tool: The tool to execute
+ arguments: The arguments to pass to the tool
+ timeout: Optional timeout override
+
+ Returns:
+ ExecutionResult with the outcome
+ """
+ execution_timeout = timeout or self.timeout
+ start_time = datetime.now()
+
+ # Validate arguments
+ is_valid, error_msg = tool.validate_arguments(arguments)
+ if not is_valid:
+ result = ExecutionResult(
+ tool_name=tool.name,
+ arguments=arguments,
+ error=error_msg,
+ success=False,
+ start_time=start_time,
+ end_time=datetime.now(),
+ )
+ result.duration_ms = (result.end_time - start_time).total_seconds() * 1000
+ self.execution_history.append(result)
+ return result
+
+ # Execute with retries
+ last_error = None
+ for attempt in range(self.max_retries + 1):
+ try:
+ # Execute with timeout
+ output = await asyncio.wait_for(
+ tool.execute(arguments, self.runtime), timeout=execution_timeout
+ )
+
+ end_time = datetime.now()
+ result = ExecutionResult(
+ tool_name=tool.name,
+ arguments=arguments,
+ result=output,
+ success=True,
+ start_time=start_time,
+ end_time=end_time,
+ duration_ms=(end_time - start_time).total_seconds() * 1000,
+ )
+ self.execution_history.append(result)
+ return result
+
+ except asyncio.TimeoutError:
+ last_error = f"Execution timed out after {execution_timeout} seconds"
+ except Exception as e:
+ last_error = str(e)
+
+ # Wait before retry
+ if attempt < self.max_retries:
+ await asyncio.sleep(1)
+
+ # All attempts failed
+ end_time = datetime.now()
+ result = ExecutionResult(
+ tool_name=tool.name,
+ arguments=arguments,
+ error=last_error,
+ success=False,
+ start_time=start_time,
+ end_time=end_time,
+ duration_ms=(end_time - start_time).total_seconds() * 1000,
+ )
+ self.execution_history.append(result)
+ return result
+
+ async def execute_batch(
+ self, executions: List[tuple["Tool", dict]], parallel: bool = False
+ ) -> List[ExecutionResult]:
+ """
+ Execute multiple tools.
+
+ Args:
+ executions: List of (tool, arguments) tuples
+ parallel: Whether to execute in parallel
+
+ Returns:
+ List of ExecutionResults
+ """
+ if parallel:
+ tasks = [self.execute(tool, args) for tool, args in executions]
+ return await asyncio.gather(*tasks)
+ else:
+ results = []
+ for tool, args in executions:
+ result = await self.execute(tool, args)
+ results.append(result)
+ return results
+
+ def get_execution_stats(self) -> Dict[str, Any]:
+ """
+ Get statistics about tool executions.
+
+ Returns:
+ Dictionary with execution statistics
+ """
+ if not self.execution_history:
+ return {
+ "total_executions": 0,
+ "successful": 0,
+ "failed": 0,
+ "success_rate": 0.0,
+ "avg_duration_ms": 0.0,
+ "tools_used": {},
+ }
+
+ total = len(self.execution_history)
+ successful = sum(1 for r in self.execution_history if r.success)
+ failed = total - successful
+
+ # Calculate average duration
+ durations = [r.duration_ms for r in self.execution_history]
+ avg_duration = sum(durations) / len(durations) if durations else 0.0
+
+ # Count tool usage
+ tools_used: Dict[str, int] = {}
+ for result in self.execution_history:
+ tools_used[result.tool_name] = tools_used.get(result.tool_name, 0) + 1
+
+ return {
+ "total_executions": total,
+ "successful": successful,
+ "failed": failed,
+ "success_rate": successful / total if total > 0 else 0.0,
+ "avg_duration_ms": avg_duration,
+ "tools_used": tools_used,
+ }
+
+ def clear_history(self):
+ """Clear the execution history."""
+ self.execution_history.clear()
+
+ def get_last_result(
+ self, tool_name: Optional[str] = None
+ ) -> Optional[ExecutionResult]:
+ """
+ Get the last execution result.
+
+ Args:
+ tool_name: Optional filter by tool name
+
+ Returns:
+ The last ExecutionResult or None
+ """
+ if not self.execution_history:
+ return None
+
+ if tool_name:
+ for result in reversed(self.execution_history):
+ if result.tool_name == tool_name:
+ return result
+ return None
+
+ return self.execution_history[-1]
diff --git a/ghostcrew/tools/loader.py b/ghostcrew/tools/loader.py
new file mode 100644
index 0000000..7891855
--- /dev/null
+++ b/ghostcrew/tools/loader.py
@@ -0,0 +1,143 @@
+"""Dynamic tool loader for GhostCrew."""
+
+import importlib
+import sys
+from pathlib import Path
+from typing import List, Optional
+
+from .registry import get_all_tools
+
+
+def discover_tools(tools_dir: Optional[Path] = None) -> List[str]:
+ """
+ Discover all tool modules in the tools directory.
+
+ Args:
+ tools_dir: Path to tools directory. Defaults to package tools dir.
+
+ Returns:
+ List of discovered tool module names
+ """
+ if tools_dir is None:
+ tools_dir = Path(__file__).parent
+
+ discovered = []
+
+ for item in tools_dir.iterdir():
+ # Skip non-tool items
+ if item.name.startswith("_"):
+ continue
+ if item.name in ("registry.py", "executor.py", "loader.py"):
+ continue
+
+ # Check if it's a tool module
+ if item.is_dir() and (item / "__init__.py").exists():
+ discovered.append(item.name)
+ elif item.suffix == ".py":
+ discovered.append(item.stem)
+
+ return discovered
+
+
+def load_tool_module(module_name: str, tools_dir: Optional[Path] = None) -> bool:
+ """
+ Load a tool module by name.
+
+ Args:
+ module_name: Name of the tool module to load
+ tools_dir: Path to tools directory
+
+ Returns:
+ True if loaded successfully, False otherwise
+ """
+ if tools_dir is None:
+ tools_dir = Path(__file__).parent
+
+ try:
+ # Build the full module path
+ full_module_name = f"ghostcrew.tools.{module_name}"
+
+ # Check if already loaded
+ if full_module_name in sys.modules:
+ return True
+
+ # Try to import the module
+ importlib.import_module(full_module_name)
+ return True
+
+ except ImportError as e:
+ print(f"Warning: Failed to load tool module '{module_name}': {e}")
+ return False
+ except Exception as e:
+ print(f"Warning: Error loading tool module '{module_name}': {e}")
+ return False
+
+
+def load_all_tools(tools_dir: Optional[Path] = None) -> List[str]:
+ """
+ Discover and load all tool modules.
+
+ Args:
+ tools_dir: Path to tools directory
+
+ Returns:
+ List of successfully loaded tool module names
+ """
+ discovered = discover_tools(tools_dir)
+ loaded = []
+
+ for module_name in discovered:
+ if load_tool_module(module_name, tools_dir):
+ loaded.append(module_name)
+
+ return loaded
+
+
+def get_tool_info() -> List[dict]:
+ """
+ Get information about all registered tools.
+
+ Returns:
+ List of tool info dictionaries
+ """
+ tools = get_all_tools()
+ return [
+ {
+ "name": tool.name,
+ "description": tool.description,
+ "category": tool.category,
+ "enabled": tool.enabled,
+ "parameters": (
+ list(tool.schema.properties.keys()) if tool.schema.properties else []
+ ),
+ }
+ for tool in tools
+ ]
+
+
+def reload_tools():
+ """Reload all tool modules."""
+ from .registry import clear_tools
+
+ # Clear existing tools
+ clear_tools()
+
+ # Unload tool modules from sys.modules
+ to_remove = [
+ name
+ for name in sys.modules
+ if name.startswith("ghostcrew.tools.")
+ and name
+ not in (
+ "ghostcrew.tools",
+ "ghostcrew.tools.registry",
+ "ghostcrew.tools.executor",
+ "ghostcrew.tools.loader",
+ )
+ ]
+
+ for name in to_remove:
+ del sys.modules[name]
+
+ # Reload all tools
+ return load_all_tools()
diff --git a/ghostcrew/tools/notes/__init__.py b/ghostcrew/tools/notes/__init__.py
new file mode 100644
index 0000000..c8212aa
--- /dev/null
+++ b/ghostcrew/tools/notes/__init__.py
@@ -0,0 +1,140 @@
+"""Notes tool for GhostCrew - persistent key findings storage."""
+
+import json
+from pathlib import Path
+from typing import Dict
+
+from ..registry import ToolSchema, register_tool
+
+# Notes storage
+_notes: Dict[str, str] = {}
+_notes_file: Path = Path("loot/notes.json")
+
+
+def _load_notes() -> None:
+ """Load notes from file."""
+ global _notes
+ if _notes_file.exists():
+ try:
+ _notes = json.loads(_notes_file.read_text())
+ except (json.JSONDecodeError, IOError):
+ _notes = {}
+
+
+def _save_notes() -> None:
+ """Save notes to file."""
+ _notes_file.parent.mkdir(parents=True, exist_ok=True)
+ _notes_file.write_text(json.dumps(_notes, indent=2))
+
+
+def get_all_notes() -> Dict[str, str]:
+ """Get all notes (for TUI /notes command)."""
+ if not _notes:
+ _load_notes()
+ return _notes.copy()
+
+
+def set_notes_file(path: Path) -> None:
+ """Set custom notes file path."""
+ global _notes_file
+ _notes_file = path
+ _load_notes()
+
+
+# Load notes on module import
+_load_notes()
+
+
+@register_tool(
+ name="notes",
+ description="Manage persistent notes for key findings. Actions: create, read, update, delete, list.",
+ schema=ToolSchema(
+ properties={
+ "action": {
+ "type": "string",
+ "enum": ["create", "read", "update", "delete", "list"],
+ "description": "The action to perform",
+ },
+ "key": {
+ "type": "string",
+ "description": "Note identifier (e.g., 'creds_ssh', 'open_ports', 'vuln_sqli')",
+ },
+ "value": {
+ "type": "string",
+ "description": "Note content (for create/update)",
+ },
+ },
+ required=["action"],
+ ),
+ category="utility",
+)
+async def notes(arguments: dict, runtime) -> str:
+ """
+ Manage persistent notes.
+
+ Args:
+ arguments: Dictionary with action, key, value
+ runtime: The runtime environment (unused)
+
+ Returns:
+ Result message
+ """
+ action = arguments["action"]
+ key = arguments.get("key", "").strip()
+ value = arguments.get("value", "")
+
+ if action == "create":
+ if not key:
+ return "Error: key is required for create"
+ if not value:
+ return "Error: value is required for create"
+ if key in _notes:
+ return f"Error: note '{key}' already exists. Use 'update' to modify."
+
+ _notes[key] = value
+ _save_notes()
+ return f"Created note '{key}'"
+
+ elif action == "read":
+ if not key:
+ return "Error: key is required for read"
+ if key not in _notes:
+ return f"Note '{key}' not found"
+
+ return f"[{key}] {_notes[key]}"
+
+ elif action == "update":
+ if not key:
+ return "Error: key is required for update"
+ if not value:
+ return "Error: value is required for update"
+
+ existed = key in _notes
+ _notes[key] = value
+ _save_notes()
+ return f"{'Updated' if existed else 'Created'} note '{key}'"
+
+ elif action == "delete":
+ if not key:
+ return "Error: key is required for delete"
+ if key not in _notes:
+ return f"Note '{key}' not found"
+
+ del _notes[key]
+ _save_notes()
+ return f"Deleted note '{key}'"
+
+ elif action == "list":
+ if not _notes:
+ return "No notes saved"
+
+ lines = [f"Notes ({len(_notes)} entries):"]
+ for k, v in _notes.items():
+ # Truncate long values for display
+ display_val = v if len(v) <= 60 else v[:57] + "..."
+ lines.append(f" [{k}] {display_val}")
+
+ return "\n".join(lines)
+
+ else:
+ return f"Unknown action: {action}"
diff --git a/ghostcrew/tools/registry.py b/ghostcrew/tools/registry.py
new file mode 100644
index 0000000..4fc7ad8
--- /dev/null
+++ b/ghostcrew/tools/registry.py
@@ -0,0 +1,235 @@
+"""Tool registry for GhostCrew."""
+
+from dataclasses import dataclass, field
+from functools import wraps
+from typing import TYPE_CHECKING, Any, Callable, Dict, List, Optional
+
+if TYPE_CHECKING:
+ from ..runtime import Runtime
+
+
+@dataclass
+class ToolSchema:
+ """JSON Schema for tool parameters."""
+
+ type: str = "object"
+ properties: Optional[Dict[str, Any]] = None
+ required: Optional[List[str]] = None
+
+ def __post_init__(self):
+ if self.properties is None:
+ self.properties = {}
+ if self.required is None:
+ self.required = []
+
+ def to_dict(self) -> dict:
+ """Convert to dictionary format."""
+ return {
+ "type": self.type,
+ "properties": self.properties,
+ "required": self.required,
+ }
+
+
+@dataclass
+class Tool:
+ """Represents a tool available to agents."""
+
+ name: str
+ description: str
+ schema: ToolSchema
+ execute_fn: Callable
+ category: str = "general"
+ enabled: bool = True
+ metadata: Dict[str, Any] = field(default_factory=dict)
+
+ async def execute(self, arguments: dict, runtime: "Runtime") -> str:
+ """
+ Execute the tool with given arguments.
+
+ Args:
+ arguments: The arguments for the tool
+ runtime: The runtime environment
+
+ Returns:
+ The tool execution result as a string
+ """
+ if not self.enabled:
+ return f"Tool '{self.name}' is currently disabled."
+
+ return await self.execute_fn(arguments, runtime)
+
+ def to_llm_format(self) -> dict:
+ """Convert to LLM function calling format."""
+ return {
+ "type": "function",
+ "function": {
+ "name": self.name,
+ "description": self.description,
+ "parameters": {
+ "type": self.schema.type,
+ "properties": self.schema.properties or {},
+ "required": self.schema.required or [],
+ },
+ },
+ }
+
+ def validate_arguments(self, arguments: dict) -> tuple[bool, Optional[str]]:
+ """
+ Validate arguments against the schema.
+
+ Args:
+ arguments: The arguments to validate
+
+ Returns:
+ Tuple of (is_valid, error_message)
+ """
+ # Check required fields
+ for required_field in self.schema.required or []:
+ if required_field not in arguments:
+ return False, f"Missing required field: {required_field}"
+
+ # Type checking (basic)
+ for key, value in arguments.items():
+ if key in (self.schema.properties or {}):
+ expected_type = self.schema.properties[key].get("type")
+ if expected_type:
+ if not self._check_type(value, expected_type):
+ return (
+ False,
+ f"Invalid type for {key}: expected {expected_type}",
+ )
+
+ return True, None
+
+ def _check_type(self, value: Any, expected_type: str) -> bool:
+ """Check if a value matches the expected type."""
+ type_map = {
+ "string": str,
+ "integer": int,
+ "number": (int, float),
+ "boolean": bool,
+ "array": list,
+ "object": dict,
+ }
+
+ expected = type_map.get(expected_type)
+ if expected is None:
+ return True # Unknown type, allow
+
+ return isinstance(value, expected)
+
+
+# Global tool registry
+_tools: Dict[str, Tool] = {}
+
+
+def register_tool(
+ name: str, description: str, schema: ToolSchema, category: str = "general"
+) -> Callable:
+ """
+ Decorator to register a tool.
+
+ Args:
+ name: The tool name
+ description: Description of what the tool does
+ schema: The parameter schema
+ category: Tool category for organization
+
+ Returns:
+ Decorator function
+ """
+
+ def decorator(fn: Callable) -> Callable:
+ @wraps(fn)
+ async def wrapper(arguments: dict, runtime: "Runtime") -> str:
+ return await fn(arguments, runtime)
+
+ tool = Tool(
+ name=name,
+ description=description,
+ schema=schema,
+ execute_fn=wrapper,
+ category=category,
+ )
+ _tools[name] = tool
+ return wrapper
+
+ return decorator
+
+
+def get_all_tools() -> List[Tool]:
+ """Get all registered tools."""
+ return list(_tools.values())
+
+
+def get_tool(name: str) -> Optional[Tool]:
+ """Get a tool by name."""
+ return _tools.get(name)
+
+
+def register_tool_instance(tool: Tool) -> None:
+ """
+ Register a pre-built tool instance (used for MCP tools).
+
+ Args:
+ tool: The Tool instance to register
+ """
+ _tools[tool.name] = tool
+
+
+def unregister_tool(name: str) -> bool:
+ """
+ Unregister a tool by name.
+
+ Args:
+ name: The tool name to unregister
+
+ Returns:
+ True if tool was unregistered, False if not found
+ """
+ if name in _tools:
+ del _tools[name]
+ return True
+ return False
+
+
+def get_tools_by_category(category: str) -> List[Tool]:
+ """
+ Get all tools in a specific category.
+
+ Args:
+ category: The category to filter by
+
+ Returns:
+ List of tools in that category
+ """
+ return [tool for tool in _tools.values() if tool.category == category]
+
+
+def clear_tools() -> None:
+ """Clear all registered tools."""
+ _tools.clear()
+
+
+def get_tool_names() -> List[str]:
+ """Get list of all registered tool names."""
+ return list(_tools.keys())
+
+
+def enable_tool(name: str) -> bool:
+ """Enable a tool by name."""
+ tool = _tools.get(name)
+ if tool:
+ tool.enabled = True
+ return True
+ return False
+
+
+def disable_tool(name: str) -> bool:
+ """Disable a tool by name."""
+ tool = _tools.get(name)
+ if tool:
+ tool.enabled = False
+ return True
+ return False
diff --git a/ghostcrew/tools/terminal/__init__.py b/ghostcrew/tools/terminal/__init__.py
new file mode 100644
index 0000000..599aafb
--- /dev/null
+++ b/ghostcrew/tools/terminal/__init__.py
@@ -0,0 +1,74 @@
+"""Terminal tool for GhostCrew."""
+
+from typing import TYPE_CHECKING
+
+from ..registry import ToolSchema, register_tool
+
+if TYPE_CHECKING:
+ from ...runtime import Runtime
+
+
+@register_tool(
+ name="terminal",
+ description="Execute shell commands.",
+ schema=ToolSchema(
+ properties={
+ "command": {
+ "type": "string",
+ "description": "The shell command to execute",
+ },
+ "timeout": {
+ "type": "integer",
+ "description": "Timeout in seconds (default: 300)",
+ "default": 300,
+ },
+ "working_dir": {
+ "type": "string",
+ "description": "Working directory for the command (optional)",
+ },
+ },
+ required=["command"],
+ ),
+ category="execution",
+)
+async def terminal(arguments: dict, runtime: "Runtime") -> str:
+ """
+ Execute a terminal command in the sandbox.
+
+ Args:
+ arguments: Dictionary with 'command', optional 'timeout' and 'working_dir'
+ runtime: The runtime environment
+
+ Returns:
+ Formatted output string with command results
+ """
+ command = arguments["command"]
+ timeout = arguments.get("timeout", 300)
+ working_dir = arguments.get("working_dir")
+
+ # Build the full command with working directory if specified
+ if working_dir:
+ full_command = f"cd {working_dir} && {command}"
+ else:
+ full_command = command
+
+ result = await runtime.execute_command(full_command, timeout=timeout)
+
+ # Format the output
+ output_parts = [f"Command: {command}"]
+
+ if working_dir:
+ output_parts.append(f"Working Directory: {working_dir}")
+
+ output_parts.append(f"Exit Code: {result.exit_code}")
+
+ if result.stdout:
+ output_parts.append(f"\n--- stdout ---\n{result.stdout}")
+
+ if result.stderr:
+ output_parts.append(f"\n--- stderr ---\n{result.stderr}")
+
+ if not result.stdout and not result.stderr:
+ output_parts.append("\n(No output)")
+
+ return "\n".join(output_parts)
diff --git a/ghostcrew/tools/terminal/terminal.py b/ghostcrew/tools/terminal/terminal.py
new file mode 100644
index 0000000..d8f1ce3
--- /dev/null
+++ b/ghostcrew/tools/terminal/terminal.py
@@ -0,0 +1,5 @@
+"""Terminal tool implementation."""
+
+from . import terminal
+
+__all__ = ["terminal"]
diff --git a/ghostcrew/tools/web_search/__init__.py b/ghostcrew/tools/web_search/__init__.py
new file mode 100644
index 0000000..6eadd10
--- /dev/null
+++ b/ghostcrew/tools/web_search/__init__.py
@@ -0,0 +1,104 @@
+"""Web search tool for GhostCrew."""
+
+import os
+from typing import TYPE_CHECKING
+
+import httpx
+
+from ..registry import ToolSchema, register_tool
+
+if TYPE_CHECKING:
+ from ...runtime import Runtime
+
+
+@register_tool(
+ name="web_search",
+ description="Search the web for new security research, CVEs, exploits, bypass techniques, and documentation.",
+ schema=ToolSchema(
+ properties={
+ "query": {
+ "type": "string",
+ "description": "Search query (be specific - include CVE numbers, tool names, versions)",
+ }
+ },
+ required=["query"],
+ ),
+ category="research",
+)
+async def web_search(arguments: dict, runtime: "Runtime") -> str:
+ """
+ Search the web using Tavily API.
+
+ Args:
+ arguments: Dictionary with 'query'
+ runtime: The runtime environment
+
+ Returns:
+ Search results formatted for the LLM
+ """
+ query = arguments.get("query", "").strip()
+ if not query:
+ return "Error: No search query provided"
+
+ api_key = os.getenv("TAVILY_API_KEY")
+ if not api_key:
+ return (
+ "Error: TAVILY_API_KEY environment variable not set.\n"
+ "Get a free API key at https://tavily.com (1000 searches/month free)"
+ )
+
+ try:
+ async with httpx.AsyncClient(timeout=30.0) as client:
+ response = await client.post(
+ "https://api.tavily.com/search",
+ json={
+ "api_key": api_key,
+ "query": query,
+ "search_depth": "advanced",
+ "include_answer": True,
+ "include_raw_content": False,
+ "max_results": 5,
+ },
+ )
+ response.raise_for_status()
+ data = response.json()
+
+ return _format_results(query, data)
+
+ except httpx.TimeoutException:
+ return "Error: Search request timed out"
+ except httpx.HTTPStatusError as e:
+ return f"Error: Search API returned {e.response.status_code}"
+ except Exception as e:
+ return f"Error: Search failed - {str(e)}"
+
+
+def _format_results(query: str, data: dict) -> str:
+ """Format Tavily results for LLM consumption."""
+ parts = [f"Search: {query}\n"]
+
+ # Include synthesized answer if available
+ if answer := data.get("answer"):
+ parts.append(f"Summary:\n{answer}\n")
+
+ # Include individual results
+ results = data.get("results", [])
+ if results:
+ parts.append("Sources:")
+ for i, result in enumerate(results, 1):
+ title = result.get("title", "Untitled")
+ url = result.get("url", "")
+ content = result.get("content", "")
+
+ # Truncate content if too long
+ if len(content) > 500:
+ content = content[:500] + "..."
+
+ parts.append(f"\n[{i}] {title}")
+ parts.append(f" URL: {url}")
+ if content:
+ parts.append(f" {content}")
+ else:
+ parts.append("No results found")
+
+ return "\n".join(parts)
diff --git a/knowledge/wordlist.txt b/knowledge/wordlist.txt
deleted file mode 100644
index ee25af5..0000000
--- a/knowledge/wordlist.txt
+++ /dev/null
@@ -1,34 +0,0 @@
-admin
-login
-dashboard
-test
-dev
-backup
-config
-old
-index.html
-index.php
-robots.txt
-.htaccess
-uploads
-testpass
-images
-js
-css
-api
-server-status
-cgi-bin
-cgi-local
-cgi-shl
-cgi
-scripts
-bin
-test-cgi
-nph-test
-nph-ping
-nph-trace
-formmail.cgi
-test.cgi
-guestbook.cgi
-count.cgi
-submit.cgi
diff --git a/main.py b/main.py
deleted file mode 100644
index 3b1d320..0000000
--- a/main.py
+++ /dev/null
@@ -1,42 +0,0 @@
-#!/usr/bin/env python3
-"""
-GHOSTCREW - AI-driven penetration testing assistant
-
-"""
-
-import asyncio
-import sys
-from colorama import init
-
-init(autoreset=True)
-
-from agents import set_tracing_disabled
-set_tracing_disabled(True)
-
-from agents.mcp import MCPServerStdio, MCPServerSse
-
-
-async def main():
- """Main application entry point."""
- try:
- from core.pentest_agent import PentestAgent
-
- agent = PentestAgent(MCPServerStdio, MCPServerSse)
- await agent.run()
-
- except ImportError as e:
- print(f"Error importing required modules: {e}")
- print("Please ensure all dependencies are installed: pip install -r requirements.txt")
- sys.exit(1)
- except KeyboardInterrupt:
- print("\nApplication interrupted by user.")
- sys.exit(0)
- except Exception as e:
- print(f"Unexpected error: {e}")
- import traceback
- traceback.print_exc()
- sys.exit(1)
-
-
-if __name__ == "__main__":
- asyncio.run(main())
\ No newline at end of file
diff --git a/mcp.json b/mcp.json
deleted file mode 100644
index 0e74b2b..0000000
--- a/mcp.json
+++ /dev/null
@@ -1,3 +0,0 @@
-{
- "servers": []
-}
\ No newline at end of file
diff --git a/pyproject.toml b/pyproject.toml
new file mode 100644
index 0000000..46eb449
--- /dev/null
+++ b/pyproject.toml
@@ -0,0 +1,131 @@
+[project]
+name = "ghostcrew"
+version = "0.2.0"
+description = "AI penetration testing"
+readme = "README.md"
+license = {text = "MIT"}
+requires-python = ">=3.10"
+authors = [
+ {name = "Masic"}
+]
+keywords = [
+ "penetration-testing",
+ "security",
+ "ai",
+ "llm",
+ "mcp",
+ "automation"
+]
+classifiers = [
+ "Development Status :: 3 - Alpha",
+ "Intended Audience :: Developers",
+ "Intended Audience :: Information Technology",
+ "License :: OSI Approved :: MIT License",
+ "Operating System :: OS Independent",
+ "Programming Language :: Python :: 3",
+ "Programming Language :: Python :: 3.10",
+ "Programming Language :: Python :: 3.11",
+ "Programming Language :: Python :: 3.12",
+ "Topic :: Security",
+ "Topic :: Software Development :: Libraries :: Python Modules",
+]
+dependencies = [
+ "litellm>=1.40.0",
+ "openai>=1.30.0",
+ "anthropic>=0.25.0",
+ "tiktoken>=0.7.0",
+ "aiohttp>=3.9.0",
+ "aiofiles>=23.2.0",
+ "playwright>=1.44.0",
+ "beautifulsoup4>=4.12.0",
+ "httpx>=0.27.0",
+ "numpy>=1.26.0",
+ "docker>=7.0.0",
+ "rich>=13.7.0",
+ "textual>=0.63.0",
+ "typer>=0.12.0",
+ "pydantic>=2.7.0",
+ "pydantic-settings>=2.2.0",
+ "python-dotenv>=1.0.0",
+ "pyyaml>=6.0.0",
+ "jinja2>=3.1.0",
+]
+
+[project.optional-dependencies]
+dev = [
+ "pytest>=8.2.0",
+ "pytest-asyncio>=0.23.0",
+ "pytest-cov>=5.0.0",
+ "pytest-mock>=3.14.0",
+ "black>=24.4.0",
+ "isort>=5.13.0",
+ "mypy>=1.10.0",
+ "ruff>=0.4.0",
+]
+rag = [
+ "sentence-transformers>=2.7.0",
+ "faiss-cpu>=1.8.0",
+]
+all = [
+ "ghostcrew[dev,rag]",
+]
+
+[project.urls]
+Homepage = "https://github.com/GH05TCREW/ghostcrew"
+
+[project.scripts]
+ghostcrew = "ghostcrew.interface.main:main"
+
+[build-system]
+requires = ["hatchling"]
+build-backend = "hatchling.build"
+
+[tool.hatch.build.targets.wheel]
+packages = ["ghostcrew"]
+
+[tool.hatch.build.targets.sdist]
+include = [
+ "ghostcrew/**",
+ "*.md",
+ "*.txt"
+]
+
+[tool.pytest.ini_options]
+asyncio_mode = "auto"
+testpaths = ["tests"]
+python_files = ["test_*.py"]
+python_functions = ["test_*"]
+addopts = "-v --tb=short"
+
+[tool.black]
+line-length = 88
+target-version = ["py310", "py311", "py312"]
+include = '\.pyi?$'
+
+[tool.isort]
+profile = "black"
+line_length = 88
+known_first_party = ["ghostcrew"]
+
+[tool.ruff]
+line-length = 88
+target-version = "py310"
+
+[tool.ruff.lint]
+select = [
+ "E", # pycodestyle errors
+ "W", # pycodestyle warnings
+ "F", # pyflakes
+ "I", # isort
+ "B", # flake8-bugbear
+ "C4", # flake8-comprehensions
+]
+ignore = [
+ "E501", # line too long (handled by black)
+]
+
+[tool.mypy]
+python_version = "3.10"
+warn_return_any = true
+warn_unused_configs = true
+ignore_missing_imports = true
diff --git a/rag/__init__.py b/rag/__init__.py
deleted file mode 100644
index d103297..0000000
--- a/rag/__init__.py
+++ /dev/null
@@ -1 +0,0 @@
-"""RAG (Retrieval-Augmented Generation) system for GHOSTCREW."""
\ No newline at end of file
diff --git a/rag/embedding.py b/rag/embedding.py
deleted file mode 100644
index b53370b..0000000
--- a/rag/embedding.py
+++ /dev/null
@@ -1,25 +0,0 @@
-import os
-import json
-from openai import OpenAI
-from dotenv import load_dotenv
-
-# Load environment variables from .env file
-load_dotenv()
-
-client = OpenAI(
- api_key=os.getenv("OPENAI_API_KEY"), # Use the standard OpenAI API key env variable
- base_url=os.getenv("OPENAI_BASE_URL") # Read base_url from environment variable
-)
-
-completion = client.embeddings.create(
- model="text-embedding-ada-002",
- input='This is a sample text for embedding generation to test the functionality.',
- encoding_format="float"
-)
-
-response_json = completion.model_dump_json()
-embedding_data = json.loads(response_json)
-embedding_array = embedding_data['data'][0]['embedding']
-print(len(embedding_array))
-print(type(embedding_array))
-print("Extracted embedding array:", embedding_array)
\ No newline at end of file
diff --git a/rag/knowledge_base.py b/rag/knowledge_base.py
deleted file mode 100644
index 86663e8..0000000
--- a/rag/knowledge_base.py
+++ /dev/null
@@ -1,136 +0,0 @@
-#from curses import color_content
-from ollama import chat,Message
-from ollama import embeddings
-import os
-import json
-from openai import OpenAI
-import numpy as np
-from dotenv import load_dotenv
-
-# Load environment variables from .env file
-load_dotenv()
-
-# Set numpy print options to display full arrays
-np.set_printoptions(threshold=np.inf)
-
-client = OpenAI(
- api_key=os.getenv("OPENAI_API_KEY"), # Use the standard OpenAI API key env variable
- base_url=os.getenv("OPENAI_BASE_URL") # Read base_url from environment variable
-)
-
-import os # Added for directory operations
-
-class Kb:
- def __init__(self, dirpath): # Read all documents in the directory
- all_content = ""
- if not os.path.isdir(dirpath):
- print(f"Error: {dirpath} is not a valid directory.")
- self.docs = []
- self.embedss = np.array([])
- return
-
- # Define binary file extensions to skip
- binary_extensions = {
- '.exe', '.dll', '.so', '.dylib', '.elf', '.bin', '.dat',
- '.zip', '.tar', '.gz', '.7z', '.rar', '.pdf', '.doc', '.docx',
- '.xls', '.xlsx', '.ppt', '.pptx', '.jpg', '.jpeg', '.png', '.gif',
- '.bmp', '.ico', '.mp3', '.mp4', '.avi', '.mov', '.wav', '.flv',
- '.iso', '.img', '.vmdk', '.vdi'
- }
-
- for filename in os.listdir(dirpath):
- filepath = os.path.join(dirpath, filename)
- if os.path.isfile(filepath):
- # Get file extension in lowercase
- file_ext = os.path.splitext(filename)[1].lower()
-
- # Skip binary files
- if file_ext in binary_extensions:
- continue
-
- try:
- with open(filepath, 'r', encoding="utf-8") as f:
- all_content += f.read() + "\n" # Add a newline to separate file contents
- except Exception as e:
- print(f"Error reading file {filepath}: {e}")
-
- if not all_content.strip():
- print(f"Warning: No content found in directory {dirpath}.")
- self.docs = []
- self.embedss = np.array([])
- return
-
- self.docs = self.split_content(all_content) # Split all document content after merging
- if self.docs:
- self.embedss = self.encode(self.docs)
- else:
- self.embedss = np.array([])
-
- @staticmethod
- def split_content(content,max_length=5000):
- chuncks=[]
- for i in range(0,len(content),max_length):
- chuncks.append(content[i:i+max_length])
- return chuncks
-
-
- def encode(self,texts):
- embeds=[]
- for text in texts:
- completion = client.embeddings.create(
- model="text-embedding-ada-002",
- input=text,
- encoding_format="float"
- )
- response_json = completion.model_dump_json()
- embedding_data = json.loads(response_json)
- embedding_array = embedding_data['data'][0]['embedding']
- embeds.append(embedding_array)
- return np.array(embeds)
-
-
- @staticmethod #similarity
- def similarity(A,B):
- dot_product=np.dot(A,B)
- norm_A=np.linalg.norm(A)
- norm_B=np.linalg.norm(B)
- similarity=dot_product/(norm_A*norm_B)
- return similarity
-
- def search(self,query):
- max_similarity=0
- max_similarity_index=0
- query_embedding=self.encode([query])[0]
- for idx,te in enumerate(self.embedss):
- similarity=self.similarity(query_embedding,te)
- if similarity>max_similarity:
- max_similarity=similarity
- max_similarity_index=idx
- return self.docs[max_similarity_index]
-
-
-if __name__ == "__main__":
- # Example usage: Create a dummy directory and file for testing
- test_kb_dir = "knowledge_test"
- if not os.path.exists(test_kb_dir):
- os.makedirs(test_kb_dir)
- with open(os.path.join(test_kb_dir, "test_doc.txt"), 'w', encoding='utf-8') as f:
- f.write("This is a test document for security audit information.")
-
- kb = Kb(test_kb_dir)
- if kb.docs: # Check if docs were loaded
- #for doc in kb.docs:
- # print("========================================================")
- # print(doc)
-
- #for e in kb.embedss:
- # print(e)
- result = kb.search("security audit")
- print(f"Search result: {result}")
- else:
- print("Knowledge base is empty or failed to load.")
-
- # Clean up dummy directory and file
- # import shutil
- # if os.path.exists(test_kb_dir):
- # shutil.rmtree(test_kb_dir)
\ No newline at end of file
diff --git a/reporting/__init__.py b/reporting/__init__.py
deleted file mode 100644
index fb1b610..0000000
--- a/reporting/__init__.py
+++ /dev/null
@@ -1 +0,0 @@
-"""Report generation system for GHOSTCREW."""
\ No newline at end of file
diff --git a/reporting/generators.py b/reporting/generators.py
deleted file mode 100644
index 60f832b..0000000
--- a/reporting/generators.py
+++ /dev/null
@@ -1,817 +0,0 @@
-"""
-Professional Markdown Report Generator for GHOSTCREW
-Processes workflow conversation history and generates beautiful reports
-"""
-
-import json
-import os
-import asyncio
-from datetime import datetime
-from typing import Dict, List, Any
-import re
-from colorama import Fore, Style
-
-
-class PentestReportGenerator:
- """Generate professional penetration testing reports from workflow data"""
-
- def __init__(self, report_data: Dict[str, Any]):
- self.workflow_name = report_data['workflow_name']
- self.workflow_key = report_data['workflow_key']
- self.target = report_data['target']
- self.timestamp = report_data['timestamp']
- self.conversation_history = report_data['conversation_history']
- self.tools_used = report_data.get('tools_used', [])
-
- # Will be populated by AI analysis
- self.structured_findings = {}
-
- def format_conversation_history(self) -> str:
- """Format conversation history for AI analysis"""
- formatted = []
-
- for i, entry in enumerate(self.conversation_history, 1):
- formatted.append(f"\n--- STEP {i} ---")
- formatted.append(f"QUERY: {entry.get('user_query', '')}")
- formatted.append(f"RESPONSE: {entry.get('ai_response', '')}")
- formatted.append("=" * 50)
-
- return "\n".join(formatted)
-
- def create_analysis_prompt(self) -> str:
- """Create comprehensive analysis prompt for AI"""
-
- prompt = f"""
-You are analyzing a complete penetration testing workflow to create a professional security assessment report.
-
-ASSESSMENT DETAILS:
-- Workflow: {self.workflow_name}
-- Target: {self.target}
-- Date: {self.timestamp.strftime('%Y-%m-%d')}
-- Tools Available: {', '.join(self.tools_used) if self.tools_used else 'Various security tools'}
-
-COMPLETE WORKFLOW CONVERSATION LOG:
-{self.format_conversation_history()}
-
-Please analyze this entire workflow and extract structured information. When extracting evidence, include the actual commands used and their outputs. Respond with a JSON object containing:
-
-{{
- "executive_summary": "2-3 paragraph executive summary focusing on business impact and key risks",
- "key_statistics": {{
- "total_vulnerabilities": 0,
- "critical_count": 0,
- "high_count": 0,
- "systems_tested": 0,
- "systems_compromised": 0
- }},
- "vulnerabilities": [
- {{
- "severity": "Critical|High|Medium|Low|Informational",
- "title": "Descriptive vulnerability title",
- "description": "Technical description of the vulnerability",
- "impact": "Business impact if exploited",
- "affected_systems": ["IP/hostname"],
- "remediation": "Specific remediation steps",
- "evidence": "Include actual commands used and key outputs that demonstrate this finding. Format as: 'Command: [command] Output: [relevant output]'",
- "cvss_score": "If applicable",
- "references": ["CVE numbers, links, etc."]
- }}
- ],
- "compromised_systems": [
- {{
- "system": "IP/hostname",
- "access_level": "user|admin|root|system",
- "method": "How access was gained",
- "evidence": "Actual commands and outputs showing compromise"
- }}
- ],
- "credentials_found": [
- {{
- "username": "username",
- "credential_type": "password|hash|token|key",
- "system": "where found",
- "strength": "weak|moderate|strong"
- }}
- ],
- "tools_used": ["List of tools actually used in testing"],
- "attack_paths": [
- {{
- "path_description": "Description of attack chain",
- "steps": ["Step 1", "Step 2", "etc"],
- "impact": "What this path achieves"
- }}
- ],
- "recommendations": [
- {{
- "priority": "Immediate|Short-term|Medium-term|Long-term",
- "category": "Network|Application|System|Process",
- "recommendation": "Specific actionable recommendation",
- "business_justification": "Why this is important for business"
- }}
- ],
- "methodology": "Brief description of testing methodology used",
- "scope_limitations": "Any scope limitations or areas not tested",
- "conclusion": "Overall security posture assessment and key takeaways"
-}}
-
-Focus on extracting real findings from the conversation. Include actual command examples and outputs in the evidence fields. If no vulnerabilities were found, that's also valuable information. Be accurate and professional.
-"""
- return prompt
-
- async def analyze_with_ai(self, prompt: str, run_agent_func, connected_servers, kb_instance=None):
- """Run AI analysis using the main agent function"""
- try:
- # Use streaming=True since run_agent doesn't properly handle streaming=False
- result = await run_agent_func(
- prompt,
- connected_servers,
- history=[],
- streaming=True,
- kb_instance=kb_instance
- )
-
- if result and hasattr(result, "final_output"):
- return result.final_output
- return None
-
- except Exception as e:
- print(f"Error in AI analysis: {e}")
- return None
-
- def parse_ai_response(self, ai_response: str) -> Dict[str, Any]:
- """Parse AI response and extract JSON data"""
- try:
- # Try to find JSON in the response
- json_start = ai_response.find('{')
- json_end = ai_response.rfind('}') + 1
-
- if json_start != -1 and json_end != -1:
- json_str = ai_response[json_start:json_end]
- return json.loads(json_str)
- else:
- # Fallback - create basic structure
- return {
- "executive_summary": ai_response[:500] + "...",
- "key_statistics": {"total_vulnerabilities": 0},
- "vulnerabilities": [],
- "compromised_systems": [],
- "recommendations": [],
- "methodology": "Standard penetration testing methodology",
- "conclusion": "Assessment completed successfully."
- }
-
- except json.JSONDecodeError:
- # Fallback structure
- return {
- "executive_summary": "Assessment completed. See technical findings for details.",
- "key_statistics": {"total_vulnerabilities": 0},
- "vulnerabilities": [],
- "compromised_systems": [],
- "recommendations": [],
- "methodology": "Standard penetration testing methodology",
- "conclusion": "Unable to parse detailed findings."
- }
-
- def generate_markdown_report(self) -> str:
- """Generate the final markdown report"""
- findings = self.structured_findings
-
- report = []
-
- # Title Page
- report.append(f"# Penetration Testing Report")
- report.append(f"\n## {self.workflow_name}")
- report.append(f"\n**Target:** {self.target} ")
- report.append(f"**Assessment Date:** {self.timestamp.strftime('%Y-%m-%d')} ")
- report.append(f"**Report Generated:** {datetime.now().strftime('%Y-%m-%d %H:%M:%S')} ")
- report.append(f"**Report ID:** GHOSTCREW-{self.workflow_key}-{int(self.timestamp.timestamp())} ")
- report.append(f"\n---\n")
-
- # Table of Contents
- report.append("## Table of Contents\n")
- report.append("1. [Executive Summary](#1-executive-summary)")
- report.append("2. [Assessment Overview](#2-assessment-overview)")
- report.append("3. [Key Findings](#3-key-findings)")
- report.append("4. [Vulnerability Details](#4-vulnerability-details)")
- report.append("5. [Compromised Systems](#5-compromised-systems)")
- report.append("6. [Attack Paths](#6-attack-paths)")
- report.append("7. [Recommendations](#7-recommendations)")
- report.append("8. [Technical Methodology](#8-technical-methodology)")
- report.append("9. [Conclusion](#9-conclusion)")
- report.append("\n---\n")
-
- # Executive Summary
- report.append("## 1. Executive Summary\n")
- report.append(findings.get('executive_summary', 'Assessment completed successfully.'))
- report.append("\n---\n")
-
- # Assessment Overview
- report.append("## 2. Assessment Overview\n")
- report.append(f"### Scope")
- report.append(f"- **Primary Target:** {self.target}")
- report.append(f"- **Assessment Type:** {self.workflow_name}")
- report.append(f"- **Testing Window:** {self.timestamp.strftime('%Y-%m-%d')}")
-
- if self.tools_used:
- report.append(f"\n### Tools Used")
- for tool in self.tools_used:
- report.append(f"- {tool}")
-
- stats = findings.get('key_statistics', {})
- if stats:
- report.append(f"\n### Key Statistics")
- report.append(f"- **Total Vulnerabilities:** {stats.get('total_vulnerabilities', 0)}")
- report.append(f"- **Critical Severity:** {stats.get('critical_count', 0)}")
- report.append(f"- **High Severity:** {stats.get('high_count', 0)}")
- report.append(f"- **Systems Compromised:** {stats.get('systems_compromised', 0)}")
-
- report.append("\n---\n")
-
- # Key Findings Summary
- report.append("## 3. Key Findings\n")
-
- vulnerabilities = findings.get('vulnerabilities', [])
- if vulnerabilities:
- # Group by severity
- severity_groups = {'Critical': [], 'High': [], 'Medium': [], 'Low': [], 'Informational': []}
- for vuln in vulnerabilities:
- severity = vuln.get('severity', 'Low')
- if severity in severity_groups:
- severity_groups[severity].append(vuln)
-
- report.append("### Vulnerability Summary\n")
- report.append("| Severity | Count | Description |")
- report.append("|----------|-------|-------------|")
-
- for severity, vulns in severity_groups.items():
- if vulns:
- count = len(vulns)
- titles = [v.get('title', 'Unknown') for v in vulns[:3]]
- desc = ', '.join(titles)
- if len(vulns) > 3:
- desc += f' (and {len(vulns) - 3} more)'
- report.append(f"| {severity} | {count} | {desc} |")
- else:
- report.append("No significant vulnerabilities were identified during the assessment.")
-
- report.append("\n---\n")
-
- # Vulnerability Details
- report.append("## 4. Vulnerability Details\n")
-
- if vulnerabilities:
- # Group by severity for detailed listing
- for severity in ['Critical', 'High', 'Medium', 'Low', 'Informational']:
- severity_vulns = [v for v in vulnerabilities if v.get('severity') == severity]
-
- if severity_vulns:
- report.append(f"### {severity} Severity Vulnerabilities\n")
-
- for i, vuln in enumerate(severity_vulns, 1):
- report.append(f"#### {severity.upper()}-{i:03d}: {vuln.get('title', 'Unknown Vulnerability')}\n")
- report.append(f"**Description:** {vuln.get('description', 'No description provided')}\n")
- report.append(f"**Impact:** {vuln.get('impact', 'Impact assessment pending')}\n")
-
- if vuln.get('affected_systems'):
- report.append(f"**Affected Systems:** {', '.join(vuln['affected_systems'])}\n")
-
- report.append(f"**Remediation:** {vuln.get('remediation', 'Remediation steps pending')}\n")
-
- if vuln.get('evidence'):
- report.append(f"**Evidence:**")
- report.append("```")
- report.append(vuln['evidence'])
- report.append("```")
-
- if vuln.get('references'):
- report.append(f"**References:** {', '.join(vuln['references'])}\n")
-
- report.append("\n")
- else:
- report.append("No vulnerabilities were identified during this assessment.")
-
- report.append("---\n")
-
- # Compromised Systems
- report.append("## 5. Compromised Systems\n")
-
- compromised = findings.get('compromised_systems', [])
- if compromised:
- report.append("| System | Access Level | Method | Evidence |")
- report.append("|--------|--------------|--------|----------|")
-
- for system in compromised:
- report.append(f"| {system.get('system', 'Unknown')} | {system.get('access_level', 'Unknown')} | {system.get('method', 'Unknown')} | {system.get('evidence', 'See technical details')[:50]}{'...' if len(system.get('evidence', '')) > 50 else ''} |")
- else:
- report.append("No systems were successfully compromised during the assessment.")
-
- report.append("\n---\n")
-
- # Attack Paths
- report.append("## 6. Attack Paths\n")
-
- attack_paths = findings.get('attack_paths', [])
- if attack_paths:
- for i, path in enumerate(attack_paths, 1):
- report.append(f"### Attack Path {i}: {path.get('path_description', 'Unknown Path')}\n")
- report.append(f"**Impact:** {path.get('impact', 'Unknown impact')}\n")
-
- steps = path.get('steps', [])
- if steps:
- report.append("**Steps:**")
- for step_num, step in enumerate(steps, 1):
- report.append(f"{step_num}. {step}")
- report.append("\n")
- else:
- report.append("No specific attack paths were identified or documented.")
-
- report.append("---\n")
-
- # Recommendations
- report.append("## 7. Recommendations\n")
-
- recommendations = findings.get('recommendations', [])
- if recommendations:
- # Group by priority
- priority_groups = {'Immediate': [], 'Short-term': [], 'Medium-term': [], 'Long-term': []}
- for rec in recommendations:
- priority = rec.get('priority', 'Medium-term')
- if priority in priority_groups:
- priority_groups[priority].append(rec)
-
- for priority, recs in priority_groups.items():
- if recs:
- report.append(f"### {priority} Priority\n")
- for rec in recs:
- report.append(f"**{rec.get('category', 'General')}:** {rec.get('recommendation', 'No recommendation provided')}")
- if rec.get('business_justification'):
- report.append(f" \n*Business Justification:* {rec['business_justification']}")
- report.append("\n")
- else:
- report.append("Continue following security best practices and conduct regular assessments.")
-
- report.append("---\n")
-
- # Technical Methodology
- report.append("## 8. Technical Methodology\n")
- report.append(findings.get('methodology', 'Standard penetration testing methodology was followed.'))
-
- if findings.get('scope_limitations'):
- report.append(f"\n### Scope Limitations\n")
- report.append(findings['scope_limitations'])
-
- report.append("\n---\n")
-
- # Conclusion
- report.append("## 9. Conclusion\n")
- report.append(findings.get('conclusion', 'Assessment completed successfully.'))
-
- report.append(f"\n\n---\n")
- report.append(f"*Report generated by GHOSTCREW v0.1.0* ")
- report.append(f"*{datetime.now().strftime('%Y-%m-%d %H:%M:%S')}*")
-
- return "\n".join(report)
-
- async def generate_report(self, run_agent_func, connected_servers, kb_instance=None, save_raw_history=False) -> str:
- """Main method to generate the complete report"""
-
- print(f"Analyzing workflow findings...")
-
- # Step 1: Create analysis prompt
- analysis_prompt = self.create_analysis_prompt()
-
- # Step 2: Get AI analysis
- ai_response = await self.analyze_with_ai(
- analysis_prompt,
- run_agent_func,
- connected_servers,
- kb_instance
- )
-
- if not ai_response:
- raise Exception("Failed to get AI analysis")
-
- print(f"Extracting structured findings...")
-
- # Step 3: Parse AI response
- self.structured_findings = self.parse_ai_response(ai_response)
-
- print(f"Generating markdown report...")
-
- # Step 4: Generate markdown report
- markdown_report = self.generate_markdown_report()
-
- # Step 5: Save report with options
- report_filename = self.save_report(markdown_report, save_raw_history)
-
- return report_filename
-
- def save_report(self, markdown_content: str, save_raw_history: bool = False) -> str:
- """Save the report to file with optional raw history"""
-
- # Create reports directory if it doesn't exist
- reports_dir = "reports"
- if not os.path.exists(reports_dir):
- os.makedirs(reports_dir)
-
- # Generate filename
- timestamp_str = str(int(self.timestamp.timestamp()))
- filename = f"{reports_dir}/ghostcrew_{self.workflow_key}_{timestamp_str}.md"
-
- # Save markdown file
- with open(filename, 'w', encoding='utf-8') as f:
- f.write(markdown_content)
-
- # Optionally save raw conversation history
- if save_raw_history:
- raw_history_content = []
- raw_history_content.append(f"GHOSTCREW Raw Workflow History")
- raw_history_content.append(f"Workflow: {self.workflow_name}")
- raw_history_content.append(f"Target: {self.target}")
- raw_history_content.append(f"Date: {self.timestamp.strftime('%Y-%m-%d %H:%M:%S')}")
- raw_history_content.append(f"=" * 60)
- raw_history_content.append("")
-
- for i, entry in enumerate(self.conversation_history, 1):
- raw_history_content.append(f"STEP {i} - QUERY:")
- raw_history_content.append("-" * 40)
- raw_history_content.append(entry.get('user_query', 'No query recorded'))
- raw_history_content.append("")
- raw_history_content.append(f"STEP {i} - AI RESPONSE:")
- raw_history_content.append("-" * 40)
- raw_history_content.append(entry.get('ai_response', 'No response recorded'))
- raw_history_content.append("")
- raw_history_content.append("=" * 60)
- raw_history_content.append("")
-
- raw_filename = f"{reports_dir}/ghostcrew_{self.workflow_key}_{timestamp_str}_raw_history.txt"
- with open(raw_filename, 'w', encoding='utf-8') as f:
- f.write('\n'.join(raw_history_content))
- print(f"Raw conversation history saved: {raw_filename}")
-
- return filename
-
-
-async def generate_report_from_workflow(report_data: Dict[str, Any], run_agent_func, connected_servers, kb_instance=None, save_raw_history=False) -> str:
- """
- Main function to generate a professional report from workflow data
-
- Args:
- report_data: Dictionary containing workflow information
- run_agent_func: The main agent function for AI analysis
- connected_servers: Connected MCP servers
- kb_instance: Knowledge base instance
- save_raw_history: Whether to save raw conversation history
-
- Returns:
- str: Path to generated report file
- """
-
- generator = PentestReportGenerator(report_data)
- return await generator.generate_report(run_agent_func, connected_servers, kb_instance, save_raw_history)
-
-
-async def generate_report_from_ptt(ptt_manager, conversation_history: List[Dict[str, Any]], run_agent_func=None, connected_servers=None, kb_instance=None, save_raw_history=False) -> str:
- """
- Generate a professional report from PTT (Pentesting Task Tree) data
-
- Args:
- ptt_manager: TaskTreeManager instance containing the PTT
- conversation_history: List of conversation history entries
- run_agent_func: The main agent function for AI analysis
- connected_servers: Connected MCP servers
- kb_instance: Knowledge base instance
- save_raw_history: Whether to save raw conversation history
-
- Returns:
- str: Path to generated report file
- """
-
- # Convert PTT data to report-compatible format
- report_data = {
- 'workflow_name': f"Agent Mode: {ptt_manager.goal}",
- 'workflow_key': 'agent_mode',
- 'target': ptt_manager.target,
- 'timestamp': ptt_manager.creation_time,
- 'conversation_history': conversation_history,
- 'tools_used': [server.name for server in connected_servers] if connected_servers else [],
- 'ptt_data': {
- 'goal': ptt_manager.goal,
- 'target': ptt_manager.target,
- 'constraints': ptt_manager.constraints,
- 'statistics': ptt_manager.get_statistics(),
- 'tree_structure': ptt_manager.to_natural_language(),
- 'nodes': {node_id: node.to_dict() for node_id, node in ptt_manager.nodes.items()}
- }
- }
-
- # Create a specialized PTT report generator
- generator = PTTReportGenerator(report_data)
-
- if run_agent_func and connected_servers:
- return await generator.generate_report(run_agent_func, connected_servers, kb_instance, save_raw_history)
- else:
- # Generate a basic report without AI analysis if no agent function available
- return generator.generate_basic_report(save_raw_history)
-
-
-class PTTReportGenerator:
- """Generate professional penetration testing reports from PTT data"""
-
- def __init__(self, report_data: Dict[str, Any]):
- self.workflow_name = report_data['workflow_name']
- self.workflow_key = report_data['workflow_key']
- self.target = report_data['target']
- self.timestamp = report_data['timestamp']
- self.conversation_history = report_data['conversation_history']
- self.tools_used = report_data.get('tools_used', [])
- self.ptt_data = report_data.get('ptt_data', {})
-
- # Will be populated by AI analysis
- self.structured_findings = {}
-
- def generate_basic_report(self, save_raw_history: bool = False) -> str:
- """Generate a basic report without AI analysis"""
- # Extract findings from PTT nodes
- vulnerabilities = []
- completed_tasks = []
- failed_tasks = []
-
- for node_data in self.ptt_data.get('nodes', {}).values():
- if node_data.get('status') == 'completed' and node_data.get('findings'):
- completed_tasks.append({
- 'description': node_data.get('description', ''),
- 'findings': node_data.get('findings', ''),
- 'tool_used': node_data.get('tool_used', ''),
- 'output_summary': node_data.get('output_summary', '')
- })
- elif node_data.get('status') == 'vulnerable':
- vulnerabilities.append({
- 'title': node_data.get('description', 'Unknown Vulnerability'),
- 'description': node_data.get('findings', 'No description available'),
- 'severity': 'Medium', # Default severity
- 'affected_systems': [self.target],
- 'evidence': node_data.get('output_summary', ''),
- 'remediation': 'Review and patch identified vulnerabilities'
- })
- elif node_data.get('status') == 'failed':
- failed_tasks.append({
- 'description': node_data.get('description', ''),
- 'tool_used': node_data.get('tool_used', ''),
- 'error': node_data.get('output_summary', '')
- })
-
- # Create structured findings
- self.structured_findings = {
- 'executive_summary': f"Autonomous penetration testing completed against {self.target}. Goal: {self.ptt_data.get('goal', 'Unknown')}. {len(completed_tasks)} tasks completed successfully, {len(vulnerabilities)} vulnerabilities identified.",
- 'vulnerabilities': vulnerabilities,
- 'key_statistics': {
- 'total_vulnerabilities': len(vulnerabilities),
- 'critical_count': 0,
- 'high_count': 0,
- 'medium_count': len(vulnerabilities),
- 'systems_compromised': 1 if vulnerabilities else 0
- },
- 'methodology': f"Autonomous penetration testing using Pentesting Task Tree (PTT) methodology with intelligent task prioritization and execution.",
- 'conclusion': f"Assessment {'successfully identified security weaknesses' if vulnerabilities else 'completed without identifying critical vulnerabilities'}. {'Immediate remediation recommended' if vulnerabilities else 'Continue monitoring and regular assessments'}.",
- 'recommendations': [
- {
- 'category': 'Patch Management',
- 'recommendation': 'Apply security patches to all identified vulnerable services',
- 'priority': 'Immediate',
- 'business_justification': 'Prevents exploitation of known vulnerabilities'
- },
- {
- 'category': 'Monitoring',
- 'recommendation': 'Implement monitoring for the services and ports identified during reconnaissance',
- 'priority': 'Short-term',
- 'business_justification': 'Early detection of potential security incidents'
- }
- ] if vulnerabilities else [
- {
- 'category': 'Continued Security',
- 'recommendation': 'Maintain current security posture and conduct regular assessments',
- 'priority': 'Medium-term',
- 'business_justification': 'Proactive security maintenance'
- }
- ]
- }
-
- # Generate the markdown report
- markdown_report = self.generate_markdown_report()
-
- # Save report
- return self.save_report(markdown_report, save_raw_history)
-
- async def generate_report(self, run_agent_func, connected_servers, kb_instance=None, save_raw_history=False) -> str:
- """Generate a comprehensive report with AI analysis"""
- try:
- # Create analysis prompt specifically for PTT data
- analysis_prompt = self.create_ptt_analysis_prompt()
-
- # Get AI analysis
- ai_response = await self.analyze_with_ai(
- analysis_prompt,
- run_agent_func,
- connected_servers,
- kb_instance
- )
-
- if ai_response:
- # Parse AI response
- self.structured_findings = self.parse_ai_response(ai_response)
- else:
- print(f"{Fore.YELLOW}AI analysis failed, generating basic report...{Style.RESET_ALL}")
- return self.generate_basic_report(save_raw_history)
-
- except Exception as e:
- print(f"{Fore.YELLOW}Error in AI analysis: {e}. Generating basic report...{Style.RESET_ALL}")
- return self.generate_basic_report(save_raw_history)
-
- # Generate markdown report
- markdown_report = self.generate_markdown_report()
-
- # Save report
- return self.save_report(markdown_report, save_raw_history)
-
- def create_ptt_analysis_prompt(self) -> str:
- """Create analysis prompt for PTT data"""
- ptt_structure = self.ptt_data.get('tree_structure', 'No PTT structure available')
- goal = self.ptt_data.get('goal', 'Unknown goal')
- target = self.target
- statistics = self.ptt_data.get('statistics', {})
-
- # Extract key findings from completed tasks
- key_findings = []
- for node_data in self.ptt_data.get('nodes', {}).values():
- if node_data.get('status') in ['completed', 'vulnerable'] and node_data.get('findings'):
- key_findings.append(f"- {node_data.get('description', '')}: {node_data.get('findings', '')}")
-
- findings_text = '\n'.join(key_findings) if key_findings else 'No significant findings recorded'
-
- prompt = f"""You are analyzing the results of an autonomous penetration test conducted using a Pentesting Task Tree (PTT) methodology.
-
-ASSESSMENT DETAILS:
-Goal: {goal}
-Target: {target}
-Statistics: {statistics}
-
-PTT STRUCTURE:
-{ptt_structure}
-
-KEY FINDINGS:
-{findings_text}
-
-Based on this PTT analysis, provide a comprehensive security assessment in the following JSON format:
-
-{{
- "executive_summary": "Professional executive summary of the assessment",
- "key_statistics": {{
- "total_vulnerabilities": 0,
- "critical_count": 0,
- "high_count": 0,
- "medium_count": 0,
- "low_count": 0,
- "systems_compromised": 0
- }},
- "vulnerabilities": [
- {{
- "title": "Vulnerability name",
- "description": "Detailed description",
- "severity": "Critical/High/Medium/Low",
- "impact": "Business impact description",
- "affected_systems": ["system1", "system2"],
- "evidence": "Technical evidence",
- "remediation": "Specific remediation steps",
- "references": ["CVE-XXXX", "reference links"]
- }}
- ],
- "compromised_systems": [
- {{
- "system": "system identifier",
- "access_level": "user/admin/root",
- "method": "exploitation method",
- "evidence": "proof of compromise"
- }}
- ],
- "attack_paths": [
- {{
- "path_description": "Attack path name",
- "impact": "potential impact",
- "steps": ["step1", "step2", "step3"]
- }}
- ],
- "recommendations": [
- {{
- "category": "category name",
- "recommendation": "specific recommendation",
- "priority": "Immediate/Short-term/Medium-term/Long-term",
- "business_justification": "why this matters to business"
- }}
- ],
- "methodology": "Description of the PTT methodology used",
- "conclusion": "Professional conclusion of the assessment"
-}}
-
-Focus on:
-1. Extracting real security findings from the PTT execution
-2. Proper risk classification
-3. Actionable recommendations
-4. Business-relevant impact assessment"""
-
- return prompt
-
- async def analyze_with_ai(self, prompt: str, run_agent_func, connected_servers, kb_instance) -> str:
- """Analyze the assessment with AI"""
- try:
- result = await run_agent_func(
- prompt,
- connected_servers,
- history=[],
- streaming=True,
- kb_instance=kb_instance
- )
-
- if hasattr(result, "final_output"):
- return result.final_output
- elif hasattr(result, "output"):
- return result.output
- elif isinstance(result, str):
- return result
-
- except Exception as e:
- print(f"{Fore.RED}Error in AI analysis: {e}{Style.RESET_ALL}")
-
- return None
-
- def parse_ai_response(self, response: str) -> Dict[str, Any]:
- """Parse AI response for structured findings"""
- try:
- # Try to extract JSON from the response
- import re
-
- # Look for JSON in the response
- json_match = re.search(r'\{.*\}', response, re.DOTALL)
- if json_match:
- json_str = json_match.group()
- return json.loads(json_str)
-
- except Exception as e:
- print(f"{Fore.YELLOW}Failed to parse AI response: {e}{Style.RESET_ALL}")
-
- # Fallback to basic findings
- return {
- 'executive_summary': 'Assessment completed successfully.',
- 'vulnerabilities': [],
- 'key_statistics': {'total_vulnerabilities': 0},
- 'methodology': 'Autonomous penetration testing using PTT methodology.',
- 'conclusion': 'Assessment completed.',
- 'recommendations': []
- }
-
- def generate_markdown_report(self) -> str:
- """Generate the final markdown report using the same format as PentestReportGenerator"""
- # Use the same report generation logic as the workflow reporter
- temp_generator = PentestReportGenerator({
- 'workflow_name': self.workflow_name,
- 'workflow_key': self.workflow_key,
- 'target': self.target,
- 'timestamp': self.timestamp,
- 'conversation_history': self.conversation_history,
- 'tools_used': self.tools_used
- })
- temp_generator.structured_findings = self.structured_findings
-
- return temp_generator.generate_markdown_report()
-
- def save_report(self, markdown_content: str, save_raw_history: bool = False) -> str:
- """Save the report to file"""
- # Create reports directory if it doesn't exist
- reports_dir = "reports"
- if not os.path.exists(reports_dir):
- os.makedirs(reports_dir)
-
- # Generate filename
- timestamp_str = str(int(self.timestamp.timestamp()))
- safe_target = re.sub(r'[^\w\-_\.]', '_', self.target)
- filename = f"{reports_dir}/ghostcrew_agent_mode_{safe_target}_{timestamp_str}.md"
-
- # Save markdown file
- with open(filename, 'w', encoding='utf-8') as f:
- f.write(markdown_content)
-
- # Optionally save raw history and PTT data
- if save_raw_history:
- raw_filename = f"{reports_dir}/ghostcrew_agent_mode_{safe_target}_{timestamp_str}_raw.json"
- raw_data = {
- 'ptt_data': self.ptt_data,
- 'conversation_history': self.conversation_history,
- 'timestamp': self.timestamp.isoformat()
- }
-
- with open(raw_filename, 'w', encoding='utf-8') as f:
- json.dump(raw_data, f, indent=2, default=str)
-
- print(f"{Fore.GREEN}Raw PTT data saved: {raw_filename}{Style.RESET_ALL}")
-
- return filename
\ No newline at end of file
diff --git a/requirements.txt b/requirements.txt
index 49f62d8..310590a 100644
--- a/requirements.txt
+++ b/requirements.txt
@@ -1,14 +1,46 @@
-colorama==0.4.6
-python-dotenv==1.1.0
-openai==1.78.1
-uvicorn==0.34.0
-mcp==1.6.0
-langchain==0.3.25
-langchain-community==0.3.24
-numpy==2.2.5
-ollama==0.4.8
-openai-agents==0.0.14
-fastapi==0.115.9
-pymetasploit3==1.0.6
-tiktoken==0.9.0
-# Add other necessary dependencies
\ No newline at end of file
+# GhostCrew Dependencies
+
+# Core LLM
+litellm>=1.40.0
+openai>=1.30.0
+anthropic>=0.25.0
+tiktoken>=0.7.0
+
+# Async
+aiohttp>=3.9.0
+aiofiles>=23.2.0
+
+# Web
+playwright>=1.44.0
+beautifulsoup4>=4.12.0
+httpx>=0.27.0
+
+# RAG / Embeddings
+numpy>=1.26.0
+sentence-transformers>=2.7.0
+faiss-cpu>=1.8.0
+
+# Docker
+docker>=7.0.0
+
+# CLI / TUI
+rich>=13.7.0
+textual>=0.63.0
+typer>=0.12.0
+
+# Config
+pydantic>=2.7.0
+pydantic-settings>=2.2.0
+python-dotenv>=1.0.0
+pyyaml>=6.0.0
+jinja2>=3.1.0
+
+# Dev
+pytest>=8.2.0
+pytest-asyncio>=0.23.0
+pytest-cov>=5.0.0
+pytest-mock>=3.14.0
+black>=24.4.0
+isort>=5.13.0
+mypy>=1.10.0
+ruff>=0.4.0
diff --git a/scripts/run.sh b/scripts/run.sh
new file mode 100644
index 0000000..849099b
--- /dev/null
+++ b/scripts/run.sh
@@ -0,0 +1,74 @@
+#!/bin/bash
+# GhostCrew Run Script
+
+set -e
+
+# Activate virtual environment if exists
+if [ -d "venv" ]; then
+ source venv/bin/activate
+fi
+
+# Load environment variables
+if [ -f ".env" ]; then
+ export $(grep -v '^#' .env | xargs)
+fi
+
+# Parse arguments
+MODE="cli"
+TARGET=""
+VPN_CONFIG=""
+
+while [[ $# -gt 0 ]]; do
+ case $1 in
+ --tui)
+ MODE="tui"
+ shift
+ ;;
+ --target)
+ TARGET="$2"
+ shift 2
+ ;;
+ --vpn)
+ VPN_CONFIG="$2"
+ shift 2
+ ;;
+ --help)
+ echo "GhostCrew - AI Penetration Testing"
+ echo ""
+ echo "Usage: run.sh [options]"
+ echo ""
+ echo "Options:"
+ echo " --tui Run in TUI mode"
+ echo " --target Set initial target"
+ echo " --vpn Connect to VPN before starting"
+ echo " --help Show this help message"
+ exit 0
+ ;;
+ *)
+ echo "Unknown option: $1"
+ exit 1
+ ;;
+ esac
+done
+
+# Connect to VPN if specified
+if [ -n "$VPN_CONFIG" ]; then
+ echo "Connecting to VPN..."
+ sudo openvpn --config "$VPN_CONFIG" --daemon
+ sleep 5
+fi
+
+# Build command
+CMD="python -m ghostcrew"
+
+if [ "$MODE" = "tui" ]; then
+ CMD="$CMD --tui"
+fi
+
+if [ -n "$TARGET" ]; then
+ CMD="$CMD --target $TARGET"
+fi
+
+# Run GhostCrew
+echo "Starting GhostCrew..."
+$CMD
diff --git a/scripts/setup.ps1 b/scripts/setup.ps1
new file mode 100644
index 0000000..2bb1e12
--- /dev/null
+++ b/scripts/setup.ps1
@@ -0,0 +1,89 @@
+# GhostCrew PowerShell Setup Script
+
+Write-Host "GhostCrew Setup" -ForegroundColor Blue
+Write-Host "AI Penetration Testing" -ForegroundColor Green
+Write-Host ""
+
+# Check Python version
+Write-Host "Checking Python version..." -ForegroundColor Yellow
+try {
+ $pythonVersion = python --version 2>&1
+ if ($pythonVersion -match "Python (\d+)\.(\d+)") {
+ $major = [int]$Matches[1]
+ $minor = [int]$Matches[2]
+ if ($major -lt 3 -or ($major -eq 3 -and $minor -lt 10)) {
+ Write-Host "Error: Python 3.10 or higher is required" -ForegroundColor Red
+ exit 1
+ }
+ Write-Host "[OK] $pythonVersion" -ForegroundColor Green
+ }
+} catch {
+ Write-Host "Error: Python not found. Please install Python 3.10+" -ForegroundColor Red
+ exit 1
+}
+
+# Create virtual environment
+Write-Host "Creating virtual environment..." -ForegroundColor Yellow
+if (-not (Test-Path "venv")) {
+ python -m venv venv
+ Write-Host "[OK] Virtual environment created" -ForegroundColor Green
+} else {
+ Write-Host "[OK] Virtual environment exists" -ForegroundColor Green
+}
+
+# Activate virtual environment
+Write-Host "Activating virtual environment..." -ForegroundColor Yellow
+& .\venv\Scripts\Activate.ps1
+
+# Upgrade pip
+Write-Host "Upgrading pip..." -ForegroundColor Yellow
+pip install --upgrade pip
+
+# Install dependencies
+Write-Host "Installing dependencies..." -ForegroundColor Yellow
+pip install -e ".[all]"
+Write-Host "[OK] Dependencies installed" -ForegroundColor Green
+
+# Install playwright browsers
+Write-Host "Installing Playwright browsers..." -ForegroundColor Yellow
+playwright install chromium
+Write-Host "[OK] Playwright browsers installed" -ForegroundColor Green
+
+# Create .env file if not exists
+if (-not (Test-Path ".env")) {
+ Write-Host "Creating .env file..." -ForegroundColor Yellow
+ @"
+# GhostCrew Configuration
+# Add your API keys here
+
+# OpenAI API Key (required for GPT models)
+OPENAI_API_KEY=
+
+# Anthropic API Key (required for Claude models)
+ANTHROPIC_API_KEY=
+
+# Model Configuration
+GHOSTCREW_MODEL=gpt-5
+
+# Debug Mode
+GHOSTCREW_DEBUG=false
+
+# Max Iterations
+GHOSTCREW_MAX_ITERATIONS=50
+"@ | Set-Content -Path ".env" -Encoding UTF8
+ Write-Host "[OK] .env file created" -ForegroundColor Green
+ Write-Host "[!] Please edit .env and add your API keys" -ForegroundColor Yellow
+}
+
+# Create loot directory for reports
+New-Item -ItemType Directory -Force -Path "loot" | Out-Null
+Write-Host "[OK] Loot directory created" -ForegroundColor Green
+
+Write-Host ""
+Write-Host "Setup complete!" -ForegroundColor Green
+Write-Host ""
+Write-Host "To get started:"
+Write-Host " 1. Edit .env and add your API keys"
+Write-Host " 2. Activate: .\venv\Scripts\Activate.ps1"
+Write-Host " 3. Run: ghostcrew or python -m ghostcrew"
+Write-Host ""
diff --git a/scripts/setup.sh b/scripts/setup.sh
new file mode 100644
index 0000000..aa41cd7
--- /dev/null
+++ b/scripts/setup.sh
@@ -0,0 +1,97 @@
+#!/bin/bash
+# GhostCrew Setup Script
+
+set -e
+
+# Colors
+RED='\033[0;31m'
+GREEN='\033[0;32m'
+YELLOW='\033[1;33m'
+BLUE='\033[0;34m'
+NC='\033[0m'
+
+echo -e "${BLUE}GhostCrew${NC} - AI Penetration Testing"
+echo ""
+
+# Check Python version
+echo -e "${YELLOW}Checking Python version...${NC}"
+python_version=$(python3 --version 2>&1 | cut -d' ' -f2 | cut -d'.' -f1,2)
+required_version="3.10"
+
+if [ "$(printf '%s\n' "$required_version" "$python_version" | sort -V | head -n1)" != "$required_version" ]; then
+ echo -e "${RED}Error: Python $required_version or higher is required (found $python_version)${NC}"
+ exit 1
+fi
+echo -e "${GREEN}✓ Python $python_version${NC}"
+
+# Create virtual environment
+echo -e "${YELLOW}Creating virtual environment...${NC}"
+if [ ! -d "venv" ]; then
+ python3 -m venv venv
+ echo -e "${GREEN}✓ Virtual environment created${NC}"
+else
+ echo -e "${GREEN}✓ Virtual environment exists${NC}"
+fi
+
+# Activate virtual environment
+echo -e "${YELLOW}Activating virtual environment...${NC}"
+source venv/bin/activate
+
+# Upgrade pip
+echo -e "${YELLOW}Upgrading pip...${NC}"
+pip install --upgrade pip
+
+# Install dependencies
+echo -e "${YELLOW}Installing dependencies...${NC}"
+pip install -e ".[all]"
+echo -e "${GREEN}✓ Dependencies installed${NC}"
+
+# Install playwright browsers
+echo -e "${YELLOW}Installing Playwright browsers...${NC}"
+playwright install chromium
+echo -e "${GREEN}✓ Playwright browsers installed${NC}"
+
+# Create .env file if not exists
+if [ ! -f ".env" ]; then
+ echo -e "${YELLOW}Creating .env file...${NC}"
+ cat > .env << EOF
+# GhostCrew Configuration
+# Add your API keys here
+
+# OpenAI API Key (required for GPT models)
+OPENAI_API_KEY=
+
+# Anthropic API Key (required for Claude models)
+ANTHROPIC_API_KEY=
+
+# Model Configuration
+GHOSTCREW_MODEL=gpt-5
+
+# Debug Mode
+GHOSTCREW_DEBUG=false
+
+# Max Iterations
+GHOSTCREW_MAX_ITERATIONS=50
+EOF
+ echo -e "${GREEN}✓ .env file created${NC}"
+ echo -e "${YELLOW}⚠️ Please edit .env and add your API keys${NC}"
+fi
+
+# Create loot directory for reports
+mkdir -p loot
+echo -e "${GREEN}✓ Loot directory created${NC}"
+
+echo ""
+echo -e "${GREEN}━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━${NC}"
+echo -e "${GREEN}Setup complete!${NC}"
+echo ""
+echo -e "To get started:"
+echo -e " 1. Edit ${YELLOW}.env${NC} and add your API keys"
+echo -e " 2. Activate the virtual environment: ${YELLOW}source venv/bin/activate${NC}"
+echo -e " 3. Run GhostCrew: ${YELLOW}ghostcrew${NC} or ${YELLOW}python -m ghostcrew${NC}"
+echo ""
+echo -e "For Docker usage:"
+echo -e " ${YELLOW}docker-compose up ghostcrew${NC}"
+echo -e " ${YELLOW}docker-compose --profile kali up ghostcrew-kali${NC}"
+echo ""
+echo -e "${GREEN}━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━${NC}"
diff --git a/tests/__init__.py b/tests/__init__.py
new file mode 100644
index 0000000..ea79261
--- /dev/null
+++ b/tests/__init__.py
@@ -0,0 +1 @@
+# GhostCrew Tests
diff --git a/tests/conftest.py b/tests/conftest.py
new file mode 100644
index 0000000..a430fce
--- /dev/null
+++ b/tests/conftest.py
@@ -0,0 +1,95 @@
+"""Test fixtures for GhostCrew tests."""
+
+import pytest
+import asyncio
+from pathlib import Path
+from typing import Generator, AsyncGenerator
+from unittest.mock import MagicMock, AsyncMock
+
+from ghostcrew.config import Settings
+from ghostcrew.agents.state import AgentState, AgentStateManager
+from ghostcrew.tools import get_all_tools, Tool, ToolSchema
+
+
+@pytest.fixture
+def settings() -> Settings:
+ """Create test settings."""
+ return Settings(
+ debug=True,
+ )
+
+
+@pytest.fixture
+def agent_state() -> AgentStateManager:
+ """Create a test agent state manager."""
+ return AgentStateManager()
+
+
+@pytest.fixture
+def mock_llm() -> MagicMock:
+ """Create a mock LLM."""
+ mock = AsyncMock()
+ mock.generate.return_value = MagicMock(
+ content="Test response",
+ tool_calls=None,
+ usage={"prompt_tokens": 100, "completion_tokens": 50},
+ model="gpt-5",
+ finish_reason="stop"
+ )
+ return mock
+
+
+@pytest.fixture
+def temp_dir(tmp_path: Path) -> Path:
+ """Create a temporary directory for tests."""
+ return tmp_path
+
+
+@pytest.fixture
+def event_loop():
+ """Create an event loop for async tests."""
+ loop = asyncio.new_event_loop()
+ yield loop
+ loop.close()
+
+
+@pytest.fixture
+def sample_finding() -> dict:
+ """Create a sample security finding."""
+ return {
+ "title": "SQL Injection in Login Form",
+ "severity": "high",
+ "target": "http://example.com/login",
+ "description": "The login form is vulnerable to SQL injection attacks.",
+ "evidence": "Parameter 'username' with payload: ' OR '1'='1",
+ "remediation": "Use parameterized queries or prepared statements."
+ }
+
+
+@pytest.fixture
+def sample_tool_result() -> dict:
+ """Create a sample tool execution result."""
+ return {
+ "tool": "terminal",
+ "success": True,
+ "output": "nmap scan results...",
+ "duration_ms": 1500.0
+ }
+
+
+@pytest.fixture
+def sample_tool() -> Tool:
+ """Create a sample tool for testing."""
+ async def dummy_execute(arguments: dict, runtime) -> str:
+ return f"Executed with: {arguments}"
+
+ return Tool(
+ name="test_tool",
+ description="A test tool",
+ schema=ToolSchema(
+ properties={"param": {"type": "string", "description": "A parameter"}},
+ required=["param"]
+ ),
+ execute_fn=dummy_execute,
+ category="test"
+ )
diff --git a/tests/test_agents.py b/tests/test_agents.py
new file mode 100644
index 0000000..77dbda7
--- /dev/null
+++ b/tests/test_agents.py
@@ -0,0 +1,110 @@
+"""Tests for the agent state management."""
+
+import pytest
+from datetime import datetime
+
+from ghostcrew.agents.state import AgentState, AgentStateManager, StateTransition
+
+
+class TestAgentState:
+ """Tests for AgentState enum."""
+
+ def test_state_values(self):
+ """Test state enum values."""
+ assert AgentState.IDLE.value == "idle"
+ assert AgentState.THINKING.value == "thinking"
+ assert AgentState.EXECUTING.value == "executing"
+ assert AgentState.WAITING_INPUT.value == "waiting_input"
+ assert AgentState.COMPLETE.value == "complete"
+ assert AgentState.ERROR.value == "error"
+
+ def test_all_states_exist(self):
+ """Test that all expected states exist."""
+ states = list(AgentState)
+ assert len(states) >= 6
+
+
+class TestAgentStateManager:
+ """Tests for AgentStateManager class."""
+
+ @pytest.fixture
+ def state_manager(self):
+ """Create a fresh AgentStateManager for each test."""
+ return AgentStateManager()
+
+ def test_initial_state(self, state_manager):
+ """Test initial state is IDLE."""
+ assert state_manager.current_state == AgentState.IDLE
+ assert len(state_manager.history) == 0
+
+ def test_valid_transition(self, state_manager):
+ """Test valid state transition."""
+ result = state_manager.transition_to(AgentState.THINKING)
+ assert result is True
+ assert state_manager.current_state == AgentState.THINKING
+ assert len(state_manager.history) == 1
+
+ def test_invalid_transition(self, state_manager):
+ """Test invalid state transition."""
+ result = state_manager.transition_to(AgentState.COMPLETE)
+ assert result is False
+ assert state_manager.current_state == AgentState.IDLE
+
+ def test_transition_chain(self, state_manager):
+ """Test a chain of valid transitions."""
+ assert state_manager.transition_to(AgentState.THINKING)
+ assert state_manager.transition_to(AgentState.EXECUTING)
+ assert state_manager.transition_to(AgentState.THINKING)
+ assert state_manager.transition_to(AgentState.COMPLETE)
+
+ assert state_manager.current_state == AgentState.COMPLETE
+ assert len(state_manager.history) == 4
+
+ def test_force_transition(self, state_manager):
+ """Test forcing a transition."""
+ state_manager.force_transition(AgentState.ERROR, reason="Test error")
+ assert state_manager.current_state == AgentState.ERROR
+ assert "FORCED" in state_manager.history[-1].reason
+
+ def test_reset(self, state_manager):
+ """Test resetting state."""
+ state_manager.transition_to(AgentState.THINKING)
+ state_manager.transition_to(AgentState.EXECUTING)
+
+ state_manager.reset()
+
+ assert state_manager.current_state == AgentState.IDLE
+ assert len(state_manager.history) == 0
+
+ def test_is_terminal(self, state_manager):
+ """Test terminal state detection."""
+ assert state_manager.is_terminal() is False
+
+ state_manager.transition_to(AgentState.THINKING)
+ state_manager.transition_to(AgentState.COMPLETE)
+
+ assert state_manager.is_terminal() is True
+
+ def test_is_active(self, state_manager):
+ """Test active state detection."""
+ assert state_manager.is_active() is False
+
+ state_manager.transition_to(AgentState.THINKING)
+ assert state_manager.is_active() is True
+
+
+class TestStateTransition:
+ """Tests for StateTransition dataclass."""
+
+ def test_create_transition(self):
+ """Test creating a state transition."""
+ transition = StateTransition(
+ from_state=AgentState.IDLE,
+ to_state=AgentState.THINKING,
+ reason="Starting work"
+ )
+
+ assert transition.from_state == AgentState.IDLE
+ assert transition.to_state == AgentState.THINKING
+ assert transition.reason == "Starting work"
+ assert transition.timestamp is not None
diff --git a/tests/test_knowledge.py b/tests/test_knowledge.py
new file mode 100644
index 0000000..c21dbaf
--- /dev/null
+++ b/tests/test_knowledge.py
@@ -0,0 +1,100 @@
+"""Tests for the RAG knowledge system."""
+
+import pytest
+import numpy as np
+from pathlib import Path
+from unittest.mock import patch
+
+from ghostcrew.knowledge.rag import RAGEngine, Document
+
+
+class TestDocument:
+ """Tests for Document dataclass."""
+
+ def test_create_document(self):
+ """Test creating a document."""
+ doc = Document(content="Test content", source="test.md")
+ assert doc.content == "Test content"
+ assert doc.source == "test.md"
+ assert doc.metadata == {}
+ assert doc.doc_id is not None
+
+ def test_document_with_metadata(self):
+ """Test document with metadata."""
+ doc = Document(
+ content="Test",
+ source="test.md",
+ metadata={"cve_id": "CVE-2021-1234", "severity": "high"}
+ )
+ assert doc.metadata["cve_id"] == "CVE-2021-1234"
+ assert doc.metadata["severity"] == "high"
+
+ def test_document_with_embedding(self):
+ """Test document with embedding."""
+ embedding = np.random.rand(384)
+ doc = Document(content="Test", source="test.md", embedding=embedding)
+ assert doc.embedding is not None
+ assert len(doc.embedding) == 384
+
+ def test_document_with_custom_id(self):
+ """Test document with custom doc_id."""
+ doc = Document(content="Test", source="test.md", doc_id="custom-id-123")
+ assert doc.doc_id == "custom-id-123"
+
+
+class TestRAGEngine:
+ """Tests for RAGEngine class."""
+
+ @pytest.fixture
+ def rag_engine(self, tmp_path):
+ """Create a RAG engine for testing."""
+ return RAGEngine(
+ knowledge_path=tmp_path / "knowledge",
+ use_local_embeddings=True
+ )
+
+ def test_create_engine(self, rag_engine):
+ """Test creating a RAG engine."""
+ assert rag_engine is not None
+ assert len(rag_engine.documents) == 0
+ assert rag_engine.embeddings is None
+
+ def test_get_document_count_empty(self, rag_engine):
+ """Test document count on empty engine."""
+ assert rag_engine.get_document_count() == 0
+
+ def test_clear(self, rag_engine):
+ """Test clearing the engine."""
+ rag_engine.documents.append(Document(content="test", source="test.md"))
+ rag_engine.embeddings = np.random.rand(1, 384)
+ rag_engine._indexed = True
+
+ rag_engine.clear()
+
+ assert len(rag_engine.documents) == 0
+ assert rag_engine.embeddings is None
+ assert rag_engine._indexed == False
+
+
+class TestRAGEngineChunking:
+ """Tests for text chunking functionality."""
+
+ @pytest.fixture
+ def engine(self, tmp_path):
+ """Create engine for chunking tests."""
+ return RAGEngine(knowledge_path=tmp_path)
+
+ def test_chunk_short_text(self, engine):
+ """Test chunking text shorter than chunk size."""
+ text = "This is a short paragraph.\n\nThis is another paragraph."
+ chunks = engine._chunk_text(text, source="test.md", chunk_size=1000)
+
+ assert len(chunks) >= 1
+ assert all(isinstance(c, Document) for c in chunks)
+
+ def test_chunk_preserves_source(self, engine):
+ """Test that chunking preserves source information."""
+ text = "Test paragraph 1.\n\nTest paragraph 2."
+ chunks = engine._chunk_text(text, source="my_source.md")
+
+ assert all(c.source == "my_source.md" for c in chunks)
diff --git a/tests/test_tools.py b/tests/test_tools.py
new file mode 100644
index 0000000..9e68b36
--- /dev/null
+++ b/tests/test_tools.py
@@ -0,0 +1,141 @@
+"""Tests for the tool system."""
+
+import pytest
+
+from ghostcrew.tools import (
+ Tool, ToolSchema, register_tool, get_all_tools, get_tool,
+ enable_tool, disable_tool, get_tool_names
+)
+
+
+class TestToolRegistry:
+ """Tests for tool registry functions."""
+
+ def test_tools_loaded(self):
+ """Test that built-in tools are loaded."""
+ tools = get_all_tools()
+ assert len(tools) > 0
+
+ tool_names = get_tool_names()
+ assert "terminal" in tool_names
+ assert "browser" in tool_names
+
+ def test_get_tool(self):
+ """Test getting a tool by name."""
+ tool = get_tool("terminal")
+ assert tool is not None
+ assert tool.name == "terminal"
+ assert tool.category == "execution"
+
+ def test_get_nonexistent_tool(self):
+ """Test getting a tool that doesn't exist."""
+ tool = get_tool("nonexistent_tool_xyz")
+ assert tool is None
+
+ def test_disable_enable_tool(self):
+ """Test disabling and enabling a tool."""
+ result = disable_tool("terminal")
+ assert result is True
+
+ tool = get_tool("terminal")
+ assert tool.enabled is False
+
+ result = enable_tool("terminal")
+ assert result is True
+
+ tool = get_tool("terminal")
+ assert tool.enabled is True
+
+ def test_disable_nonexistent_tool(self):
+ """Test disabling a tool that doesn't exist."""
+ result = disable_tool("nonexistent_tool_xyz")
+ assert result is False
+
+
+class TestToolSchema:
+ """Tests for ToolSchema class."""
+
+ def test_create_schema(self):
+ """Test creating a tool schema."""
+ schema = ToolSchema(
+ properties={
+ "command": {"type": "string", "description": "Command to run"}
+ },
+ required=["command"]
+ )
+
+ assert schema.type == "object"
+ assert "command" in schema.properties
+ assert "command" in schema.required
+
+ def test_schema_to_dict(self):
+ """Test converting schema to dictionary."""
+ schema = ToolSchema(
+ properties={"input": {"type": "string"}},
+ required=["input"]
+ )
+
+ d = schema.to_dict()
+ assert d["type"] == "object"
+ assert d["properties"]["input"]["type"] == "string"
+ assert d["required"] == ["input"]
+
+
+class TestTool:
+ """Tests for Tool class."""
+
+ def test_create_tool(self, sample_tool):
+ """Test creating a tool."""
+ assert sample_tool.name == "test_tool"
+ assert sample_tool.description == "A test tool"
+ assert sample_tool.category == "test"
+ assert sample_tool.enabled is True
+
+ def test_tool_to_llm_format(self, sample_tool):
+ """Test converting tool to LLM format."""
+ llm_format = sample_tool.to_llm_format()
+
+ assert llm_format["type"] == "function"
+ assert llm_format["function"]["name"] == "test_tool"
+ assert llm_format["function"]["description"] == "A test tool"
+ assert "parameters" in llm_format["function"]
+
+ def test_tool_validate_arguments(self, sample_tool):
+ """Test argument validation."""
+ is_valid, error = sample_tool.validate_arguments({"param": "value"})
+ assert is_valid is True
+ assert error is None
+
+ is_valid, error = sample_tool.validate_arguments({})
+ assert is_valid is False
+ assert "param" in error
+
+ @pytest.mark.asyncio
+ async def test_tool_execute(self, sample_tool):
+ """Test tool execution."""
+ result = await sample_tool.execute({"param": "test"}, runtime=None)
+ assert "test" in result
+
+
+class TestRegisterToolDecorator:
+ """Tests for register_tool decorator."""
+
+ def test_decorator_registers_tool(self):
+ """Test that decorator registers a new tool."""
+ initial_count = len(get_all_tools())
+
+ @register_tool(
+ name="pytest_test_tool_unique",
+ description="A tool registered in tests",
+ schema=ToolSchema(properties={}, required=[]),
+ category="test"
+ )
+ async def pytest_test_tool_unique(arguments, runtime):
+ return "test result"
+
+ new_count = len(get_all_tools())
+ assert new_count == initial_count + 1
+
+ tool = get_tool("pytest_test_tool_unique")
+ assert tool is not None
+ assert tool.name == "pytest_test_tool_unique"
diff --git a/tools/__init__.py b/tools/__init__.py
deleted file mode 100644
index 85f7fd0..0000000
--- a/tools/__init__.py
+++ /dev/null
@@ -1 +0,0 @@
-"""MCP (Model Context Protocol) integration for GHOSTCREW."""
\ No newline at end of file
diff --git a/tools/configure_mcp.py b/tools/configure_mcp.py
deleted file mode 100644
index 6c6f9c4..0000000
--- a/tools/configure_mcp.py
+++ /dev/null
@@ -1,445 +0,0 @@
-import json
-import os
-import shutil
-import subprocess
-import platform
-from pathlib import Path
-from colorama import init, Fore, Style
-
-init(autoreset=True)
-
-def find_tool_path(tool_name):
- """Auto-discover tool path using system commands"""
- try:
- if platform.system() == "Windows":
- # Use 'where' command on Windows
- result = subprocess.run(['where', tool_name],
- capture_output=True, text=True, check=False)
- if result.returncode == 0:
- # Get first valid path from results
- paths = result.stdout.strip().split('\n')
- for path in paths:
- path = path.strip()
- if path and os.path.exists(path):
- return path
- else:
- # Use 'which' command on Linux/Mac
- path = shutil.which(tool_name)
- if path and os.path.exists(path):
- return path
- except Exception:
- pass
- return None
-
-def get_tool_search_variants(exe_name):
- """Get different variants of tool names to search for"""
- if not exe_name:
- return []
-
- # For Windows, just search for the base name - 'where' will find the actual executable
- base_name = exe_name.replace('.exe', '').replace('.py', '')
- variants = [base_name]
-
- # Also try the exact name if it's different
- if exe_name != base_name:
- variants.append(exe_name)
-
- return variants
-
-def auto_discover_tool_path(server):
- """Auto-discover tool path with user confirmation"""
- if not server.get('exe_name'):
- return None
-
- print(f"{Fore.CYAN}Searching for {server['name']}...{Style.RESET_ALL}")
-
- # Get search variants
- search_variants = get_tool_search_variants(server['exe_name'])
-
- # Try to find the tool
- found_path = None
- for variant in search_variants:
- found_path = find_tool_path(variant)
- if found_path:
- break
-
- if found_path:
- print(f"{Fore.GREEN}Found: {found_path}{Style.RESET_ALL}")
- choice = input(f" Use this path? (yes/no): ").strip().lower()
- if choice == 'y' or choice == 'yes':
- return found_path
- # If user says no, fall through to manual input
- else:
- print(f"{Fore.YELLOW}{server['name']} not found in PATH{Style.RESET_ALL}")
-
- # Fallback to manual input
- manual_path = input(f" Enter path to {server['exe_name']} manually (or press Enter to skip): ").strip()
- return manual_path if manual_path else None
-
-MCP_SERVERS = [
- {
- "name": "AlterX",
- "key": "AlterX",
- "command": "npx",
- "args": ["-y", "gc-alterx-mcp"],
- "description": "MCP server for subdomain permutation and wordlist generation using the AlterX tool.",
- "exe_name": "alterx.exe",
- "env_var": "ALTERX_PATH",
- "homepage": "https://www.npmjs.com/package/gc-alterx-mcp"
- },
- {
- "name": "Amass",
- "key": "Amass",
- "command": "npx",
- "args": ["-y", "gc-amass-mcp"],
- "description": "MCP server for advanced subdomain enumeration and reconnaissance using the Amass tool.",
- "exe_name": "amass.exe",
- "env_var": "AMASS_PATH",
- "homepage": "https://www.npmjs.com/package/gc-amass-mcp"
- },
- {
- "name": "Arjun",
- "key": "Arjun",
- "command": "npx",
- "args": ["-y", "gc-arjun-mcp"],
- "description": "MCP server for discovering hidden HTTP parameters using the Arjun tool.",
- "exe_name": "arjun",
- "env_var": "ARJUN_PATH",
- "homepage": "https://www.npmjs.com/package/gc-arjun-mcp"
- },
- {
- "name": "Assetfinder",
- "key": "Assetfinder",
- "command": "npx",
- "args": ["-y", "gc-assetfinder-mcp"],
- "description": "MCP server for passive subdomain discovery using the Assetfinder tool.",
- "exe_name": "assetfinder.exe",
- "env_var": "ASSETFINDER_PATH",
- "homepage": "https://www.npmjs.com/package/gc-assetfinder-mcp"
- },
- {
- "name": "Certificate Transparency",
- "key": "CrtSh",
- "command": "npx",
- "args": ["-y", "gc-crtsh-mcp"],
- "description": "MCP server for subdomain discovery using SSL certificate transparency logs (crt.sh).",
- "exe_name": None, # No executable needed for this service
- "env_var": None,
- "homepage": "https://www.npmjs.com/package/gc-crtsh-mcp"
- },
- {
- "name": "FFUF Fuzzer",
- "key": "FFUF",
- "command": "npx",
- "args": ["-y", "gc-ffuf-mcp"],
- "description": "MCP server for web fuzzing operations using FFUF (Fuzz Faster U Fool) tool.",
- "exe_name": "ffuf.exe",
- "env_var": "FFUF_PATH",
- "homepage": "https://www.npmjs.com/package/gc-ffuf-mcp"
- },
- {
- "name": "httpx",
- "key": "HTTPx",
- "command": "npx",
- "args": ["-y", "gc-httpx-mcp"],
- "description": "MCP server for fast HTTP toolkit and port scanning using the httpx tool.",
- "exe_name": "httpx.exe",
- "env_var": "HTTPX_PATH",
- "homepage": "https://www.npmjs.com/package/gc-httpx-mcp"
- },
- {
- "name": "Hydra",
- "key": "Hydra",
- "command": "npx",
- "args": ["-y", "gc-hydra-mcp"],
- "description": "MCP server for password brute-force attacks and credential testing using the Hydra tool.",
- "exe_name": "hydra.exe",
- "env_var": "HYDRA_PATH",
- "homepage": "https://www.npmjs.com/package/gc-hydra-mcp"
- },
- {
- "name": "Katana",
- "key": "Katana",
- "command": "npx",
- "args": ["-y", "gc-katana-mcp"],
- "description": "MCP server for fast web crawling with JavaScript parsing using the Katana tool.",
- "exe_name": "katana.exe",
- "env_var": "KATANA_PATH",
- "homepage": "https://www.npmjs.com/package/gc-katana-mcp"
- },
- {
- "name": "Masscan",
- "key": "Masscan",
- "command": "npx",
- "args": ["-y", "gc-masscan-mcp"],
- "description": "MCP server for high-speed network port scanning with the Masscan tool.",
- "exe_name": "masscan.exe",
- "env_var": "MASSCAN_PATH",
- "homepage": "https://www.npmjs.com/package/gc-masscan-mcp"
- },
- {
- "name": "Metasploit",
- "key": "MetasploitMCP",
- "command": "uvx",
- "args": ["gc-metasploit", "--transport", "stdio"],
- "description": "MCP server for Metasploit Framework with exploit execution, payload generation, and session management.",
- "exe_name": None, # No local executable needed - uses uvx package
- "env_var": "MSF_PASSWORD",
- "env_extra": {
- "MSF_SERVER": "127.0.0.1",
- "MSF_PORT": "55553",
- "MSF_SSL": "false",
- "PAYLOAD_SAVE_DIR": "knowledge"
- },
- "homepage": "https://github.com/GH05TCREW/MetasploitMCP"
- },
- {
- "name": "Nmap Scanner",
- "key": "Nmap",
- "command": "npx",
- "args": ["-y", "gc-nmap-mcp"],
- "description": "MCP server for interacting with Nmap network scanner to discover hosts and services on a network.",
- "exe_name": "nmap.exe",
- "env_var": "NMAP_PATH",
- "homepage": "https://www.npmjs.com/package/gc-nmap-mcp"
- },
- {
- "name": "Nuclei Scanner",
- "key": "Nuclei",
- "command": "npx",
- "args": ["-y", "gc-nuclei-mcp"],
- "description": "MCP server for vulnerability scanning using Nuclei's template-based detection engine.",
- "exe_name": "nuclei.exe",
- "env_var": "NUCLEI_PATH",
- "homepage": "https://www.npmjs.com/package/gc-nuclei-mcp"
- },
- {
- "name": "Scout Suite",
- "key": "ScoutSuite",
- "command": "npx",
- "args": ["-y", "gc-scoutsuite-mcp"],
- "description": "MCP server for cloud security auditing using the Scout Suite tool.",
- "exe_name": "scout.py",
- "env_var": "SCOUTSUITE_PATH",
- "homepage": "https://www.npmjs.com/package/gc-scoutsuite-mcp"
- },
- {
- "name": "shuffledns",
- "key": "ShuffleDNS",
- "command": "npx",
- "args": ["-y", "gc-shuffledns-mcp"],
- "description": "MCP server for high-speed DNS brute-forcing and resolution using the shuffledns tool.",
- "exe_name": "shuffledns",
- "env_var": "SHUFFLEDNS_PATH",
- "env_extra": {
- "MASSDNS_PATH": ""
- },
- "homepage": "https://www.npmjs.com/package/gc-shuffledns-mcp"
- },
- {
- "name": "SQLMap",
- "key": "SQLMap",
- "command": "npx",
- "args": ["-y", "gc-sqlmap-mcp"],
- "description": "MCP server for conducting automated SQL injection detection and exploitation using SQLMap.",
- "exe_name": "sqlmap.py",
- "env_var": "SQLMAP_PATH",
- "homepage": "https://www.npmjs.com/package/gc-sqlmap-mcp"
- },
- {
- "name": "SSL Scanner",
- "key": "SSLScan",
- "command": "npx",
- "args": ["-y", "gc-sslscan-mcp"],
- "description": "MCP server for analyzing SSL/TLS configurations and identifying security issues.",
- "exe_name": "sslscan.exe",
- "env_var": "SSLSCAN_PATH",
- "homepage": "https://www.npmjs.com/package/gc-sslscan-mcp"
- },
- {
- "name": "Wayback URLs",
- "key": "WaybackURLs",
- "command": "npx",
- "args": ["-y", "gc-waybackurls-mcp"],
- "description": "MCP server for discovering historical URLs from the Wayback Machine archive.",
- "exe_name": "waybackurls.exe",
- "env_var": "WAYBACKURLS_PATH",
- "homepage": "https://www.npmjs.com/package/gc-waybackurls-mcp"
- }
-]
-
-
-
-def check_npm_installed():
- """Check if npm is installed"""
- try:
- result = shutil.which("npm")
- return result is not None
- except:
- return False
-
-def main():
- print(f"{Fore.GREEN}===================== GHOSTCREW MCP SERVER CONFIGURATION ====================={Style.RESET_ALL}")
- print(f"{Fore.YELLOW}This tool will help you configure the MCP servers for your GHOSTCREW installation.{Style.RESET_ALL}")
- print(f"{Fore.CYAN}Auto-discovery will attempt to find tools automatically in your system PATH.{Style.RESET_ALL}")
- print(f"{Fore.CYAN}You can confirm, decline, or provide custom paths as needed.{Style.RESET_ALL}")
- print()
-
- # Check if npm is installed
- if not check_npm_installed():
- print(f"{Fore.RED}Warning: npm doesn't appear to be installed. MCP servers use Node.js and npm.{Style.RESET_ALL}")
- print(f"{Fore.YELLOW}You may need to install Node.js from: https://nodejs.org/{Style.RESET_ALL}")
- cont = input(f"{Fore.YELLOW}Continue anyway? (yes/no): {Style.RESET_ALL}").strip().lower()
- if cont != "yes":
- print(f"{Fore.RED}Configuration cancelled.{Style.RESET_ALL}")
- return
-
- # Check if mcp.json exists and load it
- mcp_config = {"servers": []}
- if os.path.exists("mcp.json"):
- try:
- with open("mcp.json", 'r') as f:
- mcp_config = json.load(f)
- print(f"{Fore.GREEN}Loaded existing mcp.json with {len(mcp_config.get('servers', []))} server configurations.{Style.RESET_ALL}")
- except:
- print(f"{Fore.RED}Error loading existing mcp.json. Starting with empty configuration.{Style.RESET_ALL}")
-
- configured_servers = []
-
- print(f"{Fore.CYAN}Available tools:{Style.RESET_ALL}")
- for i, server in enumerate(MCP_SERVERS):
- print(f"{i+1}. {server['name']} - {server['description']}")
-
- print()
- print(f"{Fore.YELLOW}Select tools to configure (comma-separated numbers, 'all' for all tools, or 'none' to skip):{Style.RESET_ALL}")
- selection = input().strip().lower()
-
- selected_indices = []
- if selection == "all":
- selected_indices = list(range(len(MCP_SERVERS)))
- elif selection != "none":
- try:
- for part in selection.split(","):
- idx = int(part.strip()) - 1
- if 0 <= idx < len(MCP_SERVERS):
- selected_indices.append(idx)
- except:
- print(f"{Fore.RED}Invalid selection. Please enter comma-separated numbers.{Style.RESET_ALL}")
- return
-
- for idx in selected_indices:
- server = MCP_SERVERS[idx]
- print(f"\n{Fore.CYAN}Configuring {server['name']}:{Style.RESET_ALL}")
-
- # Unified tool configuration - handles all tools generically
- env_vars = {}
-
- # Handle main executable and environment variable
- if server.get('exe_name'):
- # Try to auto-discover the executable
- exe_path = auto_discover_tool_path(server)
-
- if exe_path:
- # Verify the path exists
- if not os.path.exists(exe_path):
- print(f"{Fore.YELLOW}Warning: The specified path does not exist: {exe_path}{Style.RESET_ALL}")
- cont = input(f" Continue anyway? (yes/no, default: no): ").strip().lower()
- if cont != "yes":
- print(f" {Fore.YELLOW}Skipping {server['name']}.{Style.RESET_ALL}")
- continue
-
- # Set the main environment variable
- if server.get('env_var'):
- env_vars[server['env_var']] = exe_path
- else:
- # Executable not found and user didn't provide manual path
- print(f"{Fore.YELLOW}Skipping {server['name']} - executable not found.{Style.RESET_ALL}")
- continue
- elif server.get('env_var'):
- # Tool has no executable but needs a main environment variable (like Metasploit)
- value = input(f"Enter value for {server['env_var']} (default: ): ").strip()
-
- if value:
- env_vars[server['env_var']] = value
- else:
- print(f"{Fore.YELLOW}Skipping {server['name']} - {server['env_var']} required.{Style.RESET_ALL}")
- continue
- else:
- # Tool requires no executable (like Certificate Transparency)
- print(f"{Fore.GREEN}{server['name']} requires no local executable.{Style.RESET_ALL}")
-
- # Handle additional environment variables
- if 'env_extra' in server:
- for extra_var, default_value in server['env_extra'].items():
- if extra_var == "MASSDNS_PATH":
- # Special auto-discovery for massdns
- print(f"\n{Fore.CYAN}Also configuring massdns for {server['name']}...{Style.RESET_ALL}")
- print(f"{Fore.CYAN}Searching for massdns...{Style.RESET_ALL}")
-
- massdns_path = find_tool_path("massdns")
- if massdns_path:
- print(f"{Fore.GREEN}Found: {massdns_path}{Style.RESET_ALL}")
- choice = input(f" Use this path? (yes/no): ").strip().lower()
- if choice != 'y' and choice != 'yes':
- massdns_path = input(f" Enter path to massdns manually: ").strip()
- else:
- print(f"{Fore.YELLOW}massdns not found in PATH{Style.RESET_ALL}")
- massdns_path = input(f" Enter path to massdns manually (or press Enter to skip): ").strip()
-
- if massdns_path:
- env_vars[extra_var] = massdns_path
- else:
- print(f"{Fore.YELLOW}Skipping {server['name']} - massdns path required.{Style.RESET_ALL}")
- continue
- else:
- # Handle all environment variables generically
- value = input(f"Enter value for {extra_var} (default: {default_value}): ").strip()
- env_vars[extra_var] = value if value else default_value
-
- # Add to configured servers
- configured_servers.append({
- "name": server['name'],
- "params": {
- "command": server['command'],
- "args": server['args'],
- "env": env_vars
- },
- "cache_tools_list": True
- })
- print(f"{Fore.GREEN}{server['name']} configured successfully!{Style.RESET_ALL}")
-
- # Update mcp.json
- if "servers" not in mcp_config:
- mcp_config["servers"] = []
-
- if configured_servers:
- # Ask if user wants to replace or append
- if mcp_config["servers"]:
- replace = input(f"{Fore.YELLOW}Replace existing configurations or append new ones? (replace/append, default: append): {Style.RESET_ALL}").strip().lower()
- if replace == "replace":
- mcp_config["servers"] = configured_servers
- else:
- # Remove any duplicates by name
- existing_names = [s["name"] for s in mcp_config["servers"]]
- for server in configured_servers:
- if server["name"] in existing_names:
- # Replace existing configuration
- idx = existing_names.index(server["name"])
- mcp_config["servers"][idx] = server
- else:
- # Add new configuration
- mcp_config["servers"].append(server)
- else:
- mcp_config["servers"] = configured_servers
-
- # Save to mcp.json
- with open("mcp.json", 'w') as f:
- json.dump(mcp_config, f, indent=2)
-
- print(f"\n{Fore.GREEN}Configuration saved to mcp.json with {len(mcp_config['servers'])} server configurations.{Style.RESET_ALL}")
- print(f"{Fore.YELLOW}You can now run the main application with: python main.py{Style.RESET_ALL}")
- else:
- print(f"\n{Fore.YELLOW}No tools were configured. Keeping existing configuration.{Style.RESET_ALL}")
-
-if __name__ == "__main__":
- main()
\ No newline at end of file
diff --git a/tools/mcp_manager.py b/tools/mcp_manager.py
deleted file mode 100644
index 12c8427..0000000
--- a/tools/mcp_manager.py
+++ /dev/null
@@ -1,222 +0,0 @@
-"""MCP (Model Context Protocol) server management for GHOSTCREW."""
-
-import json
-import os
-from typing import List, Optional, Tuple
-from colorama import Fore, Style
-from config.constants import MCP_SESSION_TIMEOUT, MCP_CONFIG_FILE
-
-
-class MCPManager:
- """Manages MCP server connections and configuration."""
-
- def __init__(self, MCPServerStdio=None, MCPServerSse=None):
- """
- Initialize the MCP manager.
-
- Args:
- MCPServerStdio: MCP server stdio class
- MCPServerSse: MCP server SSE class
- """
- self.MCPServerStdio = MCPServerStdio
- self.MCPServerSse = MCPServerSse
- self.server_instances = []
- self.connected_servers = []
-
- @staticmethod
- def get_available_tools(connected_servers: List) -> List[str]:
- """Get list of available/connected tool names."""
- return [server.name for server in connected_servers]
-
- def load_mcp_config(self) -> List[dict]:
- """Load MCP tool configurations from mcp.json."""
- available_tools = []
- try:
- with open(MCP_CONFIG_FILE, 'r', encoding='utf-8') as f:
- mcp_config = json.load(f)
- available_tools = mcp_config.get('servers', [])
- except FileNotFoundError:
- print(f"{Fore.YELLOW}mcp.json configuration file not found.{Style.RESET_ALL}")
- except Exception as e:
- print(f"{Fore.RED}Error loading MCP configuration file: {e}{Style.RESET_ALL}")
- print(f"{Fore.YELLOW}Proceeding without MCP tools.{Style.RESET_ALL}")
- return available_tools
-
- def display_tool_menu(self, available_tools: List[dict]) -> Optional[List[int]]:
- """Display MCP tool selection menu and get user choice."""
- if not available_tools:
- print(f"{Fore.YELLOW}No MCP tools currently configured.{Style.RESET_ALL}")
- configure_now = input(f"{Fore.YELLOW}Would you like to add tools? (yes/no, default: no): {Style.RESET_ALL}").strip().lower()
- if configure_now == 'yes':
- print(f"\n{Fore.CYAN}Launching tool configuration...{Style.RESET_ALL}")
- os.system("python tools/configure_mcp.py")
- print(f"\n{Fore.GREEN}Tool configuration completed.{Style.RESET_ALL}")
- # Reload configuration and continue
- return "reload_and_continue"
- else:
- print(f"{Fore.YELLOW}Proceeding without MCP tools.{Style.RESET_ALL}")
- return []
-
- print(f"\n{Fore.CYAN}Available MCP tools:{Style.RESET_ALL}")
- for i, server in enumerate(available_tools):
- print(f"{i+1}. {server['name']}")
- print(f"{len(available_tools)+1}. Configure new tools")
- print(f"{len(available_tools)+2}. Connect to all tools")
- print(f"{len(available_tools)+3}. Skip tool connection")
- print(f"{len(available_tools)+4}. Clear all MCP tools")
-
- try:
- tool_choice = input(f"\n{Fore.YELLOW}Select option: {Style.RESET_ALL}").strip()
-
- if not tool_choice: # Default to all tools
- return list(range(len(available_tools)))
- elif tool_choice == str(len(available_tools)+1): # Configure new tools
- print(f"\n{Fore.CYAN}Launching tool configuration...{Style.RESET_ALL}")
- os.system("python tools/configure_mcp.py")
- print(f"\n{Fore.GREEN}Tool configuration completed.{Style.RESET_ALL}")
- # Reload configuration and continue
- return "reload_and_continue"
- elif tool_choice == str(len(available_tools)+2): # Connect to all tools
- return list(range(len(available_tools)))
- elif tool_choice == str(len(available_tools)+3): # Skip tool connection
- return []
- elif tool_choice == str(len(available_tools)+4): # Clear all MCP tools
- if self.clear_mcp_tools():
- return "reload_and_continue"
- return []
- else: # Parse comma-separated list
- selected_indices = []
- for part in tool_choice.split(","):
- idx = int(part.strip()) - 1
- if 0 <= idx < len(available_tools):
- selected_indices.append(idx)
- return selected_indices
- except ValueError:
- print(f"{Fore.RED}Invalid selection. Defaulting to all tools.{Style.RESET_ALL}")
- return list(range(len(available_tools)))
-
- def clear_mcp_tools(self) -> bool:
- """Clear all MCP tools from configuration."""
- confirm = input(f"{Fore.YELLOW}Are you sure you want to clear all MCP tools? This will empty mcp.json (yes/no): {Style.RESET_ALL}").strip().lower()
- if confirm == "yes":
- try:
- # Create empty mcp.json file
- with open(MCP_CONFIG_FILE, 'w', encoding='utf-8') as f:
- json.dump({"servers": []}, f, indent=2)
- print(f"{Fore.GREEN}Successfully cleared all MCP tools. mcp.json has been reset.{Style.RESET_ALL}")
- return True
- except Exception as e:
- print(f"{Fore.RED}Error clearing MCP tools: {e}{Style.RESET_ALL}")
- return False
-
- def initialize_servers(self, available_tools: List[dict], selected_indices: List[int]) -> None:
- """Initialize selected MCP servers."""
- # Use the MCP classes passed during initialization
- if not self.MCPServerStdio or not self.MCPServerSse:
- raise ValueError("MCP server classes not provided during initialization")
-
- print(f"{Fore.GREEN}Initializing selected MCP servers...{Style.RESET_ALL}")
- for idx in selected_indices:
- if idx < len(available_tools):
- server = available_tools[idx]
- print(f"{Fore.CYAN}Initializing {server['name']}...{Style.RESET_ALL}")
- try:
- if 'params' in server:
- mcp_server = self.MCPServerStdio(
- name=server['name'],
- params=server['params'],
- cache_tools_list=server.get('cache_tools_list', True),
- client_session_timeout_seconds=MCP_SESSION_TIMEOUT
- )
- elif 'url' in server:
- mcp_server = self.MCPServerSse(
- params={"url": server["url"]},
- cache_tools_list=server.get('cache_tools_list', True),
- name=server['name'],
- client_session_timeout_seconds=MCP_SESSION_TIMEOUT
- )
- else:
- print(f"{Fore.RED}Unknown MCP server configuration: {server}{Style.RESET_ALL}")
- continue
- self.server_instances.append(mcp_server)
- except Exception as e:
- print(f"{Fore.RED}Error initializing {server['name']}: {e}{Style.RESET_ALL}")
-
- async def connect_servers(self) -> List:
- """Connect to initialized MCP servers."""
- if not self.server_instances:
- return []
-
- print(f"{Fore.YELLOW}Connecting to MCP servers...{Style.RESET_ALL}")
- for mcp_server in self.server_instances:
- try:
- await mcp_server.connect()
- print(f"{Fore.GREEN}Successfully connected to MCP server: {mcp_server.name}{Style.RESET_ALL}")
- self.connected_servers.append(mcp_server)
- except Exception as e:
- print(f"{Fore.RED}Failed to connect to MCP server {mcp_server.name}: {e}{Style.RESET_ALL}")
-
- if self.connected_servers:
- print(f"{Fore.GREEN}MCP server connection successful! Can use tools provided by {len(self.connected_servers)} servers.{Style.RESET_ALL}")
- else:
- print(f"{Fore.YELLOW}No MCP servers successfully connected. Proceeding without tools.{Style.RESET_ALL}")
-
- return self.connected_servers
-
- async def setup_mcp_tools(self, use_mcp: bool = False) -> Tuple[List, List]:
- """
- Main method to setup MCP tools.
-
- Args:
- use_mcp: Whether to use MCP tools
-
- Returns:
- Tuple of (server_instances, connected_servers)
- """
- if not use_mcp:
- print(f"{Fore.YELLOW}Proceeding without MCP tools.{Style.RESET_ALL}")
- return [], []
-
- while True: # Loop to handle configuration and reload
- # Load available tools
- available_tools = self.load_mcp_config()
-
- # Get user selection
- selected_indices = self.display_tool_menu(available_tools)
-
- # Handle special cases
- if selected_indices is None:
- # Restart needed (e.g., after clearing tools)
- return self.server_instances, []
- elif selected_indices == "reload_and_continue":
- # Tools were configured, reload and show menu again
- continue
- else:
- # Normal selection, proceed with initialization
- break
-
- # Initialize servers
- if selected_indices:
- self.initialize_servers(available_tools, selected_indices)
-
- # Connect to servers
- connected = await self.connect_servers()
-
- return self.server_instances, connected
-
- async def cleanup_servers(self) -> None:
- """Clean up MCP server resources."""
- if not self.server_instances:
- return
-
- print(f"{Fore.YELLOW}Cleaning up MCP server resources...{Style.RESET_ALL}")
-
- for mcp_server in self.server_instances:
- print(f"{Fore.YELLOW}Attempting to clean up server: {mcp_server.name}...{Style.RESET_ALL}", flush=True)
- try:
- await mcp_server.cleanup()
- print(f"{Fore.GREEN}Cleanup completed for {mcp_server.name}.{Style.RESET_ALL}", flush=True)
- except Exception:
- print(f"{Fore.RED}Failed to cleanup {mcp_server.name}.{Style.RESET_ALL}", flush=True)
-
- print(f"{Fore.YELLOW}MCP server resource cleanup complete.{Style.RESET_ALL}")
\ No newline at end of file
diff --git a/ui/__init__.py b/ui/__init__.py
deleted file mode 100644
index 209243b..0000000
--- a/ui/__init__.py
+++ /dev/null
@@ -1 +0,0 @@
-"""User interface components for GHOSTCREW."""
\ No newline at end of file
diff --git a/ui/conversation_manager.py b/ui/conversation_manager.py
deleted file mode 100644
index bf01b4d..0000000
--- a/ui/conversation_manager.py
+++ /dev/null
@@ -1,122 +0,0 @@
-"""Conversation history management for GHOSTCREW."""
-
-from typing import List, Dict, Optional
-import tiktoken
-from config.app_config import app_config
-
-
-class ConversationManager:
- """Manages conversation history and dialogue tracking."""
-
- def __init__(self, max_tokens: int = 4000):
- """
- Initialize the conversation manager.
-
- Args:
- max_tokens: Maximum tokens to keep in history
- """
- self.history: List[Dict[str, str]] = []
- self.max_tokens = max_tokens
- self.model_name = app_config.model_name
-
- def add_dialogue(self, user_query: str, ai_response: str = "") -> None:
- """
- Add a dialogue entry to the conversation history.
-
- Args:
- user_query: The user's query
- ai_response: The AI's response (can be empty initially)
- """
- dialogue = {
- "user_query": user_query,
- "ai_response": ai_response
- }
- self.history.append(dialogue)
-
- # Trim history if it exceeds token limit
- self._trim_history()
-
- def update_last_response(self, ai_response: str) -> None:
- """
- Update the AI response for the last dialogue entry.
-
- Args:
- ai_response: The AI's response to update
- """
- if self.history:
- self.history[-1]["ai_response"] = ai_response
-
- def get_history(self) -> List[Dict[str, str]]:
- """Get the complete conversation history."""
- return self.history
-
- def get_history_for_context(self) -> List[Dict[str, str]]:
- """Get conversation history suitable for context."""
- return self.history
-
- def estimate_tokens(self) -> int:
- """
- Estimate the number of tokens in the conversation history.
-
- Returns:
- Estimated token count
- """
- try:
- encoding = tiktoken.encoding_for_model(self.model_name)
- return sum(
- len(encoding.encode(entry['user_query'])) +
- len(encoding.encode(entry.get('ai_response', '')))
- for entry in self.history
- )
- except Exception:
- # Fall back to approximate counting if tiktoken fails
- return sum(
- len(entry['user_query'].split()) +
- len(entry.get('ai_response', '').split())
- for entry in self.history
- )
-
- def _trim_history(self) -> None:
- """Trim history to keep token count under the limit."""
- while self.estimate_tokens() > self.max_tokens and len(self.history) > 1:
- self.history.pop(0)
-
- def clear_history(self) -> None:
- """Clear all conversation history."""
- self.history = []
-
- def get_dialogue_count(self) -> int:
- """Get the number of dialogues in history."""
- return len(self.history)
-
- def get_workflow_conversation(self, start_index: int) -> List[Dict[str, str]]:
- """
- Get conversation history starting from a specific index.
-
- Args:
- start_index: The index to start from
-
- Returns:
- Subset of conversation history
- """
- return self.history[start_index:]
-
- def export_history(self) -> str:
- """
- Export conversation history as formatted text.
-
- Returns:
- Formatted conversation history
- """
- if not self.history:
- return "No conversation history available."
-
- output = []
- for i, entry in enumerate(self.history, 1):
- output.append(f"=== Dialogue {i} ===")
- output.append(f"User: {entry['user_query']}")
- if entry.get('ai_response'):
- output.append(f"AI: {entry['ai_response']}")
- output.append("")
-
- return "\n".join(output)
\ No newline at end of file
diff --git a/ui/menu_system.py b/ui/menu_system.py
deleted file mode 100644
index d126e64..0000000
--- a/ui/menu_system.py
+++ /dev/null
@@ -1,254 +0,0 @@
-"""Menu system and user interface components for GHOSTCREW."""
-
-from typing import Optional, List, Tuple, Dict, Any
-from colorama import Fore, Style
-from config.constants import (
- MAIN_MENU_TITLE, INTERACTIVE_OPTION, AUTOMATED_OPTION,
- EXIT_OPTION, MULTI_LINE_PROMPT, MULTI_LINE_END_MARKER
-)
-
-
-class MenuSystem:
- """Handles all menu displays and user input for GHOSTCREW."""
-
- @staticmethod
- def display_main_menu(workflows_available: bool, has_connected_servers: bool) -> None:
- """Display the main application menu."""
- print(f"\n{MAIN_MENU_TITLE}")
- print(f"1. {INTERACTIVE_OPTION}")
-
- # Check if automated mode should be available
- if workflows_available and has_connected_servers:
- print(f"2. {AUTOMATED_OPTION}")
- elif workflows_available and not has_connected_servers:
- print(f"2. {Fore.LIGHTBLACK_EX}Workflows (requires MCP tools){Style.RESET_ALL}")
- else:
- print(f"2. {Fore.LIGHTBLACK_EX}Workflows (workflows.py not found){Style.RESET_ALL}")
-
- # Agent mode option
- if has_connected_servers:
- print(f"3. {Fore.YELLOW}Agent{Style.RESET_ALL}")
- else:
- print(f"3. {Fore.LIGHTBLACK_EX}Agent (requires MCP tools){Style.RESET_ALL}")
-
- print(f"4. {EXIT_OPTION}")
-
- @staticmethod
- def get_menu_choice(max_option: int = 4) -> str:
- """Get user's menu selection."""
- return input(f"\n{Fore.GREEN}Select mode (1-{max_option}): {Style.RESET_ALL}").strip()
-
- @staticmethod
- def display_interactive_mode_intro() -> None:
- """Display introduction for interactive chat mode."""
- print(f"\n{Fore.CYAN}CHAT MODE{Style.RESET_ALL}")
- print(f"{Fore.WHITE}Type your questions or commands. Use 'multi' for multi-line input.{Style.RESET_ALL}")
- print(f"{Fore.WHITE}Type 'menu' to return to main menu.{Style.RESET_ALL}\n")
-
- @staticmethod
- def display_agent_mode_intro() -> None:
- """Display introduction for agent mode."""
- print(f"\n{Fore.CYAN}AGENT MODE{Style.RESET_ALL}")
- print(f"{Fore.WHITE}{'='*60}{Style.RESET_ALL}")
- print(f"{Fore.WHITE}The AI agent will autonomously conduct a penetration test{Style.RESET_ALL}")
- print(f"{Fore.WHITE}using a dynamic Pentesting Task Tree (PTT) for strategic{Style.RESET_ALL}")
- print(f"{Fore.WHITE}decision making and maintaining context throughout the test.{Style.RESET_ALL}")
- print(f"{Fore.WHITE}{'='*60}{Style.RESET_ALL}\n")
-
- @staticmethod
- def get_agent_mode_params() -> Optional[Dict[str, Any]]:
- """Get parameters for agent mode initialization."""
- print(f"{Fore.CYAN}Agent Mode Setup{Style.RESET_ALL}")
- print(f"{Fore.WHITE}Please provide the following information:{Style.RESET_ALL}\n")
-
- # Get goal
- print(f"{Fore.YELLOW}1. Primary Goal{Style.RESET_ALL}")
- print(f"{Fore.WHITE}What is the main objective of this penetration test?{Style.RESET_ALL}")
- print(f"{Fore.LIGHTBLACK_EX}Example: 'Gain administrative access to the target system'{Style.RESET_ALL}")
- print(f"{Fore.LIGHTBLACK_EX}Example: 'Identify and exploit vulnerabilities in the web application'{Style.RESET_ALL}")
- goal = input(f"{Fore.GREEN}Goal: {Style.RESET_ALL}").strip()
-
- if not goal:
- print(f"{Fore.RED}Goal is required.{Style.RESET_ALL}")
- return None
-
- # Get target
- print(f"\n{Fore.YELLOW}2. Target Information{Style.RESET_ALL}")
- print(f"{Fore.WHITE}Specify the target system, network, or application.{Style.RESET_ALL}")
- print(f"{Fore.LIGHTBLACK_EX}Example: '192.168.1.100' or 'example.com' or '192.168.1.0/24'{Style.RESET_ALL}")
- target = input(f"{Fore.GREEN}Target: {Style.RESET_ALL}").strip()
-
- if not target:
- print(f"{Fore.RED}Target is required.{Style.RESET_ALL}")
- return None
-
- # Get constraints
- constraints = {}
- print(f"\n{Fore.YELLOW}3. Constraints/Scope (Optional){Style.RESET_ALL}")
- print(f"{Fore.WHITE}Any limitations or specific requirements?{Style.RESET_ALL}")
-
- # Iteration limit
- print(f"\n{Fore.WHITE}Iteration Limit:{Style.RESET_ALL}")
- print("How many iterations should the agent run?")
- print("Each iteration involves task selection, execution, and tree updates.")
- print("Recommended: 10-30 iterations for thorough testing")
- print("Set to 0 to run until goal is achieved or no more actions available")
- iteration_limit_input = input(f"{Fore.GREEN}Iteration limit (default: 20): {Style.RESET_ALL}").strip()
-
- try:
- iteration_limit = int(iteration_limit_input) if iteration_limit_input else 20
- # Allow 0 for unlimited, but cap maximum at 200 for safety
- iteration_limit = max(0, min(200, iteration_limit))
- constraints['iteration_limit'] = iteration_limit
- except ValueError:
- constraints['iteration_limit'] = 20
- print(f"{Fore.YELLOW}Invalid input, using default: 20{Style.RESET_ALL}")
-
- # Additional notes
- notes = input(f"{Fore.GREEN}Additional notes/constraints (optional): {Style.RESET_ALL}").strip()
- if notes:
- constraints['notes'] = notes
-
- # Confirm
- print(f"\n{Fore.CYAN}Configuration Summary:{Style.RESET_ALL}")
- print(f"{Fore.WHITE}Goal: {goal}{Style.RESET_ALL}")
- print(f"{Fore.WHITE}Target: {target}{Style.RESET_ALL}")
- print(f"{Fore.WHITE}Iteration Limit: {constraints['iteration_limit']}{Style.RESET_ALL}")
- print(f"{Fore.WHITE}Constraints: {constraints}{Style.RESET_ALL}")
-
- confirm = input(f"\n{Fore.YELLOW}Proceed with agent mode? (yes/no): {Style.RESET_ALL}").strip().lower()
- if confirm != 'yes':
- print(f"{Fore.YELLOW}Agent mode cancelled.{Style.RESET_ALL}")
- return None
-
- return {
- 'goal': goal,
- 'target': target,
- 'constraints': constraints
- }
-
- @staticmethod
- def get_user_input() -> str:
- """Get user input with prompt."""
- print(f"\n{Fore.GREEN}[>]{Style.RESET_ALL} ", end="")
- return input().strip()
-
- @staticmethod
- def get_multi_line_input() -> Optional[str]:
- """Get multi-line input from user."""
- print(f"{Fore.CYAN}Entering multi-line mode. Type your query across multiple lines.{Style.RESET_ALL}")
- print(f"{Fore.CYAN}Press Enter on empty line to submit.{Style.RESET_ALL}")
-
- lines = []
- while True:
- line = input()
- if line == "": # Empty line ends input
- break
- lines.append(line)
-
- # Only proceed if they actually entered something
- if not lines:
- print(f"{Fore.YELLOW}No query entered in multi-line mode.{Style.RESET_ALL}")
- return None
-
- return "\n".join(lines)
-
- @staticmethod
- def display_no_query_message() -> None:
- """Display message when no query is entered."""
- print(f"{Fore.YELLOW}No query entered. Please type your question.{Style.RESET_ALL}")
-
- @staticmethod
- def display_ready_message() -> None:
- """Display ready for next query message."""
- print(f"\n{Fore.CYAN}Ready for next query. Type 'quit', 'multi' for multi-line, or 'menu' for main menu.{Style.RESET_ALL}")
-
- @staticmethod
- def display_exit_message() -> None:
- """Display exit message."""
- print(f"\n{Fore.CYAN}Thank you for using GHOSTCREW, exiting...{Style.RESET_ALL}")
-
- @staticmethod
- def display_workflow_requirements_message() -> None:
- """Display message about automated workflow requirements."""
- print(f"\n{Fore.YELLOW}Workflows requires MCP tools to be configured and connected.{Style.RESET_ALL}")
- print(f"{Fore.WHITE}Without real security tools, the AI would only generate simulated responses.{Style.RESET_ALL}")
- print(f"{Fore.WHITE}Please configure MCP tools to use this feature.{Style.RESET_ALL}")
- input(f"{Fore.CYAN}Press Enter to continue...{Style.RESET_ALL}")
-
- @staticmethod
- def display_agent_mode_requirements_message() -> None:
- """Display message about agent mode requirements."""
- print(f"\n{Fore.YELLOW}Agent Mode requires MCP tools to be configured and connected.{Style.RESET_ALL}")
- print(f"{Fore.WHITE}The autonomous agent needs real security tools to execute PTT tasks.{Style.RESET_ALL}")
- print(f"{Fore.WHITE}Please configure MCP tools to use this feature.{Style.RESET_ALL}")
- input(f"{Fore.CYAN}Press Enter to continue...{Style.RESET_ALL}")
-
- @staticmethod
- def get_workflow_target() -> Optional[str]:
- """Get target input for workflow execution."""
- target = input(f"{Fore.YELLOW}Enter target (IP, domain, or network): {Style.RESET_ALL}").strip()
- if not target:
- print(f"{Fore.RED}Target is required.{Style.RESET_ALL}")
- return None
- return target
-
- @staticmethod
- def confirm_workflow_execution(workflow_name: str, target: str) -> bool:
- """Confirm workflow execution with user."""
- confirm = input(f"{Fore.YELLOW}Execute '{workflow_name}' on {target}? (yes/no): {Style.RESET_ALL}").strip().lower()
- return confirm == 'yes'
-
- @staticmethod
- def display_workflow_cancelled() -> None:
- """Display workflow cancelled message."""
- print(f"{Fore.YELLOW}Workflow cancelled.{Style.RESET_ALL}")
-
- @staticmethod
- def display_workflow_completed() -> None:
- """Display workflow completion message."""
- print(f"\n{Fore.GREEN}Workflow completed successfully!{Style.RESET_ALL}")
-
- @staticmethod
- def ask_generate_report() -> bool:
- """Ask if user wants to generate a report."""
- response = input(f"\n{Fore.CYAN}Generate markdown report? (yes/no): {Style.RESET_ALL}").strip().lower()
- return response == 'yes'
-
- @staticmethod
- def ask_save_raw_history() -> bool:
- """Ask if user wants to save raw conversation history."""
- response = input(f"{Fore.YELLOW}Save raw conversation history? (yes/no, default: no): {Style.RESET_ALL}").strip().lower()
- return response == 'yes'
-
- @staticmethod
- def display_report_generated(report_path: str) -> None:
- """Display report generation success message."""
- print(f"\n{Fore.GREEN}Report generated: {report_path}{Style.RESET_ALL}")
- print(f"{Fore.CYAN}Open the markdown file in any markdown viewer for best formatting{Style.RESET_ALL}")
-
- @staticmethod
- def display_report_error(error: Exception) -> None:
- """Display report generation error message."""
- print(f"\n{Fore.RED}Error generating report: {error}{Style.RESET_ALL}")
- print(f"{Fore.YELLOW}Raw workflow data is still available in conversation history{Style.RESET_ALL}")
-
- @staticmethod
- def display_invalid_choice() -> None:
- """Display invalid choice message."""
- print(f"{Fore.RED}Invalid choice. Please select a valid option.{Style.RESET_ALL}")
-
- @staticmethod
- def display_invalid_input() -> None:
- """Display invalid input message."""
- print(f"{Fore.RED}Invalid input. Please enter a number.{Style.RESET_ALL}")
-
- @staticmethod
- def display_operation_cancelled() -> None:
- """Display operation cancelled message."""
- print(f"\n{Fore.YELLOW}Operation cancelled.{Style.RESET_ALL}")
-
- @staticmethod
- def press_enter_to_continue() -> None:
- """Wait for user to press enter."""
- input(f"\n{Fore.CYAN}Press Enter to continue...{Style.RESET_ALL}")
\ No newline at end of file
diff --git a/workflows/__init__.py b/workflows/__init__.py
deleted file mode 100644
index 8a7f421..0000000
--- a/workflows/__init__.py
+++ /dev/null
@@ -1 +0,0 @@
-"""Workflow system for GHOSTCREW."""
\ No newline at end of file
diff --git a/workflows/workflow_definitions.py b/workflows/workflow_definitions.py
deleted file mode 100644
index dd15b73..0000000
--- a/workflows/workflow_definitions.py
+++ /dev/null
@@ -1,73 +0,0 @@
-# GHOSTCREW Workflows
-
-def get_available_workflows():
- """
- Get all available automated workflows.
- All workflows can use any configured tools - no restrictions.
- """
-
- workflows = {
- "reconnaissance": {
- "name": "Reconnaissance and Discovery",
- "description": "Comprehensive information gathering and target profiling",
- "steps": [
- "Perform comprehensive reconnaissance on {target}",
- "Discover subdomains and DNS information",
- "Scan for open ports and services",
- "Identify technology stack and fingerprints",
- "Gather historical data and archived content"
- ]
- },
-
- "web_application": {
- "name": "Web Application Security Assessment",
- "description": "Comprehensive web application penetration testing",
- "steps": [
- "Discover web directories and hidden content on {target}",
- "Test for SQL injection vulnerabilities",
- "Scan for web application vulnerabilities and misconfigurations",
- "Analyze SSL/TLS configuration and security",
- "Test for authentication and session management flaws",
- "Check for file inclusion and upload vulnerabilities"
- ]
- },
-
- "network_infrastructure": {
- "name": "Network Infrastructure Penetration Test",
- "description": "Network-focused penetration testing and exploitation",
- "steps": [
- "Scan network range {target} for live hosts and services",
- "Perform detailed service enumeration and version detection",
- "Scan for known vulnerabilities in discovered services",
- "Test for network service misconfigurations",
- "Attempt exploitation of discovered vulnerabilities",
- "Assess network segmentation and access controls"
- ]
- },
-
- "full_penetration_test": {
- "name": "Complete Penetration Test",
- "description": "Full-scope penetration testing methodology",
- "steps": [
- "Phase 1: Quick port scan to identify open services on {target}",
- "Phase 2: Service version detection on discovered ports",
- "Phase 3: Web service discovery and directory enumeration",
- "Phase 4: Focused vulnerability scanning of services",
- "Phase 5: Targeted exploitation of discovered vulnerabilities",
- "Phase 6: Post-exploitation enumeration if access gained",
- "Phase 7: Compile findings and remediation recommendations"
- ]
- }
- }
-
- return workflows
-
-def get_workflow_by_key(workflow_key):
- """Get a specific workflow by its key"""
- workflows = get_available_workflows()
- return workflows.get(workflow_key, None)
-
-def list_workflow_names():
- """Get a list of all workflow names for display"""
- workflows = get_available_workflows()
- return [(key, workflow["name"]) for key, workflow in workflows.items()]
\ No newline at end of file
diff --git a/workflows/workflow_engine.py b/workflows/workflow_engine.py
deleted file mode 100644
index 39e8cb4..0000000
--- a/workflows/workflow_engine.py
+++ /dev/null
@@ -1,153 +0,0 @@
-"""Workflow execution engine for GHOSTCREW."""
-
-import asyncio
-from typing import List, Dict, Any, Optional
-from colorama import Fore, Style
-from datetime import datetime
-from workflows.workflow_definitions import (
- get_available_workflows, get_workflow_by_key, list_workflow_names
-)
-from config.constants import (
- ERROR_NO_WORKFLOWS, ERROR_WORKFLOW_NOT_FOUND, WORKFLOW_TARGET_PROMPT,
- WORKFLOW_CONFIRM_PROMPT, WORKFLOW_CANCELLED_MESSAGE, WORKFLOW_COMPLETED_MESSAGE
-)
-from tools.mcp_manager import MCPManager
-
-
-class WorkflowEngine:
- """Handles automated workflow execution."""
-
- def __init__(self):
- """Initialize the workflow engine."""
- self.workflows_available = self._check_workflows_available()
-
- @staticmethod
- def _check_workflows_available() -> bool:
- """Check if workflow definitions are available."""
- try:
- # Test import to verify module is available
- from workflows.workflow_definitions import get_available_workflows
- return True
- except ImportError:
- return False
-
- def is_available(self) -> bool:
- """Check if workflows are available."""
- return self.workflows_available
-
- @staticmethod
- def show_automated_menu() -> Optional[List[tuple]]:
- """Display the automated workflow selection menu."""
- try:
- print(f"\n{Fore.CYAN}WORKFLOWS{Style.RESET_ALL}")
- print(f"{Fore.WHITE}{'='*50}{Style.RESET_ALL}")
-
- workflow_list = list_workflow_names()
- workflows = get_available_workflows()
-
- for i, (key, name) in enumerate(workflow_list, 1):
- description = workflows[key]["description"]
- step_count = len(workflows[key]["steps"])
- print(f"{i}. {Fore.YELLOW}{name}{Style.RESET_ALL}")
- print(f" {Fore.WHITE}{description}{Style.RESET_ALL}")
- print(f" {Fore.CYAN}Steps: {step_count}{Style.RESET_ALL}")
- print()
-
- print(f"{len(workflow_list)+1}. {Fore.RED}Back to Main Menu{Style.RESET_ALL}")
-
- return workflow_list
- except Exception:
- print(f"{Fore.YELLOW}Error loading workflows.{Style.RESET_ALL}")
- return None
-
- async def run_automated_workflow(
- self,
- workflow: Dict[str, Any],
- target: str,
- connected_servers: List[Any],
- conversation_history: List[Dict[str, str]],
- kb_instance: Any,
- run_agent_func: Any
- ) -> List[Dict[str, Any]]:
- """
- Execute a workflow.
-
- Args:
- workflow: The workflow definition
- target: The target for the workflow
- connected_servers: List of connected MCP servers
- conversation_history: Conversation history list
- kb_instance: Knowledge base instance
- run_agent_func: Function to run agent queries
-
- Returns:
- List of workflow results
- """
- available_tools = MCPManager.get_available_tools(connected_servers)
-
- print(f"\n{Fore.CYAN}Starting Automated Workflow: {workflow['name']}{Style.RESET_ALL}")
- print(f"{Fore.YELLOW}Target: {target}{Style.RESET_ALL}")
- print(f"{Fore.CYAN}Available Tools: {', '.join(available_tools) if available_tools else 'None'}{Style.RESET_ALL}")
- print(f"{Fore.WHITE}Description: {workflow['description']}{Style.RESET_ALL}")
- print(f"{Fore.WHITE}{'='*60}{Style.RESET_ALL}")
-
- results = []
-
- for i, step in enumerate(workflow['steps'], 1):
- print(f"\n{Fore.CYAN}Step {i}/{len(workflow['steps'])}{Style.RESET_ALL}")
- formatted_step = step.format(target=target)
- print(f"{Fore.WHITE}{formatted_step}{Style.RESET_ALL}")
-
- # Create comprehensive query for this step
- enhanced_query = f"""
-TARGET: {target}
-STEP: {formatted_step}
-
-Execute this step and provide the results.
-"""
-
- # Execute the step through the agent
- result = await run_agent_func(
- enhanced_query,
- connected_servers,
- history=conversation_history,
- streaming=True,
- kb_instance=kb_instance
- )
-
- if result and hasattr(result, "final_output"):
- results.append({
- "step": i,
- "description": formatted_step,
- "output": result.final_output
- })
-
- # Add to conversation history
- conversation_history.append({
- "user_query": enhanced_query,
- "ai_response": result.final_output
- })
-
- print(f"{Fore.GREEN}Step {i} completed{Style.RESET_ALL}")
-
- # Brief delay between steps
- await asyncio.sleep(1)
-
- # Workflow completion summary
- print(f"{Fore.CYAN}Steps executed: {len(results)}/{len(workflow['steps'])}{Style.RESET_ALL}")
-
- return results
-
- def get_workflow(self, workflow_key: str) -> Optional[Dict[str, Any]]:
- """Get a workflow by its key."""
- try:
- return get_workflow_by_key(workflow_key)
- except Exception:
- return None
-
- def get_workflow_list(self) -> List[tuple]:
- """Get list of available workflows."""
- try:
- return list_workflow_names()
- except Exception:
- return []
\ No newline at end of file