diff --git a/.kiro/specs/three-stage-scan/tasks.md b/.kiro/specs/three-stage-scan/tasks.md index 2667a31..2cbd736 100644 --- a/.kiro/specs/three-stage-scan/tasks.md +++ b/.kiro/specs/three-stage-scan/tasks.md @@ -110,7 +110,7 @@ This plan ships the three-stage scan without rebuilding CWE policy or the regex - _Boundary: RegressionRunner_ - _Depends: 2.4_ -- [ ] 5.2 (P) Add language recipes with inconclusive fallback +- [x] 5.2 (P) Add language recipes with inconclusive fallback - Python and JavaScript recipes run a scratch snippet with the repo on the language path; C compiles and runs when the image has a compiler; a missing recipe yields `inconclusive` rather than a crash. - Observable completion: Python and JavaScript recipes produce a job; an unknown language returns inconclusive without calling the runner. - _Requirements: 5.1, 5.6_ diff --git a/src/openultrasast/regress/__init__.py b/src/openultrasast/regress/__init__.py index 745bb94..7584d71 100644 --- a/src/openultrasast/regress/__init__.py +++ b/src/openultrasast/regress/__init__.py @@ -1,14 +1,17 @@ """Isolated regression snippets and verdicts.""" -from .candidate import INCONCLUSIVE, SAFETY_REJECTED, RegressionRunner, RegressionVerdict, select_candidates +from .candidate import INCONCLUSIVE, MISSING_RECIPE, SAFETY_REJECTED, RegressionRunner, RegressionVerdict, select_candidates +from .recipes import recipe_for from .safety import UnsafeSnippetError, check_snippet_safety __all__ = [ "INCONCLUSIVE", + "MISSING_RECIPE", "SAFETY_REJECTED", "RegressionRunner", "RegressionVerdict", "UnsafeSnippetError", "check_snippet_safety", + "recipe_for", "select_candidates", ] diff --git a/src/openultrasast/regress/candidate.py b/src/openultrasast/regress/candidate.py index 6928b0e..43f4c47 100644 --- a/src/openultrasast/regress/candidate.py +++ b/src/openultrasast/regress/candidate.py @@ -4,15 +4,19 @@ from __future__ import annotations from collections.abc import Mapping, Sequence from dataclasses import dataclass +from pathlib import Path from ..complexity.ledger import hotspot_key, select_forced_candidates from ..complexity.map import Hotspot +from ..config import SandboxConfig from ..findings import StaticFinding from ..sandbox import SandboxJob, SandboxResult, SandboxRunner +from .recipes import recipe_for from .safety import UnsafeSnippetError, check_snippet_safety INCONCLUSIVE = "inconclusive" SAFETY_REJECTED = "safety_rejected" +MISSING_RECIPE = "missing_recipe" TRIGGERABLE = "triggerable" NOT_TRIGGERABLE = "not_triggerable" @@ -81,7 +85,23 @@ class RegressionRunner: def __init__(self, sandbox: SandboxRunner) -> None: self._sandbox = sandbox - def run_snippet(self, snippet: str, job: SandboxJob) -> RegressionVerdict: + def run_recipe( + self, + language: str, + snippet: str, + image: str, + sandbox_limits: SandboxConfig, + *, + repo_root: Path, + ) -> RegressionVerdict: + job = recipe_for(language, snippet, image, sandbox_limits, repo_root=repo_root) + if job is None: + return RegressionVerdict(verdict=INCONCLUSIVE, reason=MISSING_RECIPE) + return self.run_snippet(snippet, job) + + def run_snippet(self, snippet: str, job: SandboxJob | None) -> RegressionVerdict: + if job is None: + return RegressionVerdict(verdict=INCONCLUSIVE, reason=MISSING_RECIPE) try: check_snippet_safety(snippet) except UnsafeSnippetError: diff --git a/src/openultrasast/regress/recipes.py b/src/openultrasast/regress/recipes.py new file mode 100644 index 0000000..fbcd7bc --- /dev/null +++ b/src/openultrasast/regress/recipes.py @@ -0,0 +1,71 @@ +"""Language command recipes for isolated regression snippets.""" + +from __future__ import annotations + +from pathlib import Path + +from ..config import SandboxConfig +from ..sandbox.runner import SCRATCH_MOUNT, WORKSPACE_MOUNT, SandboxJob + +_PYTHON_FILE = "case.py" +_JS_FILE = "case.js" +_C_FILE = "case.c" +_C_BIN = "case" + + +def recipe_for( + language: str, + snippet: str, + image: str, + sandbox_limits: SandboxConfig, + *, + repo_root: Path, +) -> SandboxJob | None: + """Return a sandbox job for `language`, or None when no recipe exists.""" + key = language.strip().lower() + if key == "python": + return _job( + image, + sandbox_limits, + repo_root, + command=("env", f"PYTHONPATH={WORKSPACE_MOUNT}", "python", f"{SCRATCH_MOUNT}/{_PYTHON_FILE}"), + scratch_files={_PYTHON_FILE: snippet}, + ) + if key == "javascript": + return _job( + image, + sandbox_limits, + repo_root, + command=("env", f"NODE_PATH={WORKSPACE_MOUNT}", "node", f"{SCRATCH_MOUNT}/{_JS_FILE}"), + scratch_files={_JS_FILE: snippet}, + ) + if key == "c": + source = f"{SCRATCH_MOUNT}/{_C_FILE}" + binary = f"{SCRATCH_MOUNT}/{_C_BIN}" + return _job( + image, + sandbox_limits, + repo_root, + command=("sh", "-c", f"cc {source} -o {binary} && {binary}"), + scratch_files={_C_FILE: snippet}, + ) + return None + + +def _job( + image: str, + sandbox_limits: SandboxConfig, + repo_root: Path, + *, + command: tuple[str, ...], + scratch_files: dict[str, str], +) -> SandboxJob: + return SandboxJob( + image=image, + command=command, + repo_root=repo_root, + scratch_files=scratch_files, + timeout_seconds=sandbox_limits.timeout_seconds, + memory_mb=sandbox_limits.memory_mb, + pids_limit=sandbox_limits.pids_limit, + ) diff --git a/tests/test_regress_recipes.py b/tests/test_regress_recipes.py new file mode 100644 index 0000000..a2bf609 --- /dev/null +++ b/tests/test_regress_recipes.py @@ -0,0 +1,103 @@ +"""Language recipes for isolated regression snippets (task 5.2).""" + +from pathlib import Path + +from openultrasast.config import SandboxConfig +from openultrasast.regress import INCONCLUSIVE, RegressionRunner +from openultrasast.regress.recipes import recipe_for +from openultrasast.sandbox import FakeSandboxRunner, SandboxJob + +LIMITS = SandboxConfig(memory_mb=256, timeout_seconds=30, pids_limit=128) +PYTHON_SNIPPET = "assert 1 + 1 == 2\n" +JS_SNIPPET = "console.log('ok')\n" +C_SNIPPET = "int main(void) { return 0; }\n" +JAVA_SNIPPET = "class Case { public static void main(String[] args) {} }\n" + + +def _repo(tmp_path: Path) -> Path: + repo = tmp_path / "repo" + repo.mkdir() + return repo + + +def test_python_recipe_runs_scratch_snippet_with_repo_on_pythonpath(tmp_path: Path) -> None: + repo = _repo(tmp_path) + job = recipe_for("python", PYTHON_SNIPPET, "python:3.12-alpine", LIMITS, repo_root=repo) + + assert isinstance(job, SandboxJob) + joined = " ".join(job.command) + assert job.image == "python:3.12-alpine" + assert job.repo_root == repo + assert job.scratch_files == {"case.py": PYTHON_SNIPPET} + assert "python" in job.command + assert "/scratch/case.py" in job.command + assert "PYTHONPATH=/workspace" in joined + assert job.timeout_seconds == LIMITS.timeout_seconds + assert job.memory_mb == LIMITS.memory_mb + assert job.pids_limit == LIMITS.pids_limit + + +def test_javascript_recipe_runs_scratch_snippet_with_repo_on_node_path(tmp_path: Path) -> None: + repo = _repo(tmp_path) + job = recipe_for("javascript", JS_SNIPPET, "node:22-alpine", LIMITS, repo_root=repo) + + assert isinstance(job, SandboxJob) + joined = " ".join(job.command) + assert job.image == "node:22-alpine" + assert job.repo_root == repo + assert job.scratch_files == {"case.js": JS_SNIPPET} + assert "node" in job.command + assert "/scratch/case.js" in job.command + assert "NODE_PATH=/workspace" in joined + assert job.timeout_seconds == LIMITS.timeout_seconds + assert job.memory_mb == LIMITS.memory_mb + assert job.pids_limit == LIMITS.pids_limit + + +def test_c_recipe_compiles_and_runs_when_image_has_compiler(tmp_path: Path) -> None: + repo = _repo(tmp_path) + job = recipe_for("c", C_SNIPPET, "gcc:13", LIMITS, repo_root=repo) + + assert isinstance(job, SandboxJob) + joined = " ".join(job.command) + assert job.scratch_files == {"case.c": C_SNIPPET} + assert "cc" in joined + assert "/scratch/case.c" in joined + assert "-o" in joined + assert "/scratch/case" in joined + + +def test_unknown_language_recipe_is_none(tmp_path: Path) -> None: + repo = _repo(tmp_path) + assert recipe_for("java", JAVA_SNIPPET, "eclipse-temurin:21", LIMITS, repo_root=repo) is None + assert recipe_for("unknown", "print(1)\n", "python:3.12-alpine", LIMITS, repo_root=repo) is None + + +def test_python_and_javascript_recipes_produce_runner_jobs(tmp_path: Path) -> None: + repo = _repo(tmp_path) + python_job = recipe_for("python", PYTHON_SNIPPET, "python:3.12-alpine", LIMITS, repo_root=repo) + js_job = recipe_for("javascript", JS_SNIPPET, "node:22-alpine", LIMITS, repo_root=repo) + sandbox = FakeSandboxRunner() + runner = RegressionRunner(sandbox) + + python_verdict = runner.run_recipe("python", PYTHON_SNIPPET, "python:3.12-alpine", LIMITS, repo_root=repo) + js_verdict = runner.run_recipe("javascript", JS_SNIPPET, "node:22-alpine", LIMITS, repo_root=repo) + + assert python_job is not None + assert js_job is not None + assert python_verdict.reason != "missing_recipe" + assert js_verdict.reason != "missing_recipe" + assert sandbox.jobs == [python_job, js_job] + + +def test_unknown_language_returns_inconclusive_without_calling_runner(tmp_path: Path) -> None: + repo = _repo(tmp_path) + sandbox = FakeSandboxRunner() + runner = RegressionRunner(sandbox) + + assert recipe_for("java", JAVA_SNIPPET, "eclipse-temurin:21", LIMITS, repo_root=repo) is None + verdict = runner.run_recipe("java", JAVA_SNIPPET, "eclipse-temurin:21", LIMITS, repo_root=repo) + + assert verdict.verdict == INCONCLUSIVE + assert verdict.reason == "missing_recipe" + assert sandbox.jobs == []