diff --git a/src/skillspector/cli.py b/src/skillspector/cli.py index 838f838c..aea12cb9 100644 --- a/src/skillspector/cli.py +++ b/src/skillspector/cli.py @@ -494,6 +494,13 @@ def scan( help="Exit 1 when relevant analysis is partial or incomplete.", ), ] = False, + fail_on_findings: Annotated[ + bool, + typer.Option( + "--fail-on-findings", + help="Exit 1 when the scan reports one or more active findings.", + ), + ] = False, verbose: Annotated[ bool, typer.Option( @@ -628,6 +635,7 @@ def scan( yara_dir=yara_dir, verbose=verbose, fail_on_incomplete=fail_on_incomplete, + fail_on_findings=fail_on_findings, ) return if detection.complete and not detection.has_root_skill and len(detection.skills) == 0: @@ -702,6 +710,8 @@ def scan( ) if fail_on_incomplete and not is_complete: raise typer.Exit(code=1) + if fail_on_findings and effective_findings(result): + raise typer.Exit(code=1) if (result.get("risk_score") or 0) > RISK_THRESHOLD: raise typer.Exit(code=1) except typer.Exit: @@ -2075,6 +2085,7 @@ def _scan_multi_skill( yara_dir: str | None = None, verbose: bool = False, fail_on_incomplete: bool = False, + fail_on_findings: bool = False, **legacy_kwargs: object, ) -> None: """Scan each detected sub-skill independently and produce a combined report.""" @@ -2097,6 +2108,7 @@ def _scan_multi_skill( transitive_finding_count = 0 transitive_sources: set[str] = set() analysis_incomplete = not detection.complete + has_findings = False aggregate_limitations = [ f"recursive discovery {limitation.resource} limit reached" for limitation in detection.limitations[:256] @@ -2152,6 +2164,7 @@ def _scan_multi_skill( result_body = _result_body(result) result_characters = len(result_body) result_records = _multi_skill_public_record_count(result) + has_findings = has_findings or bool(effective_findings(result)) if ( retained_public_records + result_records > _MULTI_SKILL_MAX_PUBLIC_RECORDS or retained_report_characters + result_characters @@ -2396,6 +2409,8 @@ def _scan_multi_skill( raise typer.Exit(code=2) if fail_on_incomplete and analysis_incomplete: raise typer.Exit(code=1) + if fail_on_findings and has_findings: + raise typer.Exit(code=1) if max_score > RISK_THRESHOLD: raise typer.Exit(code=1) diff --git a/tests/unit/test_cli.py b/tests/unit/test_cli.py index 9302c1f9..c6d27784 100644 --- a/tests/unit/test_cli.py +++ b/tests/unit/test_cli.py @@ -220,6 +220,33 @@ def test_cli_fail_on_incomplete_exits_one_after_writing_report( assert output.exists() +def test_cli_fail_on_findings_exits_one_below_risk_threshold( + monkeypatch: pytest.MonkeyPatch, tmp_path: Path +) -> None: + """Active findings can gate automation even when aggregate risk remains low.""" + (tmp_path / "SKILL.md").write_text("# Safe", encoding="utf-8") + output = tmp_path / "report.json" + monkeypatch.setattr( + "skillspector.cli.graph.invoke", + lambda state, config: { + "report_body": '{"issues": []}', + "execution_successful": True, + "analysis_completeness": {"is_complete": True}, + "risk_score": 0, + "findings": [_finding("T1", "active finding")], + "filtered_findings": [_finding("T1", "active finding")], + }, + ) + + result = runner.invoke( + app, + ["scan", str(tmp_path), "-f", "json", "-o", str(output), "--fail-on-findings"], + ) + + assert result.exit_code == 1 + assert output.exists() + + def test_recursive_scan_exits_two_after_writing_all_child_reports(tmp_path: Path) -> None: """Recursive mode aggregates child execution failures after producing output.""" s1 = SkillDirectory(path=tmp_path / "one", name="one", relative_path="one") @@ -914,6 +941,28 @@ def _bounded_recursive_result(label: str, *, finding_count: int = 1) -> dict[str } +def test_recursive_fail_on_findings_exits_one_below_risk_threshold( + monkeypatch: pytest.MonkeyPatch, tmp_path: Path +) -> None: + """Recursive scans gate on active child findings, not only aggregate score.""" + skill = SkillDirectory(tmp_path / "one", "one", "one") + detection = MultiSkillDetectionResult(is_multi_skill=True, skills=[skill]) + monkeypatch.setattr( + cli.graph, "invoke", lambda *_args, **_kwargs: _bounded_recursive_result("one") + ) + + with pytest.raises(typer.Exit) as exit_info: + _scan_multi_skill( + detection, + FormatChoice.json, + None, + no_llm=True, + fail_on_findings=True, + ) + + assert exit_info.value.exit_code == 1 + + def test_recursive_json_uses_one_global_public_record_budget( monkeypatch: pytest.MonkeyPatch, tmp_path: Path ) -> None: