mirror of
https://github.com/FuzzingLabs/fuzzforge_ai.git
synced 2026-08-15 08:00:20 +02:00
refactor: remove module system, migrate to MCP hub tools architecture
This commit is contained in:
@@ -1,4 +1,4 @@
|
|||||||
.PHONY: help install sync format lint typecheck test build-modules build-hub-images clean
|
.PHONY: help install sync format lint typecheck test build-hub-images clean
|
||||||
|
|
||||||
SHELL := /bin/bash
|
SHELL := /bin/bash
|
||||||
|
|
||||||
@@ -12,7 +12,6 @@ help:
|
|||||||
@echo " make lint - Lint code with ruff"
|
@echo " make lint - Lint code with ruff"
|
||||||
@echo " make typecheck - Type check with mypy"
|
@echo " make typecheck - Type check with mypy"
|
||||||
@echo " make test - Run all tests"
|
@echo " make test - Run all tests"
|
||||||
@echo " make build-modules - Build all module container images"
|
|
||||||
@echo " make build-hub-images - Build all mcp-security-hub images"
|
@echo " make build-hub-images - Build all mcp-security-hub images"
|
||||||
@echo " make clean - Clean build artifacts"
|
@echo " make clean - Clean build artifacts"
|
||||||
@echo ""
|
@echo ""
|
||||||
@@ -65,35 +64,6 @@ test:
|
|||||||
fi \
|
fi \
|
||||||
done
|
done
|
||||||
|
|
||||||
# Build all module container images
|
|
||||||
# Uses Docker by default, or Podman if FUZZFORGE_ENGINE=podman
|
|
||||||
build-modules:
|
|
||||||
@echo "Building FuzzForge module images..."
|
|
||||||
@if [ "$$FUZZFORGE_ENGINE" = "podman" ]; then \
|
|
||||||
if [ -n "$$SNAP" ]; then \
|
|
||||||
echo "Using Podman with isolated storage (Snap detected)"; \
|
|
||||||
CONTAINER_CMD="podman --root ~/.fuzzforge/containers/storage --runroot ~/.fuzzforge/containers/run"; \
|
|
||||||
else \
|
|
||||||
echo "Using Podman"; \
|
|
||||||
CONTAINER_CMD="podman"; \
|
|
||||||
fi; \
|
|
||||||
else \
|
|
||||||
echo "Using Docker"; \
|
|
||||||
CONTAINER_CMD="docker"; \
|
|
||||||
fi; \
|
|
||||||
for module in fuzzforge-modules/*/; do \
|
|
||||||
if [ -f "$$module/Dockerfile" ] && \
|
|
||||||
[ "$$module" != "fuzzforge-modules/fuzzforge-modules-sdk/" ] && \
|
|
||||||
[ "$$module" != "fuzzforge-modules/fuzzforge-module-template/" ]; then \
|
|
||||||
name=$$(basename $$module); \
|
|
||||||
version=$$(grep 'version' "$$module/pyproject.toml" 2>/dev/null | head -1 | sed 's/.*"\(.*\\)".*/\\1/' || echo "0.1.0"); \
|
|
||||||
echo "Building $$name:$$version..."; \
|
|
||||||
$$CONTAINER_CMD build -t "fuzzforge-$$name:$$version" "$$module" || exit 1; \
|
|
||||||
fi \
|
|
||||||
done
|
|
||||||
@echo ""
|
|
||||||
@echo "✓ All modules built successfully!"
|
|
||||||
|
|
||||||
# Build all mcp-security-hub images for the firmware analysis pipeline
|
# Build all mcp-security-hub images for the firmware analysis pipeline
|
||||||
build-hub-images:
|
build-hub-images:
|
||||||
@bash scripts/build-hub-images.sh
|
@bash scripts/build-hub-images.sh
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ authors = []
|
|||||||
readme = "README.md"
|
readme = "README.md"
|
||||||
requires-python = ">=3.14"
|
requires-python = ">=3.14"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"fuzzforge-runner==0.0.1",
|
"fuzzforge-mcp==0.0.1",
|
||||||
"rich>=14.0.0",
|
"rich>=14.0.0",
|
||||||
"typer==0.20.1",
|
"typer==0.20.1",
|
||||||
]
|
]
|
||||||
@@ -25,4 +25,4 @@ tests = [
|
|||||||
fuzzforge = "fuzzforge_cli.__main__:main"
|
fuzzforge = "fuzzforge_cli.__main__:main"
|
||||||
|
|
||||||
[tool.uv.sources]
|
[tool.uv.sources]
|
||||||
fuzzforge-runner = { workspace = true }
|
fuzzforge-mcp = { workspace = true }
|
||||||
|
|||||||
@@ -3,12 +3,12 @@
|
|||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Annotated
|
from typing import Annotated
|
||||||
|
|
||||||
from fuzzforge_runner import Runner, Settings
|
|
||||||
from typer import Context as TyperContext
|
from typer import Context as TyperContext
|
||||||
from typer import Option, Typer
|
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_cli.context import Context
|
||||||
|
from fuzzforge_mcp.storage import LocalStorage
|
||||||
|
|
||||||
application: Typer = Typer(
|
application: Typer = Typer(
|
||||||
name="fuzzforge",
|
name="fuzzforge",
|
||||||
@@ -27,15 +27,6 @@ def main(
|
|||||||
help="Path to the FuzzForge project directory.",
|
help="Path to the FuzzForge project directory.",
|
||||||
),
|
),
|
||||||
] = Path.cwd(),
|
] = 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[
|
storage_path: Annotated[
|
||||||
Path,
|
Path,
|
||||||
Option(
|
Option(
|
||||||
@@ -44,53 +35,20 @@ def main(
|
|||||||
help="Path to the storage directory.",
|
help="Path to the storage directory.",
|
||||||
),
|
),
|
||||||
] = Path.home() / ".fuzzforge" / "storage",
|
] = 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]
|
context: TyperContext = None, # type: ignore[assignment]
|
||||||
) -> None:
|
) -> None:
|
||||||
"""FuzzForge AI - 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
|
storage = LocalStorage(storage_path=storage_path)
|
||||||
|
|
||||||
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)
|
|
||||||
|
|
||||||
context.obj = Context(
|
context.obj = Context(
|
||||||
runner=runner,
|
storage=storage,
|
||||||
project_path=project_path,
|
project_path=project_path,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
application.add_typer(mcp.application)
|
application.add_typer(mcp.application)
|
||||||
application.add_typer(modules.application)
|
|
||||||
application.add_typer(projects.application)
|
application.add_typer(projects.application)
|
||||||
|
|||||||
@@ -137,7 +137,7 @@ def _find_fuzzforge_root() -> Path:
|
|||||||
|
|
||||||
# Walk up to find fuzzforge-oss root
|
# Walk up to find fuzzforge-oss root
|
||||||
for parent in current.parents:
|
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
|
return parent
|
||||||
|
|
||||||
# Fall back to cwd
|
# Fall back to cwd
|
||||||
@@ -146,14 +146,12 @@ def _find_fuzzforge_root() -> Path:
|
|||||||
|
|
||||||
def _generate_mcp_config(
|
def _generate_mcp_config(
|
||||||
fuzzforge_root: Path,
|
fuzzforge_root: Path,
|
||||||
modules_path: Path,
|
|
||||||
engine_type: str,
|
engine_type: str,
|
||||||
engine_socket: str,
|
engine_socket: str,
|
||||||
) -> dict:
|
) -> dict:
|
||||||
"""Generate MCP server configuration.
|
"""Generate MCP server configuration.
|
||||||
|
|
||||||
:param fuzzforge_root: Path to fuzzforge-oss installation.
|
: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_type: Container engine type (podman or docker).
|
||||||
:param engine_socket: Container engine socket path.
|
:param engine_socket: Container engine socket path.
|
||||||
:returns: MCP configuration dictionary.
|
:returns: MCP configuration dictionary.
|
||||||
@@ -181,7 +179,6 @@ def _generate_mcp_config(
|
|||||||
"args": args,
|
"args": args,
|
||||||
"cwd": str(fuzzforge_root),
|
"cwd": str(fuzzforge_root),
|
||||||
"env": {
|
"env": {
|
||||||
"FUZZFORGE_MODULES_PATH": str(modules_path),
|
|
||||||
"FUZZFORGE_ENGINE__TYPE": engine_type,
|
"FUZZFORGE_ENGINE__TYPE": engine_type,
|
||||||
"FUZZFORGE_ENGINE__GRAPHROOT": str(graphroot),
|
"FUZZFORGE_ENGINE__GRAPHROOT": str(graphroot),
|
||||||
"FUZZFORGE_ENGINE__RUNROOT": str(runroot),
|
"FUZZFORGE_ENGINE__RUNROOT": str(runroot),
|
||||||
@@ -266,14 +263,6 @@ def generate(
|
|||||||
help="AI agent to generate config for (copilot, claude-desktop, or claude-code).",
|
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[
|
engine: Annotated[
|
||||||
str,
|
str,
|
||||||
Option(
|
Option(
|
||||||
@@ -287,16 +276,12 @@ def generate(
|
|||||||
|
|
||||||
:param context: Typer context.
|
:param context: Typer context.
|
||||||
:param agent: Target AI agent.
|
:param agent: Target AI agent.
|
||||||
:param modules_path: Override modules path.
|
|
||||||
:param engine: Container engine type.
|
:param engine: Container engine type.
|
||||||
|
|
||||||
"""
|
"""
|
||||||
console = Console()
|
console = Console()
|
||||||
fuzzforge_root = _find_fuzzforge_root()
|
fuzzforge_root = _find_fuzzforge_root()
|
||||||
|
|
||||||
# Use defaults if not specified
|
|
||||||
resolved_modules = modules_path or (fuzzforge_root / "fuzzforge-modules")
|
|
||||||
|
|
||||||
# Detect socket
|
# Detect socket
|
||||||
if engine == "podman":
|
if engine == "podman":
|
||||||
socket = _detect_podman_socket()
|
socket = _detect_podman_socket()
|
||||||
@@ -306,7 +291,6 @@ def generate(
|
|||||||
# Generate config
|
# Generate config
|
||||||
server_config = _generate_mcp_config(
|
server_config = _generate_mcp_config(
|
||||||
fuzzforge_root=fuzzforge_root,
|
fuzzforge_root=fuzzforge_root,
|
||||||
modules_path=resolved_modules,
|
|
||||||
engine_type=engine,
|
engine_type=engine,
|
||||||
engine_socket=socket,
|
engine_socket=socket,
|
||||||
)
|
)
|
||||||
@@ -350,14 +334,6 @@ def install(
|
|||||||
help="AI agent to install config for (copilot, claude-desktop, or claude-code).",
|
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[
|
engine: Annotated[
|
||||||
str,
|
str,
|
||||||
Option(
|
Option(
|
||||||
@@ -382,7 +358,6 @@ def install(
|
|||||||
|
|
||||||
:param context: Typer context.
|
:param context: Typer context.
|
||||||
:param agent: Target AI agent.
|
:param agent: Target AI agent.
|
||||||
:param modules_path: Override modules path.
|
|
||||||
:param engine: Container engine type.
|
:param engine: Container engine type.
|
||||||
:param force: Overwrite existing configuration.
|
:param force: Overwrite existing configuration.
|
||||||
|
|
||||||
@@ -401,9 +376,6 @@ def install(
|
|||||||
config_path = _get_claude_desktop_mcp_path()
|
config_path = _get_claude_desktop_mcp_path()
|
||||||
servers_key = "mcpServers"
|
servers_key = "mcpServers"
|
||||||
|
|
||||||
# Use defaults if not specified
|
|
||||||
resolved_modules = modules_path or (fuzzforge_root / "fuzzforge-modules")
|
|
||||||
|
|
||||||
# Detect socket
|
# Detect socket
|
||||||
if engine == "podman":
|
if engine == "podman":
|
||||||
socket = _detect_podman_socket()
|
socket = _detect_podman_socket()
|
||||||
@@ -413,7 +385,6 @@ def install(
|
|||||||
# Generate server config
|
# Generate server config
|
||||||
server_config = _generate_mcp_config(
|
server_config = _generate_mcp_config(
|
||||||
fuzzforge_root=fuzzforge_root,
|
fuzzforge_root=fuzzforge_root,
|
||||||
modules_path=resolved_modules,
|
|
||||||
engine_type=engine,
|
engine_type=engine,
|
||||||
engine_socket=socket,
|
engine_socket=socket,
|
||||||
)
|
)
|
||||||
@@ -453,7 +424,6 @@ def install(
|
|||||||
console.print(f"[bold]Configuration file:[/bold] {config_path}")
|
console.print(f"[bold]Configuration file:[/bold] {config_path}")
|
||||||
console.print()
|
console.print()
|
||||||
console.print("[bold]Settings:[/bold]")
|
console.print("[bold]Settings:[/bold]")
|
||||||
console.print(f" Modules Path: {resolved_modules}")
|
|
||||||
console.print(f" Engine: {engine}")
|
console.print(f" Engine: {engine}")
|
||||||
console.print(f" Socket: {socket}")
|
console.print(f" Socket: {socket}")
|
||||||
console.print(f" Hub Config: {fuzzforge_root / 'hub-config.json'}")
|
console.print(f" Hub Config: {fuzzforge_root / 'hub-config.json'}")
|
||||||
|
|||||||
@@ -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 rich.table import Table
|
||||||
from typer import Argument, Context, Option, Typer
|
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(
|
application: Typer = Typer(
|
||||||
name="project",
|
name="project",
|
||||||
@@ -36,10 +36,10 @@ def init_project(
|
|||||||
:param path: Path to initialize (defaults to current directory).
|
: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)
|
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 = Console()
|
||||||
console.print(f"[green]✓[/green] Project initialized at {project_path}")
|
console.print(f"[green]✓[/green] Project initialized at {project_path}")
|
||||||
@@ -65,10 +65,10 @@ def set_assets(
|
|||||||
:param assets_path: Path to assets.
|
:param assets_path: Path to assets.
|
||||||
|
|
||||||
"""
|
"""
|
||||||
runner = get_runner(context)
|
storage = get_storage(context)
|
||||||
project_path = get_project_path(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 = Console()
|
||||||
console.print(f"[green]✓[/green] Assets stored from {assets_path}")
|
console.print(f"[green]✓[/green] Assets stored from {assets_path}")
|
||||||
@@ -87,11 +87,11 @@ def show_info(
|
|||||||
:param context: Typer context.
|
:param context: Typer context.
|
||||||
|
|
||||||
"""
|
"""
|
||||||
runner = get_runner(context)
|
storage = get_storage(context)
|
||||||
project_path = get_project_path(context)
|
project_path = get_project_path(context)
|
||||||
|
|
||||||
executions = runner.list_executions(project_path)
|
executions = storage.list_executions(project_path)
|
||||||
assets_path = runner.storage.get_project_assets_path(project_path)
|
assets_path = storage.get_project_assets_path(project_path)
|
||||||
|
|
||||||
console = Console()
|
console = Console()
|
||||||
table = Table(title=f"Project: {project_path.name}")
|
table = Table(title=f"Project: {project_path.name}")
|
||||||
@@ -118,10 +118,10 @@ def list_executions(
|
|||||||
:param context: Typer context.
|
:param context: Typer context.
|
||||||
|
|
||||||
"""
|
"""
|
||||||
runner = get_runner(context)
|
storage = get_storage(context)
|
||||||
project_path = get_project_path(context)
|
project_path = get_project_path(context)
|
||||||
|
|
||||||
executions = runner.list_executions(project_path)
|
executions = storage.list_executions(project_path)
|
||||||
|
|
||||||
console = Console()
|
console = Console()
|
||||||
|
|
||||||
@@ -134,7 +134,7 @@ def list_executions(
|
|||||||
table.add_column("Has Results")
|
table.add_column("Has Results")
|
||||||
|
|
||||||
for exec_id in executions:
|
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 "-")
|
table.add_row(exec_id, "✓" if has_results else "-")
|
||||||
|
|
||||||
console.print(table)
|
console.print(table)
|
||||||
@@ -168,10 +168,10 @@ def get_results(
|
|||||||
:param extract_to: Optional directory to extract to.
|
:param extract_to: Optional directory to extract to.
|
||||||
|
|
||||||
"""
|
"""
|
||||||
runner = get_runner(context)
|
storage = get_storage(context)
|
||||||
project_path = get_project_path(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()
|
console = Console()
|
||||||
|
|
||||||
@@ -182,5 +182,5 @@ def get_results(
|
|||||||
console.print(f"[green]✓[/green] Results: {results_path}")
|
console.print(f"[green]✓[/green] Results: {results_path}")
|
||||||
|
|
||||||
if extract_to:
|
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}")
|
console.print(f" Extracted to: {extracted}")
|
||||||
|
|||||||
@@ -5,35 +5,35 @@ from __future__ import annotations
|
|||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import TYPE_CHECKING, cast
|
from typing import TYPE_CHECKING, cast
|
||||||
|
|
||||||
from fuzzforge_runner import Runner, Settings
|
from fuzzforge_mcp.storage import LocalStorage
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
if TYPE_CHECKING:
|
||||||
from typer import Context as TyperContext
|
from typer import Context as TyperContext
|
||||||
|
|
||||||
|
|
||||||
class Context:
|
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
|
_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.
|
"""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.
|
:param project_path: Path to the current project.
|
||||||
|
|
||||||
"""
|
"""
|
||||||
self._runner = runner
|
self._storage = storage
|
||||||
self._project_path = project_path
|
self._project_path = project_path
|
||||||
|
|
||||||
def get_runner(self) -> Runner:
|
def get_storage(self) -> LocalStorage:
|
||||||
"""Get the runner instance.
|
"""Get the storage instance.
|
||||||
|
|
||||||
:return: Runner instance.
|
:return: LocalStorage instance.
|
||||||
|
|
||||||
"""
|
"""
|
||||||
return self._runner
|
return self._storage
|
||||||
|
|
||||||
def get_project_path(self) -> Path:
|
def get_project_path(self) -> Path:
|
||||||
"""Get the current project path.
|
"""Get the current project path.
|
||||||
@@ -44,14 +44,14 @@ class Context:
|
|||||||
return self._project_path
|
return self._project_path
|
||||||
|
|
||||||
|
|
||||||
def get_runner(context: TyperContext) -> Runner:
|
def get_storage(context: TyperContext) -> LocalStorage:
|
||||||
"""Get runner from Typer context.
|
"""Get storage from Typer context.
|
||||||
|
|
||||||
:param context: 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:
|
def get_project_path(context: TyperContext) -> Path:
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ Supported transport types:
|
|||||||
|
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from fuzzforge_common.hub.client import HubClient, HubClientError
|
from fuzzforge_common.hub.client import HubClient, HubClientError, PersistentSession
|
||||||
from fuzzforge_common.hub.executor import HubExecutionResult, HubExecutor
|
from fuzzforge_common.hub.executor import HubExecutionResult, HubExecutor
|
||||||
from fuzzforge_common.hub.models import (
|
from fuzzforge_common.hub.models import (
|
||||||
HubConfig,
|
HubConfig,
|
||||||
@@ -39,4 +39,5 @@ __all__ = [
|
|||||||
"HubServerType",
|
"HubServerType",
|
||||||
"HubTool",
|
"HubTool",
|
||||||
"HubToolParameter",
|
"HubToolParameter",
|
||||||
|
"PersistentSession",
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ via stdio (docker/command) or SSE transport. It handles:
|
|||||||
- Connecting to SSE endpoints
|
- Connecting to SSE endpoints
|
||||||
- Discovering tools via list_tools()
|
- Discovering tools via list_tools()
|
||||||
- Executing tools via call_tool()
|
- Executing tools via call_tool()
|
||||||
|
- Persistent container sessions for stateful interactions
|
||||||
|
|
||||||
"""
|
"""
|
||||||
|
|
||||||
@@ -16,6 +17,8 @@ import json
|
|||||||
import os
|
import os
|
||||||
import subprocess
|
import subprocess
|
||||||
from contextlib import asynccontextmanager
|
from contextlib import asynccontextmanager
|
||||||
|
from dataclasses import dataclass, field
|
||||||
|
from datetime import datetime, timezone
|
||||||
from typing import TYPE_CHECKING, Any, cast
|
from typing import TYPE_CHECKING, Any, cast
|
||||||
|
|
||||||
from fuzzforge_common.hub.models import (
|
from fuzzforge_common.hub.models import (
|
||||||
@@ -47,6 +50,48 @@ class HubClientError(Exception):
|
|||||||
"""Error in hub client operations."""
|
"""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:
|
class HubClient:
|
||||||
"""Client for communicating with MCP hub servers.
|
"""Client for communicating with MCP hub servers.
|
||||||
|
|
||||||
@@ -65,6 +110,8 @@ class HubClient:
|
|||||||
|
|
||||||
"""
|
"""
|
||||||
self._timeout = timeout
|
self._timeout = timeout
|
||||||
|
self._persistent_sessions: dict[str, PersistentSession] = {}
|
||||||
|
self._request_id: int = 0
|
||||||
|
|
||||||
async def discover_tools(self, server: HubServer) -> list[HubTool]:
|
async def discover_tools(self, server: HubServer) -> list[HubTool]:
|
||||||
"""Discover tools from a hub server.
|
"""Discover tools from a hub server.
|
||||||
@@ -84,8 +131,9 @@ class HubClient:
|
|||||||
|
|
||||||
try:
|
try:
|
||||||
async with self._connect(config) as (reader, writer):
|
async with self._connect(config) as (reader, writer):
|
||||||
# Initialize MCP session
|
# Initialise MCP session (skip for persistent — already done)
|
||||||
await self._initialize_session(reader, writer, config.name)
|
if not self._persistent_sessions.get(config.name):
|
||||||
|
await self._initialize_session(reader, writer, config.name)
|
||||||
|
|
||||||
# List tools
|
# List tools
|
||||||
tools_data = await self._call_method(
|
tools_data = await self._call_method(
|
||||||
@@ -141,7 +189,7 @@ class HubClient:
|
|||||||
"""
|
"""
|
||||||
logger = get_logger()
|
logger = get_logger()
|
||||||
config = server.config
|
config = server.config
|
||||||
exec_timeout = timeout or self._timeout
|
exec_timeout = timeout or config.timeout or self._timeout
|
||||||
|
|
||||||
logger.info(
|
logger.info(
|
||||||
"Executing hub tool",
|
"Executing hub tool",
|
||||||
@@ -152,8 +200,9 @@ class HubClient:
|
|||||||
|
|
||||||
try:
|
try:
|
||||||
async with self._connect(config) as (reader, writer):
|
async with self._connect(config) as (reader, writer):
|
||||||
# Initialize MCP session
|
# Initialise MCP session (skip for persistent — already done)
|
||||||
await self._initialize_session(reader, writer, config.name)
|
if not self._persistent_sessions.get(config.name):
|
||||||
|
await self._initialize_session(reader, writer, config.name)
|
||||||
|
|
||||||
# Call tool
|
# Call tool
|
||||||
result = await asyncio.wait_for(
|
result = await asyncio.wait_for(
|
||||||
@@ -202,10 +251,22 @@ class HubClient:
|
|||||||
) -> AsyncGenerator[tuple[asyncio.StreamReader, asyncio.StreamWriter], None]:
|
) -> AsyncGenerator[tuple[asyncio.StreamReader, asyncio.StreamWriter], None]:
|
||||||
"""Connect to an MCP server.
|
"""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.
|
:param config: Server configuration.
|
||||||
:yields: Tuple of (reader, writer) for communication.
|
: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:
|
if config.type == HubServerType.DOCKER:
|
||||||
async with self._connect_docker(config) as streams:
|
async with self._connect_docker(config) as streams:
|
||||||
yield streams
|
yield streams
|
||||||
@@ -251,11 +312,15 @@ class HubClient:
|
|||||||
|
|
||||||
cmd.append(config.image)
|
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(
|
process: Process = await asyncio.create_subprocess_exec(
|
||||||
*cmd,
|
*cmd,
|
||||||
stdin=subprocess.PIPE,
|
stdin=subprocess.PIPE,
|
||||||
stdout=subprocess.PIPE,
|
stdout=subprocess.PIPE,
|
||||||
stderr=subprocess.PIPE,
|
stderr=subprocess.PIPE,
|
||||||
|
limit=_STREAM_LIMIT,
|
||||||
)
|
)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
@@ -294,12 +359,16 @@ class HubClient:
|
|||||||
# Set up environment
|
# Set up environment
|
||||||
env = dict(config.environment) if config.environment else None
|
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(
|
process: Process = await asyncio.create_subprocess_exec(
|
||||||
*config.command,
|
*config.command,
|
||||||
stdin=subprocess.PIPE,
|
stdin=subprocess.PIPE,
|
||||||
stdout=subprocess.PIPE,
|
stdout=subprocess.PIPE,
|
||||||
stderr=subprocess.PIPE,
|
stderr=subprocess.PIPE,
|
||||||
env=env,
|
env=env,
|
||||||
|
limit=_STREAM_LIMIT,
|
||||||
)
|
)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
@@ -385,10 +454,11 @@ class HubClient:
|
|||||||
:returns: Method result.
|
:returns: Method result.
|
||||||
|
|
||||||
"""
|
"""
|
||||||
# Create JSON-RPC request
|
# Create JSON-RPC request with unique ID
|
||||||
|
self._request_id += 1
|
||||||
request = {
|
request = {
|
||||||
"jsonrpc": "2.0",
|
"jsonrpc": "2.0",
|
||||||
"id": 1,
|
"id": self._request_id,
|
||||||
"method": method,
|
"method": method,
|
||||||
"params": params,
|
"params": params,
|
||||||
}
|
}
|
||||||
@@ -415,7 +485,16 @@ class HubClient:
|
|||||||
msg = f"MCP error: {error.get('message', 'Unknown error')}"
|
msg = f"MCP error: {error.get('message', 'Unknown error')}"
|
||||||
raise HubClientError(msg)
|
raise HubClientError(msg)
|
||||||
|
|
||||||
return response.get("result", {})
|
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(
|
async def _send_notification(
|
||||||
self,
|
self,
|
||||||
@@ -442,3 +521,218 @@ class HubClient:
|
|||||||
notification_line = json.dumps(notification) + "\n"
|
notification_line = json.dumps(notification) + "\n"
|
||||||
writer.write(notification_line.encode())
|
writer.write(notification_line.encode())
|
||||||
await writer.drain()
|
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
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ from __future__ import annotations
|
|||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import TYPE_CHECKING, Any, cast
|
from typing import TYPE_CHECKING, Any, cast
|
||||||
|
|
||||||
from fuzzforge_common.hub.client import HubClient, HubClientError
|
from fuzzforge_common.hub.client import HubClient, HubClientError, PersistentSession
|
||||||
from fuzzforge_common.hub.models import HubServer, HubServerConfig, HubTool
|
from fuzzforge_common.hub.models import HubServer, HubServerConfig, HubTool
|
||||||
from fuzzforge_common.hub.registry import HubRegistry
|
from fuzzforge_common.hub.registry import HubRegistry
|
||||||
|
|
||||||
@@ -106,6 +106,7 @@ class HubExecutor:
|
|||||||
"""
|
"""
|
||||||
self._registry = HubRegistry(config_path)
|
self._registry = HubRegistry(config_path)
|
||||||
self._client = HubClient(timeout=timeout)
|
self._client = HubClient(timeout=timeout)
|
||||||
|
self._continuous_sessions: dict[str, dict[str, Any]] = {}
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def registry(self) -> HubRegistry:
|
def registry(self) -> HubRegistry:
|
||||||
@@ -291,6 +292,7 @@ class HubExecutor:
|
|||||||
"""
|
"""
|
||||||
servers = []
|
servers = []
|
||||||
for server in self._registry.servers:
|
for server in self._registry.servers:
|
||||||
|
session = self._client.get_persistent_session(server.name)
|
||||||
servers.append({
|
servers.append({
|
||||||
"name": server.name,
|
"name": server.name,
|
||||||
"identifier": server.identifier,
|
"identifier": server.identifier,
|
||||||
@@ -298,6 +300,8 @@ class HubExecutor:
|
|||||||
"enabled": server.config.enabled,
|
"enabled": server.config.enabled,
|
||||||
"category": server.config.category,
|
"category": server.config.category,
|
||||||
"description": server.config.description,
|
"description": server.config.description,
|
||||||
|
"persistent": server.config.persistent,
|
||||||
|
"persistent_session_active": session is not None and session.alive,
|
||||||
"discovered": server.discovered,
|
"discovered": server.discovered,
|
||||||
"tool_count": len(server.tools),
|
"tool_count": len(server.tools),
|
||||||
"error": server.discovery_error,
|
"error": server.discovery_error,
|
||||||
@@ -332,3 +336,287 @@ class HubExecutor:
|
|||||||
if tool:
|
if tool:
|
||||||
return tool.input_schema
|
return tool.input_schema
|
||||||
return None
|
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())
|
||||||
|
|||||||
@@ -92,6 +92,18 @@ class HubServerConfig(BaseModel):
|
|||||||
description="Category for grouping servers",
|
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):
|
class HubToolParameter(BaseModel):
|
||||||
"""A parameter for an MCP tool.
|
"""A parameter for an MCP tool.
|
||||||
|
|||||||
@@ -8,7 +8,6 @@ requires-python = ">=3.14"
|
|||||||
dependencies = [
|
dependencies = [
|
||||||
"fastmcp==2.14.1",
|
"fastmcp==2.14.1",
|
||||||
"fuzzforge-common==0.0.1",
|
"fuzzforge-common==0.0.1",
|
||||||
"fuzzforge-runner==0.0.1",
|
|
||||||
"pydantic==2.12.4",
|
"pydantic==2.12.4",
|
||||||
"pydantic-settings==2.12.0",
|
"pydantic-settings==2.12.0",
|
||||||
"structlog==25.5.0",
|
"structlog==25.5.0",
|
||||||
@@ -32,5 +31,4 @@ tests = [
|
|||||||
|
|
||||||
[tool.uv.sources]
|
[tool.uv.sources]
|
||||||
fuzzforge-common = { workspace = true }
|
fuzzforge-common = { workspace = true }
|
||||||
fuzzforge-runner = { workspace = true }
|
|
||||||
fuzzforge-tests = { workspace = true }
|
fuzzforge-tests = { workspace = true }
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
"""FuzzForge MCP Server Application.
|
"""FuzzForge MCP Server Application.
|
||||||
|
|
||||||
This is the main entry point for the FuzzForge MCP server, providing
|
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 fastmcp.server.middleware.error_handling import ErrorHandlingMiddleware
|
||||||
|
|
||||||
from fuzzforge_mcp import resources, tools
|
from fuzzforge_mcp import resources, tools
|
||||||
from fuzzforge_runner import Settings
|
from fuzzforge_mcp.settings import Settings
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
if TYPE_CHECKING:
|
||||||
from collections.abc import AsyncGenerator
|
from collections.abc import AsyncGenerator
|
||||||
@@ -38,19 +39,18 @@ mcp: FastMCP = FastMCP(
|
|||||||
instructions="""
|
instructions="""
|
||||||
FuzzForge is a security research orchestration platform. Use these tools to:
|
FuzzForge is a security research orchestration platform. Use these tools to:
|
||||||
|
|
||||||
1. **List modules**: Discover available security research modules
|
1. **List hub servers**: Discover registered MCP tool servers
|
||||||
2. **Execute modules**: Run modules in isolated containers
|
2. **Discover tools**: Find available tools from hub servers
|
||||||
3. **Execute workflows**: Chain multiple modules together
|
3. **Execute hub tools**: Run security tools in isolated containers
|
||||||
4. **Manage projects**: Initialize and configure projects
|
4. **Manage projects**: Initialize and configure projects
|
||||||
5. **Get results**: Retrieve execution results
|
5. **Get results**: Retrieve execution results
|
||||||
6. **Hub tools**: Discover and execute tools from external MCP servers
|
|
||||||
|
|
||||||
Typical workflow:
|
Typical workflow:
|
||||||
1. Initialize a project with `init_project`
|
1. Initialize a project with `init_project`
|
||||||
2. Set project assets with `set_project_assets` (optional, only needed once for the source directory)
|
2. Set project assets with `set_project_assets` (optional, only needed once for the source directory)
|
||||||
3. List available modules with `list_modules`
|
3. List available hub servers with `list_hub_servers`
|
||||||
4. Execute a module with `execute_module` — use `assets_path` param to pass different inputs per module
|
4. Discover tools from servers with `discover_hub_tools`
|
||||||
5. Read outputs from `results_path` returned by `execute_module` — check module's `output_artifacts` metadata for filenames
|
5. Execute hub tools with `execute_hub_tool`
|
||||||
|
|
||||||
Hub workflow:
|
Hub workflow:
|
||||||
1. List available hub servers with `list_hub_servers`
|
1. List available hub servers with `list_hub_servers`
|
||||||
|
|||||||
@@ -6,9 +6,10 @@ from pathlib import Path
|
|||||||
from typing import TYPE_CHECKING, cast
|
from typing import TYPE_CHECKING, cast
|
||||||
|
|
||||||
from fastmcp.server.dependencies import get_context
|
from fastmcp.server.dependencies import get_context
|
||||||
from fuzzforge_runner import Runner, Settings
|
|
||||||
|
|
||||||
from fuzzforge_mcp.exceptions import FuzzForgeMCPError
|
from fuzzforge_mcp.exceptions import FuzzForgeMCPError
|
||||||
|
from fuzzforge_mcp.settings import Settings
|
||||||
|
from fuzzforge_mcp.storage import LocalStorage
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
if TYPE_CHECKING:
|
||||||
from fastmcp import Context
|
from fastmcp import Context
|
||||||
@@ -17,6 +18,9 @@ if TYPE_CHECKING:
|
|||||||
# Track the current active project path (set by init_project)
|
# Track the current active project path (set by init_project)
|
||||||
_current_project_path: Path | None = None
|
_current_project_path: Path | None = None
|
||||||
|
|
||||||
|
# Singleton storage instance
|
||||||
|
_storage: LocalStorage | None = None
|
||||||
|
|
||||||
|
|
||||||
def set_current_project_path(project_path: Path) -> None:
|
def set_current_project_path(project_path: Path) -> None:
|
||||||
"""Set the current project path.
|
"""Set the current project path.
|
||||||
@@ -60,11 +64,14 @@ def get_project_path() -> Path:
|
|||||||
return Path.cwd()
|
return Path.cwd()
|
||||||
|
|
||||||
|
|
||||||
def get_runner() -> Runner:
|
def get_storage() -> LocalStorage:
|
||||||
"""Get a configured Runner instance.
|
"""Get the storage backend instance.
|
||||||
|
|
||||||
:return: Runner instance configured from MCP settings.
|
:return: LocalStorage instance.
|
||||||
|
|
||||||
"""
|
"""
|
||||||
settings: Settings = get_settings()
|
global _storage
|
||||||
return Runner(settings)
|
if _storage is None:
|
||||||
|
settings = get_settings()
|
||||||
|
_storage = LocalStorage(settings.storage.path)
|
||||||
|
return _storage
|
||||||
|
|||||||
@@ -2,14 +2,12 @@
|
|||||||
|
|
||||||
from fastmcp import FastMCP
|
from fastmcp import FastMCP
|
||||||
|
|
||||||
from fuzzforge_mcp.resources import executions, modules, project, workflows
|
from fuzzforge_mcp.resources import executions, project
|
||||||
|
|
||||||
mcp: FastMCP = FastMCP()
|
mcp: FastMCP = FastMCP()
|
||||||
|
|
||||||
mcp.mount(executions.mcp)
|
mcp.mount(executions.mcp)
|
||||||
mcp.mount(modules.mcp)
|
|
||||||
mcp.mount(project.mcp)
|
mcp.mount(project.mcp)
|
||||||
mcp.mount(workflows.mcp)
|
|
||||||
|
|
||||||
__all__ = [
|
__all__ = [
|
||||||
"mcp",
|
"mcp",
|
||||||
|
|||||||
@@ -3,15 +3,12 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import TYPE_CHECKING, Any
|
from typing import Any
|
||||||
|
|
||||||
from fastmcp import FastMCP
|
from fastmcp import FastMCP
|
||||||
from fastmcp.exceptions import ResourceError
|
from fastmcp.exceptions import ResourceError
|
||||||
|
|
||||||
from fuzzforge_mcp.dependencies import get_project_path, get_runner
|
from fuzzforge_mcp.dependencies import get_project_path, get_storage
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
|
||||||
from fuzzforge_runner import Runner
|
|
||||||
|
|
||||||
|
|
||||||
mcp: FastMCP = FastMCP()
|
mcp: FastMCP = FastMCP()
|
||||||
@@ -26,16 +23,16 @@ async def list_executions() -> list[dict[str, Any]]:
|
|||||||
:return: List of execution information dictionaries.
|
:return: List of execution information dictionaries.
|
||||||
|
|
||||||
"""
|
"""
|
||||||
runner: Runner = get_runner()
|
storage = get_storage()
|
||||||
project_path: Path = get_project_path()
|
project_path: Path = get_project_path()
|
||||||
|
|
||||||
try:
|
try:
|
||||||
execution_ids = runner.list_executions(project_path)
|
execution_ids = storage.list_executions(project_path)
|
||||||
|
|
||||||
return [
|
return [
|
||||||
{
|
{
|
||||||
"execution_id": exec_id,
|
"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
|
for exec_id in execution_ids
|
||||||
]
|
]
|
||||||
@@ -53,11 +50,11 @@ async def get_execution(execution_id: str) -> dict[str, Any]:
|
|||||||
:return: Execution information dictionary.
|
:return: Execution information dictionary.
|
||||||
|
|
||||||
"""
|
"""
|
||||||
runner: Runner = get_runner()
|
storage = get_storage()
|
||||||
project_path: Path = get_project_path()
|
project_path: Path = get_project_path()
|
||||||
|
|
||||||
try:
|
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:
|
if results_path is None:
|
||||||
raise ResourceError(f"Execution not found: {execution_id}")
|
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 __future__ import annotations
|
||||||
|
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import TYPE_CHECKING, Any
|
from typing import Any
|
||||||
|
|
||||||
from fastmcp import FastMCP
|
from fastmcp import FastMCP
|
||||||
from fastmcp.exceptions import ResourceError
|
from fastmcp.exceptions import ResourceError
|
||||||
|
|
||||||
from fuzzforge_mcp.dependencies import get_project_path, get_runner
|
from fuzzforge_mcp.dependencies import get_project_path, get_settings, get_storage
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
|
||||||
from fuzzforge_runner import Runner
|
|
||||||
|
|
||||||
|
|
||||||
mcp: FastMCP = FastMCP()
|
mcp: FastMCP = FastMCP()
|
||||||
@@ -27,12 +24,12 @@ async def get_project() -> dict[str, Any]:
|
|||||||
:return: Project information dictionary.
|
:return: Project information dictionary.
|
||||||
|
|
||||||
"""
|
"""
|
||||||
runner: Runner = get_runner()
|
storage = get_storage()
|
||||||
project_path: Path = get_project_path()
|
project_path: Path = get_project_path()
|
||||||
|
|
||||||
try:
|
try:
|
||||||
executions = runner.list_executions(project_path)
|
executions = storage.list_executions(project_path)
|
||||||
assets_path = runner.storage.get_project_assets_path(project_path)
|
assets_path = storage.get_project_assets_path(project_path)
|
||||||
|
|
||||||
return {
|
return {
|
||||||
"path": str(project_path),
|
"path": str(project_path),
|
||||||
@@ -40,7 +37,7 @@ async def get_project() -> dict[str, Any]:
|
|||||||
"has_assets": assets_path is not None,
|
"has_assets": assets_path is not None,
|
||||||
"assets_path": str(assets_path) if assets_path else None,
|
"assets_path": str(assets_path) if assets_path else None,
|
||||||
"execution_count": len(executions),
|
"execution_count": len(executions),
|
||||||
"recent_executions": executions[:10], # Last 10 executions
|
"recent_executions": executions[:10],
|
||||||
}
|
}
|
||||||
|
|
||||||
except Exception as exception:
|
except Exception as exception:
|
||||||
@@ -53,13 +50,11 @@ async def get_project_settings() -> dict[str, Any]:
|
|||||||
"""Get current FuzzForge settings.
|
"""Get current FuzzForge settings.
|
||||||
|
|
||||||
Returns the active configuration for the MCP server including
|
Returns the active configuration for the MCP server including
|
||||||
engine, storage, and project settings.
|
engine, storage, and hub settings.
|
||||||
|
|
||||||
:return: Settings dictionary.
|
:return: Settings dictionary.
|
||||||
|
|
||||||
"""
|
"""
|
||||||
from fuzzforge_mcp.dependencies import get_settings
|
|
||||||
|
|
||||||
try:
|
try:
|
||||||
settings = get_settings()
|
settings = get_settings()
|
||||||
|
|
||||||
@@ -71,9 +66,10 @@ async def get_project_settings() -> dict[str, Any]:
|
|||||||
"storage": {
|
"storage": {
|
||||||
"path": str(settings.storage.path),
|
"path": str(settings.storage.path),
|
||||||
},
|
},
|
||||||
"project": {
|
"hub": {
|
||||||
"path": str(settings.project.path),
|
"enabled": settings.hub.enabled,
|
||||||
"modules_path": str(settings.modules_path),
|
"config_path": str(settings.hub.config_path),
|
||||||
|
"timeout": settings.hub.timeout,
|
||||||
},
|
},
|
||||||
"debug": settings.debug,
|
"debug": settings.debug,
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,53 +0,0 @@
|
|||||||
"""Workflow resources for FuzzForge MCP.
|
|
||||||
|
|
||||||
Note: In FuzzForge AI, 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 AI 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)",
|
|
||||||
},
|
|
||||||
}
|
|
||||||
+24
-46
@@ -1,10 +1,23 @@
|
|||||||
"""FuzzForge Runner settings configuration."""
|
"""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 __future__ import annotations
|
||||||
|
|
||||||
from enum import StrEnum
|
from enum import StrEnum
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Literal
|
|
||||||
|
|
||||||
from pydantic import BaseModel, Field
|
from pydantic import BaseModel, Field
|
||||||
from pydantic_settings import BaseSettings, SettingsConfigDict
|
from pydantic_settings import BaseSettings, SettingsConfigDict
|
||||||
@@ -20,24 +33,21 @@ class EngineType(StrEnum):
|
|||||||
class EngineSettings(BaseModel):
|
class EngineSettings(BaseModel):
|
||||||
"""Container engine configuration."""
|
"""Container engine configuration."""
|
||||||
|
|
||||||
#: Type of container engine to use. Docker is the default for simplicity.
|
#: Type of container engine to use.
|
||||||
type: EngineType = EngineType.DOCKER
|
type: EngineType = EngineType.DOCKER
|
||||||
|
|
||||||
#: Path to the container engine socket (only used as fallback for socket-based engines).
|
#: Path to the container engine socket.
|
||||||
socket: str = Field(default="")
|
socket: str = Field(default="")
|
||||||
|
|
||||||
#: Custom graph root for Podman storage (only used with Podman under Snap).
|
#: Custom graph root for Podman storage.
|
||||||
graphroot: Path = Field(default=Path.home() / ".fuzzforge" / "containers" / "storage")
|
graphroot: Path = Field(default=Path.home() / ".fuzzforge" / "containers" / "storage")
|
||||||
|
|
||||||
#: Custom run root for Podman runtime state (only used with Podman under Snap).
|
#: Custom run root for Podman runtime state.
|
||||||
runroot: Path = Field(default=Path.home() / ".fuzzforge" / "containers" / "run")
|
runroot: Path = Field(default=Path.home() / ".fuzzforge" / "containers" / "run")
|
||||||
|
|
||||||
|
|
||||||
class StorageSettings(BaseModel):
|
class StorageSettings(BaseModel):
|
||||||
"""Storage configuration for local filesystem storage.
|
"""Storage configuration for local filesystem storage."""
|
||||||
|
|
||||||
OSS uses direct file mounting without archiving for simplicity.
|
|
||||||
"""
|
|
||||||
|
|
||||||
#: Base path for local storage.
|
#: Base path for local storage.
|
||||||
path: Path = Field(default=Path.home() / ".fuzzforge" / "storage")
|
path: Path = Field(default=Path.home() / ".fuzzforge" / "storage")
|
||||||
@@ -50,33 +60,12 @@ class ProjectSettings(BaseModel):
|
|||||||
default_path: Path = Field(default=Path.home() / ".fuzzforge" / "projects")
|
default_path: Path = Field(default=Path.home() / ".fuzzforge" / "projects")
|
||||||
|
|
||||||
|
|
||||||
class RegistrySettings(BaseModel):
|
|
||||||
"""Container registry configuration for module images.
|
|
||||||
|
|
||||||
By default, registry URL is empty (local-only mode). When empty,
|
|
||||||
modules must be built locally with `make build-modules`.
|
|
||||||
Set via FUZZFORGE_REGISTRY__URL environment variable if needed.
|
|
||||||
"""
|
|
||||||
|
|
||||||
#: Registry URL for pulling module images (empty = local-only mode).
|
|
||||||
url: str = Field(default="")
|
|
||||||
|
|
||||||
#: Default tag to use when pulling images.
|
|
||||||
default_tag: str = Field(default="latest")
|
|
||||||
|
|
||||||
#: Registry username for authentication (optional).
|
|
||||||
username: str | None = None
|
|
||||||
|
|
||||||
#: Registry password/token for authentication (optional).
|
|
||||||
password: str | None = None
|
|
||||||
|
|
||||||
|
|
||||||
class HubSettings(BaseModel):
|
class HubSettings(BaseModel):
|
||||||
"""MCP Hub configuration for external tool servers.
|
"""MCP Hub configuration for external tool servers.
|
||||||
|
|
||||||
Controls the hub that bridges FuzzForge with external MCP servers
|
Controls the hub that bridges FuzzForge with external MCP servers
|
||||||
(e.g., mcp-security-hub). When enabled, AI agents can discover
|
(e.g., mcp-security-hub). AI agents discover and execute tools
|
||||||
and execute tools from registered MCP servers.
|
from registered MCP servers.
|
||||||
|
|
||||||
Configure via environment variables:
|
Configure via environment variables:
|
||||||
``FUZZFORGE_HUB__ENABLED=true``
|
``FUZZFORGE_HUB__ENABLED=true``
|
||||||
@@ -95,15 +84,10 @@ class HubSettings(BaseModel):
|
|||||||
|
|
||||||
|
|
||||||
class Settings(BaseSettings):
|
class Settings(BaseSettings):
|
||||||
"""FuzzForge Runner settings.
|
"""FuzzForge MCP Server settings.
|
||||||
|
|
||||||
Settings can be configured via environment variables with the prefix
|
Settings can be configured via environment variables with the prefix
|
||||||
``FUZZFORGE_``. Nested settings use underscore as delimiter.
|
``FUZZFORGE_``. Nested settings use double-underscore as delimiter.
|
||||||
|
|
||||||
Example:
|
|
||||||
``FUZZFORGE_ENGINE_TYPE=docker``
|
|
||||||
``FUZZFORGE_STORAGE_PATH=/data/fuzzforge``
|
|
||||||
``FUZZFORGE_MODULES_PATH=/path/to/modules``
|
|
||||||
|
|
||||||
"""
|
"""
|
||||||
|
|
||||||
@@ -122,14 +106,8 @@ class Settings(BaseSettings):
|
|||||||
#: Project settings.
|
#: Project settings.
|
||||||
project: ProjectSettings = Field(default_factory=ProjectSettings)
|
project: ProjectSettings = Field(default_factory=ProjectSettings)
|
||||||
|
|
||||||
#: Container registry settings.
|
|
||||||
registry: RegistrySettings = Field(default_factory=RegistrySettings)
|
|
||||||
|
|
||||||
#: MCP Hub settings.
|
#: MCP Hub settings.
|
||||||
hub: HubSettings = Field(default_factory=HubSettings)
|
hub: HubSettings = Field(default_factory=HubSettings)
|
||||||
|
|
||||||
#: Path to modules directory (for development/local builds).
|
|
||||||
modules_path: Path = Field(default=Path.home() / ".fuzzforge" / "modules")
|
|
||||||
|
|
||||||
#: Enable debug logging.
|
#: Enable debug logging.
|
||||||
debug: bool = False
|
debug: bool = False
|
||||||
@@ -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,11 @@
|
|||||||
|
|
||||||
from fastmcp import FastMCP
|
from fastmcp import FastMCP
|
||||||
|
|
||||||
from fuzzforge_mcp.tools import hub, modules, projects, workflows
|
from fuzzforge_mcp.tools import hub, projects
|
||||||
|
|
||||||
mcp: FastMCP = FastMCP()
|
mcp: FastMCP = FastMCP()
|
||||||
|
|
||||||
mcp.mount(modules.mcp)
|
|
||||||
mcp.mount(projects.mcp)
|
mcp.mount(projects.mcp)
|
||||||
mcp.mount(workflows.mcp)
|
|
||||||
mcp.mount(hub.mcp)
|
mcp.mount(hub.mcp)
|
||||||
|
|
||||||
__all__ = [
|
__all__ = [
|
||||||
|
|||||||
@@ -313,3 +313,249 @@ async def add_hub_server(
|
|||||||
raise
|
raise
|
||||||
msg = f"Failed to add hub server: {e}"
|
msg = f"Failed to add hub server: {e}"
|
||||||
raise ToolError(msg) from 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,392 +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),
|
|
||||||
# Incremental stream.jsonl tracking
|
|
||||||
"stream_lines_read": 0,
|
|
||||||
"total_crashes": 0,
|
|
||||||
}
|
|
||||||
|
|
||||||
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).
|
|
||||||
|
|
||||||
Uses incremental reads of ``stream.jsonl`` via ``tail -n +offset`` so that
|
|
||||||
only new lines appended since the last poll are fetched and parsed. Crash
|
|
||||||
counts and latest metrics are accumulated across polls.
|
|
||||||
|
|
||||||
"""
|
|
||||||
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")
|
|
||||||
|
|
||||||
# Carry forward accumulated state
|
|
||||||
metrics: dict[str, Any] = {
|
|
||||||
"total_executions": 0,
|
|
||||||
"total_crashes": execution.get("total_crashes", 0),
|
|
||||||
"exec_per_sec": 0,
|
|
||||||
"coverage": 0,
|
|
||||||
"current_target": "",
|
|
||||||
"new_events": [],
|
|
||||||
}
|
|
||||||
|
|
||||||
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
|
|
||||||
|
|
||||||
# Incremental read: only fetch lines we haven't seen yet
|
|
||||||
lines_read: int = execution.get("stream_lines_read", 0)
|
|
||||||
stream_content = executor.read_module_output_incremental(
|
|
||||||
container_id,
|
|
||||||
start_line=lines_read + 1,
|
|
||||||
output_file="/data/output/stream.jsonl",
|
|
||||||
)
|
|
||||||
|
|
||||||
if stream_content:
|
|
||||||
new_lines = stream_content.strip().split("\n")
|
|
||||||
new_line_count = 0
|
|
||||||
|
|
||||||
for line in new_lines:
|
|
||||||
if not line.strip():
|
|
||||||
continue
|
|
||||||
try:
|
|
||||||
event = json.loads(line)
|
|
||||||
except json.JSONDecodeError:
|
|
||||||
# Possible torn read on the very last line — skip it
|
|
||||||
# and do NOT advance the offset so it is re-read next
|
|
||||||
# poll when the write is complete.
|
|
||||||
continue
|
|
||||||
|
|
||||||
new_line_count += 1
|
|
||||||
metrics["new_events"].append(event)
|
|
||||||
|
|
||||||
# Extract latest metrics snapshot
|
|
||||||
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":
|
|
||||||
metrics["total_crashes"] += 1
|
|
||||||
|
|
||||||
# Advance offset by successfully parsed lines only
|
|
||||||
execution["stream_lines_read"] = lines_read + new_line_count
|
|
||||||
execution["total_crashes"] = metrics["total_crashes"]
|
|
||||||
|
|
||||||
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 __future__ import annotations
|
||||||
|
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import TYPE_CHECKING, Any
|
from typing import Any
|
||||||
|
|
||||||
from fastmcp import FastMCP
|
from fastmcp import FastMCP
|
||||||
from fastmcp.exceptions import ToolError
|
from fastmcp.exceptions import ToolError
|
||||||
|
|
||||||
from fuzzforge_mcp.dependencies import get_project_path, get_runner, set_current_project_path
|
from fuzzforge_mcp.dependencies import get_project_path, get_storage, set_current_project_path
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
|
||||||
from fuzzforge_runner import Runner
|
|
||||||
|
|
||||||
|
|
||||||
mcp: FastMCP = FastMCP()
|
mcp: FastMCP = FastMCP()
|
||||||
@@ -22,25 +19,24 @@ async def init_project(project_path: str | None = None) -> dict[str, Any]:
|
|||||||
"""Initialize a new FuzzForge project.
|
"""Initialize a new FuzzForge project.
|
||||||
|
|
||||||
Creates a `.fuzzforge/` directory inside the project for storing:
|
Creates a `.fuzzforge/` directory inside the project for storing:
|
||||||
- assets/: Input files (source code, etc.)
|
- config.json: Project configuration
|
||||||
- inputs/: Prepared module inputs (for debugging)
|
- runs/: Execution results
|
||||||
- runs/: Execution results from each module
|
|
||||||
|
|
||||||
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.
|
:param project_path: Path to the project directory. If not provided, uses current directory.
|
||||||
:return: Project initialization result.
|
:return: Project initialization result.
|
||||||
|
|
||||||
"""
|
"""
|
||||||
runner: Runner = get_runner()
|
storage = get_storage()
|
||||||
|
|
||||||
try:
|
try:
|
||||||
path = Path(project_path) if project_path else get_project_path()
|
path = Path(project_path) if project_path else get_project_path()
|
||||||
|
|
||||||
# Track this as the current active project
|
# Track this as the current active project
|
||||||
set_current_project_path(path)
|
set_current_project_path(path)
|
||||||
|
|
||||||
storage_path = runner.init_project(path)
|
storage_path = storage.init_project(path)
|
||||||
|
|
||||||
return {
|
return {
|
||||||
"success": True,
|
"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]:
|
async def set_project_assets(assets_path: str) -> dict[str, Any]:
|
||||||
"""Set the initial assets (source code) for a project.
|
"""Set the initial assets (source code) for a project.
|
||||||
|
|
||||||
This sets the DEFAULT source directory mounted into modules.
|
This sets the DEFAULT source directory that will be mounted into
|
||||||
Usually this is the project root containing source code (e.g. Cargo.toml, src/).
|
hub tool containers via volume mounts.
|
||||||
|
|
||||||
IMPORTANT: This OVERWRITES the previous assets path. Only call this once
|
:param assets_path: Path to the project source directory.
|
||||||
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.
|
|
||||||
:return: Result including stored assets path.
|
:return: Result including stored assets path.
|
||||||
|
|
||||||
"""
|
"""
|
||||||
runner: Runner = get_runner()
|
storage = get_storage()
|
||||||
project_path: Path = get_project_path()
|
project_path: Path = get_project_path()
|
||||||
|
|
||||||
try:
|
try:
|
||||||
stored_path = runner.set_project_assets(
|
stored_path = storage.set_project_assets(
|
||||||
project_path=project_path,
|
project_path=project_path,
|
||||||
assets_path=Path(assets_path),
|
assets_path=Path(assets_path),
|
||||||
)
|
)
|
||||||
@@ -100,11 +91,11 @@ async def list_executions() -> dict[str, Any]:
|
|||||||
:return: List of execution IDs.
|
:return: List of execution IDs.
|
||||||
|
|
||||||
"""
|
"""
|
||||||
runner: Runner = get_runner()
|
storage = get_storage()
|
||||||
project_path: Path = get_project_path()
|
project_path: Path = get_project_path()
|
||||||
|
|
||||||
try:
|
try:
|
||||||
executions = runner.list_executions(project_path)
|
executions = storage.list_executions(project_path)
|
||||||
|
|
||||||
return {
|
return {
|
||||||
"success": True,
|
"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.
|
:return: Result including path to results archive.
|
||||||
|
|
||||||
"""
|
"""
|
||||||
runner: Runner = get_runner()
|
storage = get_storage()
|
||||||
project_path: Path = get_project_path()
|
project_path: Path = get_project_path()
|
||||||
|
|
||||||
try:
|
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:
|
if results_path is None:
|
||||||
return {
|
return {
|
||||||
@@ -140,7 +131,7 @@ async def get_execution_results(execution_id: str, extract_to: str | None = None
|
|||||||
"error": "Execution results not found",
|
"error": "Execution results not found",
|
||||||
}
|
}
|
||||||
|
|
||||||
result = {
|
result: dict[str, Any] = {
|
||||||
"success": True,
|
"success": True,
|
||||||
"execution_id": execution_id,
|
"execution_id": execution_id,
|
||||||
"results_path": str(results_path),
|
"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
|
# Extract if requested
|
||||||
if extract_to:
|
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)
|
result["extracted_path"] = str(extracted_path)
|
||||||
|
|
||||||
return result
|
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
|
|
||||||
|
|
||||||
@@ -11,16 +11,6 @@ if TYPE_CHECKING:
|
|||||||
from fastmcp.client import FastMCPTransport
|
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(
|
async def test_init_project_tool_exists(
|
||||||
mcp_client: "Client[FastMCPTransport]",
|
mcp_client: "Client[FastMCPTransport]",
|
||||||
) -> None:
|
) -> None:
|
||||||
@@ -31,31 +21,11 @@ async def test_init_project_tool_exists(
|
|||||||
assert "init_project" in tool_names
|
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(
|
async def test_mcp_has_expected_tool_count(
|
||||||
mcp_client: "Client[FastMCPTransport]",
|
mcp_client: "Client[FastMCPTransport]",
|
||||||
) -> None:
|
) -> None:
|
||||||
"""Test that MCP has the expected number of tools."""
|
"""Test that MCP has the expected number of tools."""
|
||||||
tools = await mcp_client.list_tools()
|
tools = await mcp_client.list_tools()
|
||||||
|
|
||||||
# Should have at least 4 core tools
|
# Should have project tools + hub tools
|
||||||
assert len(tools) >= 4
|
assert len(tools) >= 2
|
||||||
|
|||||||
@@ -1,26 +0,0 @@
|
|||||||
FROM localhost/fuzzforge-modules-sdk:0.1.0
|
|
||||||
|
|
||||||
# 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, FuzzForgeModuleStatus
|
|
||||||
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=FuzzForgeModuleStatus.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=FuzzForgeModuleStatus.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=FuzzForgeModuleStatus.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=FuzzForgeModuleStatus.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=FuzzForgeModuleStatus.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=FuzzForgeModuleStatus.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,11 +0,0 @@
|
|||||||
FROM localhost/fuzzforge-modules-sdk:0.1.0
|
|
||||||
|
|
||||||
# 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,12 +0,0 @@
|
|||||||
FROM localhost/fuzzforge-modules-sdk:0.1.0
|
|
||||||
|
|
||||||
# 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
|
|
||||||
]
|
|
||||||
-66
@@ -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
|
|
||||||
-30
@@ -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,9 +0,0 @@
|
|||||||
from pathlib import Path
|
|
||||||
|
|
||||||
PATH_TO_DATA: Path = Path("/fuzzforge")
|
|
||||||
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")
|
|
||||||
@@ -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,
|
|
||||||
)
|
|
||||||
-90
@@ -1,90 +0,0 @@
|
|||||||
"""FuzzForge modules SDK models.
|
|
||||||
|
|
||||||
This module provides backward-compatible exports for all model types.
|
|
||||||
For Core SDK compatibility, use imports from `fuzzforge_modules_sdk.api.models.mod`.
|
|
||||||
"""
|
|
||||||
|
|
||||||
from enum import StrEnum
|
|
||||||
from pathlib import Path # noqa: TC003 (required by pydantic at runtime)
|
|
||||||
|
|
||||||
from pydantic import ConfigDict
|
|
||||||
|
|
||||||
# Re-export from mod.py for Core SDK compatibility
|
|
||||||
from fuzzforge_modules_sdk.api.models.mod import (
|
|
||||||
Base,
|
|
||||||
FuzzForgeModuleInputBase,
|
|
||||||
FuzzForgeModuleResource,
|
|
||||||
FuzzForgeModuleResources,
|
|
||||||
FuzzForgeModulesSettingsBase,
|
|
||||||
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):
|
|
||||||
"""Module execution result enumeration."""
|
|
||||||
|
|
||||||
SUCCESS = "success"
|
|
||||||
FAILURE = "failure"
|
|
||||||
|
|
||||||
|
|
||||||
class FuzzForgeModuleStatus(StrEnum):
|
|
||||||
"""Possible statuses emitted by a running module."""
|
|
||||||
|
|
||||||
#: Module is setting up its environment.
|
|
||||||
INITIALIZING = "initializing"
|
|
||||||
#: Module is actively running.
|
|
||||||
RUNNING = "running"
|
|
||||||
#: Module finished successfully.
|
|
||||||
COMPLETED = "completed"
|
|
||||||
#: Module encountered an error.
|
|
||||||
FAILED = "failed"
|
|
||||||
#: Module was stopped by the orchestrator (SIGTERM).
|
|
||||||
STOPPED = "stopped"
|
|
||||||
|
|
||||||
|
|
||||||
class FuzzForgeModuleOutputBase(Base):
|
|
||||||
"""The (standardized) output of a FuzzForge module."""
|
|
||||||
|
|
||||||
#: The collection of artifacts generated by the module during its run.
|
|
||||||
artifacts: list[FuzzForgeModuleArtifact]
|
|
||||||
#: The path to the logs.
|
|
||||||
logs: Path
|
|
||||||
#: The result of the module's run.
|
|
||||||
result: FuzzForgeModuleResults
|
|
||||||
|
|
||||||
|
|
||||||
__all__ = [
|
|
||||||
# Core SDK compatible exports
|
|
||||||
"Base",
|
|
||||||
"FuzzForgeModuleInputBase",
|
|
||||||
"FuzzForgeModuleResource",
|
|
||||||
"FuzzForgeModuleResources",
|
|
||||||
"FuzzForgeModulesSettingsBase",
|
|
||||||
"FuzzForgeModulesSettingsType",
|
|
||||||
# OSS-specific exports (also used in OSS modules)
|
|
||||||
"FuzzForgeModuleArtifact",
|
|
||||||
"FuzzForgeModuleArtifacts",
|
|
||||||
"FuzzForgeModuleOutputBase",
|
|
||||||
"FuzzForgeModuleResults",
|
|
||||||
"FuzzForgeModuleStatus",
|
|
||||||
]
|
|
||||||
@@ -1,53 +0,0 @@
|
|||||||
"""Core module models for FuzzForge modules SDK.
|
|
||||||
|
|
||||||
This module contains the base classes for module settings, inputs, and resources.
|
|
||||||
These are compatible with the fuzzforge-core SDK structure.
|
|
||||||
"""
|
|
||||||
|
|
||||||
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):
|
|
||||||
"""Base model for all FuzzForge module types."""
|
|
||||||
|
|
||||||
model_config = ConfigDict(extra="forbid")
|
|
||||||
|
|
||||||
|
|
||||||
class FuzzForgeModulesSettingsBase(Base):
|
|
||||||
"""Base class for module settings."""
|
|
||||||
|
|
||||||
|
|
||||||
FuzzForgeModulesSettingsType = TypeVar("FuzzForgeModulesSettingsType", bound=FuzzForgeModulesSettingsBase)
|
|
||||||
|
|
||||||
|
|
||||||
class FuzzForgeModuleResources(StrEnum):
|
|
||||||
"""Enumeration of resource types."""
|
|
||||||
|
|
||||||
#: The type of the resource is unknown or irrelevant.
|
|
||||||
UNKNOWN = "unknown"
|
|
||||||
|
|
||||||
|
|
||||||
class FuzzForgeModuleResource(Base):
|
|
||||||
"""A resource provided to a module as input."""
|
|
||||||
|
|
||||||
#: 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
|
|
||||||
-335
@@ -1,335 +0,0 @@
|
|||||||
from abc import ABC, abstractmethod
|
|
||||||
import json
|
|
||||||
import signal
|
|
||||||
import threading
|
|
||||||
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_RESULTS,
|
|
||||||
)
|
|
||||||
from fuzzforge_modules_sdk.api.exceptions import FuzzForgeModuleError
|
|
||||||
from fuzzforge_modules_sdk.api.models import (
|
|
||||||
FuzzForgeModuleArtifact,
|
|
||||||
FuzzForgeModuleArtifacts,
|
|
||||||
FuzzForgeModuleInputBase,
|
|
||||||
FuzzForgeModuleOutputBase,
|
|
||||||
FuzzForgeModuleResource,
|
|
||||||
FuzzForgeModuleResults,
|
|
||||||
FuzzForgeModuleStatus,
|
|
||||||
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]
|
|
||||||
|
|
||||||
#: Event set when stop is requested (SIGTERM received).
|
|
||||||
#: Using :class:`threading.Event` so multi-threaded modules can
|
|
||||||
#: efficiently wait on it via :pymethod:`threading.Event.wait`.
|
|
||||||
__stop_requested: threading.Event
|
|
||||||
|
|
||||||
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 = {}
|
|
||||||
self.__stop_requested = threading.Event()
|
|
||||||
|
|
||||||
# Register SIGTERM handler for graceful shutdown
|
|
||||||
signal.signal(signal.SIGTERM, self._handle_sigterm)
|
|
||||||
|
|
||||||
@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 is_stop_requested(self) -> bool:
|
|
||||||
"""Check if stop was requested (SIGTERM received).
|
|
||||||
|
|
||||||
Long-running modules should check this periodically and exit gracefully
|
|
||||||
when True. Results will be written automatically on SIGTERM.
|
|
||||||
|
|
||||||
The underlying :class:`threading.Event` can be obtained via
|
|
||||||
:meth:`stop_event` for modules that need to *wait* on it.
|
|
||||||
|
|
||||||
:returns: True if SIGTERM was received.
|
|
||||||
|
|
||||||
"""
|
|
||||||
return self.__stop_requested.is_set()
|
|
||||||
|
|
||||||
@final
|
|
||||||
def stop_event(self) -> threading.Event:
|
|
||||||
"""Return the stop :class:`threading.Event`.
|
|
||||||
|
|
||||||
Multi-threaded modules can use ``self.stop_event().wait(timeout)``
|
|
||||||
instead of polling :meth:`is_stop_requested` in a busy-loop.
|
|
||||||
|
|
||||||
:returns: The threading event that is set on SIGTERM.
|
|
||||||
|
|
||||||
"""
|
|
||||||
return self.__stop_requested
|
|
||||||
|
|
||||||
@final
|
|
||||||
def _handle_sigterm(self, signum: int, frame: Any) -> None:
|
|
||||||
"""Handle SIGTERM signal for graceful shutdown.
|
|
||||||
|
|
||||||
Sets the stop event and emits a final progress update, then returns.
|
|
||||||
The normal :meth:`main` lifecycle (run → cleanup → write results) will
|
|
||||||
complete as usual once :meth:`_run` observes :meth:`is_stop_requested`
|
|
||||||
and returns, giving the module a chance to do any last-minute work
|
|
||||||
before the process exits.
|
|
||||||
|
|
||||||
:param signum: Signal number.
|
|
||||||
:param frame: Current stack frame.
|
|
||||||
|
|
||||||
"""
|
|
||||||
self.__stop_requested.set()
|
|
||||||
self.get_logger().info("received SIGTERM, stopping after current operation")
|
|
||||||
|
|
||||||
# Emit final progress update
|
|
||||||
self.emit_progress(
|
|
||||||
progress=100,
|
|
||||||
status=FuzzForgeModuleStatus.STOPPED,
|
|
||||||
message="Module stopped by orchestrator (SIGTERM)",
|
|
||||||
)
|
|
||||||
|
|
||||||
@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: FuzzForgeModuleStatus = FuzzForgeModuleStatus.RUNNING,
|
|
||||||
message: str = "",
|
|
||||||
metrics: dict[str, Any] | None = None,
|
|
||||||
current_task: str = "",
|
|
||||||
) -> None:
|
|
||||||
"""Emit a structured progress event to stdout (JSONL).
|
|
||||||
|
|
||||||
Progress is written as a single JSON line to stdout so that the
|
|
||||||
orchestrator can capture it via ``kubectl logs`` without requiring
|
|
||||||
any file-system access inside the container.
|
|
||||||
|
|
||||||
:param progress: Progress percentage (0-100).
|
|
||||||
:param status: Current module status.
|
|
||||||
: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.
|
|
||||||
|
|
||||||
"""
|
|
||||||
self.emit_event(
|
|
||||||
"progress",
|
|
||||||
status=status.value,
|
|
||||||
progress=max(0, min(100, progress)),
|
|
||||||
message=message,
|
|
||||||
current_task=current_task,
|
|
||||||
metrics=metrics or {},
|
|
||||||
)
|
|
||||||
|
|
||||||
@final
|
|
||||||
def emit_event(self, event: str, **data: Any) -> None:
|
|
||||||
"""Emit a structured event to stdout as a single JSONL line.
|
|
||||||
|
|
||||||
All module events (including progress updates) are written to stdout
|
|
||||||
so the orchestrator can stream them in real time via ``kubectl logs``.
|
|
||||||
|
|
||||||
:param event: Event type (e.g., ``"crash_found"``, ``"target_started"``,
|
|
||||||
``"progress"``, ``"metrics"``).
|
|
||||||
:param data: Additional event data as keyword arguments.
|
|
||||||
|
|
||||||
"""
|
|
||||||
event_data = {
|
|
||||||
"timestamp": datetime.now(timezone.utc).isoformat(),
|
|
||||||
"elapsed_seconds": round(self.get_elapsed_seconds(), 2),
|
|
||||||
"module": self.__name,
|
|
||||||
"event": event,
|
|
||||||
**data,
|
|
||||||
}
|
|
||||||
print(json.dumps(event_data), flush=True)
|
|
||||||
|
|
||||||
@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:
|
|
||||||
"""Execute the module lifecycle: prepare → run → cleanup → write results."""
|
|
||||||
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,
|
|
||||||
)
|
|
||||||
PATH_TO_RESULTS.parent.mkdir(exist_ok=True, parents=True)
|
|
||||||
PATH_TO_RESULTS.write_bytes(output.model_dump_json().encode("utf-8"))
|
|
||||||
|
|
||||||
@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
@@ -1 +0,0 @@
|
|||||||
../../../pyproject.toml
|
|
||||||
-12
@@ -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
|
|
||||||
-45
@@ -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)'
|
|
||||||
-46
@@ -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.
|
|
||||||
-6
@@ -1,6 +0,0 @@
|
|||||||
[mypy]
|
|
||||||
plugins = pydantic.mypy
|
|
||||||
strict = True
|
|
||||||
warn_unused_ignores = True
|
|
||||||
warn_redundant_casts = True
|
|
||||||
warn_return_any = True
|
|
||||||
-62
@@ -1,62 +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"
|
|
||||||
]
|
|
||||||
|
|
||||||
# REQUIRED: How AI should display output to user
|
|
||||||
output_treatment = "FIXME: Describe how to present the output"
|
|
||||||
-19
@@ -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
|
|
||||||
]
|
|
||||||
-19
@@ -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()
|
|
||||||
-54
@@ -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.
|
|
||||||
|
|
||||||
"""
|
|
||||||
-11
@@ -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."""
|
|
||||||
-7
@@ -1,7 +0,0 @@
|
|||||||
from fuzzforge_modules_sdk.api.models import FuzzForgeModulesSettingsBase
|
|
||||||
|
|
||||||
|
|
||||||
class Settings(FuzzForgeModulesSettingsBase):
|
|
||||||
"""TODO."""
|
|
||||||
|
|
||||||
# Here goes your attributes
|
|
||||||
@@ -1,26 +0,0 @@
|
|||||||
FROM localhost/fuzzforge-modules-sdk:0.1.0
|
|
||||||
|
|
||||||
# Module metadata is now read from pyproject.toml [tool.fuzzforge.module] section
|
|
||||||
|
|
||||||
# Install build tools and Rust nightly for compiling and testing fuzz harnesses
|
|
||||||
RUN apt-get update && apt-get install -y \
|
|
||||||
curl \
|
|
||||||
build-essential \
|
|
||||||
pkg-config \
|
|
||||||
libssl-dev \
|
|
||||||
&& rm -rf /var/lib/apt/lists/*
|
|
||||||
|
|
||||||
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 for testing harnesses
|
|
||||||
RUN cargo install cargo-fuzz --locked || true
|
|
||||||
|
|
||||||
COPY ./src /app/src
|
|
||||||
COPY ./pyproject.toml /app/pyproject.toml
|
|
||||||
COPY ./README.md /app/README.md
|
|
||||||
|
|
||||||
# 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,289 +0,0 @@
|
|||||||
# Harness Tester Feedback Types
|
|
||||||
|
|
||||||
Complete reference of all feedback the `harness-tester` module provides to help AI agents improve fuzz harnesses.
|
|
||||||
|
|
||||||
## Overview
|
|
||||||
|
|
||||||
The harness-tester evaluates harnesses across **6 dimensions** and provides specific, actionable suggestions for each issue detected.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 1. Compilation Feedback
|
|
||||||
|
|
||||||
### ✅ Success Cases
|
|
||||||
- **Compiles successfully** → Strength noted
|
|
||||||
|
|
||||||
### ❌ Error Cases
|
|
||||||
|
|
||||||
| Issue Type | Severity | Detection | Suggestion |
|
|
||||||
|------------|----------|-----------|------------|
|
|
||||||
| `undefined_variable` | CRITICAL | "cannot find" in error | Check variable names match function signature. Use exact names from fuzzable_functions.json |
|
|
||||||
| `type_mismatch` | CRITICAL | "mismatched types" in error | Check function expects types you're passing. Convert fuzzer input to correct type (e.g., &[u8] to &str with from_utf8) |
|
|
||||||
| `trait_not_implemented` | CRITICAL | "trait" + "not implemented" | Ensure you're using correct types. Some functions require specific trait implementations |
|
|
||||||
| `compilation_error` | CRITICAL | Any other error | Review error message and fix syntax/type issues. Check function signatures in source code |
|
|
||||||
|
|
||||||
### ⚠️ Warning Cases
|
|
||||||
|
|
||||||
| Issue Type | Severity | Detection | Suggestion |
|
|
||||||
|------------|----------|-----------|------------|
|
|
||||||
| `unused_variable` | INFO | "unused" in warning | Remove unused variables or use underscore prefix (_variable) to suppress warning |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 2. Execution Feedback
|
|
||||||
|
|
||||||
### ✅ Success Cases
|
|
||||||
- **Executes without crashing** → Strength noted
|
|
||||||
|
|
||||||
### ❌ Error Cases
|
|
||||||
|
|
||||||
| Issue Type | Severity | Detection | Suggestion |
|
|
||||||
|------------|----------|-----------|------------|
|
|
||||||
| `stack_overflow` | CRITICAL | "stack overflow" in crash | Check for infinite recursion or large stack allocations. Use heap allocation (Box, Vec) for large data structures |
|
|
||||||
| `panic_on_start` | CRITICAL | "panic" in crash | Check initialization code. Ensure required resources are available and input validation doesn't panic on empty input |
|
|
||||||
| `immediate_crash` | CRITICAL | Crashes on first run | Debug harness initialization. Add error handling and check for null/invalid pointers |
|
|
||||||
| `infinite_loop` | CRITICAL | Execution timeout | Check for loops that depend on fuzzer input. Add iteration limits or timeout mechanisms |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 3. Coverage Feedback
|
|
||||||
|
|
||||||
### ✅ Success Cases
|
|
||||||
- **>50% coverage** → "Excellent coverage"
|
|
||||||
- **Good growth** → "Harness exploring code paths"
|
|
||||||
|
|
||||||
### ❌ Error Cases
|
|
||||||
|
|
||||||
| Issue Type | Severity | Detection | Suggestion |
|
|
||||||
|------------|----------|-----------|------------|
|
|
||||||
| `no_coverage` | CRITICAL | 0 new edges found | Ensure you're actually calling the target function with fuzzer-provided data. Check that 'data' parameter is passed to function |
|
|
||||||
| `very_low_coverage` | WARNING | <5% coverage or "none" growth | Harness may not be reaching target code. Verify correct entry point function. Check if input validation rejects all fuzzer data |
|
|
||||||
| `low_coverage` | WARNING | <20% coverage or "poor" growth | Try fuzzing multiple entry points or remove restrictive input validation. Consider using dictionary for structured inputs |
|
|
||||||
| `early_stagnation` | INFO | Coverage stops growing <10s | Harness may be hitting input validation barriers. Consider fuzzing with seed corpus of valid inputs |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 4. Performance Feedback
|
|
||||||
|
|
||||||
### ✅ Success Cases
|
|
||||||
- **>1000 execs/s** → "Excellent performance"
|
|
||||||
- **>500 execs/s** → "Good performance"
|
|
||||||
|
|
||||||
### ❌ Error Cases
|
|
||||||
|
|
||||||
| Issue Type | Severity | Detection | Suggestion |
|
|
||||||
|------------|----------|-----------|------------|
|
|
||||||
| `extremely_slow` | CRITICAL | <10 execs/s | Remove file I/O, network operations, or expensive computations from harness loop. Move setup code outside fuzz target function |
|
|
||||||
| `slow_execution` | WARNING | <100 execs/s | Optimize harness: avoid allocations in hot path, reuse buffers, remove logging. Profile to find bottlenecks |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 5. Stability Feedback
|
|
||||||
|
|
||||||
### ✅ Success Cases
|
|
||||||
- **Stable execution** → Strength noted
|
|
||||||
- **Found unique crashes** → "Found N potential bugs!"
|
|
||||||
|
|
||||||
### ⚠️ Warning Cases
|
|
||||||
|
|
||||||
| Issue Type | Severity | Detection | Suggestion |
|
|
||||||
|------------|----------|-----------|------------|
|
|
||||||
| `unstable_frequent_crashes` | WARNING | >10 crashes per 1000 execs | This might be expected if testing buggy code. If not, add error handling for edge cases or invalid inputs |
|
|
||||||
| `hangs_detected` | WARNING | Hangs found during trial | Add timeouts to prevent infinite loops. Check for blocking operations or resource exhaustion |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 6. Code Quality Feedback
|
|
||||||
|
|
||||||
### Informational
|
|
||||||
|
|
||||||
| Issue Type | Severity | Detection | Suggestion |
|
|
||||||
|------------|----------|-----------|------------|
|
|
||||||
| `unused_variable` | INFO | Compiler warnings | Clean up code for better maintainability |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Quality Scoring Formula
|
|
||||||
|
|
||||||
```
|
|
||||||
Base Score: 20 points (for compiling + running)
|
|
||||||
|
|
||||||
+ Coverage (0-40 points):
|
|
||||||
- Excellent growth: +40
|
|
||||||
- Good growth: +30
|
|
||||||
- Poor growth: +10
|
|
||||||
- No growth: +0
|
|
||||||
|
|
||||||
+ Performance (0-25 points):
|
|
||||||
- >1000 execs/s: +25
|
|
||||||
- >500 execs/s: +20
|
|
||||||
- >100 execs/s: +10
|
|
||||||
- >10 execs/s: +5
|
|
||||||
- <10 execs/s: +0
|
|
||||||
|
|
||||||
+ Stability (0-15 points):
|
|
||||||
- Stable: +15
|
|
||||||
- Unstable: +10
|
|
||||||
- Crashes frequently: +5
|
|
||||||
|
|
||||||
Maximum: 100 points
|
|
||||||
```
|
|
||||||
|
|
||||||
### Verdicts
|
|
||||||
|
|
||||||
- **70-100**: `production-ready` → Use for long-term fuzzing campaigns
|
|
||||||
- **30-69**: `needs-improvement` → Fix issues before production use
|
|
||||||
- **0-29**: `broken` → Critical issues block execution
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Example Feedback Flow
|
|
||||||
|
|
||||||
### Scenario 1: Broken Harness (Type Mismatch)
|
|
||||||
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"quality": {
|
|
||||||
"score": 0,
|
|
||||||
"verdict": "broken",
|
|
||||||
"issues": [
|
|
||||||
{
|
|
||||||
"category": "compilation",
|
|
||||||
"severity": "critical",
|
|
||||||
"type": "type_mismatch",
|
|
||||||
"message": "Type mismatch: expected &[u8], found &str",
|
|
||||||
"suggestion": "Check function expects types you're passing. Convert fuzzer input to correct type (e.g., &[u8] to &str with from_utf8)"
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"recommended_actions": [
|
|
||||||
"Fix 1 critical issue(s) preventing execution"
|
|
||||||
]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
**AI Agent Action**: Regenerate harness with correct type conversion
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### Scenario 2: Low Coverage Harness
|
|
||||||
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"quality": {
|
|
||||||
"score": 35,
|
|
||||||
"verdict": "needs-improvement",
|
|
||||||
"issues": [
|
|
||||||
{
|
|
||||||
"category": "coverage",
|
|
||||||
"severity": "warning",
|
|
||||||
"type": "low_coverage",
|
|
||||||
"message": "Low coverage: 12% - not exploring enough code paths",
|
|
||||||
"suggestion": "Try fuzzing multiple entry points or remove restrictive input validation"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"category": "performance",
|
|
||||||
"severity": "warning",
|
|
||||||
"type": "slow_execution",
|
|
||||||
"message": "Slow execution: 45 execs/sec (expected 500+)",
|
|
||||||
"suggestion": "Optimize harness: avoid allocations in hot path, reuse buffers"
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"strengths": [
|
|
||||||
"Compiles successfully",
|
|
||||||
"Executes without crashing"
|
|
||||||
],
|
|
||||||
"recommended_actions": [
|
|
||||||
"Address 2 warning(s) to improve harness quality"
|
|
||||||
]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
**AI Agent Action**: Remove input validation, optimize performance
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### Scenario 3: Production-Ready Harness
|
|
||||||
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"quality": {
|
|
||||||
"score": 85,
|
|
||||||
"verdict": "production-ready",
|
|
||||||
"issues": [],
|
|
||||||
"strengths": [
|
|
||||||
"Compiles successfully",
|
|
||||||
"Executes without crashing",
|
|
||||||
"Excellent coverage: 67% of target code reached",
|
|
||||||
"Excellent performance: 1507 execs/sec",
|
|
||||||
"Stable execution - no crashes or hangs"
|
|
||||||
],
|
|
||||||
"recommended_actions": [
|
|
||||||
"Harness is ready for production fuzzing"
|
|
||||||
]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
**AI Agent Action**: Proceed to long-term fuzzing with cargo-fuzzer
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Integration with AI Workflow
|
|
||||||
|
|
||||||
```python
|
|
||||||
def iterative_harness_generation(target_function):
|
|
||||||
"""AI agent iteratively improves harness based on feedback."""
|
|
||||||
|
|
||||||
max_iterations = 3
|
|
||||||
|
|
||||||
for iteration in range(max_iterations):
|
|
||||||
# Generate or improve harness
|
|
||||||
if iteration == 0:
|
|
||||||
harness = ai_generate_harness(target_function)
|
|
||||||
else:
|
|
||||||
harness = ai_improve_harness(previous_harness, feedback)
|
|
||||||
|
|
||||||
# Test harness
|
|
||||||
result = execute_module("harness-tester", harness)
|
|
||||||
evaluation = result["harnesses"][0]
|
|
||||||
|
|
||||||
# Check verdict
|
|
||||||
if evaluation["quality"]["verdict"] == "production-ready":
|
|
||||||
return harness # Success!
|
|
||||||
|
|
||||||
# Extract feedback for next iteration
|
|
||||||
feedback = {
|
|
||||||
"issues": evaluation["quality"]["issues"],
|
|
||||||
"suggestions": [issue["suggestion"] for issue in evaluation["quality"]["issues"]],
|
|
||||||
"score": evaluation["quality"]["score"],
|
|
||||||
"coverage": evaluation["fuzzing_trial"]["coverage"] if "fuzzing_trial" in evaluation else None,
|
|
||||||
"performance": evaluation["fuzzing_trial"]["performance"] if "fuzzing_trial" in evaluation else None
|
|
||||||
}
|
|
||||||
|
|
||||||
# Store for next iteration
|
|
||||||
previous_harness = harness
|
|
||||||
|
|
||||||
return harness # Return best attempt after max iterations
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Summary
|
|
||||||
|
|
||||||
The harness-tester provides **comprehensive, actionable feedback** across 6 dimensions:
|
|
||||||
|
|
||||||
1. ✅ **Compilation** - Syntax and type correctness
|
|
||||||
2. ✅ **Execution** - Runtime stability
|
|
||||||
3. ✅ **Coverage** - Code exploration effectiveness
|
|
||||||
4. ✅ **Performance** - Execution speed
|
|
||||||
5. ✅ **Stability** - Crash/hang frequency
|
|
||||||
6. ✅ **Code Quality** - Best practices
|
|
||||||
|
|
||||||
Each issue includes:
|
|
||||||
- **Clear detection** of what went wrong
|
|
||||||
- **Specific suggestion** on how to fix it
|
|
||||||
- **Severity level** to prioritize fixes
|
|
||||||
|
|
||||||
This enables AI agents to rapidly iterate and produce high-quality fuzz harnesses with minimal human intervention.
|
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user