From 1d495cedce701fbffa33fe77a3d1dab46c126716 Mon Sep 17 00:00:00 2001 From: AFredefon Date: Sun, 8 Mar 2026 17:53:29 +0100 Subject: [PATCH] refactor: remove module system, migrate to MCP hub tools architecture --- Makefile | 32 +- fuzzforge-cli/pyproject.toml | 4 +- .../src/fuzzforge_cli/application.py | 52 +- .../src/fuzzforge_cli/commands/mcp.py | 32 +- .../src/fuzzforge_cli/commands/modules.py | 166 ---- .../src/fuzzforge_cli/commands/projects.py | 28 +- fuzzforge-cli/src/fuzzforge_cli/context.py | 28 +- .../src/fuzzforge_common/hub/__init__.py | 3 +- .../src/fuzzforge_common/hub/client.py | 310 ++++++- .../src/fuzzforge_common/hub/executor.py | 290 ++++++- .../src/fuzzforge_common/hub/models.py | 12 + fuzzforge-mcp/pyproject.toml | 2 - .../src/fuzzforge_mcp/application.py | 18 +- .../src/fuzzforge_mcp/dependencies.py | 19 +- .../src/fuzzforge_mcp/resources/__init__.py | 4 +- .../src/fuzzforge_mcp/resources/executions.py | 17 +- .../src/fuzzforge_mcp/resources/modules.py | 78 -- .../src/fuzzforge_mcp/resources/project.py | 26 +- .../src/fuzzforge_mcp/resources/workflows.py | 53 -- .../src/fuzzforge_mcp}/settings.py | 70 +- fuzzforge-mcp/src/fuzzforge_mcp/storage.py | 203 +++++ .../src/fuzzforge_mcp/tools/__init__.py | 4 +- fuzzforge-mcp/src/fuzzforge_mcp/tools/hub.py | 246 ++++++ .../src/fuzzforge_mcp/tools/modules.py | 392 --------- .../src/fuzzforge_mcp/tools/projects.py | 49 +- .../src/fuzzforge_mcp/tools/workflows.py | 92 -- fuzzforge-mcp/tests/test_resources.py | 34 +- fuzzforge-modules/cargo-fuzzer/Dockerfile | 26 - fuzzforge-modules/cargo-fuzzer/Makefile | 45 - fuzzforge-modules/cargo-fuzzer/README.md | 46 - fuzzforge-modules/cargo-fuzzer/mypy.ini | 6 - fuzzforge-modules/cargo-fuzzer/pyproject.toml | 58 -- fuzzforge-modules/cargo-fuzzer/ruff.toml | 19 - .../cargo-fuzzer/src/module/__init__.py | 0 .../cargo-fuzzer/src/module/__main__.py | 19 - .../cargo-fuzzer/src/module/mod.py | 538 ------------ .../cargo-fuzzer/src/module/models.py | 88 -- .../cargo-fuzzer/src/module/settings.py | 35 - fuzzforge-modules/cargo-fuzzer/tests/.gitkeep | 0 fuzzforge-modules/crash-analyzer/Dockerfile | 11 - fuzzforge-modules/crash-analyzer/Makefile | 45 - fuzzforge-modules/crash-analyzer/README.md | 46 - fuzzforge-modules/crash-analyzer/mypy.ini | 6 - .../crash-analyzer/pyproject.toml | 58 -- fuzzforge-modules/crash-analyzer/ruff.toml | 19 - .../crash-analyzer/src/module/__init__.py | 0 .../crash-analyzer/src/module/__main__.py | 19 - .../crash-analyzer/src/module/mod.py | 340 -------- .../crash-analyzer/src/module/models.py | 79 -- .../crash-analyzer/src/module/settings.py | 16 - .../crash-analyzer/tests/.gitkeep | 0 .../fuzzforge-module-template/Dockerfile | 12 - .../fuzzforge-module-template/Makefile | 45 - .../fuzzforge-module-template/README.md | 46 - .../fuzzforge-module-template/mypy.ini | 6 - .../fuzzforge-module-template/pyproject.toml | 59 -- .../fuzzforge-module-template/ruff.toml | 19 - .../src/module/__init__.py | 0 .../src/module/__main__.py | 19 - .../src/module/mod.py | 54 -- .../src/module/models.py | 11 - .../src/module/settings.py | 7 - .../fuzzforge-module-template/tests/.gitkeep | 0 .../fuzzforge-modules-sdk/Dockerfile | 30 - .../fuzzforge-modules-sdk/Makefile | 39 - .../fuzzforge-modules-sdk/README.md | 67 -- .../fuzzforge-modules-sdk/mypy.ini | 7 - .../fuzzforge-modules-sdk/pyproject.toml | 32 - .../fuzzforge-modules-sdk/ruff.toml | 19 - .../src/fuzzforge_modules_sdk/__init__.py | 0 .../fuzzforge_modules_sdk/_cli/__init__.py | 0 .../_cli/build_base_image.py | 66 -- .../_cli/create_new_module.py | 30 - .../src/fuzzforge_modules_sdk/_cli/main.py | 71 -- .../src/fuzzforge_modules_sdk/api/__init__.py | 0 .../fuzzforge_modules_sdk/api/constants.py | 9 - .../fuzzforge_modules_sdk/api/exceptions.py | 2 - .../src/fuzzforge_modules_sdk/api/logs.py | 43 - .../api/models/__init__.py | 90 -- .../fuzzforge_modules_sdk/api/models/mod.py | 53 -- .../api/modules/__init__.py | 0 .../fuzzforge_modules_sdk/api/modules/base.py | 335 -------- .../fuzzforge_modules_sdk/assets/Dockerfile | 20 - .../assets/pyproject.toml | 1 - .../src/fuzzforge_modules_sdk/py.typed | 0 .../fuzzforge-module-template/Dockerfile | 12 - .../fuzzforge-module-template/Makefile | 45 - .../fuzzforge-module-template/README.md | 46 - .../fuzzforge-module-template/mypy.ini | 6 - .../fuzzforge-module-template/pyproject.toml | 62 -- .../fuzzforge-module-template/ruff.toml | 19 - .../src/module/__init__.py | 0 .../src/module/__main__.py | 19 - .../src/module/mod.py | 54 -- .../src/module/models.py | 11 - .../src/module/settings.py | 7 - .../fuzzforge-module-template/tests/.gitkeep | 0 .../fuzzforge-modules-sdk/tests/.gitkeep | 0 fuzzforge-modules/harness-tester/Dockerfile | 26 - .../harness-tester/FEEDBACK_TYPES.md | 289 ------- fuzzforge-modules/harness-tester/Makefile | 28 - fuzzforge-modules/harness-tester/README.md | 155 ---- fuzzforge-modules/harness-tester/mypy.ini | 6 - .../harness-tester/pyproject.toml | 60 -- fuzzforge-modules/harness-tester/ruff.toml | 19 - .../harness-tester/src/module/__init__.py | 730 ---------------- .../harness-tester/src/module/__main__.py | 16 - .../harness-tester/src/module/analyzer.py | 486 ----------- .../harness-tester/src/module/feedback.py | 148 ---- .../harness-tester/src/module/models.py | 27 - .../harness-tester/src/module/settings.py | 19 - fuzzforge-modules/rust-analyzer/Dockerfile | 27 - fuzzforge-modules/rust-analyzer/Makefile | 45 - fuzzforge-modules/rust-analyzer/README.md | 46 - fuzzforge-modules/rust-analyzer/mypy.ini | 6 - .../rust-analyzer/pyproject.toml | 52 -- fuzzforge-modules/rust-analyzer/ruff.toml | 19 - .../rust-analyzer/src/module/__init__.py | 0 .../rust-analyzer/src/module/__main__.py | 19 - .../rust-analyzer/src/module/mod.py | 314 ------- .../rust-analyzer/src/module/models.py | 100 --- .../rust-analyzer/src/module/settings.py | 16 - .../rust-analyzer/tests/.gitkeep | 0 fuzzforge-runner/Makefile | 14 - fuzzforge-runner/README.md | 44 - fuzzforge-runner/mypy.ini | 2 - fuzzforge-runner/pyproject.toml | 26 - fuzzforge-runner/pytest.ini | 3 - fuzzforge-runner/ruff.toml | 1 - .../src/fuzzforge_runner/__init__.py | 9 - .../src/fuzzforge_runner/__main__.py | 28 - .../src/fuzzforge_runner/constants.py | 21 - .../src/fuzzforge_runner/exceptions.py | 27 - .../src/fuzzforge_runner/executor.py | 806 ------------------ .../src/fuzzforge_runner/orchestrator.py | 361 -------- .../src/fuzzforge_runner/runner.py | 452 ---------- .../src/fuzzforge_runner/storage.py | 363 -------- fuzzforge-runner/tests/__init__.py | 1 - fuzzforge-runner/tests/conftest.py | 3 - .../src/fuzzforge_tests/fixtures.py | 100 +-- pyproject.toml | 4 - 141 files changed, 1182 insertions(+), 8992 deletions(-) delete mode 100644 fuzzforge-cli/src/fuzzforge_cli/commands/modules.py delete mode 100644 fuzzforge-mcp/src/fuzzforge_mcp/resources/modules.py delete mode 100644 fuzzforge-mcp/src/fuzzforge_mcp/resources/workflows.py rename {fuzzforge-runner/src/fuzzforge_runner => fuzzforge-mcp/src/fuzzforge_mcp}/settings.py (55%) create mode 100644 fuzzforge-mcp/src/fuzzforge_mcp/storage.py delete mode 100644 fuzzforge-mcp/src/fuzzforge_mcp/tools/modules.py delete mode 100644 fuzzforge-mcp/src/fuzzforge_mcp/tools/workflows.py delete mode 100644 fuzzforge-modules/cargo-fuzzer/Dockerfile delete mode 100644 fuzzforge-modules/cargo-fuzzer/Makefile delete mode 100644 fuzzforge-modules/cargo-fuzzer/README.md delete mode 100644 fuzzforge-modules/cargo-fuzzer/mypy.ini delete mode 100644 fuzzforge-modules/cargo-fuzzer/pyproject.toml delete mode 100644 fuzzforge-modules/cargo-fuzzer/ruff.toml delete mode 100644 fuzzforge-modules/cargo-fuzzer/src/module/__init__.py delete mode 100644 fuzzforge-modules/cargo-fuzzer/src/module/__main__.py delete mode 100644 fuzzforge-modules/cargo-fuzzer/src/module/mod.py delete mode 100644 fuzzforge-modules/cargo-fuzzer/src/module/models.py delete mode 100644 fuzzforge-modules/cargo-fuzzer/src/module/settings.py delete mode 100644 fuzzforge-modules/cargo-fuzzer/tests/.gitkeep delete mode 100644 fuzzforge-modules/crash-analyzer/Dockerfile delete mode 100644 fuzzforge-modules/crash-analyzer/Makefile delete mode 100644 fuzzforge-modules/crash-analyzer/README.md delete mode 100644 fuzzforge-modules/crash-analyzer/mypy.ini delete mode 100644 fuzzforge-modules/crash-analyzer/pyproject.toml delete mode 100644 fuzzforge-modules/crash-analyzer/ruff.toml delete mode 100644 fuzzforge-modules/crash-analyzer/src/module/__init__.py delete mode 100644 fuzzforge-modules/crash-analyzer/src/module/__main__.py delete mode 100644 fuzzforge-modules/crash-analyzer/src/module/mod.py delete mode 100644 fuzzforge-modules/crash-analyzer/src/module/models.py delete mode 100644 fuzzforge-modules/crash-analyzer/src/module/settings.py delete mode 100644 fuzzforge-modules/crash-analyzer/tests/.gitkeep delete mode 100644 fuzzforge-modules/fuzzforge-module-template/Dockerfile delete mode 100644 fuzzforge-modules/fuzzforge-module-template/Makefile delete mode 100644 fuzzforge-modules/fuzzforge-module-template/README.md delete mode 100644 fuzzforge-modules/fuzzforge-module-template/mypy.ini delete mode 100644 fuzzforge-modules/fuzzforge-module-template/pyproject.toml delete mode 100644 fuzzforge-modules/fuzzforge-module-template/ruff.toml delete mode 100644 fuzzforge-modules/fuzzforge-module-template/src/module/__init__.py delete mode 100644 fuzzforge-modules/fuzzforge-module-template/src/module/__main__.py delete mode 100644 fuzzforge-modules/fuzzforge-module-template/src/module/mod.py delete mode 100644 fuzzforge-modules/fuzzforge-module-template/src/module/models.py delete mode 100644 fuzzforge-modules/fuzzforge-module-template/src/module/settings.py delete mode 100644 fuzzforge-modules/fuzzforge-module-template/tests/.gitkeep delete mode 100644 fuzzforge-modules/fuzzforge-modules-sdk/Dockerfile delete mode 100644 fuzzforge-modules/fuzzforge-modules-sdk/Makefile delete mode 100644 fuzzforge-modules/fuzzforge-modules-sdk/README.md delete mode 100644 fuzzforge-modules/fuzzforge-modules-sdk/mypy.ini delete mode 100644 fuzzforge-modules/fuzzforge-modules-sdk/pyproject.toml delete mode 100644 fuzzforge-modules/fuzzforge-modules-sdk/ruff.toml delete mode 100644 fuzzforge-modules/fuzzforge-modules-sdk/src/fuzzforge_modules_sdk/__init__.py delete mode 100644 fuzzforge-modules/fuzzforge-modules-sdk/src/fuzzforge_modules_sdk/_cli/__init__.py delete mode 100644 fuzzforge-modules/fuzzforge-modules-sdk/src/fuzzforge_modules_sdk/_cli/build_base_image.py delete mode 100644 fuzzforge-modules/fuzzforge-modules-sdk/src/fuzzforge_modules_sdk/_cli/create_new_module.py delete mode 100644 fuzzforge-modules/fuzzforge-modules-sdk/src/fuzzforge_modules_sdk/_cli/main.py delete mode 100644 fuzzforge-modules/fuzzforge-modules-sdk/src/fuzzforge_modules_sdk/api/__init__.py delete mode 100644 fuzzforge-modules/fuzzforge-modules-sdk/src/fuzzforge_modules_sdk/api/constants.py delete mode 100644 fuzzforge-modules/fuzzforge-modules-sdk/src/fuzzforge_modules_sdk/api/exceptions.py delete mode 100644 fuzzforge-modules/fuzzforge-modules-sdk/src/fuzzforge_modules_sdk/api/logs.py delete mode 100644 fuzzforge-modules/fuzzforge-modules-sdk/src/fuzzforge_modules_sdk/api/models/__init__.py delete mode 100644 fuzzforge-modules/fuzzforge-modules-sdk/src/fuzzforge_modules_sdk/api/models/mod.py delete mode 100644 fuzzforge-modules/fuzzforge-modules-sdk/src/fuzzforge_modules_sdk/api/modules/__init__.py delete mode 100644 fuzzforge-modules/fuzzforge-modules-sdk/src/fuzzforge_modules_sdk/api/modules/base.py delete mode 100644 fuzzforge-modules/fuzzforge-modules-sdk/src/fuzzforge_modules_sdk/assets/Dockerfile delete mode 120000 fuzzforge-modules/fuzzforge-modules-sdk/src/fuzzforge_modules_sdk/assets/pyproject.toml delete mode 100644 fuzzforge-modules/fuzzforge-modules-sdk/src/fuzzforge_modules_sdk/py.typed delete mode 100644 fuzzforge-modules/fuzzforge-modules-sdk/src/fuzzforge_modules_sdk/templates/fuzzforge-module-template/Dockerfile delete mode 100644 fuzzforge-modules/fuzzforge-modules-sdk/src/fuzzforge_modules_sdk/templates/fuzzforge-module-template/Makefile delete mode 100644 fuzzforge-modules/fuzzforge-modules-sdk/src/fuzzforge_modules_sdk/templates/fuzzforge-module-template/README.md delete mode 100644 fuzzforge-modules/fuzzforge-modules-sdk/src/fuzzforge_modules_sdk/templates/fuzzforge-module-template/mypy.ini delete mode 100644 fuzzforge-modules/fuzzforge-modules-sdk/src/fuzzforge_modules_sdk/templates/fuzzforge-module-template/pyproject.toml delete mode 100644 fuzzforge-modules/fuzzforge-modules-sdk/src/fuzzforge_modules_sdk/templates/fuzzforge-module-template/ruff.toml delete mode 100644 fuzzforge-modules/fuzzforge-modules-sdk/src/fuzzforge_modules_sdk/templates/fuzzforge-module-template/src/module/__init__.py delete mode 100644 fuzzforge-modules/fuzzforge-modules-sdk/src/fuzzforge_modules_sdk/templates/fuzzforge-module-template/src/module/__main__.py delete mode 100644 fuzzforge-modules/fuzzforge-modules-sdk/src/fuzzforge_modules_sdk/templates/fuzzforge-module-template/src/module/mod.py delete mode 100644 fuzzforge-modules/fuzzforge-modules-sdk/src/fuzzforge_modules_sdk/templates/fuzzforge-module-template/src/module/models.py delete mode 100644 fuzzforge-modules/fuzzforge-modules-sdk/src/fuzzforge_modules_sdk/templates/fuzzforge-module-template/src/module/settings.py delete mode 100644 fuzzforge-modules/fuzzforge-modules-sdk/src/fuzzforge_modules_sdk/templates/fuzzforge-module-template/tests/.gitkeep delete mode 100644 fuzzforge-modules/fuzzforge-modules-sdk/tests/.gitkeep delete mode 100644 fuzzforge-modules/harness-tester/Dockerfile delete mode 100644 fuzzforge-modules/harness-tester/FEEDBACK_TYPES.md delete mode 100644 fuzzforge-modules/harness-tester/Makefile delete mode 100644 fuzzforge-modules/harness-tester/README.md delete mode 100644 fuzzforge-modules/harness-tester/mypy.ini delete mode 100644 fuzzforge-modules/harness-tester/pyproject.toml delete mode 100644 fuzzforge-modules/harness-tester/ruff.toml delete mode 100644 fuzzforge-modules/harness-tester/src/module/__init__.py delete mode 100644 fuzzforge-modules/harness-tester/src/module/__main__.py delete mode 100644 fuzzforge-modules/harness-tester/src/module/analyzer.py delete mode 100644 fuzzforge-modules/harness-tester/src/module/feedback.py delete mode 100644 fuzzforge-modules/harness-tester/src/module/models.py delete mode 100644 fuzzforge-modules/harness-tester/src/module/settings.py delete mode 100644 fuzzforge-modules/rust-analyzer/Dockerfile delete mode 100644 fuzzforge-modules/rust-analyzer/Makefile delete mode 100644 fuzzforge-modules/rust-analyzer/README.md delete mode 100644 fuzzforge-modules/rust-analyzer/mypy.ini delete mode 100644 fuzzforge-modules/rust-analyzer/pyproject.toml delete mode 100644 fuzzforge-modules/rust-analyzer/ruff.toml delete mode 100644 fuzzforge-modules/rust-analyzer/src/module/__init__.py delete mode 100644 fuzzforge-modules/rust-analyzer/src/module/__main__.py delete mode 100644 fuzzforge-modules/rust-analyzer/src/module/mod.py delete mode 100644 fuzzforge-modules/rust-analyzer/src/module/models.py delete mode 100644 fuzzforge-modules/rust-analyzer/src/module/settings.py delete mode 100644 fuzzforge-modules/rust-analyzer/tests/.gitkeep delete mode 100644 fuzzforge-runner/Makefile delete mode 100644 fuzzforge-runner/README.md delete mode 100644 fuzzforge-runner/mypy.ini delete mode 100644 fuzzforge-runner/pyproject.toml delete mode 100644 fuzzforge-runner/pytest.ini delete mode 100644 fuzzforge-runner/ruff.toml delete mode 100644 fuzzforge-runner/src/fuzzforge_runner/__init__.py delete mode 100644 fuzzforge-runner/src/fuzzforge_runner/__main__.py delete mode 100644 fuzzforge-runner/src/fuzzforge_runner/constants.py delete mode 100644 fuzzforge-runner/src/fuzzforge_runner/exceptions.py delete mode 100644 fuzzforge-runner/src/fuzzforge_runner/executor.py delete mode 100644 fuzzforge-runner/src/fuzzforge_runner/orchestrator.py delete mode 100644 fuzzforge-runner/src/fuzzforge_runner/runner.py delete mode 100644 fuzzforge-runner/src/fuzzforge_runner/storage.py delete mode 100644 fuzzforge-runner/tests/__init__.py delete mode 100644 fuzzforge-runner/tests/conftest.py diff --git a/Makefile b/Makefile index 7605f7a..f04b240 100644 --- a/Makefile +++ b/Makefile @@ -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 @@ -12,7 +12,6 @@ help: @echo " make lint - Lint code with ruff" @echo " make typecheck - Type check with mypy" @echo " make test - Run all tests" - @echo " make build-modules - Build all module container images" @echo " make build-hub-images - Build all mcp-security-hub images" @echo " make clean - Clean build artifacts" @echo "" @@ -65,35 +64,6 @@ test: fi \ 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-hub-images: @bash scripts/build-hub-images.sh diff --git a/fuzzforge-cli/pyproject.toml b/fuzzforge-cli/pyproject.toml index d68ff43..0d5154a 100644 --- a/fuzzforge-cli/pyproject.toml +++ b/fuzzforge-cli/pyproject.toml @@ -6,7 +6,7 @@ authors = [] readme = "README.md" requires-python = ">=3.14" dependencies = [ - "fuzzforge-runner==0.0.1", + "fuzzforge-mcp==0.0.1", "rich>=14.0.0", "typer==0.20.1", ] @@ -25,4 +25,4 @@ tests = [ fuzzforge = "fuzzforge_cli.__main__:main" [tool.uv.sources] -fuzzforge-runner = { workspace = true } +fuzzforge-mcp = { workspace = true } diff --git a/fuzzforge-cli/src/fuzzforge_cli/application.py b/fuzzforge-cli/src/fuzzforge_cli/application.py index 114ef0a..372ef29 100644 --- a/fuzzforge-cli/src/fuzzforge_cli/application.py +++ b/fuzzforge-cli/src/fuzzforge_cli/application.py @@ -3,12 +3,12 @@ from pathlib import Path from typing import Annotated -from fuzzforge_runner import Runner, Settings from typer import Context as TyperContext from typer import Option, Typer -from fuzzforge_cli.commands import mcp, modules, projects +from fuzzforge_cli.commands import mcp, projects from fuzzforge_cli.context import Context +from fuzzforge_mcp.storage import LocalStorage application: Typer = Typer( name="fuzzforge", @@ -27,15 +27,6 @@ def main( help="Path to the FuzzForge project directory.", ), ] = Path.cwd(), - modules_path: Annotated[ - Path, - Option( - "--modules", - "-m", - envvar="FUZZFORGE_MODULES_PATH", - help="Path to the modules directory.", - ), - ] = Path.home() / ".fuzzforge" / "modules", storage_path: Annotated[ Path, Option( @@ -44,53 +35,20 @@ def main( help="Path to the storage directory.", ), ] = Path.home() / ".fuzzforge" / "storage", - engine_type: Annotated[ - str, - Option( - "--engine", - envvar="FUZZFORGE_ENGINE__TYPE", - help="Container engine type (docker or podman).", - ), - ] = "docker", - engine_socket: Annotated[ - str, - Option( - "--socket", - envvar="FUZZFORGE_ENGINE__SOCKET", - help="Container engine socket path.", - ), - ] = "", context: TyperContext = None, # type: ignore[assignment] ) -> None: """FuzzForge AI - Security research orchestration platform. - Execute security research modules in isolated containers. + Discover and execute MCP hub tools for security research. """ - from fuzzforge_runner.settings import EngineSettings, ProjectSettings, StorageSettings - - settings = Settings( - engine=EngineSettings( - type=engine_type, # type: ignore[arg-type] - socket=engine_socket, - ), - storage=StorageSettings( - path=storage_path, - ), - project=ProjectSettings( - default_path=project_path, - modules_path=modules_path, - ), - ) - - runner = Runner(settings) + storage = LocalStorage(storage_path=storage_path) context.obj = Context( - runner=runner, + storage=storage, project_path=project_path, ) application.add_typer(mcp.application) -application.add_typer(modules.application) application.add_typer(projects.application) diff --git a/fuzzforge-cli/src/fuzzforge_cli/commands/mcp.py b/fuzzforge-cli/src/fuzzforge_cli/commands/mcp.py index 92b2fbb..0b0ebe4 100644 --- a/fuzzforge-cli/src/fuzzforge_cli/commands/mcp.py +++ b/fuzzforge-cli/src/fuzzforge_cli/commands/mcp.py @@ -137,7 +137,7 @@ def _find_fuzzforge_root() -> Path: # Walk up to find fuzzforge-oss root for parent in current.parents: - if (parent / "fuzzforge-mcp").is_dir() and (parent / "fuzzforge-runner").is_dir(): + if (parent / "fuzzforge-mcp").is_dir(): return parent # Fall back to cwd @@ -146,14 +146,12 @@ def _find_fuzzforge_root() -> Path: def _generate_mcp_config( fuzzforge_root: Path, - modules_path: Path, engine_type: str, engine_socket: str, ) -> dict: """Generate MCP server configuration. :param fuzzforge_root: Path to fuzzforge-oss installation. - :param modules_path: Path to the modules directory. :param engine_type: Container engine type (podman or docker). :param engine_socket: Container engine socket path. :returns: MCP configuration dictionary. @@ -181,7 +179,6 @@ def _generate_mcp_config( "args": args, "cwd": str(fuzzforge_root), "env": { - "FUZZFORGE_MODULES_PATH": str(modules_path), "FUZZFORGE_ENGINE__TYPE": engine_type, "FUZZFORGE_ENGINE__GRAPHROOT": str(graphroot), "FUZZFORGE_ENGINE__RUNROOT": str(runroot), @@ -266,14 +263,6 @@ def generate( help="AI agent to generate config for (copilot, claude-desktop, or claude-code).", ), ], - modules_path: Annotated[ - Path | None, - Option( - "--modules", - "-m", - help="Path to the modules directory.", - ), - ] = None, engine: Annotated[ str, Option( @@ -287,16 +276,12 @@ def generate( :param context: Typer context. :param agent: Target AI agent. - :param modules_path: Override modules path. :param engine: Container engine type. """ console = Console() fuzzforge_root = _find_fuzzforge_root() - # Use defaults if not specified - resolved_modules = modules_path or (fuzzforge_root / "fuzzforge-modules") - # Detect socket if engine == "podman": socket = _detect_podman_socket() @@ -306,7 +291,6 @@ def generate( # Generate config server_config = _generate_mcp_config( fuzzforge_root=fuzzforge_root, - modules_path=resolved_modules, engine_type=engine, engine_socket=socket, ) @@ -350,14 +334,6 @@ def install( help="AI agent to install config for (copilot, claude-desktop, or claude-code).", ), ], - modules_path: Annotated[ - Path | None, - Option( - "--modules", - "-m", - help="Path to the modules directory.", - ), - ] = None, engine: Annotated[ str, Option( @@ -382,7 +358,6 @@ def install( :param context: Typer context. :param agent: Target AI agent. - :param modules_path: Override modules path. :param engine: Container engine type. :param force: Overwrite existing configuration. @@ -401,9 +376,6 @@ def install( config_path = _get_claude_desktop_mcp_path() servers_key = "mcpServers" - # Use defaults if not specified - resolved_modules = modules_path or (fuzzforge_root / "fuzzforge-modules") - # Detect socket if engine == "podman": socket = _detect_podman_socket() @@ -413,7 +385,6 @@ def install( # Generate server config server_config = _generate_mcp_config( fuzzforge_root=fuzzforge_root, - modules_path=resolved_modules, engine_type=engine, engine_socket=socket, ) @@ -453,7 +424,6 @@ def install( console.print(f"[bold]Configuration file:[/bold] {config_path}") console.print() console.print("[bold]Settings:[/bold]") - console.print(f" Modules Path: {resolved_modules}") console.print(f" Engine: {engine}") console.print(f" Socket: {socket}") console.print(f" Hub Config: {fuzzforge_root / 'hub-config.json'}") diff --git a/fuzzforge-cli/src/fuzzforge_cli/commands/modules.py b/fuzzforge-cli/src/fuzzforge_cli/commands/modules.py deleted file mode 100644 index 0e43d3b..0000000 --- a/fuzzforge-cli/src/fuzzforge_cli/commands/modules.py +++ /dev/null @@ -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) diff --git a/fuzzforge-cli/src/fuzzforge_cli/commands/projects.py b/fuzzforge-cli/src/fuzzforge_cli/commands/projects.py index 8be9f58..a21c666 100644 --- a/fuzzforge-cli/src/fuzzforge_cli/commands/projects.py +++ b/fuzzforge-cli/src/fuzzforge_cli/commands/projects.py @@ -7,7 +7,7 @@ from rich.console import Console from rich.table import Table from typer import Argument, Context, Option, Typer -from fuzzforge_cli.context import get_project_path, get_runner +from fuzzforge_cli.context import get_project_path, get_storage application: Typer = Typer( name="project", @@ -36,10 +36,10 @@ def init_project( :param path: Path to initialize (defaults to current directory). """ - runner = get_runner(context) + storage = get_storage(context) project_path = path or get_project_path(context) - storage_path = runner.init_project(project_path) + storage_path = storage.init_project(project_path) console = Console() console.print(f"[green]✓[/green] Project initialized at {project_path}") @@ -65,10 +65,10 @@ def set_assets( :param assets_path: Path to assets. """ - runner = get_runner(context) + storage = get_storage(context) project_path = get_project_path(context) - stored_path = runner.set_project_assets(project_path, assets_path) + stored_path = storage.set_project_assets(project_path, assets_path) console = Console() console.print(f"[green]✓[/green] Assets stored from {assets_path}") @@ -87,11 +87,11 @@ def show_info( :param context: Typer context. """ - runner = get_runner(context) + storage = get_storage(context) project_path = get_project_path(context) - executions = runner.list_executions(project_path) - assets_path = runner.storage.get_project_assets_path(project_path) + executions = storage.list_executions(project_path) + assets_path = storage.get_project_assets_path(project_path) console = Console() table = Table(title=f"Project: {project_path.name}") @@ -118,10 +118,10 @@ def list_executions( :param context: Typer context. """ - runner = get_runner(context) + storage = get_storage(context) project_path = get_project_path(context) - executions = runner.list_executions(project_path) + executions = storage.list_executions(project_path) console = Console() @@ -134,7 +134,7 @@ def list_executions( table.add_column("Has Results") for exec_id in executions: - has_results = runner.get_execution_results(project_path, exec_id) is not None + has_results = storage.get_execution_results(project_path, exec_id) is not None table.add_row(exec_id, "✓" if has_results else "-") console.print(table) @@ -168,10 +168,10 @@ def get_results( :param extract_to: Optional directory to extract to. """ - runner = get_runner(context) + storage = get_storage(context) project_path = get_project_path(context) - results_path = runner.get_execution_results(project_path, execution_id) + results_path = storage.get_execution_results(project_path, execution_id) console = Console() @@ -182,5 +182,5 @@ def get_results( console.print(f"[green]✓[/green] Results: {results_path}") if extract_to: - extracted = runner.extract_results(results_path, extract_to) + extracted = storage.extract_results(results_path, extract_to) console.print(f" Extracted to: {extracted}") diff --git a/fuzzforge-cli/src/fuzzforge_cli/context.py b/fuzzforge-cli/src/fuzzforge_cli/context.py index c53a061..d46ab1f 100644 --- a/fuzzforge-cli/src/fuzzforge_cli/context.py +++ b/fuzzforge-cli/src/fuzzforge_cli/context.py @@ -5,35 +5,35 @@ from __future__ import annotations from pathlib import Path from typing import TYPE_CHECKING, cast -from fuzzforge_runner import Runner, Settings +from fuzzforge_mcp.storage import LocalStorage if TYPE_CHECKING: from typer import Context as TyperContext class Context: - """CLI context holding the runner instance and settings.""" + """CLI context holding the storage instance and settings.""" - _runner: Runner + _storage: LocalStorage _project_path: Path - def __init__(self, runner: Runner, project_path: Path) -> None: + def __init__(self, storage: LocalStorage, project_path: Path) -> None: """Initialize an instance of the class. - :param runner: FuzzForge runner instance. + :param storage: FuzzForge local storage instance. :param project_path: Path to the current project. """ - self._runner = runner + self._storage = storage self._project_path = project_path - def get_runner(self) -> Runner: - """Get the runner instance. + def get_storage(self) -> LocalStorage: + """Get the storage instance. - :return: Runner instance. + :return: LocalStorage instance. """ - return self._runner + return self._storage def get_project_path(self) -> Path: """Get the current project path. @@ -44,14 +44,14 @@ class Context: return self._project_path -def get_runner(context: TyperContext) -> Runner: - """Get runner from Typer context. +def get_storage(context: TyperContext) -> LocalStorage: + """Get storage from Typer context. :param context: Typer context. - :return: Runner instance. + :return: LocalStorage instance. """ - return cast("Context", context.obj).get_runner() + return cast("Context", context.obj).get_storage() def get_project_path(context: TyperContext) -> Path: diff --git a/fuzzforge-common/src/fuzzforge_common/hub/__init__.py b/fuzzforge-common/src/fuzzforge_common/hub/__init__.py index ebe22f0..d719f3f 100644 --- a/fuzzforge-common/src/fuzzforge_common/hub/__init__.py +++ b/fuzzforge-common/src/fuzzforge_common/hub/__init__.py @@ -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.models import ( HubConfig, @@ -39,4 +39,5 @@ __all__ = [ "HubServerType", "HubTool", "HubToolParameter", + "PersistentSession", ] diff --git a/fuzzforge-common/src/fuzzforge_common/hub/client.py b/fuzzforge-common/src/fuzzforge_common/hub/client.py index 7a6f7fd..90a753b 100644 --- a/fuzzforge-common/src/fuzzforge_common/hub/client.py +++ b/fuzzforge-common/src/fuzzforge_common/hub/client.py @@ -6,6 +6,7 @@ via stdio (docker/command) or SSE transport. It handles: - Connecting to SSE endpoints - Discovering tools via list_tools() - Executing tools via call_tool() +- Persistent container sessions for stateful interactions """ @@ -16,6 +17,8 @@ import json import os import subprocess from contextlib import asynccontextmanager +from dataclasses import dataclass, field +from datetime import datetime, timezone from typing import TYPE_CHECKING, Any, cast from fuzzforge_common.hub.models import ( @@ -47,6 +50,48 @@ class HubClientError(Exception): """Error in hub client operations.""" +@dataclass +class PersistentSession: + """A persistent container session with an active MCP connection. + + Keeps a Docker container running between tool calls to allow + stateful interactions (e.g., radare2 analysis, long-running fuzzing). + + """ + + #: Server name this session belongs to. + server_name: str + + #: Docker container name. + container_name: str + + #: Underlying process (docker run). + process: Process + + #: Stream reader (process stdout). + reader: asyncio.StreamReader + + #: Stream writer (process stdin). + writer: asyncio.StreamWriter + + #: Whether the MCP session has been initialized. + initialized: bool = False + + #: Lock to serialise concurrent requests on the same session. + lock: asyncio.Lock = field(default_factory=asyncio.Lock) + + #: When the session was started. + started_at: datetime = field(default_factory=lambda: datetime.now(tz=timezone.utc)) + + #: Monotonic counter for JSON-RPC request IDs. + request_id: int = 0 + + @property + def alive(self) -> bool: + """Check if the underlying process is still running.""" + return self.process.returncode is None + + class HubClient: """Client for communicating with MCP hub servers. @@ -65,6 +110,8 @@ class HubClient: """ self._timeout = timeout + self._persistent_sessions: dict[str, PersistentSession] = {} + self._request_id: int = 0 async def discover_tools(self, server: HubServer) -> list[HubTool]: """Discover tools from a hub server. @@ -84,8 +131,9 @@ class HubClient: try: async with self._connect(config) as (reader, writer): - # Initialize MCP session - await self._initialize_session(reader, writer, config.name) + # Initialise MCP session (skip for persistent — already done) + if not self._persistent_sessions.get(config.name): + await self._initialize_session(reader, writer, config.name) # List tools tools_data = await self._call_method( @@ -141,7 +189,7 @@ class HubClient: """ logger = get_logger() config = server.config - exec_timeout = timeout or self._timeout + exec_timeout = timeout or config.timeout or self._timeout logger.info( "Executing hub tool", @@ -152,8 +200,9 @@ class HubClient: try: async with self._connect(config) as (reader, writer): - # Initialize MCP session - await self._initialize_session(reader, writer, config.name) + # Initialise MCP session (skip for persistent — already done) + if not self._persistent_sessions.get(config.name): + await self._initialize_session(reader, writer, config.name) # Call tool result = await asyncio.wait_for( @@ -202,10 +251,22 @@ class HubClient: ) -> AsyncGenerator[tuple[asyncio.StreamReader, asyncio.StreamWriter], None]: """Connect to an MCP server. + If a persistent session exists for this server, reuse it (with a lock + to serialise concurrent requests). Otherwise, fall through to the + ephemeral per-call connection logic. + :param config: Server configuration. :yields: Tuple of (reader, writer) for communication. """ + # Check for active persistent session + session = self._persistent_sessions.get(config.name) + if session and session.initialized and session.alive: + async with session.lock: + yield session.reader, session.writer # type: ignore[misc] + return + + # Ephemeral connection (original behaviour) if config.type == HubServerType.DOCKER: async with self._connect_docker(config) as streams: yield streams @@ -251,11 +312,15 @@ class HubClient: cmd.append(config.image) + # Use 4 MB buffer to handle large tool responses (YARA rulesets, trivy output, etc.) + _STREAM_LIMIT = 4 * 1024 * 1024 + process: Process = await asyncio.create_subprocess_exec( *cmd, stdin=subprocess.PIPE, stdout=subprocess.PIPE, stderr=subprocess.PIPE, + limit=_STREAM_LIMIT, ) try: @@ -294,12 +359,16 @@ class HubClient: # Set up environment env = dict(config.environment) if config.environment else None + # Use 4 MB buffer to handle large tool responses + _STREAM_LIMIT = 4 * 1024 * 1024 + process: Process = await asyncio.create_subprocess_exec( *config.command, stdin=subprocess.PIPE, stdout=subprocess.PIPE, stderr=subprocess.PIPE, env=env, + limit=_STREAM_LIMIT, ) try: @@ -385,10 +454,11 @@ class HubClient: :returns: Method result. """ - # Create JSON-RPC request + # Create JSON-RPC request with unique ID + self._request_id += 1 request = { "jsonrpc": "2.0", - "id": 1, + "id": self._request_id, "method": method, "params": params, } @@ -415,7 +485,16 @@ class HubClient: msg = f"MCP error: {error.get('message', 'Unknown error')}" 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( self, @@ -442,3 +521,218 @@ class HubClient: notification_line = json.dumps(notification) + "\n" writer.write(notification_line.encode()) await writer.drain() + + # ------------------------------------------------------------------ + # Persistent session management + # ------------------------------------------------------------------ + + async def start_persistent_session( + self, + config: HubServerConfig, + ) -> PersistentSession: + """Start a persistent Docker container and initialise MCP session. + + The container stays running until :meth:`stop_persistent_session` is + called, allowing multiple tool calls on the same session. + + :param config: Server configuration (must be Docker type). + :returns: The created persistent session. + :raises HubClientError: If the container cannot be started. + + """ + logger = get_logger() + + if config.name in self._persistent_sessions: + session = self._persistent_sessions[config.name] + if session.alive: + logger.info("Persistent session already running", server=config.name) + return session + # Dead session — clean up and restart + await self._cleanup_session(config.name) + + if config.type != HubServerType.DOCKER: + msg = f"Persistent mode only supports Docker servers (got {config.type.value})" + raise HubClientError(msg) + + if not config.image: + msg = f"Docker image not specified for server '{config.name}'" + raise HubClientError(msg) + + container_name = f"fuzzforge-{config.name}" + + # Remove stale container with same name if it exists + try: + rm_proc = await asyncio.create_subprocess_exec( + "docker", "rm", "-f", container_name, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + ) + await rm_proc.wait() + except Exception: + pass + + # Build docker run command (no --rm, with --name) + cmd = ["docker", "run", "-i", "--name", container_name] + + for cap in config.capabilities: + cmd.extend(["--cap-add", cap]) + + for volume in config.volumes: + cmd.extend(["-v", os.path.expanduser(volume)]) + + for key, value in config.environment.items(): + cmd.extend(["-e", f"{key}={value}"]) + + cmd.append(config.image) + + _STREAM_LIMIT = 4 * 1024 * 1024 + + logger.info( + "Starting persistent container", + server=config.name, + container=container_name, + image=config.image, + ) + + process: Process = await asyncio.create_subprocess_exec( + *cmd, + stdin=subprocess.PIPE, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + limit=_STREAM_LIMIT, + ) + + if process.stdin is None or process.stdout is None: + process.terminate() + msg = "Failed to get process streams" + raise HubClientError(msg) + + session = PersistentSession( + server_name=config.name, + container_name=container_name, + process=process, + reader=process.stdout, + writer=process.stdin, + ) + + # Initialise MCP session + try: + await self._initialize_session( + session.reader, # type: ignore[arg-type] + session.writer, # type: ignore[arg-type] + config.name, + ) + session.initialized = True + except Exception as e: + process.terminate() + try: + await asyncio.wait_for(process.wait(), timeout=5) + except asyncio.TimeoutError: + process.kill() + msg = f"Failed to initialise MCP session for {config.name}: {e}" + raise HubClientError(msg) from e + + self._persistent_sessions[config.name] = session + + logger.info( + "Persistent session started", + server=config.name, + container=container_name, + ) + return session + + async def stop_persistent_session(self, server_name: str) -> bool: + """Stop a persistent container session. + + :param server_name: Name of the server whose session to stop. + :returns: True if a session was stopped, False if none found. + + """ + return await self._cleanup_session(server_name) + + def get_persistent_session(self, server_name: str) -> PersistentSession | None: + """Get a persistent session by server name. + + :param server_name: Server name. + :returns: The session if running, None otherwise. + + """ + session = self._persistent_sessions.get(server_name) + if session and not session.alive: + # Mark dead session — don't remove here to avoid async issues + return None + return session + + def list_persistent_sessions(self) -> list[dict[str, Any]]: + """List all persistent sessions with their status. + + :returns: List of session info dictionaries. + + """ + sessions = [] + for name, session in self._persistent_sessions.items(): + sessions.append({ + "server_name": name, + "container_name": session.container_name, + "alive": session.alive, + "initialized": session.initialized, + "started_at": session.started_at.isoformat(), + "uptime_seconds": int( + (datetime.now(tz=timezone.utc) - session.started_at).total_seconds() + ), + }) + return sessions + + async def stop_all_persistent_sessions(self) -> int: + """Stop all persistent sessions. + + :returns: Number of sessions stopped. + + """ + names = list(self._persistent_sessions.keys()) + count = 0 + for name in names: + if await self._cleanup_session(name): + count += 1 + return count + + async def _cleanup_session(self, server_name: str) -> bool: + """Clean up a persistent session (terminate process, remove container). + + :param server_name: Server name. + :returns: True if cleaned up, False if not found. + + """ + logger = get_logger() + session = self._persistent_sessions.pop(server_name, None) + if session is None: + return False + + logger.info("Stopping persistent session", server=server_name) + + # Terminate process + if session.alive: + session.process.terminate() + try: + await asyncio.wait_for(session.process.wait(), timeout=10) + except asyncio.TimeoutError: + session.process.kill() + await session.process.wait() + + # Remove Docker container + try: + rm_proc = await asyncio.create_subprocess_exec( + "docker", "rm", "-f", session.container_name, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + ) + await rm_proc.wait() + except Exception: + pass + + logger.info( + "Persistent session stopped", + server=server_name, + container=session.container_name, + ) + return True diff --git a/fuzzforge-common/src/fuzzforge_common/hub/executor.py b/fuzzforge-common/src/fuzzforge_common/hub/executor.py index 22c6c6b..7fef168 100644 --- a/fuzzforge-common/src/fuzzforge_common/hub/executor.py +++ b/fuzzforge-common/src/fuzzforge_common/hub/executor.py @@ -12,7 +12,7 @@ from __future__ import annotations from pathlib import Path 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.registry import HubRegistry @@ -106,6 +106,7 @@ class HubExecutor: """ self._registry = HubRegistry(config_path) self._client = HubClient(timeout=timeout) + self._continuous_sessions: dict[str, dict[str, Any]] = {} @property def registry(self) -> HubRegistry: @@ -291,6 +292,7 @@ class HubExecutor: """ servers = [] for server in self._registry.servers: + session = self._client.get_persistent_session(server.name) servers.append({ "name": server.name, "identifier": server.identifier, @@ -298,6 +300,8 @@ class HubExecutor: "enabled": server.config.enabled, "category": server.config.category, "description": server.config.description, + "persistent": server.config.persistent, + "persistent_session_active": session is not None and session.alive, "discovered": server.discovered, "tool_count": len(server.tools), "error": server.discovery_error, @@ -332,3 +336,287 @@ class HubExecutor: if tool: return tool.input_schema return None + + # ------------------------------------------------------------------ + # Persistent session management + # ------------------------------------------------------------------ + + async def start_persistent_server(self, server_name: str) -> dict[str, Any]: + """Start a persistent container session for a server. + + The container stays running between tool calls, allowing stateful + interactions (e.g., radare2 sessions, long-running fuzzing). + + :param server_name: Name of the hub server to start. + :returns: Session status dictionary. + :raises ValueError: If server not found. + + """ + logger = get_logger() + server = self._registry.get_server(server_name) + if not server: + msg = f"Server '{server_name}' not found" + raise ValueError(msg) + + session = await self._client.start_persistent_session(server.config) + + # Auto-discover tools on the new session + try: + tools = await self._client.discover_tools(server) + self._registry.update_server_tools(server_name, tools) + except HubClientError as e: + logger.warning( + "Tool discovery failed on persistent session", + server=server_name, + error=str(e), + ) + + # Include discovered tools in the result so agent knows what's available + discovered_tools = [] + server_obj = self._registry.get_server(server_name) + if server_obj: + for tool in server_obj.tools: + discovered_tools.append({ + "identifier": tool.identifier, + "name": tool.name, + "description": tool.description, + }) + + return { + "server_name": session.server_name, + "container_name": session.container_name, + "alive": session.alive, + "initialized": session.initialized, + "started_at": session.started_at.isoformat(), + "tools": discovered_tools, + "tool_count": len(discovered_tools), + } + + async def stop_persistent_server(self, server_name: str) -> bool: + """Stop a persistent container session. + + :param server_name: Server name. + :returns: True if a session was stopped. + + """ + return await self._client.stop_persistent_session(server_name) + + def get_persistent_status(self, server_name: str) -> dict[str, Any] | None: + """Get status of a persistent session. + + :param server_name: Server name. + :returns: Status dict or None if no session. + + """ + session = self._client.get_persistent_session(server_name) + if not session: + return None + + from datetime import datetime, timezone # noqa: PLC0415 + + return { + "server_name": session.server_name, + "container_name": session.container_name, + "alive": session.alive, + "initialized": session.initialized, + "started_at": session.started_at.isoformat(), + "uptime_seconds": int( + (datetime.now(tz=timezone.utc) - session.started_at).total_seconds() + ), + } + + def list_persistent_sessions(self) -> list[dict[str, Any]]: + """List all persistent sessions. + + :returns: List of session status dicts. + + """ + return self._client.list_persistent_sessions() + + async def stop_all_persistent_servers(self) -> int: + """Stop all persistent sessions. + + :returns: Number of sessions stopped. + + """ + return await self._client.stop_all_persistent_sessions() + + # ------------------------------------------------------------------ + # Continuous session management + # ------------------------------------------------------------------ + + async def start_continuous_tool( + self, + server_name: str, + start_tool: str, + arguments: dict[str, Any], + ) -> dict[str, Any]: + """Start a continuous hub tool session. + + Ensures a persistent container is running, then calls the start tool + (e.g., ``cargo_fuzz_start``) which returns a session_id. Tracks the + session for subsequent status/stop calls. + + :param server_name: Hub server name. + :param start_tool: Name of the start tool on the server. + :param arguments: Arguments for the start tool. + :returns: Start result including session_id. + :raises ValueError: If server not found. + + """ + logger = get_logger() + + server = self._registry.get_server(server_name) + if not server: + msg = f"Server '{server_name}' not found" + raise ValueError(msg) + + # Ensure persistent session is running + persistent = self._client.get_persistent_session(server_name) + if not persistent or not persistent.alive: + logger.info( + "Auto-starting persistent session for continuous tool", + server=server_name, + ) + await self._client.start_persistent_session(server.config) + # Discover tools on the new session + try: + tools = await self._client.discover_tools(server) + self._registry.update_server_tools(server_name, tools) + except HubClientError as e: + logger.warning( + "Tool discovery failed on persistent session", + server=server_name, + error=str(e), + ) + + # Call the start tool + result = await self._client.execute_tool( + server, start_tool, arguments, + ) + + # Extract session_id from result + content_text = "" + for item in result.get("content", []): + if item.get("type") == "text": + content_text = item.get("text", "") + break + + import json # noqa: PLC0415 + + try: + start_result = json.loads(content_text) if content_text else result + except json.JSONDecodeError: + start_result = result + + session_id = start_result.get("session_id", "") + + if session_id: + from datetime import datetime, timezone # noqa: PLC0415 + + self._continuous_sessions[session_id] = { + "session_id": session_id, + "server_name": server_name, + "start_tool": start_tool, + "status_tool": start_tool.replace("_start", "_status"), + "stop_tool": start_tool.replace("_start", "_stop"), + "started_at": datetime.now(tz=timezone.utc).isoformat(), + "status": "running", + } + + return start_result + + async def get_continuous_tool_status( + self, + session_id: str, + ) -> dict[str, Any]: + """Get status of a continuous hub tool session. + + :param session_id: Session ID from start_continuous_tool. + :returns: Status dict from the hub server's status tool. + :raises ValueError: If session not found. + + """ + session_info = self._continuous_sessions.get(session_id) + if not session_info: + msg = f"Unknown continuous session: {session_id}" + raise ValueError(msg) + + server = self._registry.get_server(session_info["server_name"]) + if not server: + msg = f"Server '{session_info['server_name']}' not found" + raise ValueError(msg) + + result = await self._client.execute_tool( + server, + session_info["status_tool"], + {"session_id": session_id}, + ) + + # Parse the text content + content_text = "" + for item in result.get("content", []): + if item.get("type") == "text": + content_text = item.get("text", "") + break + + import json # noqa: PLC0415 + + try: + return json.loads(content_text) if content_text else result + except json.JSONDecodeError: + return result + + async def stop_continuous_tool( + self, + session_id: str, + ) -> dict[str, Any]: + """Stop a continuous hub tool session. + + :param session_id: Session ID to stop. + :returns: Final results from the hub server's stop tool. + :raises ValueError: If session not found. + + """ + session_info = self._continuous_sessions.get(session_id) + if not session_info: + msg = f"Unknown continuous session: {session_id}" + raise ValueError(msg) + + server = self._registry.get_server(session_info["server_name"]) + if not server: + msg = f"Server '{session_info['server_name']}' not found" + raise ValueError(msg) + + result = await self._client.execute_tool( + server, + session_info["stop_tool"], + {"session_id": session_id}, + ) + + # Parse the text content + content_text = "" + for item in result.get("content", []): + if item.get("type") == "text": + content_text = item.get("text", "") + break + + import json # noqa: PLC0415 + + try: + stop_result = json.loads(content_text) if content_text else result + except json.JSONDecodeError: + stop_result = result + + # Update session tracking + session_info["status"] = "stopped" + + return stop_result + + def list_continuous_sessions(self) -> list[dict[str, Any]]: + """List all tracked continuous sessions. + + :returns: List of continuous session info dicts. + + """ + return list(self._continuous_sessions.values()) diff --git a/fuzzforge-common/src/fuzzforge_common/hub/models.py b/fuzzforge-common/src/fuzzforge_common/hub/models.py index 5edf9a8..f0df8bd 100644 --- a/fuzzforge-common/src/fuzzforge_common/hub/models.py +++ b/fuzzforge-common/src/fuzzforge_common/hub/models.py @@ -92,6 +92,18 @@ class HubServerConfig(BaseModel): description="Category for grouping servers", ) + #: Per-server timeout override in seconds (None = use default_timeout). + timeout: int | None = Field( + default=None, + description="Per-server execution timeout override in seconds", + ) + + #: Whether to use persistent container mode (keep container running between calls). + persistent: bool = Field( + default=False, + description="Keep container running between tool calls for stateful interactions", + ) + class HubToolParameter(BaseModel): """A parameter for an MCP tool. diff --git a/fuzzforge-mcp/pyproject.toml b/fuzzforge-mcp/pyproject.toml index d173c07..a0636f5 100644 --- a/fuzzforge-mcp/pyproject.toml +++ b/fuzzforge-mcp/pyproject.toml @@ -8,7 +8,6 @@ requires-python = ">=3.14" dependencies = [ "fastmcp==2.14.1", "fuzzforge-common==0.0.1", - "fuzzforge-runner==0.0.1", "pydantic==2.12.4", "pydantic-settings==2.12.0", "structlog==25.5.0", @@ -32,5 +31,4 @@ tests = [ [tool.uv.sources] fuzzforge-common = { workspace = true } -fuzzforge-runner = { workspace = true } fuzzforge-tests = { workspace = true } diff --git a/fuzzforge-mcp/src/fuzzforge_mcp/application.py b/fuzzforge-mcp/src/fuzzforge_mcp/application.py index ddd41da..57dcf0c 100644 --- a/fuzzforge-mcp/src/fuzzforge_mcp/application.py +++ b/fuzzforge-mcp/src/fuzzforge_mcp/application.py @@ -1,7 +1,8 @@ """FuzzForge MCP Server Application. This is the main entry point for the FuzzForge MCP server, providing -AI agents with tools to execute security research modules. +AI agents with tools to discover and execute MCP hub tools for +security research. """ @@ -12,7 +13,7 @@ from fastmcp import FastMCP from fastmcp.server.middleware.error_handling import ErrorHandlingMiddleware from fuzzforge_mcp import resources, tools -from fuzzforge_runner import Settings +from fuzzforge_mcp.settings import Settings if TYPE_CHECKING: from collections.abc import AsyncGenerator @@ -38,19 +39,18 @@ mcp: FastMCP = FastMCP( instructions=""" FuzzForge is a security research orchestration platform. Use these tools to: -1. **List modules**: Discover available security research modules -2. **Execute modules**: Run modules in isolated containers -3. **Execute workflows**: Chain multiple modules together +1. **List hub servers**: Discover registered MCP tool servers +2. **Discover tools**: Find available tools from hub servers +3. **Execute hub tools**: Run security tools in isolated containers 4. **Manage projects**: Initialize and configure projects 5. **Get results**: Retrieve execution results -6. **Hub tools**: Discover and execute tools from external MCP servers Typical workflow: 1. Initialize a project with `init_project` 2. Set project assets with `set_project_assets` (optional, only needed once for the source directory) -3. List available modules with `list_modules` -4. Execute a module with `execute_module` — use `assets_path` param to pass different inputs per module -5. Read outputs from `results_path` returned by `execute_module` — check module's `output_artifacts` metadata for filenames +3. List available hub servers with `list_hub_servers` +4. Discover tools from servers with `discover_hub_tools` +5. Execute hub tools with `execute_hub_tool` Hub workflow: 1. List available hub servers with `list_hub_servers` diff --git a/fuzzforge-mcp/src/fuzzforge_mcp/dependencies.py b/fuzzforge-mcp/src/fuzzforge_mcp/dependencies.py index 5781427..2942855 100644 --- a/fuzzforge-mcp/src/fuzzforge_mcp/dependencies.py +++ b/fuzzforge-mcp/src/fuzzforge_mcp/dependencies.py @@ -6,9 +6,10 @@ from pathlib import Path from typing import TYPE_CHECKING, cast from fastmcp.server.dependencies import get_context -from fuzzforge_runner import Runner, Settings from fuzzforge_mcp.exceptions import FuzzForgeMCPError +from fuzzforge_mcp.settings import Settings +from fuzzforge_mcp.storage import LocalStorage if TYPE_CHECKING: from fastmcp import Context @@ -17,6 +18,9 @@ if TYPE_CHECKING: # Track the current active project path (set by init_project) _current_project_path: Path | None = None +# Singleton storage instance +_storage: LocalStorage | None = None + def set_current_project_path(project_path: Path) -> None: """Set the current project path. @@ -60,11 +64,14 @@ def get_project_path() -> Path: return Path.cwd() -def get_runner() -> Runner: - """Get a configured Runner instance. +def get_storage() -> LocalStorage: + """Get the storage backend instance. - :return: Runner instance configured from MCP settings. + :return: LocalStorage instance. """ - settings: Settings = get_settings() - return Runner(settings) + global _storage + if _storage is None: + settings = get_settings() + _storage = LocalStorage(settings.storage.path) + return _storage diff --git a/fuzzforge-mcp/src/fuzzforge_mcp/resources/__init__.py b/fuzzforge-mcp/src/fuzzforge_mcp/resources/__init__.py index f6a1ce0..ac66e72 100644 --- a/fuzzforge-mcp/src/fuzzforge_mcp/resources/__init__.py +++ b/fuzzforge-mcp/src/fuzzforge_mcp/resources/__init__.py @@ -2,14 +2,12 @@ from fastmcp import FastMCP -from fuzzforge_mcp.resources import executions, modules, project, workflows +from fuzzforge_mcp.resources import executions, project mcp: FastMCP = FastMCP() mcp.mount(executions.mcp) -mcp.mount(modules.mcp) mcp.mount(project.mcp) -mcp.mount(workflows.mcp) __all__ = [ "mcp", diff --git a/fuzzforge-mcp/src/fuzzforge_mcp/resources/executions.py b/fuzzforge-mcp/src/fuzzforge_mcp/resources/executions.py index c3d5f31..f2163d8 100644 --- a/fuzzforge-mcp/src/fuzzforge_mcp/resources/executions.py +++ b/fuzzforge-mcp/src/fuzzforge_mcp/resources/executions.py @@ -3,15 +3,12 @@ from __future__ import annotations from pathlib import Path -from typing import TYPE_CHECKING, Any +from typing import Any from fastmcp import FastMCP from fastmcp.exceptions import ResourceError -from fuzzforge_mcp.dependencies import get_project_path, get_runner - -if TYPE_CHECKING: - from fuzzforge_runner import Runner +from fuzzforge_mcp.dependencies import get_project_path, get_storage mcp: FastMCP = FastMCP() @@ -26,16 +23,16 @@ async def list_executions() -> list[dict[str, Any]]: :return: List of execution information dictionaries. """ - runner: Runner = get_runner() + storage = get_storage() project_path: Path = get_project_path() try: - execution_ids = runner.list_executions(project_path) + execution_ids = storage.list_executions(project_path) return [ { "execution_id": exec_id, - "has_results": runner.get_execution_results(project_path, exec_id) is not None, + "has_results": storage.get_execution_results(project_path, exec_id) is not None, } for exec_id in execution_ids ] @@ -53,11 +50,11 @@ async def get_execution(execution_id: str) -> dict[str, Any]: :return: Execution information dictionary. """ - runner: Runner = get_runner() + storage = get_storage() project_path: Path = get_project_path() try: - results_path = runner.get_execution_results(project_path, execution_id) + results_path = storage.get_execution_results(project_path, execution_id) if results_path is None: raise ResourceError(f"Execution not found: {execution_id}") diff --git a/fuzzforge-mcp/src/fuzzforge_mcp/resources/modules.py b/fuzzforge-mcp/src/fuzzforge_mcp/resources/modules.py deleted file mode 100644 index a551ccd..0000000 --- a/fuzzforge-mcp/src/fuzzforge_mcp/resources/modules.py +++ /dev/null @@ -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 - diff --git a/fuzzforge-mcp/src/fuzzforge_mcp/resources/project.py b/fuzzforge-mcp/src/fuzzforge_mcp/resources/project.py index 566ba0f..1eb5ec0 100644 --- a/fuzzforge-mcp/src/fuzzforge_mcp/resources/project.py +++ b/fuzzforge-mcp/src/fuzzforge_mcp/resources/project.py @@ -3,15 +3,12 @@ from __future__ import annotations from pathlib import Path -from typing import TYPE_CHECKING, Any +from typing import Any from fastmcp import FastMCP from fastmcp.exceptions import ResourceError -from fuzzforge_mcp.dependencies import get_project_path, get_runner - -if TYPE_CHECKING: - from fuzzforge_runner import Runner +from fuzzforge_mcp.dependencies import get_project_path, get_settings, get_storage mcp: FastMCP = FastMCP() @@ -27,12 +24,12 @@ async def get_project() -> dict[str, Any]: :return: Project information dictionary. """ - runner: Runner = get_runner() + storage = get_storage() project_path: Path = get_project_path() try: - executions = runner.list_executions(project_path) - assets_path = runner.storage.get_project_assets_path(project_path) + executions = storage.list_executions(project_path) + assets_path = storage.get_project_assets_path(project_path) return { "path": str(project_path), @@ -40,7 +37,7 @@ async def get_project() -> dict[str, Any]: "has_assets": assets_path is not None, "assets_path": str(assets_path) if assets_path else None, "execution_count": len(executions), - "recent_executions": executions[:10], # Last 10 executions + "recent_executions": executions[:10], } except Exception as exception: @@ -53,13 +50,11 @@ async def get_project_settings() -> dict[str, Any]: """Get current FuzzForge settings. Returns the active configuration for the MCP server including - engine, storage, and project settings. + engine, storage, and hub settings. :return: Settings dictionary. """ - from fuzzforge_mcp.dependencies import get_settings - try: settings = get_settings() @@ -71,9 +66,10 @@ async def get_project_settings() -> dict[str, Any]: "storage": { "path": str(settings.storage.path), }, - "project": { - "path": str(settings.project.path), - "modules_path": str(settings.modules_path), + "hub": { + "enabled": settings.hub.enabled, + "config_path": str(settings.hub.config_path), + "timeout": settings.hub.timeout, }, "debug": settings.debug, } diff --git a/fuzzforge-mcp/src/fuzzforge_mcp/resources/workflows.py b/fuzzforge-mcp/src/fuzzforge_mcp/resources/workflows.py deleted file mode 100644 index 280c306..0000000 --- a/fuzzforge-mcp/src/fuzzforge_mcp/resources/workflows.py +++ /dev/null @@ -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)", - }, - } diff --git a/fuzzforge-runner/src/fuzzforge_runner/settings.py b/fuzzforge-mcp/src/fuzzforge_mcp/settings.py similarity index 55% rename from fuzzforge-runner/src/fuzzforge_runner/settings.py rename to fuzzforge-mcp/src/fuzzforge_mcp/settings.py index 56b7786..a2d6bbd 100644 --- a/fuzzforge-runner/src/fuzzforge_runner/settings.py +++ b/fuzzforge-mcp/src/fuzzforge_mcp/settings.py @@ -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 enum import StrEnum from pathlib import Path -from typing import Literal from pydantic import BaseModel, Field from pydantic_settings import BaseSettings, SettingsConfigDict @@ -20,24 +33,21 @@ class EngineType(StrEnum): class EngineSettings(BaseModel): """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 - #: Path to the container engine socket (only used as fallback for socket-based engines). + #: Path to the container engine socket. 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") - #: 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") class StorageSettings(BaseModel): - """Storage configuration for local filesystem storage. - - OSS uses direct file mounting without archiving for simplicity. - """ + """Storage configuration for local filesystem storage.""" #: Base path for local 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") -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): """MCP Hub configuration for external tool servers. Controls the hub that bridges FuzzForge with external MCP servers - (e.g., mcp-security-hub). When enabled, AI agents can discover - and execute tools from registered MCP servers. + (e.g., mcp-security-hub). AI agents discover and execute tools + from registered MCP servers. Configure via environment variables: ``FUZZFORGE_HUB__ENABLED=true`` @@ -95,15 +84,10 @@ class HubSettings(BaseModel): class Settings(BaseSettings): - """FuzzForge Runner settings. + """FuzzForge MCP Server settings. Settings can be configured via environment variables with the prefix - ``FUZZFORGE_``. Nested settings use underscore as delimiter. - - Example: - ``FUZZFORGE_ENGINE_TYPE=docker`` - ``FUZZFORGE_STORAGE_PATH=/data/fuzzforge`` - ``FUZZFORGE_MODULES_PATH=/path/to/modules`` + ``FUZZFORGE_``. Nested settings use double-underscore as delimiter. """ @@ -122,14 +106,8 @@ class Settings(BaseSettings): #: Project settings. project: ProjectSettings = Field(default_factory=ProjectSettings) - #: Container registry settings. - registry: RegistrySettings = Field(default_factory=RegistrySettings) - #: MCP Hub settings. 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. debug: bool = False diff --git a/fuzzforge-mcp/src/fuzzforge_mcp/storage.py b/fuzzforge-mcp/src/fuzzforge_mcp/storage.py new file mode 100644 index 0000000..83a9df9 --- /dev/null +++ b/fuzzforge-mcp/src/fuzzforge_mcp/storage.py @@ -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 diff --git a/fuzzforge-mcp/src/fuzzforge_mcp/tools/__init__.py b/fuzzforge-mcp/src/fuzzforge_mcp/tools/__init__.py index ac55ae3..fc339f9 100644 --- a/fuzzforge-mcp/src/fuzzforge_mcp/tools/__init__.py +++ b/fuzzforge-mcp/src/fuzzforge_mcp/tools/__init__.py @@ -2,13 +2,11 @@ from fastmcp import FastMCP -from fuzzforge_mcp.tools import hub, modules, projects, workflows +from fuzzforge_mcp.tools import hub, projects mcp: FastMCP = FastMCP() -mcp.mount(modules.mcp) mcp.mount(projects.mcp) -mcp.mount(workflows.mcp) mcp.mount(hub.mcp) __all__ = [ diff --git a/fuzzforge-mcp/src/fuzzforge_mcp/tools/hub.py b/fuzzforge-mcp/src/fuzzforge_mcp/tools/hub.py index 5b06bff..88b3511 100644 --- a/fuzzforge-mcp/src/fuzzforge_mcp/tools/hub.py +++ b/fuzzforge-mcp/src/fuzzforge_mcp/tools/hub.py @@ -313,3 +313,249 @@ async def add_hub_server( raise msg = f"Failed to add hub server: {e}" raise ToolError(msg) from e + + +@mcp.tool +async def start_hub_server(server_name: str) -> dict[str, Any]: + """Start a persistent container session for a hub server. + + Starts a Docker container that stays running between tool calls, + allowing stateful interactions. Tools are auto-discovered on start. + + Use this for servers like radare2 or ghidra where you want to + keep an analysis session open across multiple tool calls. + + After starting, use execute_hub_tool as normal - calls will be + routed to the persistent container automatically. + + :param server_name: Name of the hub server to start (e.g., "radare2-mcp"). + :return: Session status with container name and start time. + + """ + try: + executor = _get_hub_executor() + + result = await executor.start_persistent_server(server_name) + + return { + "success": True, + "session": result, + "tools": result.get("tools", []), + "tool_count": result.get("tool_count", 0), + "message": ( + f"Persistent session started for '{server_name}'. " + f"Discovered {result.get('tool_count', 0)} tools. " + "Use execute_hub_tool to call them — they will reuse this container. " + f"Stop with stop_hub_server('{server_name}') when done." + ), + } + + except ValueError as e: + msg = f"Server not found: {e}" + raise ToolError(msg) from e + except Exception as e: + if isinstance(e, ToolError): + raise + msg = f"Failed to start persistent server: {e}" + raise ToolError(msg) from e + + +@mcp.tool +async def stop_hub_server(server_name: str) -> dict[str, Any]: + """Stop a persistent container session for a hub server. + + Terminates the running Docker container and cleans up resources. + After stopping, tool calls will fall back to ephemeral mode + (a new container per call). + + :param server_name: Name of the hub server to stop. + :return: Result indicating if the session was stopped. + + """ + try: + executor = _get_hub_executor() + + stopped = await executor.stop_persistent_server(server_name) + + if stopped: + return { + "success": True, + "message": f"Persistent session for '{server_name}' stopped and container removed.", + } + else: + return { + "success": False, + "message": f"No active persistent session found for '{server_name}'.", + } + + except Exception as e: + if isinstance(e, ToolError): + raise + msg = f"Failed to stop persistent server: {e}" + raise ToolError(msg) from e + + +@mcp.tool +async def hub_server_status(server_name: str | None = None) -> dict[str, Any]: + """Get status of persistent hub server sessions. + + If server_name is provided, returns status for that specific server. + Otherwise returns status for all active persistent sessions. + + :param server_name: Optional specific server to check. + :return: Session status information. + + """ + try: + executor = _get_hub_executor() + + if server_name: + status = executor.get_persistent_status(server_name) + if status: + return {"active": True, "session": status} + else: + return { + "active": False, + "message": f"No active persistent session for '{server_name}'.", + } + else: + sessions = executor.list_persistent_sessions() + return { + "active_sessions": sessions, + "count": len(sessions), + } + + except Exception as e: + if isinstance(e, ToolError): + raise + msg = f"Failed to get server status: {e}" + raise ToolError(msg) from e + + +# ------------------------------------------------------------------ +# Continuous mode tools +# ------------------------------------------------------------------ + + +@mcp.tool +async def start_continuous_hub_tool( + server_name: str, + start_tool: str, + arguments: dict[str, Any] | None = None, +) -> dict[str, Any]: + """Start a continuous/background tool on a hub server. + + Automatically starts a persistent container if not already running, + then calls the server's start tool (e.g., cargo_fuzz_start) which + launches a background process and returns a session_id. + + The tool runs indefinitely until stopped with stop_continuous_hub_tool. + Use get_continuous_hub_status to monitor progress. + + Example workflow for continuous cargo fuzzing: + 1. start_continuous_hub_tool("cargo-fuzzer-mcp", "cargo_fuzz_start", {"project_path": "/data/myproject"}) + 2. get_continuous_hub_status(session_id) -- poll every 10-30s + 3. stop_continuous_hub_tool(session_id) -- when done + + :param server_name: Hub server name (e.g., "cargo-fuzzer-mcp"). + :param start_tool: Name of the start tool on the server. + :param arguments: Arguments for the start tool. + :return: Start result including session_id for monitoring. + + """ + try: + executor = _get_hub_executor() + + result = await executor.start_continuous_tool( + server_name=server_name, + start_tool=start_tool, + arguments=arguments or {}, + ) + + # Return the server's response directly — it already contains + # session_id, status, targets, and a message. + return result + + except ValueError as e: + msg = f"Server not found: {e}" + raise ToolError(msg) from e + except Exception as e: + if isinstance(e, ToolError): + raise + msg = f"Failed to start continuous tool: {e}" + raise ToolError(msg) from e + + +@mcp.tool +async def get_continuous_hub_status(session_id: str) -> dict[str, Any]: + """Get live status of a continuous hub tool session. + + Returns current metrics, progress, and recent output from the + running tool. Call periodically (every 10-30 seconds) to monitor. + + :param session_id: Session ID returned by start_continuous_hub_tool. + :return: Current status with metrics (executions, coverage, crashes, etc.). + + """ + try: + executor = _get_hub_executor() + + return await executor.get_continuous_tool_status(session_id) + + except ValueError as e: + msg = str(e) + raise ToolError(msg) from e + except Exception as e: + if isinstance(e, ToolError): + raise + msg = f"Failed to get continuous status: {e}" + raise ToolError(msg) from e + + +@mcp.tool +async def stop_continuous_hub_tool(session_id: str) -> dict[str, Any]: + """Stop a running continuous hub tool session. + + Gracefully stops the background process and returns final results + including total metrics and any artifacts (crash files, etc.). + + :param session_id: Session ID of the session to stop. + :return: Final metrics and results summary. + + """ + try: + executor = _get_hub_executor() + + return await executor.stop_continuous_tool(session_id) + + except ValueError as e: + msg = str(e) + raise ToolError(msg) from e + except Exception as e: + if isinstance(e, ToolError): + raise + msg = f"Failed to stop continuous tool: {e}" + raise ToolError(msg) from e + + +@mcp.tool +async def list_continuous_hub_sessions() -> dict[str, Any]: + """List all active and recent continuous hub tool sessions. + + :return: List of sessions with their status and server info. + + """ + try: + executor = _get_hub_executor() + + sessions = executor.list_continuous_sessions() + return { + "sessions": sessions, + "count": len(sessions), + } + + except Exception as e: + if isinstance(e, ToolError): + raise + msg = f"Failed to list continuous sessions: {e}" + raise ToolError(msg) from e diff --git a/fuzzforge-mcp/src/fuzzforge_mcp/tools/modules.py b/fuzzforge-mcp/src/fuzzforge_mcp/tools/modules.py deleted file mode 100644 index d1d0c93..0000000 --- a/fuzzforge-mcp/src/fuzzforge_mcp/tools/modules.py +++ /dev/null @@ -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), - } - diff --git a/fuzzforge-mcp/src/fuzzforge_mcp/tools/projects.py b/fuzzforge-mcp/src/fuzzforge_mcp/tools/projects.py index 1868576..6e7dc75 100644 --- a/fuzzforge-mcp/src/fuzzforge_mcp/tools/projects.py +++ b/fuzzforge-mcp/src/fuzzforge_mcp/tools/projects.py @@ -3,15 +3,12 @@ from __future__ import annotations from pathlib import Path -from typing import TYPE_CHECKING, Any +from typing import Any from fastmcp import FastMCP from fastmcp.exceptions import ToolError -from fuzzforge_mcp.dependencies import get_project_path, get_runner, set_current_project_path - -if TYPE_CHECKING: - from fuzzforge_runner import Runner +from fuzzforge_mcp.dependencies import get_project_path, get_storage, set_current_project_path mcp: FastMCP = FastMCP() @@ -22,25 +19,24 @@ async def init_project(project_path: str | None = None) -> dict[str, Any]: """Initialize a new FuzzForge project. Creates a `.fuzzforge/` directory inside the project for storing: - - assets/: Input files (source code, etc.) - - inputs/: Prepared module inputs (for debugging) - - runs/: Execution results from each module + - config.json: Project configuration + - runs/: Execution results - This should be called before executing modules or workflows. + This should be called before executing hub tools. :param project_path: Path to the project directory. If not provided, uses current directory. :return: Project initialization result. """ - runner: Runner = get_runner() + storage = get_storage() try: path = Path(project_path) if project_path else get_project_path() - + # Track this as the current active project set_current_project_path(path) - - storage_path = runner.init_project(path) + + storage_path = storage.init_project(path) return { "success": True, @@ -58,23 +54,18 @@ async def init_project(project_path: str | None = None) -> dict[str, Any]: async def set_project_assets(assets_path: str) -> dict[str, Any]: """Set the initial assets (source code) for a project. - This sets the DEFAULT source directory mounted into modules. - Usually this is the project root containing source code (e.g. Cargo.toml, src/). + This sets the DEFAULT source directory that will be mounted into + hub tool containers via volume mounts. - IMPORTANT: This OVERWRITES the previous assets path. Only call this once - during project setup. To pass different inputs to a specific module - (e.g. crash files to crash-analyzer), use the `assets_path` parameter - on `execute_module` instead. - - :param assets_path: Path to the project source directory or archive. + :param assets_path: Path to the project source directory. :return: Result including stored assets path. """ - runner: Runner = get_runner() + storage = get_storage() project_path: Path = get_project_path() try: - stored_path = runner.set_project_assets( + stored_path = storage.set_project_assets( project_path=project_path, assets_path=Path(assets_path), ) @@ -100,11 +91,11 @@ async def list_executions() -> dict[str, Any]: :return: List of execution IDs. """ - runner: Runner = get_runner() + storage = get_storage() project_path: Path = get_project_path() try: - executions = runner.list_executions(project_path) + executions = storage.list_executions(project_path) return { "success": True, @@ -127,11 +118,11 @@ async def get_execution_results(execution_id: str, extract_to: str | None = None :return: Result including path to results archive. """ - runner: Runner = get_runner() + storage = get_storage() project_path: Path = get_project_path() try: - results_path = runner.get_execution_results(project_path, execution_id) + results_path = storage.get_execution_results(project_path, execution_id) if results_path is None: return { @@ -140,7 +131,7 @@ async def get_execution_results(execution_id: str, extract_to: str | None = None "error": "Execution results not found", } - result = { + result: dict[str, Any] = { "success": True, "execution_id": execution_id, "results_path": str(results_path), @@ -148,7 +139,7 @@ async def get_execution_results(execution_id: str, extract_to: str | None = None # Extract if requested if extract_to: - extracted_path = runner.extract_results(results_path, Path(extract_to)) + extracted_path = storage.extract_results(results_path, Path(extract_to)) result["extracted_path"] = str(extracted_path) return result diff --git a/fuzzforge-mcp/src/fuzzforge_mcp/tools/workflows.py b/fuzzforge-mcp/src/fuzzforge_mcp/tools/workflows.py deleted file mode 100644 index 222ca60..0000000 --- a/fuzzforge-mcp/src/fuzzforge_mcp/tools/workflows.py +++ /dev/null @@ -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 - diff --git a/fuzzforge-mcp/tests/test_resources.py b/fuzzforge-mcp/tests/test_resources.py index 21c8149..6665f91 100644 --- a/fuzzforge-mcp/tests/test_resources.py +++ b/fuzzforge-mcp/tests/test_resources.py @@ -11,16 +11,6 @@ if TYPE_CHECKING: from fastmcp.client import FastMCPTransport -async def test_list_modules_tool_exists( - mcp_client: "Client[FastMCPTransport]", -) -> None: - """Test that the list_modules tool is available.""" - tools = await mcp_client.list_tools() - tool_names = [tool.name for tool in tools] - - assert "list_modules" in tool_names - - async def test_init_project_tool_exists( mcp_client: "Client[FastMCPTransport]", ) -> None: @@ -31,31 +21,11 @@ async def test_init_project_tool_exists( assert "init_project" in tool_names -async def test_execute_module_tool_exists( - mcp_client: "Client[FastMCPTransport]", -) -> None: - """Test that the execute_module tool is available.""" - tools = await mcp_client.list_tools() - tool_names = [tool.name for tool in tools] - - assert "execute_module" in tool_names - - -async def test_execute_workflow_tool_exists( - mcp_client: "Client[FastMCPTransport]", -) -> None: - """Test that the execute_workflow tool is available.""" - tools = await mcp_client.list_tools() - tool_names = [tool.name for tool in tools] - - assert "execute_workflow" in tool_names - - async def test_mcp_has_expected_tool_count( mcp_client: "Client[FastMCPTransport]", ) -> None: """Test that MCP has the expected number of tools.""" tools = await mcp_client.list_tools() - # Should have at least 4 core tools - assert len(tools) >= 4 + # Should have project tools + hub tools + assert len(tools) >= 2 diff --git a/fuzzforge-modules/cargo-fuzzer/Dockerfile b/fuzzforge-modules/cargo-fuzzer/Dockerfile deleted file mode 100644 index 33f658a..0000000 --- a/fuzzforge-modules/cargo-fuzzer/Dockerfile +++ /dev/null @@ -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 diff --git a/fuzzforge-modules/cargo-fuzzer/Makefile b/fuzzforge-modules/cargo-fuzzer/Makefile deleted file mode 100644 index cada4d0..0000000 --- a/fuzzforge-modules/cargo-fuzzer/Makefile +++ /dev/null @@ -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)' diff --git a/fuzzforge-modules/cargo-fuzzer/README.md b/fuzzforge-modules/cargo-fuzzer/README.md deleted file mode 100644 index d0671a1..0000000 --- a/fuzzforge-modules/cargo-fuzzer/README.md +++ /dev/null @@ -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" \ - ':' '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. diff --git a/fuzzforge-modules/cargo-fuzzer/mypy.ini b/fuzzforge-modules/cargo-fuzzer/mypy.ini deleted file mode 100644 index 84e90d2..0000000 --- a/fuzzforge-modules/cargo-fuzzer/mypy.ini +++ /dev/null @@ -1,6 +0,0 @@ -[mypy] -plugins = pydantic.mypy -strict = True -warn_unused_ignores = True -warn_redundant_casts = True -warn_return_any = True diff --git a/fuzzforge-modules/cargo-fuzzer/pyproject.toml b/fuzzforge-modules/cargo-fuzzer/pyproject.toml deleted file mode 100644 index 935549c..0000000 --- a/fuzzforge-modules/cargo-fuzzer/pyproject.toml +++ /dev/null @@ -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." diff --git a/fuzzforge-modules/cargo-fuzzer/ruff.toml b/fuzzforge-modules/cargo-fuzzer/ruff.toml deleted file mode 100644 index 6374f62..0000000 --- a/fuzzforge-modules/cargo-fuzzer/ruff.toml +++ /dev/null @@ -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 -] diff --git a/fuzzforge-modules/cargo-fuzzer/src/module/__init__.py b/fuzzforge-modules/cargo-fuzzer/src/module/__init__.py deleted file mode 100644 index e69de29..0000000 diff --git a/fuzzforge-modules/cargo-fuzzer/src/module/__main__.py b/fuzzforge-modules/cargo-fuzzer/src/module/__main__.py deleted file mode 100644 index bc8914a..0000000 --- a/fuzzforge-modules/cargo-fuzzer/src/module/__main__.py +++ /dev/null @@ -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() diff --git a/fuzzforge-modules/cargo-fuzzer/src/module/mod.py b/fuzzforge-modules/cargo-fuzzer/src/module/mod.py deleted file mode 100644 index fecb350..0000000 --- a/fuzzforge-modules/cargo-fuzzer/src/module/mod.py +++ /dev/null @@ -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)) diff --git a/fuzzforge-modules/cargo-fuzzer/src/module/models.py b/fuzzforge-modules/cargo-fuzzer/src/module/models.py deleted file mode 100644 index 9c4fb9e..0000000 --- a/fuzzforge-modules/cargo-fuzzer/src/module/models.py +++ /dev/null @@ -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) diff --git a/fuzzforge-modules/cargo-fuzzer/src/module/settings.py b/fuzzforge-modules/cargo-fuzzer/src/module/settings.py deleted file mode 100644 index ec49c21..0000000 --- a/fuzzforge-modules/cargo-fuzzer/src/module/settings.py +++ /dev/null @@ -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 diff --git a/fuzzforge-modules/cargo-fuzzer/tests/.gitkeep b/fuzzforge-modules/cargo-fuzzer/tests/.gitkeep deleted file mode 100644 index e69de29..0000000 diff --git a/fuzzforge-modules/crash-analyzer/Dockerfile b/fuzzforge-modules/crash-analyzer/Dockerfile deleted file mode 100644 index 3f32d99..0000000 --- a/fuzzforge-modules/crash-analyzer/Dockerfile +++ /dev/null @@ -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 diff --git a/fuzzforge-modules/crash-analyzer/Makefile b/fuzzforge-modules/crash-analyzer/Makefile deleted file mode 100644 index cada4d0..0000000 --- a/fuzzforge-modules/crash-analyzer/Makefile +++ /dev/null @@ -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)' diff --git a/fuzzforge-modules/crash-analyzer/README.md b/fuzzforge-modules/crash-analyzer/README.md deleted file mode 100644 index d0671a1..0000000 --- a/fuzzforge-modules/crash-analyzer/README.md +++ /dev/null @@ -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" \ - ':' '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. diff --git a/fuzzforge-modules/crash-analyzer/mypy.ini b/fuzzforge-modules/crash-analyzer/mypy.ini deleted file mode 100644 index 84e90d2..0000000 --- a/fuzzforge-modules/crash-analyzer/mypy.ini +++ /dev/null @@ -1,6 +0,0 @@ -[mypy] -plugins = pydantic.mypy -strict = True -warn_unused_ignores = True -warn_redundant_casts = True -warn_return_any = True diff --git a/fuzzforge-modules/crash-analyzer/pyproject.toml b/fuzzforge-modules/crash-analyzer/pyproject.toml deleted file mode 100644 index c1a5268..0000000 --- a/fuzzforge-modules/crash-analyzer/pyproject.toml +++ /dev/null @@ -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." diff --git a/fuzzforge-modules/crash-analyzer/ruff.toml b/fuzzforge-modules/crash-analyzer/ruff.toml deleted file mode 100644 index 6374f62..0000000 --- a/fuzzforge-modules/crash-analyzer/ruff.toml +++ /dev/null @@ -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 -] diff --git a/fuzzforge-modules/crash-analyzer/src/module/__init__.py b/fuzzforge-modules/crash-analyzer/src/module/__init__.py deleted file mode 100644 index e69de29..0000000 diff --git a/fuzzforge-modules/crash-analyzer/src/module/__main__.py b/fuzzforge-modules/crash-analyzer/src/module/__main__.py deleted file mode 100644 index bc8914a..0000000 --- a/fuzzforge-modules/crash-analyzer/src/module/__main__.py +++ /dev/null @@ -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() diff --git a/fuzzforge-modules/crash-analyzer/src/module/mod.py b/fuzzforge-modules/crash-analyzer/src/module/mod.py deleted file mode 100644 index 40f71ff..0000000 --- a/fuzzforge-modules/crash-analyzer/src/module/mod.py +++ /dev/null @@ -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)) diff --git a/fuzzforge-modules/crash-analyzer/src/module/models.py b/fuzzforge-modules/crash-analyzer/src/module/models.py deleted file mode 100644 index bf8620c..0000000 --- a/fuzzforge-modules/crash-analyzer/src/module/models.py +++ /dev/null @@ -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) diff --git a/fuzzforge-modules/crash-analyzer/src/module/settings.py b/fuzzforge-modules/crash-analyzer/src/module/settings.py deleted file mode 100644 index fdfaf62..0000000 --- a/fuzzforge-modules/crash-analyzer/src/module/settings.py +++ /dev/null @@ -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 diff --git a/fuzzforge-modules/crash-analyzer/tests/.gitkeep b/fuzzforge-modules/crash-analyzer/tests/.gitkeep deleted file mode 100644 index e69de29..0000000 diff --git a/fuzzforge-modules/fuzzforge-module-template/Dockerfile b/fuzzforge-modules/fuzzforge-module-template/Dockerfile deleted file mode 100644 index d663a1f..0000000 --- a/fuzzforge-modules/fuzzforge-module-template/Dockerfile +++ /dev/null @@ -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 diff --git a/fuzzforge-modules/fuzzforge-module-template/Makefile b/fuzzforge-modules/fuzzforge-module-template/Makefile deleted file mode 100644 index cada4d0..0000000 --- a/fuzzforge-modules/fuzzforge-module-template/Makefile +++ /dev/null @@ -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)' diff --git a/fuzzforge-modules/fuzzforge-module-template/README.md b/fuzzforge-modules/fuzzforge-module-template/README.md deleted file mode 100644 index d0671a1..0000000 --- a/fuzzforge-modules/fuzzforge-module-template/README.md +++ /dev/null @@ -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" \ - ':' '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. diff --git a/fuzzforge-modules/fuzzforge-module-template/mypy.ini b/fuzzforge-modules/fuzzforge-module-template/mypy.ini deleted file mode 100644 index 84e90d2..0000000 --- a/fuzzforge-modules/fuzzforge-module-template/mypy.ini +++ /dev/null @@ -1,6 +0,0 @@ -[mypy] -plugins = pydantic.mypy -strict = True -warn_unused_ignores = True -warn_redundant_casts = True -warn_return_any = True diff --git a/fuzzforge-modules/fuzzforge-module-template/pyproject.toml b/fuzzforge-modules/fuzzforge-module-template/pyproject.toml deleted file mode 100644 index 303600d..0000000 --- a/fuzzforge-modules/fuzzforge-module-template/pyproject.toml +++ /dev/null @@ -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" -] diff --git a/fuzzforge-modules/fuzzforge-module-template/ruff.toml b/fuzzforge-modules/fuzzforge-module-template/ruff.toml deleted file mode 100644 index 6374f62..0000000 --- a/fuzzforge-modules/fuzzforge-module-template/ruff.toml +++ /dev/null @@ -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 -] diff --git a/fuzzforge-modules/fuzzforge-module-template/src/module/__init__.py b/fuzzforge-modules/fuzzforge-module-template/src/module/__init__.py deleted file mode 100644 index e69de29..0000000 diff --git a/fuzzforge-modules/fuzzforge-module-template/src/module/__main__.py b/fuzzforge-modules/fuzzforge-module-template/src/module/__main__.py deleted file mode 100644 index bc8914a..0000000 --- a/fuzzforge-modules/fuzzforge-module-template/src/module/__main__.py +++ /dev/null @@ -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() diff --git a/fuzzforge-modules/fuzzforge-module-template/src/module/mod.py b/fuzzforge-modules/fuzzforge-module-template/src/module/mod.py deleted file mode 100644 index f0f85e9..0000000 --- a/fuzzforge-modules/fuzzforge-module-template/src/module/mod.py +++ /dev/null @@ -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. - - """ diff --git a/fuzzforge-modules/fuzzforge-module-template/src/module/models.py b/fuzzforge-modules/fuzzforge-module-template/src/module/models.py deleted file mode 100644 index 2a3f021..0000000 --- a/fuzzforge-modules/fuzzforge-module-template/src/module/models.py +++ /dev/null @@ -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.""" diff --git a/fuzzforge-modules/fuzzforge-module-template/src/module/settings.py b/fuzzforge-modules/fuzzforge-module-template/src/module/settings.py deleted file mode 100644 index f916ad4..0000000 --- a/fuzzforge-modules/fuzzforge-module-template/src/module/settings.py +++ /dev/null @@ -1,7 +0,0 @@ -from fuzzforge_modules_sdk.api.models import FuzzForgeModulesSettingsBase - - -class Settings(FuzzForgeModulesSettingsBase): - """TODO.""" - - # Here goes your attributes diff --git a/fuzzforge-modules/fuzzforge-module-template/tests/.gitkeep b/fuzzforge-modules/fuzzforge-module-template/tests/.gitkeep deleted file mode 100644 index e69de29..0000000 diff --git a/fuzzforge-modules/fuzzforge-modules-sdk/Dockerfile b/fuzzforge-modules/fuzzforge-modules-sdk/Dockerfile deleted file mode 100644 index c98782a..0000000 --- a/fuzzforge-modules/fuzzforge-modules-sdk/Dockerfile +++ /dev/null @@ -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"] diff --git a/fuzzforge-modules/fuzzforge-modules-sdk/Makefile b/fuzzforge-modules/fuzzforge-modules-sdk/Makefile deleted file mode 100644 index e7ce0a9..0000000 --- a/fuzzforge-modules/fuzzforge-modules-sdk/Makefile +++ /dev/null @@ -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)' diff --git a/fuzzforge-modules/fuzzforge-modules-sdk/README.md b/fuzzforge-modules/fuzzforge-modules-sdk/README.md deleted file mode 100644 index 334325b..0000000 --- a/fuzzforge-modules/fuzzforge-modules-sdk/README.md +++ /dev/null @@ -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 -``` \ No newline at end of file diff --git a/fuzzforge-modules/fuzzforge-modules-sdk/mypy.ini b/fuzzforge-modules/fuzzforge-modules-sdk/mypy.ini deleted file mode 100644 index f74350d..0000000 --- a/fuzzforge-modules/fuzzforge-modules-sdk/mypy.ini +++ /dev/null @@ -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 diff --git a/fuzzforge-modules/fuzzforge-modules-sdk/pyproject.toml b/fuzzforge-modules/fuzzforge-modules-sdk/pyproject.toml deleted file mode 100644 index 4734f59..0000000 --- a/fuzzforge-modules/fuzzforge-modules-sdk/pyproject.toml +++ /dev/null @@ -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/**/*", -] diff --git a/fuzzforge-modules/fuzzforge-modules-sdk/ruff.toml b/fuzzforge-modules/fuzzforge-modules-sdk/ruff.toml deleted file mode 100644 index 6374f62..0000000 --- a/fuzzforge-modules/fuzzforge-modules-sdk/ruff.toml +++ /dev/null @@ -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 -] diff --git a/fuzzforge-modules/fuzzforge-modules-sdk/src/fuzzforge_modules_sdk/__init__.py b/fuzzforge-modules/fuzzforge-modules-sdk/src/fuzzforge_modules_sdk/__init__.py deleted file mode 100644 index e69de29..0000000 diff --git a/fuzzforge-modules/fuzzforge-modules-sdk/src/fuzzforge_modules_sdk/_cli/__init__.py b/fuzzforge-modules/fuzzforge-modules-sdk/src/fuzzforge_modules_sdk/_cli/__init__.py deleted file mode 100644 index e69de29..0000000 diff --git a/fuzzforge-modules/fuzzforge-modules-sdk/src/fuzzforge_modules_sdk/_cli/build_base_image.py b/fuzzforge-modules/fuzzforge-modules-sdk/src/fuzzforge_modules_sdk/_cli/build_base_image.py deleted file mode 100644 index 3cc04cb..0000000 --- a/fuzzforge-modules/fuzzforge-modules-sdk/src/fuzzforge_modules_sdk/_cli/build_base_image.py +++ /dev/null @@ -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 diff --git a/fuzzforge-modules/fuzzforge-modules-sdk/src/fuzzforge_modules_sdk/_cli/create_new_module.py b/fuzzforge-modules/fuzzforge-modules-sdk/src/fuzzforge_modules_sdk/_cli/create_new_module.py deleted file mode 100644 index 0c2001c..0000000 --- a/fuzzforge-modules/fuzzforge-modules-sdk/src/fuzzforge_modules_sdk/_cli/create_new_module.py +++ /dev/null @@ -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)) diff --git a/fuzzforge-modules/fuzzforge-modules-sdk/src/fuzzforge_modules_sdk/_cli/main.py b/fuzzforge-modules/fuzzforge-modules-sdk/src/fuzzforge_modules_sdk/_cli/main.py deleted file mode 100644 index e6ab418..0000000 --- a/fuzzforge-modules/fuzzforge-modules-sdk/src/fuzzforge_modules_sdk/_cli/main.py +++ /dev/null @@ -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)) diff --git a/fuzzforge-modules/fuzzforge-modules-sdk/src/fuzzforge_modules_sdk/api/__init__.py b/fuzzforge-modules/fuzzforge-modules-sdk/src/fuzzforge_modules_sdk/api/__init__.py deleted file mode 100644 index e69de29..0000000 diff --git a/fuzzforge-modules/fuzzforge-modules-sdk/src/fuzzforge_modules_sdk/api/constants.py b/fuzzforge-modules/fuzzforge-modules-sdk/src/fuzzforge_modules_sdk/api/constants.py deleted file mode 100644 index cbab687..0000000 --- a/fuzzforge-modules/fuzzforge-modules-sdk/src/fuzzforge_modules_sdk/api/constants.py +++ /dev/null @@ -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") diff --git a/fuzzforge-modules/fuzzforge-modules-sdk/src/fuzzforge_modules_sdk/api/exceptions.py b/fuzzforge-modules/fuzzforge-modules-sdk/src/fuzzforge_modules_sdk/api/exceptions.py deleted file mode 100644 index da1d040..0000000 --- a/fuzzforge-modules/fuzzforge-modules-sdk/src/fuzzforge_modules_sdk/api/exceptions.py +++ /dev/null @@ -1,2 +0,0 @@ -class FuzzForgeModuleError(Exception): - """TODO.""" diff --git a/fuzzforge-modules/fuzzforge-modules-sdk/src/fuzzforge_modules_sdk/api/logs.py b/fuzzforge-modules/fuzzforge-modules-sdk/src/fuzzforge_modules_sdk/api/logs.py deleted file mode 100644 index d3a0fb4..0000000 --- a/fuzzforge-modules/fuzzforge-modules-sdk/src/fuzzforge_modules_sdk/api/logs.py +++ /dev/null @@ -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, - ) diff --git a/fuzzforge-modules/fuzzforge-modules-sdk/src/fuzzforge_modules_sdk/api/models/__init__.py b/fuzzforge-modules/fuzzforge-modules-sdk/src/fuzzforge_modules_sdk/api/models/__init__.py deleted file mode 100644 index c825cbb..0000000 --- a/fuzzforge-modules/fuzzforge-modules-sdk/src/fuzzforge_modules_sdk/api/models/__init__.py +++ /dev/null @@ -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", -] diff --git a/fuzzforge-modules/fuzzforge-modules-sdk/src/fuzzforge_modules_sdk/api/models/mod.py b/fuzzforge-modules/fuzzforge-modules-sdk/src/fuzzforge_modules_sdk/api/models/mod.py deleted file mode 100644 index 69d6a09..0000000 --- a/fuzzforge-modules/fuzzforge-modules-sdk/src/fuzzforge_modules_sdk/api/models/mod.py +++ /dev/null @@ -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 diff --git a/fuzzforge-modules/fuzzforge-modules-sdk/src/fuzzforge_modules_sdk/api/modules/__init__.py b/fuzzforge-modules/fuzzforge-modules-sdk/src/fuzzforge_modules_sdk/api/modules/__init__.py deleted file mode 100644 index e69de29..0000000 diff --git a/fuzzforge-modules/fuzzforge-modules-sdk/src/fuzzforge_modules_sdk/api/modules/base.py b/fuzzforge-modules/fuzzforge-modules-sdk/src/fuzzforge_modules_sdk/api/modules/base.py deleted file mode 100644 index 90d8fa6..0000000 --- a/fuzzforge-modules/fuzzforge-modules-sdk/src/fuzzforge_modules_sdk/api/modules/base.py +++ /dev/null @@ -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) diff --git a/fuzzforge-modules/fuzzforge-modules-sdk/src/fuzzforge_modules_sdk/assets/Dockerfile b/fuzzforge-modules/fuzzforge-modules-sdk/src/fuzzforge_modules_sdk/assets/Dockerfile deleted file mode 100644 index 416c8f2..0000000 --- a/fuzzforge-modules/fuzzforge-modules-sdk/src/fuzzforge_modules_sdk/assets/Dockerfile +++ /dev/null @@ -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" ] diff --git a/fuzzforge-modules/fuzzforge-modules-sdk/src/fuzzforge_modules_sdk/assets/pyproject.toml b/fuzzforge-modules/fuzzforge-modules-sdk/src/fuzzforge_modules_sdk/assets/pyproject.toml deleted file mode 120000 index 7aa7944..0000000 --- a/fuzzforge-modules/fuzzforge-modules-sdk/src/fuzzforge_modules_sdk/assets/pyproject.toml +++ /dev/null @@ -1 +0,0 @@ -../../../pyproject.toml \ No newline at end of file diff --git a/fuzzforge-modules/fuzzforge-modules-sdk/src/fuzzforge_modules_sdk/py.typed b/fuzzforge-modules/fuzzforge-modules-sdk/src/fuzzforge_modules_sdk/py.typed deleted file mode 100644 index e69de29..0000000 diff --git a/fuzzforge-modules/fuzzforge-modules-sdk/src/fuzzforge_modules_sdk/templates/fuzzforge-module-template/Dockerfile b/fuzzforge-modules/fuzzforge-modules-sdk/src/fuzzforge_modules_sdk/templates/fuzzforge-module-template/Dockerfile deleted file mode 100644 index f2c71af..0000000 --- a/fuzzforge-modules/fuzzforge-modules-sdk/src/fuzzforge_modules_sdk/templates/fuzzforge-module-template/Dockerfile +++ /dev/null @@ -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 diff --git a/fuzzforge-modules/fuzzforge-modules-sdk/src/fuzzforge_modules_sdk/templates/fuzzforge-module-template/Makefile b/fuzzforge-modules/fuzzforge-modules-sdk/src/fuzzforge_modules_sdk/templates/fuzzforge-module-template/Makefile deleted file mode 100644 index cada4d0..0000000 --- a/fuzzforge-modules/fuzzforge-modules-sdk/src/fuzzforge_modules_sdk/templates/fuzzforge-module-template/Makefile +++ /dev/null @@ -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)' diff --git a/fuzzforge-modules/fuzzforge-modules-sdk/src/fuzzforge_modules_sdk/templates/fuzzforge-module-template/README.md b/fuzzforge-modules/fuzzforge-modules-sdk/src/fuzzforge_modules_sdk/templates/fuzzforge-module-template/README.md deleted file mode 100644 index d0671a1..0000000 --- a/fuzzforge-modules/fuzzforge-modules-sdk/src/fuzzforge_modules_sdk/templates/fuzzforge-module-template/README.md +++ /dev/null @@ -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" \ - ':' '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. diff --git a/fuzzforge-modules/fuzzforge-modules-sdk/src/fuzzforge_modules_sdk/templates/fuzzforge-module-template/mypy.ini b/fuzzforge-modules/fuzzforge-modules-sdk/src/fuzzforge_modules_sdk/templates/fuzzforge-module-template/mypy.ini deleted file mode 100644 index 84e90d2..0000000 --- a/fuzzforge-modules/fuzzforge-modules-sdk/src/fuzzforge_modules_sdk/templates/fuzzforge-module-template/mypy.ini +++ /dev/null @@ -1,6 +0,0 @@ -[mypy] -plugins = pydantic.mypy -strict = True -warn_unused_ignores = True -warn_redundant_casts = True -warn_return_any = True diff --git a/fuzzforge-modules/fuzzforge-modules-sdk/src/fuzzforge_modules_sdk/templates/fuzzforge-module-template/pyproject.toml b/fuzzforge-modules/fuzzforge-modules-sdk/src/fuzzforge_modules_sdk/templates/fuzzforge-module-template/pyproject.toml deleted file mode 100644 index 086e7b2..0000000 --- a/fuzzforge-modules/fuzzforge-modules-sdk/src/fuzzforge_modules_sdk/templates/fuzzforge-module-template/pyproject.toml +++ /dev/null @@ -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" diff --git a/fuzzforge-modules/fuzzforge-modules-sdk/src/fuzzforge_modules_sdk/templates/fuzzforge-module-template/ruff.toml b/fuzzforge-modules/fuzzforge-modules-sdk/src/fuzzforge_modules_sdk/templates/fuzzforge-module-template/ruff.toml deleted file mode 100644 index 6374f62..0000000 --- a/fuzzforge-modules/fuzzforge-modules-sdk/src/fuzzforge_modules_sdk/templates/fuzzforge-module-template/ruff.toml +++ /dev/null @@ -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 -] diff --git a/fuzzforge-modules/fuzzforge-modules-sdk/src/fuzzforge_modules_sdk/templates/fuzzforge-module-template/src/module/__init__.py b/fuzzforge-modules/fuzzforge-modules-sdk/src/fuzzforge_modules_sdk/templates/fuzzforge-module-template/src/module/__init__.py deleted file mode 100644 index e69de29..0000000 diff --git a/fuzzforge-modules/fuzzforge-modules-sdk/src/fuzzforge_modules_sdk/templates/fuzzforge-module-template/src/module/__main__.py b/fuzzforge-modules/fuzzforge-modules-sdk/src/fuzzforge_modules_sdk/templates/fuzzforge-module-template/src/module/__main__.py deleted file mode 100644 index bc8914a..0000000 --- a/fuzzforge-modules/fuzzforge-modules-sdk/src/fuzzforge_modules_sdk/templates/fuzzforge-module-template/src/module/__main__.py +++ /dev/null @@ -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() diff --git a/fuzzforge-modules/fuzzforge-modules-sdk/src/fuzzforge_modules_sdk/templates/fuzzforge-module-template/src/module/mod.py b/fuzzforge-modules/fuzzforge-modules-sdk/src/fuzzforge_modules_sdk/templates/fuzzforge-module-template/src/module/mod.py deleted file mode 100644 index f0f85e9..0000000 --- a/fuzzforge-modules/fuzzforge-modules-sdk/src/fuzzforge_modules_sdk/templates/fuzzforge-module-template/src/module/mod.py +++ /dev/null @@ -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. - - """ diff --git a/fuzzforge-modules/fuzzforge-modules-sdk/src/fuzzforge_modules_sdk/templates/fuzzforge-module-template/src/module/models.py b/fuzzforge-modules/fuzzforge-modules-sdk/src/fuzzforge_modules_sdk/templates/fuzzforge-module-template/src/module/models.py deleted file mode 100644 index 2a3f021..0000000 --- a/fuzzforge-modules/fuzzforge-modules-sdk/src/fuzzforge_modules_sdk/templates/fuzzforge-module-template/src/module/models.py +++ /dev/null @@ -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.""" diff --git a/fuzzforge-modules/fuzzforge-modules-sdk/src/fuzzforge_modules_sdk/templates/fuzzforge-module-template/src/module/settings.py b/fuzzforge-modules/fuzzforge-modules-sdk/src/fuzzforge_modules_sdk/templates/fuzzforge-module-template/src/module/settings.py deleted file mode 100644 index f916ad4..0000000 --- a/fuzzforge-modules/fuzzforge-modules-sdk/src/fuzzforge_modules_sdk/templates/fuzzforge-module-template/src/module/settings.py +++ /dev/null @@ -1,7 +0,0 @@ -from fuzzforge_modules_sdk.api.models import FuzzForgeModulesSettingsBase - - -class Settings(FuzzForgeModulesSettingsBase): - """TODO.""" - - # Here goes your attributes diff --git a/fuzzforge-modules/fuzzforge-modules-sdk/src/fuzzforge_modules_sdk/templates/fuzzforge-module-template/tests/.gitkeep b/fuzzforge-modules/fuzzforge-modules-sdk/src/fuzzforge_modules_sdk/templates/fuzzforge-module-template/tests/.gitkeep deleted file mode 100644 index e69de29..0000000 diff --git a/fuzzforge-modules/fuzzforge-modules-sdk/tests/.gitkeep b/fuzzforge-modules/fuzzforge-modules-sdk/tests/.gitkeep deleted file mode 100644 index e69de29..0000000 diff --git a/fuzzforge-modules/harness-tester/Dockerfile b/fuzzforge-modules/harness-tester/Dockerfile deleted file mode 100644 index b960d5b..0000000 --- a/fuzzforge-modules/harness-tester/Dockerfile +++ /dev/null @@ -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 diff --git a/fuzzforge-modules/harness-tester/FEEDBACK_TYPES.md b/fuzzforge-modules/harness-tester/FEEDBACK_TYPES.md deleted file mode 100644 index f964dc7..0000000 --- a/fuzzforge-modules/harness-tester/FEEDBACK_TYPES.md +++ /dev/null @@ -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. diff --git a/fuzzforge-modules/harness-tester/Makefile b/fuzzforge-modules/harness-tester/Makefile deleted file mode 100644 index a28ba9c..0000000 --- a/fuzzforge-modules/harness-tester/Makefile +++ /dev/null @@ -1,28 +0,0 @@ -.PHONY: help build clean format lint test - -help: - @echo "Available targets:" - @echo " build - Build Docker image" - @echo " clean - Remove build artifacts" - @echo " format - Format code with ruff" - @echo " lint - Lint code with ruff and mypy" - @echo " test - Run tests" - -build: - docker build -t fuzzforge-harness-tester:0.1.0 . - -clean: - rm -rf .pytest_cache - rm -rf .mypy_cache - rm -rf .ruff_cache - find . -type d -name __pycache__ -exec rm -rf {} + - -format: - uv run ruff format ./src ./tests - -lint: - uv run ruff check ./src ./tests - uv run mypy ./src - -test: - uv run pytest tests/ -v diff --git a/fuzzforge-modules/harness-tester/README.md b/fuzzforge-modules/harness-tester/README.md deleted file mode 100644 index 130bc84..0000000 --- a/fuzzforge-modules/harness-tester/README.md +++ /dev/null @@ -1,155 +0,0 @@ -# Harness Tester Module - -Tests and evaluates fuzz harnesses with comprehensive feedback for AI-driven iteration. - -## Overview - -The `harness-tester` module runs a battery of tests on fuzz harnesses to provide actionable feedback: - -1. **Compilation Testing** - Validates harness compiles correctly -2. **Execution Testing** - Ensures harness runs without immediate crashes -3. **Fuzzing Trial** - Runs short fuzzing session (default: 30s) to measure: - - Coverage growth - - Execution performance (execs/sec) - - Stability (crashes, hangs) -4. **Quality Assessment** - Generates scored evaluation with specific issues and suggestions - -## Feedback Categories - -### 1. Compilation Feedback -- Undefined variables → "Check variable names match function signature" -- Type mismatches → "Convert fuzzer input to correct type" -- Missing traits → "Ensure you're using correct types" - -### 2. Execution Feedback -- Stack overflow → "Check for infinite recursion, use heap allocation" -- Immediate panic → "Check initialization code and input validation" -- Timeout/infinite loop → "Add iteration limits" - -### 3. Coverage Feedback -- No coverage → "Harness may not be using fuzzer input" -- Very low coverage (<5%) → "May not be reaching target code, check entry point" -- Low coverage (<20%) → "Try fuzzing multiple entry points" -- Good/Excellent coverage → "Harness is exploring code paths well" - -### 4. Performance Feedback -- Extremely slow (<10 execs/s) → "Remove file I/O or network operations" -- Slow (<100 execs/s) → "Optimize harness, avoid allocations in hot path" -- Good (>500 execs/s) → Ready for production -- Excellent (>1000 execs/s) → Optimal performance - -### 5. Stability Feedback -- Frequent crashes → "Add error handling for edge cases" -- Hangs detected → "Add timeouts to prevent infinite loops" -- Stable → Ready for production - -## Usage - -```python -# Via MCP -result = execute_module("harness-tester", - assets_path="/path/to/rust/project", - configuration={ - "trial_duration_sec": 30, - "execution_timeout_sec": 10 - }) -``` - -## Input Requirements - -- Rust project with `Cargo.toml` -- Fuzz harnesses in `fuzz/fuzz_targets/` -- Source code to analyze - -## Output Artifacts - -### `harness-evaluation.json` -Complete structured evaluation with: -```json -{ - "harnesses": [ - { - "name": "fuzz_png_decode", - "compilation": { "success": true, "time_ms": 4523 }, - "execution": { "success": true }, - "fuzzing_trial": { - "coverage": { - "final_edges": 891, - "growth_rate": "good", - "percentage_estimate": 67.0 - }, - "performance": { - "execs_per_sec": 1507.0, - "performance_rating": "excellent" - }, - "stability": { "status": "stable" } - }, - "quality": { - "score": 85, - "verdict": "production-ready", - "issues": [], - "strengths": ["Excellent performance", "Good coverage"], - "recommended_actions": ["Ready for production fuzzing"] - } - } - ], - "summary": { - "total_harnesses": 1, - "production_ready": 1, - "average_score": 85.0 - } -} -``` - -### `feedback-summary.md` -Human-readable summary with all issues and suggestions. - -## Quality Scoring - -Harnesses are scored 0-100 based on: - -- **Compilation** (20 points): Must compile to proceed -- **Execution** (20 points): Must run without crashing -- **Coverage** (40 points): - - Excellent growth: 40 pts - - Good growth: 30 pts - - Poor growth: 10 pts -- **Performance** (25 points): - - >1000 execs/s: 25 pts - - >500 execs/s: 20 pts - - >100 execs/s: 10 pts -- **Stability** (15 points): - - Stable: 15 pts - - Unstable: 10 pts - - Crashes frequently: 5 pts - -**Verdicts:** -- 70-100: `production-ready` -- 30-69: `needs-improvement` -- 0-29: `broken` - -## AI Agent Iteration Pattern - -``` -1. AI generates harness -2. harness-tester evaluates it -3. Returns: score=35, verdict="needs-improvement" - Issues: "Low coverage (8%), slow execution (7.8 execs/s)" - Suggestions: "Check entry point function, remove I/O operations" -4. AI fixes harness based on feedback -5. harness-tester re-evaluates -6. Returns: score=85, verdict="production-ready" -7. Proceed to production fuzzing -``` - -## Configuration Options - -| Option | Default | Description | -|--------|---------|-------------| -| `trial_duration_sec` | 30 | How long to run fuzzing trial | -| `execution_timeout_sec` | 10 | Timeout for execution test | - -## See Also - -- [Module SDK Documentation](../fuzzforge-modules-sdk/README.md) -- [MODULE_METADATA.md](../MODULE_METADATA.md) diff --git a/fuzzforge-modules/harness-tester/mypy.ini b/fuzzforge-modules/harness-tester/mypy.ini deleted file mode 100644 index 84e90d2..0000000 --- a/fuzzforge-modules/harness-tester/mypy.ini +++ /dev/null @@ -1,6 +0,0 @@ -[mypy] -plugins = pydantic.mypy -strict = True -warn_unused_ignores = True -warn_redundant_casts = True -warn_return_any = True diff --git a/fuzzforge-modules/harness-tester/pyproject.toml b/fuzzforge-modules/harness-tester/pyproject.toml deleted file mode 100644 index 078eff9..0000000 --- a/fuzzforge-modules/harness-tester/pyproject.toml +++ /dev/null @@ -1,60 +0,0 @@ -[project] -name = "fuzzforge-harness-tester" -version = "0.1.0" -description = "Tests and evaluates fuzz harnesses with detailed feedback for AI-driven iteration" -readme = "README.md" -requires-python = ">=3.14" -dependencies = [ - "fuzzforge-modules-sdk==0.0.1", - "pydantic==2.12.4", - "structlog==25.5.0", -] - -[project.scripts] -module = "module.__main__:main" - -[tool.uv.sources] -fuzzforge-modules-sdk = { workspace = true } - -[build-system] -requires = ["hatchling"] -build-backend = "hatchling.build" - -[tool.hatch.build.targets.wheel] -packages = ["src/module"] - -[dependency-groups] -dev = [ - "mypy>=1.8.0", - "pytest>=7.4.3", - "pytest-asyncio>=0.21.1", - "pytest-cov>=4.1.0", - "ruff>=0.1.9", -] - -# FuzzForge module metadata for AI agent discovery -[tool.fuzzforge.module] -identifier = "fuzzforge-harness-tester" -suggested_predecessors = ["fuzzforge-rust-analyzer"] -continuous_mode = false - -use_cases = [ - "Validate Rust fuzz harnesses compile correctly", - "Run short fuzzing trials to assess harness quality", - "Provide detailed feedback for AI to improve harnesses", - "Gate before running expensive long fuzzing campaigns" -] - -common_inputs = [ - "fuzz-harnesses", - "Cargo.toml", - "rust-source-code" -] - -output_artifacts = [ - "artifacts/harness-evaluation.json", - "artifacts/feedback-summary.md", - "results.json" -] - -output_treatment = "Display artifacts/feedback-summary.md as rendered markdown for quick review. Read artifacts/harness-evaluation.json for detailed per-harness results with verdict (production_ready/needs_improvement/broken), score, strengths, and issues with suggestions." diff --git a/fuzzforge-modules/harness-tester/ruff.toml b/fuzzforge-modules/harness-tester/ruff.toml deleted file mode 100644 index 6374f62..0000000 --- a/fuzzforge-modules/harness-tester/ruff.toml +++ /dev/null @@ -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 -] diff --git a/fuzzforge-modules/harness-tester/src/module/__init__.py b/fuzzforge-modules/harness-tester/src/module/__init__.py deleted file mode 100644 index 77e4a87..0000000 --- a/fuzzforge-modules/harness-tester/src/module/__init__.py +++ /dev/null @@ -1,730 +0,0 @@ -"""Harness tester module - tests and evaluates fuzz harnesses.""" - -from __future__ import annotations - -import json -import subprocess -import time -from pathlib import Path -from typing import TYPE_CHECKING, Any - -from fuzzforge_modules_sdk.api.models import ( - FuzzForgeModuleResource, - FuzzForgeModuleResults, -) -from fuzzforge_modules_sdk.api.modules.base import FuzzForgeModule - -from module.analyzer import FeedbackGenerator -from module.feedback import ( - CompilationResult, - CoverageMetrics, - EvaluationSummary, - ExecutionResult, - FuzzingTrial, - HarnessEvaluation, - HarnessTestReport, - PerformanceMetrics, - StabilityMetrics, -) -from module.models import Input, Output -from module.settings import Settings - - -class HarnessTesterModule(FuzzForgeModule): - """Tests fuzz harnesses with compilation, execution, and short fuzzing trials.""" - - _settings: Settings | None - - def __init__(self) -> None: - """Initialize an instance of the class.""" - name: str = "harness-tester" - version: str = "0.1.0" - FuzzForgeModule.__init__(self, name=name, version=version) - self._settings = None - self.configuration: dict[str, Any] = {} - - @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 - self.configuration = { - "trial_duration_sec": settings.trial_duration_sec, - "execution_timeout_sec": settings.execution_timeout_sec, - "enable_coverage": settings.enable_coverage, - "min_quality_score": settings.min_quality_score, - } - - def _cleanup(self, settings: Settings) -> None: # type: ignore[override] - """Cleanup after module execution. - - :param settings: Module settings. - - """ - pass # No cleanup needed - - def _run(self, resources: list[FuzzForgeModuleResource]) -> FuzzForgeModuleResults: - """Run harness testing on provided resources. - - :param resources: List of resources (Rust project with fuzz harnesses) - :returns: Module execution result - """ - import shutil - - self.emit_event("started", message="Beginning harness testing") - - # Configuration - trial_duration = self.configuration.get("trial_duration_sec", 30) - timeout_sec = self.configuration.get("execution_timeout_sec", 10) - - # Debug: Log resources - self.get_logger().info( - "Received resources", - count=len(resources), - resources=[str(r.path) for r in resources], - ) - - # Find Rust project - project_path = self._find_rust_project(resources) - if not project_path: - self.emit_event("error", message="No Rust project found in resources") - return FuzzForgeModuleResults.FAILURE - - # Copy project to writable workspace (input is read-only) - workspace = Path("/tmp/harness-workspace") - if workspace.exists(): - shutil.rmtree(workspace) - shutil.copytree(project_path, workspace) - project_path = workspace - - self.get_logger().info("Copied project to writable workspace", path=str(project_path)) - - # Find fuzz harnesses - harnesses = self._find_fuzz_harnesses(project_path) - - # Debug: Log fuzz directory status - fuzz_dir = project_path / "fuzz" / "fuzz_targets" - self.get_logger().info( - "Checking fuzz directory", - fuzz_dir=str(fuzz_dir), - exists=fuzz_dir.exists(), - ) - - if not harnesses: - self.emit_event("error", message="No fuzz harnesses found") - return FuzzForgeModuleResults.FAILURE - - self.emit_event( - "found_harnesses", - count=len(harnesses), - harnesses=[h.name for h in harnesses], - ) - - # Test each harness - evaluations = [] - total_harnesses = len(harnesses) - - for idx, harness in enumerate(harnesses, 1): - self.emit_progress( - int((idx / total_harnesses) * 90), - status="testing", - message=f"Testing harness {idx}/{total_harnesses}: {harness.name}", - ) - - evaluation = self._test_harness( - project_path, harness, trial_duration, timeout_sec - ) - evaluations.append(evaluation) - - # Emit evaluation summary - self.emit_event( - "harness_tested", - harness=harness.name, - verdict=evaluation.quality.verdict, - score=evaluation.quality.score, - issues=len(evaluation.quality.issues), - ) - - # Generate summary - summary = self._generate_summary(evaluations) - - # Create report - report = HarnessTestReport( - harnesses=evaluations, - summary=summary, - test_configuration={ - "trial_duration_sec": trial_duration, - "execution_timeout_sec": timeout_sec, - }, - ) - - # Save report - self._save_report(report) - - self.emit_progress(100, status="completed", message="Harness testing complete") - self.emit_event( - "completed", - total_harnesses=total_harnesses, - production_ready=summary.production_ready, - needs_improvement=summary.needs_improvement, - broken=summary.broken, - ) - - return FuzzForgeModuleResults.SUCCESS - - def _find_rust_project(self, resources: list[FuzzForgeModuleResource]) -> Path | None: - """Find Rust project with Cargo.toml (the main project, not fuzz workspace). - - :param resources: List of resources - :returns: Path to Rust project or None - """ - # First, try to find a directory with both Cargo.toml and src/ - for resource in resources: - path = Path(resource.path) - cargo_toml = path / "Cargo.toml" - src_dir = path / "src" - if cargo_toml.exists() and src_dir.exists(): - return path - - # Fall back to finding parent of fuzz directory - for resource in resources: - path = Path(resource.path) - if path.name == "fuzz" and (path / "Cargo.toml").exists(): - # This is the fuzz workspace, return parent - parent = path.parent - if (parent / "Cargo.toml").exists(): - return parent - - # Last resort: find any Cargo.toml - for resource in resources: - path = Path(resource.path) - cargo_toml = path / "Cargo.toml" - if cargo_toml.exists(): - return path - return None - - def _find_fuzz_harnesses(self, project_path: Path) -> list[Path]: - """Find fuzz harnesses in project. - - :param project_path: Path to Rust project - :returns: List of harness file paths - """ - fuzz_dir = project_path / "fuzz" / "fuzz_targets" - if not fuzz_dir.exists(): - return [] - - harnesses = list(fuzz_dir.glob("*.rs")) - return harnesses - - def _test_harness( - self, - project_path: Path, - harness_path: Path, - trial_duration: int, - timeout_sec: int, - ) -> HarnessEvaluation: - """Test a single harness comprehensively. - - :param project_path: Path to Rust project - :param harness_path: Path to harness file - :param trial_duration: Duration for fuzzing trial in seconds - :param timeout_sec: Timeout for execution test - :returns: Harness evaluation - """ - harness_name = harness_path.stem - - # Step 1: Compilation - self.emit_event("compiling", harness=harness_name) - compilation = self._test_compilation(project_path, harness_name) - - # If compilation failed, generate feedback and return early - if not compilation.success: - quality = FeedbackGenerator.generate_quality_assessment( - compilation_result=compilation.model_dump(), - execution_result=None, - coverage=None, - performance=None, - stability=None, - ) - return HarnessEvaluation( - name=harness_name, - path=str(harness_path), - compilation=compilation, - execution=None, - fuzzing_trial=None, - quality=quality, - ) - - # Step 2: Execution test - self.emit_event("testing_execution", harness=harness_name) - execution = self._test_execution(project_path, harness_name, timeout_sec) - - if not execution.success: - quality = FeedbackGenerator.generate_quality_assessment( - compilation_result=compilation.model_dump(), - execution_result=execution.model_dump(), - coverage=None, - performance=None, - stability=None, - ) - return HarnessEvaluation( - name=harness_name, - path=str(harness_path), - compilation=compilation, - execution=execution, - fuzzing_trial=None, - quality=quality, - ) - - # Step 3: Fuzzing trial - self.emit_event("running_trial", harness=harness_name, duration=trial_duration) - fuzzing_trial = self._run_fuzzing_trial( - project_path, harness_name, trial_duration - ) - - # Generate quality assessment - quality = FeedbackGenerator.generate_quality_assessment( - compilation_result=compilation.model_dump(), - execution_result=execution.model_dump(), - coverage=fuzzing_trial.coverage if fuzzing_trial else None, - performance=fuzzing_trial.performance if fuzzing_trial else None, - stability=fuzzing_trial.stability if fuzzing_trial else None, - ) - - return HarnessEvaluation( - name=harness_name, - path=str(harness_path), - compilation=compilation, - execution=execution, - fuzzing_trial=fuzzing_trial, - quality=quality, - ) - - def _test_compilation(self, project_path: Path, harness_name: str) -> CompilationResult: - """Test harness compilation. - - :param project_path: Path to Rust project - :param harness_name: Name of harness to compile - :returns: Compilation result - """ - start_time = time.time() - - try: - result = subprocess.run( - ["cargo", "fuzz", "build", harness_name], - cwd=project_path, - capture_output=True, - text=True, - timeout=300, # 5 min timeout for compilation - ) - - compilation_time = int((time.time() - start_time) * 1000) - - if result.returncode == 0: - # Parse warnings - warnings = self._parse_compiler_warnings(result.stderr) - return CompilationResult( - success=True, time_ms=compilation_time, warnings=warnings - ) - else: - # Parse errors - errors = self._parse_compiler_errors(result.stderr) - return CompilationResult( - success=False, - time_ms=compilation_time, - errors=errors, - stderr=result.stderr, - ) - - except subprocess.TimeoutExpired: - return CompilationResult( - success=False, - errors=["Compilation timed out after 5 minutes"], - stderr="Timeout", - ) - except Exception as e: - return CompilationResult( - success=False, errors=[f"Compilation failed: {e!s}"], stderr=str(e) - ) - - def _test_execution( - self, project_path: Path, harness_name: str, timeout_sec: int - ) -> ExecutionResult: - """Test harness execution with minimal input. - - :param project_path: Path to Rust project - :param harness_name: Name of harness - :param timeout_sec: Timeout for execution - :returns: Execution result - """ - try: - # Run with very short timeout and max runs - result = subprocess.run( - [ - "cargo", - "fuzz", - "run", - harness_name, - "--", - "-runs=10", - f"-max_total_time={timeout_sec}", - ], - cwd=project_path, - capture_output=True, - text=True, - timeout=timeout_sec + 5, - ) - - # Check if it crashed immediately - if "SUMMARY: libFuzzer: deadly signal" in result.stderr: - return ExecutionResult( - success=False, - immediate_crash=True, - crash_details=self._extract_crash_info(result.stderr), - ) - - # Success if completed runs - return ExecutionResult(success=True, runs_completed=10) - - except subprocess.TimeoutExpired: - return ExecutionResult(success=False, timeout=True) - except Exception as e: - return ExecutionResult( - success=False, immediate_crash=True, crash_details=str(e) - ) - - def _run_fuzzing_trial( - self, project_path: Path, harness_name: str, duration_sec: int - ) -> FuzzingTrial | None: - """Run short fuzzing trial to gather metrics. - - :param project_path: Path to Rust project - :param harness_name: Name of harness - :param duration_sec: Duration to run fuzzing - :returns: Fuzzing trial results or None if failed - """ - try: - result = subprocess.run( - [ - "cargo", - "fuzz", - "run", - harness_name, - "--", - f"-max_total_time={duration_sec}", - "-print_final_stats=1", - ], - cwd=project_path, - capture_output=True, - text=True, - timeout=duration_sec + 30, - ) - - # Parse fuzzing statistics - stats = self._parse_fuzzing_stats(result.stderr) - - # Create metrics - coverage = CoverageMetrics( - initial_edges=stats.get("initial_edges", 0), - final_edges=stats.get("cov_edges", 0), - new_edges_found=stats.get("cov_edges", 0) - stats.get("initial_edges", 0), - growth_rate=self._assess_coverage_growth(stats), - percentage_estimate=self._estimate_coverage_percentage(stats), - stagnation_time_sec=stats.get("stagnation_time"), - ) - - performance = PerformanceMetrics( - total_execs=stats.get("total_execs", 0), - execs_per_sec=stats.get("exec_per_sec", 0.0), - performance_rating=self._assess_performance(stats.get("exec_per_sec", 0.0)), - ) - - stability = StabilityMetrics( - status=self._assess_stability(stats), - crashes_found=stats.get("crashes", 0), - unique_crashes=stats.get("unique_crashes", 0), - crash_rate=self._calculate_crash_rate(stats), - ) - - return FuzzingTrial( - duration_seconds=duration_sec, - coverage=coverage, - performance=performance, - stability=stability, - trial_successful=True, - ) - - except Exception: - return None - - def _parse_compiler_errors(self, stderr: str) -> list[str]: - """Parse compiler error messages. - - :param stderr: Compiler stderr output - :returns: List of error messages - """ - errors = [] - for line in stderr.split("\n"): - if "error:" in line or "error[" in line: - errors.append(line.strip()) - return errors[:10] # Limit to first 10 errors - - def _parse_compiler_warnings(self, stderr: str) -> list[str]: - """Parse compiler warnings. - - :param stderr: Compiler stderr output - :returns: List of warning messages - """ - warnings = [] - for line in stderr.split("\n"): - if "warning:" in line: - warnings.append(line.strip()) - return warnings[:5] # Limit to first 5 warnings - - def _extract_crash_info(self, stderr: str) -> str: - """Extract crash information from stderr. - - :param stderr: Fuzzer stderr output - :returns: Crash details - """ - lines = stderr.split("\n") - for i, line in enumerate(lines): - if "SUMMARY:" in line or "deadly signal" in line: - return "\n".join(lines[max(0, i - 3) : i + 5]) - return stderr[:500] # First 500 chars if no specific crash info - - def _parse_fuzzing_stats(self, stderr: str) -> dict: - """Parse fuzzing statistics from libFuzzer output. - - :param stderr: Fuzzer stderr output - :returns: Dictionary of statistics - """ - stats = { - "total_execs": 0, - "exec_per_sec": 0.0, - "cov_edges": 0, - "initial_edges": 0, - "crashes": 0, - "unique_crashes": 0, - } - - lines = stderr.split("\n") - - # Find initial coverage - for line in lines[:20]: - if "cov:" in line: - try: - cov_part = line.split("cov:")[1].split()[0] - stats["initial_edges"] = int(cov_part) - break - except (IndexError, ValueError): - pass - - # Parse final stats - for line in reversed(lines): - if "#" in line and "cov:" in line and "exec/s:" in line: - try: - # Parse line like: "#12345 cov: 891 ft: 1234 corp: 56/789b exec/s: 1507" - parts = line.split() - for i, part in enumerate(parts): - if part.startswith("#"): - stats["total_execs"] = int(part[1:]) - elif part == "cov:": - stats["cov_edges"] = int(parts[i + 1]) - elif part == "exec/s:": - stats["exec_per_sec"] = float(parts[i + 1]) - except (IndexError, ValueError): - pass - - # Count crashes - if "crash-" in line or "leak-" in line or "timeout-" in line: - stats["crashes"] += 1 - - # Estimate unique crashes (simplified) - stats["unique_crashes"] = min(stats["crashes"], 10) - - return stats - - def _assess_coverage_growth(self, stats: dict) -> str: - """Assess coverage growth quality. - - :param stats: Fuzzing statistics - :returns: Growth rate assessment - """ - new_edges = stats.get("cov_edges", 0) - stats.get("initial_edges", 0) - - if new_edges == 0: - return "none" - elif new_edges < 50: - return "poor" - elif new_edges < 200: - return "good" - else: - return "excellent" - - def _estimate_coverage_percentage(self, stats: dict) -> float | None: - """Estimate coverage percentage (rough heuristic). - - :param stats: Fuzzing statistics - :returns: Estimated percentage or None - """ - edges = stats.get("cov_edges", 0) - if edges == 0: - return 0.0 - - # Rough heuristic: assume medium-sized function has ~2000 edges - # This is very approximate - estimated = min((edges / 2000) * 100, 100) - return round(estimated, 1) - - def _assess_performance(self, execs_per_sec: float) -> str: - """Assess performance rating. - - :param execs_per_sec: Executions per second - :returns: Performance rating - """ - if execs_per_sec > 1000: - return "excellent" - elif execs_per_sec > 100: - return "good" - else: - return "poor" - - def _assess_stability(self, stats: dict) -> str: - """Assess stability status. - - :param stats: Fuzzing statistics - :returns: Stability status - """ - crashes = stats.get("crashes", 0) - total_execs = stats.get("total_execs", 0) - - if total_execs == 0: - return "unknown" - - crash_rate = (crashes / total_execs) * 1000 - - if crash_rate > 10: - return "crashes_frequently" - elif crash_rate > 1: - return "unstable" - else: - return "stable" - - def _calculate_crash_rate(self, stats: dict) -> float: - """Calculate crash rate per 1000 executions. - - :param stats: Fuzzing statistics - :returns: Crash rate - """ - crashes = stats.get("crashes", 0) - total = stats.get("total_execs", 0) - - if total == 0: - return 0.0 - - return (crashes / total) * 1000 - - def _generate_summary(self, evaluations: list[HarnessEvaluation]) -> EvaluationSummary: - """Generate evaluation summary. - - :param evaluations: List of harness evaluations - :returns: Summary statistics - """ - production_ready = sum( - 1 for e in evaluations if e.quality.verdict == "production-ready" - ) - needs_improvement = sum( - 1 for e in evaluations if e.quality.verdict == "needs-improvement" - ) - broken = sum(1 for e in evaluations if e.quality.verdict == "broken") - - avg_score = ( - sum(e.quality.score for e in evaluations) / len(evaluations) - if evaluations - else 0 - ) - - # Generate recommendation - if broken > 0: - recommended_action = f"Fix {broken} broken harness(es) before proceeding." - elif needs_improvement > 0: - recommended_action = f"Improve {needs_improvement} harness(es) for better results." - else: - recommended_action = "All harnesses are production-ready!" - - return EvaluationSummary( - total_harnesses=len(evaluations), - production_ready=production_ready, - needs_improvement=needs_improvement, - broken=broken, - average_score=round(avg_score, 1), - recommended_action=recommended_action, - ) - - def _save_report(self, report: HarnessTestReport) -> None: - """Save test report to results directory. - - :param report: Harness test report - """ - from fuzzforge_modules_sdk.api.constants import PATH_TO_ARTIFACTS - - # Ensure artifacts directory exists - PATH_TO_ARTIFACTS.mkdir(parents=True, exist_ok=True) - - # Save JSON report - results_path = PATH_TO_ARTIFACTS / "harness-evaluation.json" - with results_path.open("w") as f: - json.dump(report.model_dump(), f, indent=2) - - # Save human-readable summary - summary_path = PATH_TO_ARTIFACTS / "feedback-summary.md" - with summary_path.open("w") as f: - f.write("# Harness Testing Report\n\n") - f.write(f"**Total Harnesses:** {report.summary.total_harnesses}\n") - f.write(f"**Production Ready:** {report.summary.production_ready}\n") - f.write(f"**Needs Improvement:** {report.summary.needs_improvement}\n") - f.write(f"**Broken:** {report.summary.broken}\n") - f.write(f"**Average Score:** {report.summary.average_score}/100\n\n") - f.write(f"**Recommendation:** {report.summary.recommended_action}\n\n") - - f.write("## Individual Harness Results\n\n") - for harness in report.harnesses: - f.write(f"### {harness.name}\n\n") - f.write(f"- **Verdict:** {harness.quality.verdict}\n") - f.write(f"- **Score:** {harness.quality.score}/100\n\n") - - if harness.quality.strengths: - f.write("**Strengths:**\n") - for strength in harness.quality.strengths: - f.write(f"- {strength}\n") - f.write("\n") - - if harness.quality.issues: - f.write("**Issues:**\n") - for issue in harness.quality.issues: - f.write(f"- [{issue.severity.upper()}] {issue.message}\n") - f.write(f" - **Suggestion:** {issue.suggestion}\n") - f.write("\n") - - if harness.quality.recommended_actions: - f.write("**Actions:**\n") - for action in harness.quality.recommended_actions: - f.write(f"- {action}\n") - f.write("\n") - - -# Export the module class for use by __main__.py -__all__ = ["HarnessTesterModule"] diff --git a/fuzzforge-modules/harness-tester/src/module/__main__.py b/fuzzforge-modules/harness-tester/src/module/__main__.py deleted file mode 100644 index dc334b1..0000000 --- a/fuzzforge-modules/harness-tester/src/module/__main__.py +++ /dev/null @@ -1,16 +0,0 @@ -"""Harness tester module entrypoint.""" - -from fuzzforge_modules_sdk.api import logs - -from module import HarnessTesterModule - - -def main() -> None: - """Run the harness tester module.""" - logs.configure() - module = HarnessTesterModule() - module.main() - - -if __name__ == "__main__": - main() diff --git a/fuzzforge-modules/harness-tester/src/module/analyzer.py b/fuzzforge-modules/harness-tester/src/module/analyzer.py deleted file mode 100644 index ec25fb5..0000000 --- a/fuzzforge-modules/harness-tester/src/module/analyzer.py +++ /dev/null @@ -1,486 +0,0 @@ -"""Feedback generator with actionable suggestions for AI agents.""" - -from module.feedback import ( - CoverageMetrics, - FeedbackCategory, - FeedbackIssue, - FeedbackSeverity, - PerformanceMetrics, - QualityAssessment, - StabilityMetrics, -) - - -class FeedbackGenerator: - """Generates actionable feedback based on harness test results.""" - - @staticmethod - def analyze_compilation( - compilation_result: dict, - ) -> tuple[list[FeedbackIssue], list[str]]: - """Analyze compilation results and generate feedback. - - :param compilation_result: Compilation output and errors - :returns: Tuple of (issues, strengths) - """ - issues = [] - strengths = [] - - if not compilation_result.get("success"): - errors = compilation_result.get("errors", []) - - for error in errors: - # Analyze specific error types - if "cannot find" in error.lower(): - issues.append( - FeedbackIssue( - category=FeedbackCategory.COMPILATION, - severity=FeedbackSeverity.CRITICAL, - type="undefined_variable", - message=f"Compilation error: {error}", - suggestion="Check variable names match the function signature. Use the exact names from fuzzable_functions.json.", - details={"error": error}, - ) - ) - elif "mismatched types" in error.lower(): - issues.append( - FeedbackIssue( - category=FeedbackCategory.COMPILATION, - severity=FeedbackSeverity.CRITICAL, - type="type_mismatch", - message=f"Type mismatch: {error}", - suggestion="Check the function expects the types you're passing. Convert fuzzer input to the correct type (e.g., &[u8] to &str with from_utf8).", - details={"error": error}, - ) - ) - elif "trait" in error.lower() and "not implemented" in error.lower(): - issues.append( - FeedbackIssue( - category=FeedbackCategory.COMPILATION, - severity=FeedbackSeverity.CRITICAL, - type="trait_not_implemented", - message=f"Trait not implemented: {error}", - suggestion="Ensure you're using the correct types. Some functions require specific trait implementations.", - details={"error": error}, - ) - ) - else: - issues.append( - FeedbackIssue( - category=FeedbackCategory.COMPILATION, - severity=FeedbackSeverity.CRITICAL, - type="compilation_error", - message=f"Compilation failed: {error}", - suggestion="Review the error message and fix syntax/type issues. Check function signatures in the source code.", - details={"error": error}, - ) - ) - else: - strengths.append("Compiles successfully") - - # Check for warnings - warnings = compilation_result.get("warnings", []) - if warnings: - for warning in warnings[:3]: # Limit to 3 most important - if "unused" in warning.lower(): - issues.append( - FeedbackIssue( - category=FeedbackCategory.CODE_QUALITY, - severity=FeedbackSeverity.INFO, - type="unused_variable", - message=f"Code quality: {warning}", - suggestion="Remove unused variables or use underscore prefix (_variable) to suppress warning.", - details={"warning": warning}, - ) - ) - - return issues, strengths - - @staticmethod - def analyze_execution( - execution_result: dict, - ) -> tuple[list[FeedbackIssue], list[str]]: - """Analyze execution results. - - :param execution_result: Execution test results - :returns: Tuple of (issues, strengths) - """ - issues = [] - strengths = [] - - if not execution_result.get("success"): - if execution_result.get("immediate_crash"): - crash_details = execution_result.get("crash_details", "") - - if "stack overflow" in crash_details.lower(): - issues.append( - FeedbackIssue( - category=FeedbackCategory.EXECUTION, - severity=FeedbackSeverity.CRITICAL, - type="stack_overflow", - message="Harness crashes immediately with stack overflow", - suggestion="Check for infinite recursion or large stack allocations. Use heap allocation (Box, Vec) for large data structures.", - details={"crash": crash_details}, - ) - ) - elif "panic" in crash_details.lower(): - issues.append( - FeedbackIssue( - category=FeedbackCategory.EXECUTION, - severity=FeedbackSeverity.CRITICAL, - type="panic_on_start", - message="Harness panics immediately", - suggestion="Check initialization code. Ensure required resources are available and input validation doesn't panic on empty input.", - details={"crash": crash_details}, - ) - ) - else: - issues.append( - FeedbackIssue( - category=FeedbackCategory.EXECUTION, - severity=FeedbackSeverity.CRITICAL, - type="immediate_crash", - message=f"Harness crashes immediately: {crash_details}", - suggestion="Debug the harness initialization. Add error handling and check for null/invalid pointers.", - details={"crash": crash_details}, - ) - ) - - elif execution_result.get("timeout"): - issues.append( - FeedbackIssue( - category=FeedbackCategory.EXECUTION, - severity=FeedbackSeverity.CRITICAL, - type="infinite_loop", - message="Harness times out - likely infinite loop", - suggestion="Check for loops that depend on fuzzer input. Add iteration limits or timeout mechanisms.", - details={}, - ) - ) - else: - strengths.append("Executes without crashing") - - return issues, strengths - - @staticmethod - def analyze_coverage( - coverage: CoverageMetrics, - ) -> tuple[list[FeedbackIssue], list[str]]: - """Analyze coverage metrics. - - :param coverage: Coverage metrics from fuzzing trial - :returns: Tuple of (issues, strengths) - """ - issues = [] - strengths = [] - - # No coverage growth - if coverage.new_edges_found == 0: - issues.append( - FeedbackIssue( - category=FeedbackCategory.COVERAGE, - severity=FeedbackSeverity.CRITICAL, - type="no_coverage", - message="No coverage detected - harness may not be using fuzzer input", - suggestion="Ensure you're actually calling the target function with fuzzer-provided data. Check that 'data' parameter is passed to the function being fuzzed.", - details={"initial_edges": coverage.initial_edges}, - ) - ) - # Very low coverage - elif coverage.growth_rate == "none" or ( - coverage.percentage_estimate and coverage.percentage_estimate < 5 - ): - issues.append( - FeedbackIssue( - category=FeedbackCategory.COVERAGE, - severity=FeedbackSeverity.WARNING, - type="very_low_coverage", - message=f"Very low coverage: ~{coverage.percentage_estimate}%", - suggestion="Harness may not be reaching the target code. Verify you're calling the correct entry point function. Check if there's input validation that rejects all fuzzer data.", - details={ - "percentage": coverage.percentage_estimate, - "edges": coverage.final_edges, - }, - ) - ) - # Low coverage - elif coverage.growth_rate == "poor" or ( - coverage.percentage_estimate and coverage.percentage_estimate < 20 - ): - issues.append( - FeedbackIssue( - category=FeedbackCategory.COVERAGE, - severity=FeedbackSeverity.WARNING, - type="low_coverage", - message=f"Low coverage: {coverage.percentage_estimate}% - not exploring enough code paths", - suggestion="Try fuzzing multiple entry points or remove restrictive input validation. Consider using a dictionary for structured inputs.", - details={ - "percentage": coverage.percentage_estimate, - "new_edges": coverage.new_edges_found, - }, - ) - ) - # Good coverage - elif coverage.growth_rate in ["good", "excellent"]: - if coverage.percentage_estimate and coverage.percentage_estimate > 50: - strengths.append( - f"Excellent coverage: {coverage.percentage_estimate}% of target code reached" - ) - else: - strengths.append("Good coverage growth - harness is exploring code paths") - - # Coverage stagnation - if ( - coverage.stagnation_time_sec - and coverage.stagnation_time_sec < 10 - and coverage.final_edges < 500 - ): - issues.append( - FeedbackIssue( - category=FeedbackCategory.COVERAGE, - severity=FeedbackSeverity.INFO, - type="early_stagnation", - message=f"Coverage stopped growing after {coverage.stagnation_time_sec}s", - suggestion="Harness may be hitting input validation barriers. Consider fuzzing with a seed corpus of valid inputs.", - details={"stagnation_time": coverage.stagnation_time_sec}, - ) - ) - - return issues, strengths - - @staticmethod - def analyze_performance( - performance: PerformanceMetrics, - ) -> tuple[list[FeedbackIssue], list[str]]: - """Analyze performance metrics. - - :param performance: Performance metrics from fuzzing trial - :returns: Tuple of (issues, strengths) - """ - issues = [] - strengths = [] - - execs_per_sec = performance.execs_per_sec - - # Very slow execution - if execs_per_sec < 10: - issues.append( - FeedbackIssue( - category=FeedbackCategory.PERFORMANCE, - severity=FeedbackSeverity.CRITICAL, - type="extremely_slow", - message=f"Extremely slow: {execs_per_sec:.1f} execs/sec", - suggestion="Remove file I/O, network operations, or expensive computations from the harness loop. Move setup code outside the fuzz target function.", - details={"execs_per_sec": execs_per_sec}, - ) - ) - # Slow execution - elif execs_per_sec < 100: - issues.append( - FeedbackIssue( - category=FeedbackCategory.PERFORMANCE, - severity=FeedbackSeverity.WARNING, - type="slow_execution", - message=f"Slow execution: {execs_per_sec:.1f} execs/sec (expected 500+)", - suggestion="Optimize harness: avoid allocations in hot path, reuse buffers, remove logging. Profile to find bottlenecks.", - details={"execs_per_sec": execs_per_sec}, - ) - ) - # Good performance - elif execs_per_sec > 1000: - strengths.append(f"Excellent performance: {execs_per_sec:.0f} execs/sec") - elif execs_per_sec > 500: - strengths.append(f"Good performance: {execs_per_sec:.0f} execs/sec") - - return issues, strengths - - @staticmethod - def analyze_stability( - stability: StabilityMetrics, - ) -> tuple[list[FeedbackIssue], list[str]]: - """Analyze stability metrics. - - :param stability: Stability metrics from fuzzing trial - :returns: Tuple of (issues, strengths) - """ - issues = [] - strengths = [] - - if stability.status == "crashes_frequently": - issues.append( - FeedbackIssue( - category=FeedbackCategory.STABILITY, - severity=FeedbackSeverity.WARNING, - type="unstable_frequent_crashes", - message=f"Harness crashes frequently: {stability.crash_rate:.1f} crashes per 1000 execs", - suggestion="This might be expected if testing buggy code. If not, add error handling for edge cases or invalid inputs.", - details={ - "crashes": stability.crashes_found, - "crash_rate": stability.crash_rate, - }, - ) - ) - elif stability.status == "hangs": - issues.append( - FeedbackIssue( - category=FeedbackCategory.STABILITY, - severity=FeedbackSeverity.WARNING, - type="hangs_detected", - message=f"Harness hangs: {stability.hangs_found} detected", - suggestion="Add timeouts to prevent infinite loops. Check for blocking operations or resource exhaustion.", - details={"hangs": stability.hangs_found}, - ) - ) - elif stability.status == "stable": - strengths.append("Stable execution - no crashes or hangs") - - # Finding crashes can be good! - if stability.unique_crashes > 0 and stability.status != "crashes_frequently": - strengths.append( - f"Found {stability.unique_crashes} potential bugs during trial!" - ) - - return issues, strengths - - @staticmethod - def calculate_quality_score( - compilation_success: bool, - execution_success: bool, - coverage: CoverageMetrics | None, - performance: PerformanceMetrics | None, - stability: StabilityMetrics | None, - ) -> int: - """Calculate overall quality score (0-100). - - :param compilation_success: Whether compilation succeeded - :param execution_success: Whether execution succeeded - :param coverage: Coverage metrics - :param performance: Performance metrics - :param stability: Stability metrics - :returns: Quality score 0-100 - """ - if not compilation_success: - return 0 - - if not execution_success: - return 10 - - score = 20 # Base score for compiling and running - - # Coverage contribution (0-40 points) - if coverage: - if coverage.growth_rate == "excellent": - score += 40 - elif coverage.growth_rate == "good": - score += 30 - elif coverage.growth_rate == "poor": - score += 10 - - # Performance contribution (0-25 points) - if performance: - if performance.execs_per_sec > 1000: - score += 25 - elif performance.execs_per_sec > 500: - score += 20 - elif performance.execs_per_sec > 100: - score += 10 - elif performance.execs_per_sec > 10: - score += 5 - - # Stability contribution (0-15 points) - if stability: - if stability.status == "stable": - score += 15 - elif stability.status == "unstable": - score += 10 - elif stability.status == "crashes_frequently": - score += 5 - - return min(score, 100) - - @classmethod - def generate_quality_assessment( - cls, - compilation_result: dict, - execution_result: dict | None, - coverage: CoverageMetrics | None, - performance: PerformanceMetrics | None, - stability: StabilityMetrics | None, - ) -> QualityAssessment: - """Generate complete quality assessment with all feedback. - - :param compilation_result: Compilation results - :param execution_result: Execution results - :param coverage: Coverage metrics - :param performance: Performance metrics - :param stability: Stability metrics - :returns: Complete quality assessment - """ - all_issues = [] - all_strengths = [] - - # Analyze each aspect - comp_issues, comp_strengths = cls.analyze_compilation(compilation_result) - all_issues.extend(comp_issues) - all_strengths.extend(comp_strengths) - - if execution_result: - exec_issues, exec_strengths = cls.analyze_execution(execution_result) - all_issues.extend(exec_issues) - all_strengths.extend(exec_strengths) - - if coverage: - cov_issues, cov_strengths = cls.analyze_coverage(coverage) - all_issues.extend(cov_issues) - all_strengths.extend(cov_strengths) - - if performance: - perf_issues, perf_strengths = cls.analyze_performance(performance) - all_issues.extend(perf_issues) - all_strengths.extend(perf_strengths) - - if stability: - stab_issues, stab_strengths = cls.analyze_stability(stability) - all_issues.extend(stab_issues) - all_strengths.extend(stab_strengths) - - # Calculate score - score = cls.calculate_quality_score( - compilation_result.get("success", False), - execution_result.get("success", False) if execution_result else False, - coverage, - performance, - stability, - ) - - # Determine verdict - if score >= 70: - verdict = "production-ready" - elif score >= 30: - verdict = "needs-improvement" - else: - verdict = "broken" - - # Generate recommended actions - recommended_actions = [] - critical_issues = [i for i in all_issues if i.severity == FeedbackSeverity.CRITICAL] - warning_issues = [i for i in all_issues if i.severity == FeedbackSeverity.WARNING] - - if critical_issues: - recommended_actions.append( - f"Fix {len(critical_issues)} critical issue(s) preventing execution" - ) - if warning_issues: - recommended_actions.append( - f"Address {len(warning_issues)} warning(s) to improve harness quality" - ) - if verdict == "production-ready": - recommended_actions.append("Harness is ready for production fuzzing") - - return QualityAssessment( - score=score, - verdict=verdict, - issues=all_issues, - strengths=all_strengths, - recommended_actions=recommended_actions, - ) diff --git a/fuzzforge-modules/harness-tester/src/module/feedback.py b/fuzzforge-modules/harness-tester/src/module/feedback.py deleted file mode 100644 index fab8848..0000000 --- a/fuzzforge-modules/harness-tester/src/module/feedback.py +++ /dev/null @@ -1,148 +0,0 @@ -"""Feedback types and schemas for harness testing.""" - -from enum import Enum -from typing import Any - -from pydantic import BaseModel, Field - - -class FeedbackSeverity(str, Enum): - """Severity levels for feedback issues.""" - - CRITICAL = "critical" # Blocks execution (compilation errors, crashes) - WARNING = "warning" # Should fix (low coverage, slow execution) - INFO = "info" # Nice to have (optimization suggestions) - - -class FeedbackCategory(str, Enum): - """Categories of feedback.""" - - COMPILATION = "compilation" - EXECUTION = "execution" - PERFORMANCE = "performance" - COVERAGE = "coverage" - STABILITY = "stability" - CODE_QUALITY = "code_quality" - - -class FeedbackIssue(BaseModel): - """A single feedback issue with actionable suggestion.""" - - category: FeedbackCategory - severity: FeedbackSeverity - type: str = Field(description="Specific issue type (e.g., 'low_coverage', 'compilation_error')") - message: str = Field(description="Human-readable description of the issue") - suggestion: str = Field(description="Actionable suggestion for AI agent to fix the issue") - details: dict[str, Any] = Field(default_factory=dict, description="Additional technical details") - - -class CompilationResult(BaseModel): - """Results from compilation attempt.""" - - success: bool - time_ms: int | None = None - errors: list[str] = Field(default_factory=list) - warnings: list[str] = Field(default_factory=list) - stderr: str | None = None - - -class ExecutionResult(BaseModel): - """Results from execution test.""" - - success: bool - runs_completed: int | None = None - immediate_crash: bool = False - timeout: bool = False - crash_details: str | None = None - - -class CoverageMetrics(BaseModel): - """Coverage metrics from fuzzing trial.""" - - initial_edges: int = 0 - final_edges: int = 0 - new_edges_found: int = 0 - growth_rate: str = Field( - description="Qualitative assessment: 'excellent', 'good', 'poor', 'none'" - ) - percentage_estimate: float | None = Field( - None, description="Estimated percentage of target code covered" - ) - stagnation_time_sec: float | None = Field( - None, description="Time until coverage stopped growing" - ) - - -class PerformanceMetrics(BaseModel): - """Performance metrics from fuzzing trial.""" - - total_execs: int - execs_per_sec: float - average_exec_time_us: float | None = None - performance_rating: str = Field( - description="'excellent' (>1000/s), 'good' (100-1000/s), 'poor' (<100/s)" - ) - - -class StabilityMetrics(BaseModel): - """Stability metrics from fuzzing trial.""" - - status: str = Field( - description="'stable', 'unstable', 'crashes_frequently', 'hangs'" - ) - crashes_found: int = 0 - hangs_found: int = 0 - unique_crashes: int = 0 - crash_rate: float = Field(0.0, description="Crashes per 1000 executions") - - -class FuzzingTrial(BaseModel): - """Results from short fuzzing trial.""" - - duration_seconds: int - coverage: CoverageMetrics - performance: PerformanceMetrics - stability: StabilityMetrics - trial_successful: bool - - -class QualityAssessment(BaseModel): - """Overall quality assessment of the harness.""" - - score: int = Field(ge=0, le=100, description="Quality score 0-100") - verdict: str = Field( - description="'production-ready', 'needs-improvement', 'broken'" - ) - issues: list[FeedbackIssue] = Field(default_factory=list) - strengths: list[str] = Field(default_factory=list) - recommended_actions: list[str] = Field(default_factory=list) - - -class HarnessEvaluation(BaseModel): - """Complete evaluation of a single harness.""" - - name: str - path: str | None = None - compilation: CompilationResult - execution: ExecutionResult | None = None - fuzzing_trial: FuzzingTrial | None = None - quality: QualityAssessment - - -class EvaluationSummary(BaseModel): - """Summary of all harness evaluations.""" - - total_harnesses: int - production_ready: int - needs_improvement: int - broken: int - average_score: float - recommended_action: str - - -class HarnessTestReport(BaseModel): - """Complete harness testing report.""" - - harnesses: list[HarnessEvaluation] - summary: EvaluationSummary - test_configuration: dict[str, Any] = Field(default_factory=dict) diff --git a/fuzzforge-modules/harness-tester/src/module/models.py b/fuzzforge-modules/harness-tester/src/module/models.py deleted file mode 100644 index ed6412b..0000000 --- a/fuzzforge-modules/harness-tester/src/module/models.py +++ /dev/null @@ -1,27 +0,0 @@ -"""Models for harness-tester module.""" - -from pathlib import Path -from typing import Any - -from pydantic import BaseModel - -from fuzzforge_modules_sdk.api.models import ( - FuzzForgeModuleInputBase, - FuzzForgeModuleOutputBase, -) - -from module.settings import Settings - - -class Input(FuzzForgeModuleInputBase[Settings]): - """Input for the harness-tester module.""" - - -class Output(FuzzForgeModuleOutputBase): - """Output for the harness-tester module.""" - - #: The test report data. - report: dict[str, Any] | None = None - - #: Path to the report JSON file. - report_file: Path | None = None diff --git a/fuzzforge-modules/harness-tester/src/module/settings.py b/fuzzforge-modules/harness-tester/src/module/settings.py deleted file mode 100644 index 01aa011..0000000 --- a/fuzzforge-modules/harness-tester/src/module/settings.py +++ /dev/null @@ -1,19 +0,0 @@ -"""Settings for harness-tester module.""" - -from pydantic import BaseModel, Field - - -class Settings(BaseModel): - """Settings for the harness-tester module.""" - - #: Duration for each fuzzing trial in seconds. - trial_duration_sec: int = Field(default=30, ge=1, le=300) - - #: Timeout for harness execution in seconds. - execution_timeout_sec: int = Field(default=10, ge=1, le=60) - - #: Whether to generate coverage reports. - enable_coverage: bool = Field(default=True) - - #: Minimum score threshold for harness to be considered "good". - min_quality_score: int = Field(default=50, ge=0, le=100) diff --git a/fuzzforge-modules/rust-analyzer/Dockerfile b/fuzzforge-modules/rust-analyzer/Dockerfile deleted file mode 100644 index 70b18cb..0000000 --- a/fuzzforge-modules/rust-analyzer/Dockerfile +++ /dev/null @@ -1,27 +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 -RUN apt-get update && apt-get install -y \ - curl \ - build-essential \ - pkg-config \ - libssl-dev \ - && rm -rf /var/lib/apt/lists/* - -# Install Rust toolchain -RUN curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y -ENV PATH="/root/.cargo/bin:${PATH}" - -# Install Rust analysis tools (skipping cargo-geiger as it's heavy) -# RUN cargo install cargo-geiger --locked || true -RUN cargo install cargo-audit --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 diff --git a/fuzzforge-modules/rust-analyzer/Makefile b/fuzzforge-modules/rust-analyzer/Makefile deleted file mode 100644 index cada4d0..0000000 --- a/fuzzforge-modules/rust-analyzer/Makefile +++ /dev/null @@ -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)' diff --git a/fuzzforge-modules/rust-analyzer/README.md b/fuzzforge-modules/rust-analyzer/README.md deleted file mode 100644 index d0671a1..0000000 --- a/fuzzforge-modules/rust-analyzer/README.md +++ /dev/null @@ -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" \ - ':' '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. diff --git a/fuzzforge-modules/rust-analyzer/mypy.ini b/fuzzforge-modules/rust-analyzer/mypy.ini deleted file mode 100644 index 84e90d2..0000000 --- a/fuzzforge-modules/rust-analyzer/mypy.ini +++ /dev/null @@ -1,6 +0,0 @@ -[mypy] -plugins = pydantic.mypy -strict = True -warn_unused_ignores = True -warn_redundant_casts = True -warn_return_any = True diff --git a/fuzzforge-modules/rust-analyzer/pyproject.toml b/fuzzforge-modules/rust-analyzer/pyproject.toml deleted file mode 100644 index 2f5a512..0000000 --- a/fuzzforge-modules/rust-analyzer/pyproject.toml +++ /dev/null @@ -1,52 +0,0 @@ -[project] -name = "fuzzforge-rust-analyzer" -version = "0.1.0" -description = "Analyzes Rust projects to identify functions suitable for fuzzing" -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] -package = true - -# FuzzForge module metadata for AI agent discovery -[tool.fuzzforge.module] -identifier = "fuzzforge-rust-analyzer" -suggested_predecessors = [] -continuous_mode = false - -use_cases = [ - "Analyze Rust crate to find fuzzable functions", - "First step in Rust fuzzing pipeline before harness generation", - "Produces fuzzable_functions.json for AI harness generation" -] - -common_inputs = [ - "rust-source-code", - "Cargo.toml" -] - -output_artifacts = [ - "analysis.json", - "results.json" -] - -output_treatment = "Read analysis.json which contains: project_info, fuzzable_functions (array with name, signature, file_path, fuzz_score), and vulnerabilities (array of known CVEs). Display fuzzable_functions as a table. Highlight any vulnerabilities found." diff --git a/fuzzforge-modules/rust-analyzer/ruff.toml b/fuzzforge-modules/rust-analyzer/ruff.toml deleted file mode 100644 index 6374f62..0000000 --- a/fuzzforge-modules/rust-analyzer/ruff.toml +++ /dev/null @@ -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 -] diff --git a/fuzzforge-modules/rust-analyzer/src/module/__init__.py b/fuzzforge-modules/rust-analyzer/src/module/__init__.py deleted file mode 100644 index e69de29..0000000 diff --git a/fuzzforge-modules/rust-analyzer/src/module/__main__.py b/fuzzforge-modules/rust-analyzer/src/module/__main__.py deleted file mode 100644 index bc8914a..0000000 --- a/fuzzforge-modules/rust-analyzer/src/module/__main__.py +++ /dev/null @@ -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() diff --git a/fuzzforge-modules/rust-analyzer/src/module/mod.py b/fuzzforge-modules/rust-analyzer/src/module/mod.py deleted file mode 100644 index 751d3bd..0000000 --- a/fuzzforge-modules/rust-analyzer/src/module/mod.py +++ /dev/null @@ -1,314 +0,0 @@ -"""Rust Analyzer module for FuzzForge. - -This module analyzes Rust source code to identify fuzzable entry points, -unsafe blocks, and known vulnerabilities. -""" - -from __future__ import annotations - -import json -import re -import subprocess -from pathlib import Path -from typing import TYPE_CHECKING - -from fuzzforge_modules_sdk.api.constants import PATH_TO_OUTPUTS -from fuzzforge_modules_sdk.api.models import FuzzForgeModuleResults -from fuzzforge_modules_sdk.api.modules.base import FuzzForgeModule - -from module.models import AnalysisResult, EntryPoint, Input, Output, UnsafeBlock, Vulnerability -from module.settings import Settings - -if TYPE_CHECKING: - from fuzzforge_modules_sdk.api.models import FuzzForgeModuleResource - - -class Module(FuzzForgeModule): - """Rust Analyzer module - analyzes Rust code for fuzzable entry points.""" - - def __init__(self) -> None: - """Initialize an instance of the class.""" - name: str = "rust-analyzer" - version: str = "0.1.0" - FuzzForgeModule.__init__(self, name=name, version=version) - self._project_path: Path | None = None - self._settings: Settings | None = 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 - - def _find_cargo_toml(self, resources: list[FuzzForgeModuleResource]) -> Path | None: - """Find the Cargo.toml file in the resources. - - :param resources: List of input resources. - :returns: Path to Cargo.toml or None. - - """ - for resource in resources: - if resource.path.name == "Cargo.toml": - return resource.path - # Check if resource is a directory containing Cargo.toml - cargo_path = resource.path / "Cargo.toml" - if cargo_path.exists(): - return cargo_path - return None - - def _parse_cargo_toml(self, cargo_path: Path) -> tuple[str, str, str]: - """Parse Cargo.toml to extract crate name, version, and lib name. - - :param cargo_path: Path to Cargo.toml. - :returns: Tuple of (crate_name, version, lib_name). - - """ - import tomllib - - with cargo_path.open("rb") as f: - data = tomllib.load(f) - - package = data.get("package", {}) - crate_name = package.get("name", "unknown") - version = package.get("version", "0.0.0") - - # Get lib name - defaults to crate name with dashes converted to underscores - lib_section = data.get("lib", {}) - lib_name = lib_section.get("name", crate_name.replace("-", "_")) - - return crate_name, version, lib_name - - def _find_entry_points(self, project_path: Path) -> list[EntryPoint]: - """Find fuzzable entry points in the Rust source. - - :param project_path: Path to the Rust project. - :returns: List of entry points. - - """ - entry_points: list[EntryPoint] = [] - - # Patterns for fuzzable functions (take &[u8], &str, or impl Read) - fuzzable_patterns = [ - r"pub\s+fn\s+(\w+)\s*\([^)]*&\[u8\][^)]*\)", - r"pub\s+fn\s+(\w+)\s*\([^)]*&str[^)]*\)", - r"pub\s+fn\s+(\w+)\s*\([^)]*impl\s+Read[^)]*\)", - r"pub\s+fn\s+(\w+)\s*\([^)]*data:\s*&\[u8\][^)]*\)", - r"pub\s+fn\s+(\w+)\s*\([^)]*input:\s*&\[u8\][^)]*\)", - r"pub\s+fn\s+(\w+)\s*\([^)]*buf:\s*&\[u8\][^)]*\)", - ] - - # Also find parse/decode functions - parser_patterns = [ - r"pub\s+fn\s+(parse\w*)\s*\([^)]*\)", - r"pub\s+fn\s+(decode\w*)\s*\([^)]*\)", - r"pub\s+fn\s+(deserialize\w*)\s*\([^)]*\)", - r"pub\s+fn\s+(from_bytes\w*)\s*\([^)]*\)", - r"pub\s+fn\s+(read\w*)\s*\([^)]*\)", - ] - - src_path = project_path / "src" - if not src_path.exists(): - src_path = project_path - - for rust_file in src_path.rglob("*.rs"): - try: - content = rust_file.read_text() - lines = content.split("\n") - - for line_num, line in enumerate(lines, 1): - # Check fuzzable patterns - for pattern in fuzzable_patterns: - match = re.search(pattern, line) - if match: - entry_points.append( - EntryPoint( - function=match.group(1), - file=str(rust_file.relative_to(project_path)), - line=line_num, - signature=line.strip(), - fuzzable=True, - ) - ) - - # Check parser patterns (may need manual review) - for pattern in parser_patterns: - match = re.search(pattern, line) - if match: - # Avoid duplicates - func_name = match.group(1) - if not any(ep.function == func_name for ep in entry_points): - entry_points.append( - EntryPoint( - function=func_name, - file=str(rust_file.relative_to(project_path)), - line=line_num, - signature=line.strip(), - fuzzable=True, - ) - ) - except Exception: - continue - - return entry_points - - def _find_unsafe_blocks(self, project_path: Path) -> list[UnsafeBlock]: - """Find unsafe blocks in the Rust source. - - :param project_path: Path to the Rust project. - :returns: List of unsafe blocks. - - """ - unsafe_blocks: list[UnsafeBlock] = [] - - src_path = project_path / "src" - if not src_path.exists(): - src_path = project_path - - for rust_file in src_path.rglob("*.rs"): - try: - content = rust_file.read_text() - lines = content.split("\n") - - for line_num, line in enumerate(lines, 1): - if "unsafe" in line and ("{" in line or "fn" in line): - # Determine context - context = "unsafe block" - if "unsafe fn" in line: - context = "unsafe function" - elif "unsafe impl" in line: - context = "unsafe impl" - elif "*const" in line or "*mut" in line: - context = "raw pointer operation" - - unsafe_blocks.append( - UnsafeBlock( - file=str(rust_file.relative_to(project_path)), - line=line_num, - context=context, - ) - ) - except Exception: - continue - - return unsafe_blocks - - def _run_cargo_audit(self, project_path: Path) -> list[Vulnerability]: - """Run cargo-audit to find known vulnerabilities. - - :param project_path: Path to the Rust project. - :returns: List of vulnerabilities. - - """ - vulnerabilities: list[Vulnerability] = [] - - try: - result = subprocess.run( - ["cargo", "audit", "--json"], - cwd=project_path, - capture_output=True, - text=True, - timeout=120, - ) - - if result.stdout: - audit_data = json.loads(result.stdout) - for vuln in audit_data.get("vulnerabilities", {}).get("list", []): - advisory = vuln.get("advisory", {}) - vulnerabilities.append( - Vulnerability( - advisory_id=advisory.get("id", "UNKNOWN"), - crate_name=vuln.get("package", {}).get("name", "unknown"), - version=vuln.get("package", {}).get("version", "0.0.0"), - title=advisory.get("title", "Unknown vulnerability"), - severity=advisory.get("severity", "unknown"), - ) - ) - except (subprocess.TimeoutExpired, json.JSONDecodeError, FileNotFoundError): - pass - - return vulnerabilities - - def _run(self, resources: list[FuzzForgeModuleResource]) -> FuzzForgeModuleResults: - """Run the analysis. - - :param resources: Input resources. - :returns: Module result status. - - """ - # Find the Rust project - cargo_path = self._find_cargo_toml(resources) - if cargo_path is None: - self.get_logger().error("No Cargo.toml found in resources") - return FuzzForgeModuleResults.FAILURE - - project_path = cargo_path.parent - self._project_path = project_path - - self.get_logger().info("Analyzing Rust project", project=str(project_path)) - - # Parse Cargo.toml - crate_name, crate_version, lib_name = self._parse_cargo_toml(cargo_path) - self.get_logger().info("Found crate", name=crate_name, version=crate_version, lib_name=lib_name) - - # Find entry points - entry_points = self._find_entry_points(project_path) - self.get_logger().info("Found entry points", count=len(entry_points)) - - # Find unsafe blocks - unsafe_blocks = self._find_unsafe_blocks(project_path) - self.get_logger().info("Found unsafe blocks", count=len(unsafe_blocks)) - - # Run cargo-audit if enabled - vulnerabilities: list[Vulnerability] = [] - if self._settings and self._settings.run_audit: - vulnerabilities = self._run_cargo_audit(project_path) - self.get_logger().info("Found vulnerabilities", count=len(vulnerabilities)) - - # Build result - analysis = AnalysisResult( - crate_name=crate_name, - crate_version=crate_version, - lib_name=lib_name, - entry_points=entry_points, - unsafe_blocks=unsafe_blocks, - vulnerabilities=vulnerabilities, - summary={ - "entry_points": len(entry_points), - "unsafe_blocks": len(unsafe_blocks), - "vulnerabilities": len(vulnerabilities), - }, - ) - - # Set output data for results.json - self.set_output( - analysis=analysis.model_dump(), - ) - - # Write analysis to output file (for backwards compatibility) - output_path = PATH_TO_OUTPUTS / "analysis.json" - output_path.parent.mkdir(parents=True, exist_ok=True) - output_path.write_text(analysis.model_dump_json(indent=2)) - - self.get_logger().info("Analysis complete", output=str(output_path)) - - return FuzzForgeModuleResults.SUCCESS - - def _cleanup(self, settings: Settings) -> None: # type: ignore[override] - """Clean up after execution. - - :param settings: Module settings. - - """ - pass diff --git a/fuzzforge-modules/rust-analyzer/src/module/models.py b/fuzzforge-modules/rust-analyzer/src/module/models.py deleted file mode 100644 index f87f280..0000000 --- a/fuzzforge-modules/rust-analyzer/src/module/models.py +++ /dev/null @@ -1,100 +0,0 @@ -"""Models for rust-analyzer module.""" - -from pathlib import Path - -from pydantic import BaseModel - -from fuzzforge_modules_sdk.api.models import FuzzForgeModuleInputBase, FuzzForgeModuleOutputBase - -from module.settings import Settings - - -class Input(FuzzForgeModuleInputBase[Settings]): - """Input for the rust-analyzer module.""" - - -class EntryPoint(BaseModel): - """A fuzzable entry point in the Rust codebase.""" - - #: Function name. - function: str - - #: Source file path. - file: str - - #: Line number. - line: int - - #: Function signature. - signature: str - - #: Whether the function takes &[u8] or similar fuzzable input. - fuzzable: bool = True - - -class UnsafeBlock(BaseModel): - """An unsafe block detected in the codebase.""" - - #: Source file path. - file: str - - #: Line number. - line: int - - #: Context description. - context: str - - -class Vulnerability(BaseModel): - """A known vulnerability from cargo-audit.""" - - #: Advisory ID (e.g., RUSTSEC-2021-0001). - advisory_id: str - - #: Affected crate name. - crate_name: str - - #: Affected version. - version: str - - #: Vulnerability title. - title: str - - #: Severity level. - severity: str - - -class AnalysisResult(BaseModel): - """The complete analysis result.""" - - #: Crate name from Cargo.toml (use this in fuzz/Cargo.toml dependencies). - crate_name: str - - #: Crate version. - crate_version: str - - #: Library name for use in Rust code (use in `use` statements). - #: In Rust, dashes become underscores: "fuzz-demo" -> "fuzz_demo". - lib_name: str = "" - - #: List of fuzzable entry points. - entry_points: list[EntryPoint] - - #: List of unsafe blocks. - unsafe_blocks: list[UnsafeBlock] - - #: List of known vulnerabilities. - vulnerabilities: list[Vulnerability] - - #: Summary statistics. - summary: dict[str, int] - - -class Output(FuzzForgeModuleOutputBase): - """Output for the rust-analyzer module.""" - - #: The analysis result (as dict for serialization). - analysis: dict | None = None - - #: Path to the analysis JSON file. - analysis_file: Path | None = None diff --git a/fuzzforge-modules/rust-analyzer/src/module/settings.py b/fuzzforge-modules/rust-analyzer/src/module/settings.py deleted file mode 100644 index 17767ff..0000000 --- a/fuzzforge-modules/rust-analyzer/src/module/settings.py +++ /dev/null @@ -1,16 +0,0 @@ -"""Settings for rust-analyzer module.""" - -from fuzzforge_modules_sdk.api.models import FuzzForgeModulesSettingsBase - - -class Settings(FuzzForgeModulesSettingsBase): - """Settings for the rust-analyzer module.""" - - #: Whether to run cargo-audit for CVE detection. - run_audit: bool = True - - #: Whether to run cargo-geiger for unsafe detection. - run_geiger: bool = True - - #: Maximum depth for dependency analysis. - max_depth: int = 3 diff --git a/fuzzforge-modules/rust-analyzer/tests/.gitkeep b/fuzzforge-modules/rust-analyzer/tests/.gitkeep deleted file mode 100644 index e69de29..0000000 diff --git a/fuzzforge-runner/Makefile b/fuzzforge-runner/Makefile deleted file mode 100644 index eb78637..0000000 --- a/fuzzforge-runner/Makefile +++ /dev/null @@ -1,14 +0,0 @@ -.PHONY: lint test format check - -lint: - uv run ruff check src tests - uv run mypy src - -test: - uv run pytest tests - -format: - uv run ruff format src tests - uv run ruff check --fix src tests - -check: lint test diff --git a/fuzzforge-runner/README.md b/fuzzforge-runner/README.md deleted file mode 100644 index c401d98..0000000 --- a/fuzzforge-runner/README.md +++ /dev/null @@ -1,44 +0,0 @@ -# FuzzForge Runner - -Direct execution engine for FuzzForge AI. Provides simplified module and workflow execution without requiring Temporal or external infrastructure. - -## Overview - -The Runner is designed for local-first operation, executing FuzzForge modules directly in containerized sandboxes (Docker/Podman) without workflow orchestration overhead. - -## Features - -- Direct module execution in isolated containers -- Sequential workflow orchestration (no Temporal required) -- Local filesystem storage (S3 optional) -- SQLite-based state management (optional) - -## Usage - -```python -from fuzzforge_runner import Runner -from fuzzforge_runner.settings import Settings - -settings = Settings() -runner = Runner(settings) - -# Execute a single module -result = await runner.execute_module( - module_identifier="my-module", - project_path="/path/to/project", -) - -# Execute a workflow (sequential steps) -result = await runner.execute_workflow( - workflow_definition=workflow, - project_path="/path/to/project", -) -``` - -## Configuration - -Environment variables: - -- `FUZZFORGE_STORAGE_PATH`: Local storage directory (default: `~/.fuzzforge/storage`) -- `FUZZFORGE_ENGINE_TYPE`: Container engine (`docker` or `podman`) -- `FUZZFORGE_ENGINE_SOCKET`: Container socket path diff --git a/fuzzforge-runner/mypy.ini b/fuzzforge-runner/mypy.ini deleted file mode 100644 index be0671c..0000000 --- a/fuzzforge-runner/mypy.ini +++ /dev/null @@ -1,2 +0,0 @@ -[mypy] -strict = true diff --git a/fuzzforge-runner/pyproject.toml b/fuzzforge-runner/pyproject.toml deleted file mode 100644 index cbee128..0000000 --- a/fuzzforge-runner/pyproject.toml +++ /dev/null @@ -1,26 +0,0 @@ -[project] -name = "fuzzforge-runner" -version = "0.0.1" -description = "FuzzForge Runner - Direct execution engine for FuzzForge AI." -authors = [] -readme = "README.md" -requires-python = ">=3.14" -dependencies = [ - "fuzzforge-common", - "structlog>=25.5.0", - "pydantic>=2.12.4", - "pydantic-settings>=2.8.1", -] - -[project.scripts] -fuzzforge-runner = "fuzzforge_runner.__main__:main" - -[build-system] -requires = ["hatchling"] -build-backend = "hatchling.build" - -[tool.hatch.build.targets.wheel] -packages = ["src/fuzzforge_runner"] - -[tool.uv.sources] -fuzzforge-common = { workspace = true } diff --git a/fuzzforge-runner/pytest.ini b/fuzzforge-runner/pytest.ini deleted file mode 100644 index c8c9c75..0000000 --- a/fuzzforge-runner/pytest.ini +++ /dev/null @@ -1,3 +0,0 @@ -[pytest] -asyncio_mode = auto -asyncio_default_fixture_loop_scope = function diff --git a/fuzzforge-runner/ruff.toml b/fuzzforge-runner/ruff.toml deleted file mode 100644 index b9f8af9..0000000 --- a/fuzzforge-runner/ruff.toml +++ /dev/null @@ -1 +0,0 @@ -extend = "../ruff.toml" diff --git a/fuzzforge-runner/src/fuzzforge_runner/__init__.py b/fuzzforge-runner/src/fuzzforge_runner/__init__.py deleted file mode 100644 index f6a8f62..0000000 --- a/fuzzforge-runner/src/fuzzforge_runner/__init__.py +++ /dev/null @@ -1,9 +0,0 @@ -"""FuzzForge Runner - Direct execution engine for FuzzForge AI.""" - -from fuzzforge_runner.runner import Runner -from fuzzforge_runner.settings import Settings - -__all__ = [ - "Runner", - "Settings", -] diff --git a/fuzzforge-runner/src/fuzzforge_runner/__main__.py b/fuzzforge-runner/src/fuzzforge_runner/__main__.py deleted file mode 100644 index 36e4131..0000000 --- a/fuzzforge-runner/src/fuzzforge_runner/__main__.py +++ /dev/null @@ -1,28 +0,0 @@ -"""FuzzForge Runner CLI entry point.""" - -from fuzzforge_runner.runner import Runner -from fuzzforge_runner.settings import Settings - - -def main() -> None: - """Entry point for the FuzzForge Runner CLI. - - This is a minimal entry point that can be used for testing - and direct execution. The primary interface is via the MCP server. - - """ - import argparse - - parser = argparse.ArgumentParser(description="FuzzForge Runner") - parser.add_argument("--version", action="store_true", help="Print version and exit") - args = parser.parse_args() - - if args.version: - print("fuzzforge-runner 0.0.1") # noqa: T201 - return - - print("FuzzForge Runner - Use via MCP server or programmatically") # noqa: T201 - - -if __name__ == "__main__": - main() diff --git a/fuzzforge-runner/src/fuzzforge_runner/constants.py b/fuzzforge-runner/src/fuzzforge_runner/constants.py deleted file mode 100644 index da836fc..0000000 --- a/fuzzforge-runner/src/fuzzforge_runner/constants.py +++ /dev/null @@ -1,21 +0,0 @@ -"""FuzzForge Runner constants.""" - -from pydantic import UUID7 - -#: Type alias for execution identifiers. -type FuzzForgeExecutionIdentifier = UUID7 - -#: Default directory name for module input inside sandbox. -SANDBOX_INPUT_DIRECTORY: str = "/fuzzforge/input" - -#: Default directory name for module output inside sandbox. -SANDBOX_OUTPUT_DIRECTORY: str = "/fuzzforge/output" - -#: Default archive filename for results. -RESULTS_ARCHIVE_FILENAME: str = "results.tar.gz" - -#: Default configuration filename. -MODULE_CONFIG_FILENAME: str = "config.json" - -#: Module entrypoint script name. -MODULE_ENTRYPOINT: str = "module" diff --git a/fuzzforge-runner/src/fuzzforge_runner/exceptions.py b/fuzzforge-runner/src/fuzzforge_runner/exceptions.py deleted file mode 100644 index ad98b2a..0000000 --- a/fuzzforge-runner/src/fuzzforge_runner/exceptions.py +++ /dev/null @@ -1,27 +0,0 @@ -"""FuzzForge Runner exceptions.""" - -from __future__ import annotations - - -class RunnerError(Exception): - """Base exception for all Runner errors.""" - - -class ModuleNotFoundError(RunnerError): - """Raised when a module cannot be found.""" - - -class ModuleExecutionError(RunnerError): - """Raised when module execution fails.""" - - -class WorkflowExecutionError(RunnerError): - """Raised when workflow execution fails.""" - - -class StorageError(RunnerError): - """Raised when storage operations fail.""" - - -class SandboxError(RunnerError): - """Raised when sandbox operations fail.""" diff --git a/fuzzforge-runner/src/fuzzforge_runner/executor.py b/fuzzforge-runner/src/fuzzforge_runner/executor.py deleted file mode 100644 index d8fc576..0000000 --- a/fuzzforge-runner/src/fuzzforge_runner/executor.py +++ /dev/null @@ -1,806 +0,0 @@ -"""FuzzForge Runner - Direct module execution engine. - -This module provides direct execution of FuzzForge modules without -requiring Temporal workflow orchestration. It's designed for local -development and OSS deployment scenarios. - -""" - -from __future__ import annotations - -import json -from io import BytesIO -from pathlib import Path, PurePath -from tarfile import TarFile, TarInfo -from tarfile import open as Archive # noqa: N812 -from tempfile import NamedTemporaryFile, TemporaryDirectory -from typing import TYPE_CHECKING, Any, cast - -from fuzzforge_common.sandboxes.engines.docker.configuration import DockerConfiguration -from fuzzforge_common.sandboxes.engines.podman.configuration import PodmanConfiguration - -from fuzzforge_runner.constants import ( - MODULE_ENTRYPOINT, - RESULTS_ARCHIVE_FILENAME, - SANDBOX_INPUT_DIRECTORY, - SANDBOX_OUTPUT_DIRECTORY, - FuzzForgeExecutionIdentifier, -) -from fuzzforge_runner.exceptions import ModuleExecutionError, SandboxError - -if TYPE_CHECKING: - from fuzzforge_common.sandboxes.engines.base.engine import AbstractFuzzForgeSandboxEngine - from fuzzforge_runner.settings import EngineSettings, Settings - from structlog.stdlib import BoundLogger - - -def get_logger() -> BoundLogger: - """Get structlog logger instance. - - :returns: Configured structlog logger. - - """ - from structlog import get_logger # noqa: PLC0415 - - return cast("BoundLogger", get_logger()) - - -class ModuleExecutor: - """Direct executor for FuzzForge modules. - - Handles the complete lifecycle of module execution: - - Spawning isolated sandbox containers - - Pushing input assets and configuration - - Running the module - - Pulling output results - - Cleanup - - """ - - #: Full settings including engine and registry. - _settings: Settings - #: Engine settings for container operations. - _engine_settings: EngineSettings - - def __init__(self, settings: Settings) -> None: - """Initialize an instance of the class. - - :param settings: FuzzForge runner settings. - - """ - self._settings = settings - self._engine_settings = settings.engine - - def _get_engine_configuration(self) -> DockerConfiguration | PodmanConfiguration: - """Get the appropriate engine configuration. - - :returns: Engine configuration based on settings. - - Note: This is only used when socket mode is explicitly needed. - The default is now PodmanCLI with custom storage paths. - - """ - from fuzzforge_common.sandboxes.engines.enumeration import FuzzForgeSandboxEngines - - # Ensure socket has proper scheme - socket = self._engine_settings.socket - if not socket.startswith(("unix://", "tcp://", "http://", "ssh://")): - socket = f"unix://{socket}" - - if self._engine_settings.type == "docker": - return DockerConfiguration( - kind=FuzzForgeSandboxEngines.DOCKER, - socket=socket, - ) - return PodmanConfiguration( - kind=FuzzForgeSandboxEngines.PODMAN, - socket=socket, - ) - - def _get_engine(self) -> AbstractFuzzForgeSandboxEngine: - """Get the container engine instance. - - Uses DockerCLI by default for simplicity (works on Linux, macOS, Windows). - PodmanCLI is used when engine type is set to 'podman'. - - :returns: Configured container engine. - - """ - from fuzzforge_common.sandboxes.engines.docker import DockerCLI - from fuzzforge_common.sandboxes.engines.podman import PodmanCLI - - # Use PodmanCLI for Podman (with custom storage under Snap) - if self._engine_settings.type == "podman": - return PodmanCLI( - graphroot=self._engine_settings.graphroot, - runroot=self._engine_settings.runroot, - ) - - # Use DockerCLI for Docker (default) - return DockerCLI() - - def _check_image_exists(self, module_identifier: str) -> bool: - """Check if a module image exists locally. - - :param module_identifier: Name/identifier of the module image. - :returns: True if image exists, False otherwise. - - """ - engine = self._get_engine() - - # Try common tags - tags_to_check = ["latest", "0.1.0", "0.0.1"] - - # Try multiple naming conventions: - # - fuzzforge-{name}:{tag} (OSS local builds) - # - fuzzforge-module-{name}:{tag} (OSS local builds with module prefix) - # - localhost/fuzzforge-module-{name}:{tag} (standard convention) - # - localhost/{name}:{tag} (legacy/short form) - - # For OSS local builds (no localhost/ prefix) - for tag in tags_to_check: - # Check direct module name (fuzzforge-cargo-fuzzer:0.1.0) - if engine.image_exists(f"{module_identifier}:{tag}"): - return True - # Check with fuzzforge- prefix if not already present - if not module_identifier.startswith("fuzzforge-"): - if engine.image_exists(f"fuzzforge-{module_identifier}:{tag}"): - return True - - # For registry-style naming (localhost/ prefix) - name_prefixes = [f"fuzzforge-module-{module_identifier}", module_identifier] - - for prefix in name_prefixes: - for tag in tags_to_check: - image_name = f"localhost/{prefix}:{tag}" - if engine.image_exists(image_name): - return True - - return False - - def _get_local_image_name(self, module_identifier: str) -> str: - """Get the full local image name for a module. - - :param module_identifier: Name/identifier of the module. - :returns: Full image name (may or may not have localhost prefix). - - """ - engine = self._get_engine() - - # Try common tags - tags_to_check = ["latest", "0.1.0", "0.0.1"] - - # Check OSS local builds first (no localhost/ prefix) - for tag in tags_to_check: - # Direct module name (fuzzforge-cargo-fuzzer:0.1.0) - direct_name = f"{module_identifier}:{tag}" - if engine.image_exists(direct_name): - return direct_name - - # With fuzzforge- prefix if not already present - if not module_identifier.startswith("fuzzforge-"): - prefixed_name = f"fuzzforge-{module_identifier}:{tag}" - if engine.image_exists(prefixed_name): - return prefixed_name - - # Check registry-style naming (localhost/ prefix) - for tag in tags_to_check: - # Standard convention: localhost/fuzzforge-module-{name}:{tag} - prefixed_name = f"localhost/fuzzforge-module-{module_identifier}:{tag}" - if engine.image_exists(prefixed_name): - return prefixed_name - - # Legacy short form: localhost/{name}:{tag} - short_name = f"localhost/{module_identifier}:{tag}" - if engine.image_exists(short_name): - return short_name - - # Default fallback - return f"localhost/{module_identifier}:latest" - - def _pull_module_image(self, module_identifier: str, registry_url: str, tag: str = "latest") -> None: - """Pull a module image from the container registry. - - :param module_identifier: Name/identifier of the module to pull. - :param registry_url: Container registry URL. - :param tag: Image tag to pull. - :raises SandboxError: If pull fails. - - """ - logger = get_logger() - engine = self._get_engine() - - # Construct full image name - remote_image = f"{registry_url}/fuzzforge-module-{module_identifier}:{tag}" - local_image = f"localhost/{module_identifier}:{tag}" - - logger.info("pulling module image from registry", module=module_identifier, remote_image=remote_image) - - try: - # Pull the image using engine abstraction - engine.pull_image(remote_image, timeout=300) - - logger.info("module image pulled successfully", module=module_identifier) - - # Tag the image locally for consistency - engine.tag_image(remote_image, local_image) - - logger.debug("tagged image locally", local_image=local_image) - - except TimeoutError as exc: - message = f"Module image pull timed out after 5 minutes: {module_identifier}" - raise SandboxError(message) from exc - except Exception as exc: - message = ( - f"Failed to pull module image '{module_identifier}': {exc}\n" - f"Registry: {registry_url}\n" - f"Image: {remote_image}" - ) - raise SandboxError(message) from exc - - def _ensure_module_image(self, module_identifier: str, registry_url: str = "", tag: str = "latest") -> None: - """Ensure module image exists, pulling it if necessary. - - :param module_identifier: Name/identifier of the module image. - :param registry_url: Container registry URL to pull from (empty = local-only mode). - :param tag: Image tag to pull. - :raises SandboxError: If image not found locally and no registry configured. - - """ - logger = get_logger() - - if self._check_image_exists(module_identifier): - logger.debug("module image exists locally", module=module_identifier) - return - - # If no registry configured, we're in local-only mode - if not registry_url: - raise SandboxError( - f"Module image '{module_identifier}' not found locally.\n" - "Build it with: make build-modules\n" - "\n" - "Or configure a registry URL via FUZZFORGE_REGISTRY__URL environment variable." - ) - - logger.info( - "module image not found locally, pulling from registry", - module=module_identifier, - registry=registry_url, - info="This may take a moment on first run", - ) - self._pull_module_image(module_identifier, registry_url, tag) - - # Verify image now exists - if not self._check_image_exists(module_identifier): - message = ( - f"Module image '{module_identifier}' still not found after pull attempt.\n" - f"Tried to pull from: {registry_url}/fuzzforge-module-{module_identifier}:{tag}" - ) - raise SandboxError(message) - - def spawn_sandbox(self, module_identifier: str, input_volume: Path | None = None) -> str: - """Create and prepare a sandbox container for module execution. - - Automatically pulls the module image from registry if it doesn't exist locally. - - :param module_identifier: Name/identifier of the module image. - :param input_volume: Optional path to mount as /fuzzforge/input in the container. - :returns: The sandbox container identifier. - :raises SandboxError: If sandbox creation fails. - - """ - logger = get_logger() - engine = self._get_engine() - - # Ensure module image exists (auto-pull if needed) - # Use registry settings from configuration - registry_url = self._settings.registry.url - tag = self._settings.registry.default_tag - self._ensure_module_image(module_identifier, registry_url, tag) - - logger.info("spawning sandbox", module=module_identifier) - try: - image = self._get_local_image_name(module_identifier) - - # Build volume mappings - volumes: dict[str, str] | None = None - if input_volume: - volumes = {str(input_volume): SANDBOX_INPUT_DIRECTORY} - - sandbox_id = engine.create_container(image=image, volumes=volumes) - logger.info("sandbox spawned", sandbox=sandbox_id, module=module_identifier) - return sandbox_id - - except TimeoutError as exc: - message = f"Container creation timed out for module {module_identifier}" - raise SandboxError(message) from exc - except Exception as exc: - message = f"Failed to spawn sandbox for module {module_identifier}" - raise SandboxError(message) from exc - - def prepare_input_directory( - self, - assets_path: Path, - configuration: dict[str, Any] | None = None, - project_path: Path | None = None, - execution_id: str | None = None, - ) -> Path: - """Prepare input directory with assets and configuration. - - Creates a directory with input.json describing all resources. - This directory can be volume-mounted into the container. - - If assets_path is a directory, it is used directly (zero-copy mount). - If assets_path is a file (e.g., tar.gz), it is extracted first. - - :param assets_path: Path to the assets (file or directory). - :param configuration: Optional module configuration dict. - :param project_path: Project directory for storing inputs in .fuzzforge/. - :param execution_id: Execution ID for organizing inputs. - :returns: Path to prepared input directory. - :raises SandboxError: If preparation fails. - - """ - logger = get_logger() - - logger.info("preparing input directory", assets=str(assets_path)) - - try: - # If assets_path is already a directory, use it directly (zero-copy mount) - if assets_path.exists() and assets_path.is_dir(): - # Create input.json directly in the source directory - input_json_path = assets_path / "input.json" - - # Scan files and build resource list - resources = [] - for item in assets_path.iterdir(): - if item.name == "input.json": - continue - if item.is_file(): - resources.append( - { - "name": item.stem, - "description": f"Input file: {item.name}", - "kind": "unknown", - "path": f"{SANDBOX_INPUT_DIRECTORY}/{item.name}", - } - ) - elif item.is_dir(): - resources.append( - { - "name": item.name, - "description": f"Input directory: {item.name}", - "kind": "unknown", - "path": f"{SANDBOX_INPUT_DIRECTORY}/{item.name}", - } - ) - - input_data = { - "settings": configuration or {}, - "resources": resources, - } - input_json_path.write_text(json.dumps(input_data, indent=2)) - - logger.debug("using source directory directly", path=str(assets_path)) - return assets_path - - # File input: extract to a directory first - # Determine input directory location - if project_path: - # Store inputs in .fuzzforge/inputs/ for visibility - from fuzzforge_runner.storage import FUZZFORGE_DIR_NAME - exec_id = execution_id or "latest" - input_dir = project_path / FUZZFORGE_DIR_NAME / "inputs" / exec_id - input_dir.mkdir(parents=True, exist_ok=True) - # Clean previous contents if exists - import shutil - for item in input_dir.iterdir(): - if item.is_file(): - item.unlink() - elif item.is_dir(): - shutil.rmtree(item) - else: - # Fallback to temporary directory - from tempfile import mkdtemp - input_dir = Path(mkdtemp(prefix="fuzzforge-input-")) - - # Copy/extract assets to input directory - if assets_path.exists(): - if assets_path.is_file(): - # Check if it's a tar.gz archive that needs extraction - if assets_path.suffix == ".gz" or assets_path.name.endswith(".tar.gz"): - # Extract archive contents - import tarfile - - with tarfile.open(assets_path, "r:gz") as tar: - tar.extractall(path=input_dir) - logger.debug("extracted tar.gz archive", archive=str(assets_path)) - else: - # Single file - copy it - import shutil - - shutil.copy2(assets_path, input_dir / assets_path.name) - else: - # Directory - copy all files (including subdirectories) - import shutil - - for item in assets_path.iterdir(): - if item.is_file(): - shutil.copy2(item, input_dir / item.name) - elif item.is_dir(): - shutil.copytree(item, input_dir / item.name, dirs_exist_ok=True) - - # Scan files and directories and build resource list - resources = [] - for item in input_dir.iterdir(): - if item.name == "input.json": - continue - if item.is_file(): - resources.append( - { - "name": item.stem, - "description": f"Input file: {item.name}", - "kind": "unknown", - "path": f"{SANDBOX_INPUT_DIRECTORY}/{item.name}", - } - ) - elif item.is_dir(): - resources.append( - { - "name": item.name, - "description": f"Input directory: {item.name}", - "kind": "unknown", - "path": f"{SANDBOX_INPUT_DIRECTORY}/{item.name}", - } - ) - - # Create input.json with settings and resources - input_data = { - "settings": configuration or {}, - "resources": resources, - } - input_json_path = input_dir / "input.json" - input_json_path.write_text(json.dumps(input_data, indent=2)) - - logger.debug("prepared input directory", resources=len(resources), path=str(input_dir)) - return input_dir - - except Exception as exc: - message = f"Failed to prepare input directory" - raise SandboxError(message) from exc - - def _push_config_to_sandbox(self, sandbox: str, configuration: dict[str, Any]) -> None: - """Write module configuration to sandbox as config.json. - - :param sandbox: The sandbox container identifier. - :param configuration: Configuration dictionary to write. - - """ - logger = get_logger() - engine = self._get_engine() - - logger.info("writing configuration to sandbox", sandbox=sandbox) - - with NamedTemporaryFile(mode="w", suffix=".json", delete=False) as config_file: - config_path = Path(config_file.name) - config_file.write(json.dumps(configuration, indent=2)) - - try: - engine.copy_to_container(sandbox, config_path, SANDBOX_INPUT_DIRECTORY) - except Exception as exc: - message = f"Failed to copy config.json: {exc}" - raise SandboxError(message) from exc - finally: - config_path.unlink() - - def run_module(self, sandbox: str) -> None: - """Start the sandbox and execute the module. - - :param sandbox: The sandbox container identifier. - :raises ModuleExecutionError: If module execution fails. - - """ - logger = get_logger() - engine = self._get_engine() - - logger.info("starting sandbox and running module", sandbox=sandbox) - try: - # The container runs its ENTRYPOINT (uv run module) when started - exit_code, stdout, stderr = engine.start_container_attached(sandbox, timeout=600) - - if exit_code != 0: - logger.error("module execution failed", sandbox=sandbox, stderr=stderr) - message = f"Module execution failed: {stderr}" - raise ModuleExecutionError(message) - logger.info("module execution completed", sandbox=sandbox) - - except TimeoutError as exc: - message = f"Module execution timed out after 10 minutes in sandbox {sandbox}" - raise ModuleExecutionError(message) from exc - except ModuleExecutionError: - raise - except Exception as exc: - message = f"Module execution failed in sandbox {sandbox}" - raise ModuleExecutionError(message) from exc - - def pull_results_from_sandbox(self, sandbox: str) -> Path: - """Pull the results archive from the sandbox. - - :param sandbox: The sandbox container identifier. - :returns: Path to the downloaded results archive (tar.gz file). - :raises SandboxError: If pull operation fails. - - """ - logger = get_logger() - engine = self._get_engine() - - logger.info("pulling results from sandbox", sandbox=sandbox) - try: - # Create temporary directory for results - from tempfile import mkdtemp - - temp_dir = Path(mkdtemp(prefix="fuzzforge-results-")) - - # Copy entire output directory from container - try: - engine.copy_from_container(sandbox, SANDBOX_OUTPUT_DIRECTORY, temp_dir) - except Exception: - # If output directory doesn't exist, that's okay - module may not have produced results - logger.warning("no results found in sandbox", sandbox=sandbox) - - # Create tar archive from results directory - import tarfile - - archive_file = NamedTemporaryFile(delete=False, suffix=".tar.gz") - archive_path = Path(archive_file.name) - archive_file.close() - - with tarfile.open(archive_path, "w:gz") as tar: - # The output is extracted into a subdirectory named after the source - output_subdir = temp_dir / "output" - if output_subdir.exists(): - for item in output_subdir.iterdir(): - tar.add(item, arcname=item.name) - else: - for item in temp_dir.iterdir(): - tar.add(item, arcname=item.name) - - # Clean up temp directory - import shutil - - shutil.rmtree(temp_dir, ignore_errors=True) - - logger.info("results pulled successfully", sandbox=sandbox, archive=str(archive_path)) - return archive_path - - except TimeoutError as exc: - message = f"Timeout pulling results from sandbox {sandbox}" - raise SandboxError(message) from exc - except Exception as exc: - message = f"Failed to pull results from sandbox {sandbox}" - raise SandboxError(message) from exc - - def terminate_sandbox(self, sandbox: str) -> None: - """Terminate and cleanup the sandbox container. - - :param sandbox: The sandbox container identifier. - - """ - logger = get_logger() - engine = self._get_engine() - - logger.info("terminating sandbox", sandbox=sandbox) - try: - engine.remove_container(sandbox, force=True) - logger.info("sandbox terminated", sandbox=sandbox) - except Exception as exc: - # Log but don't raise - cleanup should be best-effort - logger.warning("failed to terminate sandbox", sandbox=sandbox, error=str(exc)) - - async def execute( - self, - module_identifier: str, - assets_path: Path, - configuration: dict[str, Any] | None = None, - project_path: Path | None = None, - execution_id: str | None = None, - ) -> Path: - """Execute a module end-to-end. - - This is the main entry point that handles the complete execution flow: - 1. Spawn sandbox - 2. Push assets and configuration - 3. Run module - 4. Pull results - 5. Terminate sandbox - - All intermediate files are stored in {project_path}/.fuzzforge/ for - easy debugging and visibility. - - Source directories are mounted directly without tar.gz compression - for better performance. - - :param module_identifier: Name/identifier of the module to execute. - :param assets_path: Path to the input assets (file or directory). - :param configuration: Optional module configuration. - :param project_path: Project directory for .fuzzforge/ storage. - :param execution_id: Execution ID for organizing files. - :returns: Path to the results archive. - :raises ModuleExecutionError: If any step fails. - - """ - logger = get_logger() - sandbox: str | None = None - input_dir: Path | None = None - # Don't cleanup if we're using the source directory directly - cleanup_input = False - - try: - # 1. Prepare input directory with assets - input_dir = self.prepare_input_directory( - assets_path, - configuration, - project_path=project_path, - execution_id=execution_id, - ) - - # Only cleanup if we created a temp directory (file input case) - cleanup_input = input_dir != assets_path and project_path is None - - # 2. Spawn sandbox with volume mount - sandbox = self.spawn_sandbox(module_identifier, input_volume=input_dir) - - # 3. Run module - self.run_module(sandbox) - - # 4. Pull results - results_path = self.pull_results_from_sandbox(sandbox) - - logger.info( - "module execution completed successfully", - module=module_identifier, - results=str(results_path), - ) - - return results_path - - finally: - # 5. Always cleanup sandbox - if sandbox: - self.terminate_sandbox(sandbox) - # Only cleanup input if it was a temp directory - if cleanup_input and input_dir and input_dir.exists(): - import shutil - shutil.rmtree(input_dir, ignore_errors=True) - - # ------------------------------------------------------------------------- - # Continuous/Background Execution Methods - # ------------------------------------------------------------------------- - - def start_module_continuous( - self, - module_identifier: str, - assets_path: Path, - configuration: dict[str, Any] | None = None, - project_path: Path | None = None, - execution_id: str | None = None, - ) -> dict[str, Any]: - """Start a module in continuous/background mode without waiting. - - Returns immediately with container info. Use read_module_output() to - get current status and stop_module_continuous() to stop. - - Source directories are mounted directly without tar.gz compression - for better performance. - - :param module_identifier: Name/identifier of the module to execute. - :param assets_path: Path to the input assets (file or directory). - :param configuration: Optional module configuration. - :param project_path: Project directory for .fuzzforge/ storage. - :param execution_id: Execution ID for organizing files. - :returns: Dict with container_id, input_dir for later cleanup. - - """ - logger = get_logger() - - # 1. Prepare input directory with assets - input_dir = self.prepare_input_directory( - assets_path, - configuration, - project_path=project_path, - execution_id=execution_id, - ) - - # 2. Spawn sandbox with volume mount - sandbox = self.spawn_sandbox(module_identifier, input_volume=input_dir) - - # 3. Start container (non-blocking) - engine = self._get_engine() - engine.start_container(sandbox) - - logger.info( - "module started in continuous mode", - module=module_identifier, - container_id=sandbox, - ) - - return { - "container_id": sandbox, - "input_dir": str(input_dir), - "module": module_identifier, - } - - def read_module_output(self, container_id: str, output_file: str = f"{SANDBOX_OUTPUT_DIRECTORY}/stream.jsonl") -> str: - """Read output file from a running module container. - - :param container_id: The container identifier. - :param output_file: Path to output file inside container. - :returns: File contents as string. - - """ - engine = self._get_engine() - return engine.read_file_from_container(container_id, output_file) - - def read_module_output_incremental( - self, - container_id: str, - start_line: int = 1, - output_file: str = f"{SANDBOX_OUTPUT_DIRECTORY}/stream.jsonl", - ) -> str: - """Read new lines from an output file inside a running module container. - - Uses ``tail -n +{start_line}`` so only lines appended since the last - read are returned. Callers should track the number of lines already - consumed and pass ``start_line = previous_count + 1`` on the next call. - - :param container_id: The container identifier. - :param start_line: 1-based line number to start reading from. - :param output_file: Path to output file inside container. - :returns: New file contents from *start_line* onwards (may be empty). - - """ - engine = self._get_engine() - return engine.tail_file_from_container(container_id, output_file, start_line=start_line) - - def get_module_status(self, container_id: str) -> str: - """Get the status of a running module container. - - :param container_id: The container identifier. - :returns: Container status (e.g., "running", "exited"). - - """ - engine = self._get_engine() - return engine.get_container_status(container_id) - - def stop_module_continuous(self, container_id: str, input_dir: str | None = None) -> Path: - """Stop a continuously running module and collect results. - - :param container_id: The container identifier. - :param input_dir: Optional input directory to cleanup. - :returns: Path to the results archive. - - """ - logger = get_logger() - engine = self._get_engine() - - try: - # 1. Stop the container gracefully - status = engine.get_container_status(container_id) - if status == "running": - engine.stop_container(container_id, timeout=10) - logger.info("stopped running container", container_id=container_id) - - # 2. Pull results - results_path = self.pull_results_from_sandbox(container_id) - - logger.info("collected results from continuous session", results=str(results_path)) - - return results_path - - finally: - # 3. Cleanup - self.terminate_sandbox(container_id) - if input_dir: - import shutil - - shutil.rmtree(input_dir, ignore_errors=True) diff --git a/fuzzforge-runner/src/fuzzforge_runner/orchestrator.py b/fuzzforge-runner/src/fuzzforge_runner/orchestrator.py deleted file mode 100644 index aaaa1f5..0000000 --- a/fuzzforge-runner/src/fuzzforge_runner/orchestrator.py +++ /dev/null @@ -1,361 +0,0 @@ -"""FuzzForge Runner - Workflow orchestration without Temporal. - -This module provides simplified workflow orchestration for sequential -module execution without requiring Temporal infrastructure. - -""" - -from __future__ import annotations - -from dataclasses import dataclass, field -from datetime import UTC, datetime -from pathlib import Path -from typing import TYPE_CHECKING, Any, cast -from uuid import uuid4 - -from fuzzforge_runner.constants import FuzzForgeExecutionIdentifier -from fuzzforge_runner.exceptions import WorkflowExecutionError -from fuzzforge_runner.executor import ModuleExecutor - -if TYPE_CHECKING: - from fuzzforge_runner.settings import Settings - from fuzzforge_runner.storage import LocalStorage - from structlog.stdlib import BoundLogger - - -def get_logger() -> BoundLogger: - """Get structlog logger instance. - - :returns: Configured structlog logger. - - """ - from structlog import get_logger # noqa: PLC0415 - - return cast("BoundLogger", get_logger()) - - -@dataclass -class WorkflowStep: - """Represents a single step in a workflow.""" - - #: Module identifier to execute. - module_identifier: str - - #: Optional configuration for the module. - configuration: dict[str, Any] | None = None - - #: Step name/label for logging. - name: str | None = None - - -@dataclass -class WorkflowDefinition: - """Defines a workflow as a sequence of module executions.""" - - #: Workflow name. - name: str - - #: Ordered list of steps to execute. - steps: list[WorkflowStep] = field(default_factory=list) - - #: Optional workflow description. - description: str | None = None - - -@dataclass -class StepResult: - """Result of a single workflow step execution.""" - - #: Step index (0-based). - step_index: int - - #: Module that was executed. - module_identifier: str - - #: Path to the results archive. - results_path: Path - - #: Execution identifier. - execution_id: str - - #: Execution start time. - started_at: datetime - - #: Execution end time. - completed_at: datetime - - #: Whether execution was successful. - success: bool = True - - #: Error message if failed. - error: str | None = None - - -@dataclass -class WorkflowResult: - """Result of a complete workflow execution.""" - - #: Workflow execution identifier. - execution_id: str - - #: Workflow name. - name: str - - #: Results for each step. - steps: list[StepResult] = field(default_factory=list) - - #: Overall success status. - success: bool = True - - #: Final results path (from last step). - final_results_path: Path | None = None - - -class WorkflowOrchestrator: - """Orchestrates sequential workflow execution. - - Executes workflow steps sequentially, passing output from each - module as input to the next. No Temporal required. - - """ - - #: Module executor instance. - _executor: ModuleExecutor - - #: Storage backend. - _storage: LocalStorage - - def __init__(self, executor: ModuleExecutor, storage: LocalStorage) -> None: - """Initialize an instance of the class. - - :param executor: Module executor for running modules. - :param storage: Storage backend for managing assets. - - """ - self._executor = executor - self._storage = storage - - def _generate_execution_id(self) -> str: - """Generate a unique execution identifier. - - :returns: UUID string for execution tracking. - - """ - return str(uuid4()) - - async def execute_workflow( - self, - workflow: WorkflowDefinition, - project_path: Path, - initial_assets_path: Path | None = None, - ) -> WorkflowResult: - """Execute a workflow as a sequence of module executions. - - Each step receives the output of the previous step as input. - The first step receives the initial assets. - - :param workflow: Workflow definition with steps to execute. - :param project_path: Path to the project directory. - :param initial_assets_path: Path to initial assets (optional). - :returns: Workflow execution result. - :raises WorkflowExecutionError: If workflow execution fails. - - """ - logger = get_logger() - workflow_id = self._generate_execution_id() - - logger.info( - "starting workflow execution", - workflow=workflow.name, - execution_id=workflow_id, - steps=len(workflow.steps), - ) - - result = WorkflowResult( - execution_id=workflow_id, - name=workflow.name, - ) - - if not workflow.steps: - logger.warning("workflow has no steps", workflow=workflow.name) - return result - - # Track current assets path - starts with initial assets, then uses previous step output - current_assets: Path | None = initial_assets_path - - # If no initial assets, try to get from project - if current_assets is None: - current_assets = self._storage.get_project_assets_path(project_path) - - try: - for step_index, step in enumerate(workflow.steps): - step_name = step.name or f"step-{step_index}" - step_execution_id = self._generate_execution_id() - - logger.info( - "executing workflow step", - workflow=workflow.name, - step=step_name, - step_index=step_index, - module=step.module_identifier, - execution_id=step_execution_id, - ) - - started_at = datetime.now(UTC) - - try: - # Ensure we have assets for this step - if current_assets is None or not current_assets.exists(): - if step_index == 0: - # First step with no assets - create empty archive - current_assets = self._storage.create_empty_assets_archive(project_path) - else: - message = f"No assets available for step {step_index}" - raise WorkflowExecutionError(message) - - # Execute the module (inputs stored in .fuzzforge/inputs/) - results_path = await self._executor.execute( - module_identifier=step.module_identifier, - assets_path=current_assets, - configuration=step.configuration, - project_path=project_path, - execution_id=step_execution_id, - ) - - completed_at = datetime.now(UTC) - - # Store results to persistent storage - stored_path = self._storage.store_execution_results( - project_path=project_path, - workflow_id=workflow_id, - step_index=step_index, - execution_id=step_execution_id, - results_path=results_path, - ) - - # Clean up temporary results archive after storing - try: - if results_path.exists() and results_path != stored_path: - results_path.unlink() - except Exception as cleanup_exc: - logger.warning("failed to clean up temporary results", path=str(results_path), error=str(cleanup_exc)) - - # Record step result with stored path - step_result = StepResult( - step_index=step_index, - module_identifier=step.module_identifier, - results_path=stored_path, - execution_id=step_execution_id, - started_at=started_at, - completed_at=completed_at, - success=True, - ) - result.steps.append(step_result) - - # Next step uses this step's output - current_assets = stored_path - - logger.info( - "workflow step completed", - step=step_name, - step_index=step_index, - duration_seconds=(completed_at - started_at).total_seconds(), - ) - - except Exception as exc: - completed_at = datetime.now(UTC) - error_msg = str(exc) - - step_result = StepResult( - step_index=step_index, - module_identifier=step.module_identifier, - results_path=Path(), - execution_id=step_execution_id, - started_at=started_at, - completed_at=completed_at, - success=False, - error=error_msg, - ) - result.steps.append(step_result) - result.success = False - - logger.error( - "workflow step failed", - step=step_name, - step_index=step_index, - error=error_msg, - ) - - # Stop workflow on failure - break - - # Set final results path - if result.steps and result.steps[-1].success: - result.final_results_path = result.steps[-1].results_path - - logger.info( - "workflow execution completed", - workflow=workflow.name, - execution_id=workflow_id, - success=result.success, - completed_steps=len([s for s in result.steps if s.success]), - total_steps=len(workflow.steps), - ) - - return result - - except Exception as exc: - message = f"Workflow execution failed: {exc}" - logger.exception("workflow execution error", workflow=workflow.name) - raise WorkflowExecutionError(message) from exc - - async def execute_single_module( - self, - module_identifier: str, - project_path: Path, - assets_path: Path | None = None, - configuration: dict[str, Any] | None = None, - ) -> StepResult: - """Execute a single module (convenience method). - - This is a simplified interface for executing a single module - outside of a workflow context. - - :param module_identifier: Module to execute. - :param project_path: Project directory path. - :param assets_path: Optional path to input assets. - :param configuration: Optional module configuration. - :returns: Execution result. - - """ - workflow = WorkflowDefinition( - name=f"single-{module_identifier}", - steps=[ - WorkflowStep( - module_identifier=module_identifier, - configuration=configuration, - name="main", - ) - ], - ) - - result = await self.execute_workflow( - workflow=workflow, - project_path=project_path, - initial_assets_path=assets_path, - ) - - if result.steps: - return result.steps[0] - - # Should not happen, but handle gracefully - return StepResult( - step_index=0, - module_identifier=module_identifier, - results_path=Path(), - execution_id=result.execution_id, - started_at=datetime.now(UTC), - completed_at=datetime.now(UTC), - success=False, - error="No step results produced", - ) diff --git a/fuzzforge-runner/src/fuzzforge_runner/runner.py b/fuzzforge-runner/src/fuzzforge_runner/runner.py deleted file mode 100644 index 01bc525..0000000 --- a/fuzzforge-runner/src/fuzzforge_runner/runner.py +++ /dev/null @@ -1,452 +0,0 @@ -"""FuzzForge Runner - Main runner interface. - -This module provides the high-level interface for FuzzForge AI, -coordinating module execution, workflow orchestration, and storage. - -""" - -from __future__ import annotations - -from dataclasses import dataclass -from pathlib import Path -from typing import TYPE_CHECKING, Any, cast - -from fuzzforge_runner.executor import ModuleExecutor -from fuzzforge_runner.orchestrator import ( - StepResult, - WorkflowDefinition, - WorkflowOrchestrator, - WorkflowResult, - WorkflowStep, -) -from fuzzforge_runner.settings import Settings -from fuzzforge_runner.storage import LocalStorage - -if TYPE_CHECKING: - from structlog.stdlib import BoundLogger - - -def get_logger() -> BoundLogger: - """Get structlog logger instance. - - :returns: Configured structlog logger. - - """ - from structlog import get_logger # noqa: PLC0415 - - return cast("BoundLogger", get_logger()) - - -@dataclass -class ModuleInfo: - """Information about an available module.""" - - #: Module identifier/name. - identifier: str - - #: Module description. - description: str | None = None - - #: Module version. - version: str | None = None - - #: Whether module image exists locally. - available: bool = True - - #: Module identifiers that should run before this one. - suggested_predecessors: list[str] | None = None - - #: Whether module supports continuous/background execution. - continuous_mode: bool = False - - #: Typical use cases and scenarios for this module. - use_cases: list[str] | None = None - - #: Common inputs (e.g., ["rust-source-code", "Cargo.toml"]). - common_inputs: list[str] | None = None - - #: Output artifacts produced (e.g., ["fuzzable_functions.json"]). - output_artifacts: list[str] | None = None - - #: How AI should display/treat outputs. - output_treatment: str | None = None - - -class Runner: - """Main FuzzForge Runner interface. - - Provides a unified interface for: - - Module discovery and execution - - Workflow orchestration - - Project and asset management - - This is the primary entry point for OSS users and the MCP server. - - """ - - #: Runner settings. - _settings: Settings - - #: Module executor. - _executor: ModuleExecutor - - #: Local storage backend. - _storage: LocalStorage - - #: Workflow orchestrator. - _orchestrator: WorkflowOrchestrator - - def __init__(self, settings: Settings | None = None) -> None: - """Initialize an instance of the class. - - :param settings: Runner settings. If None, loads from environment. - - """ - self._settings = settings or Settings() - self._executor = ModuleExecutor(self._settings) - self._storage = LocalStorage(self._settings.storage.path) - self._orchestrator = WorkflowOrchestrator(self._executor, self._storage) - - @property - def settings(self) -> Settings: - """Get runner settings. - - :returns: Current settings instance. - - """ - return self._settings - - @property - def storage(self) -> LocalStorage: - """Get storage backend. - - :returns: Storage instance. - - """ - return self._storage - - # ------------------------------------------------------------------------- - # Project Management - # ------------------------------------------------------------------------- - - def init_project(self, project_path: Path) -> Path: - """Initialize a new project. - - Creates necessary storage directories for a project. - - :param project_path: Path to the project directory. - :returns: Path to the project storage directory. - - """ - logger = get_logger() - logger.info("initializing project", path=str(project_path)) - return self._storage.init_project(project_path) - - def set_project_assets(self, project_path: Path, assets_path: Path) -> Path: - """Set source path for a project (no copying). - - Just stores a reference to the source directory. - The source is mounted directly into containers at runtime. - - :param project_path: Path to the project directory. - :param assets_path: Path to source directory. - :returns: The assets path (unchanged). - - """ - logger = get_logger() - logger.info("setting project assets", project=str(project_path), assets=str(assets_path)) - return self._storage.set_project_assets(project_path, assets_path) - - # ------------------------------------------------------------------------- - # Module Discovery - # ------------------------------------------------------------------------- - - def list_modules(self) -> list[ModuleInfo]: - """List available modules. - - Discovers modules from the configured modules directory. - - :returns: List of available modules. - - """ - logger = get_logger() - modules: list[ModuleInfo] = [] - - modules_path = self._settings.modules_path - if not modules_path.exists(): - logger.warning("modules directory not found", path=str(modules_path)) - return modules - - # Look for module directories (each should have a Dockerfile or be a built image) - for item in modules_path.iterdir(): - if item.is_dir(): - # Check for module markers - has_dockerfile = (item / "Dockerfile").exists() - has_pyproject = (item / "pyproject.toml").exists() - - if has_dockerfile or has_pyproject: - modules.append( - ModuleInfo( - identifier=item.name, - available=has_dockerfile, - ) - ) - - logger.info("discovered modules", count=len(modules)) - return modules - - def list_module_images( - self, - filter_prefix: str = "fuzzforge-", - include_all_tags: bool = True, - ) -> list[ModuleInfo]: - """List available module images from the container engine. - - Uses the container engine API to discover built module images. - Reads metadata from pyproject.toml inside each image. - - :param filter_prefix: Prefix to filter images (default: "fuzzforge-"). - :param include_all_tags: If True, include all image tags, not just 'latest'. - :returns: List of available module images. - - """ - import tomllib # noqa: PLC0415 - - logger = get_logger() - modules: list[ModuleInfo] = [] - seen: set[str] = set() - - # Infrastructure images to skip - skip_images = {"fuzzforge-modules-sdk", "fuzzforge-runner", "fuzzforge-api"} - - engine = self._executor._get_engine() - images = engine.list_images(filter_prefix=filter_prefix) - - for image in images: - # Only include :latest images unless include_all_tags is set - if not include_all_tags and image.tag != "latest": - continue - - # Extract module name from repository - full_name = image.repository.split("/")[-1] - - # Skip infrastructure images - if full_name in skip_images: - continue - - # Extract clean module name (remove fuzzforge-module- prefix if present) - if full_name.startswith("fuzzforge-module-"): - module_name = full_name.replace("fuzzforge-module-", "") - else: - module_name = full_name - - # Skip UUID-like names (temporary/broken containers) - if module_name.count("-") >= 4 and len(module_name) > 30: - continue - - # Add unique modules - if module_name not in seen: - seen.add(module_name) - - # Read metadata from pyproject.toml inside the image - image_ref = f"{image.repository}:{image.tag}" - module_meta = self._get_module_metadata_from_image(engine, image_ref) - - # Get basic info from pyproject.toml [project] section - project_info = module_meta.get("_project", {}) - fuzzforge_meta = module_meta.get("module", {}) - - modules.append( - ModuleInfo( - identifier=fuzzforge_meta.get("identifier", module_name), - description=project_info.get("description"), - version=project_info.get("version", image.tag), - available=True, - suggested_predecessors=fuzzforge_meta.get("suggested_predecessors", []), - continuous_mode=fuzzforge_meta.get("continuous_mode", False), - use_cases=fuzzforge_meta.get("use_cases", []), - common_inputs=fuzzforge_meta.get("common_inputs", []), - output_artifacts=fuzzforge_meta.get("output_artifacts", []), - output_treatment=fuzzforge_meta.get("output_treatment"), - ) - ) - - logger.info("listed module images", count=len(modules)) - return modules - - def _get_module_metadata_from_image(self, engine: Any, image_ref: str) -> dict: - """Read module metadata from pyproject.toml inside a container image. - - :param engine: Container engine instance. - :param image_ref: Image reference (e.g., "fuzzforge-rust-analyzer:latest"). - :returns: Dict with module metadata from [tool.fuzzforge] section. - - """ - import tomllib # noqa: PLC0415 - - logger = get_logger() - - try: - # Read pyproject.toml from the image - content = engine.read_file_from_image(image_ref, "/app/pyproject.toml") - if not content: - logger.debug("no pyproject.toml found in image", image=image_ref) - return {} - - pyproject = tomllib.loads(content) - - # Return the [tool.fuzzforge] section plus [project] info - result = pyproject.get("tool", {}).get("fuzzforge", {}) - result["_project"] = pyproject.get("project", {}) - return result - - except Exception as exc: - logger.debug("failed to read metadata from image", image=image_ref, error=str(exc)) - return {} - - def get_module_info(self, module_identifier: str) -> ModuleInfo | None: - """Get information about a specific module. - - :param module_identifier: Module identifier to look up. - :returns: Module info, or None if not found. - - """ - modules = self.list_modules() - for module in modules: - if module.identifier == module_identifier: - return module - return None - - # ------------------------------------------------------------------------- - # Module Execution - # ------------------------------------------------------------------------- - - async def execute_module( - self, - module_identifier: str, - project_path: Path, - configuration: dict[str, Any] | None = None, - assets_path: Path | None = None, - ) -> StepResult: - """Execute a single module. - - :param module_identifier: Module to execute. - :param project_path: Path to the project directory. - :param configuration: Optional module configuration. - :param assets_path: Optional path to input assets. - :returns: Execution result. - - """ - logger = get_logger() - logger.info( - "executing module", - module=module_identifier, - project=str(project_path), - ) - - return await self._orchestrator.execute_single_module( - module_identifier=module_identifier, - project_path=project_path, - assets_path=assets_path, - configuration=configuration, - ) - - # ------------------------------------------------------------------------- - # Workflow Execution - # ------------------------------------------------------------------------- - - async def execute_workflow( - self, - workflow: WorkflowDefinition, - project_path: Path, - initial_assets_path: Path | None = None, - ) -> WorkflowResult: - """Execute a workflow. - - :param workflow: Workflow definition with steps. - :param project_path: Path to the project directory. - :param initial_assets_path: Optional path to initial assets. - :returns: Workflow execution result. - - """ - logger = get_logger() - logger.info( - "executing workflow", - workflow=workflow.name, - project=str(project_path), - steps=len(workflow.steps), - ) - - return await self._orchestrator.execute_workflow( - workflow=workflow, - project_path=project_path, - initial_assets_path=initial_assets_path, - ) - - def create_workflow( - self, - name: str, - steps: list[tuple[str, dict[str, Any] | None]], - description: str | None = None, - ) -> WorkflowDefinition: - """Create a workflow definition. - - Convenience method for creating workflows programmatically. - - :param name: Workflow name. - :param steps: List of (module_identifier, configuration) tuples. - :param description: Optional workflow description. - :returns: Workflow definition. - - """ - workflow_steps = [ - WorkflowStep( - module_identifier=module_id, - configuration=config, - name=f"step-{i}", - ) - for i, (module_id, config) in enumerate(steps) - ] - - return WorkflowDefinition( - name=name, - steps=workflow_steps, - description=description, - ) - - # ------------------------------------------------------------------------- - # Results Management - # ------------------------------------------------------------------------- - - def get_execution_results( - self, - project_path: Path, - execution_id: str, - ) -> Path | None: - """Get results for an execution. - - :param project_path: Path to the project directory. - :param execution_id: Execution ID. - :returns: Path to results archive, or None if not found. - - """ - return self._storage.get_execution_results(project_path, execution_id) - - def list_executions(self, project_path: Path) -> list[str]: - """List all executions for a project. - - :param project_path: Path to the project directory. - :returns: List of execution IDs. - - """ - return self._storage.list_executions(project_path) - - def extract_results(self, results_path: Path, destination: Path) -> Path: - """Extract results archive to a directory. - - :param results_path: Path to results archive. - :param destination: Destination directory. - :returns: Path to extracted directory. - - """ - return self._storage.extract_results(results_path, destination) diff --git a/fuzzforge-runner/src/fuzzforge_runner/storage.py b/fuzzforge-runner/src/fuzzforge_runner/storage.py deleted file mode 100644 index f39f93e..0000000 --- a/fuzzforge-runner/src/fuzzforge_runner/storage.py +++ /dev/null @@ -1,363 +0,0 @@ -"""FuzzForge Runner - Local filesystem storage. - -This module provides local filesystem storage for OSS deployments. - -Storage is placed directly in the project directory as `.fuzzforge/` -for maximum visibility and ease of debugging. - -In OSS mode, source files are referenced (not copied) and mounted -directly into containers at runtime for zero-copy performance. - -""" - -from __future__ import annotations - -import shutil -from pathlib import Path -from tarfile import open as Archive # noqa: N812 -from typing import TYPE_CHECKING, cast - -from fuzzforge_runner.constants import RESULTS_ARCHIVE_FILENAME -from fuzzforge_runner.exceptions import StorageError - -if TYPE_CHECKING: - from structlog.stdlib import BoundLogger - -#: Name of the FuzzForge storage directory within projects. -FUZZFORGE_DIR_NAME: str = ".fuzzforge" - - -def get_logger() -> BoundLogger: - """Get structlog logger instance. - - :returns: Configured structlog logger. - - """ - from structlog import get_logger # noqa: PLC0415 - - return cast("BoundLogger", get_logger()) - - -class LocalStorage: - """Local filesystem storage backend for FuzzForge AI. - - Provides lightweight storage for execution results while using - direct source mounting (no copying) for input assets. - - Storage is placed directly in the project directory as `.fuzzforge/` - so users can easily inspect outputs and configuration. - - Directory structure (inside project directory): - {project_path}/.fuzzforge/ - config.json # Project config (source path reference) - runs/ # Execution results - {execution_id}/ - results.tar.gz - {workflow_id}/ - modules/ - step-0-{exec_id}/ - results.tar.gz - - Source files are NOT copied - they are referenced and mounted directly. - - """ - - #: Base path for global storage (only used for fallback/config). - _base_path: Path - - def __init__(self, base_path: Path) -> None: - """Initialize an instance of the class. - - :param base_path: Root directory for global storage (fallback only). - - """ - self._base_path = base_path - self._ensure_base_path() - - def _ensure_base_path(self) -> None: - """Ensure the base storage directory exists.""" - self._base_path.mkdir(parents=True, exist_ok=True) - - def _get_project_path(self, project_path: Path) -> Path: - """Get the storage path for a project. - - Storage is placed directly inside the project as `.fuzzforge/`. - - :param project_path: Path to the project directory. - :returns: Storage path for the project (.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: - - assets/: Input files (source code, etc.) - - inputs/: Prepared module inputs (for debugging) - - runs/: Execution results from each module - - :param project_path: Path to the project directory. - :returns: Path to the project storage directory. - - """ - logger = get_logger() - storage_path = self._get_project_path(project_path) - - # Create directory structure (minimal for OSS) - 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_content = """# FuzzForge storage - ignore large/temporary files -# Execution results (can be very large) -runs/ - -# Project configuration -!config.json -""" - gitignore_path.write_text(gitignore_content) - - logger.info("initialized project storage", project=project_path.name, storage=str(storage_path)) - - return storage_path - - def get_project_assets_path(self, project_path: Path) -> Path | None: - """Get the path to project assets (source directory). - - Returns the configured source path for the project. - In OSS mode, this is just a reference to the user's source - no copying. - - :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(): - import json - 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 - - # Fallback: check if project_path itself is the source - # (common case: user runs from their project directory) - if (project_path / "Cargo.toml").exists() or (project_path / "src").exists(): - return project_path - - return None - - def set_project_assets(self, project_path: Path, assets_path: Path) -> Path: - """Set the source path for a project (no copying). - - Just stores a reference to the source directory. - The source is mounted directly into containers at runtime. - - :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. - - """ - import json - - logger = get_logger() - - if not assets_path.exists(): - raise StorageError(f"Assets path does not exist: {assets_path}") - - # Resolve to absolute path - assets_path = assets_path.resolve() - - # Store reference in config - 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", project=project_path.name, source=str(assets_path)) - return assets_path - - def store_execution_results( - self, - project_path: Path, - workflow_id: str | None, - step_index: int, - execution_id: str, - results_path: Path, - ) -> Path: - """Store execution results. - - :param project_path: Path to the project directory. - :param workflow_id: Workflow execution ID (None for standalone). - :param step_index: Step index in workflow. - :param execution_id: Module execution ID. - :param results_path: Path to results archive to store. - :returns: Path to the stored results. - :raises StorageError: If storage operation fails. - - """ - logger = get_logger() - storage_path = self._get_project_path(project_path) - - try: - if workflow_id: - # Part of workflow - dest_dir = storage_path / "runs" / workflow_id / "modules" / f"step-{step_index}-{execution_id}" - else: - # Standalone execution - dest_dir = storage_path / "runs" / execution_id - - dest_dir.mkdir(parents=True, exist_ok=True) - dest_path = dest_dir / RESULTS_ARCHIVE_FILENAME - - shutil.copy2(results_path, dest_path) - - logger.info( - "stored execution results", - execution_id=execution_id, - path=str(dest_path), - ) - - return dest_path - - except Exception as exc: - message = f"Failed to store results: {exc}" - raise StorageError(message) from exc - - def get_execution_results( - self, - project_path: Path, - execution_id: str, - workflow_id: str | None = None, - step_index: int | None = None, - ) -> Path | None: - """Retrieve execution results. - - :param project_path: Path to the project directory. - :param execution_id: Module execution ID. - :param workflow_id: Workflow execution ID (None for standalone). - :param step_index: Step index in workflow. - :returns: Path to results archive, or None if not found. - - """ - storage_path = self._get_project_path(project_path) - - if workflow_id and step_index is not None: - # Direct workflow path lookup - results_path = ( - storage_path / "runs" / workflow_id / "modules" / f"step-{step_index}-{execution_id}" / RESULTS_ARCHIVE_FILENAME - ) - if results_path.exists(): - return results_path - - # Try standalone path - results_path = storage_path / "runs" / execution_id / RESULTS_ARCHIVE_FILENAME - if results_path.exists(): - return results_path - - # Search for execution_id in all workflow runs - runs_dir = storage_path / "runs" - if runs_dir.exists(): - for workflow_dir in runs_dir.iterdir(): - if not workflow_dir.is_dir(): - continue - - # Check if this is a workflow directory (has 'modules' subdirectory) - modules_dir = workflow_dir / "modules" - if modules_dir.exists() and modules_dir.is_dir(): - # Search for step directories containing this execution_id - for step_dir in modules_dir.iterdir(): - if step_dir.is_dir() and execution_id in step_dir.name: - results_path = step_dir / RESULTS_ARCHIVE_FILENAME - if results_path.exists(): - return results_path - - return None - - 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. - - """ - storage_path = self._get_project_path(project_path) - runs_dir = storage_path / "runs" - - if not runs_dir.exists(): - return [] - - return [d.name for d in runs_dir.iterdir() if d.is_dir()] - - def delete_execution(self, project_path: Path, execution_id: str) -> bool: - """Delete an execution and its results. - - :param project_path: Path to the project directory. - :param execution_id: Execution ID to delete. - :returns: True if deleted, False if not found. - - """ - logger = get_logger() - storage_path = self._get_project_path(project_path) - exec_path = storage_path / "runs" / execution_id - - if exec_path.exists(): - shutil.rmtree(exec_path) - logger.info("deleted execution", execution_id=execution_id) - return True - - return False - - def delete_project(self, project_path: Path) -> bool: - """Delete all storage for a project. - - :param project_path: Path to the project directory. - :returns: True if deleted, False if not found. - - """ - logger = get_logger() - storage_path = self._get_project_path(project_path) - - if storage_path.exists(): - shutil.rmtree(storage_path) - logger.info("deleted project storage", project=project_path.name) - return True - - return False - - 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. - - """ - logger = get_logger() - - try: - destination.mkdir(parents=True, exist_ok=True) - - with Archive(results_path, "r:gz") as tar: - tar.extractall(path=destination) - - logger.info("extracted results", source=str(results_path), destination=str(destination)) - return destination - - except Exception as exc: - message = f"Failed to extract results: {exc}" - raise StorageError(message) from exc diff --git a/fuzzforge-runner/tests/__init__.py b/fuzzforge-runner/tests/__init__.py deleted file mode 100644 index 85ff61d..0000000 --- a/fuzzforge-runner/tests/__init__.py +++ /dev/null @@ -1 +0,0 @@ -"""Tests package for fuzzforge-runner.""" diff --git a/fuzzforge-runner/tests/conftest.py b/fuzzforge-runner/tests/conftest.py deleted file mode 100644 index 29bc724..0000000 --- a/fuzzforge-runner/tests/conftest.py +++ /dev/null @@ -1,3 +0,0 @@ -"""Pytest configuration for fuzzforge-runner tests.""" - -import pytest diff --git a/fuzzforge-tests/src/fuzzforge_tests/fixtures.py b/fuzzforge-tests/src/fuzzforge_tests/fixtures.py index bedaa5c..3cb1b8e 100644 --- a/fuzzforge-tests/src/fuzzforge_tests/fixtures.py +++ b/fuzzforge-tests/src/fuzzforge_tests/fixtures.py @@ -16,10 +16,8 @@ from fuzzforge_common.sandboxes.engines.podman.configuration import PodmanConfig from podman import PodmanClient from pydantic import UUID7 -# Type aliases for identifiers (inlined from fuzzforge-types) +# Type aliases for identifiers type FuzzForgeProjectIdentifier = UUID7 -type FuzzForgeModuleIdentifier = UUID7 -type FuzzForgeWorkflowIdentifier = UUID7 type FuzzForgeExecutionIdentifier = UUID7 # Constants for validation @@ -27,14 +25,6 @@ FUZZFORGE_PROJECT_NAME_LENGTH_MIN: int = 3 FUZZFORGE_PROJECT_NAME_LENGTH_MAX: int = 64 FUZZFORGE_PROJECT_DESCRIPTION_LENGTH_MAX: int = 256 -FUZZFORGE_MODULE_NAME_LENGTH_MIN: int = 3 -FUZZFORGE_MODULE_NAME_LENGTH_MAX: int = 64 -FUZZFORGE_MODULE_DESCRIPTION_LENGTH_MAX: int = 256 - -FUZZFORGE_WORKFLOW_NAME_LENGTH_MIN: int = 3 -FUZZFORGE_WORKFLOW_NAME_LENGTH_MAX: int = 64 -FUZZFORGE_WORKFLOW_DESCRIPTION_LENGTH_MAX: int = 256 - if TYPE_CHECKING: from collections.abc import Callable, Generator from pathlib import Path @@ -49,8 +39,6 @@ def generate_random_string( # ===== Project Fixtures ===== -# Note: random_project_identifier is provided by fuzzforge-tests -# Note: random_module_execution_identifier is provided by fuzzforge-tests @pytest.fixture @@ -79,78 +67,6 @@ def random_project_description() -> Callable[[], str]: return inner -@pytest.fixture -def random_module_name() -> Callable[[], str]: - """Generate random module names.""" - - def inner() -> str: - return generate_random_string( - min_length=FUZZFORGE_MODULE_NAME_LENGTH_MIN, - max_length=FUZZFORGE_MODULE_NAME_LENGTH_MAX, - ) - - return inner - - -@pytest.fixture -def random_module_description() -> Callable[[], str]: - """Generate random module descriptions.""" - - def inner() -> str: - return generate_random_string( - min_length=1, - max_length=FUZZFORGE_MODULE_DESCRIPTION_LENGTH_MAX, - ) - - return inner - - -@pytest.fixture -def random_workflow_identifier() -> Callable[[], FuzzForgeWorkflowIdentifier]: - """Generate random workflow identifiers.""" - - def inner() -> FuzzForgeWorkflowIdentifier: - return uuid7() - - return inner - - -@pytest.fixture -def random_workflow_name() -> Callable[[], str]: - """Generate random workflow names.""" - - def inner() -> str: - return generate_random_string( - min_length=FUZZFORGE_WORKFLOW_NAME_LENGTH_MIN, - max_length=FUZZFORGE_WORKFLOW_NAME_LENGTH_MAX, - ) - - return inner - - -@pytest.fixture -def random_workflow_description() -> Callable[[], str]: - """Generate random workflow descriptions.""" - - def inner() -> str: - return generate_random_string( - min_length=1, - max_length=FUZZFORGE_WORKFLOW_DESCRIPTION_LENGTH_MAX, - ) - - return inner - - -@pytest.fixture -def random_workflow_execution_identifier() -> Callable[[], FuzzForgeExecutionIdentifier]: - """Generate random workflow execution identifiers.""" - - def inner() -> FuzzForgeExecutionIdentifier: - return uuid7() - - return inner - - @pytest.fixture def random_project_identifier() -> Callable[[], FuzzForgeProjectIdentifier]: """Generate random project identifiers. @@ -169,18 +85,8 @@ def random_project_identifier() -> Callable[[], FuzzForgeProjectIdentifier]: @pytest.fixture -def random_module_identifier() -> Callable[[], FuzzForgeModuleIdentifier]: - """Generate random module identifiers.""" - - def inner() -> FuzzForgeModuleIdentifier: - return uuid7() - - return inner - - -@pytest.fixture -def random_module_execution_identifier() -> Callable[[], FuzzForgeExecutionIdentifier]: - """Generate random workflow execution identifiers. +def random_execution_identifier() -> Callable[[], FuzzForgeExecutionIdentifier]: + """Generate random execution identifiers. Returns a callable that generates fresh UUID7 identifiers for each call. This pattern allows generating multiple unique identifiers within a single test. diff --git a/pyproject.toml b/pyproject.toml index c6a7cc2..4093d5c 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -21,8 +21,6 @@ dev = [ [tool.uv.workspace] members = [ "fuzzforge-common", - "fuzzforge-modules/fuzzforge-modules-sdk", - "fuzzforge-runner", "fuzzforge-mcp", "fuzzforge-cli", "fuzzforge-tests", @@ -30,8 +28,6 @@ members = [ [tool.uv.sources] fuzzforge-common = { workspace = true } -fuzzforge-modules-sdk = { workspace = true } -fuzzforge-runner = { workspace = true } fuzzforge-mcp = { workspace = true } fuzzforge-cli = { workspace = true } fuzzforge-tests = { workspace = true }