codereview-loop 0.13.0__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.
- codereview/__init__.py +6 -0
- codereview/__main__.py +4 -0
- codereview/acceptance.py +649 -0
- codereview/agentsmd.py +259 -0
- codereview/bench.py +881 -0
- codereview/claims.py +182 -0
- codereview/cli.py +2927 -0
- codereview/core.py +9070 -0
- codereview/crash.py +247 -0
- codereview/discovery.py +289 -0
- codereview/disputes.py +749 -0
- codereview/diversity.py +183 -0
- codereview/evidence.py +1023 -0
- codereview/feedback.py +416 -0
- codereview/github.py +286 -0
- codereview/hardening.py +45 -0
- codereview/herdrhost.py +373 -0
- codereview/hooks.py +261 -0
- codereview/intake.py +208 -0
- codereview/mcp_tools.py +455 -0
- codereview/mcp_transport.py +272 -0
- codereview/notify.py +237 -0
- codereview/pilot.py +549 -0
- codereview/policy.py +1086 -0
- codereview/probe.py +534 -0
- codereview/providers.py +1681 -0
- codereview/record_schema.py +904 -0
- codereview/registry.py +294 -0
- codereview/risk.py +368 -0
- codereview/sarif.py +309 -0
- codereview/seatstate.py +398 -0
- codereview/setup_wizard.py +355 -0
- codereview/support.py +328 -0
- codereview/techlead.py +1041 -0
- codereview/tiering.py +265 -0
- codereview/userconfig.py +307 -0
- codereview_loop-0.13.0.dist-info/METADATA +502 -0
- codereview_loop-0.13.0.dist-info/RECORD +44 -0
- codereview_loop-0.13.0.dist-info/WHEEL +5 -0
- codereview_loop-0.13.0.dist-info/entry_points.txt +3 -0
- codereview_loop-0.13.0.dist-info/licenses/LICENSE +21 -0
- codereview_loop-0.13.0.dist-info/top_level.txt +2 -0
- reviewloop/__init__.py +6 -0
- reviewloop/__main__.py +6 -0
codereview/probe.py
ADDED
|
@@ -0,0 +1,534 @@
|
|
|
1
|
+
"""Probe harness (refutation-process solution §5.2,
|
|
2
|
+
owner-adopted 2026-11-04): the harness — never a model —
|
|
3
|
+
executes probes in a sandbox, and a Tech Lead (binding or
|
|
4
|
+
shadow) may request exactly ONE probe round per case
|
|
5
|
+
(MAX_PROBE_ROUNDS in core). The Tech Lead never executes
|
|
6
|
+
anything itself; it emits a machine-executable probe SPEC,
|
|
7
|
+
this module runs it, and the structured result goes back
|
|
8
|
+
into the adjudication as evidence.
|
|
9
|
+
|
|
10
|
+
Sandbox discipline:
|
|
11
|
+
- The reviewed tree is COPIED to a fresh temp directory and
|
|
12
|
+
the probe runs there; the real tree is never the cwd, so a
|
|
13
|
+
probe cannot mutate the code under review.
|
|
14
|
+
- The environment is an ALLOWLIST (PATH, LANG, LC_*, TERM,
|
|
15
|
+
plus a temp HOME/TMPDIR): provider keys, tokens and every
|
|
16
|
+
other process secret are absent by construction, not by
|
|
17
|
+
filtering.
|
|
18
|
+
- Network: when the platform offers `unshare -n`, the probe
|
|
19
|
+
runs inside a network namespace with no interfaces; the
|
|
20
|
+
level actually achieved is recorded on the result —
|
|
21
|
+
never overstated.
|
|
22
|
+
- A hard wall-clock timeout bounds every probe.
|
|
23
|
+
- Resource limits and tree kill (the C5 discipline the
|
|
24
|
+
evidence sandbox established): the probe runs in its own
|
|
25
|
+
session under POSIX rlimits (CPU time, address space,
|
|
26
|
+
per-file size, open files; core dumps off), its output
|
|
27
|
+
is drained under a per-stream byte cap so a spewing
|
|
28
|
+
probe cannot grow the harness's memory, and expiry —
|
|
29
|
+
or normal exit — kills the probe's whole process
|
|
30
|
+
group, so children a probe forks cannot outlive it.
|
|
31
|
+
|
|
32
|
+
Probe semantics: a probe tests the CLAIM's falsifiable
|
|
33
|
+
prediction. Exit 0 means the probe's assertion held. The
|
|
34
|
+
result carries `can_rebut: True, can_support: False` — the
|
|
35
|
+
adopted asymmetry: probe evidence may rebut a finding (it
|
|
36
|
+
feeds the refutation side's evidence); it can never sustain
|
|
37
|
+
a finding by itself, and a sustain decision must still
|
|
38
|
+
carry its own validated quote from the code region.
|
|
39
|
+
"""
|
|
40
|
+
|
|
41
|
+
import hashlib
|
|
42
|
+
import json
|
|
43
|
+
import os
|
|
44
|
+
import re
|
|
45
|
+
import shutil
|
|
46
|
+
import signal
|
|
47
|
+
import subprocess
|
|
48
|
+
import sys
|
|
49
|
+
import tempfile
|
|
50
|
+
import threading
|
|
51
|
+
import time
|
|
52
|
+
|
|
53
|
+
try:
|
|
54
|
+
import resource
|
|
55
|
+
except ImportError: # non-POSIX: no rlimits to apply
|
|
56
|
+
resource = None
|
|
57
|
+
|
|
58
|
+
PROBE_TIMEOUT_DEFAULT = 120
|
|
59
|
+
_OUTPUT_CAP = 20000
|
|
60
|
+
# C5 sandbox discipline (the evidence sandbox's conventions,
|
|
61
|
+
# src/codereview/evidence.py): the wall-clock timeout is the
|
|
62
|
+
# primary bound; these are the backstops that hold even if
|
|
63
|
+
# supervision itself misbehaves.
|
|
64
|
+
_CPU_SLACK_S = 5 # RLIMIT_CPU = timeout + this
|
|
65
|
+
_PROBE_MEMORY_LIMIT = 2 * 1024 * 1024 * 1024 # RLIMIT_AS:
|
|
66
|
+
# ~100x a bare interpreter's
|
|
67
|
+
# footprint, so fat stacks fit,
|
|
68
|
+
# while resident growth (RSS can
|
|
69
|
+
# never exceed AS) stays bounded
|
|
70
|
+
_PROBE_FILE_SIZE_LIMIT = 256 * 1024 * 1024 # RLIMIT_FSIZE
|
|
71
|
+
# per file (evidence convention)
|
|
72
|
+
_PROBE_OPEN_FILES_LIMIT = 1024 # RLIMIT_NOFILE
|
|
73
|
+
_PROBE_OUTPUT_CAP_BYTES = 8 * 1024 * 1024 # captured per
|
|
74
|
+
# stream, drained past the cap
|
|
75
|
+
# (evidence convention)
|
|
76
|
+
_KILL_SETTLE_S = 10 # post-kill drain budget
|
|
77
|
+
_DRAIN_GRACE_S = 2 # post-exit pipe-drain grace
|
|
78
|
+
# RLIMIT_NPROC is deliberately NOT set: it is a per-USER
|
|
79
|
+
# ceiling, not a per-tree one — the probe shares the
|
|
80
|
+
# operator's uid, whose live process count could already
|
|
81
|
+
# exceed any sane per-probe budget and would then break
|
|
82
|
+
# every fork a legitimate probe makes. Process-count
|
|
83
|
+
# containment comes from the process-group kill plus the
|
|
84
|
+
# CPU/address-space limits above.
|
|
85
|
+
_ENV_ALLOW = ("PATH", "LANG", "LC_ALL", "LC_CTYPE", "TERM")
|
|
86
|
+
_IGNORE = shutil.ignore_patterns(
|
|
87
|
+
".git", "__pycache__", "*.pyc", "codereview-out",
|
|
88
|
+
"bench-out", "acceptance-out")
|
|
89
|
+
|
|
90
|
+
_unshare_level = None
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def _network_prefix():
|
|
94
|
+
"""argv prefix isolating the network namespace, or
|
|
95
|
+
([], 'env-only') when the platform cannot. Probed once
|
|
96
|
+
per process; the achieved level is recorded, honestly."""
|
|
97
|
+
global _unshare_level
|
|
98
|
+
if _unshare_level is None:
|
|
99
|
+
_unshare_level = ([], "env-only")
|
|
100
|
+
exe = shutil.which("unshare")
|
|
101
|
+
if exe:
|
|
102
|
+
try:
|
|
103
|
+
res = subprocess.run(
|
|
104
|
+
[exe, "-n", "true"], capture_output=True,
|
|
105
|
+
timeout=10)
|
|
106
|
+
if res.returncode == 0:
|
|
107
|
+
_unshare_level = ([exe, "-n"],
|
|
108
|
+
"network-namespace")
|
|
109
|
+
except Exception: # noqa: BLE001 — level stays env-only
|
|
110
|
+
pass
|
|
111
|
+
return _unshare_level
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def parse_probe_spec(request):
|
|
115
|
+
"""Normalize a probe request into an executable spec, or
|
|
116
|
+
None when the request carries nothing machine-executable.
|
|
117
|
+
|
|
118
|
+
Accepted shapes (dict, JSON text, or prose with one
|
|
119
|
+
embedded JSON object):
|
|
120
|
+
{"kind": "python", "code": "<script>", "claim": "..."}
|
|
121
|
+
{"kind": "command", "argv": ["prog", "arg", ...]}
|
|
122
|
+
{"kind": "test", "target": "tests/test_x.py"}
|
|
123
|
+
"""
|
|
124
|
+
spec = None
|
|
125
|
+
if isinstance(request, dict):
|
|
126
|
+
spec = request
|
|
127
|
+
elif isinstance(request, str) and request.strip():
|
|
128
|
+
text = request.strip()
|
|
129
|
+
try:
|
|
130
|
+
parsed = json.loads(text)
|
|
131
|
+
spec = parsed if isinstance(parsed, dict) else None
|
|
132
|
+
except ValueError:
|
|
133
|
+
spec = None
|
|
134
|
+
if spec is None:
|
|
135
|
+
start = text.find("{")
|
|
136
|
+
while start != -1:
|
|
137
|
+
depth = 0
|
|
138
|
+
for i in range(start, len(text)):
|
|
139
|
+
if text[i] == "{":
|
|
140
|
+
depth += 1
|
|
141
|
+
elif text[i] == "}":
|
|
142
|
+
depth -= 1
|
|
143
|
+
if depth == 0:
|
|
144
|
+
try:
|
|
145
|
+
parsed = json.loads(text[start:i + 1])
|
|
146
|
+
except ValueError:
|
|
147
|
+
parsed = None
|
|
148
|
+
if isinstance(parsed, dict) \
|
|
149
|
+
and parsed.get("kind"):
|
|
150
|
+
spec = parsed
|
|
151
|
+
break
|
|
152
|
+
if spec is not None:
|
|
153
|
+
break
|
|
154
|
+
start = text.find("{", start + 1)
|
|
155
|
+
if not isinstance(spec, dict):
|
|
156
|
+
return None
|
|
157
|
+
kind = str(spec.get("kind") or "").strip().lower()
|
|
158
|
+
out = {"kind": kind, "claim": str(spec.get("claim") or "")}
|
|
159
|
+
if kind == "python":
|
|
160
|
+
code = spec.get("code")
|
|
161
|
+
if not isinstance(code, str) or not code.strip():
|
|
162
|
+
return None
|
|
163
|
+
out["code"] = code
|
|
164
|
+
return out
|
|
165
|
+
if kind == "command":
|
|
166
|
+
argv = spec.get("argv")
|
|
167
|
+
if not isinstance(argv, list) or not argv \
|
|
168
|
+
or not all(isinstance(a, str) and a
|
|
169
|
+
for a in argv):
|
|
170
|
+
return None
|
|
171
|
+
out["argv"] = list(argv)
|
|
172
|
+
return out
|
|
173
|
+
if kind == "test":
|
|
174
|
+
target = spec.get("target")
|
|
175
|
+
if not isinstance(target, str) or not target.strip():
|
|
176
|
+
return None
|
|
177
|
+
out["target"] = target
|
|
178
|
+
return out
|
|
179
|
+
return None
|
|
180
|
+
|
|
181
|
+
|
|
182
|
+
def _sandbox_env(tmp, work):
|
|
183
|
+
env = {k: os.environ[k] for k in _ENV_ALLOW if k in os.environ}
|
|
184
|
+
env["HOME"] = tmp
|
|
185
|
+
env["TMPDIR"] = tmp
|
|
186
|
+
env["PYTHONDONTWRITEBYTECODE"] = "1"
|
|
187
|
+
# The copied tree root is importable, so a python probe
|
|
188
|
+
# can import the reviewed code exactly as the tree lays
|
|
189
|
+
# it out (the probe script itself lives in a subdir,
|
|
190
|
+
# whose directory would otherwise shadow sys.path).
|
|
191
|
+
env["PYTHONPATH"] = work
|
|
192
|
+
return env
|
|
193
|
+
|
|
194
|
+
|
|
195
|
+
def _apply_probe_limits(timeout):
|
|
196
|
+
"""preexec_fn body (executed in the child): POSIX
|
|
197
|
+
rlimits for the probe. A limit that cannot be set must
|
|
198
|
+
not kill the child before it starts; the wall-clock
|
|
199
|
+
timeout (enforced by the parent) always applies
|
|
200
|
+
regardless."""
|
|
201
|
+
if resource is None:
|
|
202
|
+
return
|
|
203
|
+
|
|
204
|
+
def _set(res, value):
|
|
205
|
+
try:
|
|
206
|
+
resource.setrlimit(res, (value, value))
|
|
207
|
+
except (ValueError, OSError):
|
|
208
|
+
pass
|
|
209
|
+
|
|
210
|
+
_set(resource.RLIMIT_CPU, int(timeout) + _CPU_SLACK_S)
|
|
211
|
+
_set(resource.RLIMIT_AS, _PROBE_MEMORY_LIMIT)
|
|
212
|
+
_set(resource.RLIMIT_FSIZE, _PROBE_FILE_SIZE_LIMIT)
|
|
213
|
+
_set(resource.RLIMIT_NOFILE, _PROBE_OPEN_FILES_LIMIT)
|
|
214
|
+
_set(resource.RLIMIT_CORE, 0)
|
|
215
|
+
|
|
216
|
+
|
|
217
|
+
def _kill_probe_group(proc):
|
|
218
|
+
"""Kill the probe's whole process group (it runs in its
|
|
219
|
+
own session), so a timed-out — or merely backgrounding —
|
|
220
|
+
probe leaves no children behind. Never raises."""
|
|
221
|
+
killpg = getattr(os, "killpg", None)
|
|
222
|
+
if killpg is not None:
|
|
223
|
+
try:
|
|
224
|
+
pgid = os.getpgid(proc.pid)
|
|
225
|
+
except (ProcessLookupError, PermissionError, OSError):
|
|
226
|
+
# The child has exited and been reaped (the
|
|
227
|
+
# normal-exit path): it WAS its group leader
|
|
228
|
+
# (start_new_session), so the pid IS the pgid —
|
|
229
|
+
# and the group can still hold live
|
|
230
|
+
# grandchildren.
|
|
231
|
+
pgid = proc.pid
|
|
232
|
+
try:
|
|
233
|
+
killpg(pgid, signal.SIGKILL)
|
|
234
|
+
return
|
|
235
|
+
except (ProcessLookupError, PermissionError, OSError):
|
|
236
|
+
pass
|
|
237
|
+
try:
|
|
238
|
+
proc.kill()
|
|
239
|
+
except Exception: # noqa: BLE001
|
|
240
|
+
pass
|
|
241
|
+
|
|
242
|
+
|
|
243
|
+
def _drain_probe_stream(stream, sink):
|
|
244
|
+
"""Reader-thread body: pull `stream` to EOF, keeping at
|
|
245
|
+
most _PROBE_OUTPUT_CAP_BYTES in sink["data"] and
|
|
246
|
+
draining (and discarding) the rest, so a spewing probe
|
|
247
|
+
never blocks on a full pipe and never grows the
|
|
248
|
+
harness's memory without bound. sink["truncated"]
|
|
249
|
+
records that output was dropped."""
|
|
250
|
+
try:
|
|
251
|
+
while True:
|
|
252
|
+
# read1, not read: read(n) waits until n bytes or
|
|
253
|
+
# EOF, which would strand a trickling probe's
|
|
254
|
+
# available bytes inside the buffer when EOF
|
|
255
|
+
# never comes (a detached pipe holder).
|
|
256
|
+
chunk = stream.read1(65536)
|
|
257
|
+
if not chunk:
|
|
258
|
+
break
|
|
259
|
+
room = _PROBE_OUTPUT_CAP_BYTES - len(sink["data"])
|
|
260
|
+
if room > 0:
|
|
261
|
+
sink["data"] += chunk[:room]
|
|
262
|
+
if len(chunk) > max(room, 0):
|
|
263
|
+
sink["truncated"] = True
|
|
264
|
+
except Exception: # noqa: BLE001 — a dead stream is EOF
|
|
265
|
+
pass
|
|
266
|
+
finally:
|
|
267
|
+
try:
|
|
268
|
+
stream.close()
|
|
269
|
+
except Exception: # noqa: BLE001
|
|
270
|
+
pass
|
|
271
|
+
|
|
272
|
+
|
|
273
|
+
def _probe_settled(proc, readers, deadline):
|
|
274
|
+
"""True when the probe has exited and both pipes are
|
|
275
|
+
drained by `deadline` (an absolute monotonic time)."""
|
|
276
|
+
for reader in readers:
|
|
277
|
+
reader.join(max(deadline - time.monotonic(), 0.0))
|
|
278
|
+
return proc.poll() is not None and not any(
|
|
279
|
+
reader.is_alive() for reader in readers)
|
|
280
|
+
|
|
281
|
+
|
|
282
|
+
def _probe_output(out_sink, err_sink, incomplete=False):
|
|
283
|
+
"""The combined output text the result records: stdout
|
|
284
|
+
then stderr, exactly as the harness has always combined
|
|
285
|
+
them, plus a marker when the bytes are less than what
|
|
286
|
+
the probe wrote — partial output is never presented as
|
|
287
|
+
whole."""
|
|
288
|
+
output = bytes(out_sink["data"]).decode("utf-8", "replace") \
|
|
289
|
+
+ bytes(err_sink["data"]).decode("utf-8", "replace")
|
|
290
|
+
if out_sink["truncated"] or err_sink["truncated"]:
|
|
291
|
+
output += (
|
|
292
|
+
f"\n[codereview] probe output truncated at the "
|
|
293
|
+
f"sandbox cap of {_PROBE_OUTPUT_CAP_BYTES} bytes "
|
|
294
|
+
f"per stream")
|
|
295
|
+
if incomplete:
|
|
296
|
+
output += (
|
|
297
|
+
"\n[codereview] probe output capture incomplete — "
|
|
298
|
+
"a detached process still holds the pipe")
|
|
299
|
+
return output
|
|
300
|
+
|
|
301
|
+
|
|
302
|
+
def _execute_probe(argv, cwd, env, timeout):
|
|
303
|
+
"""Run one probe command under the C5 discipline and
|
|
304
|
+
return (outcome, exit_code, output) in run_probe's
|
|
305
|
+
long-standing vocabulary: pass (exit 0), fail (any
|
|
306
|
+
other exit — a signal death included, its exit_code
|
|
307
|
+
the negative signal number, exactly as subprocess has
|
|
308
|
+
always reported it), timeout (the wall clock expired,
|
|
309
|
+
or a forked child holding a pipe forced the deadline),
|
|
310
|
+
error (the harness itself could not run the probe).
|
|
311
|
+
Never raises for probe failures — they are outcomes."""
|
|
312
|
+
try:
|
|
313
|
+
proc = subprocess.Popen(
|
|
314
|
+
argv, cwd=cwd, env=env,
|
|
315
|
+
stdout=subprocess.PIPE, stderr=subprocess.PIPE,
|
|
316
|
+
start_new_session=True,
|
|
317
|
+
preexec_fn=lambda: _apply_probe_limits(timeout))
|
|
318
|
+
except Exception as exc: # noqa: BLE001 — an outcome, not a crash
|
|
319
|
+
return "error", None, f"probe spawn failed: {exc}"
|
|
320
|
+
out_sink = {"data": bytearray(), "truncated": False}
|
|
321
|
+
err_sink = {"data": bytearray(), "truncated": False}
|
|
322
|
+
readers = [
|
|
323
|
+
threading.Thread(target=_drain_probe_stream,
|
|
324
|
+
args=(proc.stdout, out_sink),
|
|
325
|
+
daemon=True),
|
|
326
|
+
threading.Thread(target=_drain_probe_stream,
|
|
327
|
+
args=(proc.stderr, err_sink),
|
|
328
|
+
daemon=True),
|
|
329
|
+
]
|
|
330
|
+
for reader in readers:
|
|
331
|
+
reader.start()
|
|
332
|
+
return _supervise_probe_execution(
|
|
333
|
+
proc, readers, out_sink, err_sink, timeout,
|
|
334
|
+
time.monotonic())
|
|
335
|
+
|
|
336
|
+
|
|
337
|
+
def _supervise_probe_execution(proc, readers, out_sink,
|
|
338
|
+
err_sink, timeout, started):
|
|
339
|
+
"""The probe's supervised wait — the pairing the
|
|
340
|
+
subprocess-timeout guard accepts by name, as it does
|
|
341
|
+
the evidence sandbox's: every wait here is
|
|
342
|
+
deadline-bounded (wait(timeout=...), bounded joins)
|
|
343
|
+
and expiry kills the whole process group. It is a
|
|
344
|
+
stronger bound than communicate(timeout=...), not a
|
|
345
|
+
weaker one: communicate buffers the child's output
|
|
346
|
+
without bound, this wait drains it under the C5 cap."""
|
|
347
|
+
try:
|
|
348
|
+
timed_out = False
|
|
349
|
+
try:
|
|
350
|
+
proc.wait(timeout=timeout)
|
|
351
|
+
except subprocess.TimeoutExpired:
|
|
352
|
+
timed_out = True
|
|
353
|
+
if not timed_out and not _probe_settled(
|
|
354
|
+
proc, readers,
|
|
355
|
+
started + timeout + _DRAIN_GRACE_S):
|
|
356
|
+
# The probe exited but a pipe is still not
|
|
357
|
+
# drained at the deadline — a child it forked
|
|
358
|
+
# inherited the pipe and is holding it open.
|
|
359
|
+
# subprocess.run reported this as a timeout (and
|
|
360
|
+
# could hang on it after the kill); so does the
|
|
361
|
+
# sandbox.
|
|
362
|
+
timed_out = True
|
|
363
|
+
if timed_out:
|
|
364
|
+
_kill_probe_group(proc)
|
|
365
|
+
try:
|
|
366
|
+
proc.wait(timeout=_KILL_SETTLE_S)
|
|
367
|
+
except Exception: # noqa: BLE001
|
|
368
|
+
pass
|
|
369
|
+
# Post-kill drain: after the group kill the
|
|
370
|
+
# pipes reach EOF and the drainers finish, so
|
|
371
|
+
# the sinks are final before the output is read.
|
|
372
|
+
# A drainer still blocked after the settle means
|
|
373
|
+
# a detached process (outside the killed group)
|
|
374
|
+
# holds the pipe — the capture is partial and
|
|
375
|
+
# is marked incomplete, never presented whole.
|
|
376
|
+
final = _probe_settled(
|
|
377
|
+
proc, readers,
|
|
378
|
+
time.monotonic() + _KILL_SETTLE_S)
|
|
379
|
+
return "timeout", None, _probe_output(
|
|
380
|
+
out_sink, err_sink, incomplete=not final)
|
|
381
|
+
# Normal exit: kill the group anyway — a probe that
|
|
382
|
+
# backgrounded a child with redirected stdio and
|
|
383
|
+
# then exited cleanly must not leave that child
|
|
384
|
+
# running past the sandbox (the evidence runner's
|
|
385
|
+
# P3-06 lesson). _probe_settled above has already
|
|
386
|
+
# confirmed the readers are done, so the captured
|
|
387
|
+
# output is final.
|
|
388
|
+
_kill_probe_group(proc)
|
|
389
|
+
outcome = "pass" if proc.returncode == 0 else "fail"
|
|
390
|
+
return outcome, proc.returncode, _probe_output(
|
|
391
|
+
out_sink, err_sink)
|
|
392
|
+
except Exception as exc: # noqa: BLE001 — an outcome, not a crash
|
|
393
|
+
_kill_probe_group(proc)
|
|
394
|
+
return "error", None, f"probe execution failed: {exc}"
|
|
395
|
+
|
|
396
|
+
|
|
397
|
+
def run_probe(spec, tree_dir, timeout=PROBE_TIMEOUT_DEFAULT):
|
|
398
|
+
"""Execute one probe spec against a temp COPY of
|
|
399
|
+
`tree_dir`. Returns the structured result:
|
|
400
|
+
{outcome: pass|fail|error|timeout, exit_code, duration_s,
|
|
401
|
+
output, output_sha256, sandbox, spec,
|
|
402
|
+
can_rebut: True, can_support: False}.
|
|
403
|
+
Never raises for probe failures — they are outcomes."""
|
|
404
|
+
spec = parse_probe_spec(spec)
|
|
405
|
+
if spec is None:
|
|
406
|
+
return {"outcome": "error", "exit_code": None,
|
|
407
|
+
"duration_s": 0.0, "output": "",
|
|
408
|
+
"output_sha256": hashlib.sha256(b"").hexdigest(),
|
|
409
|
+
"sandbox": {}, "spec": None,
|
|
410
|
+
"can_rebut": True, "can_support": False,
|
|
411
|
+
"detail": "probe request carried no "
|
|
412
|
+
"machine-executable spec"}
|
|
413
|
+
tmp = tempfile.mkdtemp(prefix="codereview-probe-")
|
|
414
|
+
started = time.monotonic()
|
|
415
|
+
try:
|
|
416
|
+
work = os.path.join(tmp, "tree")
|
|
417
|
+
if tree_dir and os.path.isdir(tree_dir):
|
|
418
|
+
try:
|
|
419
|
+
# symlinks=True, as in evidence._copy_tree: a
|
|
420
|
+
# link planted in the reviewed tree must arrive
|
|
421
|
+
# as a link, never be dereferenced into the
|
|
422
|
+
# copy — dereferencing would pull the target's
|
|
423
|
+
# content (any host file the operator can read)
|
|
424
|
+
# into the sandbox tree, and would stream a
|
|
425
|
+
# special file (e.g. /dev/zero) into the copy
|
|
426
|
+
# in THIS process, unbounded (L6 F1/F2).
|
|
427
|
+
shutil.copytree(tree_dir, work, ignore=_IGNORE,
|
|
428
|
+
symlinks=True)
|
|
429
|
+
except Exception as exc: # noqa: BLE001 — an outcome
|
|
430
|
+
# A copy failure is a probe outcome, never an
|
|
431
|
+
# exception escaping run_probe (its contract:
|
|
432
|
+
# probe failures are outcomes, not crashes).
|
|
433
|
+
return {"outcome": "error", "exit_code": None,
|
|
434
|
+
"duration_s": round(
|
|
435
|
+
time.monotonic() - started, 3),
|
|
436
|
+
"output": "",
|
|
437
|
+
"output_sha256": hashlib.sha256(
|
|
438
|
+
b"").hexdigest(),
|
|
439
|
+
"sandbox": {}, "spec": spec,
|
|
440
|
+
"can_rebut": True, "can_support": False,
|
|
441
|
+
"detail": f"probe tree copy failed: {exc}"}
|
|
442
|
+
else:
|
|
443
|
+
os.makedirs(work)
|
|
444
|
+
if spec["kind"] == "python":
|
|
445
|
+
probe_dir = os.path.join(work, ".codereview-probe")
|
|
446
|
+
os.makedirs(probe_dir, exist_ok=True)
|
|
447
|
+
script = os.path.join(probe_dir, "probe_main.py")
|
|
448
|
+
with open(script, "w", encoding="utf-8") as fh:
|
|
449
|
+
fh.write(spec["code"])
|
|
450
|
+
argv = [sys.executable, script]
|
|
451
|
+
elif spec["kind"] == "command":
|
|
452
|
+
argv = list(spec["argv"])
|
|
453
|
+
else: # test — execute through the unittest runner
|
|
454
|
+
# (discovery rooted at the target's directory), NOT
|
|
455
|
+
# as a plain script: script execution runs zero
|
|
456
|
+
# tests when the file lacks a __main__ guard and
|
|
457
|
+
# still exits 0 (finding 4cf81c69d7cf).
|
|
458
|
+
target = spec["target"]
|
|
459
|
+
start = os.path.dirname(target) or "."
|
|
460
|
+
argv = [sys.executable, "-m", "unittest",
|
|
461
|
+
"discover", "-s", start,
|
|
462
|
+
"-p", os.path.basename(target)]
|
|
463
|
+
prefix, net_level = _network_prefix()
|
|
464
|
+
outcome, exit_code, output = _execute_probe(
|
|
465
|
+
prefix + argv, work, _sandbox_env(tmp, work),
|
|
466
|
+
timeout)
|
|
467
|
+
if spec["kind"] == "test" and outcome in ("pass",
|
|
468
|
+
"fail"):
|
|
469
|
+
# A test probe's verdict is evidence only if tests
|
|
470
|
+
# actually executed. A runner invocation that ran
|
|
471
|
+
# zero tests (an empty module, a target discovery
|
|
472
|
+
# matched nothing in) must be recorded as an error,
|
|
473
|
+
# never as a verdict — whether the interpreter
|
|
474
|
+
# exits 0 for it or 5 (4cf81c69d7cf).
|
|
475
|
+
ran = re.findall(r"Ran (\d+) tests?", output)
|
|
476
|
+
if (ran and int(ran[-1]) == 0) or (
|
|
477
|
+
not ran and outcome == "pass"):
|
|
478
|
+
outcome = "error"
|
|
479
|
+
output += ("\n[codereview] test probe executed "
|
|
480
|
+
"zero tests — refusing to report "
|
|
481
|
+
"a verdict")
|
|
482
|
+
duration = time.monotonic() - started
|
|
483
|
+
return {
|
|
484
|
+
"outcome": outcome, "exit_code": exit_code,
|
|
485
|
+
"duration_s": round(duration, 3),
|
|
486
|
+
"output": output[:_OUTPUT_CAP],
|
|
487
|
+
"output_sha256": hashlib.sha256(
|
|
488
|
+
output.encode("utf-8")).hexdigest(),
|
|
489
|
+
"sandbox": {"cwd": "temp-copy",
|
|
490
|
+
"network": net_level,
|
|
491
|
+
"env": "allowlist",
|
|
492
|
+
"timeout_s": timeout},
|
|
493
|
+
"spec": spec,
|
|
494
|
+
"can_rebut": True, "can_support": False,
|
|
495
|
+
"detail": (f"harness probe {outcome} "
|
|
496
|
+
f"(exit {exit_code}, {duration:.1f}s)"),
|
|
497
|
+
}
|
|
498
|
+
finally:
|
|
499
|
+
shutil.rmtree(tmp, ignore_errors=True)
|
|
500
|
+
|
|
501
|
+
|
|
502
|
+
def make_probe_fn(tree_dir, timeout=PROBE_TIMEOUT_DEFAULT):
|
|
503
|
+
"""The probe_fn contract run_review consumes —
|
|
504
|
+
probe_fn(finding, request) -> {"outcome",
|
|
505
|
+
"evidence_quote", "detail", ...}. A request with no
|
|
506
|
+
executable spec is `inconclusive`, NOT an escalation for
|
|
507
|
+
lack of a channel: the channel exists; the request was
|
|
508
|
+
not executable."""
|
|
509
|
+
def probe_fn(finding, request):
|
|
510
|
+
spec = parse_probe_spec(request)
|
|
511
|
+
if spec is None:
|
|
512
|
+
return {"outcome": "inconclusive",
|
|
513
|
+
"evidence_quote": "",
|
|
514
|
+
"detail": "probe channel wired, but the "
|
|
515
|
+
"request carried no "
|
|
516
|
+
"machine-executable spec (want "
|
|
517
|
+
"JSON: {\"kind\": \"python\", "
|
|
518
|
+
"\"code\": ...} / {\"kind\": "
|
|
519
|
+
"\"command\", \"argv\": [...]} / "
|
|
520
|
+
"{\"kind\": \"test\", \"target\": "
|
|
521
|
+
"...})",
|
|
522
|
+
"can_rebut": True, "can_support": False}
|
|
523
|
+
res = run_probe(spec, tree_dir, timeout=timeout)
|
|
524
|
+
quote = ""
|
|
525
|
+
for line in (res.get("output") or "").splitlines():
|
|
526
|
+
if line.strip():
|
|
527
|
+
quote = line.strip()[:300]
|
|
528
|
+
break
|
|
529
|
+
return {**res, "evidence_quote": quote,
|
|
530
|
+
"detail": (f"{res['detail']}; output sha256 "
|
|
531
|
+
f"{res['output_sha256'][:12]}…; "
|
|
532
|
+
f"sandbox network="
|
|
533
|
+
f"{res['sandbox'].get('network')}")}
|
|
534
|
+
return probe_fn
|