feat(three-stage-scan): add language recipes with inconclusive fallback

This commit is contained in:
Marius Ciepluch
2026-09-03 22:48:10 +02:00
parent 7a584d4578
commit 62c7990e49
5 changed files with 200 additions and 3 deletions
+1 -1
View File
@@ -110,7 +110,7 @@ This plan ships the three-stage scan without rebuilding CWE policy or the regex
- _Boundary: RegressionRunner_ - _Boundary: RegressionRunner_
- _Depends: 2.4_ - _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. - 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. - Observable completion: Python and JavaScript recipes produce a job; an unknown language returns inconclusive without calling the runner.
- _Requirements: 5.1, 5.6_ - _Requirements: 5.1, 5.6_
+4 -1
View File
@@ -1,14 +1,17 @@
"""Isolated regression snippets and verdicts.""" """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 from .safety import UnsafeSnippetError, check_snippet_safety
__all__ = [ __all__ = [
"INCONCLUSIVE", "INCONCLUSIVE",
"MISSING_RECIPE",
"SAFETY_REJECTED", "SAFETY_REJECTED",
"RegressionRunner", "RegressionRunner",
"RegressionVerdict", "RegressionVerdict",
"UnsafeSnippetError", "UnsafeSnippetError",
"check_snippet_safety", "check_snippet_safety",
"recipe_for",
"select_candidates", "select_candidates",
] ]
+21 -1
View File
@@ -4,15 +4,19 @@ from __future__ import annotations
from collections.abc import Mapping, Sequence from collections.abc import Mapping, Sequence
from dataclasses import dataclass from dataclasses import dataclass
from pathlib import Path
from ..complexity.ledger import hotspot_key, select_forced_candidates from ..complexity.ledger import hotspot_key, select_forced_candidates
from ..complexity.map import Hotspot from ..complexity.map import Hotspot
from ..config import SandboxConfig
from ..findings import StaticFinding from ..findings import StaticFinding
from ..sandbox import SandboxJob, SandboxResult, SandboxRunner from ..sandbox import SandboxJob, SandboxResult, SandboxRunner
from .recipes import recipe_for
from .safety import UnsafeSnippetError, check_snippet_safety from .safety import UnsafeSnippetError, check_snippet_safety
INCONCLUSIVE = "inconclusive" INCONCLUSIVE = "inconclusive"
SAFETY_REJECTED = "safety_rejected" SAFETY_REJECTED = "safety_rejected"
MISSING_RECIPE = "missing_recipe"
TRIGGERABLE = "triggerable" TRIGGERABLE = "triggerable"
NOT_TRIGGERABLE = "not_triggerable" NOT_TRIGGERABLE = "not_triggerable"
@@ -81,7 +85,23 @@ class RegressionRunner:
def __init__(self, sandbox: SandboxRunner) -> None: def __init__(self, sandbox: SandboxRunner) -> None:
self._sandbox = sandbox 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: try:
check_snippet_safety(snippet) check_snippet_safety(snippet)
except UnsafeSnippetError: except UnsafeSnippetError:
+71
View File
@@ -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,
)
+103
View File
@@ -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 == []