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
15 changes: 15 additions & 0 deletions src/skillspector/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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."""
Expand All @@ -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]
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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)

Expand Down
49 changes: 49 additions & 0 deletions tests/unit/test_cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand Down Expand Up @@ -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:
Expand Down
Loading