scaneval 2.0.0a1__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (115) hide show
  1. scaneval/__init__.py +13 -0
  2. scaneval/__main__.py +3 -0
  3. scaneval/adapters/__init__.py +30 -0
  4. scaneval/adapters/base.py +535 -0
  5. scaneval/adapters/deepsec.py +3944 -0
  6. scaneval/adapters/llm_harness.py +3312 -0
  7. scaneval/adapters/llm_harness_driver.mts +2160 -0
  8. scaneval/adapters/pi_oci_runner.mts +317 -0
  9. scaneval/adapters/pi_open_context.mts +19 -0
  10. scaneval/adapters/pi_open_context_tools.mjs +506 -0
  11. scaneval/adapters/pi_synthetic_provider.mts +115 -0
  12. scaneval/adapters/pr.py +252 -0
  13. scaneval/adapters/runsortie.py +3279 -0
  14. scaneval/adapters/runsortie_evidence.py +2232 -0
  15. scaneval/adapters/semgrep.py +1081 -0
  16. scaneval/aggregate.py +2548 -0
  17. scaneval/artifacts.py +38 -0
  18. scaneval/benchmark.py +486 -0
  19. scaneval/benchmark_cli.py +61 -0
  20. scaneval/blinding.py +1886 -0
  21. scaneval/cases.py +1573 -0
  22. scaneval/cli.py +1799 -0
  23. scaneval/collectors/__init__.py +545 -0
  24. scaneval/collectors/claude_code.py +1600 -0
  25. scaneval/collectors/codex.py +494 -0
  26. scaneval/collectors/source.py +63 -0
  27. scaneval/conditions.py +624 -0
  28. scaneval/conditions_identity.py +93 -0
  29. scaneval/contracts.py +2695 -0
  30. scaneval/corpus.py +57 -0
  31. scaneval/data/model-cutoffs.json +1717 -0
  32. scaneval/datasets/__init__.py +35 -0
  33. scaneval/datasets/base.py +139 -0
  34. scaneval/datasets/builtin.py +224 -0
  35. scaneval/datasets/cli.py +50 -0
  36. scaneval/datasets/locks/aacr-bench.json +54 -0
  37. scaneval/datasets/locks/martian.json +522 -0
  38. scaneval/datasets/locks/openssf.json +1628 -0
  39. scaneval/datasets/locks/reviewbench.json +1578 -0
  40. scaneval/demo.py +64 -0
  41. scaneval/diagnostics.py +1089 -0
  42. scaneval/evaluator.py +27 -0
  43. scaneval/execution.py +1764 -0
  44. scaneval/experiment.py +507 -0
  45. scaneval/experiment_execution.py +405 -0
  46. scaneval/experiment_report.py +457 -0
  47. scaneval/gate.py +1344 -0
  48. scaneval/identity_policy.py +1278 -0
  49. scaneval/investigation.py +467 -0
  50. scaneval/investigation_html.py +138 -0
  51. scaneval/isolation/__init__.py +275 -0
  52. scaneval/isolation/egress_proxy.py +302 -0
  53. scaneval/isolation/oci.py +1335 -0
  54. scaneval/isolation/public_retrieval.py +556 -0
  55. scaneval/kinds.py +86 -0
  56. scaneval/machine_review.py +299 -0
  57. scaneval/mappings/kinds.json +43 -0
  58. scaneval/materialize.py +1243 -0
  59. scaneval/model_cutoff_projection.py +148 -0
  60. scaneval/model_cutoffs.py +261 -0
  61. scaneval/model_cutoffs_cli.py +36 -0
  62. scaneval/observer/__init__.py +81 -0
  63. scaneval/observer/emitter.py +1795 -0
  64. scaneval/open_context.py +341 -0
  65. scaneval/open_context_evidence.py +285 -0
  66. scaneval/precision.py +929 -0
  67. scaneval/report.py +114 -0
  68. scaneval/resampling.py +93 -0
  69. scaneval/review.py +475 -0
  70. scaneval/runner.py +1257 -0
  71. scaneval/sarif.py +2240 -0
  72. scaneval/schedule.py +277 -0
  73. scaneval/schemas/aggregate-report.schema.json +2011 -0
  74. scaneval/schemas/aggregation-policy.schema.json +131 -0
  75. scaneval/schemas/blinding-map.schema.json +426 -0
  76. scaneval/schemas/case-pack-2.1.schema.json +1172 -0
  77. scaneval/schemas/case-pack.schema.json +257 -0
  78. scaneval/schemas/comparison-report.schema.json +2078 -0
  79. scaneval/schemas/condition-record.schema.json +41 -0
  80. scaneval/schemas/dataset.schema.json +61 -0
  81. scaneval/schemas/evaluation-plan-2.1.schema.json +329 -0
  82. scaneval/schemas/evaluation-plan.schema.json +52 -0
  83. scaneval/schemas/evaluation-schedule.schema.json +647 -0
  84. scaneval/schemas/execution-record-2.1.schema.json +501 -0
  85. scaneval/schemas/execution-record.schema.json +79 -0
  86. scaneval/schemas/experiment-matrix.schema.json +99 -0
  87. scaneval/schemas/gate-decision.schema.json +387 -0
  88. scaneval/schemas/gate-policy.schema.json +436 -0
  89. scaneval/schemas/identity-transformation-manifest.schema.json +136 -0
  90. scaneval/schemas/import-record.schema.json +680 -0
  91. scaneval/schemas/machine-judgments.schema.json +72 -0
  92. scaneval/schemas/precision-estimate.schema.json +807 -0
  93. scaneval/schemas/precision-reviews.schema.json +96 -0
  94. scaneval/schemas/precision-sample.schema.json +667 -0
  95. scaneval/schemas/review-decisions.schema.json +40 -0
  96. scaneval/schemas/review-record.schema.json +21 -0
  97. scaneval/schemas/run-config-2.1.schema.json +275 -0
  98. scaneval/schemas/run-config.schema.json +29 -0
  99. scaneval/schemas/run-manifest-2.1.schema.json +599 -0
  100. scaneval/schemas/run-manifest.schema.json +140 -0
  101. scaneval/schemas/scan-request.schema.json +52 -0
  102. scaneval/schemas/scan-result-2.1.schema.json +295 -0
  103. scaneval/schemas/scan-result.schema.json +73 -0
  104. scaneval/schemas/severity-report.schema.json +1158 -0
  105. scaneval/schemas/trace-event.schema.json +20 -0
  106. scaneval/scoring.py +379 -0
  107. scaneval/severity.py +422 -0
  108. scaneval/source.py +20 -0
  109. scaneval/trace.py +80 -0
  110. scaneval-2.0.0a1.dist-info/METADATA +145 -0
  111. scaneval-2.0.0a1.dist-info/RECORD +115 -0
  112. scaneval-2.0.0a1.dist-info/WHEEL +5 -0
  113. scaneval-2.0.0a1.dist-info/entry_points.txt +2 -0
  114. scaneval-2.0.0a1.dist-info/licenses/LICENSE +21 -0
  115. scaneval-2.0.0a1.dist-info/top_level.txt +1 -0
scaneval/__init__.py ADDED
@@ -0,0 +1,13 @@
1
+ """Framework-independent ScanEval evaluation core.
2
+
3
+ Live scanner integrations remain separate from this protocol.
4
+ """
5
+
6
+ __version__ = "2.0.0a1"
7
+
8
+
9
+ def evaluate(plan: dict, result: dict, decisions: dict) -> dict:
10
+ """Evaluate one saved scan with frozen evaluator-side decisions, offline."""
11
+ from .scoring import score
12
+
13
+ return score(plan, result, decisions)
scaneval/__main__.py ADDED
@@ -0,0 +1,3 @@
1
+ from .cli import main
2
+
3
+ raise SystemExit(main())
@@ -0,0 +1,30 @@
1
+ """Adapter registry. Adapters are looked up by name and constructed without arguments."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from importlib import import_module
6
+
7
+ from .base import Adapter, AdapterError, NativeOutcome, SystemSpec
8
+
9
+ _REGISTRY = {
10
+ "semgrep": ("scaneval.adapters.semgrep", "SemgrepAdapter"),
11
+ "llm-harness": ("scaneval.adapters.llm_harness", "LlmHarnessAdapter"),
12
+ "deepsec": ("scaneval.adapters.deepsec", "DeepsecAdapter"),
13
+ "runsortie": ("scaneval.adapters.runsortie", "RunSortieAdapter"),
14
+ }
15
+
16
+
17
+ def adapter_names() -> list[str]:
18
+ return sorted(_REGISTRY)
19
+
20
+
21
+ def get_adapter(name: str) -> Adapter:
22
+ try:
23
+ module_name, class_name = _REGISTRY[name]
24
+ except KeyError as exc:
25
+ raise AdapterError(f"unknown adapter {name!r}; expected one of {adapter_names()}") from exc
26
+ module = import_module(module_name)
27
+ return getattr(module, class_name)()
28
+
29
+
30
+ __all__ = ["Adapter", "AdapterError", "NativeOutcome", "SystemSpec", "adapter_names", "get_adapter"]
@@ -0,0 +1,535 @@
1
+ """Adapter protocol for invoking one scanner on one prepared input.
2
+
3
+ An adapter runs the real product once, preserves its raw output, and translates the
4
+ native findings into normalized claims. It never receives labels, never decides whether
5
+ a claim is true, and never turns an execution failure into an empty successful scan.
6
+
7
+ Every scanner process an adapter starts goes through :func:`run_command`, and that is what lets
8
+ an execution backend hold it: while a backend is active (:func:`routed_through`, which the
9
+ backend's own ``activate()`` enters for the span of one scan), :func:`run_command` hands the
10
+ command to that backend instead of starting it on the host. With no backend active the command
11
+ runs locally exactly as it always has. A process an adapter starts any other way is outside every
12
+ backend, which is why only an adapter declaring :attr:`Adapter.oci_compatible` is run under one.
13
+ """
14
+
15
+ from __future__ import annotations
16
+
17
+ from abc import ABC, abstractmethod
18
+ from contextlib import contextmanager
19
+ from contextvars import ContextVar
20
+ from dataclasses import dataclass, field
21
+ import os
22
+ from pathlib import Path
23
+ import signal
24
+ import stat
25
+ import subprocess
26
+ import threading
27
+ import time
28
+ from typing import Any, Iterator
29
+
30
+
31
+ STATUSES = ("success", "partial", "unsupported", "error", "timeout")
32
+ # Environment variables passed to scanner subprocesses unless an adapter adds more.
33
+ BASE_ENV_PASSTHROUGH = ("PATH", "HOME", "LANG", "LC_ALL", "TMPDIR", "TERM", "USER", "SHELL")
34
+
35
+ # The execution backend every scanner process in this context is routed through, or None, which
36
+ # runs it on the host. Set only by :func:`routed_through`.
37
+ _ACTIVE_BACKEND: ContextVar[Any] = ContextVar("scaneval_execution_backend", default=None)
38
+ # How many backends are routing commands anywhere in this process. A context variable does not
39
+ # follow a thread started inside the routed block, so a command started on such a thread finds no
40
+ # backend in its context; this count is what makes that a refusal rather than a process started
41
+ # on the host while the scan it belongs to is recorded as contained.
42
+ _ROUTING = 0
43
+ _ROUTING_LOCK = threading.Lock()
44
+
45
+
46
+ class AdapterError(RuntimeError):
47
+ """The adapter could not be prepared or invoked as configured."""
48
+
49
+
50
+ @dataclass(frozen=True)
51
+ class SystemSpec:
52
+ """One evaluated system configuration. ``config`` is recorded verbatim, so keep secrets out."""
53
+
54
+ system_id: str
55
+ adapter: str
56
+ config: dict = field(default_factory=dict)
57
+ model_id: str | None = None
58
+ model_revision: str | None = None
59
+
60
+
61
+ @dataclass
62
+ class CommandResult:
63
+ argv: list[str]
64
+ exit_code: int | None
65
+ timed_out: bool
66
+ wall_seconds: float
67
+ stdout_path: Path
68
+ stderr_path: Path
69
+ cleanup_succeeded: bool = True
70
+
71
+
72
+ @dataclass
73
+ class NativeOutcome:
74
+ """Everything one invocation produced, before the runner writes the bundle.
75
+
76
+ ``omitted_paths`` is what the adapter observed the scanner did not examine of its input: paths the
77
+ scanner's own selection, filter, or limit left out, as the adapter saw them, in no particular
78
+ order. ``None`` is an adapter that reports no omissions, which says nothing about whether there
79
+ were any, and ``[]`` is one that looked and saw none. The runner records a list as the result's
80
+ ``omitted_paths``, sorted and each path once, in a 2.1 result, and the scorer withholds quiet credit
81
+ from a control on a listed path. A list is only as true as the adapter's observation, and it is
82
+ meant for an outcome that carries claims the adapter read; it does not change ``status``, which
83
+ stays the adapter's own account of how the run ended.
84
+
85
+ ``examined_nothing`` is the adapter's statement about the whole input, beside that list: ``True``
86
+ says it can show no part of the input to have been examined, and in a PR review that is every path
87
+ the change touches being one the scanner is not shown to have read; ``False`` says it saw some
88
+ examined; ``None`` says it does not report. The runner records a ``bool`` as the result's
89
+ ``examined_nothing`` in a 2.1 result, and the scorer withholds quiet credit from every control of a
90
+ result that says ``True``, wherever the plan places it, because a scan that read none of what it
91
+ was given has reached no control. It is the adapter's claim like the list, and like the list it
92
+ never changes ``status``.
93
+
94
+ ``processing`` is the adapter's statement about the system it ran rather than about what the scan
95
+ saw: ``"curated"`` says the system's configuration chose, before the run, which paths of the input
96
+ are assigned the scanner's analysis, ``"full"`` says it chose none, and ``None`` says the adapter
97
+ does not report it. The runner records a string as the result's ``processing`` in a 2.1 result,
98
+ whose contract admits only those two values. It is the selection marker that aggregation, comparison
99
+ and the benchmark score show beside the adapter version, and they refuse one that the system's
100
+ configuration and recorded adapter version do not give. It changes no status, claim, or quiet
101
+ credit.
102
+ """
103
+
104
+ status: str
105
+ exit_code: int | None
106
+ command: list[str]
107
+ claims: list[dict] = field(default_factory=list)
108
+ ranking: str = "unranked"
109
+ bundles_resolved: bool = True
110
+ artifacts: list[dict] = field(default_factory=list) # {"id": ..., "path": Path}
111
+ tool_versions: dict[str, str] = field(default_factory=dict)
112
+ model_identity: dict | None = None
113
+ usage: dict = field(default_factory=dict)
114
+ error: dict | None = None
115
+ capture: dict = field(default_factory=dict)
116
+ notes: list[str] = field(default_factory=list)
117
+ trace_path: Path | None = None
118
+ capture_state: dict | None = None
119
+ timed_out: bool = False
120
+ omitted_paths: list[str] | None = None
121
+ examined_nothing: bool | None = None
122
+ processing: str | None = None
123
+
124
+ def __post_init__(self) -> None:
125
+ if self.status not in STATUSES:
126
+ raise AdapterError(f"invalid outcome status {self.status!r}")
127
+
128
+
129
+ def build_env(extra_names: tuple[str, ...] = (), overrides: dict[str, str] | None = None) -> dict[str, str]:
130
+ env = {name: os.environ[name] for name in (*BASE_ENV_PASSTHROUGH, *extra_names) if name in os.environ}
131
+ env.update(overrides or {})
132
+ return env
133
+
134
+
135
+ @contextmanager
136
+ def routed_through(backend: Any) -> Iterator[None]:
137
+ """Hand every :func:`run_command` in this context to *backend* until the block exits.
138
+
139
+ *backend* provides ``run_command`` with this module's signature and returns a
140
+ :class:`CommandResult`. The routing follows the context, not the process: a thread started
141
+ inside the block does not carry it. While any such block is open, a :func:`run_command` whose
142
+ context carries no backend is therefore refused instead of run on the host, because the only
143
+ way to reach that state during a routed scan is a command the backend would never see. The
144
+ block does not start, stop, or clean up anything itself; that is the backend's job.
145
+ """
146
+ global _ROUTING
147
+ token = _ACTIVE_BACKEND.set(backend)
148
+ with _ROUTING_LOCK:
149
+ _ROUTING += 1
150
+ try:
151
+ yield
152
+ finally:
153
+ with _ROUTING_LOCK:
154
+ _ROUTING -= 1
155
+ _ACTIVE_BACKEND.reset(token)
156
+
157
+
158
+ def active_backend() -> Any:
159
+ """The backend :func:`run_command` hands commands to in this context, or ``None`` (the host)."""
160
+ return _ACTIVE_BACKEND.get()
161
+
162
+
163
+ def _wait_for_unreaped_exit(pid: int, timeout_seconds: float) -> bool:
164
+ """Observe *pid* exit without releasing its PID/process-group identity.
165
+
166
+ ``waitpid`` (and :meth:`subprocess.Popen.wait`) reaps the leader. A later
167
+ ``killpg(pid, ...)`` can then target an unrelated process group if the kernel
168
+ recycles that numeric ID between the probe and signal. ``WNOWAIT`` leaves the
169
+ exited leader waitable, so its PID and process-group ID cannot be recycled while
170
+ the caller drains descendants.
171
+ """
172
+ deadline = time.monotonic() + max(0.0, timeout_seconds)
173
+ flags = os.WEXITED | os.WNOHANG | os.WNOWAIT
174
+ while True:
175
+ try:
176
+ event = os.waitid(os.P_PID, pid, flags)
177
+ except InterruptedError:
178
+ continue
179
+ if event is not None:
180
+ return True
181
+ remaining = deadline - time.monotonic()
182
+ if remaining <= 0:
183
+ return False
184
+ time.sleep(min(0.01, remaining))
185
+
186
+
187
+ def _signal_reserved_process_group(pid: int, signal_to_send: int) -> bool:
188
+ """Signal a group whose unreaped leader still reserves *pid*.
189
+
190
+ Some supported kernels report ``EPERM`` for a group containing only an exited
191
+ zombie leader. The definitive check is therefore the observation after the
192
+ leader is reaped; this return value only controls the TERM grace period.
193
+ """
194
+ try:
195
+ os.killpg(pid, signal_to_send)
196
+ except OSError:
197
+ return False
198
+ return True
199
+
200
+
201
+ def _process_group_absent_after_reap(pid: int, timeout_seconds: float) -> bool:
202
+ """Boundedly observe group disappearance after reaping, without signalling it."""
203
+ deadline = time.monotonic() + max(0.0, timeout_seconds)
204
+ while True:
205
+ try:
206
+ os.killpg(pid, 0)
207
+ except ProcessLookupError:
208
+ return True
209
+ except OSError:
210
+ # Permission denial or any other result is not proof of absence. It may
211
+ # still clear as a killed descendant is reaped, so keep observing briefly.
212
+ pass
213
+ remaining = deadline - time.monotonic()
214
+ if remaining <= 0:
215
+ return False
216
+ time.sleep(min(0.01, remaining))
217
+
218
+
219
+ def _feed_stdin_while_leader_is_unreaped(process: subprocess.Popen, data: bytes, deadline: float) -> bool:
220
+ """Boundedly feed stdin without polling or reaping the process-group leader."""
221
+ if process.stdin is None:
222
+ return True
223
+ descriptor = process.stdin.fileno()
224
+ offset = 0
225
+ try:
226
+ os.set_blocking(descriptor, False)
227
+ while offset < len(data):
228
+ try:
229
+ written = os.write(descriptor, data[offset:])
230
+ except BlockingIOError:
231
+ written = 0
232
+ except BrokenPipeError:
233
+ return True
234
+ if written:
235
+ offset += written
236
+ continue
237
+ try:
238
+ event = os.waitid(os.P_PID, process.pid, os.WEXITED | os.WNOHANG | os.WNOWAIT)
239
+ except InterruptedError:
240
+ continue
241
+ if event is not None:
242
+ return True
243
+ remaining = deadline - time.monotonic()
244
+ if remaining <= 0:
245
+ return False
246
+ time.sleep(min(0.01, remaining))
247
+ return True
248
+ finally:
249
+ process.stdin.close()
250
+
251
+
252
+ def run_command(
253
+ argv: list[str],
254
+ *,
255
+ cwd: Path,
256
+ timeout_seconds: float,
257
+ env: dict[str, str],
258
+ stdout_path: Path,
259
+ stderr_path: Path,
260
+ stdin_text: str | None = None,
261
+ ensure_group_empty: bool = False,
262
+ ) -> CommandResult:
263
+ """Run one process with a hard timeout, streaming stdout/stderr to files.
264
+
265
+ On timeout the whole process group is killed and ``timed_out`` is reported; the
266
+ partial stdout/stderr files are kept as raw artifacts.
267
+
268
+ Inside :func:`routed_through` the command goes to the active backend instead, with the same
269
+ arguments, and what that backend returns is returned; the backend decides where the process
270
+ runs and states its own guarantees. A command started outside every routed context while one
271
+ is open elsewhere in the process, which is a thread the routed scan started, is refused with
272
+ :class:`AdapterError` and never started. Everything below that applies only when no backend
273
+ is involved, and it is the same code path this function has always had.
274
+ """
275
+ backend = _ACTIVE_BACKEND.get()
276
+ if backend is not None:
277
+ if ensure_group_empty:
278
+ raise AdapterError("process-group emptiness checks are available only on the local execution seam")
279
+ return backend.run_command(argv, cwd=cwd, timeout_seconds=timeout_seconds, env=env,
280
+ stdout_path=stdout_path, stderr_path=stderr_path, stdin_text=stdin_text)
281
+ if _ROUTING:
282
+ raise AdapterError(
283
+ f"could not start {argv[0] if argv else 'a command'}: an execution backend is holding a "
284
+ "scan in this process and this command was started outside it, from a thread that does "
285
+ "not carry the backend; it was not run on the host")
286
+ if ensure_group_empty:
287
+ waitid_names = ("waitid", "P_PID", "WEXITED", "WNOHANG", "WNOWAIT", "killpg")
288
+ if any(not hasattr(os, name) for name in waitid_names):
289
+ raise AdapterError("process-group emptiness checks require POSIX waitid(WNOWAIT) support")
290
+ started = time.monotonic()
291
+ with stdout_path.open("wb") as out, stderr_path.open("wb") as err:
292
+ try:
293
+ process = subprocess.Popen(
294
+ argv, cwd=str(cwd), env=env, stdout=out, stderr=err,
295
+ stdin=subprocess.PIPE if stdin_text is not None else subprocess.DEVNULL,
296
+ start_new_session=True,
297
+ )
298
+ except OSError as exc:
299
+ raise AdapterError(f"could not start {argv[0]}: {exc}") from exc
300
+ timed_out = False
301
+ cleanup_succeeded = True
302
+ if ensure_group_empty:
303
+ # Do not call poll(), communicate(), or wait() until the process group has been
304
+ # drained: all three can reap the leader and free its numeric PGID for reuse.
305
+ try:
306
+ command_deadline = started + max(0.0, timeout_seconds)
307
+ input_delivered = _feed_stdin_while_leader_is_unreaped(
308
+ process, stdin_text.encode("utf-8") if stdin_text is not None else b"", command_deadline)
309
+ leader_exited = input_delivered and _wait_for_unreaped_exit(
310
+ process.pid, max(0.0, command_deadline - time.monotonic()))
311
+ if not leader_exited:
312
+ timed_out = True
313
+ _signal_reserved_process_group(process.pid, signal.SIGKILL)
314
+ leader_exited = _wait_for_unreaped_exit(process.pid, 5)
315
+ if not leader_exited:
316
+ raise AdapterError("scanner leader did not exit within bounded process-group cleanup")
317
+
318
+ # The exited leader is deliberately still waitable here. Its PID/PGID therefore
319
+ # cannot be recycled between either signal and the eventual reap.
320
+ if _signal_reserved_process_group(process.pid, signal.SIGTERM):
321
+ time.sleep(0.25)
322
+ _signal_reserved_process_group(process.pid, signal.SIGKILL)
323
+ except ChildProcessError as exc:
324
+ # An unexpected external waiter has already freed the group identity. Never
325
+ # signal that numeric PGID after this point.
326
+ raise AdapterError("scanner leader was reaped before process-group cleanup") from exc
327
+ except BaseException as original:
328
+ # The code above has not reaped the leader, so these group signals cannot hit a
329
+ # recycled PGID. Preserve the triggering exception after bounded best-effort
330
+ # termination and reaping.
331
+ group_stopped = False
332
+ for _ in range(3):
333
+ try:
334
+ os.killpg(process.pid, signal.SIGKILL)
335
+ group_stopped = True
336
+ break
337
+ except ProcessLookupError:
338
+ group_stopped = True
339
+ break
340
+ except BaseException:
341
+ pass
342
+ if not group_stopped:
343
+ try:
344
+ original.add_note("scanner process-group cleanup failed after three attempts")
345
+ except BaseException:
346
+ pass
347
+ try:
348
+ process.kill()
349
+ except BaseException:
350
+ pass
351
+ try:
352
+ exited_for_reap = _wait_for_unreaped_exit(process.pid, 5)
353
+ except BaseException:
354
+ exited_for_reap = False
355
+ if exited_for_reap:
356
+ deadline = time.monotonic() + 5
357
+ for _ in range(3):
358
+ try:
359
+ process.wait(timeout=max(0, deadline - time.monotonic()))
360
+ break
361
+ except BaseException:
362
+ pass
363
+ if process.returncode is None:
364
+ try:
365
+ original.add_note("scanner could not be reaped within bounded cleanup attempts")
366
+ except BaseException:
367
+ pass
368
+ raise
369
+
370
+ # From this point onward the implementation may reap and observe, but must never
371
+ # signal the numeric PGID again. A surviving group makes the command non-success.
372
+ process.wait(timeout=5)
373
+ cleanup_succeeded = _process_group_absent_after_reap(process.pid, 1.0)
374
+ else:
375
+ try:
376
+ process.communicate(input=stdin_text.encode("utf-8") if stdin_text is not None else None,
377
+ timeout=timeout_seconds)
378
+ except subprocess.TimeoutExpired:
379
+ timed_out = True
380
+ try:
381
+ os.killpg(process.pid, signal.SIGKILL)
382
+ except ProcessLookupError:
383
+ pass
384
+ process.wait(timeout=5)
385
+ except BaseException as original:
386
+ # Retry interrupted cleanup without replacing the triggering exception. The
387
+ # attempts and total wait are bounded even if another signal handler raises.
388
+ group_stopped = False
389
+ for _ in range(3):
390
+ try:
391
+ os.killpg(process.pid, signal.SIGKILL)
392
+ group_stopped = True
393
+ break
394
+ except ProcessLookupError:
395
+ group_stopped = True
396
+ break
397
+ except BaseException:
398
+ pass
399
+ if not group_stopped:
400
+ try:
401
+ original.add_note("scanner process-group cleanup failed after three attempts")
402
+ except BaseException:
403
+ pass
404
+ # A group operation can fail independently of direct-child termination.
405
+ try:
406
+ process.kill()
407
+ except BaseException:
408
+ pass
409
+ deadline = time.monotonic() + 5
410
+ reaped = False
411
+ for _ in range(3):
412
+ try:
413
+ process.wait(timeout=max(0, deadline - time.monotonic()))
414
+ reaped = True
415
+ break
416
+ except BaseException:
417
+ pass
418
+ if not reaped:
419
+ try:
420
+ original.add_note("scanner could not be reaped within bounded cleanup attempts")
421
+ except BaseException:
422
+ pass
423
+ if process.stdin is not None:
424
+ try:
425
+ process.stdin.close()
426
+ except BaseException:
427
+ pass
428
+ raise
429
+ wall = time.monotonic() - started
430
+ return CommandResult(list(argv), None if timed_out else process.returncode, timed_out, wall,
431
+ stdout_path, stderr_path, cleanup_succeeded)
432
+
433
+
434
+ def tail_text(path: Path, limit: int = 2000) -> str:
435
+ """The last *limit* bytes of *path* as text, or ``""`` when it cannot be read. Never raises.
436
+
437
+ The file is one a scanner wrote, and the scanner can replace it after its own descriptor
438
+ closed, so it is read the way :func:`~scaneval.execution.read_regular_file` reads:
439
+ ``O_NOFOLLOW`` refuses a symbolic link, ``O_NONBLOCK`` makes a named pipe fail at once instead
440
+ of waiting for a writer that never comes, and :func:`os.fstat` on the descriptor refuses
441
+ anything that is not a regular file. The read seeks to the last *limit* bytes, so the size of
442
+ the file does not decide how much memory this takes. A regular file gives exactly the text it
443
+ always gave. What changed is that a link planted where stderr belongs is no longer followed to
444
+ a file of the scanner's choosing, whose tail every failure message would then have quoted into
445
+ the record; under an isolating backend that was a host file carried across the boundary.
446
+ """
447
+ try:
448
+ descriptor = os.open(path, os.O_RDONLY | os.O_NOFOLLOW | getattr(os, "O_NONBLOCK", 0))
449
+ except OSError:
450
+ return ""
451
+ try:
452
+ with os.fdopen(descriptor, "rb") as stream:
453
+ status = os.fstat(stream.fileno())
454
+ if not stat.S_ISREG(status.st_mode):
455
+ return ""
456
+ if status.st_size > limit:
457
+ stream.seek(status.st_size - limit)
458
+ return stream.read(limit).decode("utf-8", errors="replace")
459
+ except (OSError, ValueError):
460
+ return ""
461
+
462
+
463
+ class Adapter(ABC):
464
+ """Invoke one system once on one prepared input and normalize its native output."""
465
+
466
+ name: str = "abstract"
467
+ adapter_version: str = "0.0.0"
468
+ requires_git: bool = False
469
+ supported_languages: frozenset[str] = frozenset()
470
+ # The scan modes this adapter carries out: ``full`` scans the whole exported tree, ``pr`` reviews
471
+ # the change between the base and head commits its request names. The runner never calls scan()
472
+ # for an input whose mode is not listed: it records the invocation as unsupported, which stays in
473
+ # every denominator, and a full scan of the head is never run in place of a PR review. An adapter
474
+ # declaring ``pr`` promises to read ``request["input"]["pr"]`` and to review that change, and to
475
+ # refuse a request it cannot honour rather than scan something else.
476
+ scan_modes: frozenset[str] = frozenset({"full"})
477
+ env_passthrough: tuple[str, ...] = ()
478
+ # Top-level scanner-owned paths excluded from the immutable input comparison. By default the
479
+ # runner also copies each one under raw/harness-state. An adapter that independently performs a
480
+ # bounded, no-follow evidence import may set ``state_capture_dirs`` to a subset (including the
481
+ # empty tuple) so the generic unbounded tree copy cannot replace its already-preserved outcome.
482
+ state_dirs: tuple[str, ...] = ()
483
+ state_capture_dirs: tuple[str, ...] | None = None
484
+ # Whether this adapter may run under the ``oci`` execution backend. True is three promises:
485
+ # every scanner process it starts goes through :func:`run_command` from the thread that called
486
+ # ``scan()``; its scanner exists in a Linux image and it takes the in-image tool when a backend
487
+ # is active; and every host-side read of what a command wrote goes through
488
+ # :func:`~scaneval.execution.read_regular_file` or :func:`tail_text`, because a container can
489
+ # replace any path under its writable mounts with a link to a host file, and a read that
490
+ # followed it would carry that file across the boundary. An adapter that shells out any other
491
+ # way, whose tools exist only as host installs, or whose reads follow links would run partly
492
+ # outside the boundary, so the default is False and the runner refuses such a system under
493
+ # ``oci`` with a recorded reason instead of running it.
494
+ oci_compatible: bool = False
495
+
496
+ def condition_controls(self, spec: SystemSpec, preparation: dict) -> dict[str, Any]:
497
+ """Machine-readable retrieval controls applied by this adapter, recorded beside the run.
498
+
499
+ The default supplies no evidence. An implementation describes actual adapter controls,
500
+ not the operator's policy or a scanner's self-report. ``inference`` is ``offline`` or
501
+ ``remote``; ``tools`` maps browser, web_search, remote_fetch, package_install, and
502
+ scanner_update to disabled/enabled/unknown. Provider capability, the requested control,
503
+ and an applicable provider attestation remain separate in ``provider``. Artifact paths
504
+ in an attestation are bundle-relative and must name saved bytes.
505
+ """
506
+ return {}
507
+
508
+ def prepare(self, spec: SystemSpec, cache_root: Path) -> dict[str, Any]:
509
+ """Separately recorded preparation phase (rulesets, dependencies). Default: nothing."""
510
+ return {}
511
+
512
+ def runtime_mounts(self, spec: SystemSpec, preparation: dict) -> tuple[str, ...]:
513
+ """Host paths the scanner reads at run time besides its workspace. Default: none.
514
+
515
+ An isolating backend mounts each of them read-only at its own path, so a path the adapter
516
+ hands its scanner means the same file inside the boundary as outside it. Only what the
517
+ scan needs belongs here, and never a directory holding evaluator material: whatever is
518
+ listed becomes readable to the scanner.
519
+ """
520
+ return ()
521
+
522
+ @abstractmethod
523
+ def scan(
524
+ self,
525
+ *,
526
+ request: dict,
527
+ source_dir: Path,
528
+ raw_dir: Path,
529
+ spec: SystemSpec,
530
+ preparation: dict,
531
+ timeout_seconds: float,
532
+ trace_mode: str,
533
+ trace_dir: Path | None,
534
+ ) -> NativeOutcome:
535
+ """Run the system once. Must preserve raw output and report an explicit status."""