Merge pull request #45 from FuzzingLabs/feature/tui-agent-setup

feat(tui): add terminal UI with hub and agent management
This commit is contained in:
AFredefon
2026-03-10 04:11:50 +01:00
committed by GitHub
12 changed files with 3592 additions and 196 deletions

413
USAGE.md
View File

@@ -1,8 +1,9 @@
# FuzzForge AI Usage Guide
This guide covers everything you need to know to get started with FuzzForge AI - from installation to running your first security research workflow with AI.
This guide covers everything you need to know to get started with FuzzForge AI from installation to linking your first MCP hub and running security research workflows with AI.
> **FuzzForge is designed to be used with AI agents** (GitHub Copilot, Claude, etc.) via MCP.
> A terminal UI (`fuzzforge ui`) is provided for managing agents and hubs.
> The CLI is available for advanced users but the primary experience is through natural language interaction with your AI assistant.
---
@@ -12,8 +13,17 @@ This guide covers everything you need to know to get started with FuzzForge AI -
- [Quick Start](#quick-start)
- [Prerequisites](#prerequisites)
- [Installation](#installation)
- [Building Modules](#building-modules)
- [MCP Server Configuration](#mcp-server-configuration)
- [Terminal UI](#terminal-ui)
- [Launching the UI](#launching-the-ui)
- [Dashboard](#dashboard)
- [Agent Setup](#agent-setup)
- [Hub Manager](#hub-manager)
- [MCP Hub System](#mcp-hub-system)
- [What is an MCP Hub?](#what-is-an-mcp-hub)
- [FuzzingLabs Security Hub](#fuzzinglabs-security-hub)
- [Linking a Custom Hub](#linking-a-custom-hub)
- [Building Hub Images](#building-hub-images)
- [MCP Server Configuration (CLI)](#mcp-server-configuration-cli)
- [GitHub Copilot](#github-copilot)
- [Claude Code (CLI)](#claude-code-cli)
- [Claude Desktop](#claude-desktop)
@@ -27,7 +37,7 @@ This guide covers everything you need to know to get started with FuzzForge AI -
## Quick Start
> **Prerequisites:** You need [uv](https://docs.astral.sh/uv/) and [Docker](https://docs.docker.com/get-docker/) installed.
> See the [Prerequisites](#prerequisites) section for installation instructions.
> See the [Prerequisites](#prerequisites) section for details.
```bash
# 1. Clone and install
@@ -35,20 +45,29 @@ git clone https://github.com/FuzzingLabs/fuzzforge_ai.git
cd fuzzforge_ai
uv sync
# 2. Build the module images (one-time setup)
make build-modules
# 2. Launch the terminal UI
uv run fuzzforge ui
# 3. Install MCP for your AI agent
uv run fuzzforge mcp install copilot # For VS Code + GitHub Copilot
# OR
uv run fuzzforge mcp install claude-code # For Claude Code CLI
# 4. Restart your AI agent (VS Code, Claude, etc.)
# 5. Start talking to your AI:
# "List available FuzzForge modules"
# 3. Press 'h' → "FuzzingLabs Hub" to clone & link the default security hub
# 4. Select an agent row and press Enter to link it
# 5. Restart your AI agent and start talking:
# "What security tools are available?"
# "Scan this binary with binwalk and yara"
# "Analyze this Rust crate for fuzzable functions"
# "Start fuzzing the parse_input function"
```
Or do it entirely from the command line:
```bash
# Install MCP for your AI agent
uv run fuzzforge mcp install copilot # For VS Code + GitHub Copilot
# OR
uv run fuzzforge mcp install claude-code # For Claude Code CLI
# Build hub tool images
./scripts/build-hub-images.sh
# Restart your AI agent — done!
```
> **Note:** FuzzForge uses Docker by default. Podman is also supported via `--engine podman`.
@@ -59,9 +78,10 @@ uv run fuzzforge mcp install claude-code # For Claude Code CLI
Before installing FuzzForge AI, ensure you have:
- **Python 3.12+** - [Download Python](https://www.python.org/downloads/)
- **uv** package manager - [Install uv](https://docs.astral.sh/uv/)
- **Docker** - Container runtime ([Install Docker](https://docs.docker.com/get-docker/))
- **Python 3.12+** [Download Python](https://www.python.org/downloads/)
- **uv** package manager [Install uv](https://docs.astral.sh/uv/)
- **Docker** Container runtime ([Install Docker](https://docs.docker.com/get-docker/))
- **Git** — For cloning hub repositories
### Installing uv
@@ -115,74 +135,164 @@ uv run fuzzforge --help
---
## Building Modules
## Terminal UI
FuzzForge modules are containerized security tools. After cloning, you need to build them once:
FuzzForge ships with a terminal user interface (TUI) built on [Textual](https://textual.textualize.io/) for managing AI agents and MCP hub servers from a single dashboard.
### Build All Modules
### Launching the UI
```bash
# From the fuzzforge_ai directory
make build-modules
uv run fuzzforge ui
```
This builds all available modules:
- `fuzzforge-rust-analyzer` - Analyzes Rust code for fuzzable functions
- `fuzzforge-cargo-fuzzer` - Runs cargo-fuzz on Rust crates
- `fuzzforge-harness-validator` - Validates generated fuzzing harnesses
- `fuzzforge-crash-analyzer` - Analyzes crash inputs
### Dashboard
### Build a Single Module
The main screen is split into two panels:
```bash
# Build a specific module
cd fuzzforge-modules/rust-analyzer
make build
```
| Panel | Content |
|-------|---------|
| **AI Agents** (left) | Shows GitHub Copilot, Claude Desktop, and Claude Code with live link status and config file path |
| **Hub Servers** (right) | Shows all configured MCP hub tools with Docker image name, source hub, and build status (✓ Ready / ✗ Not built) |
### Verify Modules are Built
### Keyboard Shortcuts
```bash
# List built module images
docker images | grep fuzzforge
```
| Key | Action |
|-----|--------|
| `Enter` | **Select** — Act on the selected row (setup/unlink an agent) |
| `h` | **Hub Manager** — Open the hub management screen |
| `r` | **Refresh** — Re-check all agent and hub statuses |
| `q` | **Quit** |
You should see something like:
```
fuzzforge-rust-analyzer 0.1.0 abc123def456 2 minutes ago 850 MB
fuzzforge-cargo-fuzzer 0.1.0 789ghi012jkl 2 minutes ago 1.2 GB
...
```
### Agent Setup
Select an agent row in the AI Agents table and press `Enter`:
- **If the agent is not linked** → a setup dialog opens asking for your container engine (Docker or Podman), then installs the FuzzForge MCP configuration
- **If the agent is already linked** → a confirmation dialog offers to unlink it (removes the `fuzzforge` entry without touching other MCP servers)
The setup auto-detects:
- FuzzForge installation root
- Docker/Podman socket path
- Hub configuration from `hub-config.json`
### Hub Manager
Press `h` to open the hub manager. This is where you manage your MCP hub repositories:
| Button | Action |
|--------|--------|
| **FuzzingLabs Hub** | One-click clone of the official [mcp-security-hub](https://github.com/FuzzingLabs/mcp-security-hub) repository — clones to `~/.fuzzforge/hubs/mcp-security-hub`, scans for tools, and registers them in `hub-config.json` |
| **Link Path** | Link any local directory as a hub — enter a name and path, FuzzForge scans it for `category/tool-name/Dockerfile` patterns |
| **Clone URL** | Clone any git repository and link it as a hub |
| **Remove** | Unlink the selected hub and remove its servers from the configuration |
The hub table shows:
- **Name** — Hub name (★ prefix for the default hub)
- **Path** — Local directory path
- **Servers** — Number of MCP tools discovered
- **Source** — Git URL or "local"
---
## MCP Server Configuration
## MCP Hub System
FuzzForge integrates with AI agents through the Model Context Protocol (MCP). Configure your preferred AI agent to use FuzzForge tools.
### What is an MCP Hub?
An MCP hub is a directory containing one or more containerized MCP tools, organized by category:
```
my-hub/
├── category-a/
│ ├── tool-1/
│ │ └── Dockerfile
│ └── tool-2/
│ └── Dockerfile
├── category-b/
│ └── tool-3/
│ └── Dockerfile
└── ...
```
FuzzForge scans for the pattern `category/tool-name/Dockerfile` and auto-generates server configuration entries for each discovered tool.
### FuzzingLabs Security Hub
The default MCP hub is [mcp-security-hub](https://github.com/FuzzingLabs/mcp-security-hub), maintained by FuzzingLabs. It includes **40+ security tools** across categories:
| Category | Tools |
|----------|-------|
| **Reconnaissance** | nmap, masscan, shodan, zoomeye, whatweb, pd-tools, externalattacker, networksdb |
| **Binary Analysis** | binwalk, yara, capa, radare2, ghidra, ida |
| **Code Security** | semgrep, rust-analyzer, harness-tester, cargo-fuzzer, crash-analyzer |
| **Web Security** | nuclei, nikto, sqlmap, ffuf, burp, waybackurls |
| **Fuzzing** | boofuzz, dharma |
| **Exploitation** | searchsploit |
| **Secrets** | gitleaks |
| **Cloud Security** | trivy, prowler, roadrecon |
| **OSINT** | maigret, dnstwist |
| **Threat Intel** | virustotal, otx |
| **Password Cracking** | hashcat |
| **Blockchain** | medusa, solazy, daml-viewer |
**Clone it via the UI:**
1. `uv run fuzzforge ui`
2. Press `h` → click **FuzzingLabs Hub**
3. Wait for the clone to finish — servers are auto-registered
**Or clone manually:**
```bash
git clone git@github.com:FuzzingLabs/mcp-security-hub.git ~/.fuzzforge/hubs/mcp-security-hub
```
### Linking a Custom Hub
You can link any directory that follows the `category/tool-name/Dockerfile` layout:
**Via the UI:**
1. Press `h`**Link Path**
2. Enter a name and the directory path
**Via the CLI (planned):** Not yet available — use the UI.
### Building Hub Images
After linking a hub, you need to build the Docker images before the tools can be used:
```bash
# Build all images from the default security hub
./scripts/build-hub-images.sh
# Or build a single tool image
docker build -t semgrep-mcp:latest mcp-security-hub/code-security/semgrep-mcp/
```
The dashboard hub table shows ✓ Ready for built images and ✗ Not built for missing ones.
---
## MCP Server Configuration (CLI)
If you prefer the command line over the TUI, you can configure agents directly:
### GitHub Copilot
```bash
# That's it! Just run this command:
uv run fuzzforge mcp install copilot
```
The command auto-detects everything:
- **FuzzForge root** - Where FuzzForge is installed
- **Modules path** - Defaults to `fuzzforge_ai/fuzzforge-modules`
- **Docker socket** - Auto-detects `/var/run/docker.sock`
The command auto-detects:
- **FuzzForge root** Where FuzzForge is installed
- **Docker socket** — Auto-detects `/var/run/docker.sock`
**Optional overrides** (usually not needed):
**Optional overrides:**
```bash
uv run fuzzforge mcp install copilot \
--modules /path/to/modules \
--engine podman # if using Podman instead of Docker
uv run fuzzforge mcp install copilot --engine podman
```
**After installation:**
1. Restart VS Code
2. Open GitHub Copilot Chat
3. FuzzForge tools are now available!
**After installation:** Restart VS Code. FuzzForge tools appear in GitHub Copilot Chat.
### Claude Code (CLI)
@@ -190,143 +300,89 @@ uv run fuzzforge mcp install copilot \
uv run fuzzforge mcp install claude-code
```
Installs to `~/.claude.json` so FuzzForge tools are available from any directory.
**After installation:**
1. Run `claude` from any directory
2. FuzzForge tools are now available!
Installs to `~/.claude.json`. FuzzForge tools are available from any directory after restarting Claude.
### Claude Desktop
```bash
# Automatic installation
uv run fuzzforge mcp install claude-desktop
# Verify
uv run fuzzforge mcp status
```
**After installation:**
1. Restart Claude Desktop
2. FuzzForge tools are now available!
**After installation:** Restart Claude Desktop.
### Check MCP Status
### Check Status
```bash
uv run fuzzforge mcp status
```
Shows configuration status for all supported AI agents:
```
┏━━━━━━━━━━━━━━━━━━━━━━┳━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┳━━━━━━━━━━━━━━┳━━━━━━━━━━━━━━━━━━━━━━━━━━┓
┃ Agent ┃ Config Path ┃ Status ┃ FuzzForge Configured ┃
┡━━━━━━━━━━━━━━━━━━━━━━╇━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━╇━━━━━━━━━━━━━━╇━━━━━━━━━━━━━━━━━━━━━━━━━━┩
│ GitHub Copilot │ ~/.config/Code/User/mcp.json │ ✓ Exists │ ✓ Yes │
│ Claude Desktop │ ~/.config/Claude/claude_desktop_config... │ Not found │ - │
│ Claude Code │ ~/.claude.json │ ✓ Exists │ ✓ Yes │
└──────────────────────┴───────────────────────────────────────────┴──────────────┴─────────────────────────┘
```
### Generate Config Without Installing
```bash
# Preview the configuration that would be installed
uv run fuzzforge mcp generate copilot
uv run fuzzforge mcp generate claude-desktop
uv run fuzzforge mcp generate claude-code
```
### Remove MCP Configuration
### Remove Configuration
```bash
uv run fuzzforge mcp uninstall copilot
uv run fuzzforge mcp uninstall claude-desktop
uv run fuzzforge mcp uninstall claude-code
uv run fuzzforge mcp uninstall claude-desktop
```
---
## Using FuzzForge with AI
Once MCP is configured, you interact with FuzzForge through natural language with your AI assistant.
Once MCP is configured and hub images are built, interact with FuzzForge through natural language with your AI assistant.
### Example Conversations
**Discover available tools:**
```
You: "What FuzzForge modules are available?"
AI: Uses list_modules → "I found 4 modules: rust-analyzer, cargo-fuzzer,
harness-validator, and crash-analyzer..."
You: "What security tools are available in FuzzForge?"
AI: Queries hub tools → "I found 15 tools across categories: nmap for
port scanning, binwalk for firmware analysis, semgrep for code
scanning, cargo-fuzzer for Rust fuzzing..."
```
**Analyze code for fuzzing targets:**
**Analyze a binary:**
```
You: "Extract and analyze this firmware image"
AI: Uses binwalk to extract → yara for pattern matching → capa for
capability detection → "Found 3 embedded filesystems, 2 YARA
matches for known vulnerabilities..."
```
**Fuzz Rust code:**
```
You: "Analyze this Rust crate for functions I should fuzz"
AI: Uses execute_module("rust-analyzer") → "I found 3 good fuzzing candidates:
- parse_input() in src/parser.rs - handles untrusted input
- decode_message() in src/codec.rs - complex parsing logic
..."
```
AI: Uses rust-analyzer → "Found 3 fuzzable entry points..."
**Generate and validate harnesses:**
```
You: "Generate a fuzzing harness for the parse_input function"
AI: Creates harness code, then uses execute_module("harness-validator")
→ "Here's a harness that compiles successfully..."
```
**Run continuous fuzzing:**
```
You: "Start fuzzing parse_input for 10 minutes"
AI: Uses start_continuous_module("cargo-fuzzer") → "Started fuzzing session abc123"
You: "How's the fuzzing going?"
AI: Uses get_continuous_status("abc123") → "Running for 5 minutes:
- 150,000 executions
- 2 crashes found
- 45% edge coverage"
You: "Stop and show me the crashes"
AI: Uses stop_continuous_module("abc123") → "Found 2 unique crashes..."
AI: Uses cargo-fuzzer → "Fuzzing session started. 2 crashes found..."
```
### Available MCP Tools
| Tool | Description |
|------|-------------|
| `list_modules` | List all available security modules |
| `execute_module` | Run a module once and get results |
| `start_continuous_module` | Start a long-running module (e.g., fuzzing) |
| `get_continuous_status` | Check status of a continuous session |
| `stop_continuous_module` | Stop a continuous session |
| `list_continuous_sessions` | List all active sessions |
| `get_execution_results` | Retrieve results from an execution |
| `execute_workflow` | Run a multi-step workflow |
**Scan for vulnerabilities:**
```
You: "Scan this codebase with semgrep for security issues"
AI: Uses semgrep-mcp → "Found 5 findings: 2 high severity SQL injection
patterns, 3 medium severity hardcoded secrets..."
```
---
## CLI Reference
> **Note:** The CLI is for advanced users. Most users should interact with FuzzForge through their AI assistant.
### UI Command
```bash
uv run fuzzforge ui # Launch the terminal dashboard
```
### MCP Commands
```bash
uv run fuzzforge mcp status # Check configuration status
uv run fuzzforge mcp install <agent> # Install MCP config
uv run fuzzforge mcp status # Check agent configuration status
uv run fuzzforge mcp install <agent> # Install MCP config (copilot|claude-code|claude-desktop)
uv run fuzzforge mcp uninstall <agent> # Remove MCP config
uv run fuzzforge mcp generate <agent> # Preview config without installing
```
### Module Commands
```bash
uv run fuzzforge modules list # List available modules
uv run fuzzforge modules info <module> # Show module details
uv run fuzzforge modules run <module> --assets . # Run a module
```
### Project Commands
```bash
@@ -343,14 +399,13 @@ uv run fuzzforge project results <id> # Get execution results
Configure FuzzForge using environment variables:
```bash
# Project paths
export FUZZFORGE_MODULES_PATH=/path/to/modules
# Storage path for projects and execution results
export FUZZFORGE_STORAGE_PATH=/path/to/storage
# Container engine (Docker is default)
export FUZZFORGE_ENGINE__TYPE=docker # or podman
# Podman-specific settings (only needed if using Podman under Snap)
# Podman-specific settings
export FUZZFORGE_ENGINE__GRAPHROOT=~/.fuzzforge/containers/storage
export FUZZFORGE_ENGINE__RUNROOT=~/.fuzzforge/containers/run
```
@@ -384,66 +439,62 @@ Error: Permission denied connecting to Docker socket
**Solution:**
```bash
# Add your user to the docker group
sudo usermod -aG docker $USER
# Log out and back in for changes to take effect
# Then verify:
# Log out and back in, then verify:
docker run --rm hello-world
```
### No Modules Found
### Hub Images Not Built
```
No modules found.
```
The dashboard shows ✗ Not built for tools:
**Solution:**
1. Build the modules first: `make build-modules`
2. Check the modules path: `uv run fuzzforge modules list`
3. Verify images exist: `docker images | grep fuzzforge`
```bash
# Build all hub images
./scripts/build-hub-images.sh
# Or build a single tool
docker build -t <tool-name>:latest mcp-security-hub/<category>/<tool-name>/
```
### MCP Server Not Starting
Check the MCP configuration:
```bash
# Check agent configuration
uv run fuzzforge mcp status
```
Verify the configuration file path exists and contains valid JSON.
### Module Container Fails to Build
```bash
# Build module container manually to see errors
cd fuzzforge-modules/<module-name>
docker build -t <module-name> .
# Verify the config file path exists and contains valid JSON
cat ~/.config/Code/User/mcp.json # Copilot
cat ~/.claude.json # Claude Code
```
### Using Podman Instead of Docker
If you prefer Podman:
```bash
# Use --engine podman with CLI
# Install with Podman engine
uv run fuzzforge mcp install copilot --engine podman
# Or set environment variable
export FUZZFORGE_ENGINE=podman
```
### Check Logs
### Hub Registry
FuzzForge stores linked hub information in `~/.fuzzforge/hubs.json`. If something goes wrong:
FuzzForge stores execution logs in the storage directory:
```bash
ls -la ~/.fuzzforge/storage/<project-id>/<execution-id>/
# View registry
cat ~/.fuzzforge/hubs.json
# Reset registry
rm ~/.fuzzforge/hubs.json
```
---
## Next Steps
- 📖 Read the [Module SDK Guide](fuzzforge-modules/fuzzforge-modules-sdk/README.md) to create custom modules
- 🎬 Check the demos in the [README](README.md)
- 🖥️ Launch `uv run fuzzforge ui` and explore the dashboard
- 🔒 Clone the [mcp-security-hub](https://github.com/FuzzingLabs/mcp-security-hub) for 40+ security tools
- 💬 Join our [Discord](https://discord.gg/8XEX33UUwZ) for support
---

View File

@@ -8,6 +8,7 @@ requires-python = ">=3.14"
dependencies = [
"fuzzforge-mcp==0.0.1",
"rich>=14.0.0",
"textual>=1.0.0",
"typer==0.20.1",
]

View File

@@ -34,7 +34,7 @@ def main(
envvar="FUZZFORGE_STORAGE__PATH",
help="Path to the storage directory.",
),
] = Path.home() / ".fuzzforge" / "storage",
] = Path.cwd() / ".fuzzforge" / "storage",
context: TyperContext = None, # type: ignore[assignment]
) -> None:
"""FuzzForge AI - Security research orchestration platform.
@@ -42,7 +42,7 @@ def main(
Discover and execute MCP hub tools for security research.
"""
storage = LocalStorage(storage_path=storage_path)
storage = LocalStorage(base_path=storage_path)
context.obj = Context(
storage=storage,
@@ -52,3 +52,19 @@ def main(
application.add_typer(mcp.application)
application.add_typer(projects.application)
@application.command(
name="ui",
help="Launch the FuzzForge terminal interface.",
)
def launch_ui() -> None:
"""Launch the interactive FuzzForge TUI dashboard.
Provides a visual dashboard showing AI agent connection status
and hub server availability, with wizards for setup and configuration.
"""
from fuzzforge_cli.tui.app import FuzzForgeApp
FuzzForgeApp().run()

View File

@@ -169,7 +169,7 @@ def _generate_mcp_config(
# Self-contained storage paths for FuzzForge containers
# This isolates FuzzForge from system Podman and avoids snap issues
fuzzforge_home = Path.home() / ".fuzzforge"
fuzzforge_home = Path.cwd() / ".fuzzforge"
graphroot = fuzzforge_home / "containers" / "storage"
runroot = fuzzforge_home / "containers" / "run"

View File

@@ -0,0 +1 @@
"""FuzzForge terminal user interface."""

View File

@@ -0,0 +1,360 @@
"""FuzzForge TUI application.
Main terminal user interface for FuzzForge, providing a dashboard
with AI agent connection status, hub server availability, and
hub management capabilities.
"""
from __future__ import annotations
from collections import defaultdict
from rich.text import Text
from textual.app import App, ComposeResult
from textual.binding import Binding
from textual.containers import Horizontal, Vertical, VerticalScroll
from textual.widgets import Button, DataTable, Footer, Header, Label
from fuzzforge_cli.tui.helpers import (
check_agent_status,
check_hub_image,
find_fuzzforge_root,
get_agent_configs,
load_hub_config,
)
# Agent config entries stored alongside their linked status for row mapping
_AgentRow = tuple[str, "AIAgent", "Path", str, bool] # noqa: F821
class FuzzForgeApp(App):
"""FuzzForge AI terminal user interface."""
TITLE = "FuzzForge AI"
SUB_TITLE = "Security Research Orchestration"
CSS = """
Screen {
background: $surface;
}
#main {
height: 1fr;
margin: 1 2;
}
.panel {
width: 1fr;
border: round #4699fc;
padding: 1 2;
margin: 0 0 1 0;
}
#hub-panel {
height: 12;
}
#hub-table {
height: 1fr;
}
#agents-panel {
height: auto;
}
.panel-title {
text-style: bold;
color: #4699fc;
text-align: left;
margin-bottom: 1;
}
#hub-title-bar {
height: auto;
align: center middle;
margin: 0 0 1 0;
}
#btn-hub-manager {
min-width: 40;
margin-right: 2;
}
#btn-fuzzinglabs-hub {
min-width: 30;
}
#agents-table {
height: auto;
}
/* Modal screens */
AgentSetupScreen, AgentUnlinkScreen,
HubManagerScreen, LinkHubScreen, CloneHubScreen {
align: center middle;
}
#setup-dialog, #unlink-dialog {
width: 56;
height: auto;
max-height: 80%;
border: thick #4699fc;
background: $surface;
padding: 2 3;
overflow-y: auto;
}
#hub-manager-dialog {
width: 100;
height: auto;
max-height: 85%;
border: thick #4699fc;
background: $surface;
padding: 2 3;
overflow-y: auto;
}
#link-dialog, #clone-dialog {
width: 72;
height: auto;
max-height: 80%;
border: thick #4699fc;
background: $surface;
padding: 2 3;
overflow-y: auto;
}
.dialog-title {
text-style: bold;
text-align: center;
color: #4699fc;
margin-bottom: 1;
}
.field-label {
margin-top: 1;
text-style: bold;
}
RadioSet {
height: auto;
margin: 0 0 1 2;
}
Input {
margin: 0 0 1 0;
}
.dialog-buttons {
layout: horizontal;
height: 3;
align: center middle;
margin-top: 1;
}
.dialog-buttons Button {
margin: 0 1;
min-width: 14;
}
"""
BINDINGS = [
Binding("q", "quit", "Quit"),
Binding("h", "manage_hubs", "Hub Manager"),
Binding("r", "refresh", "Refresh"),
]
def compose(self) -> ComposeResult:
"""Compose the dashboard layout."""
yield Header()
with VerticalScroll(id="main"):
with Vertical(id="hub-panel", classes="panel"):
yield DataTable(id="hub-table")
with Horizontal(id="hub-title-bar"):
yield Button(
"Hub Manager (h)",
variant="primary",
id="btn-hub-manager",
)
yield Button(
"FuzzingLabs Hub",
variant="primary",
id="btn-fuzzinglabs-hub",
)
with Vertical(id="agents-panel", classes="panel"):
yield DataTable(id="agents-table")
yield Footer()
def on_mount(self) -> None:
"""Populate tables on startup."""
self._agent_rows: list[_AgentRow] = []
self.query_one("#hub-panel").border_title = "Hub Servers"
self.query_one("#agents-panel").border_title = "AI Agents"
self._refresh_agents()
self._refresh_hub()
def _refresh_agents(self) -> None:
"""Refresh the AI agents status table."""
table = self.query_one("#agents-table", DataTable)
table.clear(columns=True)
table.add_columns("Agent", "Status", "Config Path")
table.cursor_type = "row"
self._agent_rows = []
for display_name, agent, config_path, servers_key in get_agent_configs():
is_linked, status_text = check_agent_status(config_path, servers_key)
if is_linked:
status_cell = Text(f"{status_text}", style="green")
else:
status_cell = Text(f"{status_text}", style="red")
table.add_row(display_name, status_cell, str(config_path))
self._agent_rows.append(
(display_name, agent, config_path, servers_key, is_linked)
)
def _refresh_hub(self) -> None:
"""Refresh the hub servers table, grouped by source hub."""
table = self.query_one("#hub-table", DataTable)
table.clear(columns=True)
table.add_columns("Server", "Image", "Hub", "Status")
try:
fuzzforge_root = find_fuzzforge_root()
hub_config = load_hub_config(fuzzforge_root)
except Exception:
table.add_row(
Text("Error loading config", style="red"), "", "", ""
)
return
servers = hub_config.get("servers", [])
if not servers:
table.add_row(
Text("No servers — press h", style="dim"), "", "", ""
)
return
# Group servers by source hub
groups: dict[str, list[dict]] = defaultdict(list)
for server in servers:
source = server.get("source_hub", "manual")
groups[source].append(server)
for hub_name, hub_servers in groups.items():
ready_count = 0
total = len(hub_servers)
statuses: list[tuple[dict, bool, str]] = []
for server in hub_servers:
enabled = server.get("enabled", True)
if not enabled:
statuses.append((server, False, "Disabled"))
else:
is_ready, status_text = check_hub_image(
server.get("image", "")
)
if is_ready:
ready_count += 1
statuses.append((server, is_ready, status_text))
# Group header row
if hub_name == "manual":
header = Text(
f"▼ 📦 Local config ({ready_count}/{total} ready)",
style="bold",
)
else:
header = Text(
f"▼ 🔗 {hub_name} ({ready_count}/{total} ready)",
style="bold",
)
table.add_row(header, "", "", "")
# Tool rows
for server, is_ready, status_text in statuses:
name = server.get("name", "unknown")
image = server.get("image", "unknown")
enabled = server.get("enabled", True)
if not enabled:
status_cell = Text("Disabled", style="dim")
elif is_ready:
status_cell = Text("✓ Ready", style="green")
else:
status_cell = Text(f"{status_text}", style="red")
table.add_row(
f" {name}",
Text(image, style="dim"),
hub_name,
status_cell,
)
def on_data_table_row_selected(self, event: DataTable.RowSelected) -> None:
"""Handle row selection on the agents table."""
if event.data_table.id != "agents-table":
return
idx = event.cursor_row
if idx < 0 or idx >= len(self._agent_rows):
return
display_name, agent, _config_path, _servers_key, is_linked = self._agent_rows[idx]
if is_linked:
from fuzzforge_cli.tui.screens.agent_setup import AgentUnlinkScreen
self.push_screen(
AgentUnlinkScreen(agent, display_name),
callback=self._on_agent_changed,
)
else:
from fuzzforge_cli.tui.screens.agent_setup import AgentSetupScreen
self.push_screen(
AgentSetupScreen(agent, display_name),
callback=self._on_agent_changed,
)
def on_button_pressed(self, event: Button.Pressed) -> None:
"""Handle button presses."""
if event.button.id == "btn-hub-manager":
self.action_manage_hubs()
elif event.button.id == "btn-fuzzinglabs-hub":
self.action_add_fuzzinglabs_hub()
def action_add_fuzzinglabs_hub(self) -> None:
"""Open the clone dialog pre-filled with the FuzzingLabs hub URL."""
from fuzzforge_cli.tui.screens.hub_manager import CloneHubScreen
self.push_screen(
CloneHubScreen(
default_url="https://github.com/FuzzingLabs/mcp-security-hub",
default_name="mcp-security-hub",
is_default=True,
),
callback=self._on_hub_changed,
)
def action_manage_hubs(self) -> None:
"""Open the hub manager."""
from fuzzforge_cli.tui.screens.hub_manager import HubManagerScreen
self.push_screen(HubManagerScreen(), callback=self._on_hub_changed)
def _on_agent_changed(self, result: str | None) -> None:
"""Handle agent setup/unlink completion."""
if result:
self.notify(result)
self._refresh_agents()
def _on_hub_changed(self, result: str | None) -> None:
"""Handle hub manager completion — refresh the hub table."""
self._refresh_hub()
def action_refresh(self) -> None:
"""Refresh all status panels."""
self._refresh_agents()
self._refresh_hub()
self.notify("Status refreshed")

View File

@@ -0,0 +1,535 @@
"""Shared helpers for FuzzForge TUI and CLI.
Provides utility functions for checking AI agent configuration status,
hub server image availability, installing/removing MCP configurations,
and managing linked MCP hub repositories.
"""
from __future__ import annotations
import json
import subprocess
from pathlib import Path
from typing import Any
from fuzzforge_cli.commands.mcp import (
AIAgent,
_detect_docker_socket,
_detect_podman_socket,
_find_fuzzforge_root,
_generate_mcp_config,
_get_claude_code_user_mcp_path,
_get_claude_desktop_mcp_path,
_get_copilot_mcp_path,
)
# --- Hub Management Constants ---
FUZZFORGE_DEFAULT_HUB_URL = "git@github.com:FuzzingLabs/mcp-security-hub.git"
FUZZFORGE_DEFAULT_HUB_NAME = "mcp-security-hub"
def get_fuzzforge_dir() -> Path:
"""Return the project-local ``.fuzzforge/`` directory.
Uses the current working directory so that each project gets its
own isolated FuzzForge configuration, hubs, and storage — similar
to how ``.git/`` or ``.venv/`` work.
:return: ``Path.cwd() / ".fuzzforge"``
"""
return Path.cwd() / ".fuzzforge"
# Categories that typically need NET_RAW capability for network access
_NET_RAW_CATEGORIES = {"reconnaissance", "web-security"}
# Directories to skip when scanning a hub for MCP tool Dockerfiles
_SCAN_SKIP_DIRS = {
".git",
".github",
"scripts",
"tests",
"examples",
"meta",
"__pycache__",
"node_modules",
".venv",
}
def get_agent_configs() -> list[tuple[str, AIAgent, Path, str]]:
"""Return agent display configs with resolved paths.
Each tuple contains:
- Display name
- AIAgent enum value
- Config file path
- Servers JSON key
:return: List of agent configuration tuples.
"""
return [
("GitHub Copilot", AIAgent.COPILOT, _get_copilot_mcp_path(), "servers"),
("Claude Desktop", AIAgent.CLAUDE_DESKTOP, _get_claude_desktop_mcp_path(), "mcpServers"),
("Claude Code", AIAgent.CLAUDE_CODE, _get_claude_code_user_mcp_path(), "mcpServers"),
]
def check_agent_status(config_path: Path, servers_key: str) -> tuple[bool, str]:
"""Check whether an AI agent has FuzzForge configured.
:param config_path: Path to the agent's MCP config file.
:param servers_key: JSON key for the servers dict (e.g. "servers" or "mcpServers").
:return: Tuple of (is_linked, status_description).
"""
if not config_path.exists():
return False, "Not configured"
try:
config = json.loads(config_path.read_text())
servers = config.get(servers_key, {})
if "fuzzforge" in servers:
return True, "Linked"
return False, "Config exists, not linked"
except json.JSONDecodeError:
return False, "Invalid config file"
def check_hub_image(image: str) -> tuple[bool, str]:
"""Check whether a Docker image exists locally.
:param image: Docker image name (e.g. "semgrep-mcp:latest").
:return: Tuple of (is_ready, status_description).
"""
try:
result = subprocess.run(
["docker", "image", "inspect", image],
capture_output=True,
text=True,
timeout=5,
)
if result.returncode == 0:
return True, "Ready"
return False, "Not built"
except subprocess.TimeoutExpired:
return False, "Timeout"
except FileNotFoundError:
return False, "Docker not found"
def load_hub_config(fuzzforge_root: Path) -> dict[str, Any]:
"""Load hub-config.json from the FuzzForge root.
:param fuzzforge_root: Path to fuzzforge-oss directory.
:return: Parsed hub configuration dict, empty dict on error.
"""
config_path = fuzzforge_root / "hub-config.json"
if not config_path.exists():
return {}
try:
return json.loads(config_path.read_text())
except json.JSONDecodeError:
return {}
def find_fuzzforge_root() -> Path:
"""Find the FuzzForge installation root directory.
:return: Path to the fuzzforge-oss directory.
"""
return _find_fuzzforge_root()
def install_agent_config(agent: AIAgent, engine: str, force: bool = False) -> str:
"""Install FuzzForge MCP configuration for an AI agent.
:param agent: Target AI agent.
:param engine: Container engine type ("docker" or "podman").
:param force: Overwrite existing configuration.
:return: Result message string.
"""
fuzzforge_root = _find_fuzzforge_root()
if agent == AIAgent.COPILOT:
config_path = _get_copilot_mcp_path()
servers_key = "servers"
elif agent == AIAgent.CLAUDE_CODE:
config_path = _get_claude_code_user_mcp_path()
servers_key = "mcpServers"
else:
config_path = _get_claude_desktop_mcp_path()
servers_key = "mcpServers"
socket = _detect_docker_socket() if engine == "docker" else _detect_podman_socket()
server_config = _generate_mcp_config(
fuzzforge_root=fuzzforge_root,
engine_type=engine,
engine_socket=socket,
)
if config_path.exists():
try:
existing = json.loads(config_path.read_text())
except json.JSONDecodeError:
return f"Error: Invalid JSON in {config_path}"
servers = existing.get(servers_key, {})
if "fuzzforge" in servers and not force:
return "Already configured (use force to overwrite)"
if servers_key not in existing:
existing[servers_key] = {}
existing[servers_key]["fuzzforge"] = server_config
full_config = existing
else:
config_path.parent.mkdir(parents=True, exist_ok=True)
full_config = {servers_key: {"fuzzforge": server_config}}
config_path.write_text(json.dumps(full_config, indent=4))
return f"Installed FuzzForge for {agent.value}"
def uninstall_agent_config(agent: AIAgent) -> str:
"""Remove FuzzForge MCP configuration from an AI agent.
:param agent: Target AI agent.
:return: Result message string.
"""
if agent == AIAgent.COPILOT:
config_path = _get_copilot_mcp_path()
servers_key = "servers"
elif agent == AIAgent.CLAUDE_CODE:
config_path = _get_claude_code_user_mcp_path()
servers_key = "mcpServers"
else:
config_path = _get_claude_desktop_mcp_path()
servers_key = "mcpServers"
if not config_path.exists():
return "Configuration file not found"
try:
config = json.loads(config_path.read_text())
except json.JSONDecodeError:
return "Error: Invalid JSON in config file"
servers = config.get(servers_key, {})
if "fuzzforge" not in servers:
return "FuzzForge is not configured for this agent"
del servers["fuzzforge"]
config_path.write_text(json.dumps(config, indent=4))
return f"Removed FuzzForge from {agent.value}"
# ---------------------------------------------------------------------------
# Hub Management
# ---------------------------------------------------------------------------
def get_hubs_registry_path() -> Path:
"""Return path to the hubs registry file (``.fuzzforge/hubs.json``).
:return: Path to the registry JSON file.
"""
return get_fuzzforge_dir() / "hubs.json"
def get_default_hubs_dir() -> Path:
"""Return default directory for cloned hubs (``.fuzzforge/hubs/``).
:return: Path to the default hubs directory.
"""
return get_fuzzforge_dir() / "hubs"
def load_hubs_registry() -> dict[str, Any]:
"""Load the hubs registry from disk.
:return: Registry dict with ``hubs`` key containing a list of hub entries.
"""
path = get_hubs_registry_path()
if not path.exists():
return {"hubs": []}
try:
return json.loads(path.read_text())
except (json.JSONDecodeError, OSError):
return {"hubs": []}
def save_hubs_registry(registry: dict[str, Any]) -> None:
"""Save the hubs registry to disk.
:param registry: Registry dict to persist.
"""
path = get_hubs_registry_path()
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text(json.dumps(registry, indent=2))
def scan_hub_for_servers(hub_path: Path) -> list[dict[str, Any]]:
"""Scan a hub directory for MCP tool Dockerfiles.
Looks for the ``category/tool-name/Dockerfile`` pattern and generates
a server configuration entry for each discovered tool.
:param hub_path: Root directory of the hub repository.
:return: Sorted list of server configuration dicts.
"""
servers: list[dict[str, Any]] = []
if not hub_path.is_dir():
return servers
for dockerfile in sorted(hub_path.rglob("Dockerfile")):
rel = dockerfile.relative_to(hub_path)
parts = rel.parts
# Expected layout: category/tool-name/Dockerfile (exactly 3 parts)
if len(parts) != 3:
continue
category, tool_name, _ = parts
if category in _SCAN_SKIP_DIRS:
continue
capabilities: list[str] = []
if category in _NET_RAW_CATEGORIES:
capabilities = ["NET_RAW"]
servers.append(
{
"name": tool_name,
"description": f"{tool_name}{category}",
"type": "docker",
"image": f"{tool_name}:latest",
"category": category,
"capabilities": capabilities,
"volumes": [f"{get_fuzzforge_dir()}/hub/workspace:/data"],
"enabled": True,
}
)
return servers
def link_hub(
name: str,
path: str | Path,
git_url: str | None = None,
is_default: bool = False,
) -> str:
"""Link a hub directory and add its servers to hub-config.json.
:param name: Display name for the hub.
:param path: Local directory path containing the hub.
:param git_url: Optional git remote URL (for tracking).
:param is_default: Whether this is the default FuzzingLabs hub.
:return: Result message string.
"""
hub_path = Path(path).resolve()
if not hub_path.is_dir():
return f"Error: directory not found: {hub_path}"
# Update registry
registry = load_hubs_registry()
hubs = registry.get("hubs", [])
# Remove existing entry with same name
hubs = [h for h in hubs if h.get("name") != name]
hubs.append(
{
"name": name,
"path": str(hub_path),
"git_url": git_url,
"is_default": is_default,
}
)
registry["hubs"] = hubs
save_hubs_registry(registry)
# Scan and update hub-config.json
scanned = scan_hub_for_servers(hub_path)
if not scanned:
return f"Linked '{name}' (0 servers found)"
try:
added = _merge_servers_into_hub_config(name, scanned)
except Exception as exc:
return f"Linked '{name}' but config update failed: {exc}"
return f"Linked '{name}'{added} new servers added ({len(scanned)} scanned)"
def unlink_hub(name: str) -> str:
"""Unlink a hub and remove its servers from hub-config.json.
:param name: Name of the hub to unlink.
:return: Result message string.
"""
registry = load_hubs_registry()
hubs = registry.get("hubs", [])
if not any(h.get("name") == name for h in hubs):
return f"Hub '{name}' is not linked"
hubs = [h for h in hubs if h.get("name") != name]
registry["hubs"] = hubs
save_hubs_registry(registry)
try:
removed = _remove_hub_servers_from_config(name)
except Exception:
removed = 0
return f"Unlinked '{name}'{removed} server(s) removed"
def clone_hub(
git_url: str,
dest: Path | None = None,
name: str | None = None,
) -> tuple[bool, str, Path | None]:
"""Clone a git hub repository.
If the destination already exists and is a git repo, pulls instead.
:param git_url: Git remote URL to clone.
:param dest: Destination directory (auto-derived from URL if *None*).
:param name: Hub name (auto-derived from URL if *None*).
:return: Tuple of ``(success, message, clone_path)``.
"""
if name is None:
name = git_url.rstrip("/").split("/")[-1]
if name.endswith(".git"):
name = name[:-4]
if dest is None:
dest = get_default_hubs_dir() / name
if dest.exists():
if (dest / ".git").is_dir():
try:
result = subprocess.run(
["git", "-C", str(dest), "pull"],
capture_output=True,
text=True,
timeout=120,
)
if result.returncode == 0:
return True, f"Updated existing clone at {dest}", dest
return False, f"Git pull failed: {result.stderr.strip()}", None
except subprocess.TimeoutExpired:
return False, "Git pull timed out", None
except FileNotFoundError:
return False, "Git not found", None
return False, f"Directory already exists (not a git repo): {dest}", None
dest.parent.mkdir(parents=True, exist_ok=True)
try:
result = subprocess.run(
["git", "clone", git_url, str(dest)],
capture_output=True,
text=True,
timeout=300,
)
if result.returncode == 0:
return True, f"Cloned to {dest}", dest
return False, f"Git clone failed: {result.stderr.strip()}", None
except subprocess.TimeoutExpired:
return False, "Git clone timed out (5 min limit)", None
except FileNotFoundError:
return False, "Git not found on PATH", None
def _merge_servers_into_hub_config(
hub_name: str,
servers: list[dict[str, Any]],
) -> int:
"""Merge scanned servers into hub-config.json.
Only adds servers whose name does not already exist in the config.
New entries are tagged with ``source_hub`` for later removal.
:param hub_name: Name of the source hub (used for tagging).
:param servers: List of server dicts from :func:`scan_hub_for_servers`.
:return: Number of newly added servers.
"""
fuzzforge_root = find_fuzzforge_root()
config_path = fuzzforge_root / "hub-config.json"
if config_path.exists():
try:
config = json.loads(config_path.read_text())
except json.JSONDecodeError:
config = {"servers": [], "default_timeout": 300, "cache_tools": True}
else:
config = {"servers": [], "default_timeout": 300, "cache_tools": True}
existing = config.get("servers", [])
existing_names = {s.get("name") for s in existing}
added = 0
for server in servers:
if server["name"] not in existing_names:
server["source_hub"] = hub_name
existing.append(server)
existing_names.add(server["name"])
added += 1
config["servers"] = existing
config_path.write_text(json.dumps(config, indent=2))
return added
def _remove_hub_servers_from_config(hub_name: str) -> int:
"""Remove servers belonging to a hub from hub-config.json.
Only removes servers tagged with the given ``source_hub`` value.
Manually-added servers (without a tag) are preserved.
:param hub_name: Name of the hub whose servers should be removed.
:return: Number of servers removed.
"""
fuzzforge_root = find_fuzzforge_root()
config_path = fuzzforge_root / "hub-config.json"
if not config_path.exists():
return 0
try:
config = json.loads(config_path.read_text())
except json.JSONDecodeError:
return 0
existing = config.get("servers", [])
before = len(existing)
config["servers"] = [s for s in existing if s.get("source_hub") != hub_name]
after = len(config["servers"])
config_path.write_text(json.dumps(config, indent=2))
return before - after

View File

@@ -0,0 +1 @@
"""TUI screens for FuzzForge."""

View File

@@ -0,0 +1,96 @@
"""Agent setup and unlink modal screens for FuzzForge TUI.
Provides context-aware modals that receive the target agent directly
from the dashboard row selection — no redundant agent picker needed.
"""
from __future__ import annotations
from textual.app import ComposeResult
from textual.containers import Horizontal, Vertical
from textual.screen import ModalScreen
from textual.widgets import Button, Label, RadioButton, RadioSet
from fuzzforge_cli.commands.mcp import AIAgent
from fuzzforge_cli.tui.helpers import install_agent_config, uninstall_agent_config
class AgentSetupScreen(ModalScreen[str | None]):
"""Modal for linking a specific agent — only asks for engine choice."""
BINDINGS = [("escape", "cancel", "Cancel")]
def __init__(self, agent: AIAgent, display_name: str) -> None:
super().__init__()
self._agent = agent
self._display_name = display_name
def compose(self) -> ComposeResult:
"""Compose the setup dialog layout."""
with Vertical(id="setup-dialog"):
yield Label(f"Setup {self._display_name}", classes="dialog-title")
yield Label("Container Engine:", classes="field-label")
yield RadioSet(
RadioButton("Docker", value=True),
RadioButton("Podman"),
id="engine-select",
)
with Horizontal(classes="dialog-buttons"):
yield Button("Install", variant="primary", id="btn-install")
yield Button("Cancel", variant="default", id="btn-cancel")
def on_button_pressed(self, event: Button.Pressed) -> None:
"""Handle button clicks."""
if event.button.id == "btn-cancel":
self.dismiss(None)
elif event.button.id == "btn-install":
self._do_install()
def action_cancel(self) -> None:
"""Dismiss the dialog without action."""
self.dismiss(None)
def _do_install(self) -> None:
"""Execute the installation."""
engine_set = self.query_one("#engine-select", RadioSet)
engine = "docker" if engine_set.pressed_index <= 0 else "podman"
result = install_agent_config(self._agent, engine, force=True)
self.dismiss(result)
class AgentUnlinkScreen(ModalScreen[str | None]):
"""Confirmation modal for unlinking a specific agent."""
BINDINGS = [("escape", "cancel", "Cancel")]
def __init__(self, agent: AIAgent, display_name: str) -> None:
super().__init__()
self._agent = agent
self._display_name = display_name
def compose(self) -> ComposeResult:
"""Compose the unlink confirmation layout."""
with Vertical(id="unlink-dialog"):
yield Label(f"Unlink {self._display_name}?", classes="dialog-title")
yield Label(
f"This will remove the FuzzForge MCP configuration from {self._display_name}.",
)
with Horizontal(classes="dialog-buttons"):
yield Button("Unlink", variant="warning", id="btn-unlink")
yield Button("Cancel", variant="default", id="btn-cancel")
def on_button_pressed(self, event: Button.Pressed) -> None:
"""Handle button clicks."""
if event.button.id == "btn-cancel":
self.dismiss(None)
elif event.button.id == "btn-unlink":
result = uninstall_agent_config(self._agent)
self.dismiss(result)
def action_cancel(self) -> None:
"""Dismiss without action."""
self.dismiss(None)

View File

@@ -0,0 +1,299 @@
"""Hub management screens for FuzzForge TUI.
Provides modal dialogs for managing linked MCP hub repositories:
- HubManagerScreen: list, add, remove linked hubs
- LinkHubScreen: link a local directory as a hub
- CloneHubScreen: clone a git repo and link it (defaults to FuzzingLabs hub)
"""
from __future__ import annotations
from pathlib import Path
from rich.text import Text
from textual import work
from textual.app import ComposeResult
from textual.containers import Horizontal, Vertical
from textual.screen import ModalScreen
from textual.widgets import Button, DataTable, Input, Label, Static
from fuzzforge_cli.tui.helpers import (
FUZZFORGE_DEFAULT_HUB_NAME,
FUZZFORGE_DEFAULT_HUB_URL,
clone_hub,
link_hub,
load_hubs_registry,
scan_hub_for_servers,
unlink_hub,
)
class HubManagerScreen(ModalScreen[str | None]):
"""Modal screen for managing linked MCP hubs."""
BINDINGS = [("escape", "cancel", "Close")]
def compose(self) -> ComposeResult:
"""Compose the hub manager layout."""
with Vertical(id="hub-manager-dialog"):
yield Label("Hub Manager", classes="dialog-title")
yield DataTable(id="hubs-table")
yield Label("", id="hub-status")
with Horizontal(classes="dialog-buttons"):
yield Button(
"FuzzingLabs Hub",
variant="primary",
id="btn-clone-default",
)
yield Button("Link Path", variant="default", id="btn-link")
yield Button("Clone URL", variant="default", id="btn-clone")
yield Button("Remove", variant="primary", id="btn-remove")
yield Button("Close", variant="default", id="btn-close")
def on_mount(self) -> None:
"""Populate the hubs table on startup."""
self._refresh_hubs()
def _refresh_hubs(self) -> None:
"""Refresh the linked hubs table."""
table = self.query_one("#hubs-table", DataTable)
table.clear(columns=True)
table.add_columns("Name", "Path", "Servers", "Source")
table.cursor_type = "row"
registry = load_hubs_registry()
hubs = registry.get("hubs", [])
if not hubs:
table.add_row(
Text("No hubs linked", style="dim"),
Text("Press 'FuzzingLabs Hub' to get started", style="dim"),
"",
"",
)
return
for hub in hubs:
name = hub.get("name", "unknown")
path = hub.get("path", "")
git_url = hub.get("git_url", "")
is_default = hub.get("is_default", False)
hub_path = Path(path)
if hub_path.is_dir():
servers = scan_hub_for_servers(hub_path)
count = str(len(servers))
else:
count = Text("dir missing", style="yellow")
source = git_url or "local"
if is_default:
name_cell = Text(f"{name}", style="bold")
else:
name_cell = Text(name)
table.add_row(name_cell, path, count, source)
def on_button_pressed(self, event: Button.Pressed) -> None:
"""Route button actions."""
if event.button.id == "btn-close":
self.dismiss("refreshed")
elif event.button.id == "btn-clone-default":
self.app.push_screen(
CloneHubScreen(
FUZZFORGE_DEFAULT_HUB_URL,
FUZZFORGE_DEFAULT_HUB_NAME,
is_default=True,
),
callback=self._on_hub_action,
)
elif event.button.id == "btn-link":
self.app.push_screen(
LinkHubScreen(),
callback=self._on_hub_action,
)
elif event.button.id == "btn-clone":
self.app.push_screen(
CloneHubScreen(),
callback=self._on_hub_action,
)
elif event.button.id == "btn-remove":
self._remove_selected()
def _on_hub_action(self, result: str | None) -> None:
"""Handle result from a sub-screen."""
if result:
self.query_one("#hub-status", Label).update(result)
self.app.notify(result)
self._refresh_hubs()
def _remove_selected(self) -> None:
"""Remove the currently selected hub."""
table = self.query_one("#hubs-table", DataTable)
registry = load_hubs_registry()
hubs = registry.get("hubs", [])
if not hubs:
self.app.notify("No hubs to remove", severity="warning")
return
idx = table.cursor_row
if idx is None or idx < 0 or idx >= len(hubs):
self.app.notify("Select a hub to remove", severity="warning")
return
name = hubs[idx].get("name", "")
result = unlink_hub(name)
self.query_one("#hub-status", Label).update(result)
self._refresh_hubs()
self.app.notify(result)
def action_cancel(self) -> None:
"""Close the hub manager."""
self.dismiss("refreshed")
class LinkHubScreen(ModalScreen[str | None]):
"""Modal for linking a local directory as an MCP hub."""
BINDINGS = [("escape", "cancel", "Cancel")]
def compose(self) -> ComposeResult:
"""Compose the link dialog layout."""
with Vertical(id="link-dialog"):
yield Label("Link Local Hub", classes="dialog-title")
yield Label("Hub Name:", classes="field-label")
yield Input(placeholder="my-hub", id="name-input")
yield Label("Directory Path:", classes="field-label")
yield Input(placeholder="/path/to/hub-directory", id="path-input")
yield Label("", id="link-status")
with Horizontal(classes="dialog-buttons"):
yield Button("Link", variant="primary", id="btn-link")
yield Button("Cancel", variant="default", id="btn-cancel")
def on_button_pressed(self, event: Button.Pressed) -> None:
"""Handle button clicks."""
if event.button.id == "btn-cancel":
self.dismiss(None)
elif event.button.id == "btn-link":
self._do_link()
def _do_link(self) -> None:
"""Execute the link operation."""
name = self.query_one("#name-input", Input).value.strip()
path = self.query_one("#path-input", Input).value.strip()
if not name:
self.app.notify("Please enter a hub name", severity="warning")
return
if not path:
self.app.notify("Please enter a directory path", severity="warning")
return
result = link_hub(name, path)
self.dismiss(result)
def action_cancel(self) -> None:
"""Dismiss without action."""
self.dismiss(None)
class CloneHubScreen(ModalScreen[str | None]):
"""Modal for cloning a git hub repository and linking it.
When instantiated with *is_default=True* and FuzzingLabs URL,
provides a one-click setup for the standard security hub.
"""
BINDINGS = [("escape", "cancel", "Cancel")]
def __init__(
self,
default_url: str = "",
default_name: str = "",
is_default: bool = False,
) -> None:
super().__init__()
self._default_url = default_url
self._default_name = default_name
self._is_default = is_default
def compose(self) -> ComposeResult:
"""Compose the clone dialog layout."""
title = "Clone FuzzingLabs Hub" if self._is_default else "Clone Git Hub"
with Vertical(id="clone-dialog"):
yield Label(title, classes="dialog-title")
yield Label("Git URL:", classes="field-label")
yield Input(
value=self._default_url,
placeholder="git@github.com:org/repo.git",
id="url-input",
)
yield Label("Hub Name (optional):", classes="field-label")
yield Input(
value=self._default_name,
placeholder="auto-detect from URL",
id="name-input",
)
yield Static("", id="clone-status")
with Horizontal(classes="dialog-buttons"):
yield Button(
"Clone & Link",
variant="primary",
id="btn-clone",
)
yield Button("Cancel", variant="default", id="btn-cancel")
def on_button_pressed(self, event: Button.Pressed) -> None:
"""Handle button clicks."""
if event.button.id == "btn-cancel":
self.dismiss(None)
elif event.button.id == "btn-clone":
self._start_clone()
def _start_clone(self) -> None:
"""Validate input and start the async clone operation."""
url = self.query_one("#url-input", Input).value.strip()
if not url:
self.app.notify("Please enter a git URL", severity="warning")
return
self.query_one("#btn-clone", Button).disabled = True
self.query_one("#clone-status", Static).update("⏳ Cloning repository...")
self._do_clone(url)
@work(thread=True)
def _do_clone(self, url: str) -> None:
"""Clone the repo in a background thread."""
name_input = self.query_one("#name-input", Input).value.strip()
name = name_input or None
success, msg, path = clone_hub(url, name=name)
if success and path:
hub_name = name or path.name
link_result = link_hub(
hub_name,
path,
git_url=url,
is_default=self._is_default,
)
self.app.call_from_thread(self.dismiss, f"{link_result}")
else:
self.app.call_from_thread(self._on_clone_failed, msg)
def _on_clone_failed(self, msg: str) -> None:
"""Handle a failed clone — re-enable the button and show the error."""
self.query_one("#clone-status", Static).update(f"{msg}")
self.query_one("#btn-clone", Button).disabled = False
def action_cancel(self) -> None:
"""Dismiss without action."""
self.dismiss(None)

View File

@@ -6,7 +6,9 @@
"type": "docker",
"image": "nmap-mcp:latest",
"category": "reconnaissance",
"capabilities": ["NET_RAW"],
"capabilities": [
"NET_RAW"
],
"enabled": true
},
{
@@ -16,7 +18,9 @@
"image": "binwalk-mcp:latest",
"category": "binary-analysis",
"capabilities": [],
"volumes": ["~/.fuzzforge/hub/workspace:/data"],
"volumes": [
"~/.fuzzforge/hub/workspace:/data"
],
"enabled": true
},
{
@@ -26,7 +30,9 @@
"image": "yara-mcp:latest",
"category": "binary-analysis",
"capabilities": [],
"volumes": ["~/.fuzzforge/hub/workspace:/data"],
"volumes": [
"~/.fuzzforge/hub/workspace:/data"
],
"enabled": true
},
{
@@ -36,7 +42,9 @@
"image": "capa-mcp:latest",
"category": "binary-analysis",
"capabilities": [],
"volumes": ["~/.fuzzforge/hub/workspace:/data"],
"volumes": [
"~/.fuzzforge/hub/workspace:/data"
],
"enabled": true
},
{
@@ -46,7 +54,9 @@
"image": "radare2-mcp:latest",
"category": "binary-analysis",
"capabilities": [],
"volumes": ["~/.fuzzforge/hub/workspace:/data"],
"volumes": [
"~/.fuzzforge/hub/workspace:/data"
],
"enabled": true
},
{
@@ -56,7 +66,9 @@
"image": "ghcr.io/clearbluejar/pyghidra-mcp:latest",
"category": "binary-analysis",
"capabilities": [],
"volumes": ["~/.fuzzforge/hub/workspace:/data"],
"volumes": [
"~/.fuzzforge/hub/workspace:/data"
],
"enabled": true
},
{
@@ -66,7 +78,9 @@
"image": "searchsploit-mcp:latest",
"category": "exploitation",
"capabilities": [],
"volumes": ["~/.fuzzforge/hub/workspace:/data"],
"volumes": [
"~/.fuzzforge/hub/workspace:/data"
],
"enabled": true
},
{
@@ -75,8 +89,12 @@
"type": "docker",
"image": "nuclei-mcp:latest",
"category": "web-security",
"capabilities": ["NET_RAW"],
"volumes": ["~/.fuzzforge/hub/workspace:/data"],
"capabilities": [
"NET_RAW"
],
"volumes": [
"~/.fuzzforge/hub/workspace:/data"
],
"enabled": true
},
{
@@ -86,7 +104,9 @@
"image": "trivy-mcp:latest",
"category": "cloud-security",
"capabilities": [],
"volumes": ["~/.fuzzforge/hub/workspace:/data"],
"volumes": [
"~/.fuzzforge/hub/workspace:/data"
],
"enabled": true
},
{
@@ -96,10 +116,387 @@
"image": "gitleaks-mcp:latest",
"category": "secrets",
"capabilities": [],
"volumes": ["~/.fuzzforge/hub/workspace:/data"],
"volumes": [
"~/.fuzzforge/hub/workspace:/data"
],
"enabled": true
},
{
"name": "bloodhound-mcp",
"description": "bloodhound-mcp \u2014 active-directory",
"type": "docker",
"image": "bloodhound-mcp:latest",
"category": "active-directory",
"capabilities": [],
"volumes": [
"/home/afredefon/FuzzingLabs/FuzzForge/fuzzforge-oss/.fuzzforge/hub/workspace:/data"
],
"enabled": true,
"source_hub": "mcp-security-hub"
},
{
"name": "ida-mcp",
"description": "ida-mcp \u2014 binary-analysis",
"type": "docker",
"image": "ida-mcp:latest",
"category": "binary-analysis",
"capabilities": [],
"volumes": [
"/home/afredefon/FuzzingLabs/FuzzForge/fuzzforge-oss/.fuzzforge/hub/workspace:/data"
],
"enabled": true,
"source_hub": "mcp-security-hub"
},
{
"name": "daml-viewer-mcp",
"description": "daml-viewer-mcp \u2014 blockchain",
"type": "docker",
"image": "daml-viewer-mcp:latest",
"category": "blockchain",
"capabilities": [],
"volumes": [
"/home/afredefon/FuzzingLabs/FuzzForge/fuzzforge-oss/.fuzzforge/hub/workspace:/data"
],
"enabled": true,
"source_hub": "mcp-security-hub"
},
{
"name": "medusa-mcp",
"description": "medusa-mcp \u2014 blockchain",
"type": "docker",
"image": "medusa-mcp:latest",
"category": "blockchain",
"capabilities": [],
"volumes": [
"/home/afredefon/FuzzingLabs/FuzzForge/fuzzforge-oss/.fuzzforge/hub/workspace:/data"
],
"enabled": true,
"source_hub": "mcp-security-hub"
},
{
"name": "solazy-mcp",
"description": "solazy-mcp \u2014 blockchain",
"type": "docker",
"image": "solazy-mcp:latest",
"category": "blockchain",
"capabilities": [],
"volumes": [
"/home/afredefon/FuzzingLabs/FuzzForge/fuzzforge-oss/.fuzzforge/hub/workspace:/data"
],
"enabled": true,
"source_hub": "mcp-security-hub"
},
{
"name": "prowler-mcp",
"description": "prowler-mcp \u2014 cloud-security",
"type": "docker",
"image": "prowler-mcp:latest",
"category": "cloud-security",
"capabilities": [],
"volumes": [
"/home/afredefon/FuzzingLabs/FuzzForge/fuzzforge-oss/.fuzzforge/hub/workspace:/data"
],
"enabled": true,
"source_hub": "mcp-security-hub"
},
{
"name": "roadrecon-mcp",
"description": "roadrecon-mcp \u2014 cloud-security",
"type": "docker",
"image": "roadrecon-mcp:latest",
"category": "cloud-security",
"capabilities": [],
"volumes": [
"/home/afredefon/FuzzingLabs/FuzzForge/fuzzforge-oss/.fuzzforge/hub/workspace:/data"
],
"enabled": true,
"source_hub": "mcp-security-hub"
},
{
"name": "semgrep-mcp",
"description": "semgrep-mcp \u2014 code-security",
"type": "docker",
"image": "semgrep-mcp:latest",
"category": "code-security",
"capabilities": [],
"volumes": [
"/home/afredefon/FuzzingLabs/FuzzForge/fuzzforge-oss/.fuzzforge/hub/workspace:/data"
],
"enabled": true,
"source_hub": "mcp-security-hub"
},
{
"name": "boofuzz-mcp",
"description": "boofuzz-mcp \u2014 fuzzing",
"type": "docker",
"image": "boofuzz-mcp:latest",
"category": "fuzzing",
"capabilities": [],
"volumes": [
"/home/afredefon/FuzzingLabs/FuzzForge/fuzzforge-oss/.fuzzforge/hub/workspace:/data"
],
"enabled": true,
"source_hub": "mcp-security-hub"
},
{
"name": "dharma-mcp",
"description": "dharma-mcp \u2014 fuzzing",
"type": "docker",
"image": "dharma-mcp:latest",
"category": "fuzzing",
"capabilities": [],
"volumes": [
"/home/afredefon/FuzzingLabs/FuzzForge/fuzzforge-oss/.fuzzforge/hub/workspace:/data"
],
"enabled": true,
"source_hub": "mcp-security-hub"
},
{
"name": "dnstwist-mcp",
"description": "dnstwist-mcp \u2014 osint",
"type": "docker",
"image": "dnstwist-mcp:latest",
"category": "osint",
"capabilities": [],
"volumes": [
"/home/afredefon/FuzzingLabs/FuzzForge/fuzzforge-oss/.fuzzforge/hub/workspace:/data"
],
"enabled": true,
"source_hub": "mcp-security-hub"
},
{
"name": "maigret-mcp",
"description": "maigret-mcp \u2014 osint",
"type": "docker",
"image": "maigret-mcp:latest",
"category": "osint",
"capabilities": [],
"volumes": [
"/home/afredefon/FuzzingLabs/FuzzForge/fuzzforge-oss/.fuzzforge/hub/workspace:/data"
],
"enabled": true,
"source_hub": "mcp-security-hub"
},
{
"name": "hashcat-mcp",
"description": "hashcat-mcp \u2014 password-cracking",
"type": "docker",
"image": "hashcat-mcp:latest",
"category": "password-cracking",
"capabilities": [],
"volumes": [
"/home/afredefon/FuzzingLabs/FuzzForge/fuzzforge-oss/.fuzzforge/hub/workspace:/data"
],
"enabled": true,
"source_hub": "mcp-security-hub"
},
{
"name": "externalattacker-mcp",
"description": "externalattacker-mcp \u2014 reconnaissance",
"type": "docker",
"image": "externalattacker-mcp:latest",
"category": "reconnaissance",
"capabilities": [
"NET_RAW"
],
"volumes": [
"/home/afredefon/FuzzingLabs/FuzzForge/fuzzforge-oss/.fuzzforge/hub/workspace:/data"
],
"enabled": true,
"source_hub": "mcp-security-hub"
},
{
"name": "masscan-mcp",
"description": "masscan-mcp \u2014 reconnaissance",
"type": "docker",
"image": "masscan-mcp:latest",
"category": "reconnaissance",
"capabilities": [
"NET_RAW"
],
"volumes": [
"/home/afredefon/FuzzingLabs/FuzzForge/fuzzforge-oss/.fuzzforge/hub/workspace:/data"
],
"enabled": true,
"source_hub": "mcp-security-hub"
},
{
"name": "networksdb-mcp",
"description": "networksdb-mcp \u2014 reconnaissance",
"type": "docker",
"image": "networksdb-mcp:latest",
"category": "reconnaissance",
"capabilities": [
"NET_RAW"
],
"volumes": [
"/home/afredefon/FuzzingLabs/FuzzForge/fuzzforge-oss/.fuzzforge/hub/workspace:/data"
],
"enabled": true,
"source_hub": "mcp-security-hub"
},
{
"name": "pd-tools-mcp",
"description": "pd-tools-mcp \u2014 reconnaissance",
"type": "docker",
"image": "pd-tools-mcp:latest",
"category": "reconnaissance",
"capabilities": [
"NET_RAW"
],
"volumes": [
"/home/afredefon/FuzzingLabs/FuzzForge/fuzzforge-oss/.fuzzforge/hub/workspace:/data"
],
"enabled": true,
"source_hub": "mcp-security-hub"
},
{
"name": "shodan-mcp",
"description": "shodan-mcp \u2014 reconnaissance",
"type": "docker",
"image": "shodan-mcp:latest",
"category": "reconnaissance",
"capabilities": [
"NET_RAW"
],
"volumes": [
"/home/afredefon/FuzzingLabs/FuzzForge/fuzzforge-oss/.fuzzforge/hub/workspace:/data"
],
"enabled": true,
"source_hub": "mcp-security-hub"
},
{
"name": "whatweb-mcp",
"description": "whatweb-mcp \u2014 reconnaissance",
"type": "docker",
"image": "whatweb-mcp:latest",
"category": "reconnaissance",
"capabilities": [
"NET_RAW"
],
"volumes": [
"/home/afredefon/FuzzingLabs/FuzzForge/fuzzforge-oss/.fuzzforge/hub/workspace:/data"
],
"enabled": true,
"source_hub": "mcp-security-hub"
},
{
"name": "zoomeye-mcp",
"description": "zoomeye-mcp \u2014 reconnaissance",
"type": "docker",
"image": "zoomeye-mcp:latest",
"category": "reconnaissance",
"capabilities": [
"NET_RAW"
],
"volumes": [
"/home/afredefon/FuzzingLabs/FuzzForge/fuzzforge-oss/.fuzzforge/hub/workspace:/data"
],
"enabled": true,
"source_hub": "mcp-security-hub"
},
{
"name": "otx-mcp",
"description": "otx-mcp \u2014 threat-intel",
"type": "docker",
"image": "otx-mcp:latest",
"category": "threat-intel",
"capabilities": [],
"volumes": [
"/home/afredefon/FuzzingLabs/FuzzForge/fuzzforge-oss/.fuzzforge/hub/workspace:/data"
],
"enabled": true,
"source_hub": "mcp-security-hub"
},
{
"name": "virustotal-mcp",
"description": "virustotal-mcp \u2014 threat-intel",
"type": "docker",
"image": "virustotal-mcp:latest",
"category": "threat-intel",
"capabilities": [],
"volumes": [
"/home/afredefon/FuzzingLabs/FuzzForge/fuzzforge-oss/.fuzzforge/hub/workspace:/data"
],
"enabled": true,
"source_hub": "mcp-security-hub"
},
{
"name": "burp-mcp",
"description": "burp-mcp \u2014 web-security",
"type": "docker",
"image": "burp-mcp:latest",
"category": "web-security",
"capabilities": [
"NET_RAW"
],
"volumes": [
"/home/afredefon/FuzzingLabs/FuzzForge/fuzzforge-oss/.fuzzforge/hub/workspace:/data"
],
"enabled": true,
"source_hub": "mcp-security-hub"
},
{
"name": "ffuf-mcp",
"description": "ffuf-mcp \u2014 web-security",
"type": "docker",
"image": "ffuf-mcp:latest",
"category": "web-security",
"capabilities": [
"NET_RAW"
],
"volumes": [
"/home/afredefon/FuzzingLabs/FuzzForge/fuzzforge-oss/.fuzzforge/hub/workspace:/data"
],
"enabled": true,
"source_hub": "mcp-security-hub"
},
{
"name": "nikto-mcp",
"description": "nikto-mcp \u2014 web-security",
"type": "docker",
"image": "nikto-mcp:latest",
"category": "web-security",
"capabilities": [
"NET_RAW"
],
"volumes": [
"/home/afredefon/FuzzingLabs/FuzzForge/fuzzforge-oss/.fuzzforge/hub/workspace:/data"
],
"enabled": true,
"source_hub": "mcp-security-hub"
},
{
"name": "sqlmap-mcp",
"description": "sqlmap-mcp \u2014 web-security",
"type": "docker",
"image": "sqlmap-mcp:latest",
"category": "web-security",
"capabilities": [
"NET_RAW"
],
"volumes": [
"/home/afredefon/FuzzingLabs/FuzzForge/fuzzforge-oss/.fuzzforge/hub/workspace:/data"
],
"enabled": true,
"source_hub": "mcp-security-hub"
},
{
"name": "waybackurls-mcp",
"description": "waybackurls-mcp \u2014 web-security",
"type": "docker",
"image": "waybackurls-mcp:latest",
"category": "web-security",
"capabilities": [
"NET_RAW"
],
"volumes": [
"/home/afredefon/FuzzingLabs/FuzzForge/fuzzforge-oss/.fuzzforge/hub/workspace:/data"
],
"enabled": true,
"source_hub": "mcp-security-hub"
}
],
"default_timeout": 300,
"cache_tools": true
}
}

1639
uv.lock generated Normal file

File diff suppressed because it is too large Load Diff