Skip to content
This repository was archived by the owner on Apr 22, 2026. It is now read-only.

Commit d3a20b3

Browse files
committed
feat: implement skill packs system
1 parent 664278d commit d3a20b3

8 files changed

Lines changed: 376 additions & 25 deletions

File tree

fuzzforge-mcp/pyproject.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ dependencies = [
1010
"fuzzforge-common==0.0.1",
1111
"pydantic==2.12.4",
1212
"pydantic-settings==2.12.0",
13+
"pyyaml>=6.0",
1314
"structlog==25.5.0",
1415
]
1516

fuzzforge-mcp/src/fuzzforge_mcp/application.py

Lines changed: 12 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -53,40 +53,30 @@ async def lifespan(_: FastMCP) -> AsyncGenerator[Settings]:
5353
4. Discover tools from servers with `discover_hub_tools`
5454
5. Execute hub tools with `execute_hub_tool`
5555
56+
Skill packs:
57+
Use `list_skills` to see available analysis pipelines (e.g. firmware-analysis).
58+
Load one with `load_skill("firmware-analysis")` to get domain-specific guidance
59+
and a scoped list of relevant hub servers. Skill packs describe the methodology —
60+
follow the pipeline steps while adapting to what you find at each stage.
61+
5662
Agent context convention:
5763
When you call `discover_hub_tools`, some servers return an `agent_context` field
5864
with usage tips, known issues, rule templates, and workflow guidance. Always read
5965
this context before using the server's tools.
6066
67+
Artifact tracking:
68+
After each `execute_hub_tool` call, new output files are automatically tracked.
69+
Use `list_artifacts` to find files produced by previous tools instead of parsing
70+
paths from tool output text. Filter by source server or file type.
71+
6172
File access in containers:
6273
- Assets set via `set_project_assets` are mounted read-only at `/app/uploads/` and `/app/samples/`
6374
- A writable output directory is mounted at `/app/output/` — use it for extraction results, reports, etc.
6475
- Always use container paths (e.g. `/app/uploads/file`) when passing file arguments to hub tools
6576
6677
Stateful tools:
67-
- Some tools (e.g. radare2-mcp) require multi-step sessions. Use `start_hub_server` to launch
78+
- Some tools require multi-step sessions. Use `start_hub_server` to launch
6879
a persistent container, then `execute_hub_tool` calls reuse that container. Stop with `stop_hub_server`.
69-
70-
Firmware analysis pipeline (when analyzing firmware images):
71-
1. **binwalk-mcp** (`binwalk_scan` + `binwalk_extract`) — identify and extract filesystem from firmware
72-
2. **yara-mcp** (`yara_scan_with_rules`) — scan extracted files with vulnerability rules to prioritize targets
73-
3. **radare2-mcp** (persistent session) — confirm dangerous code paths
74-
4. **searchsploit-mcp** (`search_exploitdb`) — query version strings from radare2 against ExploitDB
75-
Run steps 3 and 4 outputs feed into a final triage summary.
76-
77-
radare2-mcp agent context (upstream tool — no embedded context):
78-
- Start a persistent session with `start_hub_server("radare2-mcp")` before any calls.
79-
- IMPORTANT: the `open_file` tool requires the parameter name `file_path` (with underscore),
80-
not `filepath`. Example: `execute_hub_tool("hub:radare2-mcp:open_file", {"file_path": "/app/output/..."})`
81-
- Workflow: `open_file` → `analyze` → `list_imports` → `xrefs_to` → `run_command` with `pdf @ <addr>`.
82-
- Static binary fallback: firmware binaries are often statically linked. When `list_imports`
83-
returns an empty result, fall back to `list_symbols` and search for dangerous function names
84-
(system, strcpy, gets, popen, sprintf) in the output. Then use `xrefs_to` on their addresses.
85-
- For string extraction, use `run_command` with `iz` (data section strings).
86-
The `list_all_strings` tool may return garbled output for large binaries.
87-
- For decompilation, use `run_command` with `pdc @ <addr>` (pseudo-C) or `pdf @ <addr>`
88-
(annotated disassembly). The `decompile` tool may fail with "not available in current mode".
89-
- Stop the session with `stop_hub_server("radare2-mcp")` when done.
9080
""",
9181
lifespan=lifespan,
9282
)

fuzzforge-mcp/src/fuzzforge_mcp/dependencies.py

Lines changed: 23 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@
33
from __future__ import annotations
44

55
from pathlib import Path
6-
from typing import TYPE_CHECKING, cast
6+
from typing import TYPE_CHECKING, Any, cast
77

88
from fastmcp.server.dependencies import get_context
99

@@ -21,6 +21,9 @@
2121
# Singleton storage instance
2222
_storage: LocalStorage | None = None
2323

24+
# Currently loaded skill pack (set by load_skill)
25+
_active_skill: dict[str, Any] | None = None
26+
2427

2528
def set_current_project_path(project_path: Path) -> None:
2629
"""Set the current project path.
@@ -75,3 +78,22 @@ def get_storage() -> LocalStorage:
7578
settings = get_settings()
7679
_storage = LocalStorage(settings.storage.path)
7780
return _storage
81+
82+
83+
def set_active_skill(skill: dict[str, Any] | None) -> None:
84+
"""Set (or clear) the currently loaded skill pack.
85+
86+
:param skill: Parsed skill dict, or None to unload.
87+
88+
"""
89+
global _active_skill
90+
_active_skill = skill
91+
92+
93+
def get_active_skill() -> dict[str, Any] | None:
94+
"""Get the currently loaded skill pack.
95+
96+
:return: Active skill dict, or None if no skill is loaded.
97+
98+
"""
99+
return _active_skill
Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
name: firmware-analysis
2+
description: |
3+
## Firmware Binary Vulnerability Analysis
4+
5+
Goal: Find exploitable vulnerabilities in firmware images.
6+
7+
### Pipeline
8+
9+
1. **Extract the filesystem** from the firmware image.
10+
Look for SquashFS, JFFS2, CPIO, or other embedded filesystems.
11+
12+
2. **Scan extracted files for vulnerability patterns.**
13+
Use vulnerability-focused rules to identify binaries with dangerous
14+
function calls (system, strcpy, popen, sprintf, gets).
15+
Prioritize targets by match count — the binary with the most hits
16+
is the highest-priority target.
17+
18+
3. **Deep-analyze the highest-priority binary.**
19+
Open a persistent analysis session. Look for:
20+
- Dangerous function calls with unsanitized input
21+
- Hardcoded credentials or backdoor strings
22+
- Network service listeners with weak input validation
23+
Focus on confirming whether flagged patterns are actually reachable.
24+
25+
4. **Search for known CVEs** matching library version strings found
26+
during analysis. Cross-reference with public exploit databases.
27+
28+
5. **Compile findings** with severity ratings:
29+
- CRITICAL: confirmed remote code execution paths
30+
- HIGH: command injection or buffer overflow with reachable input
31+
- MEDIUM: hardcoded credentials, weak crypto, format string issues
32+
- LOW: informational findings (library versions, service fingerprints)
33+
34+
### Key files to prioritize in extracted firmware
35+
- `usr/sbin/httpd`, `usr/bin/httpd` — web servers (high-priority)
36+
- `etc/shadow`, `etc/passwd` — credential files
37+
- `www/cgi-bin/*` — CGI scripts (command injection vectors)
38+
- Custom binaries in `usr/sbin/`, `usr/bin/` — vendor attack surface
39+
40+
servers:
41+
- binwalk-mcp
42+
- yara-mcp
43+
- radare2-mcp
44+
- searchsploit-mcp
Lines changed: 90 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,90 @@
1+
name: go-fuzzing
2+
description: |
3+
## Go Fuzzing Vulnerability Discovery
4+
5+
Goal: Find memory safety bugs, panics, and logic errors in a Go project
6+
using native Go fuzzing (go test -fuzz).
7+
8+
### Pipeline
9+
10+
1. **Analyze the Go project** to understand its attack surface.
11+
Use `go_analyze` to scan the codebase and identify:
12+
- Fuzzable entry points: functions accepting `[]byte`, `string`,
13+
`io.Reader`, or other parser-like signatures (`Parse*`, `Decode*`,
14+
`Unmarshal*`, `Read*`, `Open*`)
15+
- Existing `Fuzz*` test functions already in `*_test.go` files
16+
- Unsafe/cgo usage that increases the severity of any bugs found
17+
- Known CVEs via govulncheck (enable with `run_vulncheck: true`)
18+
19+
If there are **no existing Fuzz targets**, stop here and report
20+
that the project needs fuzz harnesses written first, listing the
21+
recommended entry points from the analysis.
22+
23+
2. **Test harness quality** before committing to a long fuzzing campaign.
24+
Use `go_harness_test` to evaluate each Fuzz* function:
25+
- Compilation check — does `go test -c` succeed?
26+
- Seed execution — do the seed corpus entries pass without panics?
27+
- Short fuzzing trial — does the harness sustain fuzzing for 15-30s?
28+
- Quality score (0-100): ≥80 = production-ready, ≥50 = needs work, <50 = broken
29+
30+
**Decision point:**
31+
- If all harnesses are **broken** (score < 50): stop and report issues.
32+
The user needs to fix them before fuzzing is useful.
33+
- If some are **production-ready** or **needs-improvement** (score ≥ 50):
34+
proceed with those targets to step 3.
35+
- Skip broken harnesses — do not waste fuzzing time on them.
36+
37+
3. **Run fuzzing** on the viable targets.
38+
Use `go_fuzz_run` for a bounded campaign:
39+
- Set `duration` based on project size: 60-120s for quick scan,
40+
300-600s for thorough analysis.
41+
- Pass only the targets that scored ≥ 50 in step 2 via the `targets`
42+
parameter — do not fuzz broken harnesses.
43+
- The fuzzer collects crash inputs to `/app/output/crashes/{FuzzName}/`.
44+
45+
**Alternative — continuous mode** for deeper exploration:
46+
- Use `go_fuzz_start` to begin background fuzzing.
47+
- Periodically check `go_fuzz_status` to monitor progress.
48+
- Use `go_fuzz_stop` when satisfied or when crashes are found.
49+
50+
If **no crashes** are found after a reasonable duration, report that
51+
the fuzzing campaign completed cleanly with the execution metrics.
52+
53+
4. **Analyze crashes** found during fuzzing.
54+
Use `go_crash_analyze` to process the crash inputs:
55+
- Reproduction: re-run each crash input to confirm it's real
56+
- Classification: categorize by type (nil-dereference, index-out-of-range,
57+
slice-bounds, divide-by-zero, stack-overflow, data-race, panic, etc.)
58+
- Severity assignment: critical / high / medium / low
59+
- Deduplication: group crashes by signature (target + type + top 3 frames)
60+
61+
Skip this step if no crashes were found in step 3.
62+
63+
5. **Compile the vulnerability report** with findings organized by severity:
64+
- **CRITICAL**: nil-dereference, segfault, data-race, stack-overflow
65+
- **HIGH**: index/slice out of bounds, allocation overflow
66+
- **MEDIUM**: integer overflow, divide by zero, explicit panics
67+
- **LOW**: timeout, unclassified crashes
68+
69+
For each unique crash, include:
70+
- The fuzz target that triggered it
71+
- The crash type and root cause function + file + line
72+
- Whether it was reproducible
73+
- The crash input file path for manual investigation
74+
75+
### What the user's project needs
76+
- A `go.mod` file (any Go module)
77+
- At least one `*_test.go` file with `func FuzzXxx(f *testing.F)` functions
78+
- Seed corpus entries added via `f.Add(...)` in the Fuzz functions
79+
80+
### Interpretation guide
81+
- **govulncheck CVEs** (step 1) are known dependency vulnerabilities — report separately
82+
- **Fuzzer crashes** (steps 3-4) are new bugs found by fuzzing the project's own code
83+
- High execution counts with zero crashes = good sign (code is robust to that input space)
84+
- Low quality scores in step 2 usually mean the harness needs better seed corpus or input handling
85+
86+
servers:
87+
- go-analyzer-mcp
88+
- go-harness-tester-mcp
89+
- go-fuzzer-mcp
90+
- go-crash-analyzer-mcp

fuzzforge-mcp/src/fuzzforge_mcp/storage.py

Lines changed: 100 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,8 @@
2020
from typing import Any
2121
from uuid import uuid4
2222

23+
import yaml
24+
2325
logger = logging.getLogger("fuzzforge-mcp")
2426

2527
#: Name of the FuzzForge storage directory within projects.
@@ -475,3 +477,101 @@ def get_artifact(self, project_path: Path, path: str) -> dict[str, Any] | None:
475477
if artifact["path"] == path:
476478
return artifact
477479
return None
480+
481+
# ------------------------------------------------------------------
482+
# Skill packs
483+
# ------------------------------------------------------------------
484+
485+
#: Directory containing built-in skill packs shipped with FuzzForge.
486+
_BUILTIN_SKILLS_DIR: Path = Path(__file__).parent / "skills"
487+
488+
def _skill_dirs(self, project_path: Path) -> list[Path]:
489+
"""Return skill directories in priority order (project-local first).
490+
491+
:param project_path: Path to the project directory.
492+
:returns: List of directories that may contain skill YAML files.
493+
494+
"""
495+
dirs: list[Path] = []
496+
project_skills = self._get_project_path(project_path) / "skills"
497+
if project_skills.is_dir():
498+
dirs.append(project_skills)
499+
if self._BUILTIN_SKILLS_DIR.is_dir():
500+
dirs.append(self._BUILTIN_SKILLS_DIR)
501+
return dirs
502+
503+
def list_skills(self, project_path: Path) -> list[dict[str, Any]]:
504+
"""List available skill packs from project and built-in directories.
505+
506+
:param project_path: Path to the project directory.
507+
:returns: List of skill summaries (name, description first line, source).
508+
509+
"""
510+
seen: set[str] = set()
511+
skills: list[dict[str, Any]] = []
512+
513+
for skill_dir in self._skill_dirs(project_path):
514+
for yaml_path in sorted(skill_dir.glob("*.yaml")):
515+
skill = self._parse_skill_file(yaml_path)
516+
if skill is None:
517+
continue
518+
name = skill["name"]
519+
if name in seen:
520+
continue # project-local overrides built-in
521+
seen.add(name)
522+
desc = skill.get("description", "")
523+
first_line = desc.strip().split("\n", 1)[0] if desc else ""
524+
is_project = ".fuzzforge" in str(yaml_path.parent)
525+
source = "project" if is_project else "builtin"
526+
skills.append({
527+
"name": name,
528+
"summary": first_line,
529+
"source": source,
530+
"servers": skill.get("servers", []),
531+
})
532+
533+
return skills
534+
535+
def load_skill(self, project_path: Path, name: str) -> dict[str, Any] | None:
536+
"""Load a skill pack by name.
537+
538+
Searches project-local skills first, then built-in skills.
539+
540+
:param project_path: Path to the project directory.
541+
:param name: Skill name (filename without .yaml extension).
542+
:returns: Parsed skill dict with name, description, servers — or None.
543+
544+
"""
545+
for skill_dir in self._skill_dirs(project_path):
546+
yaml_path = skill_dir / f"{name}.yaml"
547+
if yaml_path.is_file():
548+
return self._parse_skill_file(yaml_path)
549+
return None
550+
551+
@staticmethod
552+
def _parse_skill_file(yaml_path: Path) -> dict[str, Any] | None:
553+
"""Parse and validate a skill YAML file.
554+
555+
:param yaml_path: Path to the YAML file.
556+
:returns: Parsed skill dict, or None if invalid.
557+
558+
"""
559+
try:
560+
data = yaml.safe_load(yaml_path.read_text())
561+
except (yaml.YAMLError, OSError):
562+
logger.warning("Failed to parse skill file: %s", yaml_path)
563+
return None
564+
565+
if not isinstance(data, dict):
566+
return None
567+
568+
name = data.get("name")
569+
if not name or not isinstance(name, str):
570+
logger.warning("Skill file missing 'name': %s", yaml_path)
571+
return None
572+
573+
return {
574+
"name": name,
575+
"description": data.get("description", ""),
576+
"servers": data.get("servers", []),
577+
}

0 commit comments

Comments
 (0)