Skip to content
Merged
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
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,3 +1,7 @@
### 2.8.2 (Friday, August 07, 2026)
### Features/Bug Fixes
* fix(mcp): retry malformed TP4 responses
---
### 2.8.1 (Thursday, August 06, 2026)
### Features/Bug Fixes
* fix(llm): isolate malformed structured responses per batch
Expand Down
47 changes: 47 additions & 0 deletions docs/release/skillspector-2.8.2.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
# SkillSpector v2.8.2

Released: 2026-08-07

## Summary

This patch release improves the resilience of MCP tool-poisoning analysis when a model returns malformed structured output. Affected assessments are retried through the shared structured-output analyzer lifecycle rather than causing an immediate scan failure.

## Highlights

- MCP tool-poisoning TP4 checks now use the shared typed structured-output analyzer lifecycle.

## Added

- None.

## Changed

- Structured TP4 responses are validated through a typed schema with the same per-batch isolation behavior used by other LLM analyzers.

## Fixed

- Malformed structured model responses in MCP tool-poisoning checks are retried once and then isolated to the affected batch.

## Security

- None.

## Breaking Changes and Migration

- None.

## Deprecations

- None.

## Validation

- `git diff --check -- docs/release/skillspector-2.8.2.md` — passed.

## Known Limitations

- None.

## References

- `CHANGELOG.md`
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ build-backend = "hatchling.build"

[project]
name = "skillspector"
version = "2.8.1"
version = "2.8.2"
description = "SkillSpector: Security scanner for AI agent skills (Claude Code, Cursor, and similar). Scans skills for vulnerabilities, malicious patterns, and security risks before installation. Supports Git repos, URLs, zips, and local directories; runs static pattern checks and optional LLM semantic analysis; outputs terminal, JSON, and Markdown reports with risk scoring."
readme = "README.md"
license = "Apache-2.0"
Expand Down
8 changes: 5 additions & 3 deletions src/skillspector/llm_analyzer_base.py
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,7 @@
ledger_event,
)
from skillspector.llm_utils import (
StructuredOutputParseError,
_AgentCLIMessage,
_ainvoke_with_usage,
_invoke_with_usage,
Expand Down Expand Up @@ -575,7 +576,7 @@ def _invoke_batch(self, batch: Batch, prompt: str) -> tuple[Batch, list]:
if self._structured_llm:
try:
response = _invoke_with_usage(self._structured_llm, prompt, self._usage_collector)
except ValidationError as exc:
except (StructuredOutputParseError, ValidationError) as exc:
raise _StructuredResponseValidationError from exc
else:
response = _raw_response_text(
Expand All @@ -597,7 +598,7 @@ async def _ainvoke_batch(self, batch: Batch, prompt: str) -> tuple[Batch, list]:
response = await _ainvoke_with_usage(
self._structured_llm, prompt, self._usage_collector
)
except ValidationError as exc:
except (StructuredOutputParseError, ValidationError) as exc:
raise _StructuredResponseValidationError from exc
else:
response = _raw_response_text(
Expand Down Expand Up @@ -678,7 +679,8 @@ async def arun_batches(
oversized-chunk 400, ...) costs only its own batch, which is logged
and omitted from the result, so one bad call cannot cancel the rest
of the fan-out. Malformed structured responses (Pydantic
``ValidationError``) are retried once and then isolated to their batch.
``ValidationError`` or CLI JSON parse failures) are retried once and
then isolated to their batch.
Callers can detect partial results by comparing the returned batches
against the submitted ones. Other ``ValueError`` instances and
``NotImplementedError`` signal misconfiguration rather than infra trouble
Expand Down
8 changes: 7 additions & 1 deletion src/skillspector/llm_utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -178,6 +178,10 @@ def __init__(self, content: str) -> None:
self.content = content


class StructuredOutputParseError(ValueError):
"""Raised when a structured-output response does not contain a JSON object."""


def _extract_json_object(raw: str) -> dict:
"""Extract a single JSON object from a CLI model's text response.

Expand Down Expand Up @@ -206,7 +210,9 @@ def _extract_json_object(raw: str) -> dict:
return obj
except json.JSONDecodeError:
pass
raise ValueError(f"could not extract a JSON object from CLI response: {raw[:200]!r}")
raise StructuredOutputParseError(
f"could not extract a JSON object from CLI response: {raw[:200]!r}"
)


class _StructuredAgentCLIModel:
Expand Down
122 changes: 75 additions & 47 deletions src/skillspector/nodes/analyzers/mcp_tool_poisoning.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,19 +18,21 @@
from __future__ import annotations

import base64
import json
import logging
import re
import unicodedata
from typing import cast

from skillspector.inference_usage import InferenceUsageCollector, InferenceUsageRecord
from pydantic import BaseModel, Field, field_validator

from skillspector.inference_usage import InferenceUsageRecord
from skillspector.inspection_ledger import (
LedgerOutcome,
LedgerReason,
analyzer_status_event,
ledger_event,
)
from skillspector.llm_utils import chat_completion, new_inference_usage_collector
from skillspector.llm_analyzer_base import Batch, LLMAnalyzerBase
from skillspector.models import Finding
from skillspector.providers import get_active_provider
from skillspector.state import (
Expand Down Expand Up @@ -690,6 +692,44 @@ def _check_tp3(params: list[dict]) -> list[Finding]:
)


class _TP4AnalysisResult(BaseModel):
"""Validated response from the description-behavior mismatch check."""

is_mismatch: bool
confidence: float = 0.0
declared_purpose_summary: str = ""
actual_behavior_summary: str = ""
mismatched_capabilities: list[str] = Field(default_factory=list)
explanation: str = ""

@field_validator("confidence")
@classmethod
def _validate_confidence(cls, value: float) -> float:
if not 0.0 <= value <= 1.0:
raise ValueError("confidence must be between 0.0 and 1.0")
return value


class _TP4Analyzer(LLMAnalyzerBase):
"""Run TP4 through the shared structured-output analyzer lifecycle."""

response_schema = _TP4AnalysisResult

def __init__(self, model: str) -> None:
super().__init__(base_prompt="", model=model, node=ANALYZER_ID)

def build_prompt(self, batch: Batch, **_kwargs: object) -> str:
"""Use TP4's purpose-built prompt without the generic file wrapper."""
return batch.content

def parse_response( # type: ignore[override] # TP4 returns its typed assessment.
self, response: object, _batch: Batch
) -> list[_TP4AnalysisResult]:
if isinstance(response, _TP4AnalysisResult):
return [response]
raise NotImplementedError("TP4 requires a structured assessment response")


def _check_tp4(
state: SkillspectorState,
) -> tuple[
Expand All @@ -707,7 +747,7 @@ def _check_tp4(
emitted only when the provider response supplied it.
"""
attempted = False
usage_collector: InferenceUsageCollector | None = None
analyzer: _TP4Analyzer | None = None
try:
manifest: dict = state.get("manifest") or {}
description = manifest.get("description")
Expand Down Expand Up @@ -740,11 +780,7 @@ def _check_tp4(

model_config: dict = state.get("model_config") or {}
model = model_config.get(ANALYZER_ID) or model_config.get("default")
usage_collector = new_inference_usage_collector(
node=ANALYZER_ID,
request_kind="chat_completion",
model=model or get_active_provider().resolve_model(),
)
model = model or get_active_provider().resolve_model()

prompt = f"""You are a security auditor. Your task: determine whether a skill's declared
description accurately represents what its code actually does.
Expand Down Expand Up @@ -773,52 +809,42 @@ def _check_tp4(
- Utility code that supports the declared purpose (logging, error handling)
- Over-declared permissions (covered by a separate analyzer)

Respond in JSON matching this exact schema:
{{
"is_mismatch": true/false,
"confidence": 0.0-1.0,
"declared_purpose_summary": "one-sentence summary of what the description claims",
"actual_behavior_summary": "one-sentence summary of what the code actually does",
"mismatched_capabilities": ["list of capabilities in code but not in description"],
"explanation": "why this is or is not a mismatch"
}}"""
Return the assessment using the provided structured output schema."""

analyzer = _TP4Analyzer(model)
attempted = True
response = chat_completion(
prompt,
model=model,
usage_collector=usage_collector,
node=ANALYZER_ID,
)

# Parse JSON — handle optional ```json code blocks
json_text = response.strip()
if json_text.startswith("```"):
# Strip opening fence (```json or ```)
first_newline = json_text.find("\n")
if first_newline != -1:
json_text = json_text[first_newline + 1 :]
# Strip closing fence
if json_text.rstrip().endswith("```"):
json_text = json_text.rstrip()[:-3].rstrip()

result = json.loads(json_text)
outcome = analyzer.run_batches_detailed([Batch(file_path="SKILL.md", content=prompt)])
if outcome.failures:
failure = outcome.failures[0]
return (
[],
llm_call_record(
ANALYZER_ID,
ok=False,
error=f"TP4 LLM batch failed: {failure.error_class}",
),
failure.error_class,
cast(list[InferenceUsageRecord], analyzer.inference_usage),
)
result = outcome.successful[0][1][0]
if not isinstance(result, _TP4AnalysisResult):
raise RuntimeError("TP4 returned an unexpected structured response type")
ok_record = llm_call_record(ANALYZER_ID, ok=True)

if not result.get("is_mismatch"):
return [], ok_record, None, usage_collector.snapshot()
if not result.is_mismatch:
return [], ok_record, None, cast(list[InferenceUsageRecord], analyzer.inference_usage)

confidence = float(result.get("confidence", 0.0))
confidence = result.confidence
if confidence < 0.5:
return [], ok_record, None, usage_collector.snapshot()
return [], ok_record, None, cast(list[InferenceUsageRecord], analyzer.inference_usage)

severity = "HIGH" if confidence >= 0.7 else "MEDIUM"

mismatched = result.get("mismatched_capabilities") or []
mismatched = result.mismatched_capabilities
mismatched_str = ", ".join(mismatched) if mismatched else "unspecified"
explanation = result.get("explanation", "")
declared = result.get("declared_purpose_summary", description[:80])
actual = result.get("actual_behavior_summary", "")
explanation = result.explanation
declared = result.declared_purpose_summary or description[:80]
actual = result.actual_behavior_summary

return (
[
Expand All @@ -842,7 +868,7 @@ def _check_tp4(
],
ok_record,
None,
usage_collector.snapshot(),
cast(list[InferenceUsageRecord], analyzer.inference_usage),
)

except Exception as exc:
Expand All @@ -854,7 +880,9 @@ def _check_tp4(
[],
llm_call_record(ANALYZER_ID, ok=False, error=str(exc)),
type(exc).__name__,
usage_collector.snapshot() if usage_collector is not None else [],
cast(list[InferenceUsageRecord], analyzer.inference_usage)
if analyzer is not None
else [],
)
return [], None, None, []

Expand Down
23 changes: 20 additions & 3 deletions tests/integration/test_graph.py
Original file line number Diff line number Diff line change
Expand Up @@ -168,10 +168,26 @@ def test_graph_surfaces_degraded_llm_stage(tmp_path: Path, monkeypatch: pytest.M
def boom(*_a: object, **_k: object) -> object:
raise RuntimeError("simulated LLM transport failure")

# Fail both LLM transports: get_chat_model (semantic analyzers + meta) and
# chat_completion (mcp_tool_poisoning TP4).
class FailingTP4Analyzer:
"""Simulate an attempted TP4 request failing after analyzer setup."""

@property
def inference_usage(self) -> list[object]:
return []

def __init__(self, _model: str) -> None:
pass

def run_batches_detailed(self, _batches: object) -> object:
raise RuntimeError("simulated LLM transport failure")

# Semantic analyzers and meta_analyzer fail while constructing their shared
# transport. TP4's analyzer construction is deliberately not an attempted
# LLM call, so fail it at batch execution to assert its ledger projection.
monkeypatch.setattr("skillspector.llm_analyzer_base.get_chat_model", boom)
monkeypatch.setattr("skillspector.nodes.analyzers.mcp_tool_poisoning.chat_completion", boom)
monkeypatch.setattr(
"skillspector.nodes.analyzers.mcp_tool_poisoning._TP4Analyzer", FailingTP4Analyzer
)

result = graph.invoke({"skill_path": str(tmp_path), "use_llm": True, "output_format": "json"})

Expand All @@ -186,6 +202,7 @@ def boom(*_a: object, **_k: object) -> object:
"semantic_developer_intent",
"semantic_quality_policy",
"meta_analyzer",
"mcp_tool_poisoning",
} <= nodes

meta = json.loads(result["report_body"])["metadata"]
Expand Down
Loading
Loading