mirror of
https://github.com/FuzzingLabs/fuzzforge_ai.git
synced 2026-03-14 01:26:01 +00:00
Compare commits
5 Commits
fix/module
...
refactor/r
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1d495cedce | ||
|
|
075b678e9d | ||
|
|
6cd8fd3cf5 | ||
|
|
f3899279d5 | ||
|
|
04c8383739 |
3
.gitignore
vendored
3
.gitignore
vendored
@@ -8,8 +8,5 @@
|
||||
.vscode
|
||||
__pycache__
|
||||
|
||||
# Build artifacts
|
||||
**/build/
|
||||
|
||||
# Podman/Docker container storage artifacts
|
||||
~/.fuzzforge/
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# Contributing to FuzzForge OSS
|
||||
# Contributing to FuzzForge AI
|
||||
|
||||
Thank you for your interest in contributing to FuzzForge OSS! We welcome contributions from the community and are excited to collaborate with you.
|
||||
Thank you for your interest in contributing to FuzzForge AI! We welcome contributions from the community and are excited to collaborate with you.
|
||||
|
||||
**Our Vision**: FuzzForge aims to be a **universal platform for security research** across all cybersecurity domains. Through our modular architecture, any security tool—from fuzzing engines to cloud scanners, from mobile app analyzers to IoT security tools—can be integrated as a containerized module and controlled via AI agents.
|
||||
|
||||
@@ -360,8 +360,8 @@ Beyond modules, you can contribute to FuzzForge's core components.
|
||||
|
||||
1. **Clone and Install**
|
||||
```bash
|
||||
git clone https://github.com/FuzzingLabs/fuzzforge-oss.git
|
||||
cd fuzzforge-oss
|
||||
git clone https://github.com/FuzzingLabs/fuzzforge_ai.git
|
||||
cd fuzzforge_ai
|
||||
uv sync --all-extras
|
||||
```
|
||||
|
||||
@@ -538,7 +538,7 @@ Before submitting a new module:
|
||||
|
||||
## License
|
||||
|
||||
By contributing to FuzzForge OSS, you agree that your contributions will be licensed under the same license as the project (see [LICENSE](LICENSE)).
|
||||
By contributing to FuzzForge AI, you agree that your contributions will be licensed under the same license as the project (see [LICENSE](LICENSE)).
|
||||
|
||||
For module contributions:
|
||||
- Modules you create remain under the project license
|
||||
|
||||
60
Makefile
60
Makefile
@@ -1,17 +1,10 @@
|
||||
.PHONY: help install sync format lint typecheck test build-modules build-modules-sdk build-modules-images clean
|
||||
.PHONY: help install sync format lint typecheck test build-hub-images clean
|
||||
|
||||
SHELL := /bin/bash
|
||||
|
||||
# Container image configuration
|
||||
SDK_DIR := fuzzforge-modules/fuzzforge-modules-sdk/
|
||||
MODULE_TEMPLATE := fuzzforge-modules/fuzzforge-module-template/
|
||||
SDK_VERSION := $(shell scripts/pyproject-version.sh $(SDK_DIR)pyproject.toml)
|
||||
BASE_IMG_PREFIX := $(if $(filter podman,$(FUZZFORGE_ENGINE)),localhost/,)
|
||||
SDK_IMG := $(BASE_IMG_PREFIX)fuzzforge-modules-sdk:$(SDK_VERSION)
|
||||
|
||||
# Default target
|
||||
help:
|
||||
@echo "FuzzForge OSS Development Commands"
|
||||
@echo "FuzzForge AI Development Commands"
|
||||
@echo ""
|
||||
@echo " make install - Install all dependencies"
|
||||
@echo " make sync - Sync shared packages from upstream"
|
||||
@@ -19,10 +12,8 @@ help:
|
||||
@echo " make lint - Lint code with ruff"
|
||||
@echo " make typecheck - Type check with mypy"
|
||||
@echo " make test - Run all tests"
|
||||
@echo " make build-modules - Build all module container images"
|
||||
@echo " make build-modules-sdk - Build the SDK base image"
|
||||
@echo " make build-modules-images - Build all module images (requires SDK image)"
|
||||
@echo " make clean - Clean build artifacts"
|
||||
@echo " make build-hub-images - Build all mcp-security-hub images"
|
||||
@echo " make clean - Clean build artifacts"
|
||||
@echo ""
|
||||
|
||||
# Install all dependencies
|
||||
@@ -73,40 +64,15 @@ test:
|
||||
fi \
|
||||
done
|
||||
|
||||
# Build all module container images (SDK first, then modules)
|
||||
# Uses Docker by default, or Podman if FUZZFORGE_ENGINE=podman
|
||||
build-modules: build-modules-sdk build-modules-images
|
||||
@echo ""
|
||||
@echo "✓ All modules built successfully!"
|
||||
|
||||
# Build the SDK base image (also builds its Python wheel)
|
||||
build-modules-sdk:
|
||||
@source scripts/container-env.sh; \
|
||||
echo "Building wheels for fuzzforge-modules-sdk..."; \
|
||||
(cd "$(SDK_DIR)" && uv build --wheel --out-dir .wheels) || exit 1; \
|
||||
echo "Building $(SDK_IMG)..."; \
|
||||
$$CONTAINER_CMD build -t "$(SDK_IMG)" "$(SDK_DIR)" || exit 1
|
||||
|
||||
# Build all module images (requires SDK image to exist)
|
||||
build-modules-images:
|
||||
@source scripts/container-env.sh; \
|
||||
for module in fuzzforge-modules/*/; do \
|
||||
[ "$$module" = "$(SDK_DIR)" ] && continue; \
|
||||
[ "$$module" = "$(MODULE_TEMPLATE)" ] && continue; \
|
||||
[ -f "$$module/Dockerfile" ] || continue; \
|
||||
name=$$(basename "$$module"); \
|
||||
version=$$(scripts/pyproject-version.sh "$$module/pyproject.toml"); \
|
||||
case $$name in \
|
||||
fuzzforge-*) tag="$$name:$$version" ;; \
|
||||
*) tag="fuzzforge-$$name:$$version" ;; \
|
||||
esac; \
|
||||
echo "Building $$tag..."; \
|
||||
$$CONTAINER_CMD build --build-arg BASE_IMAGE="$(SDK_IMG)" -t "$$tag" "$$module" || exit 1; \
|
||||
done
|
||||
# Build all mcp-security-hub images for the firmware analysis pipeline
|
||||
build-hub-images:
|
||||
@bash scripts/build-hub-images.sh
|
||||
|
||||
# Clean build artifacts
|
||||
clean:
|
||||
@for dir in __pycache__ .pytest_cache .mypy_cache .ruff_cache "*.egg-info"; do \
|
||||
find . -type d -name "$$dir" -exec rm -rf {} + 2>/dev/null || true; \
|
||||
done
|
||||
@find . -type f -name "*.pyc" -delete 2>/dev/null || true
|
||||
find . -type d -name "__pycache__" -exec rm -rf {} + 2>/dev/null || true
|
||||
find . -type d -name ".pytest_cache" -exec rm -rf {} + 2>/dev/null || true
|
||||
find . -type d -name ".mypy_cache" -exec rm -rf {} + 2>/dev/null || true
|
||||
find . -type d -name ".ruff_cache" -exec rm -rf {} + 2>/dev/null || true
|
||||
find . -type d -name "*.egg-info" -exec rm -rf {} + 2>/dev/null || true
|
||||
find . -type f -name "*.pyc" -delete 2>/dev/null || true
|
||||
|
||||
14
README.md
14
README.md
@@ -1,4 +1,4 @@
|
||||
<h1 align="center"> FuzzForge OSS</h1>
|
||||
<h1 align="center"> FuzzForge AI</h1>
|
||||
<h3 align="center">AI-Powered Security Research Orchestration via MCP</h3>
|
||||
|
||||
<p align="center">
|
||||
@@ -26,13 +26,13 @@
|
||||
|
||||
---
|
||||
|
||||
> 🚧 **FuzzForge OSS is under active development.** Expect breaking changes and new features!
|
||||
> 🚧 **FuzzForge AI is under active development.** Expect breaking changes and new features!
|
||||
|
||||
---
|
||||
|
||||
## 🚀 Overview
|
||||
|
||||
**FuzzForge OSS** is an open-source runtime that enables AI agents (GitHub Copilot, Claude, etc.) to orchestrate security research workflows through the **Model Context Protocol (MCP)**.
|
||||
**FuzzForge AI** is an open-source runtime that enables AI agents (GitHub Copilot, Claude, etc.) to orchestrate security research workflows through the **Model Context Protocol (MCP)**.
|
||||
|
||||
### The Core: Modules
|
||||
|
||||
@@ -43,7 +43,7 @@ At the heart of FuzzForge are **modules** - containerized security tools that AI
|
||||
- **🔗 Composable**: Chain modules together into automated workflows
|
||||
- **📦 Extensible**: Build custom modules with the Python SDK
|
||||
|
||||
The OSS runtime handles module discovery, execution, and result collection. Security modules (developed separately) provide the actual security tooling - from static analyzers to fuzzers to crash triagers.
|
||||
FuzzForge AI handles module discovery, execution, and result collection. Security modules (developed separately) provide the actual security tooling - from static analyzers to fuzzers to crash triagers.
|
||||
|
||||
Instead of manually running security tools, describe what you want and let your AI assistant handle it.
|
||||
|
||||
@@ -171,11 +171,11 @@ FuzzForge modules are containerized security tools that AI agents can orchestrat
|
||||
|
||||
### Module Ecosystem
|
||||
|
||||
| | FuzzForge OSS | FuzzForge Enterprise Modules |
|
||||
| | FuzzForge AI | FuzzForge Enterprise Modules |
|
||||
|---|---|---|
|
||||
| **What** | Runtime & MCP server | Security research modules |
|
||||
| **License** | Apache 2.0 | BSL 1.1 (Business Source License) |
|
||||
| **Compatibility** | ✅ Runs any compatible module | ✅ Works with OSS runtime |
|
||||
| **Compatibility** | ✅ Runs any compatible module | ✅ Works with FuzzForge AI |
|
||||
|
||||
**Enterprise modules** are developed separately and provide production-ready security tooling:
|
||||
|
||||
@@ -187,7 +187,7 @@ FuzzForge modules are containerized security tools that AI agents can orchestrat
|
||||
| 🔐 **Vulnerability Detection** | Pattern Matcher, Taint Analyzer | Security vulnerability scanning |
|
||||
| 📝 **Reporting** | Report Generator, SARIF Exporter | Automated security report generation |
|
||||
|
||||
> 💡 **Build your own modules!** The FuzzForge SDK allows you to create custom modules that integrate seamlessly with the OSS runtime. See [Creating Custom Modules](#-creating-custom-modules).
|
||||
> 💡 **Build your own modules!** The FuzzForge SDK allows you to create custom modules that integrate seamlessly with FuzzForge AI. See [Creating Custom Modules](#-creating-custom-modules).
|
||||
|
||||
### Execution Modes
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# FuzzForge OSS Roadmap
|
||||
# FuzzForge AI Roadmap
|
||||
|
||||
This document outlines the planned features and development direction for FuzzForge OSS.
|
||||
This document outlines the planned features and development direction for FuzzForge AI.
|
||||
|
||||
---
|
||||
|
||||
|
||||
18
USAGE.md
18
USAGE.md
@@ -1,6 +1,6 @@
|
||||
# FuzzForge OSS Usage Guide
|
||||
# FuzzForge AI Usage Guide
|
||||
|
||||
This guide covers everything you need to know to get started with FuzzForge OSS - 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 running your first security research workflow with AI.
|
||||
|
||||
> **FuzzForge is designed to be used with AI agents** (GitHub Copilot, Claude, etc.) via MCP.
|
||||
> The CLI is available for advanced users but the primary experience is through natural language interaction with your AI assistant.
|
||||
@@ -31,8 +31,8 @@ This guide covers everything you need to know to get started with FuzzForge OSS
|
||||
|
||||
```bash
|
||||
# 1. Clone and install
|
||||
git clone https://github.com/FuzzingLabs/fuzzforge-oss.git
|
||||
cd fuzzforge-oss
|
||||
git clone https://github.com/FuzzingLabs/fuzzforge_ai.git
|
||||
cd fuzzforge_ai
|
||||
uv sync
|
||||
|
||||
# 2. Build the module images (one-time setup)
|
||||
@@ -57,7 +57,7 @@ uv run fuzzforge mcp install claude-code # For Claude Code CLI
|
||||
|
||||
## Prerequisites
|
||||
|
||||
Before installing FuzzForge OSS, ensure you have:
|
||||
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/)
|
||||
@@ -95,8 +95,8 @@ sudo usermod -aG docker $USER
|
||||
### 1. Clone the Repository
|
||||
|
||||
```bash
|
||||
git clone https://github.com/FuzzingLabs/fuzzforge-oss.git
|
||||
cd fuzzforge-oss
|
||||
git clone https://github.com/FuzzingLabs/fuzzforge_ai.git
|
||||
cd fuzzforge_ai
|
||||
```
|
||||
|
||||
### 2. Install Dependencies
|
||||
@@ -122,7 +122,7 @@ FuzzForge modules are containerized security tools. After cloning, you need to b
|
||||
### Build All Modules
|
||||
|
||||
```bash
|
||||
# From the fuzzforge-oss directory
|
||||
# From the fuzzforge_ai directory
|
||||
make build-modules
|
||||
```
|
||||
|
||||
@@ -169,7 +169,7 @@ uv run fuzzforge mcp install copilot
|
||||
|
||||
The command auto-detects everything:
|
||||
- **FuzzForge root** - Where FuzzForge is installed
|
||||
- **Modules path** - Defaults to `fuzzforge-oss/fuzzforge-modules`
|
||||
- **Modules path** - Defaults to `fuzzforge_ai/fuzzforge-modules`
|
||||
- **Docker socket** - Auto-detects `/var/run/docker.sock`
|
||||
|
||||
**Optional overrides** (usually not needed):
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
[project]
|
||||
name = "fuzzforge-cli"
|
||||
version = "0.0.1"
|
||||
description = "FuzzForge CLI - Command-line interface for FuzzForge OSS."
|
||||
description = "FuzzForge CLI - Command-line interface for FuzzForge AI."
|
||||
authors = []
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.14"
|
||||
dependencies = [
|
||||
"fuzzforge-runner==0.0.1",
|
||||
"fuzzforge-mcp==0.0.1",
|
||||
"rich>=14.0.0",
|
||||
"typer==0.20.1",
|
||||
]
|
||||
@@ -25,4 +25,4 @@ tests = [
|
||||
fuzzforge = "fuzzforge_cli.__main__:main"
|
||||
|
||||
[tool.uv.sources]
|
||||
fuzzforge-runner = { workspace = true }
|
||||
fuzzforge-mcp = { workspace = true }
|
||||
|
||||
@@ -3,16 +3,16 @@
|
||||
from pathlib import Path
|
||||
from typing import Annotated
|
||||
|
||||
from fuzzforge_runner import Runner, Settings
|
||||
from typer import Context as TyperContext
|
||||
from typer import Option, Typer
|
||||
|
||||
from fuzzforge_cli.commands import mcp, modules, projects
|
||||
from fuzzforge_cli.commands import mcp, projects
|
||||
from fuzzforge_cli.context import Context
|
||||
from fuzzforge_mcp.storage import LocalStorage
|
||||
|
||||
application: Typer = Typer(
|
||||
name="fuzzforge",
|
||||
help="FuzzForge OSS - Security research orchestration platform.",
|
||||
help="FuzzForge AI - Security research orchestration platform.",
|
||||
)
|
||||
|
||||
|
||||
@@ -27,15 +27,6 @@ def main(
|
||||
help="Path to the FuzzForge project directory.",
|
||||
),
|
||||
] = Path.cwd(),
|
||||
modules_path: Annotated[
|
||||
Path,
|
||||
Option(
|
||||
"--modules",
|
||||
"-m",
|
||||
envvar="FUZZFORGE_MODULES_PATH",
|
||||
help="Path to the modules directory.",
|
||||
),
|
||||
] = Path.home() / ".fuzzforge" / "modules",
|
||||
storage_path: Annotated[
|
||||
Path,
|
||||
Option(
|
||||
@@ -44,53 +35,20 @@ def main(
|
||||
help="Path to the storage directory.",
|
||||
),
|
||||
] = Path.home() / ".fuzzforge" / "storage",
|
||||
engine_type: Annotated[
|
||||
str,
|
||||
Option(
|
||||
"--engine",
|
||||
envvar="FUZZFORGE_ENGINE__TYPE",
|
||||
help="Container engine type (docker or podman).",
|
||||
),
|
||||
] = "docker",
|
||||
engine_socket: Annotated[
|
||||
str,
|
||||
Option(
|
||||
"--socket",
|
||||
envvar="FUZZFORGE_ENGINE__SOCKET",
|
||||
help="Container engine socket path.",
|
||||
),
|
||||
] = "",
|
||||
context: TyperContext = None, # type: ignore[assignment]
|
||||
) -> None:
|
||||
"""FuzzForge OSS - Security research orchestration platform.
|
||||
"""FuzzForge AI - Security research orchestration platform.
|
||||
|
||||
Execute security research modules in isolated containers.
|
||||
Discover and execute MCP hub tools for security research.
|
||||
|
||||
"""
|
||||
from fuzzforge_runner.settings import EngineSettings, ProjectSettings, StorageSettings
|
||||
|
||||
settings = Settings(
|
||||
engine=EngineSettings(
|
||||
type=engine_type, # type: ignore[arg-type]
|
||||
socket=engine_socket,
|
||||
),
|
||||
storage=StorageSettings(
|
||||
path=storage_path,
|
||||
),
|
||||
project=ProjectSettings(
|
||||
default_path=project_path,
|
||||
modules_path=modules_path,
|
||||
),
|
||||
)
|
||||
|
||||
runner = Runner(settings)
|
||||
storage = LocalStorage(storage_path=storage_path)
|
||||
|
||||
context.obj = Context(
|
||||
runner=runner,
|
||||
storage=storage,
|
||||
project_path=project_path,
|
||||
)
|
||||
|
||||
|
||||
application.add_typer(mcp.application)
|
||||
application.add_typer(modules.application)
|
||||
application.add_typer(projects.application)
|
||||
|
||||
@@ -137,7 +137,7 @@ def _find_fuzzforge_root() -> Path:
|
||||
|
||||
# Walk up to find fuzzforge-oss root
|
||||
for parent in current.parents:
|
||||
if (parent / "fuzzforge-mcp").is_dir() and (parent / "fuzzforge-runner").is_dir():
|
||||
if (parent / "fuzzforge-mcp").is_dir():
|
||||
return parent
|
||||
|
||||
# Fall back to cwd
|
||||
@@ -146,14 +146,12 @@ def _find_fuzzforge_root() -> Path:
|
||||
|
||||
def _generate_mcp_config(
|
||||
fuzzforge_root: Path,
|
||||
modules_path: Path,
|
||||
engine_type: str,
|
||||
engine_socket: str,
|
||||
) -> dict:
|
||||
"""Generate MCP server configuration.
|
||||
|
||||
:param fuzzforge_root: Path to fuzzforge-oss installation.
|
||||
:param modules_path: Path to the modules directory.
|
||||
:param engine_type: Container engine type (podman or docker).
|
||||
:param engine_socket: Container engine socket path.
|
||||
:returns: MCP configuration dictionary.
|
||||
@@ -181,10 +179,11 @@ def _generate_mcp_config(
|
||||
"args": args,
|
||||
"cwd": str(fuzzforge_root),
|
||||
"env": {
|
||||
"FUZZFORGE_MODULES_PATH": str(modules_path),
|
||||
"FUZZFORGE_ENGINE__TYPE": engine_type,
|
||||
"FUZZFORGE_ENGINE__GRAPHROOT": str(graphroot),
|
||||
"FUZZFORGE_ENGINE__RUNROOT": str(runroot),
|
||||
"FUZZFORGE_HUB__ENABLED": "true",
|
||||
"FUZZFORGE_HUB__CONFIG_PATH": str(fuzzforge_root / "hub-config.json"),
|
||||
},
|
||||
}
|
||||
|
||||
@@ -264,14 +263,6 @@ def generate(
|
||||
help="AI agent to generate config for (copilot, claude-desktop, or claude-code).",
|
||||
),
|
||||
],
|
||||
modules_path: Annotated[
|
||||
Path | None,
|
||||
Option(
|
||||
"--modules",
|
||||
"-m",
|
||||
help="Path to the modules directory.",
|
||||
),
|
||||
] = None,
|
||||
engine: Annotated[
|
||||
str,
|
||||
Option(
|
||||
@@ -285,16 +276,12 @@ def generate(
|
||||
|
||||
:param context: Typer context.
|
||||
:param agent: Target AI agent.
|
||||
:param modules_path: Override modules path.
|
||||
:param engine: Container engine type.
|
||||
|
||||
"""
|
||||
console = Console()
|
||||
fuzzforge_root = _find_fuzzforge_root()
|
||||
|
||||
# Use defaults if not specified
|
||||
resolved_modules = modules_path or (fuzzforge_root / "fuzzforge-modules")
|
||||
|
||||
# Detect socket
|
||||
if engine == "podman":
|
||||
socket = _detect_podman_socket()
|
||||
@@ -304,7 +291,6 @@ def generate(
|
||||
# Generate config
|
||||
server_config = _generate_mcp_config(
|
||||
fuzzforge_root=fuzzforge_root,
|
||||
modules_path=resolved_modules,
|
||||
engine_type=engine,
|
||||
engine_socket=socket,
|
||||
)
|
||||
@@ -348,14 +334,6 @@ def install(
|
||||
help="AI agent to install config for (copilot, claude-desktop, or claude-code).",
|
||||
),
|
||||
],
|
||||
modules_path: Annotated[
|
||||
Path | None,
|
||||
Option(
|
||||
"--modules",
|
||||
"-m",
|
||||
help="Path to the modules directory.",
|
||||
),
|
||||
] = None,
|
||||
engine: Annotated[
|
||||
str,
|
||||
Option(
|
||||
@@ -380,7 +358,6 @@ def install(
|
||||
|
||||
:param context: Typer context.
|
||||
:param agent: Target AI agent.
|
||||
:param modules_path: Override modules path.
|
||||
:param engine: Container engine type.
|
||||
:param force: Overwrite existing configuration.
|
||||
|
||||
@@ -399,9 +376,6 @@ def install(
|
||||
config_path = _get_claude_desktop_mcp_path()
|
||||
servers_key = "mcpServers"
|
||||
|
||||
# Use defaults if not specified
|
||||
resolved_modules = modules_path or (fuzzforge_root / "fuzzforge-modules")
|
||||
|
||||
# Detect socket
|
||||
if engine == "podman":
|
||||
socket = _detect_podman_socket()
|
||||
@@ -411,7 +385,6 @@ def install(
|
||||
# Generate server config
|
||||
server_config = _generate_mcp_config(
|
||||
fuzzforge_root=fuzzforge_root,
|
||||
modules_path=resolved_modules,
|
||||
engine_type=engine,
|
||||
engine_socket=socket,
|
||||
)
|
||||
@@ -451,9 +424,9 @@ def install(
|
||||
console.print(f"[bold]Configuration file:[/bold] {config_path}")
|
||||
console.print()
|
||||
console.print("[bold]Settings:[/bold]")
|
||||
console.print(f" Modules Path: {resolved_modules}")
|
||||
console.print(f" Engine: {engine}")
|
||||
console.print(f" Socket: {socket}")
|
||||
console.print(f" Hub Config: {fuzzforge_root / 'hub-config.json'}")
|
||||
console.print()
|
||||
|
||||
console.print("[bold]Next steps:[/bold]")
|
||||
|
||||
@@ -1,166 +0,0 @@
|
||||
"""Module management commands for FuzzForge CLI."""
|
||||
|
||||
import asyncio
|
||||
from pathlib import Path
|
||||
from typing import Annotated, Any
|
||||
|
||||
from rich.console import Console
|
||||
from rich.table import Table
|
||||
from typer import Argument, Context, Option, Typer
|
||||
|
||||
from fuzzforge_cli.context import get_project_path, get_runner
|
||||
|
||||
application: Typer = Typer(
|
||||
name="modules",
|
||||
help="Module management commands.",
|
||||
)
|
||||
|
||||
|
||||
@application.command(
|
||||
help="List available modules.",
|
||||
name="list",
|
||||
)
|
||||
def list_modules(
|
||||
context: Context,
|
||||
) -> None:
|
||||
"""List all available modules.
|
||||
|
||||
:param context: Typer context.
|
||||
|
||||
"""
|
||||
runner = get_runner(context)
|
||||
modules = runner.list_modules()
|
||||
|
||||
console = Console()
|
||||
|
||||
if not modules:
|
||||
console.print("[yellow]No modules found.[/yellow]")
|
||||
console.print(f" Modules directory: {runner.settings.modules_path}")
|
||||
return
|
||||
|
||||
table = Table(title="Available Modules")
|
||||
table.add_column("Identifier", style="cyan")
|
||||
table.add_column("Available")
|
||||
table.add_column("Description")
|
||||
|
||||
for module in modules:
|
||||
table.add_row(
|
||||
module.identifier,
|
||||
"✓" if module.available else "✗",
|
||||
module.description or "-",
|
||||
)
|
||||
|
||||
console.print(table)
|
||||
|
||||
|
||||
@application.command(
|
||||
help="Execute a module.",
|
||||
name="run",
|
||||
)
|
||||
def run_module(
|
||||
context: Context,
|
||||
module_identifier: Annotated[
|
||||
str,
|
||||
Argument(
|
||||
help="Identifier of the module to execute.",
|
||||
),
|
||||
],
|
||||
assets_path: Annotated[
|
||||
Path | None,
|
||||
Option(
|
||||
"--assets",
|
||||
"-a",
|
||||
help="Path to input assets.",
|
||||
),
|
||||
] = None,
|
||||
config: Annotated[
|
||||
str | None,
|
||||
Option(
|
||||
"--config",
|
||||
"-c",
|
||||
help="Module configuration as JSON string.",
|
||||
),
|
||||
] = None,
|
||||
) -> None:
|
||||
"""Execute a module.
|
||||
|
||||
:param context: Typer context.
|
||||
:param module_identifier: Module to execute.
|
||||
:param assets_path: Optional path to input assets.
|
||||
:param config: Optional JSON configuration.
|
||||
|
||||
"""
|
||||
import json
|
||||
|
||||
runner = get_runner(context)
|
||||
project_path = get_project_path(context)
|
||||
|
||||
configuration: dict[str, Any] | None = None
|
||||
if config:
|
||||
try:
|
||||
configuration = json.loads(config)
|
||||
except json.JSONDecodeError as e:
|
||||
console = Console()
|
||||
console.print(f"[red]✗[/red] Invalid JSON configuration: {e}")
|
||||
return
|
||||
|
||||
console = Console()
|
||||
console.print(f"[blue]→[/blue] Executing module: {module_identifier}")
|
||||
|
||||
async def execute() -> None:
|
||||
result = await runner.execute_module(
|
||||
module_identifier=module_identifier,
|
||||
project_path=project_path,
|
||||
configuration=configuration,
|
||||
assets_path=assets_path,
|
||||
)
|
||||
|
||||
if result.success:
|
||||
console.print(f"[green]✓[/green] Module execution completed")
|
||||
console.print(f" Execution ID: {result.execution_id}")
|
||||
console.print(f" Results: {result.results_path}")
|
||||
else:
|
||||
console.print(f"[red]✗[/red] Module execution failed")
|
||||
console.print(f" Error: {result.error}")
|
||||
|
||||
asyncio.run(execute())
|
||||
|
||||
|
||||
@application.command(
|
||||
help="Show module information.",
|
||||
name="info",
|
||||
)
|
||||
def module_info(
|
||||
context: Context,
|
||||
module_identifier: Annotated[
|
||||
str,
|
||||
Argument(
|
||||
help="Identifier of the module.",
|
||||
),
|
||||
],
|
||||
) -> None:
|
||||
"""Show information about a specific module.
|
||||
|
||||
:param context: Typer context.
|
||||
:param module_identifier: Module to get info for.
|
||||
|
||||
"""
|
||||
runner = get_runner(context)
|
||||
module = runner.get_module_info(module_identifier)
|
||||
|
||||
console = Console()
|
||||
|
||||
if module is None:
|
||||
console.print(f"[red]✗[/red] Module not found: {module_identifier}")
|
||||
return
|
||||
|
||||
table = Table(title=f"Module: {module.identifier}")
|
||||
table.add_column("Property", style="cyan")
|
||||
table.add_column("Value")
|
||||
|
||||
table.add_row("Identifier", module.identifier)
|
||||
table.add_row("Available", "Yes" if module.available else "No")
|
||||
table.add_row("Description", module.description or "-")
|
||||
table.add_row("Version", module.version or "-")
|
||||
|
||||
console.print(table)
|
||||
@@ -7,7 +7,7 @@ from rich.console import Console
|
||||
from rich.table import Table
|
||||
from typer import Argument, Context, Option, Typer
|
||||
|
||||
from fuzzforge_cli.context import get_project_path, get_runner
|
||||
from fuzzforge_cli.context import get_project_path, get_storage
|
||||
|
||||
application: Typer = Typer(
|
||||
name="project",
|
||||
@@ -36,10 +36,10 @@ def init_project(
|
||||
:param path: Path to initialize (defaults to current directory).
|
||||
|
||||
"""
|
||||
runner = get_runner(context)
|
||||
storage = get_storage(context)
|
||||
project_path = path or get_project_path(context)
|
||||
|
||||
storage_path = runner.init_project(project_path)
|
||||
storage_path = storage.init_project(project_path)
|
||||
|
||||
console = Console()
|
||||
console.print(f"[green]✓[/green] Project initialized at {project_path}")
|
||||
@@ -65,10 +65,10 @@ def set_assets(
|
||||
:param assets_path: Path to assets.
|
||||
|
||||
"""
|
||||
runner = get_runner(context)
|
||||
storage = get_storage(context)
|
||||
project_path = get_project_path(context)
|
||||
|
||||
stored_path = runner.set_project_assets(project_path, assets_path)
|
||||
stored_path = storage.set_project_assets(project_path, assets_path)
|
||||
|
||||
console = Console()
|
||||
console.print(f"[green]✓[/green] Assets stored from {assets_path}")
|
||||
@@ -87,11 +87,11 @@ def show_info(
|
||||
:param context: Typer context.
|
||||
|
||||
"""
|
||||
runner = get_runner(context)
|
||||
storage = get_storage(context)
|
||||
project_path = get_project_path(context)
|
||||
|
||||
executions = runner.list_executions(project_path)
|
||||
assets_path = runner.storage.get_project_assets_path(project_path)
|
||||
executions = storage.list_executions(project_path)
|
||||
assets_path = storage.get_project_assets_path(project_path)
|
||||
|
||||
console = Console()
|
||||
table = Table(title=f"Project: {project_path.name}")
|
||||
@@ -118,10 +118,10 @@ def list_executions(
|
||||
:param context: Typer context.
|
||||
|
||||
"""
|
||||
runner = get_runner(context)
|
||||
storage = get_storage(context)
|
||||
project_path = get_project_path(context)
|
||||
|
||||
executions = runner.list_executions(project_path)
|
||||
executions = storage.list_executions(project_path)
|
||||
|
||||
console = Console()
|
||||
|
||||
@@ -134,7 +134,7 @@ def list_executions(
|
||||
table.add_column("Has Results")
|
||||
|
||||
for exec_id in executions:
|
||||
has_results = runner.get_execution_results(project_path, exec_id) is not None
|
||||
has_results = storage.get_execution_results(project_path, exec_id) is not None
|
||||
table.add_row(exec_id, "✓" if has_results else "-")
|
||||
|
||||
console.print(table)
|
||||
@@ -168,10 +168,10 @@ def get_results(
|
||||
:param extract_to: Optional directory to extract to.
|
||||
|
||||
"""
|
||||
runner = get_runner(context)
|
||||
storage = get_storage(context)
|
||||
project_path = get_project_path(context)
|
||||
|
||||
results_path = runner.get_execution_results(project_path, execution_id)
|
||||
results_path = storage.get_execution_results(project_path, execution_id)
|
||||
|
||||
console = Console()
|
||||
|
||||
@@ -182,5 +182,5 @@ def get_results(
|
||||
console.print(f"[green]✓[/green] Results: {results_path}")
|
||||
|
||||
if extract_to:
|
||||
extracted = runner.extract_results(results_path, extract_to)
|
||||
extracted = storage.extract_results(results_path, extract_to)
|
||||
console.print(f" Extracted to: {extracted}")
|
||||
|
||||
@@ -5,35 +5,35 @@ from __future__ import annotations
|
||||
from pathlib import Path
|
||||
from typing import TYPE_CHECKING, cast
|
||||
|
||||
from fuzzforge_runner import Runner, Settings
|
||||
from fuzzforge_mcp.storage import LocalStorage
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from typer import Context as TyperContext
|
||||
|
||||
|
||||
class Context:
|
||||
"""CLI context holding the runner instance and settings."""
|
||||
"""CLI context holding the storage instance and settings."""
|
||||
|
||||
_runner: Runner
|
||||
_storage: LocalStorage
|
||||
_project_path: Path
|
||||
|
||||
def __init__(self, runner: Runner, project_path: Path) -> None:
|
||||
def __init__(self, storage: LocalStorage, project_path: Path) -> None:
|
||||
"""Initialize an instance of the class.
|
||||
|
||||
:param runner: FuzzForge runner instance.
|
||||
:param storage: FuzzForge local storage instance.
|
||||
:param project_path: Path to the current project.
|
||||
|
||||
"""
|
||||
self._runner = runner
|
||||
self._storage = storage
|
||||
self._project_path = project_path
|
||||
|
||||
def get_runner(self) -> Runner:
|
||||
"""Get the runner instance.
|
||||
def get_storage(self) -> LocalStorage:
|
||||
"""Get the storage instance.
|
||||
|
||||
:return: Runner instance.
|
||||
:return: LocalStorage instance.
|
||||
|
||||
"""
|
||||
return self._runner
|
||||
return self._storage
|
||||
|
||||
def get_project_path(self) -> Path:
|
||||
"""Get the current project path.
|
||||
@@ -44,14 +44,14 @@ class Context:
|
||||
return self._project_path
|
||||
|
||||
|
||||
def get_runner(context: TyperContext) -> Runner:
|
||||
"""Get runner from Typer context.
|
||||
def get_storage(context: TyperContext) -> LocalStorage:
|
||||
"""Get storage from Typer context.
|
||||
|
||||
:param context: Typer context.
|
||||
:return: Runner instance.
|
||||
:return: LocalStorage instance.
|
||||
|
||||
"""
|
||||
return cast("Context", context.obj).get_runner()
|
||||
return cast("Context", context.obj).get_storage()
|
||||
|
||||
|
||||
def get_project_path(context: TyperContext) -> Path:
|
||||
|
||||
43
fuzzforge-common/src/fuzzforge_common/hub/__init__.py
Normal file
43
fuzzforge-common/src/fuzzforge_common/hub/__init__.py
Normal file
@@ -0,0 +1,43 @@
|
||||
"""FuzzForge Hub - Generic MCP server bridge.
|
||||
|
||||
This module provides a generic bridge to connect FuzzForge with any MCP server.
|
||||
It allows AI agents to discover and execute tools from external MCP servers
|
||||
(like mcp-security-hub) through the same interface as native FuzzForge modules.
|
||||
|
||||
The hub is server-agnostic: it doesn't hardcode any specific tools or servers.
|
||||
Instead, it dynamically discovers tools by connecting to configured MCP servers
|
||||
and calling their `list_tools()` method.
|
||||
|
||||
Supported transport types:
|
||||
- docker: Run MCP server as a Docker container with stdio transport
|
||||
- command: Run MCP server as a local process with stdio transport
|
||||
- sse: Connect to a remote MCP server via Server-Sent Events
|
||||
|
||||
"""
|
||||
|
||||
from fuzzforge_common.hub.client import HubClient, HubClientError, PersistentSession
|
||||
from fuzzforge_common.hub.executor import HubExecutionResult, HubExecutor
|
||||
from fuzzforge_common.hub.models import (
|
||||
HubConfig,
|
||||
HubServer,
|
||||
HubServerConfig,
|
||||
HubServerType,
|
||||
HubTool,
|
||||
HubToolParameter,
|
||||
)
|
||||
from fuzzforge_common.hub.registry import HubRegistry
|
||||
|
||||
__all__ = [
|
||||
"HubClient",
|
||||
"HubClientError",
|
||||
"HubConfig",
|
||||
"HubExecutionResult",
|
||||
"HubExecutor",
|
||||
"HubRegistry",
|
||||
"HubServer",
|
||||
"HubServerConfig",
|
||||
"HubServerType",
|
||||
"HubTool",
|
||||
"HubToolParameter",
|
||||
"PersistentSession",
|
||||
]
|
||||
738
fuzzforge-common/src/fuzzforge_common/hub/client.py
Normal file
738
fuzzforge-common/src/fuzzforge_common/hub/client.py
Normal file
@@ -0,0 +1,738 @@
|
||||
"""MCP client for communicating with hub servers.
|
||||
|
||||
This module provides a generic MCP client that can connect to any MCP server
|
||||
via stdio (docker/command) or SSE transport. It handles:
|
||||
- Starting containers/processes for stdio transport
|
||||
- Connecting to SSE endpoints
|
||||
- Discovering tools via list_tools()
|
||||
- Executing tools via call_tool()
|
||||
- Persistent container sessions for stateful interactions
|
||||
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import os
|
||||
import subprocess
|
||||
from contextlib import asynccontextmanager
|
||||
from dataclasses import dataclass, field
|
||||
from datetime import datetime, timezone
|
||||
from typing import TYPE_CHECKING, Any, cast
|
||||
|
||||
from fuzzforge_common.hub.models import (
|
||||
HubServer,
|
||||
HubServerConfig,
|
||||
HubServerType,
|
||||
HubTool,
|
||||
)
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from asyncio.subprocess import Process
|
||||
from collections.abc import AsyncGenerator
|
||||
|
||||
from structlog.stdlib import BoundLogger
|
||||
|
||||
|
||||
def get_logger() -> BoundLogger:
|
||||
"""Get structlog logger instance.
|
||||
|
||||
:returns: Configured structlog logger.
|
||||
|
||||
"""
|
||||
from structlog import get_logger # noqa: PLC0415
|
||||
|
||||
return cast("BoundLogger", get_logger())
|
||||
|
||||
|
||||
class HubClientError(Exception):
|
||||
"""Error in hub client operations."""
|
||||
|
||||
|
||||
@dataclass
|
||||
class PersistentSession:
|
||||
"""A persistent container session with an active MCP connection.
|
||||
|
||||
Keeps a Docker container running between tool calls to allow
|
||||
stateful interactions (e.g., radare2 analysis, long-running fuzzing).
|
||||
|
||||
"""
|
||||
|
||||
#: Server name this session belongs to.
|
||||
server_name: str
|
||||
|
||||
#: Docker container name.
|
||||
container_name: str
|
||||
|
||||
#: Underlying process (docker run).
|
||||
process: Process
|
||||
|
||||
#: Stream reader (process stdout).
|
||||
reader: asyncio.StreamReader
|
||||
|
||||
#: Stream writer (process stdin).
|
||||
writer: asyncio.StreamWriter
|
||||
|
||||
#: Whether the MCP session has been initialized.
|
||||
initialized: bool = False
|
||||
|
||||
#: Lock to serialise concurrent requests on the same session.
|
||||
lock: asyncio.Lock = field(default_factory=asyncio.Lock)
|
||||
|
||||
#: When the session was started.
|
||||
started_at: datetime = field(default_factory=lambda: datetime.now(tz=timezone.utc))
|
||||
|
||||
#: Monotonic counter for JSON-RPC request IDs.
|
||||
request_id: int = 0
|
||||
|
||||
@property
|
||||
def alive(self) -> bool:
|
||||
"""Check if the underlying process is still running."""
|
||||
return self.process.returncode is None
|
||||
|
||||
|
||||
class HubClient:
|
||||
"""Client for communicating with MCP hub servers.
|
||||
|
||||
Supports stdio (via docker/command) and SSE transports.
|
||||
Uses the MCP protocol for tool discovery and execution.
|
||||
|
||||
"""
|
||||
|
||||
#: Default timeout for operations.
|
||||
DEFAULT_TIMEOUT: int = 30
|
||||
|
||||
def __init__(self, timeout: int = DEFAULT_TIMEOUT) -> None:
|
||||
"""Initialize the hub client.
|
||||
|
||||
:param timeout: Default timeout for operations in seconds.
|
||||
|
||||
"""
|
||||
self._timeout = timeout
|
||||
self._persistent_sessions: dict[str, PersistentSession] = {}
|
||||
self._request_id: int = 0
|
||||
|
||||
async def discover_tools(self, server: HubServer) -> list[HubTool]:
|
||||
"""Discover tools from a hub server.
|
||||
|
||||
Connects to the server, calls list_tools(), and returns
|
||||
parsed HubTool instances.
|
||||
|
||||
:param server: Hub server to discover tools from.
|
||||
:returns: List of discovered tools.
|
||||
:raises HubClientError: If discovery fails.
|
||||
|
||||
"""
|
||||
logger = get_logger()
|
||||
config = server.config
|
||||
|
||||
logger.info("Discovering tools", server=config.name, type=config.type.value)
|
||||
|
||||
try:
|
||||
async with self._connect(config) as (reader, writer):
|
||||
# Initialise MCP session (skip for persistent — already done)
|
||||
if not self._persistent_sessions.get(config.name):
|
||||
await self._initialize_session(reader, writer, config.name)
|
||||
|
||||
# List tools
|
||||
tools_data = await self._call_method(
|
||||
reader,
|
||||
writer,
|
||||
"tools/list",
|
||||
{},
|
||||
)
|
||||
|
||||
# Parse tools
|
||||
tools = []
|
||||
for tool_data in tools_data.get("tools", []):
|
||||
tool = HubTool.from_mcp_tool(
|
||||
server_name=config.name,
|
||||
name=tool_data["name"],
|
||||
description=tool_data.get("description"),
|
||||
input_schema=tool_data.get("inputSchema", {}),
|
||||
)
|
||||
tools.append(tool)
|
||||
|
||||
logger.info(
|
||||
"Discovered tools",
|
||||
server=config.name,
|
||||
count=len(tools),
|
||||
)
|
||||
return tools
|
||||
|
||||
except Exception as e:
|
||||
logger.error(
|
||||
"Tool discovery failed",
|
||||
server=config.name,
|
||||
error=str(e),
|
||||
)
|
||||
raise HubClientError(f"Discovery failed for {config.name}: {e}") from e
|
||||
|
||||
async def execute_tool(
|
||||
self,
|
||||
server: HubServer,
|
||||
tool_name: str,
|
||||
arguments: dict[str, Any],
|
||||
*,
|
||||
timeout: int | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""Execute a tool on a hub server.
|
||||
|
||||
:param server: Hub server to execute on.
|
||||
:param tool_name: Name of the tool to execute.
|
||||
:param arguments: Tool arguments.
|
||||
:param timeout: Execution timeout (uses default if None).
|
||||
:returns: Tool execution result.
|
||||
:raises HubClientError: If execution fails.
|
||||
|
||||
"""
|
||||
logger = get_logger()
|
||||
config = server.config
|
||||
exec_timeout = timeout or config.timeout or self._timeout
|
||||
|
||||
logger.info(
|
||||
"Executing hub tool",
|
||||
server=config.name,
|
||||
tool=tool_name,
|
||||
timeout=exec_timeout,
|
||||
)
|
||||
|
||||
try:
|
||||
async with self._connect(config) as (reader, writer):
|
||||
# Initialise MCP session (skip for persistent — already done)
|
||||
if not self._persistent_sessions.get(config.name):
|
||||
await self._initialize_session(reader, writer, config.name)
|
||||
|
||||
# Call tool
|
||||
result = await asyncio.wait_for(
|
||||
self._call_method(
|
||||
reader,
|
||||
writer,
|
||||
"tools/call",
|
||||
{"name": tool_name, "arguments": arguments},
|
||||
),
|
||||
timeout=exec_timeout,
|
||||
)
|
||||
|
||||
logger.info(
|
||||
"Tool execution completed",
|
||||
server=config.name,
|
||||
tool=tool_name,
|
||||
)
|
||||
return result
|
||||
|
||||
except asyncio.TimeoutError as e:
|
||||
logger.error(
|
||||
"Tool execution timed out",
|
||||
server=config.name,
|
||||
tool=tool_name,
|
||||
timeout=exec_timeout,
|
||||
)
|
||||
raise HubClientError(
|
||||
f"Execution timed out for {config.name}:{tool_name}"
|
||||
) from e
|
||||
|
||||
except Exception as e:
|
||||
logger.error(
|
||||
"Tool execution failed",
|
||||
server=config.name,
|
||||
tool=tool_name,
|
||||
error=str(e),
|
||||
)
|
||||
raise HubClientError(
|
||||
f"Execution failed for {config.name}:{tool_name}: {e}"
|
||||
) from e
|
||||
|
||||
@asynccontextmanager
|
||||
async def _connect(
|
||||
self,
|
||||
config: HubServerConfig,
|
||||
) -> AsyncGenerator[tuple[asyncio.StreamReader, asyncio.StreamWriter], None]:
|
||||
"""Connect to an MCP server.
|
||||
|
||||
If a persistent session exists for this server, reuse it (with a lock
|
||||
to serialise concurrent requests). Otherwise, fall through to the
|
||||
ephemeral per-call connection logic.
|
||||
|
||||
:param config: Server configuration.
|
||||
:yields: Tuple of (reader, writer) for communication.
|
||||
|
||||
"""
|
||||
# Check for active persistent session
|
||||
session = self._persistent_sessions.get(config.name)
|
||||
if session and session.initialized and session.alive:
|
||||
async with session.lock:
|
||||
yield session.reader, session.writer # type: ignore[misc]
|
||||
return
|
||||
|
||||
# Ephemeral connection (original behaviour)
|
||||
if config.type == HubServerType.DOCKER:
|
||||
async with self._connect_docker(config) as streams:
|
||||
yield streams
|
||||
elif config.type == HubServerType.COMMAND:
|
||||
async with self._connect_command(config) as streams:
|
||||
yield streams
|
||||
elif config.type == HubServerType.SSE:
|
||||
async with self._connect_sse(config) as streams:
|
||||
yield streams
|
||||
else:
|
||||
msg = f"Unsupported server type: {config.type}"
|
||||
raise HubClientError(msg)
|
||||
|
||||
@asynccontextmanager
|
||||
async def _connect_docker(
|
||||
self,
|
||||
config: HubServerConfig,
|
||||
) -> AsyncGenerator[tuple[asyncio.StreamReader, asyncio.StreamWriter], None]:
|
||||
"""Connect to a Docker-based MCP server.
|
||||
|
||||
:param config: Server configuration with image name.
|
||||
:yields: Tuple of (reader, writer) for stdio communication.
|
||||
|
||||
"""
|
||||
if not config.image:
|
||||
msg = f"Docker image not specified for server '{config.name}'"
|
||||
raise HubClientError(msg)
|
||||
|
||||
# Build docker command
|
||||
cmd = ["docker", "run", "-i", "--rm"]
|
||||
|
||||
# Add capabilities
|
||||
for cap in config.capabilities:
|
||||
cmd.extend(["--cap-add", cap])
|
||||
|
||||
# Add volumes
|
||||
for volume in config.volumes:
|
||||
cmd.extend(["-v", os.path.expanduser(volume)])
|
||||
|
||||
# Add environment variables
|
||||
for key, value in config.environment.items():
|
||||
cmd.extend(["-e", f"{key}={value}"])
|
||||
|
||||
cmd.append(config.image)
|
||||
|
||||
# Use 4 MB buffer to handle large tool responses (YARA rulesets, trivy output, etc.)
|
||||
_STREAM_LIMIT = 4 * 1024 * 1024
|
||||
|
||||
process: Process = await asyncio.create_subprocess_exec(
|
||||
*cmd,
|
||||
stdin=subprocess.PIPE,
|
||||
stdout=subprocess.PIPE,
|
||||
stderr=subprocess.PIPE,
|
||||
limit=_STREAM_LIMIT,
|
||||
)
|
||||
|
||||
try:
|
||||
if process.stdin is None or process.stdout is None:
|
||||
msg = "Failed to get process streams"
|
||||
raise HubClientError(msg)
|
||||
|
||||
# Create asyncio streams from process pipes
|
||||
reader = process.stdout
|
||||
writer = process.stdin
|
||||
|
||||
yield reader, writer # type: ignore[misc]
|
||||
|
||||
finally:
|
||||
process.terminate()
|
||||
try:
|
||||
await asyncio.wait_for(process.wait(), timeout=5)
|
||||
except asyncio.TimeoutError:
|
||||
process.kill()
|
||||
|
||||
@asynccontextmanager
|
||||
async def _connect_command(
|
||||
self,
|
||||
config: HubServerConfig,
|
||||
) -> AsyncGenerator[tuple[asyncio.StreamReader, asyncio.StreamWriter], None]:
|
||||
"""Connect to a command-based MCP server.
|
||||
|
||||
:param config: Server configuration with command.
|
||||
:yields: Tuple of (reader, writer) for stdio communication.
|
||||
|
||||
"""
|
||||
if not config.command:
|
||||
msg = f"Command not specified for server '{config.name}'"
|
||||
raise HubClientError(msg)
|
||||
|
||||
# Set up environment
|
||||
env = dict(config.environment) if config.environment else None
|
||||
|
||||
# Use 4 MB buffer to handle large tool responses
|
||||
_STREAM_LIMIT = 4 * 1024 * 1024
|
||||
|
||||
process: Process = await asyncio.create_subprocess_exec(
|
||||
*config.command,
|
||||
stdin=subprocess.PIPE,
|
||||
stdout=subprocess.PIPE,
|
||||
stderr=subprocess.PIPE,
|
||||
env=env,
|
||||
limit=_STREAM_LIMIT,
|
||||
)
|
||||
|
||||
try:
|
||||
if process.stdin is None or process.stdout is None:
|
||||
msg = "Failed to get process streams"
|
||||
raise HubClientError(msg)
|
||||
|
||||
reader = process.stdout
|
||||
writer = process.stdin
|
||||
|
||||
yield reader, writer # type: ignore[misc]
|
||||
|
||||
finally:
|
||||
process.terminate()
|
||||
try:
|
||||
await asyncio.wait_for(process.wait(), timeout=5)
|
||||
except asyncio.TimeoutError:
|
||||
process.kill()
|
||||
|
||||
@asynccontextmanager
|
||||
async def _connect_sse(
|
||||
self,
|
||||
config: HubServerConfig,
|
||||
) -> AsyncGenerator[tuple[asyncio.StreamReader, asyncio.StreamWriter], None]:
|
||||
"""Connect to an SSE-based MCP server.
|
||||
|
||||
:param config: Server configuration with URL.
|
||||
:yields: Tuple of (reader, writer) for SSE communication.
|
||||
|
||||
"""
|
||||
# SSE support requires additional dependencies
|
||||
# For now, raise not implemented
|
||||
msg = "SSE transport not yet implemented"
|
||||
raise NotImplementedError(msg)
|
||||
|
||||
async def _initialize_session(
|
||||
self,
|
||||
reader: asyncio.StreamReader,
|
||||
writer: asyncio.StreamWriter,
|
||||
server_name: str,
|
||||
) -> dict[str, Any]:
|
||||
"""Initialize MCP session with the server.
|
||||
|
||||
:param reader: Stream reader.
|
||||
:param writer: Stream writer.
|
||||
:param server_name: Server name for logging.
|
||||
:returns: Server capabilities.
|
||||
|
||||
"""
|
||||
# Send initialize request
|
||||
result = await self._call_method(
|
||||
reader,
|
||||
writer,
|
||||
"initialize",
|
||||
{
|
||||
"protocolVersion": "2024-11-05",
|
||||
"capabilities": {},
|
||||
"clientInfo": {
|
||||
"name": "fuzzforge-hub",
|
||||
"version": "0.1.0",
|
||||
},
|
||||
},
|
||||
)
|
||||
|
||||
# Send initialized notification
|
||||
await self._send_notification(reader, writer, "notifications/initialized", {})
|
||||
|
||||
return result
|
||||
|
||||
async def _call_method(
|
||||
self,
|
||||
reader: asyncio.StreamReader,
|
||||
writer: asyncio.StreamWriter,
|
||||
method: str,
|
||||
params: dict[str, Any],
|
||||
) -> dict[str, Any]:
|
||||
"""Call an MCP method.
|
||||
|
||||
:param reader: Stream reader.
|
||||
:param writer: Stream writer.
|
||||
:param method: Method name.
|
||||
:param params: Method parameters.
|
||||
:returns: Method result.
|
||||
|
||||
"""
|
||||
# Create JSON-RPC request with unique ID
|
||||
self._request_id += 1
|
||||
request = {
|
||||
"jsonrpc": "2.0",
|
||||
"id": self._request_id,
|
||||
"method": method,
|
||||
"params": params,
|
||||
}
|
||||
|
||||
# Send request
|
||||
request_line = json.dumps(request) + "\n"
|
||||
writer.write(request_line.encode())
|
||||
await writer.drain()
|
||||
|
||||
# Read response
|
||||
response_line = await asyncio.wait_for(
|
||||
reader.readline(),
|
||||
timeout=self._timeout,
|
||||
)
|
||||
|
||||
if not response_line:
|
||||
msg = "Empty response from server"
|
||||
raise HubClientError(msg)
|
||||
|
||||
response = json.loads(response_line.decode())
|
||||
|
||||
if "error" in response:
|
||||
error = response["error"]
|
||||
msg = f"MCP error: {error.get('message', 'Unknown error')}"
|
||||
raise HubClientError(msg)
|
||||
|
||||
result = response.get("result", {})
|
||||
|
||||
# Check for tool-level errors in content items
|
||||
for item in result.get("content", []):
|
||||
if item.get("isError", False):
|
||||
error_text = item.get("text", "unknown error")
|
||||
msg = f"Tool returned error: {error_text}"
|
||||
raise HubClientError(msg)
|
||||
|
||||
return result
|
||||
|
||||
async def _send_notification(
|
||||
self,
|
||||
reader: asyncio.StreamReader,
|
||||
writer: asyncio.StreamWriter,
|
||||
method: str,
|
||||
params: dict[str, Any],
|
||||
) -> None:
|
||||
"""Send an MCP notification (no response expected).
|
||||
|
||||
:param reader: Stream reader (unused but kept for consistency).
|
||||
:param writer: Stream writer.
|
||||
:param method: Notification method name.
|
||||
:param params: Notification parameters.
|
||||
|
||||
"""
|
||||
# Create JSON-RPC notification (no id)
|
||||
notification = {
|
||||
"jsonrpc": "2.0",
|
||||
"method": method,
|
||||
"params": params,
|
||||
}
|
||||
|
||||
notification_line = json.dumps(notification) + "\n"
|
||||
writer.write(notification_line.encode())
|
||||
await writer.drain()
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Persistent session management
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
async def start_persistent_session(
|
||||
self,
|
||||
config: HubServerConfig,
|
||||
) -> PersistentSession:
|
||||
"""Start a persistent Docker container and initialise MCP session.
|
||||
|
||||
The container stays running until :meth:`stop_persistent_session` is
|
||||
called, allowing multiple tool calls on the same session.
|
||||
|
||||
:param config: Server configuration (must be Docker type).
|
||||
:returns: The created persistent session.
|
||||
:raises HubClientError: If the container cannot be started.
|
||||
|
||||
"""
|
||||
logger = get_logger()
|
||||
|
||||
if config.name in self._persistent_sessions:
|
||||
session = self._persistent_sessions[config.name]
|
||||
if session.alive:
|
||||
logger.info("Persistent session already running", server=config.name)
|
||||
return session
|
||||
# Dead session — clean up and restart
|
||||
await self._cleanup_session(config.name)
|
||||
|
||||
if config.type != HubServerType.DOCKER:
|
||||
msg = f"Persistent mode only supports Docker servers (got {config.type.value})"
|
||||
raise HubClientError(msg)
|
||||
|
||||
if not config.image:
|
||||
msg = f"Docker image not specified for server '{config.name}'"
|
||||
raise HubClientError(msg)
|
||||
|
||||
container_name = f"fuzzforge-{config.name}"
|
||||
|
||||
# Remove stale container with same name if it exists
|
||||
try:
|
||||
rm_proc = await asyncio.create_subprocess_exec(
|
||||
"docker", "rm", "-f", container_name,
|
||||
stdout=subprocess.PIPE,
|
||||
stderr=subprocess.PIPE,
|
||||
)
|
||||
await rm_proc.wait()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# Build docker run command (no --rm, with --name)
|
||||
cmd = ["docker", "run", "-i", "--name", container_name]
|
||||
|
||||
for cap in config.capabilities:
|
||||
cmd.extend(["--cap-add", cap])
|
||||
|
||||
for volume in config.volumes:
|
||||
cmd.extend(["-v", os.path.expanduser(volume)])
|
||||
|
||||
for key, value in config.environment.items():
|
||||
cmd.extend(["-e", f"{key}={value}"])
|
||||
|
||||
cmd.append(config.image)
|
||||
|
||||
_STREAM_LIMIT = 4 * 1024 * 1024
|
||||
|
||||
logger.info(
|
||||
"Starting persistent container",
|
||||
server=config.name,
|
||||
container=container_name,
|
||||
image=config.image,
|
||||
)
|
||||
|
||||
process: Process = await asyncio.create_subprocess_exec(
|
||||
*cmd,
|
||||
stdin=subprocess.PIPE,
|
||||
stdout=subprocess.PIPE,
|
||||
stderr=subprocess.PIPE,
|
||||
limit=_STREAM_LIMIT,
|
||||
)
|
||||
|
||||
if process.stdin is None or process.stdout is None:
|
||||
process.terminate()
|
||||
msg = "Failed to get process streams"
|
||||
raise HubClientError(msg)
|
||||
|
||||
session = PersistentSession(
|
||||
server_name=config.name,
|
||||
container_name=container_name,
|
||||
process=process,
|
||||
reader=process.stdout,
|
||||
writer=process.stdin,
|
||||
)
|
||||
|
||||
# Initialise MCP session
|
||||
try:
|
||||
await self._initialize_session(
|
||||
session.reader, # type: ignore[arg-type]
|
||||
session.writer, # type: ignore[arg-type]
|
||||
config.name,
|
||||
)
|
||||
session.initialized = True
|
||||
except Exception as e:
|
||||
process.terminate()
|
||||
try:
|
||||
await asyncio.wait_for(process.wait(), timeout=5)
|
||||
except asyncio.TimeoutError:
|
||||
process.kill()
|
||||
msg = f"Failed to initialise MCP session for {config.name}: {e}"
|
||||
raise HubClientError(msg) from e
|
||||
|
||||
self._persistent_sessions[config.name] = session
|
||||
|
||||
logger.info(
|
||||
"Persistent session started",
|
||||
server=config.name,
|
||||
container=container_name,
|
||||
)
|
||||
return session
|
||||
|
||||
async def stop_persistent_session(self, server_name: str) -> bool:
|
||||
"""Stop a persistent container session.
|
||||
|
||||
:param server_name: Name of the server whose session to stop.
|
||||
:returns: True if a session was stopped, False if none found.
|
||||
|
||||
"""
|
||||
return await self._cleanup_session(server_name)
|
||||
|
||||
def get_persistent_session(self, server_name: str) -> PersistentSession | None:
|
||||
"""Get a persistent session by server name.
|
||||
|
||||
:param server_name: Server name.
|
||||
:returns: The session if running, None otherwise.
|
||||
|
||||
"""
|
||||
session = self._persistent_sessions.get(server_name)
|
||||
if session and not session.alive:
|
||||
# Mark dead session — don't remove here to avoid async issues
|
||||
return None
|
||||
return session
|
||||
|
||||
def list_persistent_sessions(self) -> list[dict[str, Any]]:
|
||||
"""List all persistent sessions with their status.
|
||||
|
||||
:returns: List of session info dictionaries.
|
||||
|
||||
"""
|
||||
sessions = []
|
||||
for name, session in self._persistent_sessions.items():
|
||||
sessions.append({
|
||||
"server_name": name,
|
||||
"container_name": session.container_name,
|
||||
"alive": session.alive,
|
||||
"initialized": session.initialized,
|
||||
"started_at": session.started_at.isoformat(),
|
||||
"uptime_seconds": int(
|
||||
(datetime.now(tz=timezone.utc) - session.started_at).total_seconds()
|
||||
),
|
||||
})
|
||||
return sessions
|
||||
|
||||
async def stop_all_persistent_sessions(self) -> int:
|
||||
"""Stop all persistent sessions.
|
||||
|
||||
:returns: Number of sessions stopped.
|
||||
|
||||
"""
|
||||
names = list(self._persistent_sessions.keys())
|
||||
count = 0
|
||||
for name in names:
|
||||
if await self._cleanup_session(name):
|
||||
count += 1
|
||||
return count
|
||||
|
||||
async def _cleanup_session(self, server_name: str) -> bool:
|
||||
"""Clean up a persistent session (terminate process, remove container).
|
||||
|
||||
:param server_name: Server name.
|
||||
:returns: True if cleaned up, False if not found.
|
||||
|
||||
"""
|
||||
logger = get_logger()
|
||||
session = self._persistent_sessions.pop(server_name, None)
|
||||
if session is None:
|
||||
return False
|
||||
|
||||
logger.info("Stopping persistent session", server=server_name)
|
||||
|
||||
# Terminate process
|
||||
if session.alive:
|
||||
session.process.terminate()
|
||||
try:
|
||||
await asyncio.wait_for(session.process.wait(), timeout=10)
|
||||
except asyncio.TimeoutError:
|
||||
session.process.kill()
|
||||
await session.process.wait()
|
||||
|
||||
# Remove Docker container
|
||||
try:
|
||||
rm_proc = await asyncio.create_subprocess_exec(
|
||||
"docker", "rm", "-f", session.container_name,
|
||||
stdout=subprocess.PIPE,
|
||||
stderr=subprocess.PIPE,
|
||||
)
|
||||
await rm_proc.wait()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
logger.info(
|
||||
"Persistent session stopped",
|
||||
server=server_name,
|
||||
container=session.container_name,
|
||||
)
|
||||
return True
|
||||
622
fuzzforge-common/src/fuzzforge_common/hub/executor.py
Normal file
622
fuzzforge-common/src/fuzzforge_common/hub/executor.py
Normal file
@@ -0,0 +1,622 @@
|
||||
"""Hub executor for managing MCP server lifecycle and tool execution.
|
||||
|
||||
This module provides a high-level interface for:
|
||||
- Discovering tools from all registered hub servers
|
||||
- Executing tools with proper error handling
|
||||
- Managing the lifecycle of hub operations
|
||||
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
from typing import TYPE_CHECKING, Any, cast
|
||||
|
||||
from fuzzforge_common.hub.client import HubClient, HubClientError, PersistentSession
|
||||
from fuzzforge_common.hub.models import HubServer, HubServerConfig, HubTool
|
||||
from fuzzforge_common.hub.registry import HubRegistry
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from structlog.stdlib import BoundLogger
|
||||
|
||||
|
||||
def get_logger() -> BoundLogger:
|
||||
"""Get structlog logger instance.
|
||||
|
||||
:returns: Configured structlog logger.
|
||||
|
||||
"""
|
||||
from structlog import get_logger # noqa: PLC0415
|
||||
|
||||
return cast("BoundLogger", get_logger())
|
||||
|
||||
|
||||
class HubExecutionResult:
|
||||
"""Result of a hub tool execution."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
success: bool,
|
||||
server_name: str,
|
||||
tool_name: str,
|
||||
result: dict[str, Any] | None = None,
|
||||
error: str | None = None,
|
||||
) -> None:
|
||||
"""Initialize execution result.
|
||||
|
||||
:param success: Whether execution succeeded.
|
||||
:param server_name: Name of the hub server.
|
||||
:param tool_name: Name of the executed tool.
|
||||
:param result: Tool execution result data.
|
||||
:param error: Error message if execution failed.
|
||||
|
||||
"""
|
||||
self.success = success
|
||||
self.server_name = server_name
|
||||
self.tool_name = tool_name
|
||||
self.result = result or {}
|
||||
self.error = error
|
||||
|
||||
@property
|
||||
def identifier(self) -> str:
|
||||
"""Get full tool identifier."""
|
||||
return f"hub:{self.server_name}:{self.tool_name}"
|
||||
|
||||
def to_dict(self) -> dict[str, Any]:
|
||||
"""Convert to dictionary.
|
||||
|
||||
:returns: Dictionary representation.
|
||||
|
||||
"""
|
||||
return {
|
||||
"success": self.success,
|
||||
"identifier": self.identifier,
|
||||
"server": self.server_name,
|
||||
"tool": self.tool_name,
|
||||
"result": self.result,
|
||||
"error": self.error,
|
||||
}
|
||||
|
||||
|
||||
class HubExecutor:
|
||||
"""Executor for hub server operations.
|
||||
|
||||
Provides high-level methods for discovering and executing
|
||||
tools from hub servers.
|
||||
|
||||
"""
|
||||
|
||||
#: Hub registry instance.
|
||||
_registry: HubRegistry
|
||||
|
||||
#: MCP client instance.
|
||||
_client: HubClient
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
config_path: Path | None = None,
|
||||
timeout: int = 300,
|
||||
) -> None:
|
||||
"""Initialize the hub executor.
|
||||
|
||||
:param config_path: Path to hub-servers.json config file.
|
||||
:param timeout: Default timeout for tool execution.
|
||||
|
||||
"""
|
||||
self._registry = HubRegistry(config_path)
|
||||
self._client = HubClient(timeout=timeout)
|
||||
self._continuous_sessions: dict[str, dict[str, Any]] = {}
|
||||
|
||||
@property
|
||||
def registry(self) -> HubRegistry:
|
||||
"""Get the hub registry.
|
||||
|
||||
:returns: Hub registry instance.
|
||||
|
||||
"""
|
||||
return self._registry
|
||||
|
||||
def add_server(self, config: HubServerConfig) -> HubServer:
|
||||
"""Add a server to the registry.
|
||||
|
||||
:param config: Server configuration.
|
||||
:returns: Created HubServer instance.
|
||||
|
||||
"""
|
||||
return self._registry.add_server(config)
|
||||
|
||||
async def discover_all_tools(self) -> dict[str, list[HubTool]]:
|
||||
"""Discover tools from all enabled servers.
|
||||
|
||||
:returns: Dict mapping server names to lists of discovered tools.
|
||||
|
||||
"""
|
||||
logger = get_logger()
|
||||
results: dict[str, list[HubTool]] = {}
|
||||
|
||||
for server in self._registry.enabled_servers:
|
||||
try:
|
||||
tools = await self._client.discover_tools(server)
|
||||
self._registry.update_server_tools(server.name, tools)
|
||||
results[server.name] = tools
|
||||
|
||||
except HubClientError as e:
|
||||
logger.warning(
|
||||
"Failed to discover tools",
|
||||
server=server.name,
|
||||
error=str(e),
|
||||
)
|
||||
self._registry.update_server_tools(server.name, [], error=str(e))
|
||||
results[server.name] = []
|
||||
|
||||
return results
|
||||
|
||||
async def discover_server_tools(self, server_name: str) -> list[HubTool]:
|
||||
"""Discover tools from a specific server.
|
||||
|
||||
:param server_name: Name of the server.
|
||||
:returns: List of discovered tools.
|
||||
:raises ValueError: If server not found.
|
||||
|
||||
"""
|
||||
server = self._registry.get_server(server_name)
|
||||
if not server:
|
||||
msg = f"Server '{server_name}' not found"
|
||||
raise ValueError(msg)
|
||||
|
||||
try:
|
||||
tools = await self._client.discover_tools(server)
|
||||
self._registry.update_server_tools(server_name, tools)
|
||||
return tools
|
||||
|
||||
except HubClientError as e:
|
||||
self._registry.update_server_tools(server_name, [], error=str(e))
|
||||
raise
|
||||
|
||||
async def execute_tool(
|
||||
self,
|
||||
identifier: str,
|
||||
arguments: dict[str, Any] | None = None,
|
||||
*,
|
||||
timeout: int | None = None,
|
||||
) -> HubExecutionResult:
|
||||
"""Execute a hub tool.
|
||||
|
||||
:param identifier: Tool identifier (hub:server:tool or server:tool).
|
||||
:param arguments: Tool arguments.
|
||||
:param timeout: Execution timeout.
|
||||
:returns: Execution result.
|
||||
|
||||
"""
|
||||
logger = get_logger()
|
||||
arguments = arguments or {}
|
||||
|
||||
# Parse identifier and find tool
|
||||
server, tool = self._registry.find_tool(identifier)
|
||||
|
||||
if not server or not tool:
|
||||
# Try to parse as server:tool and discover
|
||||
parts = identifier.replace("hub:", "").split(":")
|
||||
if len(parts) == 2: # noqa: PLR2004
|
||||
server_name, tool_name = parts
|
||||
server = self._registry.get_server(server_name)
|
||||
|
||||
if server and not server.discovered:
|
||||
# Try to discover tools first
|
||||
try:
|
||||
await self.discover_server_tools(server_name)
|
||||
tool = server.get_tool(tool_name)
|
||||
except HubClientError:
|
||||
pass
|
||||
|
||||
if server and not tool:
|
||||
# Tool not found, but server exists - try to execute anyway
|
||||
# The server might have the tool even if discovery failed
|
||||
tool_name_to_use = tool_name
|
||||
else:
|
||||
tool_name_to_use = tool.name if tool else ""
|
||||
|
||||
if not server:
|
||||
return HubExecutionResult(
|
||||
success=False,
|
||||
server_name=server_name,
|
||||
tool_name=tool_name,
|
||||
error=f"Server '{server_name}' not found",
|
||||
)
|
||||
|
||||
# Execute even if tool wasn't discovered (server might still have it)
|
||||
try:
|
||||
result = await self._client.execute_tool(
|
||||
server,
|
||||
tool_name_to_use or tool_name,
|
||||
arguments,
|
||||
timeout=timeout,
|
||||
)
|
||||
return HubExecutionResult(
|
||||
success=True,
|
||||
server_name=server.name,
|
||||
tool_name=tool_name_to_use or tool_name,
|
||||
result=result,
|
||||
)
|
||||
except HubClientError as e:
|
||||
return HubExecutionResult(
|
||||
success=False,
|
||||
server_name=server.name,
|
||||
tool_name=tool_name_to_use or tool_name,
|
||||
error=str(e),
|
||||
)
|
||||
else:
|
||||
return HubExecutionResult(
|
||||
success=False,
|
||||
server_name="unknown",
|
||||
tool_name=identifier,
|
||||
error=f"Invalid tool identifier: {identifier}",
|
||||
)
|
||||
|
||||
# Execute the tool
|
||||
logger.info(
|
||||
"Executing hub tool",
|
||||
server=server.name,
|
||||
tool=tool.name,
|
||||
arguments=arguments,
|
||||
)
|
||||
|
||||
try:
|
||||
result = await self._client.execute_tool(
|
||||
server,
|
||||
tool.name,
|
||||
arguments,
|
||||
timeout=timeout,
|
||||
)
|
||||
return HubExecutionResult(
|
||||
success=True,
|
||||
server_name=server.name,
|
||||
tool_name=tool.name,
|
||||
result=result,
|
||||
)
|
||||
|
||||
except HubClientError as e:
|
||||
return HubExecutionResult(
|
||||
success=False,
|
||||
server_name=server.name,
|
||||
tool_name=tool.name,
|
||||
error=str(e),
|
||||
)
|
||||
|
||||
def list_servers(self) -> list[dict[str, Any]]:
|
||||
"""List all registered servers with their status.
|
||||
|
||||
:returns: List of server info dicts.
|
||||
|
||||
"""
|
||||
servers = []
|
||||
for server in self._registry.servers:
|
||||
session = self._client.get_persistent_session(server.name)
|
||||
servers.append({
|
||||
"name": server.name,
|
||||
"identifier": server.identifier,
|
||||
"type": server.config.type.value,
|
||||
"enabled": server.config.enabled,
|
||||
"category": server.config.category,
|
||||
"description": server.config.description,
|
||||
"persistent": server.config.persistent,
|
||||
"persistent_session_active": session is not None and session.alive,
|
||||
"discovered": server.discovered,
|
||||
"tool_count": len(server.tools),
|
||||
"error": server.discovery_error,
|
||||
})
|
||||
return servers
|
||||
|
||||
def list_tools(self) -> list[dict[str, Any]]:
|
||||
"""List all discovered tools.
|
||||
|
||||
:returns: List of tool info dicts.
|
||||
|
||||
"""
|
||||
tools = []
|
||||
for tool in self._registry.get_all_tools():
|
||||
tools.append({
|
||||
"identifier": tool.identifier,
|
||||
"name": tool.name,
|
||||
"server": tool.server_name,
|
||||
"description": tool.description,
|
||||
"parameters": [p.model_dump() for p in tool.parameters],
|
||||
})
|
||||
return tools
|
||||
|
||||
def get_tool_schema(self, identifier: str) -> dict[str, Any] | None:
|
||||
"""Get the JSON Schema for a tool's input.
|
||||
|
||||
:param identifier: Tool identifier.
|
||||
:returns: JSON Schema dict or None if not found.
|
||||
|
||||
"""
|
||||
_, tool = self._registry.find_tool(identifier)
|
||||
if tool:
|
||||
return tool.input_schema
|
||||
return None
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Persistent session management
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
async def start_persistent_server(self, server_name: str) -> dict[str, Any]:
|
||||
"""Start a persistent container session for a server.
|
||||
|
||||
The container stays running between tool calls, allowing stateful
|
||||
interactions (e.g., radare2 sessions, long-running fuzzing).
|
||||
|
||||
:param server_name: Name of the hub server to start.
|
||||
:returns: Session status dictionary.
|
||||
:raises ValueError: If server not found.
|
||||
|
||||
"""
|
||||
logger = get_logger()
|
||||
server = self._registry.get_server(server_name)
|
||||
if not server:
|
||||
msg = f"Server '{server_name}' not found"
|
||||
raise ValueError(msg)
|
||||
|
||||
session = await self._client.start_persistent_session(server.config)
|
||||
|
||||
# Auto-discover tools on the new session
|
||||
try:
|
||||
tools = await self._client.discover_tools(server)
|
||||
self._registry.update_server_tools(server_name, tools)
|
||||
except HubClientError as e:
|
||||
logger.warning(
|
||||
"Tool discovery failed on persistent session",
|
||||
server=server_name,
|
||||
error=str(e),
|
||||
)
|
||||
|
||||
# Include discovered tools in the result so agent knows what's available
|
||||
discovered_tools = []
|
||||
server_obj = self._registry.get_server(server_name)
|
||||
if server_obj:
|
||||
for tool in server_obj.tools:
|
||||
discovered_tools.append({
|
||||
"identifier": tool.identifier,
|
||||
"name": tool.name,
|
||||
"description": tool.description,
|
||||
})
|
||||
|
||||
return {
|
||||
"server_name": session.server_name,
|
||||
"container_name": session.container_name,
|
||||
"alive": session.alive,
|
||||
"initialized": session.initialized,
|
||||
"started_at": session.started_at.isoformat(),
|
||||
"tools": discovered_tools,
|
||||
"tool_count": len(discovered_tools),
|
||||
}
|
||||
|
||||
async def stop_persistent_server(self, server_name: str) -> bool:
|
||||
"""Stop a persistent container session.
|
||||
|
||||
:param server_name: Server name.
|
||||
:returns: True if a session was stopped.
|
||||
|
||||
"""
|
||||
return await self._client.stop_persistent_session(server_name)
|
||||
|
||||
def get_persistent_status(self, server_name: str) -> dict[str, Any] | None:
|
||||
"""Get status of a persistent session.
|
||||
|
||||
:param server_name: Server name.
|
||||
:returns: Status dict or None if no session.
|
||||
|
||||
"""
|
||||
session = self._client.get_persistent_session(server_name)
|
||||
if not session:
|
||||
return None
|
||||
|
||||
from datetime import datetime, timezone # noqa: PLC0415
|
||||
|
||||
return {
|
||||
"server_name": session.server_name,
|
||||
"container_name": session.container_name,
|
||||
"alive": session.alive,
|
||||
"initialized": session.initialized,
|
||||
"started_at": session.started_at.isoformat(),
|
||||
"uptime_seconds": int(
|
||||
(datetime.now(tz=timezone.utc) - session.started_at).total_seconds()
|
||||
),
|
||||
}
|
||||
|
||||
def list_persistent_sessions(self) -> list[dict[str, Any]]:
|
||||
"""List all persistent sessions.
|
||||
|
||||
:returns: List of session status dicts.
|
||||
|
||||
"""
|
||||
return self._client.list_persistent_sessions()
|
||||
|
||||
async def stop_all_persistent_servers(self) -> int:
|
||||
"""Stop all persistent sessions.
|
||||
|
||||
:returns: Number of sessions stopped.
|
||||
|
||||
"""
|
||||
return await self._client.stop_all_persistent_sessions()
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Continuous session management
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
async def start_continuous_tool(
|
||||
self,
|
||||
server_name: str,
|
||||
start_tool: str,
|
||||
arguments: dict[str, Any],
|
||||
) -> dict[str, Any]:
|
||||
"""Start a continuous hub tool session.
|
||||
|
||||
Ensures a persistent container is running, then calls the start tool
|
||||
(e.g., ``cargo_fuzz_start``) which returns a session_id. Tracks the
|
||||
session for subsequent status/stop calls.
|
||||
|
||||
:param server_name: Hub server name.
|
||||
:param start_tool: Name of the start tool on the server.
|
||||
:param arguments: Arguments for the start tool.
|
||||
:returns: Start result including session_id.
|
||||
:raises ValueError: If server not found.
|
||||
|
||||
"""
|
||||
logger = get_logger()
|
||||
|
||||
server = self._registry.get_server(server_name)
|
||||
if not server:
|
||||
msg = f"Server '{server_name}' not found"
|
||||
raise ValueError(msg)
|
||||
|
||||
# Ensure persistent session is running
|
||||
persistent = self._client.get_persistent_session(server_name)
|
||||
if not persistent or not persistent.alive:
|
||||
logger.info(
|
||||
"Auto-starting persistent session for continuous tool",
|
||||
server=server_name,
|
||||
)
|
||||
await self._client.start_persistent_session(server.config)
|
||||
# Discover tools on the new session
|
||||
try:
|
||||
tools = await self._client.discover_tools(server)
|
||||
self._registry.update_server_tools(server_name, tools)
|
||||
except HubClientError as e:
|
||||
logger.warning(
|
||||
"Tool discovery failed on persistent session",
|
||||
server=server_name,
|
||||
error=str(e),
|
||||
)
|
||||
|
||||
# Call the start tool
|
||||
result = await self._client.execute_tool(
|
||||
server, start_tool, arguments,
|
||||
)
|
||||
|
||||
# Extract session_id from result
|
||||
content_text = ""
|
||||
for item in result.get("content", []):
|
||||
if item.get("type") == "text":
|
||||
content_text = item.get("text", "")
|
||||
break
|
||||
|
||||
import json # noqa: PLC0415
|
||||
|
||||
try:
|
||||
start_result = json.loads(content_text) if content_text else result
|
||||
except json.JSONDecodeError:
|
||||
start_result = result
|
||||
|
||||
session_id = start_result.get("session_id", "")
|
||||
|
||||
if session_id:
|
||||
from datetime import datetime, timezone # noqa: PLC0415
|
||||
|
||||
self._continuous_sessions[session_id] = {
|
||||
"session_id": session_id,
|
||||
"server_name": server_name,
|
||||
"start_tool": start_tool,
|
||||
"status_tool": start_tool.replace("_start", "_status"),
|
||||
"stop_tool": start_tool.replace("_start", "_stop"),
|
||||
"started_at": datetime.now(tz=timezone.utc).isoformat(),
|
||||
"status": "running",
|
||||
}
|
||||
|
||||
return start_result
|
||||
|
||||
async def get_continuous_tool_status(
|
||||
self,
|
||||
session_id: str,
|
||||
) -> dict[str, Any]:
|
||||
"""Get status of a continuous hub tool session.
|
||||
|
||||
:param session_id: Session ID from start_continuous_tool.
|
||||
:returns: Status dict from the hub server's status tool.
|
||||
:raises ValueError: If session not found.
|
||||
|
||||
"""
|
||||
session_info = self._continuous_sessions.get(session_id)
|
||||
if not session_info:
|
||||
msg = f"Unknown continuous session: {session_id}"
|
||||
raise ValueError(msg)
|
||||
|
||||
server = self._registry.get_server(session_info["server_name"])
|
||||
if not server:
|
||||
msg = f"Server '{session_info['server_name']}' not found"
|
||||
raise ValueError(msg)
|
||||
|
||||
result = await self._client.execute_tool(
|
||||
server,
|
||||
session_info["status_tool"],
|
||||
{"session_id": session_id},
|
||||
)
|
||||
|
||||
# Parse the text content
|
||||
content_text = ""
|
||||
for item in result.get("content", []):
|
||||
if item.get("type") == "text":
|
||||
content_text = item.get("text", "")
|
||||
break
|
||||
|
||||
import json # noqa: PLC0415
|
||||
|
||||
try:
|
||||
return json.loads(content_text) if content_text else result
|
||||
except json.JSONDecodeError:
|
||||
return result
|
||||
|
||||
async def stop_continuous_tool(
|
||||
self,
|
||||
session_id: str,
|
||||
) -> dict[str, Any]:
|
||||
"""Stop a continuous hub tool session.
|
||||
|
||||
:param session_id: Session ID to stop.
|
||||
:returns: Final results from the hub server's stop tool.
|
||||
:raises ValueError: If session not found.
|
||||
|
||||
"""
|
||||
session_info = self._continuous_sessions.get(session_id)
|
||||
if not session_info:
|
||||
msg = f"Unknown continuous session: {session_id}"
|
||||
raise ValueError(msg)
|
||||
|
||||
server = self._registry.get_server(session_info["server_name"])
|
||||
if not server:
|
||||
msg = f"Server '{session_info['server_name']}' not found"
|
||||
raise ValueError(msg)
|
||||
|
||||
result = await self._client.execute_tool(
|
||||
server,
|
||||
session_info["stop_tool"],
|
||||
{"session_id": session_id},
|
||||
)
|
||||
|
||||
# Parse the text content
|
||||
content_text = ""
|
||||
for item in result.get("content", []):
|
||||
if item.get("type") == "text":
|
||||
content_text = item.get("text", "")
|
||||
break
|
||||
|
||||
import json # noqa: PLC0415
|
||||
|
||||
try:
|
||||
stop_result = json.loads(content_text) if content_text else result
|
||||
except json.JSONDecodeError:
|
||||
stop_result = result
|
||||
|
||||
# Update session tracking
|
||||
session_info["status"] = "stopped"
|
||||
|
||||
return stop_result
|
||||
|
||||
def list_continuous_sessions(self) -> list[dict[str, Any]]:
|
||||
"""List all tracked continuous sessions.
|
||||
|
||||
:returns: List of continuous session info dicts.
|
||||
|
||||
"""
|
||||
return list(self._continuous_sessions.values())
|
||||
296
fuzzforge-common/src/fuzzforge_common/hub/models.py
Normal file
296
fuzzforge-common/src/fuzzforge_common/hub/models.py
Normal file
@@ -0,0 +1,296 @@
|
||||
"""Data models for FuzzForge Hub.
|
||||
|
||||
This module defines the Pydantic models used to represent MCP servers
|
||||
and their tools in the hub registry.
|
||||
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from enum import Enum
|
||||
from typing import Any
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
|
||||
class HubServerType(str, Enum):
|
||||
"""Type of MCP server connection."""
|
||||
|
||||
#: Run as Docker container with stdio transport.
|
||||
DOCKER = "docker"
|
||||
#: Run as local command/process with stdio transport.
|
||||
COMMAND = "command"
|
||||
#: Connect via Server-Sent Events (HTTP).
|
||||
SSE = "sse"
|
||||
|
||||
|
||||
class HubServerConfig(BaseModel):
|
||||
"""Configuration for an MCP server in the hub.
|
||||
|
||||
This defines how to connect to an MCP server, not what tools it provides.
|
||||
Tools are discovered dynamically at runtime.
|
||||
|
||||
"""
|
||||
|
||||
#: Unique identifier for this server (e.g., "nmap", "nuclei").
|
||||
name: str = Field(description="Unique server identifier")
|
||||
|
||||
#: Human-readable description of the server.
|
||||
description: str | None = Field(
|
||||
default=None,
|
||||
description="Human-readable description",
|
||||
)
|
||||
|
||||
#: Type of connection to use.
|
||||
type: HubServerType = Field(description="Connection type")
|
||||
|
||||
#: Docker image name (for type=docker).
|
||||
image: str | None = Field(
|
||||
default=None,
|
||||
description="Docker image name (for docker type)",
|
||||
)
|
||||
|
||||
#: Command to run (for type=command).
|
||||
command: list[str] | None = Field(
|
||||
default=None,
|
||||
description="Command and args (for command type)",
|
||||
)
|
||||
|
||||
#: URL endpoint (for type=sse).
|
||||
url: str | None = Field(
|
||||
default=None,
|
||||
description="SSE endpoint URL (for sse type)",
|
||||
)
|
||||
|
||||
#: Environment variables to pass to the server.
|
||||
environment: dict[str, str] = Field(
|
||||
default_factory=dict,
|
||||
description="Environment variables",
|
||||
)
|
||||
|
||||
#: Docker capabilities to add (e.g., ["NET_RAW"] for nmap).
|
||||
capabilities: list[str] = Field(
|
||||
default_factory=list,
|
||||
description="Docker capabilities to add",
|
||||
)
|
||||
|
||||
#: Volume mounts for Docker (e.g., ["/host/path:/container/path:ro"]).
|
||||
volumes: list[str] = Field(
|
||||
default_factory=list,
|
||||
description="Docker volume mounts",
|
||||
)
|
||||
|
||||
#: Whether this server is enabled.
|
||||
enabled: bool = Field(
|
||||
default=True,
|
||||
description="Whether server is enabled",
|
||||
)
|
||||
|
||||
#: Category for grouping (e.g., "reconnaissance", "web-security").
|
||||
category: str | None = Field(
|
||||
default=None,
|
||||
description="Category for grouping servers",
|
||||
)
|
||||
|
||||
#: Per-server timeout override in seconds (None = use default_timeout).
|
||||
timeout: int | None = Field(
|
||||
default=None,
|
||||
description="Per-server execution timeout override in seconds",
|
||||
)
|
||||
|
||||
#: Whether to use persistent container mode (keep container running between calls).
|
||||
persistent: bool = Field(
|
||||
default=False,
|
||||
description="Keep container running between tool calls for stateful interactions",
|
||||
)
|
||||
|
||||
|
||||
class HubToolParameter(BaseModel):
|
||||
"""A parameter for an MCP tool.
|
||||
|
||||
Parsed from the tool's JSON Schema inputSchema.
|
||||
|
||||
"""
|
||||
|
||||
#: Parameter name.
|
||||
name: str
|
||||
|
||||
#: Parameter type (string, integer, boolean, array, object).
|
||||
type: str
|
||||
|
||||
#: Human-readable description.
|
||||
description: str | None = None
|
||||
|
||||
#: Whether this parameter is required.
|
||||
required: bool = False
|
||||
|
||||
#: Default value if any.
|
||||
default: Any = None
|
||||
|
||||
#: Enum values if constrained.
|
||||
enum: list[Any] | None = None
|
||||
|
||||
|
||||
class HubTool(BaseModel):
|
||||
"""An MCP tool discovered from a hub server.
|
||||
|
||||
This is populated by calling `list_tools()` on the MCP server.
|
||||
|
||||
"""
|
||||
|
||||
#: Tool name as defined by the MCP server.
|
||||
name: str = Field(description="Tool name from MCP server")
|
||||
|
||||
#: Human-readable description.
|
||||
description: str | None = Field(
|
||||
default=None,
|
||||
description="Tool description",
|
||||
)
|
||||
|
||||
#: Name of the hub server this tool belongs to.
|
||||
server_name: str = Field(description="Parent server name")
|
||||
|
||||
#: Parsed parameters from inputSchema.
|
||||
parameters: list[HubToolParameter] = Field(
|
||||
default_factory=list,
|
||||
description="Tool parameters",
|
||||
)
|
||||
|
||||
#: Raw JSON Schema for the tool input.
|
||||
input_schema: dict[str, Any] = Field(
|
||||
default_factory=dict,
|
||||
description="Raw JSON Schema from MCP",
|
||||
)
|
||||
|
||||
@property
|
||||
def identifier(self) -> str:
|
||||
"""Get the full tool identifier (hub:server:tool)."""
|
||||
return f"hub:{self.server_name}:{self.name}"
|
||||
|
||||
@classmethod
|
||||
def from_mcp_tool(
|
||||
cls,
|
||||
server_name: str,
|
||||
name: str,
|
||||
description: str | None,
|
||||
input_schema: dict[str, Any],
|
||||
) -> HubTool:
|
||||
"""Create a HubTool from MCP tool metadata.
|
||||
|
||||
:param server_name: Name of the parent hub server.
|
||||
:param name: Tool name.
|
||||
:param description: Tool description.
|
||||
:param input_schema: JSON Schema for tool input.
|
||||
:returns: HubTool instance.
|
||||
|
||||
"""
|
||||
parameters = cls._parse_parameters(input_schema)
|
||||
return cls(
|
||||
name=name,
|
||||
description=description,
|
||||
server_name=server_name,
|
||||
parameters=parameters,
|
||||
input_schema=input_schema,
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _parse_parameters(schema: dict[str, Any]) -> list[HubToolParameter]:
|
||||
"""Parse parameters from JSON Schema.
|
||||
|
||||
:param schema: JSON Schema dict.
|
||||
:returns: List of parsed parameters.
|
||||
|
||||
"""
|
||||
parameters: list[HubToolParameter] = []
|
||||
properties = schema.get("properties", {})
|
||||
required_params = set(schema.get("required", []))
|
||||
|
||||
for name, prop in properties.items():
|
||||
param = HubToolParameter(
|
||||
name=name,
|
||||
type=prop.get("type", "string"),
|
||||
description=prop.get("description"),
|
||||
required=name in required_params,
|
||||
default=prop.get("default"),
|
||||
enum=prop.get("enum"),
|
||||
)
|
||||
parameters.append(param)
|
||||
|
||||
return parameters
|
||||
|
||||
|
||||
class HubServer(BaseModel):
|
||||
"""A hub server with its discovered tools.
|
||||
|
||||
Combines configuration with dynamically discovered tools.
|
||||
|
||||
"""
|
||||
|
||||
#: Server configuration.
|
||||
config: HubServerConfig
|
||||
|
||||
#: Tools discovered from the server (populated at runtime).
|
||||
tools: list[HubTool] = Field(
|
||||
default_factory=list,
|
||||
description="Discovered tools",
|
||||
)
|
||||
|
||||
#: Whether tools have been discovered.
|
||||
discovered: bool = Field(
|
||||
default=False,
|
||||
description="Whether tools have been discovered",
|
||||
)
|
||||
|
||||
#: Error message if discovery failed.
|
||||
discovery_error: str | None = Field(
|
||||
default=None,
|
||||
description="Error message if discovery failed",
|
||||
)
|
||||
|
||||
@property
|
||||
def name(self) -> str:
|
||||
"""Get server name."""
|
||||
return self.config.name
|
||||
|
||||
@property
|
||||
def identifier(self) -> str:
|
||||
"""Get server identifier for module listing."""
|
||||
return f"hub:{self.config.name}"
|
||||
|
||||
def get_tool(self, tool_name: str) -> HubTool | None:
|
||||
"""Get a tool by name.
|
||||
|
||||
:param tool_name: Name of the tool.
|
||||
:returns: HubTool if found, None otherwise.
|
||||
|
||||
"""
|
||||
for tool in self.tools:
|
||||
if tool.name == tool_name:
|
||||
return tool
|
||||
return None
|
||||
|
||||
|
||||
class HubConfig(BaseModel):
|
||||
"""Configuration for the entire hub.
|
||||
|
||||
Loaded from hub-servers.json or similar config file.
|
||||
|
||||
"""
|
||||
|
||||
#: List of configured servers.
|
||||
servers: list[HubServerConfig] = Field(
|
||||
default_factory=list,
|
||||
description="Configured MCP servers",
|
||||
)
|
||||
|
||||
#: Default timeout for tool execution (seconds).
|
||||
default_timeout: int = Field(
|
||||
default=300,
|
||||
description="Default execution timeout",
|
||||
)
|
||||
|
||||
#: Whether to cache discovered tools.
|
||||
cache_tools: bool = Field(
|
||||
default=True,
|
||||
description="Cache discovered tools",
|
||||
)
|
||||
258
fuzzforge-common/src/fuzzforge_common/hub/registry.py
Normal file
258
fuzzforge-common/src/fuzzforge_common/hub/registry.py
Normal file
@@ -0,0 +1,258 @@
|
||||
"""Hub registry for managing MCP server configurations.
|
||||
|
||||
The registry loads server configurations from a JSON file and provides
|
||||
methods to access and manage them. It does not hardcode any specific
|
||||
servers or tools - everything is configured by the user.
|
||||
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from pathlib import Path
|
||||
from typing import TYPE_CHECKING, cast
|
||||
|
||||
from fuzzforge_common.hub.models import (
|
||||
HubConfig,
|
||||
HubServer,
|
||||
HubServerConfig,
|
||||
)
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from structlog.stdlib import BoundLogger
|
||||
|
||||
|
||||
def get_logger() -> BoundLogger:
|
||||
"""Get structlog logger instance.
|
||||
|
||||
:returns: Configured structlog logger.
|
||||
|
||||
"""
|
||||
from structlog import get_logger # noqa: PLC0415
|
||||
|
||||
return cast("BoundLogger", get_logger())
|
||||
|
||||
|
||||
class HubRegistry:
|
||||
"""Registry for MCP hub servers.
|
||||
|
||||
Manages the configuration and state of hub servers.
|
||||
Configurations are loaded from a JSON file.
|
||||
|
||||
"""
|
||||
|
||||
#: Loaded hub configuration.
|
||||
_config: HubConfig
|
||||
|
||||
#: Server instances with discovered tools.
|
||||
_servers: dict[str, HubServer]
|
||||
|
||||
#: Path to the configuration file.
|
||||
_config_path: Path | None
|
||||
|
||||
def __init__(self, config_path: Path | str | None = None) -> None:
|
||||
"""Initialize the hub registry.
|
||||
|
||||
:param config_path: Path to hub-servers.json config file.
|
||||
If None, starts with empty configuration.
|
||||
|
||||
"""
|
||||
if config_path is not None:
|
||||
self._config_path = Path(config_path)
|
||||
else:
|
||||
self._config_path = None
|
||||
self._servers = {}
|
||||
self._config = HubConfig()
|
||||
|
||||
if self._config_path and self._config_path.exists():
|
||||
self._load_config(self._config_path)
|
||||
|
||||
def _load_config(self, config_path: Path) -> None:
|
||||
"""Load configuration from JSON file.
|
||||
|
||||
:param config_path: Path to config file.
|
||||
|
||||
"""
|
||||
logger = get_logger()
|
||||
try:
|
||||
with config_path.open() as f:
|
||||
data = json.load(f)
|
||||
|
||||
self._config = HubConfig.model_validate(data)
|
||||
|
||||
# Create server instances from config
|
||||
for server_config in self._config.servers:
|
||||
if server_config.enabled:
|
||||
self._servers[server_config.name] = HubServer(
|
||||
config=server_config,
|
||||
)
|
||||
|
||||
logger.info(
|
||||
"Loaded hub configuration",
|
||||
path=str(config_path),
|
||||
servers=len(self._servers),
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(
|
||||
"Failed to load hub configuration",
|
||||
path=str(config_path),
|
||||
error=str(e),
|
||||
)
|
||||
raise
|
||||
|
||||
def reload(self) -> None:
|
||||
"""Reload configuration from file."""
|
||||
if self._config_path and self._config_path.exists():
|
||||
self._servers.clear()
|
||||
self._load_config(self._config_path)
|
||||
|
||||
@property
|
||||
def servers(self) -> list[HubServer]:
|
||||
"""Get all registered servers.
|
||||
|
||||
:returns: List of hub servers.
|
||||
|
||||
"""
|
||||
return list(self._servers.values())
|
||||
|
||||
@property
|
||||
def enabled_servers(self) -> list[HubServer]:
|
||||
"""Get all enabled servers.
|
||||
|
||||
:returns: List of enabled hub servers.
|
||||
|
||||
"""
|
||||
return [s for s in self._servers.values() if s.config.enabled]
|
||||
|
||||
def get_server(self, name: str) -> HubServer | None:
|
||||
"""Get a server by name.
|
||||
|
||||
:param name: Server name.
|
||||
:returns: HubServer if found, None otherwise.
|
||||
|
||||
"""
|
||||
return self._servers.get(name)
|
||||
|
||||
def add_server(self, config: HubServerConfig) -> HubServer:
|
||||
"""Add a server to the registry.
|
||||
|
||||
:param config: Server configuration.
|
||||
:returns: Created HubServer instance.
|
||||
:raises ValueError: If server with same name exists.
|
||||
|
||||
"""
|
||||
if config.name in self._servers:
|
||||
msg = f"Server '{config.name}' already exists"
|
||||
raise ValueError(msg)
|
||||
|
||||
server = HubServer(config=config)
|
||||
self._servers[config.name] = server
|
||||
self._config.servers.append(config)
|
||||
|
||||
get_logger().info("Added hub server", name=config.name, type=config.type)
|
||||
return server
|
||||
|
||||
def remove_server(self, name: str) -> bool:
|
||||
"""Remove a server from the registry.
|
||||
|
||||
:param name: Server name.
|
||||
:returns: True if removed, False if not found.
|
||||
|
||||
"""
|
||||
if name not in self._servers:
|
||||
return False
|
||||
|
||||
del self._servers[name]
|
||||
self._config.servers = [s for s in self._config.servers if s.name != name]
|
||||
|
||||
get_logger().info("Removed hub server", name=name)
|
||||
return True
|
||||
|
||||
def save_config(self, path: Path | None = None) -> None:
|
||||
"""Save current configuration to file.
|
||||
|
||||
:param path: Path to save to. Uses original path if None.
|
||||
|
||||
"""
|
||||
save_path = path or self._config_path
|
||||
if not save_path:
|
||||
msg = "No config path specified"
|
||||
raise ValueError(msg)
|
||||
|
||||
with save_path.open("w") as f:
|
||||
json.dump(
|
||||
self._config.model_dump(mode="json"),
|
||||
f,
|
||||
indent=2,
|
||||
)
|
||||
|
||||
get_logger().info("Saved hub configuration", path=str(save_path))
|
||||
|
||||
def update_server_tools(
|
||||
self,
|
||||
server_name: str,
|
||||
tools: list,
|
||||
*,
|
||||
error: str | None = None,
|
||||
) -> None:
|
||||
"""Update discovered tools for a server.
|
||||
|
||||
Called by the hub client after tool discovery.
|
||||
|
||||
:param server_name: Server name.
|
||||
:param tools: List of HubTool instances.
|
||||
:param error: Error message if discovery failed.
|
||||
|
||||
"""
|
||||
server = self._servers.get(server_name)
|
||||
if not server:
|
||||
return
|
||||
|
||||
if error:
|
||||
server.discovered = False
|
||||
server.discovery_error = error
|
||||
server.tools = []
|
||||
else:
|
||||
server.discovered = True
|
||||
server.discovery_error = None
|
||||
server.tools = tools
|
||||
|
||||
def get_all_tools(self) -> list:
|
||||
"""Get all discovered tools from all servers.
|
||||
|
||||
:returns: Flat list of all HubTool instances.
|
||||
|
||||
"""
|
||||
tools = []
|
||||
for server in self._servers.values():
|
||||
if server.discovered:
|
||||
tools.extend(server.tools)
|
||||
return tools
|
||||
|
||||
def find_tool(self, identifier: str):
|
||||
"""Find a tool by its full identifier.
|
||||
|
||||
:param identifier: Full identifier (hub:server:tool or server:tool).
|
||||
:returns: Tuple of (HubServer, HubTool) if found, (None, None) otherwise.
|
||||
|
||||
"""
|
||||
# Parse identifier
|
||||
parts = identifier.split(":")
|
||||
if len(parts) == 3 and parts[0] == "hub": # noqa: PLR2004
|
||||
# hub:server:tool format
|
||||
server_name = parts[1]
|
||||
tool_name = parts[2]
|
||||
elif len(parts) == 2: # noqa: PLR2004
|
||||
# server:tool format
|
||||
server_name = parts[0]
|
||||
tool_name = parts[1]
|
||||
else:
|
||||
return None, None
|
||||
|
||||
server = self._servers.get(server_name)
|
||||
if not server:
|
||||
return None, None
|
||||
|
||||
tool = server.get_tool(tool_name)
|
||||
return server, tool
|
||||
@@ -272,6 +272,23 @@ class AbstractFuzzForgeSandboxEngine(ABC):
|
||||
message: str = f"method 'read_file_from_container' is not implemented for class '{self.__class__.__name__}'"
|
||||
raise NotImplementedError(message)
|
||||
|
||||
@abstractmethod
|
||||
def tail_file_from_container(self, identifier: str, path: str, start_line: int = 1) -> str:
|
||||
"""Read a file from a running container starting at a given line number.
|
||||
|
||||
Uses ``tail -n +{start_line}`` to avoid re-reading the entire file on
|
||||
every poll. This is the preferred method for incremental reads of
|
||||
append-only files such as ``stream.jsonl``.
|
||||
|
||||
:param identifier: Container identifier.
|
||||
:param path: Path to file inside container.
|
||||
:param start_line: 1-based line number to start reading from.
|
||||
:returns: File contents from *start_line* onwards (may be empty).
|
||||
|
||||
"""
|
||||
message: str = f"method 'tail_file_from_container' is not implemented for class '{self.__class__.__name__}'"
|
||||
raise NotImplementedError(message)
|
||||
|
||||
@abstractmethod
|
||||
def list_containers(self, all_containers: bool = True) -> list[dict]:
|
||||
"""List containers.
|
||||
|
||||
@@ -389,6 +389,24 @@ class DockerCLI(AbstractFuzzForgeSandboxEngine):
|
||||
return ""
|
||||
return result.stdout
|
||||
|
||||
def tail_file_from_container(self, identifier: str, path: str, start_line: int = 1) -> str:
|
||||
"""Read a file from a container starting at a given line number.
|
||||
|
||||
:param identifier: Container identifier.
|
||||
:param path: Path to file in container.
|
||||
:param start_line: 1-based line number to start reading from.
|
||||
:returns: File contents from *start_line* onwards.
|
||||
|
||||
"""
|
||||
result = self._run(
|
||||
["exec", identifier, "tail", "-n", f"+{start_line}", path],
|
||||
check=False,
|
||||
)
|
||||
if result.returncode != 0:
|
||||
get_logger().debug("failed to tail file from container", path=path, start_line=start_line)
|
||||
return ""
|
||||
return result.stdout
|
||||
|
||||
def list_containers(self, all_containers: bool = True) -> list[dict]:
|
||||
"""List containers.
|
||||
|
||||
|
||||
@@ -168,6 +168,11 @@ class Docker(AbstractFuzzForgeSandboxEngine):
|
||||
message: str = "Docker engine read_file_from_container is not yet implemented"
|
||||
raise NotImplementedError(message)
|
||||
|
||||
def tail_file_from_container(self, identifier: str, path: str, start_line: int = 1) -> str:
|
||||
"""Read a file from a container starting at a given line number."""
|
||||
message: str = "Docker engine tail_file_from_container is not yet implemented"
|
||||
raise NotImplementedError(message)
|
||||
|
||||
def list_containers(self, all_containers: bool = True) -> list[dict]:
|
||||
"""List containers."""
|
||||
message: str = "Docker engine list_containers is not yet implemented"
|
||||
|
||||
@@ -449,6 +449,24 @@ class PodmanCLI(AbstractFuzzForgeSandboxEngine):
|
||||
return ""
|
||||
return result.stdout
|
||||
|
||||
def tail_file_from_container(self, identifier: str, path: str, start_line: int = 1) -> str:
|
||||
"""Read a file from a container starting at a given line number.
|
||||
|
||||
:param identifier: Container identifier.
|
||||
:param path: Path to file in container.
|
||||
:param start_line: 1-based line number to start reading from.
|
||||
:returns: File contents from *start_line* onwards.
|
||||
|
||||
"""
|
||||
result = self._run(
|
||||
["exec", identifier, "tail", "-n", f"+{start_line}", path],
|
||||
check=False,
|
||||
)
|
||||
if result.returncode != 0:
|
||||
get_logger().debug("failed to tail file from container", path=path, start_line=start_line)
|
||||
return ""
|
||||
return result.stdout
|
||||
|
||||
def list_containers(self, all_containers: bool = True) -> list[dict]:
|
||||
"""List containers.
|
||||
|
||||
|
||||
@@ -475,6 +475,30 @@ class Podman(AbstractFuzzForgeSandboxEngine):
|
||||
return ""
|
||||
return stdout.decode("utf-8", errors="replace") if stdout else ""
|
||||
|
||||
def tail_file_from_container(self, identifier: str, path: str, start_line: int = 1) -> str:
|
||||
"""Read a file from a container starting at a given line number.
|
||||
|
||||
:param identifier: Container identifier.
|
||||
:param path: Path to file inside container.
|
||||
:param start_line: 1-based line number to start reading from.
|
||||
:returns: File contents from *start_line* onwards.
|
||||
|
||||
"""
|
||||
client: PodmanClient = self.get_client()
|
||||
with client:
|
||||
container: Container = client.containers.get(key=identifier)
|
||||
(status, (stdout, stderr)) = container.exec_run(
|
||||
cmd=["tail", "-n", f"+{start_line}", path],
|
||||
demux=True,
|
||||
)
|
||||
if status != 0:
|
||||
error_msg = stderr.decode("utf-8", errors="replace") if stderr else "File not found"
|
||||
get_logger().debug(
|
||||
"failed to tail file from container", path=path, start_line=start_line, error=error_msg,
|
||||
)
|
||||
return ""
|
||||
return stdout.decode("utf-8", errors="replace") if stdout else ""
|
||||
|
||||
def list_containers(self, all_containers: bool = True) -> list[dict]:
|
||||
"""List containers.
|
||||
|
||||
|
||||
@@ -45,11 +45,11 @@ For custom setups, you can manually configure the MCP server.
|
||||
{
|
||||
"mcpServers": {
|
||||
"fuzzforge": {
|
||||
"command": "/path/to/fuzzforge-oss/.venv/bin/python",
|
||||
"command": "/path/to/fuzzforge_ai/.venv/bin/python",
|
||||
"args": ["-m", "fuzzforge_mcp"],
|
||||
"cwd": "/path/to/fuzzforge-oss",
|
||||
"cwd": "/path/to/fuzzforge_ai",
|
||||
"env": {
|
||||
"FUZZFORGE_MODULES_PATH": "/path/to/fuzzforge-oss/fuzzforge-modules",
|
||||
"FUZZFORGE_MODULES_PATH": "/path/to/fuzzforge_ai/fuzzforge-modules",
|
||||
"FUZZFORGE_ENGINE__TYPE": "docker"
|
||||
}
|
||||
}
|
||||
@@ -64,11 +64,11 @@ For custom setups, you can manually configure the MCP server.
|
||||
"servers": {
|
||||
"fuzzforge": {
|
||||
"type": "stdio",
|
||||
"command": "/path/to/fuzzforge-oss/.venv/bin/python",
|
||||
"command": "/path/to/fuzzforge_ai/.venv/bin/python",
|
||||
"args": ["-m", "fuzzforge_mcp"],
|
||||
"cwd": "/path/to/fuzzforge-oss",
|
||||
"cwd": "/path/to/fuzzforge_ai",
|
||||
"env": {
|
||||
"FUZZFORGE_MODULES_PATH": "/path/to/fuzzforge-oss/fuzzforge-modules",
|
||||
"FUZZFORGE_MODULES_PATH": "/path/to/fuzzforge_ai/fuzzforge-modules",
|
||||
"FUZZFORGE_ENGINE__TYPE": "docker"
|
||||
}
|
||||
}
|
||||
@@ -83,11 +83,11 @@ For custom setups, you can manually configure the MCP server.
|
||||
"mcpServers": {
|
||||
"fuzzforge": {
|
||||
"type": "stdio",
|
||||
"command": "/path/to/fuzzforge-oss/.venv/bin/python",
|
||||
"command": "/path/to/fuzzforge_ai/.venv/bin/python",
|
||||
"args": ["-m", "fuzzforge_mcp"],
|
||||
"cwd": "/path/to/fuzzforge-oss",
|
||||
"cwd": "/path/to/fuzzforge_ai",
|
||||
"env": {
|
||||
"FUZZFORGE_MODULES_PATH": "/path/to/fuzzforge-oss/fuzzforge-modules",
|
||||
"FUZZFORGE_MODULES_PATH": "/path/to/fuzzforge_ai/fuzzforge-modules",
|
||||
"FUZZFORGE_ENGINE__TYPE": "docker"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,14 +1,13 @@
|
||||
[project]
|
||||
name = "fuzzforge-mcp"
|
||||
version = "0.0.1"
|
||||
description = "FuzzForge MCP Server - AI agent gateway for FuzzForge OSS."
|
||||
description = "FuzzForge MCP Server - AI agent gateway for FuzzForge AI."
|
||||
authors = []
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.14"
|
||||
dependencies = [
|
||||
"fastmcp==2.14.1",
|
||||
"fuzzforge-common==0.0.1",
|
||||
"fuzzforge-runner==0.0.1",
|
||||
"pydantic==2.12.4",
|
||||
"pydantic-settings==2.12.0",
|
||||
"structlog==25.5.0",
|
||||
@@ -32,5 +31,4 @@ tests = [
|
||||
|
||||
[tool.uv.sources]
|
||||
fuzzforge-common = { workspace = true }
|
||||
fuzzforge-runner = { workspace = true }
|
||||
fuzzforge-tests = { workspace = true }
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
"""FuzzForge MCP Server Application.
|
||||
|
||||
This is the main entry point for the FuzzForge MCP server, providing
|
||||
AI agents with tools to execute security research modules.
|
||||
AI agents with tools to discover and execute MCP hub tools for
|
||||
security research.
|
||||
|
||||
"""
|
||||
|
||||
@@ -12,7 +13,7 @@ from fastmcp import FastMCP
|
||||
from fastmcp.server.middleware.error_handling import ErrorHandlingMiddleware
|
||||
|
||||
from fuzzforge_mcp import resources, tools
|
||||
from fuzzforge_runner import Settings
|
||||
from fuzzforge_mcp.settings import Settings
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from collections.abc import AsyncGenerator
|
||||
@@ -38,18 +39,23 @@ mcp: FastMCP = FastMCP(
|
||||
instructions="""
|
||||
FuzzForge is a security research orchestration platform. Use these tools to:
|
||||
|
||||
1. **List modules**: Discover available security research modules
|
||||
2. **Execute modules**: Run modules in isolated containers
|
||||
3. **Execute workflows**: Chain multiple modules together
|
||||
1. **List hub servers**: Discover registered MCP tool servers
|
||||
2. **Discover tools**: Find available tools from hub servers
|
||||
3. **Execute hub tools**: Run security tools in isolated containers
|
||||
4. **Manage projects**: Initialize and configure projects
|
||||
5. **Get results**: Retrieve execution results
|
||||
|
||||
Typical workflow:
|
||||
1. Initialize a project with `init_project`
|
||||
2. Set project assets with `set_project_assets` (optional, only needed once for the source directory)
|
||||
3. List available modules with `list_modules`
|
||||
4. Execute a module with `execute_module` — use `assets_path` param to pass different inputs per module
|
||||
5. Read outputs from `results_path` returned by `execute_module` — check module's `output_artifacts` metadata for filenames
|
||||
3. List available hub servers with `list_hub_servers`
|
||||
4. Discover tools from servers with `discover_hub_tools`
|
||||
5. Execute hub tools with `execute_hub_tool`
|
||||
|
||||
Hub workflow:
|
||||
1. List available hub servers with `list_hub_servers`
|
||||
2. Discover tools from servers with `discover_hub_tools`
|
||||
3. Execute hub tools with `execute_hub_tool`
|
||||
""",
|
||||
lifespan=lifespan,
|
||||
)
|
||||
|
||||
@@ -6,9 +6,10 @@ from pathlib import Path
|
||||
from typing import TYPE_CHECKING, cast
|
||||
|
||||
from fastmcp.server.dependencies import get_context
|
||||
from fuzzforge_runner import Runner, Settings
|
||||
|
||||
from fuzzforge_mcp.exceptions import FuzzForgeMCPError
|
||||
from fuzzforge_mcp.settings import Settings
|
||||
from fuzzforge_mcp.storage import LocalStorage
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from fastmcp import Context
|
||||
@@ -17,6 +18,9 @@ if TYPE_CHECKING:
|
||||
# Track the current active project path (set by init_project)
|
||||
_current_project_path: Path | None = None
|
||||
|
||||
# Singleton storage instance
|
||||
_storage: LocalStorage | None = None
|
||||
|
||||
|
||||
def set_current_project_path(project_path: Path) -> None:
|
||||
"""Set the current project path.
|
||||
@@ -60,11 +64,14 @@ def get_project_path() -> Path:
|
||||
return Path.cwd()
|
||||
|
||||
|
||||
def get_runner() -> Runner:
|
||||
"""Get a configured Runner instance.
|
||||
def get_storage() -> LocalStorage:
|
||||
"""Get the storage backend instance.
|
||||
|
||||
:return: Runner instance configured from MCP settings.
|
||||
:return: LocalStorage instance.
|
||||
|
||||
"""
|
||||
settings: Settings = get_settings()
|
||||
return Runner(settings)
|
||||
global _storage
|
||||
if _storage is None:
|
||||
settings = get_settings()
|
||||
_storage = LocalStorage(settings.storage.path)
|
||||
return _storage
|
||||
|
||||
@@ -2,14 +2,12 @@
|
||||
|
||||
from fastmcp import FastMCP
|
||||
|
||||
from fuzzforge_mcp.resources import executions, modules, project, workflows
|
||||
from fuzzforge_mcp.resources import executions, project
|
||||
|
||||
mcp: FastMCP = FastMCP()
|
||||
|
||||
mcp.mount(executions.mcp)
|
||||
mcp.mount(modules.mcp)
|
||||
mcp.mount(project.mcp)
|
||||
mcp.mount(workflows.mcp)
|
||||
|
||||
__all__ = [
|
||||
"mcp",
|
||||
|
||||
@@ -3,15 +3,12 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
from typing import TYPE_CHECKING, Any
|
||||
from typing import Any
|
||||
|
||||
from fastmcp import FastMCP
|
||||
from fastmcp.exceptions import ResourceError
|
||||
|
||||
from fuzzforge_mcp.dependencies import get_project_path, get_runner
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from fuzzforge_runner import Runner
|
||||
from fuzzforge_mcp.dependencies import get_project_path, get_storage
|
||||
|
||||
|
||||
mcp: FastMCP = FastMCP()
|
||||
@@ -26,16 +23,16 @@ async def list_executions() -> list[dict[str, Any]]:
|
||||
:return: List of execution information dictionaries.
|
||||
|
||||
"""
|
||||
runner: Runner = get_runner()
|
||||
storage = get_storage()
|
||||
project_path: Path = get_project_path()
|
||||
|
||||
try:
|
||||
execution_ids = runner.list_executions(project_path)
|
||||
execution_ids = storage.list_executions(project_path)
|
||||
|
||||
return [
|
||||
{
|
||||
"execution_id": exec_id,
|
||||
"has_results": runner.get_execution_results(project_path, exec_id) is not None,
|
||||
"has_results": storage.get_execution_results(project_path, exec_id) is not None,
|
||||
}
|
||||
for exec_id in execution_ids
|
||||
]
|
||||
@@ -53,11 +50,11 @@ async def get_execution(execution_id: str) -> dict[str, Any]:
|
||||
:return: Execution information dictionary.
|
||||
|
||||
"""
|
||||
runner: Runner = get_runner()
|
||||
storage = get_storage()
|
||||
project_path: Path = get_project_path()
|
||||
|
||||
try:
|
||||
results_path = runner.get_execution_results(project_path, execution_id)
|
||||
results_path = storage.get_execution_results(project_path, execution_id)
|
||||
|
||||
if results_path is None:
|
||||
raise ResourceError(f"Execution not found: {execution_id}")
|
||||
|
||||
@@ -1,78 +0,0 @@
|
||||
"""Module resources for FuzzForge MCP."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import TYPE_CHECKING, Any
|
||||
|
||||
from fastmcp import FastMCP
|
||||
from fastmcp.exceptions import ResourceError
|
||||
|
||||
from fuzzforge_mcp.dependencies import get_runner
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from fuzzforge_runner import Runner
|
||||
from fuzzforge_runner.runner import ModuleInfo
|
||||
|
||||
|
||||
mcp: FastMCP = FastMCP()
|
||||
|
||||
|
||||
@mcp.resource("fuzzforge://modules/")
|
||||
async def list_modules() -> list[dict[str, Any]]:
|
||||
"""List all available FuzzForge modules.
|
||||
|
||||
Returns information about modules that can be executed,
|
||||
including their identifiers and availability status.
|
||||
|
||||
:return: List of module information dictionaries.
|
||||
|
||||
"""
|
||||
runner: Runner = get_runner()
|
||||
|
||||
try:
|
||||
modules: list[ModuleInfo] = runner.list_modules()
|
||||
|
||||
return [
|
||||
{
|
||||
"identifier": module.identifier,
|
||||
"description": module.description,
|
||||
"version": module.version,
|
||||
"available": module.available,
|
||||
}
|
||||
for module in modules
|
||||
]
|
||||
|
||||
except Exception as exception:
|
||||
message: str = f"Failed to list modules: {exception}"
|
||||
raise ResourceError(message) from exception
|
||||
|
||||
|
||||
@mcp.resource("fuzzforge://modules/{module_identifier}")
|
||||
async def get_module(module_identifier: str) -> dict[str, Any]:
|
||||
"""Get information about a specific module.
|
||||
|
||||
:param module_identifier: The identifier of the module to retrieve.
|
||||
:return: Module information dictionary.
|
||||
|
||||
"""
|
||||
runner: Runner = get_runner()
|
||||
|
||||
try:
|
||||
module: ModuleInfo | None = runner.get_module_info(module_identifier)
|
||||
|
||||
if module is None:
|
||||
raise ResourceError(f"Module not found: {module_identifier}")
|
||||
|
||||
return {
|
||||
"identifier": module.identifier,
|
||||
"description": module.description,
|
||||
"version": module.version,
|
||||
"available": module.available,
|
||||
}
|
||||
|
||||
except ResourceError:
|
||||
raise
|
||||
except Exception as exception:
|
||||
message: str = f"Failed to get module: {exception}"
|
||||
raise ResourceError(message) from exception
|
||||
|
||||
@@ -3,15 +3,12 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
from typing import TYPE_CHECKING, Any
|
||||
from typing import Any
|
||||
|
||||
from fastmcp import FastMCP
|
||||
from fastmcp.exceptions import ResourceError
|
||||
|
||||
from fuzzforge_mcp.dependencies import get_project_path, get_runner
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from fuzzforge_runner import Runner
|
||||
from fuzzforge_mcp.dependencies import get_project_path, get_settings, get_storage
|
||||
|
||||
|
||||
mcp: FastMCP = FastMCP()
|
||||
@@ -27,12 +24,12 @@ async def get_project() -> dict[str, Any]:
|
||||
:return: Project information dictionary.
|
||||
|
||||
"""
|
||||
runner: Runner = get_runner()
|
||||
storage = get_storage()
|
||||
project_path: Path = get_project_path()
|
||||
|
||||
try:
|
||||
executions = runner.list_executions(project_path)
|
||||
assets_path = runner.storage.get_project_assets_path(project_path)
|
||||
executions = storage.list_executions(project_path)
|
||||
assets_path = storage.get_project_assets_path(project_path)
|
||||
|
||||
return {
|
||||
"path": str(project_path),
|
||||
@@ -40,7 +37,7 @@ async def get_project() -> dict[str, Any]:
|
||||
"has_assets": assets_path is not None,
|
||||
"assets_path": str(assets_path) if assets_path else None,
|
||||
"execution_count": len(executions),
|
||||
"recent_executions": executions[:10], # Last 10 executions
|
||||
"recent_executions": executions[:10],
|
||||
}
|
||||
|
||||
except Exception as exception:
|
||||
@@ -53,13 +50,11 @@ async def get_project_settings() -> dict[str, Any]:
|
||||
"""Get current FuzzForge settings.
|
||||
|
||||
Returns the active configuration for the MCP server including
|
||||
engine, storage, and project settings.
|
||||
engine, storage, and hub settings.
|
||||
|
||||
:return: Settings dictionary.
|
||||
|
||||
"""
|
||||
from fuzzforge_mcp.dependencies import get_settings
|
||||
|
||||
try:
|
||||
settings = get_settings()
|
||||
|
||||
@@ -71,9 +66,10 @@ async def get_project_settings() -> dict[str, Any]:
|
||||
"storage": {
|
||||
"path": str(settings.storage.path),
|
||||
},
|
||||
"project": {
|
||||
"path": str(settings.project.path),
|
||||
"modules_path": str(settings.modules_path),
|
||||
"hub": {
|
||||
"enabled": settings.hub.enabled,
|
||||
"config_path": str(settings.hub.config_path),
|
||||
"timeout": settings.hub.timeout,
|
||||
},
|
||||
"debug": settings.debug,
|
||||
}
|
||||
|
||||
@@ -1,53 +0,0 @@
|
||||
"""Workflow resources for FuzzForge MCP.
|
||||
|
||||
Note: In FuzzForge OSS, workflows are defined at runtime rather than
|
||||
stored. This resource provides documentation about workflow capabilities.
|
||||
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
from fastmcp import FastMCP
|
||||
|
||||
|
||||
mcp: FastMCP = FastMCP()
|
||||
|
||||
|
||||
@mcp.resource("fuzzforge://workflows/help")
|
||||
async def get_workflow_help() -> dict[str, Any]:
|
||||
"""Get help information about creating workflows.
|
||||
|
||||
Workflows in FuzzForge OSS are defined at execution time rather
|
||||
than stored. Use the execute_workflow tool with step definitions.
|
||||
|
||||
:return: Workflow documentation.
|
||||
|
||||
"""
|
||||
return {
|
||||
"description": "Workflows chain multiple modules together",
|
||||
"usage": "Use the execute_workflow tool with step definitions",
|
||||
"example": {
|
||||
"workflow_name": "security-audit",
|
||||
"steps": [
|
||||
{
|
||||
"module": "compile-contracts",
|
||||
"configuration": {"solc_version": "0.8.0"},
|
||||
},
|
||||
{
|
||||
"module": "slither",
|
||||
"configuration": {},
|
||||
},
|
||||
{
|
||||
"module": "echidna",
|
||||
"configuration": {"test_limit": 10000},
|
||||
},
|
||||
],
|
||||
},
|
||||
"step_format": {
|
||||
"module": "Module identifier (required)",
|
||||
"configuration": "Module-specific configuration (optional)",
|
||||
"name": "Step name for logging (optional)",
|
||||
},
|
||||
}
|
||||
113
fuzzforge-mcp/src/fuzzforge_mcp/settings.py
Normal file
113
fuzzforge-mcp/src/fuzzforge_mcp/settings.py
Normal file
@@ -0,0 +1,113 @@
|
||||
"""FuzzForge MCP Server settings.
|
||||
|
||||
Standalone settings for the MCP server. Replaces the previous dependency
|
||||
on fuzzforge-runner Settings now that the module system has been removed
|
||||
and FuzzForge operates exclusively through MCP hub tools.
|
||||
|
||||
All settings can be configured via environment variables with the prefix
|
||||
``FUZZFORGE_``. Nested settings use double-underscore as delimiter.
|
||||
|
||||
Example:
|
||||
``FUZZFORGE_ENGINE__TYPE=docker``
|
||||
``FUZZFORGE_STORAGE__PATH=/data/fuzzforge``
|
||||
``FUZZFORGE_HUB__CONFIG_PATH=/path/to/hub-config.json``
|
||||
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from enum import StrEnum
|
||||
from pathlib import Path
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
from pydantic_settings import BaseSettings, SettingsConfigDict
|
||||
|
||||
|
||||
class EngineType(StrEnum):
|
||||
"""Supported container engine types."""
|
||||
|
||||
DOCKER = "docker"
|
||||
PODMAN = "podman"
|
||||
|
||||
|
||||
class EngineSettings(BaseModel):
|
||||
"""Container engine configuration."""
|
||||
|
||||
#: Type of container engine to use.
|
||||
type: EngineType = EngineType.DOCKER
|
||||
|
||||
#: Path to the container engine socket.
|
||||
socket: str = Field(default="")
|
||||
|
||||
#: Custom graph root for Podman storage.
|
||||
graphroot: Path = Field(default=Path.home() / ".fuzzforge" / "containers" / "storage")
|
||||
|
||||
#: Custom run root for Podman runtime state.
|
||||
runroot: Path = Field(default=Path.home() / ".fuzzforge" / "containers" / "run")
|
||||
|
||||
|
||||
class StorageSettings(BaseModel):
|
||||
"""Storage configuration for local filesystem storage."""
|
||||
|
||||
#: Base path for local storage.
|
||||
path: Path = Field(default=Path.home() / ".fuzzforge" / "storage")
|
||||
|
||||
|
||||
class ProjectSettings(BaseModel):
|
||||
"""Project configuration."""
|
||||
|
||||
#: Default path for FuzzForge projects.
|
||||
default_path: Path = Field(default=Path.home() / ".fuzzforge" / "projects")
|
||||
|
||||
|
||||
class HubSettings(BaseModel):
|
||||
"""MCP Hub configuration for external tool servers.
|
||||
|
||||
Controls the hub that bridges FuzzForge with external MCP servers
|
||||
(e.g., mcp-security-hub). AI agents discover and execute tools
|
||||
from registered MCP servers.
|
||||
|
||||
Configure via environment variables:
|
||||
``FUZZFORGE_HUB__ENABLED=true``
|
||||
``FUZZFORGE_HUB__CONFIG_PATH=/path/to/hub-config.json``
|
||||
``FUZZFORGE_HUB__TIMEOUT=300``
|
||||
"""
|
||||
|
||||
#: Whether the MCP hub is enabled.
|
||||
enabled: bool = Field(default=True)
|
||||
|
||||
#: Path to the hub configuration JSON file.
|
||||
config_path: Path = Field(default=Path.home() / ".fuzzforge" / "hub-config.json")
|
||||
|
||||
#: Default timeout in seconds for hub tool execution.
|
||||
timeout: int = Field(default=300)
|
||||
|
||||
|
||||
class Settings(BaseSettings):
|
||||
"""FuzzForge MCP Server settings.
|
||||
|
||||
Settings can be configured via environment variables with the prefix
|
||||
``FUZZFORGE_``. Nested settings use double-underscore as delimiter.
|
||||
|
||||
"""
|
||||
|
||||
model_config = SettingsConfigDict(
|
||||
case_sensitive=False,
|
||||
env_nested_delimiter="__",
|
||||
env_prefix="FUZZFORGE_",
|
||||
)
|
||||
|
||||
#: Container engine settings.
|
||||
engine: EngineSettings = Field(default_factory=EngineSettings)
|
||||
|
||||
#: Storage settings.
|
||||
storage: StorageSettings = Field(default_factory=StorageSettings)
|
||||
|
||||
#: Project settings.
|
||||
project: ProjectSettings = Field(default_factory=ProjectSettings)
|
||||
|
||||
#: MCP Hub settings.
|
||||
hub: HubSettings = Field(default_factory=HubSettings)
|
||||
|
||||
#: Enable debug logging.
|
||||
debug: bool = False
|
||||
203
fuzzforge-mcp/src/fuzzforge_mcp/storage.py
Normal file
203
fuzzforge-mcp/src/fuzzforge_mcp/storage.py
Normal file
@@ -0,0 +1,203 @@
|
||||
"""FuzzForge MCP Server - Local project storage.
|
||||
|
||||
Lightweight project storage for managing `.fuzzforge/` directories,
|
||||
execution results, and project configuration. Extracted from the
|
||||
former fuzzforge-runner storage module.
|
||||
|
||||
Storage is placed directly in the project directory as `.fuzzforge/`
|
||||
for maximum visibility and ease of debugging.
|
||||
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
import shutil
|
||||
from pathlib import Path
|
||||
from tarfile import open as Archive # noqa: N812
|
||||
|
||||
logger = logging.getLogger("fuzzforge-mcp")
|
||||
|
||||
#: Name of the FuzzForge storage directory within projects.
|
||||
FUZZFORGE_DIR_NAME: str = ".fuzzforge"
|
||||
|
||||
#: Standard results archive filename.
|
||||
RESULTS_ARCHIVE_FILENAME: str = "results.tar.gz"
|
||||
|
||||
|
||||
class StorageError(Exception):
|
||||
"""Raised when a storage operation fails."""
|
||||
|
||||
|
||||
class LocalStorage:
|
||||
"""Local filesystem storage backend for FuzzForge.
|
||||
|
||||
Provides lightweight storage for project configuration and
|
||||
execution results tracking.
|
||||
|
||||
Directory structure (inside project directory)::
|
||||
|
||||
{project_path}/.fuzzforge/
|
||||
config.json # Project config (source path reference)
|
||||
runs/ # Execution results
|
||||
{execution_id}/
|
||||
results.tar.gz
|
||||
|
||||
"""
|
||||
|
||||
_base_path: Path
|
||||
|
||||
def __init__(self, base_path: Path) -> None:
|
||||
"""Initialize storage backend.
|
||||
|
||||
:param base_path: Root directory for global storage (fallback).
|
||||
|
||||
"""
|
||||
self._base_path = base_path
|
||||
self._base_path.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
def _get_project_path(self, project_path: Path) -> Path:
|
||||
"""Get the .fuzzforge storage path for a project.
|
||||
|
||||
:param project_path: Path to the project directory.
|
||||
:returns: Storage path (.fuzzforge inside project).
|
||||
|
||||
"""
|
||||
return project_path / FUZZFORGE_DIR_NAME
|
||||
|
||||
def init_project(self, project_path: Path) -> Path:
|
||||
"""Initialize storage for a new project.
|
||||
|
||||
Creates a .fuzzforge/ directory inside the project for storing
|
||||
configuration and execution results.
|
||||
|
||||
:param project_path: Path to the project directory.
|
||||
:returns: Path to the project storage directory.
|
||||
|
||||
"""
|
||||
storage_path = self._get_project_path(project_path)
|
||||
storage_path.mkdir(parents=True, exist_ok=True)
|
||||
(storage_path / "runs").mkdir(parents=True, exist_ok=True)
|
||||
|
||||
# Create .gitignore to avoid committing large files
|
||||
gitignore_path = storage_path / ".gitignore"
|
||||
if not gitignore_path.exists():
|
||||
gitignore_path.write_text(
|
||||
"# FuzzForge storage - ignore large/temporary files\n"
|
||||
"runs/\n"
|
||||
"!config.json\n"
|
||||
)
|
||||
|
||||
logger.info("Initialized project storage: %s", storage_path)
|
||||
return storage_path
|
||||
|
||||
def get_project_assets_path(self, project_path: Path) -> Path | None:
|
||||
"""Get the configured source path for a project.
|
||||
|
||||
:param project_path: Path to the project directory.
|
||||
:returns: Path to source directory, or None if not configured.
|
||||
|
||||
"""
|
||||
storage_path = self._get_project_path(project_path)
|
||||
config_path = storage_path / "config.json"
|
||||
|
||||
if config_path.exists():
|
||||
config = json.loads(config_path.read_text())
|
||||
source_path = config.get("source_path")
|
||||
if source_path:
|
||||
path = Path(source_path)
|
||||
if path.exists():
|
||||
return path
|
||||
|
||||
return None
|
||||
|
||||
def set_project_assets(self, project_path: Path, assets_path: Path) -> Path:
|
||||
"""Set the source path for a project (reference only, no copying).
|
||||
|
||||
:param project_path: Path to the project directory.
|
||||
:param assets_path: Path to source directory.
|
||||
:returns: The assets path (unchanged).
|
||||
:raises StorageError: If path doesn't exist.
|
||||
|
||||
"""
|
||||
if not assets_path.exists():
|
||||
msg = f"Assets path does not exist: {assets_path}"
|
||||
raise StorageError(msg)
|
||||
|
||||
assets_path = assets_path.resolve()
|
||||
|
||||
storage_path = self._get_project_path(project_path)
|
||||
storage_path.mkdir(parents=True, exist_ok=True)
|
||||
config_path = storage_path / "config.json"
|
||||
|
||||
config: dict = {}
|
||||
if config_path.exists():
|
||||
config = json.loads(config_path.read_text())
|
||||
|
||||
config["source_path"] = str(assets_path)
|
||||
config_path.write_text(json.dumps(config, indent=2))
|
||||
|
||||
logger.info("Set project assets: %s -> %s", project_path.name, assets_path)
|
||||
return assets_path
|
||||
|
||||
def list_executions(self, project_path: Path) -> list[str]:
|
||||
"""List all execution IDs for a project.
|
||||
|
||||
:param project_path: Path to the project directory.
|
||||
:returns: List of execution IDs.
|
||||
|
||||
"""
|
||||
runs_dir = self._get_project_path(project_path) / "runs"
|
||||
if not runs_dir.exists():
|
||||
return []
|
||||
return [d.name for d in runs_dir.iterdir() if d.is_dir()]
|
||||
|
||||
def get_execution_results(
|
||||
self,
|
||||
project_path: Path,
|
||||
execution_id: str,
|
||||
) -> Path | None:
|
||||
"""Retrieve execution results path.
|
||||
|
||||
:param project_path: Path to the project directory.
|
||||
:param execution_id: Execution ID.
|
||||
:returns: Path to results archive, or None if not found.
|
||||
|
||||
"""
|
||||
storage_path = self._get_project_path(project_path)
|
||||
|
||||
# Try direct path
|
||||
results_path = storage_path / "runs" / execution_id / RESULTS_ARCHIVE_FILENAME
|
||||
if results_path.exists():
|
||||
return results_path
|
||||
|
||||
# Search in all run directories
|
||||
runs_dir = storage_path / "runs"
|
||||
if runs_dir.exists():
|
||||
for run_dir in runs_dir.iterdir():
|
||||
if run_dir.is_dir() and execution_id in run_dir.name:
|
||||
candidate = run_dir / RESULTS_ARCHIVE_FILENAME
|
||||
if candidate.exists():
|
||||
return candidate
|
||||
|
||||
return None
|
||||
|
||||
def extract_results(self, results_path: Path, destination: Path) -> Path:
|
||||
"""Extract a results archive to a destination directory.
|
||||
|
||||
:param results_path: Path to the results archive.
|
||||
:param destination: Directory to extract to.
|
||||
:returns: Path to extracted directory.
|
||||
:raises StorageError: If extraction fails.
|
||||
|
||||
"""
|
||||
try:
|
||||
destination.mkdir(parents=True, exist_ok=True)
|
||||
with Archive(results_path, "r:gz") as tar:
|
||||
tar.extractall(path=destination) # noqa: S202
|
||||
logger.info("Extracted results: %s -> %s", results_path, destination)
|
||||
return destination
|
||||
except Exception as exc:
|
||||
msg = f"Failed to extract results: {exc}"
|
||||
raise StorageError(msg) from exc
|
||||
@@ -2,13 +2,12 @@
|
||||
|
||||
from fastmcp import FastMCP
|
||||
|
||||
from fuzzforge_mcp.tools import modules, projects, workflows
|
||||
from fuzzforge_mcp.tools import hub, projects
|
||||
|
||||
mcp: FastMCP = FastMCP()
|
||||
|
||||
mcp.mount(modules.mcp)
|
||||
mcp.mount(projects.mcp)
|
||||
mcp.mount(workflows.mcp)
|
||||
mcp.mount(hub.mcp)
|
||||
|
||||
__all__ = [
|
||||
"mcp",
|
||||
|
||||
561
fuzzforge-mcp/src/fuzzforge_mcp/tools/hub.py
Normal file
561
fuzzforge-mcp/src/fuzzforge_mcp/tools/hub.py
Normal file
@@ -0,0 +1,561 @@
|
||||
"""MCP Hub tools for FuzzForge MCP server.
|
||||
|
||||
This module provides tools for interacting with external MCP servers
|
||||
through the FuzzForge hub. AI agents can:
|
||||
- List available hub servers and their tools
|
||||
- Discover tools from hub servers
|
||||
- Execute hub tools
|
||||
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from fastmcp import FastMCP
|
||||
from fastmcp.exceptions import ToolError
|
||||
|
||||
from fuzzforge_common.hub import HubExecutor, HubServerConfig, HubServerType
|
||||
from fuzzforge_mcp.dependencies import get_settings
|
||||
|
||||
mcp: FastMCP = FastMCP()
|
||||
|
||||
# Global hub executor instance (lazy initialization)
|
||||
_hub_executor: HubExecutor | None = None
|
||||
|
||||
|
||||
def _get_hub_executor() -> HubExecutor:
|
||||
"""Get or create the hub executor instance.
|
||||
|
||||
:returns: Hub executor instance.
|
||||
:raises ToolError: If hub is disabled.
|
||||
|
||||
"""
|
||||
global _hub_executor
|
||||
|
||||
settings = get_settings()
|
||||
|
||||
if not settings.hub.enabled:
|
||||
msg = "MCP Hub is disabled. Enable it via FUZZFORGE_HUB__ENABLED=true"
|
||||
raise ToolError(msg)
|
||||
|
||||
if _hub_executor is None:
|
||||
config_path = settings.hub.config_path
|
||||
_hub_executor = HubExecutor(
|
||||
config_path=config_path,
|
||||
timeout=settings.hub.timeout,
|
||||
)
|
||||
|
||||
return _hub_executor
|
||||
|
||||
|
||||
@mcp.tool
|
||||
async def list_hub_servers() -> dict[str, Any]:
|
||||
"""List all registered MCP hub servers.
|
||||
|
||||
Returns information about configured hub servers, including
|
||||
their connection type, status, and discovered tool count.
|
||||
|
||||
:return: Dictionary with list of hub servers.
|
||||
|
||||
"""
|
||||
try:
|
||||
executor = _get_hub_executor()
|
||||
servers = executor.list_servers()
|
||||
|
||||
return {
|
||||
"servers": servers,
|
||||
"count": len(servers),
|
||||
"enabled_count": len([s for s in servers if s["enabled"]]),
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
if isinstance(e, ToolError):
|
||||
raise
|
||||
msg = f"Failed to list hub servers: {e}"
|
||||
raise ToolError(msg) from e
|
||||
|
||||
|
||||
@mcp.tool
|
||||
async def discover_hub_tools(server_name: str | None = None) -> dict[str, Any]:
|
||||
"""Discover tools from hub servers.
|
||||
|
||||
Connects to hub servers and retrieves their available tools.
|
||||
If server_name is provided, only discovers from that server.
|
||||
Otherwise discovers from all enabled servers.
|
||||
|
||||
:param server_name: Optional specific server to discover from.
|
||||
:return: Dictionary with discovered tools.
|
||||
|
||||
"""
|
||||
try:
|
||||
executor = _get_hub_executor()
|
||||
|
||||
if server_name:
|
||||
tools = await executor.discover_server_tools(server_name)
|
||||
return {
|
||||
"server": server_name,
|
||||
"tools": [
|
||||
{
|
||||
"identifier": t.identifier,
|
||||
"name": t.name,
|
||||
"description": t.description,
|
||||
"parameters": [p.model_dump() for p in t.parameters],
|
||||
}
|
||||
for t in tools
|
||||
],
|
||||
"count": len(tools),
|
||||
}
|
||||
else:
|
||||
results = await executor.discover_all_tools()
|
||||
all_tools = []
|
||||
for server, tools in results.items():
|
||||
for tool in tools:
|
||||
all_tools.append({
|
||||
"identifier": tool.identifier,
|
||||
"name": tool.name,
|
||||
"server": server,
|
||||
"description": tool.description,
|
||||
"parameters": [p.model_dump() for p in tool.parameters],
|
||||
})
|
||||
|
||||
return {
|
||||
"servers_discovered": len(results),
|
||||
"tools": all_tools,
|
||||
"count": len(all_tools),
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
if isinstance(e, ToolError):
|
||||
raise
|
||||
msg = f"Failed to discover hub tools: {e}"
|
||||
raise ToolError(msg) from e
|
||||
|
||||
|
||||
@mcp.tool
|
||||
async def list_hub_tools() -> dict[str, Any]:
|
||||
"""List all discovered hub tools.
|
||||
|
||||
Returns tools that have been previously discovered from hub servers.
|
||||
Run discover_hub_tools first if no tools are listed.
|
||||
|
||||
:return: Dictionary with list of discovered tools.
|
||||
|
||||
"""
|
||||
try:
|
||||
executor = _get_hub_executor()
|
||||
tools = executor.list_tools()
|
||||
|
||||
return {
|
||||
"tools": tools,
|
||||
"count": len(tools),
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
if isinstance(e, ToolError):
|
||||
raise
|
||||
msg = f"Failed to list hub tools: {e}"
|
||||
raise ToolError(msg) from e
|
||||
|
||||
|
||||
@mcp.tool
|
||||
async def execute_hub_tool(
|
||||
identifier: str,
|
||||
arguments: dict[str, Any] | None = None,
|
||||
timeout: int | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""Execute a tool from a hub server.
|
||||
|
||||
:param identifier: Tool identifier (format: hub:server:tool or server:tool).
|
||||
:param arguments: Tool arguments matching the tool's input schema.
|
||||
:param timeout: Optional execution timeout in seconds.
|
||||
:return: Tool execution result.
|
||||
|
||||
Example identifiers:
|
||||
- "hub:nmap:nmap_scan"
|
||||
- "nmap:nmap_scan"
|
||||
- "hub:nuclei:nuclei_scan"
|
||||
|
||||
"""
|
||||
try:
|
||||
executor = _get_hub_executor()
|
||||
|
||||
result = await executor.execute_tool(
|
||||
identifier=identifier,
|
||||
arguments=arguments or {},
|
||||
timeout=timeout,
|
||||
)
|
||||
|
||||
return result.to_dict()
|
||||
|
||||
except Exception as e:
|
||||
if isinstance(e, ToolError):
|
||||
raise
|
||||
msg = f"Hub tool execution failed: {e}"
|
||||
raise ToolError(msg) from e
|
||||
|
||||
|
||||
@mcp.tool
|
||||
async def get_hub_tool_schema(identifier: str) -> dict[str, Any]:
|
||||
"""Get the input schema for a hub tool.
|
||||
|
||||
Returns the JSON Schema that describes the tool's expected arguments.
|
||||
|
||||
:param identifier: Tool identifier (format: hub:server:tool or server:tool).
|
||||
:return: JSON Schema for the tool's input.
|
||||
|
||||
"""
|
||||
try:
|
||||
executor = _get_hub_executor()
|
||||
schema = executor.get_tool_schema(identifier)
|
||||
|
||||
if schema is None:
|
||||
msg = f"Tool '{identifier}' not found. Run discover_hub_tools first."
|
||||
raise ToolError(msg)
|
||||
|
||||
return {
|
||||
"identifier": identifier,
|
||||
"schema": schema,
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
if isinstance(e, ToolError):
|
||||
raise
|
||||
msg = f"Failed to get tool schema: {e}"
|
||||
raise ToolError(msg) from e
|
||||
|
||||
|
||||
@mcp.tool
|
||||
async def add_hub_server(
|
||||
name: str,
|
||||
server_type: str,
|
||||
image: str | None = None,
|
||||
command: list[str] | None = None,
|
||||
url: str | None = None,
|
||||
category: str | None = None,
|
||||
description: str | None = None,
|
||||
capabilities: list[str] | None = None,
|
||||
environment: dict[str, str] | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""Add a new MCP server to the hub.
|
||||
|
||||
Register a new external MCP server that can be used for tool discovery
|
||||
and execution. Servers can be Docker images, local commands, or SSE endpoints.
|
||||
|
||||
:param name: Unique name for the server (e.g., "nmap", "nuclei").
|
||||
:param server_type: Connection type ("docker", "command", or "sse").
|
||||
:param image: Docker image name (for docker type).
|
||||
:param command: Command and args (for command type).
|
||||
:param url: SSE endpoint URL (for sse type).
|
||||
:param category: Category for grouping (e.g., "reconnaissance").
|
||||
:param description: Human-readable description.
|
||||
:param capabilities: Docker capabilities to add (e.g., ["NET_RAW"]).
|
||||
:param environment: Environment variables to pass.
|
||||
:return: Information about the added server.
|
||||
|
||||
Examples:
|
||||
- Docker: add_hub_server("nmap", "docker", image="nmap-mcp:latest", capabilities=["NET_RAW"])
|
||||
- Command: add_hub_server("custom", "command", command=["python", "server.py"])
|
||||
|
||||
"""
|
||||
try:
|
||||
executor = _get_hub_executor()
|
||||
|
||||
# Parse server type
|
||||
try:
|
||||
stype = HubServerType(server_type)
|
||||
except ValueError:
|
||||
msg = f"Invalid server type: {server_type}. Use 'docker', 'command', or 'sse'."
|
||||
raise ToolError(msg) from None
|
||||
|
||||
# Validate required fields based on type
|
||||
if stype == HubServerType.DOCKER and not image:
|
||||
msg = "Docker image required for docker type"
|
||||
raise ToolError(msg)
|
||||
if stype == HubServerType.COMMAND and not command:
|
||||
msg = "Command required for command type"
|
||||
raise ToolError(msg)
|
||||
if stype == HubServerType.SSE and not url:
|
||||
msg = "URL required for sse type"
|
||||
raise ToolError(msg)
|
||||
|
||||
config = HubServerConfig(
|
||||
name=name,
|
||||
type=stype,
|
||||
image=image,
|
||||
command=command,
|
||||
url=url,
|
||||
category=category,
|
||||
description=description,
|
||||
capabilities=capabilities or [],
|
||||
environment=environment or {},
|
||||
)
|
||||
|
||||
server = executor.add_server(config)
|
||||
|
||||
return {
|
||||
"success": True,
|
||||
"server": {
|
||||
"name": server.name,
|
||||
"identifier": server.identifier,
|
||||
"type": server.config.type.value,
|
||||
"enabled": server.config.enabled,
|
||||
},
|
||||
"message": f"Server '{name}' added. Use discover_hub_tools('{name}') to discover its tools.",
|
||||
}
|
||||
|
||||
except ValueError as e:
|
||||
msg = f"Failed to add server: {e}"
|
||||
raise ToolError(msg) from e
|
||||
except Exception as e:
|
||||
if isinstance(e, ToolError):
|
||||
raise
|
||||
msg = f"Failed to add hub server: {e}"
|
||||
raise ToolError(msg) from e
|
||||
|
||||
|
||||
@mcp.tool
|
||||
async def start_hub_server(server_name: str) -> dict[str, Any]:
|
||||
"""Start a persistent container session for a hub server.
|
||||
|
||||
Starts a Docker container that stays running between tool calls,
|
||||
allowing stateful interactions. Tools are auto-discovered on start.
|
||||
|
||||
Use this for servers like radare2 or ghidra where you want to
|
||||
keep an analysis session open across multiple tool calls.
|
||||
|
||||
After starting, use execute_hub_tool as normal - calls will be
|
||||
routed to the persistent container automatically.
|
||||
|
||||
:param server_name: Name of the hub server to start (e.g., "radare2-mcp").
|
||||
:return: Session status with container name and start time.
|
||||
|
||||
"""
|
||||
try:
|
||||
executor = _get_hub_executor()
|
||||
|
||||
result = await executor.start_persistent_server(server_name)
|
||||
|
||||
return {
|
||||
"success": True,
|
||||
"session": result,
|
||||
"tools": result.get("tools", []),
|
||||
"tool_count": result.get("tool_count", 0),
|
||||
"message": (
|
||||
f"Persistent session started for '{server_name}'. "
|
||||
f"Discovered {result.get('tool_count', 0)} tools. "
|
||||
"Use execute_hub_tool to call them — they will reuse this container. "
|
||||
f"Stop with stop_hub_server('{server_name}') when done."
|
||||
),
|
||||
}
|
||||
|
||||
except ValueError as e:
|
||||
msg = f"Server not found: {e}"
|
||||
raise ToolError(msg) from e
|
||||
except Exception as e:
|
||||
if isinstance(e, ToolError):
|
||||
raise
|
||||
msg = f"Failed to start persistent server: {e}"
|
||||
raise ToolError(msg) from e
|
||||
|
||||
|
||||
@mcp.tool
|
||||
async def stop_hub_server(server_name: str) -> dict[str, Any]:
|
||||
"""Stop a persistent container session for a hub server.
|
||||
|
||||
Terminates the running Docker container and cleans up resources.
|
||||
After stopping, tool calls will fall back to ephemeral mode
|
||||
(a new container per call).
|
||||
|
||||
:param server_name: Name of the hub server to stop.
|
||||
:return: Result indicating if the session was stopped.
|
||||
|
||||
"""
|
||||
try:
|
||||
executor = _get_hub_executor()
|
||||
|
||||
stopped = await executor.stop_persistent_server(server_name)
|
||||
|
||||
if stopped:
|
||||
return {
|
||||
"success": True,
|
||||
"message": f"Persistent session for '{server_name}' stopped and container removed.",
|
||||
}
|
||||
else:
|
||||
return {
|
||||
"success": False,
|
||||
"message": f"No active persistent session found for '{server_name}'.",
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
if isinstance(e, ToolError):
|
||||
raise
|
||||
msg = f"Failed to stop persistent server: {e}"
|
||||
raise ToolError(msg) from e
|
||||
|
||||
|
||||
@mcp.tool
|
||||
async def hub_server_status(server_name: str | None = None) -> dict[str, Any]:
|
||||
"""Get status of persistent hub server sessions.
|
||||
|
||||
If server_name is provided, returns status for that specific server.
|
||||
Otherwise returns status for all active persistent sessions.
|
||||
|
||||
:param server_name: Optional specific server to check.
|
||||
:return: Session status information.
|
||||
|
||||
"""
|
||||
try:
|
||||
executor = _get_hub_executor()
|
||||
|
||||
if server_name:
|
||||
status = executor.get_persistent_status(server_name)
|
||||
if status:
|
||||
return {"active": True, "session": status}
|
||||
else:
|
||||
return {
|
||||
"active": False,
|
||||
"message": f"No active persistent session for '{server_name}'.",
|
||||
}
|
||||
else:
|
||||
sessions = executor.list_persistent_sessions()
|
||||
return {
|
||||
"active_sessions": sessions,
|
||||
"count": len(sessions),
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
if isinstance(e, ToolError):
|
||||
raise
|
||||
msg = f"Failed to get server status: {e}"
|
||||
raise ToolError(msg) from e
|
||||
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Continuous mode tools
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
|
||||
@mcp.tool
|
||||
async def start_continuous_hub_tool(
|
||||
server_name: str,
|
||||
start_tool: str,
|
||||
arguments: dict[str, Any] | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""Start a continuous/background tool on a hub server.
|
||||
|
||||
Automatically starts a persistent container if not already running,
|
||||
then calls the server's start tool (e.g., cargo_fuzz_start) which
|
||||
launches a background process and returns a session_id.
|
||||
|
||||
The tool runs indefinitely until stopped with stop_continuous_hub_tool.
|
||||
Use get_continuous_hub_status to monitor progress.
|
||||
|
||||
Example workflow for continuous cargo fuzzing:
|
||||
1. start_continuous_hub_tool("cargo-fuzzer-mcp", "cargo_fuzz_start", {"project_path": "/data/myproject"})
|
||||
2. get_continuous_hub_status(session_id) -- poll every 10-30s
|
||||
3. stop_continuous_hub_tool(session_id) -- when done
|
||||
|
||||
:param server_name: Hub server name (e.g., "cargo-fuzzer-mcp").
|
||||
:param start_tool: Name of the start tool on the server.
|
||||
:param arguments: Arguments for the start tool.
|
||||
:return: Start result including session_id for monitoring.
|
||||
|
||||
"""
|
||||
try:
|
||||
executor = _get_hub_executor()
|
||||
|
||||
result = await executor.start_continuous_tool(
|
||||
server_name=server_name,
|
||||
start_tool=start_tool,
|
||||
arguments=arguments or {},
|
||||
)
|
||||
|
||||
# Return the server's response directly — it already contains
|
||||
# session_id, status, targets, and a message.
|
||||
return result
|
||||
|
||||
except ValueError as e:
|
||||
msg = f"Server not found: {e}"
|
||||
raise ToolError(msg) from e
|
||||
except Exception as e:
|
||||
if isinstance(e, ToolError):
|
||||
raise
|
||||
msg = f"Failed to start continuous tool: {e}"
|
||||
raise ToolError(msg) from e
|
||||
|
||||
|
||||
@mcp.tool
|
||||
async def get_continuous_hub_status(session_id: str) -> dict[str, Any]:
|
||||
"""Get live status of a continuous hub tool session.
|
||||
|
||||
Returns current metrics, progress, and recent output from the
|
||||
running tool. Call periodically (every 10-30 seconds) to monitor.
|
||||
|
||||
:param session_id: Session ID returned by start_continuous_hub_tool.
|
||||
:return: Current status with metrics (executions, coverage, crashes, etc.).
|
||||
|
||||
"""
|
||||
try:
|
||||
executor = _get_hub_executor()
|
||||
|
||||
return await executor.get_continuous_tool_status(session_id)
|
||||
|
||||
except ValueError as e:
|
||||
msg = str(e)
|
||||
raise ToolError(msg) from e
|
||||
except Exception as e:
|
||||
if isinstance(e, ToolError):
|
||||
raise
|
||||
msg = f"Failed to get continuous status: {e}"
|
||||
raise ToolError(msg) from e
|
||||
|
||||
|
||||
@mcp.tool
|
||||
async def stop_continuous_hub_tool(session_id: str) -> dict[str, Any]:
|
||||
"""Stop a running continuous hub tool session.
|
||||
|
||||
Gracefully stops the background process and returns final results
|
||||
including total metrics and any artifacts (crash files, etc.).
|
||||
|
||||
:param session_id: Session ID of the session to stop.
|
||||
:return: Final metrics and results summary.
|
||||
|
||||
"""
|
||||
try:
|
||||
executor = _get_hub_executor()
|
||||
|
||||
return await executor.stop_continuous_tool(session_id)
|
||||
|
||||
except ValueError as e:
|
||||
msg = str(e)
|
||||
raise ToolError(msg) from e
|
||||
except Exception as e:
|
||||
if isinstance(e, ToolError):
|
||||
raise
|
||||
msg = f"Failed to stop continuous tool: {e}"
|
||||
raise ToolError(msg) from e
|
||||
|
||||
|
||||
@mcp.tool
|
||||
async def list_continuous_hub_sessions() -> dict[str, Any]:
|
||||
"""List all active and recent continuous hub tool sessions.
|
||||
|
||||
:return: List of sessions with their status and server info.
|
||||
|
||||
"""
|
||||
try:
|
||||
executor = _get_hub_executor()
|
||||
|
||||
sessions = executor.list_continuous_sessions()
|
||||
return {
|
||||
"sessions": sessions,
|
||||
"count": len(sessions),
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
if isinstance(e, ToolError):
|
||||
raise
|
||||
msg = f"Failed to list continuous sessions: {e}"
|
||||
raise ToolError(msg) from e
|
||||
@@ -1,373 +0,0 @@
|
||||
"""Module tools for FuzzForge MCP."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import uuid
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
from typing import TYPE_CHECKING, Any
|
||||
|
||||
from fastmcp import FastMCP
|
||||
from fastmcp.exceptions import ToolError
|
||||
|
||||
from fuzzforge_mcp.dependencies import get_project_path, get_runner, get_settings
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from fuzzforge_runner import Runner
|
||||
from fuzzforge_runner.orchestrator import StepResult
|
||||
|
||||
|
||||
mcp: FastMCP = FastMCP()
|
||||
|
||||
# Track running background executions
|
||||
_background_executions: dict[str, dict[str, Any]] = {}
|
||||
|
||||
|
||||
@mcp.tool
|
||||
async def list_modules() -> dict[str, Any]:
|
||||
"""List all available FuzzForge modules.
|
||||
|
||||
Returns information about modules that can be executed,
|
||||
including their identifiers, availability status, and metadata
|
||||
such as use cases, input requirements, and output artifacts.
|
||||
|
||||
:return: Dictionary with list of available modules and their details.
|
||||
|
||||
"""
|
||||
try:
|
||||
runner: Runner = get_runner()
|
||||
settings = get_settings()
|
||||
|
||||
# Use the engine abstraction to list images
|
||||
# Default filter matches locally-built fuzzforge-* modules
|
||||
modules = runner.list_module_images(filter_prefix="fuzzforge-")
|
||||
|
||||
available_modules = [
|
||||
{
|
||||
"identifier": module.identifier,
|
||||
"image": f"{module.identifier}:{module.version or 'latest'}",
|
||||
"available": module.available,
|
||||
"description": module.description,
|
||||
# New metadata fields from pyproject.toml
|
||||
"category": module.category,
|
||||
"language": module.language,
|
||||
"pipeline_stage": module.pipeline_stage,
|
||||
"pipeline_order": module.pipeline_order,
|
||||
"dependencies": module.dependencies,
|
||||
"continuous_mode": module.continuous_mode,
|
||||
"typical_duration": module.typical_duration,
|
||||
# AI-discoverable metadata
|
||||
"use_cases": module.use_cases,
|
||||
"input_requirements": module.input_requirements,
|
||||
"output_artifacts": module.output_artifacts,
|
||||
}
|
||||
for module in modules
|
||||
]
|
||||
|
||||
# Sort by pipeline_order if available
|
||||
available_modules.sort(key=lambda m: (m.get("pipeline_order") or 999, m["identifier"]))
|
||||
|
||||
return {
|
||||
"modules": available_modules,
|
||||
"count": len(available_modules),
|
||||
"container_engine": settings.engine.type,
|
||||
"registry_url": settings.registry.url,
|
||||
"registry_tag": settings.registry.default_tag,
|
||||
}
|
||||
|
||||
except Exception as exception:
|
||||
message: str = f"Failed to list modules: {exception}"
|
||||
raise ToolError(message) from exception
|
||||
|
||||
|
||||
@mcp.tool
|
||||
async def execute_module(
|
||||
module_identifier: str,
|
||||
configuration: dict[str, Any] | None = None,
|
||||
assets_path: str | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""Execute a FuzzForge module in an isolated container.
|
||||
|
||||
This tool runs a module in a sandboxed environment.
|
||||
The module receives input assets and produces output results.
|
||||
|
||||
The response includes `results_path` pointing to the stored results archive.
|
||||
Use this path directly to read outputs — no need to call `get_execution_results`.
|
||||
|
||||
:param module_identifier: The identifier of the module to execute.
|
||||
:param configuration: Optional configuration dict to pass to the module.
|
||||
:param assets_path: Optional path to input assets. Use this to pass specific
|
||||
inputs to a module (e.g. crash files to crash-analyzer) without changing
|
||||
the project's default assets. If not provided, uses project assets.
|
||||
:return: Execution result including status and results path.
|
||||
|
||||
"""
|
||||
runner: Runner = get_runner()
|
||||
project_path: Path = get_project_path()
|
||||
|
||||
try:
|
||||
result: StepResult = await runner.execute_module(
|
||||
module_identifier=module_identifier,
|
||||
project_path=project_path,
|
||||
configuration=configuration,
|
||||
assets_path=Path(assets_path) if assets_path else None,
|
||||
)
|
||||
|
||||
return {
|
||||
"success": result.success,
|
||||
"execution_id": result.execution_id,
|
||||
"module": result.module_identifier,
|
||||
"results_path": str(result.results_path) if result.results_path else None,
|
||||
"started_at": result.started_at.isoformat(),
|
||||
"completed_at": result.completed_at.isoformat(),
|
||||
"error": result.error,
|
||||
}
|
||||
|
||||
except Exception as exception:
|
||||
message: str = f"Module execution failed: {exception}"
|
||||
raise ToolError(message) from exception
|
||||
|
||||
|
||||
@mcp.tool
|
||||
async def start_continuous_module(
|
||||
module_identifier: str,
|
||||
configuration: dict[str, Any] | None = None,
|
||||
assets_path: str | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""Start a module in continuous/background mode.
|
||||
|
||||
The module will run indefinitely until stopped with stop_continuous_module().
|
||||
Use get_continuous_status() to check progress and metrics.
|
||||
|
||||
This is useful for long-running modules that should run until
|
||||
the user decides to stop them.
|
||||
|
||||
:param module_identifier: The module to run.
|
||||
:param configuration: Optional configuration. Set max_duration to 0 for infinite.
|
||||
:param assets_path: Optional path to input assets.
|
||||
:return: Execution info including session_id for monitoring.
|
||||
|
||||
"""
|
||||
runner: Runner = get_runner()
|
||||
project_path: Path = get_project_path()
|
||||
session_id = str(uuid.uuid4())[:8]
|
||||
|
||||
# Set infinite duration if not specified
|
||||
if configuration is None:
|
||||
configuration = {}
|
||||
if "max_duration" not in configuration:
|
||||
configuration["max_duration"] = 0 # 0 = infinite
|
||||
|
||||
try:
|
||||
# Determine assets path
|
||||
if assets_path:
|
||||
actual_assets_path = Path(assets_path)
|
||||
else:
|
||||
storage = runner.storage
|
||||
actual_assets_path = storage.get_project_assets_path(project_path)
|
||||
|
||||
# Use the new non-blocking executor method
|
||||
executor = runner._executor
|
||||
result = executor.start_module_continuous(
|
||||
module_identifier=module_identifier,
|
||||
assets_path=actual_assets_path,
|
||||
configuration=configuration,
|
||||
project_path=project_path,
|
||||
execution_id=session_id,
|
||||
)
|
||||
|
||||
# Store execution info for tracking
|
||||
_background_executions[session_id] = {
|
||||
"session_id": session_id,
|
||||
"module": module_identifier,
|
||||
"configuration": configuration,
|
||||
"started_at": datetime.now(timezone.utc).isoformat(),
|
||||
"status": "running",
|
||||
"container_id": result["container_id"],
|
||||
"input_dir": result["input_dir"],
|
||||
"project_path": str(project_path),
|
||||
}
|
||||
|
||||
return {
|
||||
"success": True,
|
||||
"session_id": session_id,
|
||||
"module": module_identifier,
|
||||
"container_id": result["container_id"],
|
||||
"status": "running",
|
||||
"message": f"Continuous module started. Use get_continuous_status('{session_id}') to monitor progress.",
|
||||
}
|
||||
|
||||
except Exception as exception:
|
||||
message: str = f"Failed to start continuous module: {exception}"
|
||||
raise ToolError(message) from exception
|
||||
|
||||
|
||||
def _get_continuous_status_impl(session_id: str) -> dict[str, Any]:
|
||||
"""Internal helper to get continuous session status (non-tool version)."""
|
||||
if session_id not in _background_executions:
|
||||
raise ToolError(f"Unknown session: {session_id}. Use list_continuous_sessions() to see active sessions.")
|
||||
|
||||
execution = _background_executions[session_id]
|
||||
container_id = execution.get("container_id")
|
||||
|
||||
# Initialize metrics
|
||||
metrics: dict[str, Any] = {
|
||||
"total_executions": 0,
|
||||
"total_crashes": 0,
|
||||
"exec_per_sec": 0,
|
||||
"coverage": 0,
|
||||
"current_target": "",
|
||||
"latest_events": [],
|
||||
}
|
||||
|
||||
# Read stream.jsonl from inside the running container
|
||||
if container_id:
|
||||
try:
|
||||
runner: Runner = get_runner()
|
||||
executor = runner._executor
|
||||
|
||||
# Check container status first
|
||||
container_status = executor.get_module_status(container_id)
|
||||
if container_status != "running":
|
||||
execution["status"] = "stopped" if container_status == "exited" else container_status
|
||||
|
||||
# Read stream.jsonl from container
|
||||
stream_content = executor.read_module_output(container_id, "/data/output/stream.jsonl")
|
||||
|
||||
if stream_content:
|
||||
lines = stream_content.strip().split("\n")
|
||||
# Get last 20 events
|
||||
recent_lines = lines[-20:] if len(lines) > 20 else lines
|
||||
crash_count = 0
|
||||
|
||||
for line in recent_lines:
|
||||
try:
|
||||
event = json.loads(line)
|
||||
metrics["latest_events"].append(event)
|
||||
|
||||
# Extract metrics from events
|
||||
if event.get("event") == "metrics":
|
||||
metrics["total_executions"] = event.get("executions", 0)
|
||||
metrics["current_target"] = event.get("target", "")
|
||||
metrics["exec_per_sec"] = event.get("exec_per_sec", 0)
|
||||
metrics["coverage"] = event.get("coverage", 0)
|
||||
|
||||
if event.get("event") == "crash_detected":
|
||||
crash_count += 1
|
||||
|
||||
except json.JSONDecodeError:
|
||||
continue
|
||||
|
||||
metrics["total_crashes"] = crash_count
|
||||
|
||||
except Exception as e:
|
||||
metrics["error"] = str(e)
|
||||
|
||||
# Calculate elapsed time
|
||||
started_at = execution.get("started_at", "")
|
||||
elapsed_seconds = 0
|
||||
if started_at:
|
||||
try:
|
||||
start_time = datetime.fromisoformat(started_at)
|
||||
elapsed_seconds = int((datetime.now(timezone.utc) - start_time).total_seconds())
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
return {
|
||||
"session_id": session_id,
|
||||
"module": execution.get("module"),
|
||||
"status": execution.get("status"),
|
||||
"container_id": container_id,
|
||||
"started_at": started_at,
|
||||
"elapsed_seconds": elapsed_seconds,
|
||||
"elapsed_human": f"{elapsed_seconds // 60}m {elapsed_seconds % 60}s",
|
||||
"metrics": metrics,
|
||||
}
|
||||
|
||||
|
||||
@mcp.tool
|
||||
async def get_continuous_status(session_id: str) -> dict[str, Any]:
|
||||
"""Get the current status and metrics of a running continuous session.
|
||||
|
||||
Call this periodically (e.g., every 30 seconds) to get live updates
|
||||
on progress and metrics.
|
||||
|
||||
:param session_id: The session ID returned by start_continuous_module().
|
||||
:return: Current status, metrics, and any events found.
|
||||
|
||||
"""
|
||||
return _get_continuous_status_impl(session_id)
|
||||
|
||||
|
||||
@mcp.tool
|
||||
async def stop_continuous_module(session_id: str) -> dict[str, Any]:
|
||||
"""Stop a running continuous session.
|
||||
|
||||
This will gracefully stop the module and collect any results.
|
||||
|
||||
:param session_id: The session ID of the session to stop.
|
||||
:return: Final status and summary of the session.
|
||||
|
||||
"""
|
||||
if session_id not in _background_executions:
|
||||
raise ToolError(f"Unknown session: {session_id}")
|
||||
|
||||
execution = _background_executions[session_id]
|
||||
container_id = execution.get("container_id")
|
||||
input_dir = execution.get("input_dir")
|
||||
|
||||
try:
|
||||
# Get final metrics before stopping (use helper, not the tool)
|
||||
final_metrics = _get_continuous_status_impl(session_id)
|
||||
|
||||
# Stop the container and collect results
|
||||
results_path = None
|
||||
if container_id:
|
||||
runner: Runner = get_runner()
|
||||
executor = runner._executor
|
||||
|
||||
try:
|
||||
results_path = executor.stop_module_continuous(container_id, input_dir)
|
||||
except Exception:
|
||||
# Container may have already stopped
|
||||
pass
|
||||
|
||||
execution["status"] = "stopped"
|
||||
execution["stopped_at"] = datetime.now(timezone.utc).isoformat()
|
||||
|
||||
return {
|
||||
"success": True,
|
||||
"session_id": session_id,
|
||||
"message": "Continuous session stopped",
|
||||
"results_path": str(results_path) if results_path else None,
|
||||
"final_metrics": final_metrics.get("metrics", {}),
|
||||
"elapsed": final_metrics.get("elapsed_human", ""),
|
||||
}
|
||||
|
||||
except Exception as exception:
|
||||
message: str = f"Failed to stop continuous module: {exception}"
|
||||
raise ToolError(message) from exception
|
||||
|
||||
|
||||
@mcp.tool
|
||||
async def list_continuous_sessions() -> dict[str, Any]:
|
||||
"""List all active and recent continuous sessions.
|
||||
|
||||
:return: List of continuous sessions with their status.
|
||||
|
||||
"""
|
||||
sessions = []
|
||||
for session_id, execution in _background_executions.items():
|
||||
sessions.append({
|
||||
"session_id": session_id,
|
||||
"module": execution.get("module"),
|
||||
"status": execution.get("status"),
|
||||
"started_at": execution.get("started_at"),
|
||||
})
|
||||
|
||||
return {
|
||||
"sessions": sessions,
|
||||
"count": len(sessions),
|
||||
}
|
||||
|
||||
@@ -3,15 +3,12 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
from typing import TYPE_CHECKING, Any
|
||||
from typing import Any
|
||||
|
||||
from fastmcp import FastMCP
|
||||
from fastmcp.exceptions import ToolError
|
||||
|
||||
from fuzzforge_mcp.dependencies import get_project_path, get_runner, set_current_project_path
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from fuzzforge_runner import Runner
|
||||
from fuzzforge_mcp.dependencies import get_project_path, get_storage, set_current_project_path
|
||||
|
||||
|
||||
mcp: FastMCP = FastMCP()
|
||||
@@ -22,25 +19,24 @@ async def init_project(project_path: str | None = None) -> dict[str, Any]:
|
||||
"""Initialize a new FuzzForge project.
|
||||
|
||||
Creates a `.fuzzforge/` directory inside the project for storing:
|
||||
- assets/: Input files (source code, etc.)
|
||||
- inputs/: Prepared module inputs (for debugging)
|
||||
- runs/: Execution results from each module
|
||||
- config.json: Project configuration
|
||||
- runs/: Execution results
|
||||
|
||||
This should be called before executing modules or workflows.
|
||||
This should be called before executing hub tools.
|
||||
|
||||
:param project_path: Path to the project directory. If not provided, uses current directory.
|
||||
:return: Project initialization result.
|
||||
|
||||
"""
|
||||
runner: Runner = get_runner()
|
||||
storage = get_storage()
|
||||
|
||||
try:
|
||||
path = Path(project_path) if project_path else get_project_path()
|
||||
|
||||
|
||||
# Track this as the current active project
|
||||
set_current_project_path(path)
|
||||
|
||||
storage_path = runner.init_project(path)
|
||||
|
||||
storage_path = storage.init_project(path)
|
||||
|
||||
return {
|
||||
"success": True,
|
||||
@@ -58,23 +54,18 @@ async def init_project(project_path: str | None = None) -> dict[str, Any]:
|
||||
async def set_project_assets(assets_path: str) -> dict[str, Any]:
|
||||
"""Set the initial assets (source code) for a project.
|
||||
|
||||
This sets the DEFAULT source directory mounted into modules.
|
||||
Usually this is the project root containing source code (e.g. Cargo.toml, src/).
|
||||
This sets the DEFAULT source directory that will be mounted into
|
||||
hub tool containers via volume mounts.
|
||||
|
||||
IMPORTANT: This OVERWRITES the previous assets path. Only call this once
|
||||
during project setup. To pass different inputs to a specific module
|
||||
(e.g. crash files to crash-analyzer), use the `assets_path` parameter
|
||||
on `execute_module` instead.
|
||||
|
||||
:param assets_path: Path to the project source directory or archive.
|
||||
:param assets_path: Path to the project source directory.
|
||||
:return: Result including stored assets path.
|
||||
|
||||
"""
|
||||
runner: Runner = get_runner()
|
||||
storage = get_storage()
|
||||
project_path: Path = get_project_path()
|
||||
|
||||
try:
|
||||
stored_path = runner.set_project_assets(
|
||||
stored_path = storage.set_project_assets(
|
||||
project_path=project_path,
|
||||
assets_path=Path(assets_path),
|
||||
)
|
||||
@@ -100,11 +91,11 @@ async def list_executions() -> dict[str, Any]:
|
||||
:return: List of execution IDs.
|
||||
|
||||
"""
|
||||
runner: Runner = get_runner()
|
||||
storage = get_storage()
|
||||
project_path: Path = get_project_path()
|
||||
|
||||
try:
|
||||
executions = runner.list_executions(project_path)
|
||||
executions = storage.list_executions(project_path)
|
||||
|
||||
return {
|
||||
"success": True,
|
||||
@@ -127,11 +118,11 @@ async def get_execution_results(execution_id: str, extract_to: str | None = None
|
||||
:return: Result including path to results archive.
|
||||
|
||||
"""
|
||||
runner: Runner = get_runner()
|
||||
storage = get_storage()
|
||||
project_path: Path = get_project_path()
|
||||
|
||||
try:
|
||||
results_path = runner.get_execution_results(project_path, execution_id)
|
||||
results_path = storage.get_execution_results(project_path, execution_id)
|
||||
|
||||
if results_path is None:
|
||||
return {
|
||||
@@ -140,7 +131,7 @@ async def get_execution_results(execution_id: str, extract_to: str | None = None
|
||||
"error": "Execution results not found",
|
||||
}
|
||||
|
||||
result = {
|
||||
result: dict[str, Any] = {
|
||||
"success": True,
|
||||
"execution_id": execution_id,
|
||||
"results_path": str(results_path),
|
||||
@@ -148,7 +139,7 @@ async def get_execution_results(execution_id: str, extract_to: str | None = None
|
||||
|
||||
# Extract if requested
|
||||
if extract_to:
|
||||
extracted_path = runner.extract_results(results_path, Path(extract_to))
|
||||
extracted_path = storage.extract_results(results_path, Path(extract_to))
|
||||
result["extracted_path"] = str(extracted_path)
|
||||
|
||||
return result
|
||||
|
||||
@@ -1,92 +0,0 @@
|
||||
"""Workflow tools for FuzzForge MCP."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
from typing import TYPE_CHECKING, Any
|
||||
|
||||
from fastmcp import FastMCP
|
||||
from fastmcp.exceptions import ToolError
|
||||
from fuzzforge_runner.orchestrator import WorkflowDefinition, WorkflowStep
|
||||
|
||||
from fuzzforge_mcp.dependencies import get_project_path, get_runner
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from fuzzforge_runner import Runner
|
||||
from fuzzforge_runner.orchestrator import WorkflowResult
|
||||
|
||||
|
||||
mcp: FastMCP = FastMCP()
|
||||
|
||||
|
||||
@mcp.tool
|
||||
async def execute_workflow(
|
||||
workflow_name: str,
|
||||
steps: list[dict[str, Any]],
|
||||
initial_assets_path: str | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""Execute a workflow consisting of multiple module steps.
|
||||
|
||||
A workflow chains multiple modules together, passing the output of each
|
||||
module as input to the next. This enables complex pipelines.
|
||||
|
||||
:param workflow_name: Name for this workflow execution.
|
||||
:param steps: List of step definitions, each with "module" and optional "configuration".
|
||||
:param initial_assets_path: Optional path to initial assets for the first step.
|
||||
:return: Workflow execution result including status of each step.
|
||||
|
||||
Example steps format:
|
||||
[
|
||||
{"module": "module-a", "configuration": {"key": "value"}},
|
||||
{"module": "module-b", "configuration": {}},
|
||||
{"module": "module-c"}
|
||||
]
|
||||
|
||||
"""
|
||||
runner: Runner = get_runner()
|
||||
project_path: Path = get_project_path()
|
||||
|
||||
try:
|
||||
# Convert step dicts to WorkflowStep objects
|
||||
workflow_steps = [
|
||||
WorkflowStep(
|
||||
module_identifier=step["module"],
|
||||
configuration=step.get("configuration"),
|
||||
name=step.get("name", f"step-{i}"),
|
||||
)
|
||||
for i, step in enumerate(steps)
|
||||
]
|
||||
|
||||
workflow = WorkflowDefinition(
|
||||
name=workflow_name,
|
||||
steps=workflow_steps,
|
||||
)
|
||||
|
||||
result: WorkflowResult = await runner.execute_workflow(
|
||||
workflow=workflow,
|
||||
project_path=project_path,
|
||||
initial_assets_path=Path(initial_assets_path) if initial_assets_path else None,
|
||||
)
|
||||
|
||||
return {
|
||||
"success": result.success,
|
||||
"execution_id": result.execution_id,
|
||||
"workflow_name": result.name,
|
||||
"final_results_path": str(result.final_results_path) if result.final_results_path else None,
|
||||
"steps": [
|
||||
{
|
||||
"step_index": step.step_index,
|
||||
"module": step.module_identifier,
|
||||
"success": step.success,
|
||||
"execution_id": step.execution_id,
|
||||
"results_path": str(step.results_path) if step.results_path else None,
|
||||
"error": step.error,
|
||||
}
|
||||
for step in result.steps
|
||||
],
|
||||
}
|
||||
|
||||
except Exception as exception:
|
||||
message: str = f"Workflow execution failed: {exception}"
|
||||
raise ToolError(message) from exception
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
"""MCP tool tests for FuzzForge OSS.
|
||||
"""MCP tool tests for FuzzForge AI.
|
||||
|
||||
Tests the MCP tools that are available in the OSS version.
|
||||
Tests the MCP tools that are available in FuzzForge AI.
|
||||
"""
|
||||
|
||||
import pytest
|
||||
@@ -11,16 +11,6 @@ if TYPE_CHECKING:
|
||||
from fastmcp.client import FastMCPTransport
|
||||
|
||||
|
||||
async def test_list_modules_tool_exists(
|
||||
mcp_client: "Client[FastMCPTransport]",
|
||||
) -> None:
|
||||
"""Test that the list_modules tool is available."""
|
||||
tools = await mcp_client.list_tools()
|
||||
tool_names = [tool.name for tool in tools]
|
||||
|
||||
assert "list_modules" in tool_names
|
||||
|
||||
|
||||
async def test_init_project_tool_exists(
|
||||
mcp_client: "Client[FastMCPTransport]",
|
||||
) -> None:
|
||||
@@ -31,31 +21,11 @@ async def test_init_project_tool_exists(
|
||||
assert "init_project" in tool_names
|
||||
|
||||
|
||||
async def test_execute_module_tool_exists(
|
||||
mcp_client: "Client[FastMCPTransport]",
|
||||
) -> None:
|
||||
"""Test that the execute_module tool is available."""
|
||||
tools = await mcp_client.list_tools()
|
||||
tool_names = [tool.name for tool in tools]
|
||||
|
||||
assert "execute_module" in tool_names
|
||||
|
||||
|
||||
async def test_execute_workflow_tool_exists(
|
||||
mcp_client: "Client[FastMCPTransport]",
|
||||
) -> None:
|
||||
"""Test that the execute_workflow tool is available."""
|
||||
tools = await mcp_client.list_tools()
|
||||
tool_names = [tool.name for tool in tools]
|
||||
|
||||
assert "execute_workflow" in tool_names
|
||||
|
||||
|
||||
async def test_mcp_has_expected_tool_count(
|
||||
mcp_client: "Client[FastMCPTransport]",
|
||||
) -> None:
|
||||
"""Test that MCP has the expected number of tools."""
|
||||
tools = await mcp_client.list_tools()
|
||||
|
||||
# Should have at least 4 core tools
|
||||
assert len(tools) >= 4
|
||||
# Should have project tools + hub tools
|
||||
assert len(tools) >= 2
|
||||
|
||||
@@ -1,27 +0,0 @@
|
||||
ARG BASE_IMAGE=fuzzforge-modules-sdk:0.0.1
|
||||
FROM ${BASE_IMAGE}
|
||||
|
||||
# Module metadata is now read from pyproject.toml [tool.fuzzforge.module] section
|
||||
|
||||
# Install system dependencies for Rust compilation
|
||||
RUN apt-get update && apt-get install -y \
|
||||
curl \
|
||||
build-essential \
|
||||
pkg-config \
|
||||
libssl-dev \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
# Install Rust toolchain with nightly (required for cargo-fuzz)
|
||||
RUN curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y --default-toolchain nightly
|
||||
ENV PATH="/root/.cargo/bin:${PATH}"
|
||||
|
||||
# Install cargo-fuzz
|
||||
RUN cargo install cargo-fuzz --locked || true
|
||||
|
||||
COPY ./src /app/src
|
||||
COPY ./pyproject.toml /app/pyproject.toml
|
||||
|
||||
# Remove workspace reference since we're using wheels
|
||||
RUN sed -i '/\[tool\.uv\.sources\]/,/^$/d' /app/pyproject.toml
|
||||
|
||||
RUN uv sync --find-links /wheels
|
||||
@@ -1,45 +0,0 @@
|
||||
PACKAGE=$(word 1, $(shell uv version))
|
||||
VERSION=$(word 2, $(shell uv version))
|
||||
|
||||
PODMAN?=/usr/bin/podman
|
||||
|
||||
SOURCES=./src
|
||||
TESTS=./tests
|
||||
|
||||
.PHONY: bandit build clean format mypy pytest ruff version
|
||||
|
||||
bandit:
|
||||
uv run bandit --recursive $(SOURCES)
|
||||
|
||||
build:
|
||||
$(PODMAN) build --file ./Dockerfile --no-cache --tag $(PACKAGE):$(VERSION)
|
||||
|
||||
save: build
|
||||
$(PODMAN) save --format oci-archive --output /tmp/$(PACKAGE)-$(VERSION).oci $(PACKAGE):$(VERSION)
|
||||
|
||||
clean:
|
||||
@find . -type d \( \
|
||||
-name '*.egg-info' \
|
||||
-o -name '.mypy_cache' \
|
||||
-o -name '.pytest_cache' \
|
||||
-o -name '.ruff_cache' \
|
||||
-o -name '__pycache__' \
|
||||
\) -printf 'removing directory %p\n' -exec rm -rf {} +
|
||||
|
||||
cloc:
|
||||
cloc $(SOURCES)
|
||||
|
||||
format:
|
||||
uv run ruff format $(SOURCES) $(TESTS)
|
||||
|
||||
mypy:
|
||||
uv run mypy $(SOURCES)
|
||||
|
||||
pytest:
|
||||
uv run pytest $(TESTS)
|
||||
|
||||
ruff:
|
||||
uv run ruff check --fix $(SOURCES) $(TESTS)
|
||||
|
||||
version:
|
||||
@echo '$(PACKAGE)@$(VERSION)'
|
||||
@@ -1,46 +0,0 @@
|
||||
# FuzzForge Modules - FIXME
|
||||
|
||||
## Installation
|
||||
|
||||
### Python
|
||||
|
||||
```shell
|
||||
# install the package (users)
|
||||
uv sync
|
||||
# install the package and all development dependencies (developers)
|
||||
uv sync --all-extras
|
||||
```
|
||||
|
||||
### Container
|
||||
|
||||
```shell
|
||||
# build the image
|
||||
make build
|
||||
# run the container
|
||||
mkdir -p "${PWD}/data" "${PWD}/data/input" "${PWD}/data/output"
|
||||
echo '{"settings":{},"resources":[]}' > "${PWD}/data/input/input.json"
|
||||
podman run --rm \
|
||||
--volume "${PWD}/data:/data" \
|
||||
'<name>:<version>' 'uv run module'
|
||||
```
|
||||
|
||||
## Usage
|
||||
|
||||
```shell
|
||||
uv run module
|
||||
```
|
||||
|
||||
## Development tools
|
||||
|
||||
```shell
|
||||
# run ruff (formatter)
|
||||
make format
|
||||
# run mypy (type checker)
|
||||
make mypy
|
||||
# run tests (pytest)
|
||||
make pytest
|
||||
# run ruff (linter)
|
||||
make ruff
|
||||
```
|
||||
|
||||
See the file `Makefile` at the root of this directory for more tools.
|
||||
@@ -1,6 +0,0 @@
|
||||
[mypy]
|
||||
plugins = pydantic.mypy
|
||||
strict = True
|
||||
warn_unused_ignores = True
|
||||
warn_redundant_casts = True
|
||||
warn_return_any = True
|
||||
@@ -1,58 +0,0 @@
|
||||
[project]
|
||||
name = "fuzzforge-cargo-fuzzer"
|
||||
version = "0.1.0"
|
||||
description = "Runs continuous coverage-guided fuzzing on Rust targets using cargo-fuzz"
|
||||
authors = []
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.14"
|
||||
dependencies = [
|
||||
"fuzzforge-modules-sdk==0.0.1",
|
||||
"pydantic==2.12.4",
|
||||
"structlog==25.5.0",
|
||||
]
|
||||
|
||||
[project.optional-dependencies]
|
||||
lints = [
|
||||
"bandit==1.8.6",
|
||||
"mypy==1.18.2",
|
||||
"ruff==0.14.4",
|
||||
]
|
||||
tests = [
|
||||
"pytest==9.0.2",
|
||||
]
|
||||
|
||||
[project.scripts]
|
||||
module = "module.__main__:main"
|
||||
|
||||
[tool.uv.sources]
|
||||
fuzzforge-modules-sdk = { workspace = true }
|
||||
|
||||
[tool.uv]
|
||||
package = true
|
||||
|
||||
# FuzzForge module metadata for AI agent discovery
|
||||
[tool.fuzzforge.module]
|
||||
identifier = "fuzzforge-cargo-fuzzer"
|
||||
suggested_predecessors = ["fuzzforge-harness-tester"]
|
||||
continuous_mode = true
|
||||
|
||||
use_cases = [
|
||||
"Run continuous coverage-guided fuzzing on Rust targets with libFuzzer",
|
||||
"Execute cargo-fuzz on validated harnesses",
|
||||
"Produce crash artifacts for analysis",
|
||||
"Long-running fuzzing campaign"
|
||||
]
|
||||
|
||||
common_inputs = [
|
||||
"validated-harnesses",
|
||||
"Cargo.toml",
|
||||
"rust-source-code"
|
||||
]
|
||||
|
||||
output_artifacts = [
|
||||
"fuzzing_results.json",
|
||||
"crashes/",
|
||||
"results.json"
|
||||
]
|
||||
|
||||
output_treatment = "Read fuzzing_results.json which contains: targets_fuzzed, total_crashes, total_executions, crashes_path, and results array with per-target crash info. Display summary of crashes found. The crashes/ directory contains crash inputs for downstream crash-analyzer."
|
||||
@@ -1,19 +0,0 @@
|
||||
line-length = 120
|
||||
|
||||
[lint]
|
||||
select = [ "ALL" ]
|
||||
ignore = [
|
||||
"COM812", # conflicts with the formatter
|
||||
"D100", # ignoring missing docstrings in public modules
|
||||
"D104", # ignoring missing docstrings in public packages
|
||||
"D203", # conflicts with 'D211'
|
||||
"D213", # conflicts with 'D212'
|
||||
"TD002", # ignoring missing author in 'TODO' statements
|
||||
"TD003", # ignoring missing issue link in 'TODO' statements
|
||||
]
|
||||
|
||||
[lint.per-file-ignores]
|
||||
"tests/*" = [
|
||||
"PLR2004", # allowing comparisons using unamed numerical constants in tests
|
||||
"S101", # allowing 'assert' statements in tests
|
||||
]
|
||||
@@ -1,19 +0,0 @@
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from fuzzforge_modules_sdk.api import logs
|
||||
|
||||
from module.mod import Module
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from fuzzforge_modules_sdk.api.modules.base import FuzzForgeModule
|
||||
|
||||
|
||||
def main() -> None:
|
||||
"""TODO."""
|
||||
logs.configure()
|
||||
module: FuzzForgeModule = Module()
|
||||
module.main()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -1,538 +0,0 @@
|
||||
"""Cargo Fuzzer module for FuzzForge.
|
||||
|
||||
This module runs cargo-fuzz (libFuzzer) on validated Rust fuzz targets.
|
||||
It takes a fuzz project with compiled harnesses and runs fuzzing for a
|
||||
configurable duration, collecting crashes and statistics.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import shutil
|
||||
import subprocess
|
||||
import signal
|
||||
import time
|
||||
from pathlib import Path
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
import structlog
|
||||
|
||||
from fuzzforge_modules_sdk.api.constants import PATH_TO_INPUTS, PATH_TO_OUTPUTS
|
||||
from fuzzforge_modules_sdk.api.models import FuzzForgeModuleResults
|
||||
from fuzzforge_modules_sdk.api.modules.base import FuzzForgeModule
|
||||
|
||||
from module.models import Input, Output, CrashInfo, FuzzingStats, TargetResult
|
||||
from module.settings import Settings
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from fuzzforge_modules_sdk.api.models import FuzzForgeModuleResource
|
||||
|
||||
logger = structlog.get_logger()
|
||||
|
||||
|
||||
class Module(FuzzForgeModule):
|
||||
"""Cargo Fuzzer module - runs cargo-fuzz with libFuzzer on Rust targets."""
|
||||
|
||||
_settings: Settings | None
|
||||
_fuzz_project_path: Path | None
|
||||
_target_results: list[TargetResult]
|
||||
_crashes_path: Path | None
|
||||
|
||||
def __init__(self) -> None:
|
||||
"""Initialize an instance of the class."""
|
||||
name: str = "cargo-fuzzer"
|
||||
version: str = "0.1.0"
|
||||
FuzzForgeModule.__init__(self, name=name, version=version)
|
||||
self._settings = None
|
||||
self._fuzz_project_path = None
|
||||
self._target_results = []
|
||||
self._crashes_path = None
|
||||
|
||||
@classmethod
|
||||
def _get_input_type(cls) -> type[Input]:
|
||||
"""Return the input type."""
|
||||
return Input
|
||||
|
||||
@classmethod
|
||||
def _get_output_type(cls) -> type[Output]:
|
||||
"""Return the output type."""
|
||||
return Output
|
||||
|
||||
def _prepare(self, settings: Settings) -> None: # type: ignore[override]
|
||||
"""Prepare the module with settings.
|
||||
|
||||
:param settings: Module settings.
|
||||
|
||||
"""
|
||||
self._settings = settings
|
||||
logger.info("cargo-fuzzer preparing", settings=settings.model_dump() if settings else {})
|
||||
|
||||
def _run(self, resources: list[FuzzForgeModuleResource]) -> FuzzForgeModuleResults:
|
||||
"""Run the fuzzer.
|
||||
|
||||
:param resources: Input resources (fuzz project + source).
|
||||
:returns: Module execution result.
|
||||
|
||||
"""
|
||||
logger.info("cargo-fuzzer starting", resource_count=len(resources))
|
||||
|
||||
# Emit initial progress
|
||||
self.emit_progress(0, status="initializing", message="Setting up fuzzing environment")
|
||||
self.emit_event("module_started", resource_count=len(resources))
|
||||
|
||||
# Setup the fuzzing environment
|
||||
if not self._setup_environment(resources):
|
||||
self.emit_progress(100, status="failed", message="Failed to setup environment")
|
||||
return FuzzForgeModuleResults.FAILURE
|
||||
|
||||
# Get list of fuzz targets
|
||||
targets = self._get_fuzz_targets()
|
||||
if not targets:
|
||||
logger.error("no fuzz targets found")
|
||||
self.emit_progress(100, status="failed", message="No fuzz targets found")
|
||||
return FuzzForgeModuleResults.FAILURE
|
||||
|
||||
# Filter targets if specific ones were requested
|
||||
if self._settings and self._settings.targets:
|
||||
requested = set(self._settings.targets)
|
||||
targets = [t for t in targets if t in requested]
|
||||
if not targets:
|
||||
logger.error("none of the requested targets found", requested=list(requested))
|
||||
self.emit_progress(100, status="failed", message="Requested targets not found")
|
||||
return FuzzForgeModuleResults.FAILURE
|
||||
|
||||
logger.info("found fuzz targets", targets=targets)
|
||||
self.emit_event("targets_found", targets=targets, count=len(targets))
|
||||
|
||||
# Setup output directories
|
||||
self._crashes_path = PATH_TO_OUTPUTS / "crashes"
|
||||
self._crashes_path.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
# Run fuzzing on each target
|
||||
# max_duration=0 means infinite/continuous mode
|
||||
max_duration = self._settings.max_duration if self._settings else 60
|
||||
is_continuous = max_duration == 0
|
||||
|
||||
if is_continuous:
|
||||
# Continuous mode: cycle through targets indefinitely
|
||||
# Each target runs for 60 seconds before moving to next
|
||||
duration_per_target = 60
|
||||
else:
|
||||
duration_per_target = max_duration // max(len(targets), 1)
|
||||
total_crashes = 0
|
||||
|
||||
# In continuous mode, loop forever; otherwise loop once
|
||||
round_num = 0
|
||||
while True:
|
||||
round_num += 1
|
||||
|
||||
for i, target in enumerate(targets):
|
||||
if is_continuous:
|
||||
progress_msg = f"Round {round_num}: Fuzzing {target}"
|
||||
else:
|
||||
progress_msg = f"Fuzzing target {i+1}/{len(targets)}"
|
||||
|
||||
progress = int((i / len(targets)) * 100) if not is_continuous else 50
|
||||
self.emit_progress(
|
||||
progress,
|
||||
status="running",
|
||||
message=progress_msg,
|
||||
current_task=target,
|
||||
metrics={
|
||||
"targets_completed": i,
|
||||
"total_targets": len(targets),
|
||||
"crashes_found": total_crashes,
|
||||
"round": round_num if is_continuous else 1,
|
||||
}
|
||||
)
|
||||
self.emit_event("target_started", target=target, index=i, total=len(targets), round=round_num)
|
||||
|
||||
result = self._fuzz_target(target, duration_per_target)
|
||||
self._target_results.append(result)
|
||||
total_crashes += len(result.crashes)
|
||||
|
||||
# Emit target completion
|
||||
self.emit_event(
|
||||
"target_completed",
|
||||
target=target,
|
||||
crashes=len(result.crashes),
|
||||
executions=result.stats.total_executions if result.stats else 0,
|
||||
coverage=result.stats.coverage_edges if result.stats else 0,
|
||||
)
|
||||
|
||||
logger.info("target completed",
|
||||
target=target,
|
||||
crashes=len(result.crashes),
|
||||
execs=result.stats.total_executions if result.stats else 0)
|
||||
|
||||
# Exit loop if not continuous mode
|
||||
if not is_continuous:
|
||||
break
|
||||
|
||||
# Write output
|
||||
self._write_output()
|
||||
|
||||
# Emit final progress
|
||||
self.emit_progress(
|
||||
100,
|
||||
status="completed",
|
||||
message=f"Fuzzing completed. Found {total_crashes} crashes.",
|
||||
metrics={
|
||||
"targets_fuzzed": len(self._target_results),
|
||||
"total_crashes": total_crashes,
|
||||
"total_executions": sum(r.stats.total_executions for r in self._target_results if r.stats),
|
||||
}
|
||||
)
|
||||
self.emit_event("module_completed", total_crashes=total_crashes, targets_fuzzed=len(targets))
|
||||
|
||||
logger.info("cargo-fuzzer completed",
|
||||
targets=len(self._target_results),
|
||||
total_crashes=total_crashes)
|
||||
|
||||
return FuzzForgeModuleResults.SUCCESS
|
||||
|
||||
def _cleanup(self, settings: Settings) -> None: # type: ignore[override]
|
||||
"""Clean up after execution.
|
||||
|
||||
:param settings: Module settings.
|
||||
|
||||
"""
|
||||
pass
|
||||
|
||||
def _setup_environment(self, resources: list[FuzzForgeModuleResource]) -> bool:
|
||||
"""Setup the fuzzing environment.
|
||||
|
||||
:param resources: Input resources.
|
||||
:returns: True if setup successful.
|
||||
|
||||
"""
|
||||
import shutil
|
||||
|
||||
# Find fuzz project in resources
|
||||
source_fuzz_project = None
|
||||
source_project_root = None
|
||||
|
||||
for resource in resources:
|
||||
path = Path(resource.path)
|
||||
if path.is_dir():
|
||||
# Check for fuzz subdirectory
|
||||
fuzz_dir = path / "fuzz"
|
||||
if fuzz_dir.is_dir() and (fuzz_dir / "Cargo.toml").exists():
|
||||
source_fuzz_project = fuzz_dir
|
||||
source_project_root = path
|
||||
break
|
||||
# Or direct fuzz project
|
||||
if (path / "Cargo.toml").exists() and (path / "fuzz_targets").is_dir():
|
||||
source_fuzz_project = path
|
||||
source_project_root = path.parent
|
||||
break
|
||||
|
||||
if source_fuzz_project is None:
|
||||
logger.error("no fuzz project found in resources")
|
||||
return False
|
||||
|
||||
# Copy project to writable location since /data/input is read-only
|
||||
# and cargo-fuzz needs to write corpus, artifacts, and build cache
|
||||
work_dir = Path("/tmp/fuzz-work")
|
||||
if work_dir.exists():
|
||||
shutil.rmtree(work_dir)
|
||||
|
||||
# Copy the entire project root
|
||||
work_project = work_dir / source_project_root.name
|
||||
shutil.copytree(source_project_root, work_project, dirs_exist_ok=True)
|
||||
|
||||
# Update fuzz_project_path to point to the copied location
|
||||
relative_fuzz = source_fuzz_project.relative_to(source_project_root)
|
||||
self._fuzz_project_path = work_project / relative_fuzz
|
||||
|
||||
logger.info("using fuzz project", path=str(self._fuzz_project_path))
|
||||
return True
|
||||
|
||||
def _get_fuzz_targets(self) -> list[str]:
|
||||
"""Get list of fuzz target names.
|
||||
|
||||
:returns: List of target names.
|
||||
|
||||
"""
|
||||
if self._fuzz_project_path is None:
|
||||
return []
|
||||
|
||||
targets = []
|
||||
fuzz_targets_dir = self._fuzz_project_path / "fuzz_targets"
|
||||
|
||||
if fuzz_targets_dir.is_dir():
|
||||
for rs_file in fuzz_targets_dir.glob("*.rs"):
|
||||
targets.append(rs_file.stem)
|
||||
|
||||
return targets
|
||||
|
||||
def _fuzz_target(self, target: str, duration: int) -> TargetResult:
|
||||
"""Run fuzzing on a single target.
|
||||
|
||||
:param target: Name of the fuzz target.
|
||||
:param duration: Maximum duration in seconds.
|
||||
:returns: Fuzzing result for this target.
|
||||
|
||||
"""
|
||||
logger.info("fuzzing target", target=target, duration=duration)
|
||||
|
||||
crashes: list[CrashInfo] = []
|
||||
stats = FuzzingStats()
|
||||
|
||||
if self._fuzz_project_path is None:
|
||||
return TargetResult(target=target, crashes=crashes, stats=stats)
|
||||
|
||||
# Create corpus directory for this target
|
||||
corpus_dir = self._fuzz_project_path / "corpus" / target
|
||||
corpus_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
# Build the command
|
||||
cmd = [
|
||||
"cargo", "+nightly", "fuzz", "run",
|
||||
target,
|
||||
"--",
|
||||
]
|
||||
|
||||
# Add time limit
|
||||
if duration > 0:
|
||||
cmd.append(f"-max_total_time={duration}")
|
||||
|
||||
# Use fork mode to continue after crashes
|
||||
# This makes libFuzzer restart worker after crash instead of exiting
|
||||
cmd.append("-fork=1")
|
||||
cmd.append("-ignore_crashes=1")
|
||||
cmd.append("-print_final_stats=1")
|
||||
|
||||
# Add jobs if specified
|
||||
if self._settings and self._settings.jobs > 1:
|
||||
cmd.extend([f"-jobs={self._settings.jobs}"])
|
||||
|
||||
try:
|
||||
env = os.environ.copy()
|
||||
env["CARGO_INCREMENTAL"] = "0"
|
||||
|
||||
process = subprocess.Popen(
|
||||
cmd,
|
||||
cwd=self._fuzz_project_path,
|
||||
stdout=subprocess.PIPE,
|
||||
stderr=subprocess.STDOUT,
|
||||
text=True,
|
||||
env=env,
|
||||
)
|
||||
|
||||
output_lines = []
|
||||
start_time = time.time()
|
||||
last_metrics_emit = 0.0
|
||||
current_execs = 0
|
||||
current_cov = 0
|
||||
current_exec_s = 0
|
||||
crash_count = 0
|
||||
|
||||
# Read output with timeout (skip timeout check in infinite mode)
|
||||
while True:
|
||||
if process.poll() is not None:
|
||||
break
|
||||
|
||||
elapsed = time.time() - start_time
|
||||
# Only enforce timeout if duration > 0 (not infinite mode)
|
||||
if duration > 0 and elapsed > duration + 30: # Grace period
|
||||
logger.warning("fuzzer timeout, terminating", target=target)
|
||||
process.terminate()
|
||||
try:
|
||||
process.wait(timeout=10)
|
||||
except subprocess.TimeoutExpired:
|
||||
process.kill()
|
||||
break
|
||||
|
||||
try:
|
||||
if process.stdout:
|
||||
line = process.stdout.readline()
|
||||
if line:
|
||||
output_lines.append(line)
|
||||
|
||||
# Parse real-time metrics from libFuzzer output
|
||||
# Example: "#12345 NEW cov: 100 ft: 50 corp: 25/1Kb exec/s: 1000"
|
||||
exec_match = re.search(r"#(\d+)", line)
|
||||
if exec_match:
|
||||
current_execs = int(exec_match.group(1))
|
||||
|
||||
cov_match = re.search(r"cov:\s*(\d+)", line)
|
||||
if cov_match:
|
||||
current_cov = int(cov_match.group(1))
|
||||
|
||||
exec_s_match = re.search(r"exec/s:\s*(\d+)", line)
|
||||
if exec_s_match:
|
||||
current_exec_s = int(exec_s_match.group(1))
|
||||
|
||||
# Check for crash indicators
|
||||
if "SUMMARY:" in line or "ERROR:" in line or "crash-" in line.lower():
|
||||
crash_count += 1
|
||||
self.emit_event(
|
||||
"crash_detected",
|
||||
target=target,
|
||||
crash_number=crash_count,
|
||||
line=line.strip(),
|
||||
)
|
||||
logger.debug("fuzzer output", line=line.strip())
|
||||
|
||||
# Emit metrics periodically (every 2 seconds)
|
||||
if elapsed - last_metrics_emit >= 2.0:
|
||||
last_metrics_emit = elapsed
|
||||
self.emit_event(
|
||||
"metrics",
|
||||
target=target,
|
||||
executions=current_execs,
|
||||
coverage=current_cov,
|
||||
exec_per_sec=current_exec_s,
|
||||
crashes=crash_count,
|
||||
elapsed_seconds=int(elapsed),
|
||||
remaining_seconds=max(0, duration - int(elapsed)),
|
||||
)
|
||||
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# Parse statistics from output
|
||||
stats = self._parse_fuzzer_stats(output_lines)
|
||||
|
||||
# Collect crashes
|
||||
crashes = self._collect_crashes(target)
|
||||
|
||||
# Emit final event for this target if crashes were found
|
||||
if crashes:
|
||||
self.emit_event(
|
||||
"crashes_collected",
|
||||
target=target,
|
||||
count=len(crashes),
|
||||
paths=[c.file_path for c in crashes],
|
||||
)
|
||||
|
||||
except FileNotFoundError:
|
||||
logger.error("cargo-fuzz not found, please install with: cargo install cargo-fuzz")
|
||||
stats.error = "cargo-fuzz not installed"
|
||||
self.emit_event("error", target=target, message="cargo-fuzz not installed")
|
||||
except Exception as e:
|
||||
logger.exception("fuzzing error", target=target, error=str(e))
|
||||
stats.error = str(e)
|
||||
self.emit_event("error", target=target, message=str(e))
|
||||
|
||||
return TargetResult(target=target, crashes=crashes, stats=stats)
|
||||
|
||||
def _parse_fuzzer_stats(self, output_lines: list[str]) -> FuzzingStats:
|
||||
"""Parse fuzzer output for statistics.
|
||||
|
||||
:param output_lines: Lines of fuzzer output.
|
||||
:returns: Parsed statistics.
|
||||
|
||||
"""
|
||||
stats = FuzzingStats()
|
||||
full_output = "".join(output_lines)
|
||||
|
||||
# Parse libFuzzer stats
|
||||
# Example: "#12345 DONE cov: 100 ft: 50 corp: 25/1Kb exec/s: 1000"
|
||||
exec_match = re.search(r"#(\d+)", full_output)
|
||||
if exec_match:
|
||||
stats.total_executions = int(exec_match.group(1))
|
||||
|
||||
cov_match = re.search(r"cov:\s*(\d+)", full_output)
|
||||
if cov_match:
|
||||
stats.coverage_edges = int(cov_match.group(1))
|
||||
|
||||
corp_match = re.search(r"corp:\s*(\d+)", full_output)
|
||||
if corp_match:
|
||||
stats.corpus_size = int(corp_match.group(1))
|
||||
|
||||
exec_s_match = re.search(r"exec/s:\s*(\d+)", full_output)
|
||||
if exec_s_match:
|
||||
stats.executions_per_second = int(exec_s_match.group(1))
|
||||
|
||||
return stats
|
||||
|
||||
def _collect_crashes(self, target: str) -> list[CrashInfo]:
|
||||
"""Collect crash files from fuzzer output.
|
||||
|
||||
:param target: Name of the fuzz target.
|
||||
:returns: List of crash info.
|
||||
|
||||
"""
|
||||
crashes: list[CrashInfo] = []
|
||||
seen_hashes: set[str] = set()
|
||||
|
||||
if self._fuzz_project_path is None or self._crashes_path is None:
|
||||
return crashes
|
||||
|
||||
# Check multiple possible crash locations:
|
||||
# 1. Standard artifacts directory (target-specific)
|
||||
# 2. Generic artifacts directory
|
||||
# 3. Fuzz project root (fork mode sometimes writes here)
|
||||
# 4. Project root (parent of fuzz directory)
|
||||
search_paths = [
|
||||
self._fuzz_project_path / "artifacts" / target,
|
||||
self._fuzz_project_path / "artifacts",
|
||||
self._fuzz_project_path,
|
||||
self._fuzz_project_path.parent,
|
||||
]
|
||||
|
||||
for search_dir in search_paths:
|
||||
if not search_dir.is_dir():
|
||||
continue
|
||||
|
||||
# Use rglob to recursively find crash files
|
||||
for crash_file in search_dir.rglob("crash-*"):
|
||||
if not crash_file.is_file():
|
||||
continue
|
||||
|
||||
# Skip duplicates by hash
|
||||
if crash_file.name in seen_hashes:
|
||||
continue
|
||||
seen_hashes.add(crash_file.name)
|
||||
|
||||
# Copy crash to output
|
||||
output_crash = self._crashes_path / target
|
||||
output_crash.mkdir(parents=True, exist_ok=True)
|
||||
dest = output_crash / crash_file.name
|
||||
shutil.copy2(crash_file, dest)
|
||||
|
||||
# Read crash input
|
||||
crash_data = crash_file.read_bytes()
|
||||
|
||||
crash_info = CrashInfo(
|
||||
file_path=str(dest),
|
||||
input_hash=crash_file.name,
|
||||
input_size=len(crash_data),
|
||||
)
|
||||
crashes.append(crash_info)
|
||||
|
||||
logger.info("found crash", target=target, file=crash_file.name, source=str(search_dir))
|
||||
|
||||
logger.info("crash collection complete", target=target, total_crashes=len(crashes))
|
||||
return crashes
|
||||
|
||||
def _write_output(self) -> None:
|
||||
"""Write the fuzzing results to output."""
|
||||
output_path = PATH_TO_OUTPUTS / "fuzzing_results.json"
|
||||
output_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
total_crashes = sum(len(r.crashes) for r in self._target_results)
|
||||
total_execs = sum(r.stats.total_executions for r in self._target_results if r.stats)
|
||||
|
||||
output_data = {
|
||||
"fuzz_project": str(self._fuzz_project_path),
|
||||
"targets_fuzzed": len(self._target_results),
|
||||
"total_crashes": total_crashes,
|
||||
"total_executions": total_execs,
|
||||
"crashes_path": str(self._crashes_path),
|
||||
"results": [
|
||||
{
|
||||
"target": r.target,
|
||||
"crashes": [c.model_dump() for c in r.crashes],
|
||||
"stats": r.stats.model_dump() if r.stats else None,
|
||||
}
|
||||
for r in self._target_results
|
||||
],
|
||||
}
|
||||
|
||||
output_path.write_text(json.dumps(output_data, indent=2))
|
||||
logger.info("wrote fuzzing results", path=str(output_path))
|
||||
@@ -1,88 +0,0 @@
|
||||
"""Models for the cargo-fuzzer module."""
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
from fuzzforge_modules_sdk.api.models import FuzzForgeModuleInputBase, FuzzForgeModuleOutputBase
|
||||
|
||||
from module.settings import Settings
|
||||
|
||||
|
||||
class FuzzingStats(BaseModel):
|
||||
"""Statistics from a fuzzing run."""
|
||||
|
||||
#: Total number of test case executions
|
||||
total_executions: int = 0
|
||||
|
||||
#: Executions per second
|
||||
executions_per_second: int = 0
|
||||
|
||||
#: Number of coverage edges discovered
|
||||
coverage_edges: int = 0
|
||||
|
||||
#: Size of the corpus
|
||||
corpus_size: int = 0
|
||||
|
||||
#: Any error message
|
||||
error: str = ""
|
||||
|
||||
|
||||
class CrashInfo(BaseModel):
|
||||
"""Information about a discovered crash."""
|
||||
|
||||
#: Path to the crash input file
|
||||
file_path: str
|
||||
|
||||
#: Hash/name of the crash input
|
||||
input_hash: str
|
||||
|
||||
#: Size of the crash input in bytes
|
||||
input_size: int = 0
|
||||
|
||||
#: Crash type (if identified)
|
||||
crash_type: str = ""
|
||||
|
||||
#: Stack trace (if available)
|
||||
stack_trace: str = ""
|
||||
|
||||
|
||||
class TargetResult(BaseModel):
|
||||
"""Result of fuzzing a single target."""
|
||||
|
||||
#: Name of the fuzz target
|
||||
target: str
|
||||
|
||||
#: List of crashes found
|
||||
crashes: list[CrashInfo] = Field(default_factory=list)
|
||||
|
||||
#: Fuzzing statistics
|
||||
stats: FuzzingStats = Field(default_factory=FuzzingStats)
|
||||
|
||||
|
||||
class Input(FuzzForgeModuleInputBase[Settings]):
|
||||
"""Input for the cargo-fuzzer module.
|
||||
|
||||
Expects:
|
||||
- A fuzz project directory with validated harnesses
|
||||
- Optionally the source crate to link against
|
||||
"""
|
||||
|
||||
|
||||
class Output(FuzzForgeModuleOutputBase):
|
||||
"""Output from the cargo-fuzzer module."""
|
||||
|
||||
#: Path to the fuzz project
|
||||
fuzz_project: str = ""
|
||||
|
||||
#: Number of targets fuzzed
|
||||
targets_fuzzed: int = 0
|
||||
|
||||
#: Total crashes found across all targets
|
||||
total_crashes: int = 0
|
||||
|
||||
#: Total executions across all targets
|
||||
total_executions: int = 0
|
||||
|
||||
#: Path to collected crash files
|
||||
crashes_path: str = ""
|
||||
|
||||
#: Results per target
|
||||
results: list[TargetResult] = Field(default_factory=list)
|
||||
@@ -1,35 +0,0 @@
|
||||
"""Settings for the cargo-fuzzer module."""
|
||||
|
||||
from typing import Optional
|
||||
from pydantic import model_validator
|
||||
from fuzzforge_modules_sdk.api.models import FuzzForgeModulesSettingsBase
|
||||
|
||||
|
||||
class Settings(FuzzForgeModulesSettingsBase):
|
||||
"""Settings for the cargo-fuzzer module."""
|
||||
|
||||
#: Maximum fuzzing duration in seconds (total across all targets)
|
||||
#: Set to 0 for infinite/continuous mode
|
||||
max_duration: int = 60
|
||||
|
||||
#: Number of parallel fuzzing jobs
|
||||
jobs: int = 1
|
||||
|
||||
#: Maximum length of generated inputs
|
||||
max_len: int = 4096
|
||||
|
||||
#: Whether to use AddressSanitizer
|
||||
use_asan: bool = True
|
||||
|
||||
#: Specific targets to fuzz (empty = all targets)
|
||||
targets: list[str] = []
|
||||
|
||||
#: Single target to fuzz (convenience alias for targets)
|
||||
target: Optional[str] = None
|
||||
|
||||
@model_validator(mode="after")
|
||||
def handle_single_target(self) -> "Settings":
|
||||
"""Convert single target to targets list if provided."""
|
||||
if self.target and self.target not in self.targets:
|
||||
self.targets.append(self.target)
|
||||
return self
|
||||
@@ -1,12 +0,0 @@
|
||||
ARG BASE_IMAGE=fuzzforge-modules-sdk:0.0.1
|
||||
FROM ${BASE_IMAGE}
|
||||
|
||||
# Module metadata is now read from pyproject.toml [tool.fuzzforge.module] section
|
||||
|
||||
COPY ./src /app/src
|
||||
COPY ./pyproject.toml /app/pyproject.toml
|
||||
|
||||
# Remove workspace reference since we're using wheels
|
||||
RUN sed -i '/\[tool\.uv\.sources\]/,/^$/d' /app/pyproject.toml
|
||||
|
||||
RUN uv sync --find-links /wheels
|
||||
@@ -1,45 +0,0 @@
|
||||
PACKAGE=$(word 1, $(shell uv version))
|
||||
VERSION=$(word 2, $(shell uv version))
|
||||
|
||||
PODMAN?=/usr/bin/podman
|
||||
|
||||
SOURCES=./src
|
||||
TESTS=./tests
|
||||
|
||||
.PHONY: bandit build clean format mypy pytest ruff version
|
||||
|
||||
bandit:
|
||||
uv run bandit --recursive $(SOURCES)
|
||||
|
||||
build:
|
||||
$(PODMAN) build --file ./Dockerfile --no-cache --tag $(PACKAGE):$(VERSION)
|
||||
|
||||
save: build
|
||||
$(PODMAN) save --format oci-archive --output /tmp/$(PACKAGE)-$(VERSION).oci $(PACKAGE):$(VERSION)
|
||||
|
||||
clean:
|
||||
@find . -type d \( \
|
||||
-name '*.egg-info' \
|
||||
-o -name '.mypy_cache' \
|
||||
-o -name '.pytest_cache' \
|
||||
-o -name '.ruff_cache' \
|
||||
-o -name '__pycache__' \
|
||||
\) -printf 'removing directory %p\n' -exec rm -rf {} +
|
||||
|
||||
cloc:
|
||||
cloc $(SOURCES)
|
||||
|
||||
format:
|
||||
uv run ruff format $(SOURCES) $(TESTS)
|
||||
|
||||
mypy:
|
||||
uv run mypy $(SOURCES)
|
||||
|
||||
pytest:
|
||||
uv run pytest $(TESTS)
|
||||
|
||||
ruff:
|
||||
uv run ruff check --fix $(SOURCES) $(TESTS)
|
||||
|
||||
version:
|
||||
@echo '$(PACKAGE)@$(VERSION)'
|
||||
@@ -1,46 +0,0 @@
|
||||
# FuzzForge Modules - FIXME
|
||||
|
||||
## Installation
|
||||
|
||||
### Python
|
||||
|
||||
```shell
|
||||
# install the package (users)
|
||||
uv sync
|
||||
# install the package and all development dependencies (developers)
|
||||
uv sync --all-extras
|
||||
```
|
||||
|
||||
### Container
|
||||
|
||||
```shell
|
||||
# build the image
|
||||
make build
|
||||
# run the container
|
||||
mkdir -p "${PWD}/data" "${PWD}/data/input" "${PWD}/data/output"
|
||||
echo '{"settings":{},"resources":[]}' > "${PWD}/data/input/input.json"
|
||||
podman run --rm \
|
||||
--volume "${PWD}/data:/data" \
|
||||
'<name>:<version>' 'uv run module'
|
||||
```
|
||||
|
||||
## Usage
|
||||
|
||||
```shell
|
||||
uv run module
|
||||
```
|
||||
|
||||
## Development tools
|
||||
|
||||
```shell
|
||||
# run ruff (formatter)
|
||||
make format
|
||||
# run mypy (type checker)
|
||||
make mypy
|
||||
# run tests (pytest)
|
||||
make pytest
|
||||
# run ruff (linter)
|
||||
make ruff
|
||||
```
|
||||
|
||||
See the file `Makefile` at the root of this directory for more tools.
|
||||
@@ -1,6 +0,0 @@
|
||||
[mypy]
|
||||
plugins = pydantic.mypy
|
||||
strict = True
|
||||
warn_unused_ignores = True
|
||||
warn_redundant_casts = True
|
||||
warn_return_any = True
|
||||
@@ -1,58 +0,0 @@
|
||||
[project]
|
||||
name = "fuzzforge-crash-analyzer"
|
||||
version = "0.1.0"
|
||||
description = "Analyzes fuzzing crashes, deduplicates them, and generates security reports"
|
||||
authors = []
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.14"
|
||||
dependencies = [
|
||||
"fuzzforge-modules-sdk==0.0.1",
|
||||
"pydantic==2.12.4",
|
||||
"structlog==25.5.0",
|
||||
"jinja2==3.1.6",
|
||||
]
|
||||
|
||||
[project.optional-dependencies]
|
||||
lints = [
|
||||
"bandit==1.8.6",
|
||||
"mypy==1.18.2",
|
||||
"ruff==0.14.4",
|
||||
]
|
||||
tests = [
|
||||
"pytest==9.0.2",
|
||||
]
|
||||
|
||||
[project.scripts]
|
||||
module = "module.__main__:main"
|
||||
|
||||
[tool.uv.sources]
|
||||
fuzzforge-modules-sdk = { workspace = true }
|
||||
|
||||
[tool.uv]
|
||||
package = true
|
||||
|
||||
# FuzzForge module metadata for AI agent discovery
|
||||
[tool.fuzzforge.module]
|
||||
identifier = "fuzzforge-crash-analyzer"
|
||||
suggested_predecessors = ["fuzzforge-cargo-fuzzer"]
|
||||
continuous_mode = false
|
||||
|
||||
use_cases = [
|
||||
"Analyze Rust crash artifacts from fuzzing",
|
||||
"Deduplicate crashes by stack trace signature",
|
||||
"Triage crashes by severity (critical, high, medium, low)",
|
||||
"Generate security vulnerability reports"
|
||||
]
|
||||
|
||||
common_inputs = [
|
||||
"crash-artifacts",
|
||||
"stack-traces",
|
||||
"rust-source-code"
|
||||
]
|
||||
|
||||
output_artifacts = [
|
||||
"crash_analysis.json",
|
||||
"results.json"
|
||||
]
|
||||
|
||||
output_treatment = "Read crash_analysis.json which contains: total_crashes, unique_crashes, duplicate_crashes, severity_summary (high/medium/low/unknown counts), and unique_analyses array with details per crash. Display a summary table of unique crashes by severity."
|
||||
@@ -1,19 +0,0 @@
|
||||
line-length = 120
|
||||
|
||||
[lint]
|
||||
select = [ "ALL" ]
|
||||
ignore = [
|
||||
"COM812", # conflicts with the formatter
|
||||
"D100", # ignoring missing docstrings in public modules
|
||||
"D104", # ignoring missing docstrings in public packages
|
||||
"D203", # conflicts with 'D211'
|
||||
"D213", # conflicts with 'D212'
|
||||
"TD002", # ignoring missing author in 'TODO' statements
|
||||
"TD003", # ignoring missing issue link in 'TODO' statements
|
||||
]
|
||||
|
||||
[lint.per-file-ignores]
|
||||
"tests/*" = [
|
||||
"PLR2004", # allowing comparisons using unamed numerical constants in tests
|
||||
"S101", # allowing 'assert' statements in tests
|
||||
]
|
||||
@@ -1,19 +0,0 @@
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from fuzzforge_modules_sdk.api import logs
|
||||
|
||||
from module.mod import Module
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from fuzzforge_modules_sdk.api.modules.base import FuzzForgeModule
|
||||
|
||||
|
||||
def main() -> None:
|
||||
"""TODO."""
|
||||
logs.configure()
|
||||
module: FuzzForgeModule = Module()
|
||||
module.main()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -1,340 +0,0 @@
|
||||
"""Crash Analyzer module for FuzzForge.
|
||||
|
||||
This module analyzes crashes from cargo-fuzz, deduplicates them,
|
||||
extracts stack traces, and triages them by severity.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
import structlog
|
||||
|
||||
from fuzzforge_modules_sdk.api.constants import PATH_TO_INPUTS, PATH_TO_OUTPUTS
|
||||
from fuzzforge_modules_sdk.api.models import FuzzForgeModuleResults
|
||||
from fuzzforge_modules_sdk.api.modules.base import FuzzForgeModule
|
||||
|
||||
from module.models import Input, Output, CrashAnalysis, Severity
|
||||
from module.settings import Settings
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from fuzzforge_modules_sdk.api.models import FuzzForgeModuleResource
|
||||
|
||||
logger = structlog.get_logger()
|
||||
|
||||
|
||||
class Module(FuzzForgeModule):
|
||||
"""Crash Analyzer module - analyzes and triages fuzzer crashes."""
|
||||
|
||||
_settings: Settings | None
|
||||
_analyses: list[CrashAnalysis]
|
||||
_fuzz_project_path: Path | None
|
||||
|
||||
def __init__(self) -> None:
|
||||
"""Initialize an instance of the class."""
|
||||
name: str = "crash-analyzer"
|
||||
version: str = "0.1.0"
|
||||
FuzzForgeModule.__init__(self, name=name, version=version)
|
||||
self._settings = None
|
||||
self._analyses = []
|
||||
self._fuzz_project_path = None
|
||||
|
||||
@classmethod
|
||||
def _get_input_type(cls) -> type[Input]:
|
||||
"""Return the input type."""
|
||||
return Input
|
||||
|
||||
@classmethod
|
||||
def _get_output_type(cls) -> type[Output]:
|
||||
"""Return the output type."""
|
||||
return Output
|
||||
|
||||
def _prepare(self, settings: Settings) -> None: # type: ignore[override]
|
||||
"""Prepare the module.
|
||||
|
||||
:param settings: Module settings.
|
||||
|
||||
"""
|
||||
self._settings = settings
|
||||
logger.info("crash-analyzer preparing", settings=settings.model_dump() if settings else {})
|
||||
|
||||
def _run(self, resources: list[FuzzForgeModuleResource]) -> FuzzForgeModuleResults:
|
||||
"""Run the crash analyzer.
|
||||
|
||||
:param resources: Input resources (fuzzing results + crashes).
|
||||
:returns: Module execution result.
|
||||
|
||||
"""
|
||||
logger.info("crash-analyzer starting", resource_count=len(resources))
|
||||
|
||||
# Find crashes directory and fuzz project
|
||||
crashes_path = None
|
||||
for resource in resources:
|
||||
path = Path(resource.path)
|
||||
if path.is_dir():
|
||||
if path.name == "crashes" or (path / "crashes").is_dir():
|
||||
crashes_path = path if path.name == "crashes" else path / "crashes"
|
||||
if (path / "fuzz_targets").is_dir():
|
||||
self._fuzz_project_path = path
|
||||
if (path / "fuzz" / "fuzz_targets").is_dir():
|
||||
self._fuzz_project_path = path / "fuzz"
|
||||
|
||||
if crashes_path is None:
|
||||
# Try to find crashes in fuzzing_results.json
|
||||
for resource in resources:
|
||||
path = Path(resource.path)
|
||||
if path.name == "fuzzing_results.json" and path.exists():
|
||||
with open(path) as f:
|
||||
data = json.load(f)
|
||||
if "crashes_path" in data:
|
||||
crashes_path = Path(data["crashes_path"])
|
||||
break
|
||||
|
||||
if crashes_path is None or not crashes_path.exists():
|
||||
logger.warning("no crashes found to analyze")
|
||||
self._write_output()
|
||||
return FuzzForgeModuleResults.SUCCESS
|
||||
|
||||
logger.info("analyzing crashes", path=str(crashes_path))
|
||||
|
||||
# Analyze crashes per target
|
||||
for target_dir in crashes_path.iterdir():
|
||||
if target_dir.is_dir():
|
||||
target = target_dir.name
|
||||
for crash_file in target_dir.glob("crash-*"):
|
||||
if crash_file.is_file():
|
||||
analysis = self._analyze_crash(target, crash_file)
|
||||
self._analyses.append(analysis)
|
||||
|
||||
# Deduplicate crashes
|
||||
self._deduplicate_crashes()
|
||||
|
||||
# Write output
|
||||
self._write_output()
|
||||
|
||||
unique_count = sum(1 for a in self._analyses if not a.is_duplicate)
|
||||
logger.info("crash-analyzer completed",
|
||||
total=len(self._analyses),
|
||||
unique=unique_count)
|
||||
|
||||
return FuzzForgeModuleResults.SUCCESS
|
||||
|
||||
def _cleanup(self, settings: Settings) -> None: # type: ignore[override]
|
||||
"""Clean up after execution.
|
||||
|
||||
:param settings: Module settings.
|
||||
|
||||
"""
|
||||
pass
|
||||
|
||||
def _analyze_crash(self, target: str, crash_file: Path) -> CrashAnalysis:
|
||||
"""Analyze a single crash.
|
||||
|
||||
:param target: Name of the fuzz target.
|
||||
:param crash_file: Path to the crash input file.
|
||||
:returns: Crash analysis result.
|
||||
|
||||
"""
|
||||
logger.debug("analyzing crash", target=target, file=crash_file.name)
|
||||
|
||||
# Read crash input
|
||||
crash_data = crash_file.read_bytes()
|
||||
input_hash = hashlib.sha256(crash_data).hexdigest()[:16]
|
||||
|
||||
# Try to reproduce and get stack trace
|
||||
stack_trace = ""
|
||||
crash_type = "unknown"
|
||||
severity = Severity.UNKNOWN
|
||||
|
||||
if self._fuzz_project_path:
|
||||
stack_trace, crash_type = self._reproduce_crash(target, crash_file)
|
||||
severity = self._determine_severity(crash_type, stack_trace)
|
||||
|
||||
return CrashAnalysis(
|
||||
target=target,
|
||||
input_file=str(crash_file),
|
||||
input_hash=input_hash,
|
||||
input_size=len(crash_data),
|
||||
crash_type=crash_type,
|
||||
severity=severity,
|
||||
stack_trace=stack_trace,
|
||||
is_duplicate=False,
|
||||
)
|
||||
|
||||
def _reproduce_crash(self, target: str, crash_file: Path) -> tuple[str, str]:
|
||||
"""Reproduce a crash to get stack trace.
|
||||
|
||||
:param target: Name of the fuzz target.
|
||||
:param crash_file: Path to the crash input file.
|
||||
:returns: Tuple of (stack_trace, crash_type).
|
||||
|
||||
"""
|
||||
if self._fuzz_project_path is None:
|
||||
return "", "unknown"
|
||||
|
||||
try:
|
||||
env = os.environ.copy()
|
||||
env["RUST_BACKTRACE"] = "1"
|
||||
|
||||
result = subprocess.run(
|
||||
[
|
||||
"cargo", "+nightly", "fuzz", "run",
|
||||
target,
|
||||
str(crash_file),
|
||||
"--",
|
||||
"-runs=1",
|
||||
],
|
||||
cwd=self._fuzz_project_path,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=30,
|
||||
env=env,
|
||||
)
|
||||
|
||||
output = result.stdout + result.stderr
|
||||
|
||||
# Extract crash type
|
||||
crash_type = "unknown"
|
||||
if "heap-buffer-overflow" in output.lower():
|
||||
crash_type = "heap-buffer-overflow"
|
||||
elif "stack-buffer-overflow" in output.lower():
|
||||
crash_type = "stack-buffer-overflow"
|
||||
elif "heap-use-after-free" in output.lower():
|
||||
crash_type = "use-after-free"
|
||||
elif "null" in output.lower() and "deref" in output.lower():
|
||||
crash_type = "null-pointer-dereference"
|
||||
elif "panic" in output.lower():
|
||||
crash_type = "panic"
|
||||
elif "assertion" in output.lower():
|
||||
crash_type = "assertion-failure"
|
||||
elif "timeout" in output.lower():
|
||||
crash_type = "timeout"
|
||||
elif "out of memory" in output.lower() or "oom" in output.lower():
|
||||
crash_type = "out-of-memory"
|
||||
|
||||
# Extract stack trace
|
||||
stack_lines = []
|
||||
in_stack = False
|
||||
for line in output.splitlines():
|
||||
if "SUMMARY:" in line or "ERROR:" in line:
|
||||
in_stack = True
|
||||
if in_stack:
|
||||
stack_lines.append(line)
|
||||
if len(stack_lines) > 50: # Limit stack trace length
|
||||
break
|
||||
|
||||
return "\n".join(stack_lines), crash_type
|
||||
|
||||
except subprocess.TimeoutExpired:
|
||||
return "", "timeout"
|
||||
except Exception as e:
|
||||
logger.warning("failed to reproduce crash", error=str(e))
|
||||
return "", "unknown"
|
||||
|
||||
def _determine_severity(self, crash_type: str, stack_trace: str) -> Severity:
|
||||
"""Determine crash severity based on type and stack trace.
|
||||
|
||||
:param crash_type: Type of the crash.
|
||||
:param stack_trace: Stack trace string.
|
||||
:returns: Severity level.
|
||||
|
||||
"""
|
||||
high_severity = [
|
||||
"heap-buffer-overflow",
|
||||
"stack-buffer-overflow",
|
||||
"use-after-free",
|
||||
"double-free",
|
||||
]
|
||||
|
||||
medium_severity = [
|
||||
"null-pointer-dereference",
|
||||
"out-of-memory",
|
||||
"integer-overflow",
|
||||
]
|
||||
|
||||
low_severity = [
|
||||
"panic",
|
||||
"assertion-failure",
|
||||
"timeout",
|
||||
]
|
||||
|
||||
if crash_type in high_severity:
|
||||
return Severity.HIGH
|
||||
elif crash_type in medium_severity:
|
||||
return Severity.MEDIUM
|
||||
elif crash_type in low_severity:
|
||||
return Severity.LOW
|
||||
else:
|
||||
return Severity.UNKNOWN
|
||||
|
||||
def _deduplicate_crashes(self) -> None:
|
||||
"""Mark duplicate crashes based on stack trace similarity."""
|
||||
seen_signatures: set[str] = set()
|
||||
|
||||
for analysis in self._analyses:
|
||||
# Create a signature from crash type and key stack frames
|
||||
signature = self._create_signature(analysis)
|
||||
|
||||
if signature in seen_signatures:
|
||||
analysis.is_duplicate = True
|
||||
else:
|
||||
seen_signatures.add(signature)
|
||||
|
||||
def _create_signature(self, analysis: CrashAnalysis) -> str:
|
||||
"""Create a unique signature for a crash.
|
||||
|
||||
:param analysis: Crash analysis.
|
||||
:returns: Signature string.
|
||||
|
||||
"""
|
||||
# Use crash type + first few significant stack frames
|
||||
parts = [analysis.target, analysis.crash_type]
|
||||
|
||||
# Extract function names from stack trace
|
||||
func_pattern = re.compile(r"in (\S+)")
|
||||
funcs = func_pattern.findall(analysis.stack_trace)
|
||||
|
||||
# Use first 3 unique functions
|
||||
seen = set()
|
||||
for func in funcs:
|
||||
if func not in seen and not func.startswith("std::"):
|
||||
parts.append(func)
|
||||
seen.add(func)
|
||||
if len(seen) >= 3:
|
||||
break
|
||||
|
||||
return "|".join(parts)
|
||||
|
||||
def _write_output(self) -> None:
|
||||
"""Write the analysis results to output."""
|
||||
output_path = PATH_TO_OUTPUTS / "crash_analysis.json"
|
||||
output_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
unique = [a for a in self._analyses if not a.is_duplicate]
|
||||
duplicates = [a for a in self._analyses if a.is_duplicate]
|
||||
|
||||
# Group by severity
|
||||
by_severity = {
|
||||
"high": [a for a in unique if a.severity == Severity.HIGH],
|
||||
"medium": [a for a in unique if a.severity == Severity.MEDIUM],
|
||||
"low": [a for a in unique if a.severity == Severity.LOW],
|
||||
"unknown": [a for a in unique if a.severity == Severity.UNKNOWN],
|
||||
}
|
||||
|
||||
output_data = {
|
||||
"total_crashes": len(self._analyses),
|
||||
"unique_crashes": len(unique),
|
||||
"duplicate_crashes": len(duplicates),
|
||||
"severity_summary": {k: len(v) for k, v in by_severity.items()},
|
||||
"unique_analyses": [a.model_dump() for a in unique],
|
||||
"duplicate_analyses": [a.model_dump() for a in duplicates],
|
||||
}
|
||||
|
||||
output_path.write_text(json.dumps(output_data, indent=2, default=str))
|
||||
logger.info("wrote crash analysis", path=str(output_path))
|
||||
@@ -1,79 +0,0 @@
|
||||
"""Models for the crash-analyzer module."""
|
||||
|
||||
from enum import Enum
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
from fuzzforge_modules_sdk.api.models import FuzzForgeModuleInputBase, FuzzForgeModuleOutputBase
|
||||
|
||||
from module.settings import Settings
|
||||
|
||||
|
||||
class Severity(str, Enum):
|
||||
"""Severity level of a crash."""
|
||||
|
||||
HIGH = "high"
|
||||
MEDIUM = "medium"
|
||||
LOW = "low"
|
||||
UNKNOWN = "unknown"
|
||||
|
||||
|
||||
class CrashAnalysis(BaseModel):
|
||||
"""Analysis of a single crash."""
|
||||
|
||||
#: Name of the fuzz target
|
||||
target: str
|
||||
|
||||
#: Path to the input file that caused the crash
|
||||
input_file: str
|
||||
|
||||
#: Hash of the input for identification
|
||||
input_hash: str
|
||||
|
||||
#: Size of the input in bytes
|
||||
input_size: int = 0
|
||||
|
||||
#: Type of crash (e.g., "heap-buffer-overflow", "panic")
|
||||
crash_type: str = "unknown"
|
||||
|
||||
#: Severity level
|
||||
severity: Severity = Severity.UNKNOWN
|
||||
|
||||
#: Stack trace from reproducing the crash
|
||||
stack_trace: str = ""
|
||||
|
||||
#: Whether this crash is a duplicate of another
|
||||
is_duplicate: bool = False
|
||||
|
||||
#: Signature for deduplication
|
||||
signature: str = ""
|
||||
|
||||
|
||||
class Input(FuzzForgeModuleInputBase[Settings]):
|
||||
"""Input for the crash-analyzer module.
|
||||
|
||||
Expects:
|
||||
- Crashes directory from cargo-fuzzer
|
||||
- Optionally the fuzz project for reproduction
|
||||
"""
|
||||
|
||||
|
||||
class Output(FuzzForgeModuleOutputBase):
|
||||
"""Output from the crash-analyzer module."""
|
||||
|
||||
#: Total number of crashes analyzed
|
||||
total_crashes: int = 0
|
||||
|
||||
#: Number of unique crashes (after deduplication)
|
||||
unique_crashes: int = 0
|
||||
|
||||
#: Number of duplicate crashes
|
||||
duplicate_crashes: int = 0
|
||||
|
||||
#: Summary by severity
|
||||
severity_summary: dict[str, int] = Field(default_factory=dict)
|
||||
|
||||
#: Unique crash analyses
|
||||
unique_analyses: list[CrashAnalysis] = Field(default_factory=list)
|
||||
|
||||
#: Duplicate crash analyses
|
||||
duplicate_analyses: list[CrashAnalysis] = Field(default_factory=list)
|
||||
@@ -1,16 +0,0 @@
|
||||
"""Settings for the crash-analyzer module."""
|
||||
|
||||
from fuzzforge_modules_sdk.api.models import FuzzForgeModulesSettingsBase
|
||||
|
||||
|
||||
class Settings(FuzzForgeModulesSettingsBase):
|
||||
"""Settings for the crash-analyzer module."""
|
||||
|
||||
#: Whether to reproduce crashes for stack traces
|
||||
reproduce_crashes: bool = True
|
||||
|
||||
#: Timeout for reproducing each crash (seconds)
|
||||
reproduce_timeout: int = 30
|
||||
|
||||
#: Whether to deduplicate crashes
|
||||
deduplicate: bool = True
|
||||
@@ -1,13 +0,0 @@
|
||||
ARG BASE_IMAGE=fuzzforge-modules-sdk:0.0.1
|
||||
FROM ${BASE_IMAGE}
|
||||
|
||||
# Module metadata is now read from pyproject.toml [tool.fuzzforge.module] section
|
||||
# See MODULE_METADATA.md for documentation on configuring metadata
|
||||
|
||||
COPY ./src /app/src
|
||||
COPY ./pyproject.toml /app/pyproject.toml
|
||||
|
||||
# Remove workspace reference since we're using wheels
|
||||
RUN sed -i '/\[tool\.uv\.sources\]/,/^$/d' /app/pyproject.toml
|
||||
|
||||
RUN uv sync --find-links /wheels
|
||||
@@ -1,45 +0,0 @@
|
||||
PACKAGE=$(word 1, $(shell uv version))
|
||||
VERSION=$(word 2, $(shell uv version))
|
||||
|
||||
PODMAN?=/usr/bin/podman
|
||||
|
||||
SOURCES=./src
|
||||
TESTS=./tests
|
||||
|
||||
.PHONY: bandit build clean format mypy pytest ruff version
|
||||
|
||||
bandit:
|
||||
uv run bandit --recursive $(SOURCES)
|
||||
|
||||
build:
|
||||
$(PODMAN) build --file ./Dockerfile --no-cache --tag $(PACKAGE):$(VERSION)
|
||||
|
||||
save: build
|
||||
$(PODMAN) save --format oci-archive --output /tmp/$(PACKAGE)-$(VERSION).oci $(PACKAGE):$(VERSION)
|
||||
|
||||
clean:
|
||||
@find . -type d \( \
|
||||
-name '*.egg-info' \
|
||||
-o -name '.mypy_cache' \
|
||||
-o -name '.pytest_cache' \
|
||||
-o -name '.ruff_cache' \
|
||||
-o -name '__pycache__' \
|
||||
\) -printf 'removing directory %p\n' -exec rm -rf {} +
|
||||
|
||||
cloc:
|
||||
cloc $(SOURCES)
|
||||
|
||||
format:
|
||||
uv run ruff format $(SOURCES) $(TESTS)
|
||||
|
||||
mypy:
|
||||
uv run mypy $(SOURCES)
|
||||
|
||||
pytest:
|
||||
uv run pytest $(TESTS)
|
||||
|
||||
ruff:
|
||||
uv run ruff check --fix $(SOURCES) $(TESTS)
|
||||
|
||||
version:
|
||||
@echo '$(PACKAGE)@$(VERSION)'
|
||||
@@ -1,46 +0,0 @@
|
||||
# FuzzForge Modules - FIXME
|
||||
|
||||
## Installation
|
||||
|
||||
### Python
|
||||
|
||||
```shell
|
||||
# install the package (users)
|
||||
uv sync
|
||||
# install the package and all development dependencies (developers)
|
||||
uv sync --all-extras
|
||||
```
|
||||
|
||||
### Container
|
||||
|
||||
```shell
|
||||
# build the image
|
||||
make build
|
||||
# run the container
|
||||
mkdir -p "${PWD}/data" "${PWD}/data/input" "${PWD}/data/output"
|
||||
echo '{"settings":{},"resources":[]}' > "${PWD}/data/input/input.json"
|
||||
podman run --rm \
|
||||
--volume "${PWD}/data:/data" \
|
||||
'<name>:<version>' 'uv run module'
|
||||
```
|
||||
|
||||
## Usage
|
||||
|
||||
```shell
|
||||
uv run module
|
||||
```
|
||||
|
||||
## Development tools
|
||||
|
||||
```shell
|
||||
# run ruff (formatter)
|
||||
make format
|
||||
# run mypy (type checker)
|
||||
make mypy
|
||||
# run tests (pytest)
|
||||
make pytest
|
||||
# run ruff (linter)
|
||||
make ruff
|
||||
```
|
||||
|
||||
See the file `Makefile` at the root of this directory for more tools.
|
||||
@@ -1,6 +0,0 @@
|
||||
[mypy]
|
||||
plugins = pydantic.mypy
|
||||
strict = True
|
||||
warn_unused_ignores = True
|
||||
warn_redundant_casts = True
|
||||
warn_return_any = True
|
||||
@@ -1,59 +0,0 @@
|
||||
[project]
|
||||
name = "fuzzforge-module-template"
|
||||
version = "0.1.0"
|
||||
description = "FIXME: Add module description"
|
||||
authors = []
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.14"
|
||||
dependencies = [
|
||||
"fuzzforge-modules-sdk==0.0.1",
|
||||
"pydantic==2.12.4",
|
||||
"structlog==25.5.0",
|
||||
]
|
||||
|
||||
[project.optional-dependencies]
|
||||
lints = [
|
||||
"bandit==1.8.6",
|
||||
"mypy==1.18.2",
|
||||
"ruff==0.14.4",
|
||||
]
|
||||
tests = [
|
||||
"pytest==9.0.2",
|
||||
]
|
||||
|
||||
[project.scripts]
|
||||
module = "module.__main__:main"
|
||||
|
||||
[tool.uv.sources]
|
||||
fuzzforge-modules-sdk = { workspace = true }
|
||||
|
||||
[tool.uv]
|
||||
package = true
|
||||
|
||||
# FuzzForge module metadata for AI agent discovery
|
||||
[tool.fuzzforge.module]
|
||||
# REQUIRED: Unique module identifier (should match Docker image name)
|
||||
identifier = "fuzzforge-module-template"
|
||||
|
||||
# Optional: List of module identifiers that should run before this one
|
||||
suggested_predecessors = []
|
||||
|
||||
# Optional: Whether this module supports continuous/background execution
|
||||
continuous_mode = false
|
||||
|
||||
# REQUIRED: Use cases help AI agents understand when to use this module
|
||||
# Include language/target info here (e.g., "Analyze Rust crate...")
|
||||
use_cases = [
|
||||
"FIXME: Describe what this module does",
|
||||
"FIXME: Describe typical usage scenario"
|
||||
]
|
||||
|
||||
# REQUIRED: What inputs the module expects
|
||||
common_inputs = [
|
||||
"FIXME: List required input files or artifacts"
|
||||
]
|
||||
|
||||
# REQUIRED: What outputs the module produces
|
||||
output_artifacts = [
|
||||
"FIXME: List output files produced"
|
||||
]
|
||||
@@ -1,19 +0,0 @@
|
||||
line-length = 120
|
||||
|
||||
[lint]
|
||||
select = [ "ALL" ]
|
||||
ignore = [
|
||||
"COM812", # conflicts with the formatter
|
||||
"D100", # ignoring missing docstrings in public modules
|
||||
"D104", # ignoring missing docstrings in public packages
|
||||
"D203", # conflicts with 'D211'
|
||||
"D213", # conflicts with 'D212'
|
||||
"TD002", # ignoring missing author in 'TODO' statements
|
||||
"TD003", # ignoring missing issue link in 'TODO' statements
|
||||
]
|
||||
|
||||
[lint.per-file-ignores]
|
||||
"tests/*" = [
|
||||
"PLR2004", # allowing comparisons using unamed numerical constants in tests
|
||||
"S101", # allowing 'assert' statements in tests
|
||||
]
|
||||
@@ -1,19 +0,0 @@
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from fuzzforge_modules_sdk.api import logs
|
||||
|
||||
from module.mod import Module
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from fuzzforge_modules_sdk.api.modules.base import FuzzForgeModule
|
||||
|
||||
|
||||
def main() -> None:
|
||||
"""TODO."""
|
||||
logs.configure()
|
||||
module: FuzzForgeModule = Module()
|
||||
module.main()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -1,54 +0,0 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from fuzzforge_modules_sdk.api.models import FuzzForgeModuleResults
|
||||
from fuzzforge_modules_sdk.api.modules.base import FuzzForgeModule
|
||||
|
||||
from module.models import Input, Output
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from fuzzforge_modules_sdk.api.models import FuzzForgeModuleResource, FuzzForgeModulesSettingsType
|
||||
|
||||
|
||||
class Module(FuzzForgeModule):
|
||||
"""TODO."""
|
||||
|
||||
def __init__(self) -> None:
|
||||
"""Initialize an instance of the class."""
|
||||
name: str = "FIXME"
|
||||
version: str = "FIXME"
|
||||
FuzzForgeModule.__init__(self, name=name, version=version)
|
||||
|
||||
@classmethod
|
||||
def _get_input_type(cls) -> type[Input]:
|
||||
"""TODO."""
|
||||
return Input
|
||||
|
||||
@classmethod
|
||||
def _get_output_type(cls) -> type[Output]:
|
||||
"""TODO."""
|
||||
return Output
|
||||
|
||||
def _prepare(self, settings: FuzzForgeModulesSettingsType) -> None:
|
||||
"""TODO.
|
||||
|
||||
:param settings: TODO.
|
||||
|
||||
"""
|
||||
|
||||
def _run(self, resources: list[FuzzForgeModuleResource]) -> FuzzForgeModuleResults: # noqa: ARG002
|
||||
"""TODO.
|
||||
|
||||
:param resources: TODO.
|
||||
:returns: TODO.
|
||||
|
||||
"""
|
||||
return FuzzForgeModuleResults.SUCCESS
|
||||
|
||||
def _cleanup(self, settings: FuzzForgeModulesSettingsType) -> None:
|
||||
"""TODO.
|
||||
|
||||
:param settings: TODO.
|
||||
|
||||
"""
|
||||
@@ -1,11 +0,0 @@
|
||||
from fuzzforge_modules_sdk.api.models import FuzzForgeModuleInputBase, FuzzForgeModuleOutputBase
|
||||
|
||||
from module.settings import Settings
|
||||
|
||||
|
||||
class Input(FuzzForgeModuleInputBase[Settings]):
|
||||
"""TODO."""
|
||||
|
||||
|
||||
class Output(FuzzForgeModuleOutputBase):
|
||||
"""TODO."""
|
||||
@@ -1,7 +0,0 @@
|
||||
from fuzzforge_modules_sdk.api.models import FuzzForgeModulesSettingsBase
|
||||
|
||||
|
||||
class Settings(FuzzForgeModulesSettingsBase):
|
||||
"""TODO."""
|
||||
|
||||
# Here goes your attributes
|
||||
@@ -1,30 +0,0 @@
|
||||
# FuzzForge Modules SDK - Base image for all modules
|
||||
#
|
||||
# This image provides:
|
||||
# - Python 3.14 with uv package manager
|
||||
# - Pre-built wheels for common dependencies
|
||||
# - Standard module directory structure
|
||||
|
||||
FROM ghcr.io/astral-sh/uv:python3.14-bookworm-slim
|
||||
|
||||
# Install system dependencies commonly needed by modules
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||
build-essential \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
# Set up application directory structure
|
||||
WORKDIR /app
|
||||
|
||||
# Create FuzzForge standard directories
|
||||
RUN mkdir -p /fuzzforge/input /fuzzforge/output
|
||||
|
||||
# Copy wheels directory (built by parent Makefile)
|
||||
COPY .wheels /wheels
|
||||
|
||||
# Set up uv for the container
|
||||
ENV UV_SYSTEM_PYTHON=1
|
||||
ENV UV_COMPILE_BYTECODE=1
|
||||
ENV UV_LINK_MODE=copy
|
||||
|
||||
# Default entrypoint - modules override this
|
||||
ENTRYPOINT ["uv", "run", "module"]
|
||||
@@ -1,39 +0,0 @@
|
||||
PACKAGE=$(word 1, $(shell uv version))
|
||||
VERSION=$(word 2, $(shell uv version))
|
||||
|
||||
SOURCES=./src
|
||||
TESTS=./tests
|
||||
|
||||
FUZZFORGE_MODULE_TEMPLATE=$(PWD)/src/fuzzforge_modules_sdk/templates/module
|
||||
|
||||
.PHONY: bandit clean format mypy pytest ruff version
|
||||
|
||||
bandit:
|
||||
uv run bandit --recursive $(SOURCES)
|
||||
|
||||
clean:
|
||||
@find . -type d \( \
|
||||
-name '*.egg-info' \
|
||||
-o -name '.mypy_cache' \
|
||||
-o -name '.pytest_cache' \
|
||||
-o -name '.ruff_cache' \
|
||||
-o -name '__pycache__' \
|
||||
\) -printf 'removing directory %p\n' -exec rm -rf {} +
|
||||
|
||||
cloc:
|
||||
cloc $(SOURCES)
|
||||
|
||||
format:
|
||||
uv run ruff format $(SOURCES) $(TESTS)
|
||||
|
||||
mypy:
|
||||
uv run mypy $(SOURCES)
|
||||
|
||||
pytest:
|
||||
uv run pytest $(TESTS)
|
||||
|
||||
ruff:
|
||||
uv run ruff check --fix $(SOURCES) $(TESTS)
|
||||
|
||||
version:
|
||||
@echo '$(PACKAGE)@$(VERSION)'
|
||||
@@ -1,67 +0,0 @@
|
||||
# FuzzForge Modules SDK
|
||||
|
||||
...
|
||||
|
||||
# Setup
|
||||
|
||||
- start the podman user socket
|
||||
|
||||
```shell
|
||||
systemctl --user start podman.socket
|
||||
```
|
||||
|
||||
NB : you can also automaticllay start it at boot
|
||||
|
||||
```shell
|
||||
systemctl --user enable --now podman.socket
|
||||
```
|
||||
|
||||
## HACK : fix missing `fuzzforge-modules-sdk`
|
||||
|
||||
- if you have this error when using some fuzzforge-modules-sdk deps :
|
||||
|
||||
```shell
|
||||
❯ make format
|
||||
uv run ruff format ./src ./tests
|
||||
× No solution found when resolving dependencies:
|
||||
╰─▶ Because fuzzforge-modules-sdk was not found in the package registry and your project depends on fuzzforge-modules-sdk==0.0.1, we can
|
||||
conclude that your project's requirements are unsatisfiable.
|
||||
And because your project requires opengrep[lints], we can conclude that your project's requirements are unsatisfiable.
|
||||
make: *** [Makefile:30: format] Error 1
|
||||
```
|
||||
|
||||
- build a wheel package of fuzzforge-modules-sdk
|
||||
|
||||
```shell
|
||||
cd fuzzforge_ng/fuzzforge-modules/fuzzforge-modules-sdk
|
||||
uv build
|
||||
```
|
||||
|
||||
- then inside your module project, install it
|
||||
|
||||
```shell
|
||||
cd fuzzforge_ng_modules/mymodule
|
||||
uv sync --all-extras --find-links ../../fuzzforge_ng/dist/
|
||||
```
|
||||
|
||||
# Usage
|
||||
|
||||
## Prepare
|
||||
|
||||
- enter venv (or use uv run)
|
||||
|
||||
```shell
|
||||
source .venv/bin/activate
|
||||
```
|
||||
|
||||
- create a new module
|
||||
|
||||
```shell
|
||||
fuzzforge-modules-sdk new module --name my_new_module --directory ../fuzzforge_ng_modules/
|
||||
```
|
||||
|
||||
- build the base image
|
||||
|
||||
```shell
|
||||
fuzzforge-modules-sdk build image
|
||||
```
|
||||
@@ -1,7 +0,0 @@
|
||||
[mypy]
|
||||
exclude = ^src/fuzzforge_modules_sdk/templates/.*
|
||||
plugins = pydantic.mypy
|
||||
strict = True
|
||||
warn_unused_ignores = True
|
||||
warn_redundant_casts = True
|
||||
warn_return_any = True
|
||||
@@ -1,32 +0,0 @@
|
||||
[project]
|
||||
name = "fuzzforge-modules-sdk"
|
||||
version = "0.0.1"
|
||||
description = "Software development kit (SDK) for FuzzForge's modules."
|
||||
authors = []
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.14"
|
||||
dependencies = [
|
||||
"podman==5.6.0",
|
||||
"pydantic==2.12.4",
|
||||
"structlog==25.5.0",
|
||||
"tomlkit==0.13.3",
|
||||
]
|
||||
|
||||
[project.optional-dependencies]
|
||||
lints = [
|
||||
"bandit==1.8.6",
|
||||
"mypy==1.18.2",
|
||||
"ruff==0.14.4",
|
||||
]
|
||||
tests = [
|
||||
"pytest==9.0.2",
|
||||
]
|
||||
|
||||
[project.scripts]
|
||||
fuzzforge-modules-sdk = "fuzzforge_modules_sdk._cli.main:main"
|
||||
|
||||
[tool.setuptools.package-data]
|
||||
fuzzforge_modules_sdk = [
|
||||
"assets/**/*",
|
||||
"templates/**/*",
|
||||
]
|
||||
@@ -1,19 +0,0 @@
|
||||
line-length = 120
|
||||
|
||||
[lint]
|
||||
select = [ "ALL" ]
|
||||
ignore = [
|
||||
"COM812", # conflicts with the formatter
|
||||
"D100", # ignoring missing docstrings in public modules
|
||||
"D104", # ignoring missing docstrings in public packages
|
||||
"D203", # conflicts with 'D211'
|
||||
"D213", # conflicts with 'D212'
|
||||
"TD002", # ignoring missing author in 'TODO' statements
|
||||
"TD003", # ignoring missing issue link in 'TODO' statements
|
||||
]
|
||||
|
||||
[lint.per-file-ignores]
|
||||
"tests/*" = [
|
||||
"PLR2004", # allowing comparisons using unamed numerical constants in tests
|
||||
"S101", # allowing 'assert' statements in tests
|
||||
]
|
||||
@@ -1,66 +0,0 @@
|
||||
from importlib.resources import files
|
||||
from pathlib import Path
|
||||
from shutil import copyfile, copytree
|
||||
from tempfile import TemporaryDirectory
|
||||
from typing import TYPE_CHECKING, Literal
|
||||
|
||||
import os
|
||||
|
||||
from podman import PodmanClient
|
||||
from tomlkit import TOMLDocument, parse
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from importlib.resources.abc import Traversable
|
||||
|
||||
|
||||
def _get_default_podman_socket() -> str:
|
||||
"""Get the default Podman socket path for the current user."""
|
||||
uid = os.getuid()
|
||||
return f"unix:///run/user/{uid}/podman/podman.sock"
|
||||
|
||||
|
||||
PATH_TO_SOURCES: Path = Path(__file__).parent.parent
|
||||
|
||||
|
||||
def _build_podman_image(directory: Path, tag: str, socket: str | None = None) -> None:
|
||||
if socket is None:
|
||||
socket = _get_default_podman_socket()
|
||||
with PodmanClient(base_url=socket) as client:
|
||||
client.images.build(
|
||||
dockerfile="Dockerfile",
|
||||
nocache=True,
|
||||
path=directory,
|
||||
tag=tag,
|
||||
)
|
||||
|
||||
|
||||
def build_base_image(engine: Literal["podman"], socket: str | None = None) -> None:
|
||||
with TemporaryDirectory() as directory:
|
||||
path_to_assets: Traversable = files("fuzzforge_modules_sdk").joinpath("assets")
|
||||
copyfile(
|
||||
src=str(path_to_assets.joinpath("Dockerfile")),
|
||||
dst=Path(directory).joinpath("Dockerfile"),
|
||||
)
|
||||
copyfile(
|
||||
src=str(path_to_assets.joinpath("pyproject.toml")),
|
||||
dst=Path(directory).joinpath("pyproject.toml"),
|
||||
)
|
||||
copytree(src=str(PATH_TO_SOURCES), dst=Path(directory).joinpath("src").joinpath(PATH_TO_SOURCES.name))
|
||||
|
||||
# update the file 'pyproject.toml'
|
||||
path: Path = Path(directory).joinpath("pyproject.toml")
|
||||
data: TOMLDocument = parse(path.read_text())
|
||||
name: str = data["project"]["name"] # type: ignore[assignment, index]
|
||||
version: str = data["project"]["version"] # type: ignore[assignment, index]
|
||||
tag: str = f"{name}:{version}"
|
||||
|
||||
match engine:
|
||||
case "podman":
|
||||
_build_podman_image(
|
||||
directory=Path(directory),
|
||||
socket=socket,
|
||||
tag=tag,
|
||||
)
|
||||
case _:
|
||||
message: str = f"unsupported engine '{engine}'"
|
||||
raise Exception(message) # noqa: TRY002
|
||||
@@ -1,30 +0,0 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from importlib.resources import files
|
||||
from shutil import copytree, ignore_patterns
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from tomlkit import dumps, parse
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from importlib.resources.abc import Traversable
|
||||
from pathlib import Path
|
||||
|
||||
from tomlkit import TOMLDocument
|
||||
|
||||
|
||||
def create_new_module(name: str, directory: Path) -> None:
|
||||
source: Traversable = files("fuzzforge_modules_sdk").joinpath("templates").joinpath("fuzzforge-module-template")
|
||||
destination: Path = directory.joinpath(name) # TODO: sanitize path
|
||||
copytree(
|
||||
src=str(source),
|
||||
dst=destination,
|
||||
ignore=ignore_patterns("__pycache__", "*.egg-info", "*.pyc", ".mypy_cache", ".ruff_cache", ".venv"),
|
||||
)
|
||||
|
||||
# update the file 'pyproject.toml'
|
||||
path: Path = destination.joinpath("pyproject.toml")
|
||||
data: TOMLDocument = parse(path.read_text())
|
||||
data["project"]["name"] = name # type: ignore[index]
|
||||
del data["tool"]["uv"]["sources"] # type: ignore[index, union-attr]
|
||||
path.write_text(dumps(data))
|
||||
@@ -1,71 +0,0 @@
|
||||
from argparse import ArgumentParser
|
||||
from pathlib import Path
|
||||
|
||||
from fuzzforge_modules_sdk._cli.build_base_image import build_base_image
|
||||
from fuzzforge_modules_sdk._cli.create_new_module import create_new_module
|
||||
|
||||
|
||||
def create_parser() -> ArgumentParser:
|
||||
parser: ArgumentParser = ArgumentParser(
|
||||
prog="fuzzforge-modules-sdk", description="Utilities for the Fuzzforge Modules SDK."
|
||||
)
|
||||
|
||||
subparsers = parser.add_subparsers(required=True)
|
||||
|
||||
# fuzzforge-modules-sdk build ...
|
||||
parser_build = subparsers.add_parser(name="build")
|
||||
|
||||
subparsers_build = parser_build.add_subparsers(required=True)
|
||||
|
||||
# fuzzforge-modules-sdk build image ...
|
||||
parser_build_image = subparsers_build.add_parser(
|
||||
name="image",
|
||||
help="Build the image.",
|
||||
)
|
||||
parser_build_image.add_argument(
|
||||
"--engine",
|
||||
default="podman",
|
||||
)
|
||||
parser_build_image.add_argument(
|
||||
"--socket",
|
||||
default=None,
|
||||
)
|
||||
parser_build_image.set_defaults(
|
||||
function_to_execute=build_base_image,
|
||||
)
|
||||
|
||||
# fuzzforge-modules-sdk new ...
|
||||
parser_new = subparsers.add_parser(name="new")
|
||||
|
||||
subparsers_new = parser_new.add_subparsers(required=True)
|
||||
|
||||
# fuzzforge-modules-sdk new module ...
|
||||
parser_new_module = subparsers_new.add_parser(
|
||||
name="module",
|
||||
help="Generate the boilerplate required to create a new module.",
|
||||
)
|
||||
parser_new_module.add_argument(
|
||||
"--name",
|
||||
help="The name of the module to create.",
|
||||
required=True,
|
||||
)
|
||||
parser_new_module.add_argument(
|
||||
"--directory",
|
||||
default=".",
|
||||
type=Path,
|
||||
help="The directory the new module should be created into (defaults to current working directory).",
|
||||
)
|
||||
parser_new_module.set_defaults(
|
||||
function_to_execute=create_new_module,
|
||||
)
|
||||
|
||||
return parser
|
||||
|
||||
|
||||
def main() -> None:
|
||||
"""Entry point for the command-line interface."""
|
||||
parser: ArgumentParser = create_parser()
|
||||
arguments = parser.parse_args()
|
||||
function_to_execute = arguments.function_to_execute
|
||||
del arguments.function_to_execute
|
||||
function_to_execute(**vars(arguments))
|
||||
@@ -1,13 +0,0 @@
|
||||
from pathlib import Path
|
||||
|
||||
PATH_TO_DATA: Path = Path("/data")
|
||||
PATH_TO_INPUTS: Path = PATH_TO_DATA.joinpath("input")
|
||||
PATH_TO_INPUT: Path = PATH_TO_INPUTS.joinpath("input.json")
|
||||
PATH_TO_OUTPUTS: Path = PATH_TO_DATA.joinpath("output")
|
||||
PATH_TO_ARTIFACTS: Path = PATH_TO_OUTPUTS.joinpath("artifacts")
|
||||
PATH_TO_RESULTS: Path = PATH_TO_OUTPUTS.joinpath("results.json")
|
||||
PATH_TO_LOGS: Path = PATH_TO_OUTPUTS.joinpath("logs.jsonl")
|
||||
|
||||
# Streaming output paths for real-time progress
|
||||
PATH_TO_PROGRESS: Path = PATH_TO_OUTPUTS.joinpath("progress.json")
|
||||
PATH_TO_STREAM: Path = PATH_TO_OUTPUTS.joinpath("stream.jsonl")
|
||||
@@ -1,2 +0,0 @@
|
||||
class FuzzForgeModuleError(Exception):
|
||||
"""TODO."""
|
||||
@@ -1,43 +0,0 @@
|
||||
import logging
|
||||
import sys
|
||||
|
||||
import structlog
|
||||
|
||||
from fuzzforge_modules_sdk.api.constants import PATH_TO_LOGS
|
||||
|
||||
|
||||
class Formatter(logging.Formatter):
|
||||
"""TODO."""
|
||||
|
||||
def format(self, record: logging.LogRecord) -> str:
|
||||
"""TODO."""
|
||||
record.exc_info = None
|
||||
return super().format(record)
|
||||
|
||||
|
||||
def configure() -> None:
|
||||
"""TODO."""
|
||||
fmt: str = "%(message)s"
|
||||
level = logging.DEBUG
|
||||
PATH_TO_LOGS.parent.mkdir(exist_ok=True, parents=True)
|
||||
PATH_TO_LOGS.unlink(missing_ok=True)
|
||||
handler_file = logging.FileHandler(filename=PATH_TO_LOGS, mode="a")
|
||||
handler_file.setFormatter(fmt=Formatter(fmt=fmt))
|
||||
handler_file.setLevel(level=level)
|
||||
handler_stderr = logging.StreamHandler(stream=sys.stderr)
|
||||
handler_stderr.setFormatter(fmt=Formatter(fmt=fmt))
|
||||
handler_stderr.setLevel(level=level)
|
||||
logger: logging.Logger = logging.getLogger()
|
||||
logger.setLevel(level=level)
|
||||
logger.addHandler(handler_file)
|
||||
logger.addHandler(handler_stderr)
|
||||
structlog.configure(
|
||||
processors=[
|
||||
structlog.stdlib.add_log_level,
|
||||
structlog.processors.TimeStamper(fmt="iso"),
|
||||
structlog.processors.dict_tracebacks,
|
||||
structlog.processors.JSONRenderer(),
|
||||
],
|
||||
logger_factory=structlog.stdlib.LoggerFactory(),
|
||||
wrapper_class=structlog.stdlib.BoundLogger,
|
||||
)
|
||||
@@ -1,85 +0,0 @@
|
||||
from enum import StrEnum
|
||||
from pathlib import Path # noqa: TC003 (required by pydantic at runtime)
|
||||
from typing import TypeVar
|
||||
|
||||
from pydantic import BaseModel, ConfigDict
|
||||
|
||||
|
||||
class Base(BaseModel):
|
||||
"""TODO."""
|
||||
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
|
||||
|
||||
class FuzzForgeModulesSettingsBase(Base):
|
||||
"""TODO."""
|
||||
|
||||
|
||||
FuzzForgeModulesSettingsType = TypeVar("FuzzForgeModulesSettingsType", bound=FuzzForgeModulesSettingsBase)
|
||||
|
||||
|
||||
class FuzzForgeModuleResources(StrEnum):
|
||||
"""Enumeration of artifact types."""
|
||||
|
||||
#: The type of the resource is unknown or irrelevant.
|
||||
UNKNOWN = "unknown"
|
||||
|
||||
|
||||
class FuzzForgeModuleResource(Base):
|
||||
"""TODO."""
|
||||
|
||||
#: The description of the resource.
|
||||
description: str
|
||||
#: The type of the resource.
|
||||
kind: FuzzForgeModuleResources
|
||||
#: The name of the resource.
|
||||
name: str
|
||||
#: The path of the resource on disk.
|
||||
path: Path
|
||||
|
||||
|
||||
class FuzzForgeModuleInputBase[FuzzForgeModulesSettingsType: FuzzForgeModulesSettingsBase](Base):
|
||||
"""The (standardized) input of a FuzzForge module."""
|
||||
|
||||
#: The collection of resources given to the module as inputs.
|
||||
resources: list[FuzzForgeModuleResource]
|
||||
#: The settings of the module.
|
||||
settings: FuzzForgeModulesSettingsType
|
||||
|
||||
|
||||
class FuzzForgeModuleArtifacts(StrEnum):
|
||||
"""Enumeration of artifact types."""
|
||||
|
||||
#: The artifact is an asset.
|
||||
ASSET = "asset"
|
||||
|
||||
|
||||
class FuzzForgeModuleArtifact(Base):
|
||||
"""An artifact generated by the module during its run."""
|
||||
|
||||
#: The description of the artifact.
|
||||
description: str
|
||||
#: The type of the artifact.
|
||||
kind: FuzzForgeModuleArtifacts
|
||||
#: The name of the artifact.
|
||||
name: str
|
||||
#: The path to the artifact on disk.
|
||||
path: Path
|
||||
|
||||
|
||||
class FuzzForgeModuleResults(StrEnum):
|
||||
"""TODO."""
|
||||
|
||||
SUCCESS = "success"
|
||||
FAILURE = "failure"
|
||||
|
||||
|
||||
class FuzzForgeModuleOutputBase(Base):
|
||||
"""The (standardized) output of a FuzzForge module."""
|
||||
|
||||
#: The collection of artifacts generated by the module during its run.
|
||||
artifacts: list[FuzzForgeModuleArtifacts]
|
||||
#: The path to the logs.
|
||||
logs: Path
|
||||
#: The result of the module's run.
|
||||
result: FuzzForgeModuleResults
|
||||
@@ -1,288 +0,0 @@
|
||||
from abc import ABC, abstractmethod
|
||||
import json
|
||||
import time
|
||||
from datetime import datetime, timezone
|
||||
from shutil import rmtree
|
||||
from typing import TYPE_CHECKING, Any, Final, final
|
||||
|
||||
from structlog import get_logger
|
||||
|
||||
from fuzzforge_modules_sdk.api.constants import (
|
||||
PATH_TO_ARTIFACTS,
|
||||
PATH_TO_INPUT,
|
||||
PATH_TO_LOGS,
|
||||
PATH_TO_PROGRESS,
|
||||
PATH_TO_RESULTS,
|
||||
PATH_TO_STREAM,
|
||||
)
|
||||
from fuzzforge_modules_sdk.api.exceptions import FuzzForgeModuleError
|
||||
from fuzzforge_modules_sdk.api.models import (
|
||||
FuzzForgeModuleArtifact,
|
||||
FuzzForgeModuleArtifacts,
|
||||
FuzzForgeModuleInputBase,
|
||||
FuzzForgeModuleOutputBase,
|
||||
FuzzForgeModuleResource,
|
||||
FuzzForgeModuleResults,
|
||||
FuzzForgeModulesSettingsType,
|
||||
)
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from pathlib import Path
|
||||
|
||||
from structlog.stdlib import BoundLogger
|
||||
|
||||
|
||||
class FuzzForgeModule(ABC):
|
||||
"""FuzzForge Modules' base."""
|
||||
|
||||
__artifacts: dict[str, FuzzForgeModuleArtifact]
|
||||
|
||||
#: The logger associated with the module.
|
||||
__logger: Final[BoundLogger]
|
||||
|
||||
#: The name of the module.
|
||||
__name: Final[str]
|
||||
|
||||
#: The version of the module.
|
||||
__version: Final[str]
|
||||
|
||||
#: Start time for progress tracking.
|
||||
__start_time: float
|
||||
|
||||
#: Custom output data set by the module.
|
||||
__output_data: dict[str, Any]
|
||||
|
||||
def __init__(self, name: str, version: str) -> None:
|
||||
"""Initialize an instance of the class.
|
||||
|
||||
:param name: The name of the module.
|
||||
:param version: The version of the module.
|
||||
|
||||
"""
|
||||
self.__artifacts = {}
|
||||
self.__logger = get_logger("module")
|
||||
self.__name = name
|
||||
self.__version = version
|
||||
self.__start_time = time.time()
|
||||
self.__output_data = {}
|
||||
|
||||
# Initialize streaming output files
|
||||
PATH_TO_PROGRESS.parent.mkdir(exist_ok=True, parents=True)
|
||||
PATH_TO_STREAM.parent.mkdir(exist_ok=True, parents=True)
|
||||
|
||||
@final
|
||||
def get_logger(self) -> BoundLogger:
|
||||
"""Return the logger associated with the module."""
|
||||
return self.__logger
|
||||
|
||||
@final
|
||||
def get_name(self) -> str:
|
||||
"""Return the name of the module."""
|
||||
return self.__name
|
||||
|
||||
@final
|
||||
def get_version(self) -> str:
|
||||
"""Return the version of the module."""
|
||||
return self.__version
|
||||
|
||||
@final
|
||||
def set_output(self, **kwargs: Any) -> None:
|
||||
"""Set custom output data to be included in results.json.
|
||||
|
||||
Call this from _run() to add module-specific fields to the output.
|
||||
|
||||
:param kwargs: Key-value pairs to include in the output.
|
||||
|
||||
Example:
|
||||
self.set_output(
|
||||
total_targets=4,
|
||||
valid_targets=["target1", "target2"],
|
||||
results=[...]
|
||||
)
|
||||
|
||||
"""
|
||||
self.__output_data.update(kwargs)
|
||||
|
||||
@final
|
||||
def emit_progress(
|
||||
self,
|
||||
progress: int,
|
||||
status: str = "running",
|
||||
message: str = "",
|
||||
metrics: dict[str, Any] | None = None,
|
||||
current_task: str = "",
|
||||
) -> None:
|
||||
"""Emit a progress update to the progress file.
|
||||
|
||||
This method writes to /data/output/progress.json which can be polled
|
||||
by the orchestrator or UI to show real-time progress.
|
||||
|
||||
:param progress: Progress percentage (0-100).
|
||||
:param status: Current status ("initializing", "running", "completed", "failed").
|
||||
:param message: Human-readable status message.
|
||||
:param metrics: Dictionary of metrics (e.g., {"executions": 1000, "coverage": 50}).
|
||||
:param current_task: Name of the current task being performed.
|
||||
|
||||
"""
|
||||
elapsed = time.time() - self.__start_time
|
||||
|
||||
progress_data = {
|
||||
"module": self.__name,
|
||||
"version": self.__version,
|
||||
"status": status,
|
||||
"progress": max(0, min(100, progress)),
|
||||
"message": message,
|
||||
"current_task": current_task,
|
||||
"elapsed_seconds": round(elapsed, 2),
|
||||
"timestamp": datetime.now(timezone.utc).isoformat(),
|
||||
"metrics": metrics or {},
|
||||
}
|
||||
|
||||
PATH_TO_PROGRESS.write_text(json.dumps(progress_data, indent=2))
|
||||
|
||||
@final
|
||||
def emit_event(self, event: str, **data: Any) -> None:
|
||||
"""Emit a streaming event to the stream file.
|
||||
|
||||
This method appends to /data/output/stream.jsonl which can be tailed
|
||||
by the orchestrator or UI for real-time event streaming.
|
||||
|
||||
:param event: Event type (e.g., "crash_found", "target_started", "metrics").
|
||||
:param data: Additional event data as keyword arguments.
|
||||
|
||||
"""
|
||||
elapsed = time.time() - self.__start_time
|
||||
|
||||
event_data = {
|
||||
"timestamp": datetime.now(timezone.utc).isoformat(),
|
||||
"elapsed_seconds": round(elapsed, 2),
|
||||
"module": self.__name,
|
||||
"event": event,
|
||||
**data,
|
||||
}
|
||||
|
||||
# Append to stream file (create if doesn't exist)
|
||||
with PATH_TO_STREAM.open("a") as f:
|
||||
f.write(json.dumps(event_data) + "\n")
|
||||
|
||||
@final
|
||||
def get_elapsed_seconds(self) -> float:
|
||||
"""Return the elapsed time since module start.
|
||||
|
||||
:returns: Elapsed time in seconds.
|
||||
|
||||
"""
|
||||
return time.time() - self.__start_time
|
||||
|
||||
@final
|
||||
def _register_artifact(self, name: str, kind: FuzzForgeModuleArtifacts, description: str, path: Path) -> None:
|
||||
"""Register an artifact.
|
||||
|
||||
:param name: The name of the artifact.
|
||||
:param kind: The type of the artifact.
|
||||
:param description: The description of the artifact.
|
||||
:param path: The path of the artifact on the file system.
|
||||
|
||||
"""
|
||||
source: Path = path.resolve(strict=True)
|
||||
destination: Path = PATH_TO_ARTIFACTS.joinpath(name).resolve()
|
||||
if destination.parent != PATH_TO_ARTIFACTS:
|
||||
message: str = f"path '{destination} is not a direct descendant of path '{PATH_TO_ARTIFACTS}'"
|
||||
raise FuzzForgeModuleError(message)
|
||||
if destination.exists(follow_symlinks=False):
|
||||
if destination.is_file() or destination.is_symlink():
|
||||
destination.unlink()
|
||||
elif destination.is_dir():
|
||||
rmtree(destination)
|
||||
else:
|
||||
message = f"unable to remove resource at path '{destination}': unsupported resource type"
|
||||
raise FuzzForgeModuleError(message)
|
||||
destination.parent.mkdir(exist_ok=True, parents=True)
|
||||
source.copy(destination)
|
||||
self.__artifacts[name] = FuzzForgeModuleArtifact(
|
||||
description=description,
|
||||
kind=kind,
|
||||
name=name,
|
||||
path=path,
|
||||
)
|
||||
|
||||
@final
|
||||
def main(self) -> None:
|
||||
"""TODO."""
|
||||
result = FuzzForgeModuleResults.SUCCESS
|
||||
|
||||
try:
|
||||
buffer: bytes = PATH_TO_INPUT.read_bytes()
|
||||
data = self._get_input_type().model_validate_json(buffer)
|
||||
self._prepare(settings=data.settings)
|
||||
except: # noqa: E722
|
||||
self.get_logger().exception(event="exception during 'prepare' step")
|
||||
result = FuzzForgeModuleResults.FAILURE
|
||||
|
||||
if result != FuzzForgeModuleResults.FAILURE:
|
||||
try:
|
||||
result = self._run(resources=data.resources)
|
||||
except: # noqa: E722
|
||||
self.get_logger().exception(event="exception during 'run' step")
|
||||
result = FuzzForgeModuleResults.FAILURE
|
||||
|
||||
if result != FuzzForgeModuleResults.FAILURE:
|
||||
try:
|
||||
self._cleanup(settings=data.settings)
|
||||
except: # noqa: E722
|
||||
self.get_logger().exception(event="exception during 'cleanup' step")
|
||||
|
||||
output = self._get_output_type()(
|
||||
artifacts=list(self.__artifacts.values()),
|
||||
logs=PATH_TO_LOGS,
|
||||
result=result,
|
||||
**self.__output_data,
|
||||
)
|
||||
buffer = output.model_dump_json().encode("utf-8")
|
||||
PATH_TO_RESULTS.parent.mkdir(exist_ok=True, parents=True)
|
||||
PATH_TO_RESULTS.write_bytes(buffer)
|
||||
|
||||
@classmethod
|
||||
@abstractmethod
|
||||
def _get_input_type(cls) -> type[FuzzForgeModuleInputBase[Any]]:
|
||||
"""TODO."""
|
||||
message: str = f"method '_get_input_type' is not implemented for class '{cls.__name__}'"
|
||||
raise NotImplementedError(message)
|
||||
|
||||
@classmethod
|
||||
@abstractmethod
|
||||
def _get_output_type(cls) -> type[FuzzForgeModuleOutputBase]:
|
||||
"""TODO."""
|
||||
message: str = f"method '_get_output_type' is not implemented for class '{cls.__name__}'"
|
||||
raise NotImplementedError(message)
|
||||
|
||||
@abstractmethod
|
||||
def _prepare(self, settings: FuzzForgeModulesSettingsType) -> None:
|
||||
"""TODO.
|
||||
|
||||
:param settings: TODO.
|
||||
|
||||
"""
|
||||
message: str = f"method '_prepare' is not implemented for class '{self.__class__.__name__}'"
|
||||
raise NotImplementedError(message)
|
||||
|
||||
@abstractmethod
|
||||
def _run(self, resources: list[FuzzForgeModuleResource]) -> FuzzForgeModuleResults:
|
||||
"""TODO.
|
||||
|
||||
:param resources: TODO.
|
||||
:returns: TODO.
|
||||
|
||||
"""
|
||||
message: str = f"method '_run' is not implemented for class '{self.__class__.__name__}'"
|
||||
raise NotImplementedError(message)
|
||||
|
||||
@abstractmethod
|
||||
def _cleanup(self, settings: FuzzForgeModulesSettingsType) -> None:
|
||||
"""TODO.
|
||||
|
||||
:param settings: TODO.
|
||||
|
||||
"""
|
||||
message: str = f"method '_cleanup' is not implemented for class '{self.__class__.__name__}'"
|
||||
raise NotImplementedError(message)
|
||||
@@ -1,20 +0,0 @@
|
||||
FROM docker.io/debian:trixie as base
|
||||
|
||||
COPY --from=ghcr.io/astral-sh/uv:0.9.10 /uv /uvx /bin/
|
||||
|
||||
FROM base as builder
|
||||
|
||||
WORKDIR /sdk
|
||||
|
||||
COPY ./src /sdk/src
|
||||
COPY ./pyproject.toml /sdk/pyproject.toml
|
||||
|
||||
RUN uv build --wheel -o /sdk/distributions
|
||||
|
||||
FROM base as final
|
||||
|
||||
COPY --from=builder /sdk/distributions /wheels
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
CMD [ "/usr/bin/sleep", "infinity" ]
|
||||
@@ -1 +0,0 @@
|
||||
../../../pyproject.toml
|
||||
@@ -1,12 +0,0 @@
|
||||
FROM localhost/fuzzforge-modules-sdk:0.1.0
|
||||
|
||||
# Module metadata is read from pyproject.toml [tool.fuzzforge.module] section
|
||||
# See MODULE_METADATA.md for documentation on configuring metadata
|
||||
|
||||
COPY ./src /app/src
|
||||
COPY ./pyproject.toml /app/pyproject.toml
|
||||
|
||||
# Remove workspace reference since we're using wheels
|
||||
RUN sed -i '/\[tool\.uv\.sources\]/,/^$/d' /app/pyproject.toml
|
||||
|
||||
RUN uv sync --find-links /wheels
|
||||
@@ -1,45 +0,0 @@
|
||||
PACKAGE=$(word 1, $(shell uv version))
|
||||
VERSION=$(word 2, $(shell uv version))
|
||||
|
||||
PODMAN?=/usr/bin/podman
|
||||
|
||||
SOURCES=./src
|
||||
TESTS=./tests
|
||||
|
||||
.PHONY: bandit build clean format mypy pytest ruff version
|
||||
|
||||
bandit:
|
||||
uv run bandit --recursive $(SOURCES)
|
||||
|
||||
build:
|
||||
$(PODMAN) build --file ./Dockerfile --no-cache --tag $(PACKAGE):$(VERSION)
|
||||
|
||||
save: build
|
||||
$(PODMAN) save --format oci-archive --output /tmp/$(PACKAGE)-$(VERSION).oci $(PACKAGE):$(VERSION)
|
||||
|
||||
clean:
|
||||
@find . -type d \( \
|
||||
-name '*.egg-info' \
|
||||
-o -name '.mypy_cache' \
|
||||
-o -name '.pytest_cache' \
|
||||
-o -name '.ruff_cache' \
|
||||
-o -name '__pycache__' \
|
||||
\) -printf 'removing directory %p\n' -exec rm -rf {} +
|
||||
|
||||
cloc:
|
||||
cloc $(SOURCES)
|
||||
|
||||
format:
|
||||
uv run ruff format $(SOURCES) $(TESTS)
|
||||
|
||||
mypy:
|
||||
uv run mypy $(SOURCES)
|
||||
|
||||
pytest:
|
||||
uv run pytest $(TESTS)
|
||||
|
||||
ruff:
|
||||
uv run ruff check --fix $(SOURCES) $(TESTS)
|
||||
|
||||
version:
|
||||
@echo '$(PACKAGE)@$(VERSION)'
|
||||
@@ -1,46 +0,0 @@
|
||||
# FuzzForge Modules - FIXME
|
||||
|
||||
## Installation
|
||||
|
||||
### Python
|
||||
|
||||
```shell
|
||||
# install the package (users)
|
||||
uv sync
|
||||
# install the package and all development dependencies (developers)
|
||||
uv sync --all-extras
|
||||
```
|
||||
|
||||
### Container
|
||||
|
||||
```shell
|
||||
# build the image
|
||||
make build
|
||||
# run the container
|
||||
mkdir -p "${PWD}/data" "${PWD}/data/input" "${PWD}/data/output"
|
||||
echo '{"settings":{},"resources":[]}' > "${PWD}/data/input/input.json"
|
||||
podman run --rm \
|
||||
--volume "${PWD}/data:/data" \
|
||||
'<name>:<version>' 'uv run module'
|
||||
```
|
||||
|
||||
## Usage
|
||||
|
||||
```shell
|
||||
uv run module
|
||||
```
|
||||
|
||||
## Development tools
|
||||
|
||||
```shell
|
||||
# run ruff (formatter)
|
||||
make format
|
||||
# run mypy (type checker)
|
||||
make mypy
|
||||
# run tests (pytest)
|
||||
make pytest
|
||||
# run ruff (linter)
|
||||
make ruff
|
||||
```
|
||||
|
||||
See the file `Makefile` at the root of this directory for more tools.
|
||||
@@ -1,6 +0,0 @@
|
||||
[mypy]
|
||||
plugins = pydantic.mypy
|
||||
strict = True
|
||||
warn_unused_ignores = True
|
||||
warn_redundant_casts = True
|
||||
warn_return_any = True
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user