From 926c8c273ef97a0746923084f2d286651d0c7776 Mon Sep 17 00:00:00 2001 From: Marius Ciepluch <11855163+norandom@users.noreply.github.com> Date: Thu, 3 Sep 2026 19:29:54 +0200 Subject: [PATCH] feat(three-stage-scan): add complexity and regression config caps --- .kiro/specs/three-stage-scan/tasks.md | 2 +- src/openultrasast/config.py | 44 +++++++++++++++++++ tests/test_config.py | 63 +++++++++++++++++++++++++++ 3 files changed, 108 insertions(+), 1 deletion(-) diff --git a/.kiro/specs/three-stage-scan/tasks.md b/.kiro/specs/three-stage-scan/tasks.md index 30ee3a6..681ef1d 100644 --- a/.kiro/specs/three-stage-scan/tasks.md +++ b/.kiro/specs/three-stage-scan/tasks.md @@ -12,7 +12,7 @@ This plan ships the three-stage scan without rebuilding CWE policy or the regex - _Requirements: 1.1, 1.2, 1.3, 1.6_ - _Boundary: StagePlan_ -- [ ] 1.2 Add complexity and regression configuration with candidate caps +- [x] 1.2 Add complexity and regression configuration with candidate caps - Configuration exposes map top-k, max hunter hotspots, max regression candidates, and optional language image pins, with defaults that keep pull-request scans cheap. - Sandbox memory, pids, timeout, network-off, and read-only workspace remain the existing sandbox settings. - Observable completion: loading config without a file yields those defaults; an override file changes top-k and max candidates only. diff --git a/src/openultrasast/config.py b/src/openultrasast/config.py index 49f7472..d095a3a 100644 --- a/src/openultrasast/config.py +++ b/src/openultrasast/config.py @@ -96,6 +96,20 @@ class FusionConfig: high_assurance: bool = False +@dataclass(frozen=True) +class ComplexityConfig: + # Stage-2 map size and hunter attention. Small defaults keep PR/nightly map work cheap. + top_k: int = 20 + max_hunter_hotspots: int = 8 + + +@dataclass(frozen=True) +class RegressConfig: + # Stage-3 candidate cap and optional per-language sandbox image pins. Isolation limits stay on SandboxConfig. + max_candidates: int = 5 + images: tuple[tuple[str, str], ...] = () + + @dataclass(frozen=True) class ResolvedConfig: models: ModelConfig = ModelConfig() @@ -109,6 +123,8 @@ class ResolvedConfig: harnessx: HarnessxConfig = HarnessxConfig() fusion: FusionConfig = FusionConfig() hardening: HardeningConfig = HardeningConfig() + complexity: ComplexityConfig = ComplexityConfig() + regress: RegressConfig = RegressConfig() runs_dir: str = ".openultrasast/runs" @@ -130,6 +146,8 @@ def load_config(config_path: Path | None = None) -> ResolvedConfig: harnessx=_load_harnessx(data.get("harnessx", {})), fusion=_load_fusion(data.get("fusion", {})), hardening=_load_hardening(data.get("hardening", {})), + complexity=_load_complexity(data.get("complexity", {})), + regress=_load_regress(data.get("regress", {})), runs_dir=os.environ.get("OPENULTRASAST_RUNS_DIR", ".openultrasast/runs"), ) @@ -251,6 +269,32 @@ def _load_fusion(value: object) -> FusionConfig: ) +def _load_complexity(value: object) -> ComplexityConfig: + data = _section(value) + return ComplexityConfig( + top_k=_int_value(data.get("top_k"), 20), + max_hunter_hotspots=_int_value(data.get("max_hunter_hotspots"), 8), + ) + + +def _load_regress(value: object) -> RegressConfig: + data = _section(value) + return RegressConfig( + max_candidates=_int_value(data.get("max_candidates"), 5), + images=_string_map(data.get("images")), + ) + + +def _string_map(value: object) -> tuple[tuple[str, str], ...]: + data = _section(value) + images: list[tuple[str, str]] = [] + for key, item in data.items(): + pinned = _string(item) + if pinned is not None: + images.append((str(key), pinned)) + return tuple(sorted(images)) + + def _float_value(value: object, default: float) -> float: if isinstance(value, bool): return default diff --git a/tests/test_config.py b/tests/test_config.py index f5ba412..c42ed54 100644 --- a/tests/test_config.py +++ b/tests/test_config.py @@ -96,3 +96,66 @@ def test_write_resolved_config_creates_json(tmp_path: Path) -> None: assert output.exists() assert '"minimum_report_verified": "static_corroboration"' in output.read_text() + + +def test_complexity_and_regress_defaults_when_section_absent() -> None: + config = load_config(None) + + assert config.complexity.top_k == 20 + assert config.complexity.max_hunter_hotspots == 8 + assert config.regress.max_candidates == 5 + assert config.regress.images == () + assert config.sandbox.network is False + assert config.sandbox.workspace_readonly is True + assert config.sandbox.memory_mb == 2048 + assert config.sandbox.timeout_seconds == 300 + assert config.sandbox.pids_limit == 512 + + +def test_override_changes_top_k_and_max_candidates_only(tmp_path: Path) -> None: + config_path = tmp_path / "openultrasast.toml" + config_path.write_text( + "\n".join( + [ + "[complexity]", + "top_k = 3", + "[regress]", + "max_candidates = 2", + ] + ) + ) + + config = load_config(config_path) + + assert config.complexity.top_k == 3 + assert config.regress.max_candidates == 2 + assert config.complexity.max_hunter_hotspots == 8 + assert config.regress.images == () + assert config.sandbox.network is False + assert config.sandbox.workspace_readonly is True + assert config.sandbox.memory_mb == 2048 + assert config.sandbox.timeout_seconds == 300 + assert config.sandbox.pids_limit == 512 + + +def test_load_config_reads_regress_image_pins(tmp_path: Path) -> None: + config_path = tmp_path / "openultrasast.toml" + config_path.write_text( + "\n".join( + [ + "[regress]", + "max_candidates = 4", + "[regress.images]", + 'python = "python:3.12-slim"', + 'javascript = "node:22-slim"', + ] + ) + ) + + config = load_config(config_path) + + assert config.regress.max_candidates == 4 + assert dict(config.regress.images) == {"python": "python:3.12-slim", "javascript": "node:22-slim"} + assert config.complexity.top_k == 20 + assert config.complexity.max_hunter_hotspots == 8 + assert config.sandbox.memory_mb == 2048