diff --git a/.env.example b/.env.example index 1515f29e..9983e991 100644 --- a/.env.example +++ b/.env.example @@ -5,6 +5,10 @@ ENV=dev # options: dev|s # Options: openai | anthropic | anthropic_proxy | nv_build SKILLSPECTOR_PROVIDER= +# Aggregate deadline for one complete scan workflow. Defaults to 60 seconds; +# raise this for large skills that require a complete rather than partial scan. +# SKILLSPECTOR_MAX_WORKFLOW_SECONDS=120 + # Provider credentials — set the one matching SKILLSPECTOR_PROVIDER (or # leave SKILLSPECTOR_PROVIDER unset and set NVIDIA_INFERENCE_KEY for the # default nv_build path). diff --git a/docs/ANALYSIS_RESOURCE_BOUNDS.md b/docs/ANALYSIS_RESOURCE_BOUNDS.md index ea556630..4cceb8aa 100644 --- a/docs/ANALYSIS_RESOURCE_BOUNDS.md +++ b/docs/ANALYSIS_RESOURCE_BOUNDS.md @@ -211,3 +211,13 @@ When relevant analysis is incomplete: A low score or zero findings must not be interpreted as complete coverage when `analysis_completeness.is_complete` is false. + + +## Configuring the aggregate workflow deadline + +The scanner limits one complete workflow to 60 seconds by default. Set +`SKILLSPECTOR_MAX_WORKFLOW_SECONDS` to a positive finite number of seconds to +raise that aggregate deadline for large skills. The setting applies to direct, +CLI, recursive, and multi-skill scans; byte and artifact ceilings remain in +effect. Invalid, zero, negative, infinite, or NaN values safely keep the +60-second default. diff --git a/src/skillspector/cli.py b/src/skillspector/cli.py index 838f838c..43ff51f0 100644 --- a/src/skillspector/cli.py +++ b/src/skillspector/cli.py @@ -56,7 +56,7 @@ from skillspector.multi_skill import MultiSkillDetectionResult, SkillDirectory, detect_skills from skillspector.nodes.report import report from skillspector.sarif_models import SARIF_SCHEMA_URI, validate_sarif_report -from skillspector.state import MAX_WORKFLOW_BYTES +from skillspector.state import MAX_WORKFLOW_BYTES, MAX_WORKFLOW_SECONDS from skillspector.suppression import ( Baseline, build_baseline_dict, @@ -100,7 +100,7 @@ def _ensure_utf8_streams() -> None: _TRANSITIVE_MAX_TARGETS = 32 _TRANSITIVE_MAX_BYTES = 10 * 1024 * 1024 -_TRANSITIVE_MAX_SECONDS = 60.0 +_TRANSITIVE_MAX_SECONDS = MAX_WORKFLOW_SECONDS _TRANSITIVE_MAX_ARTIFACTS = 10_000 _TRANSITIVE_MAX_FINDINGS = 10_000 _TRANSITIVE_MAX_COMPONENTS = 10_000 diff --git a/src/skillspector/state.py b/src/skillspector/state.py index c5f80b6e..3c6ede4c 100644 --- a/src/skillspector/state.py +++ b/src/skillspector/state.py @@ -17,7 +17,9 @@ from __future__ import annotations +import math import operator +import os from dataclasses import dataclass, field from time import monotonic from typing import Annotated, NotRequired @@ -36,9 +38,40 @@ LedgerRecordType, ledger_event, ) +from skillspector.logging_config import get_logger from skillspector.models import Finding -MAX_WORKFLOW_SECONDS = 60.0 +logger = get_logger(__name__) + +DEFAULT_MAX_WORKFLOW_SECONDS = 60.0 + + +def _workflow_max_seconds_from_environment(value: str | None) -> float: + """Return a positive finite workflow deadline or the safe default.""" + if value is None: + return DEFAULT_MAX_WORKFLOW_SECONDS + try: + seconds = float(value) + except ValueError: + logger.warning( + "SKILLSPECTOR_MAX_WORKFLOW_SECONDS=%r is not numeric, using default %.1fs", + value, + DEFAULT_MAX_WORKFLOW_SECONDS, + ) + return DEFAULT_MAX_WORKFLOW_SECONDS + if not math.isfinite(seconds) or seconds <= 0: + logger.warning( + "SKILLSPECTOR_MAX_WORKFLOW_SECONDS=%r must be finite and positive, using default %.1fs", + value, + DEFAULT_MAX_WORKFLOW_SECONDS, + ) + return DEFAULT_MAX_WORKFLOW_SECONDS + return seconds + + +MAX_WORKFLOW_SECONDS = _workflow_max_seconds_from_environment( + os.environ.get("SKILLSPECTOR_MAX_WORKFLOW_SECONDS") +) MAX_WORKFLOW_BYTES = 64 * 1024 * 1024 MAX_WORKFLOW_ARTIFACTS = 10_000 MAX_WORKFLOW_LIMITATION_RECORDS = 256 diff --git a/tests/nodes/test_build_context.py b/tests/nodes/test_build_context.py index f841d2bd..d7748035 100644 --- a/tests/nodes/test_build_context.py +++ b/tests/nodes/test_build_context.py @@ -38,11 +38,13 @@ from skillspector.providers.openai import OpenAIProvider from skillspector.python_ast import ParsedPythonFile, get_python_ast from skillspector.state import ( + DEFAULT_MAX_WORKFLOW_SECONDS, MAX_WORKFLOW_ARTIFACTS, MAX_WORKFLOW_BYTES, MAX_WORKFLOW_SECONDS, SkillspectorState, WorkflowResourceBudget, + _workflow_max_seconds_from_environment, ) _OMS_FIXTURE = Path(__file__).parents[1] / "fixtures" / "oms" / "mcore-split-pr.skill.oms.sig" @@ -188,6 +190,22 @@ def test_build_context_starts_and_returns_default_graph_wide_budget(tmp_path: Pa assert budget.scanned_artifacts == 1 +@pytest.mark.parametrize( + ("value", "expected"), + [ + (None, DEFAULT_MAX_WORKFLOW_SECONDS), + ("120", 120.0), + ("0.5", 0.5), + ("0", DEFAULT_MAX_WORKFLOW_SECONDS), + ("-1", DEFAULT_MAX_WORKFLOW_SECONDS), + ("nan", DEFAULT_MAX_WORKFLOW_SECONDS), + ("not-a-number", DEFAULT_MAX_WORKFLOW_SECONDS), + ], +) +def test_workflow_budget_seconds_environment_parsing(value: str | None, expected: float) -> None: + assert _workflow_max_seconds_from_environment(value) == expected + + def test_build_context_reuses_supplied_stricter_transitive_budget(tmp_path: Path) -> None: from skillspector.cli import _TransitiveBudget, _TransitiveTraversalState