Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
27 changes: 24 additions & 3 deletions src/skillspector/graph.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,20 +15,22 @@

"""LangGraph workflow for Skillspector stub analyzers."""

# TODO(SADD A.2.1–A.2.4): Analyzer discovery, stage-as-category with meta last, wire registry; respect requires_api_key/is_available() and skip or warn when API key missing or analyzer unavailable. See SADD for skillspector § A.2.
# TODO(SADD A.5.1): Implement skillspector serve (FastAPI): POST /scan (zip), GET /results/{id}, GET /health. See SADD for skillspector § A.5.1.

from __future__ import annotations

from langgraph.graph import END, START, StateGraph

from skillspector.nodes.analyzers import ANALYZER_NODE_IDS, ANALYZER_NODES
from skillspector.llm_utils import is_llm_available
from skillspector.logging_config import get_logger
from skillspector.nodes.analyzers import ANALYZER_MODULES, ANALYZER_NODE_IDS, ANALYZER_NODES
from skillspector.nodes.build_context import build_context
from skillspector.nodes.meta_analyzer import meta_analyzer
from skillspector.nodes.report import report
from skillspector.nodes.resolve_input import resolve_input
from skillspector.state import SkillspectorState

logger = get_logger(__name__)

def create_graph():
"""Create and compile Skillspector workflow graph."""
Expand All @@ -39,14 +41,33 @@ def create_graph():
workflow.add_node("meta_analyzer", meta_analyzer)
workflow.add_node("report", report)

wired_analyzers = []

for analyzer_id in ANALYZER_NODE_IDS:
mod = ANALYZER_MODULES.get(analyzer_id)

is_available = getattr(mod, "is_available", None)
if callable(is_available) and not is_available():
logger.warning("Skipping analyzer %s: is_available() returned False", analyzer_id)
continue

requires_api_key = getattr(mod, "requires_api_key", False)
if requires_api_key:
has_llm, _ = is_llm_available()
if not has_llm:
logger.warning("Skipping analyzer %s: required API key is missing", analyzer_id)
continue

workflow.add_node(analyzer_id, ANALYZER_NODES[analyzer_id])
wired_analyzers.append(analyzer_id)

workflow.add_edge(START, "resolve_input")
workflow.add_edge("resolve_input", "build_context")
for analyzer_id in ANALYZER_NODE_IDS:

for analyzer_id in wired_analyzers:
workflow.add_edge("build_context", analyzer_id)
workflow.add_edge(analyzer_id, "meta_analyzer")

workflow.add_edge("meta_analyzer", "report")
workflow.add_edge("report", END)
return workflow.compile()
Expand Down
131 changes: 36 additions & 95 deletions src/skillspector/nodes/analyzers/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,101 +17,42 @@

from __future__ import annotations

from skillspector.nodes.analyzers.behavioral_ast import node as behavioral_ast_node
from skillspector.nodes.analyzers.behavioral_taint_tracking import (
node as behavioral_taint_tracking_node,
)
from skillspector.nodes.analyzers.mcp_least_privilege import node as mcp_least_privilege_node
from skillspector.nodes.analyzers.mcp_rug_pull import node as mcp_rug_pull_node
from skillspector.nodes.analyzers.mcp_tool_poisoning import node as mcp_tool_poisoning_node
from skillspector.nodes.analyzers.semantic_developer_intent import (
node as semantic_developer_intent_node,
)
from skillspector.nodes.analyzers.semantic_quality_policy import (
node as semantic_quality_policy_node,
)
from skillspector.nodes.analyzers.semantic_security_discovery import (
node as semantic_security_discovery_node,
)
from skillspector.nodes.analyzers.static_patterns_data_exfiltration import (
node as static_patterns_data_exfiltration_node,
)
from skillspector.nodes.analyzers.static_patterns_excessive_agency import (
node as static_patterns_excessive_agency_node,
)
from skillspector.nodes.analyzers.static_patterns_harmful_content import (
node as static_patterns_harmful_content_node,
)
from skillspector.nodes.analyzers.static_patterns_memory_poisoning import (
node as static_patterns_memory_poisoning_node,
)
from skillspector.nodes.analyzers.static_patterns_output_handling import (
node as static_patterns_output_handling_node,
)
from skillspector.nodes.analyzers.static_patterns_privilege_escalation import (
node as static_patterns_privilege_escalation_node,
)
from skillspector.nodes.analyzers.static_patterns_prompt_injection import (
node as static_patterns_prompt_injection_node,
)
from skillspector.nodes.analyzers.static_patterns_rogue_agent import (
node as static_patterns_rogue_agent_node,
)
from skillspector.nodes.analyzers.static_patterns_supply_chain import (
node as static_patterns_supply_chain_node,
)
from skillspector.nodes.analyzers.static_patterns_system_prompt_leakage import (
node as static_patterns_system_prompt_leakage_node,
)
from skillspector.nodes.analyzers.static_patterns_tool_misuse import (
node as static_patterns_tool_misuse_node,
)
from skillspector.nodes.analyzers.static_yara import node as static_yara_node
import importlib
import pkgutil
from typing import Any

ANALYZER_NODE_IDS: list[str] = [
"static_patterns_prompt_injection",
"static_patterns_data_exfiltration",
"static_patterns_privilege_escalation",
"static_patterns_supply_chain",
"static_patterns_harmful_content",
"static_patterns_excessive_agency",
"static_patterns_output_handling",
"static_patterns_system_prompt_leakage",
"static_patterns_memory_poisoning",
"static_patterns_tool_misuse",
"static_patterns_rogue_agent",
"static_yara",
"behavioral_ast",
"behavioral_taint_tracking",
"mcp_least_privilege",
"mcp_tool_poisoning",
"mcp_rug_pull",
"semantic_security_discovery",
"semantic_developer_intent",
"semantic_quality_policy",
]
from skillspector.logging_config import get_logger

ANALYZER_NODES = {
"static_patterns_prompt_injection": static_patterns_prompt_injection_node,
"static_patterns_data_exfiltration": static_patterns_data_exfiltration_node,
"static_patterns_privilege_escalation": static_patterns_privilege_escalation_node,
"static_patterns_supply_chain": static_patterns_supply_chain_node,
"static_patterns_harmful_content": static_patterns_harmful_content_node,
"static_patterns_excessive_agency": static_patterns_excessive_agency_node,
"static_patterns_output_handling": static_patterns_output_handling_node,
"static_patterns_system_prompt_leakage": static_patterns_system_prompt_leakage_node,
"static_patterns_memory_poisoning": static_patterns_memory_poisoning_node,
"static_patterns_tool_misuse": static_patterns_tool_misuse_node,
"static_patterns_rogue_agent": static_patterns_rogue_agent_node,
"static_yara": static_yara_node,
"behavioral_ast": behavioral_ast_node,
"behavioral_taint_tracking": behavioral_taint_tracking_node,
"mcp_least_privilege": mcp_least_privilege_node,
"mcp_tool_poisoning": mcp_tool_poisoning_node,
"mcp_rug_pull": mcp_rug_pull_node,
"semantic_security_discovery": semantic_security_discovery_node,
"semantic_developer_intent": semantic_developer_intent_node,
"semantic_quality_policy": semantic_quality_policy_node,
}
logger = get_logger(__name__)

__all__ = ["ANALYZER_NODE_IDS", "ANALYZER_NODES"]
ANALYZER_NODE_IDS: list[str] = []
ANALYZER_NODES: dict[str, Any] = {}
ANALYZER_MODULES: dict[str, Any] = {}

def _discover_analyzers() -> None:
"""Dynamically discover and register analyzer modules in this package."""
if ANALYZER_NODE_IDS:
return

for _, module_name, is_pkg in pkgutil.iter_modules(__path__):
if is_pkg:
continue

full_module_name = f"{__name__}.{module_name}"
try:
mod = importlib.import_module(full_module_name)
except Exception as exc:
logger.debug("Skipping module %s during discovery: %s", module_name, exc)
continue

analyzer_id = getattr(mod, "ANALYZER_ID", None)
node_func = getattr(mod, "node", None)

if analyzer_id and callable(node_func):
ANALYZER_NODE_IDS.append(analyzer_id)
ANALYZER_NODES[analyzer_id] = node_func
ANALYZER_MODULES[analyzer_id] = mod

_discover_analyzers()

__all__ = ["ANALYZER_NODE_IDS", "ANALYZER_NODES", "ANALYZER_MODULES"]
2 changes: 1 addition & 1 deletion tests/integration/test_graph_scanner.py
Original file line number Diff line number Diff line change
Expand Up @@ -98,7 +98,7 @@ def test_scan_malicious_skill(self, malicious_skill_dir: Path) -> None:
assert "risk_score" in result
# Malicious content: cyanide in SKILL.md + env harvesting in script
assert len(result["findings"]) > 0
# When risk_score is implemented (TODO A.3.2): assert result["risk_score"] >= 50
assert result["risk_score"] >= 50, "Risk score failed to trigger on malicious content"


class TestGraphRiskScoring:
Expand Down
4 changes: 2 additions & 2 deletions tests/nodes/analyzers/test_registry.py
Original file line number Diff line number Diff line change
Expand Up @@ -49,8 +49,8 @@ class TestAnalyzerRegistry:
"""Registry matches SADD spec node set and order."""

def test_analyzer_node_ids_match_sadd_spec(self):
"""ANALYZER_NODE_IDS equals expected list from SADD spec."""
assert ANALYZER_NODE_IDS == EXPECTED_ANALYZER_NODE_IDS
"""ANALYZER_NODE_IDS contains expected list from SADD spec."""
assert set(ANALYZER_NODE_IDS) == set(EXPECTED_ANALYZER_NODE_IDS)

def test_analyzer_nodes_has_entry_for_every_id(self):
"""Every ANALYZER_NODE_IDS entry has a corresponding ANALYZER_NODES entry."""
Expand Down