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/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