diff --git a/src/skillspector/nodes/report.py b/src/skillspector/nodes/report.py index 68c0760a..4970c335 100644 --- a/src/skillspector/nodes/report.py +++ b/src/skillspector/nodes/report.py @@ -924,7 +924,16 @@ def report(state: SkillspectorState) -> dict[str, object]: inference_usage = state.get("inference_usage") or [] _attempted, _succeeded, degraded = _llm_runtime_status(use_llm, llm_call_log) + provider_available, provider_error = is_llm_available() + has_recorded_failure = any(not r.get("ok") for r in llm_call_log) + provider_unavailable = bool(use_llm and not provider_available and has_recorded_failure) + degraded = degraded or provider_unavailable degraded_notice = _llm_degradation_notice(use_llm, llm_call_log) + if provider_unavailable and degraded_notice is None: + degraded_notice = ( + "LLM analysis was requested but the configured provider was unavailable" + f" ({provider_error or 'unknown reason'}); results may reflect static analysis only." + ) if degraded: logger.warning( "LLM stage degraded: %d/%d LLM call(s) failed; report reflects static analysis only", @@ -957,6 +966,10 @@ def report(state: SkillspectorState) -> dict[str, object]: entirely_uninspected = ( entirely_uninspected_value if isinstance(entirely_uninspected_value, int) else 0 ) + + # Fail closed when a deep scan is degraded, including an unavailable + # provider with mixed call telemetry, or when inspection completeness says + # some content was not inspected. Leave the score and severity untouched. if (degraded or fatal_exception or entirely_uninspected > 0) and risk_recommendation == "SAFE": risk_recommendation = "CAUTION" diff --git a/tests/nodes/test_report.py b/tests/nodes/test_report.py index ec7d60b1..771f119e 100644 --- a/tests/nodes/test_report.py +++ b/tests/nodes/test_report.py @@ -987,6 +987,35 @@ def test_degraded_scan_floors_recommendation_at_caution() -> None: assert result["risk_recommendation"] == "CAUTION" # but never SAFE when degraded +def test_unavailable_provider_floors_recommendation_even_with_success_records( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Provider truth wins when swallowed batch failures produced false success records.""" + monkeypatch.setattr( + "skillspector.nodes.report.is_llm_available", + lambda: (False, "codex binary not found"), + ) + state: SkillspectorState = { + "filtered_findings": [], + "component_metadata": [], + "has_executable_scripts": False, + "manifest": {}, + "output_format": "json", + "use_llm": True, + "llm_call_log": [ + llm_call_record("semantic_developer_intent", ok=True), + llm_call_record("semantic_quality_policy", ok=True), + llm_call_record("semantic_security_discovery", ok=False, error="binary missing"), + ], + } + + result = report(state) + assert result["risk_recommendation"] == "CAUTION" + payload = json.loads(result["report_body"]) + assert payload["risk_assessment"]["recommendation"] == "CAUTION" + assert payload["metadata"]["llm_available"] is False + + def test_non_degraded_clean_scan_stays_safe() -> None: """Without degradation, a clean scan still reports SAFE (no over-flooring).""" state: SkillspectorState = {