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/evidence.py ADDED
@@ -0,0 +1,1023 @@
1
+ #!/usr/bin/env python3
2
+ """Evidence runner — Epic C of the adopted roadmap (Week 2).
3
+
4
+ The reviewers in a CodeReview panel judge a diff; they cannot run
5
+ anything. The evidence runner is the deterministic counterpart:
6
+ it executes the project's own configured commands (tests, lint,
7
+ SAST) and security adapters (gitleaks, semgrep, dependency audit)
8
+ against the change, and stores a content-hashed evidence record
9
+ in the run JSON. The gate then treats required evidence as a
10
+ first-class input (ticket C3):
11
+
12
+ required evidence failed / timed out / errored / missing
13
+ -> the gate is BLOCKED
14
+ required evidence unavailable (the tool is not installed)
15
+ -> the gate is INCONCLUSIVE (a human decides; the tool's
16
+ absence is not the builder's failure)
17
+
18
+ "missing" and "unavailable" are statuses OF AN ITEM, and the
19
+ distinction is the whole rule (P6-11): "missing" is a
20
+ requirement with nothing configured to satisfy it — a
21
+ `require` name with no configured item, or (the tiering
22
+ case) a high-band run where the caller supplied no evidence
23
+ record at all: the policy demanded evidence and none exists,
24
+ so the gate BLOCKS. "unavailable" is a configured item whose
25
+ tool is absent on this machine, so the gate cannot decide —
26
+ INCONCLUSIVE. An absent record is never an "unavailable"
27
+ item: there is no item.
28
+
29
+ It also verifies builder claims (ticket C4): commands a builder's
30
+ work report claims to have run, with claimed outcomes, are re-run
31
+ in the sandbox and compared; a claim whose re-run contradicts it
32
+ is downgraded in the record — the claim is never silently trusted
33
+ and never silently dropped.
34
+
35
+ Configuration is a plain dict (loaded from JSON by the CLI). The
36
+ shape deliberately mirrors the `[evidence]` table of the Epic B
37
+ policy file (B1: "evidence requirements"), so a TOML policy's
38
+ evidence section — parsed by `tomllib` into the same nested
39
+ dicts — can be handed to `collect()` unchanged when Epic B lands:
40
+
41
+ {
42
+ "commands": [
43
+ {"name": "tests", "command": "python -m pytest -q",
44
+ "required": true, "timeout": 900}
45
+ ],
46
+ "adapters": [
47
+ {"name": "gitleaks", "required": true},
48
+ {"name": "semgrep", "required": false,
49
+ "args": ["--config", "p/default"]},
50
+ {"name": "dependency-audit", "required": false}
51
+ ],
52
+ "require": ["gitleaks"],
53
+ "timeout": 600,
54
+ "allow_network": false
55
+ }
56
+
57
+ Defaults: configured commands are required unless marked
58
+ otherwise (a configured test command the operator did not mark
59
+ optional is there to gate); adapters are optional unless marked
60
+ required. `require` names evidence that policy demands be
61
+ present: a required name with no configured item is recorded as
62
+ status "missing" and blocks the gate — that is how a policy
63
+ requires a tool's presence without configuring how it runs (C2).
64
+
65
+ Threat model (ticket C5)
66
+ ------------------------
67
+ Evidence commands execute code from the working tree under
68
+ review — the same tree whose diff is untrusted data everywhere
69
+ else in this product. The runner therefore treats every run as
70
+ executing UNTRUSTED code and confines it accordingly:
71
+
72
+ - Working copy: commands run in a throwaway copy of the tree
73
+ (`.git` excluded), created under the system temp dir and
74
+ deleted afterwards. The reviewed tree is never the cwd, so a
75
+ hostile command cannot rewrite the checkout the operator is
76
+ about to merge, and test side effects die with the copy.
77
+ - No secrets: the child environment is built from an allow-list
78
+ (PATH, locale, terminal basics) — provider API keys, tokens,
79
+ SSH agent sockets and every other operator variable stay out.
80
+ HOME and TMPDIR point inside the sandbox, so credential files
81
+ (~/.gitconfig, ~/.netrc, cloud SDK configs) are not reachable
82
+ through them either. NEVER run evidence with the operator's
83
+ environment: that is the one configuration this module refuses
84
+ to offer.
85
+ - No network by default: proxy variables are stripped (most
86
+ tooling egresses through them) and, where Linux user+network
87
+ namespaces are available (`unshare -rn`), the command runs in
88
+ a namespace with no network at all. The record states which
89
+ mode actually applied ("namespace" vs "env-only"), never
90
+ claiming isolation that did not happen. An item must opt in
91
+ with "allow_network": true to run unrestricted — adapters
92
+ whose rule sets are fetched (semgrep --config auto) need that
93
+ opt-in, and the opt-in is visible in the record.
94
+ - Resource limits: each run has a wall-clock timeout enforced
95
+ by killing the whole process group, a CPU-time limit, a
96
+ per-file size limit (POSIX rlimits), and a cap on captured
97
+ output per stream (the child is drained past the cap, never
98
+ blocked on a full pipe; the record's hash and tail cover the
99
+ capped bytes and a truncation marker says so; if a detached
100
+ process holds a pipe open past the post-kill settle, the
101
+ capture is marked incomplete rather than silently partial),
102
+ all recorded.
103
+ What the sandbox does NOT confine — filesystem reach outside
104
+ the copy, memory growth, and the other residual risks — is
105
+ stated plainly in the full threat model, `THREAT-MODEL.md`
106
+ at the repository root.
107
+ - Output is evidence, not truth: the full output is hashed
108
+ (sha256) and only a redacted tail is stored, through the same
109
+ `redact_secrets` the review path uses — a scanner that prints
110
+ the secret it found must not smuggle it into the record.
111
+
112
+ Trust boundary, stated plainly: the commands and adapter choices
113
+ come from the operator (CLI flag / policy file), never from the
114
+ diff or the tree under review — the same trust level as
115
+ `--test-command`. A repository that ships its own evidence
116
+ config has it used only when the operator points the CLI at it.
117
+ """
118
+
119
+ from __future__ import annotations
120
+
121
+ import hashlib
122
+ import json
123
+ import os
124
+ import re
125
+ import resource
126
+ import shutil
127
+ import signal
128
+ import subprocess
129
+ import sys
130
+ import tempfile
131
+ import threading
132
+ import time
133
+
134
+ from . import hardening
135
+ from .core import _now_iso, redact_secrets
136
+
137
+ SCHEMA = "codereview.evidence/1"
138
+
139
+ DEFAULT_TIMEOUT = 600 # seconds, per evidence item
140
+ _CPU_SLACK = 5 # rlimit CPU seconds beyond timeout
141
+ _FILE_SIZE_LIMIT = 256 * 1024 * 1024 # RLIMIT_FSIZE per file
142
+ _OUTPUT_CAP_BYTES = 8 * 1024 * 1024 # captured output, per stream
143
+ _OUTPUT_TAIL_CHARS = 4000 # stored tail, matching _run_tests
144
+ _KILL_SETTLE_S = 30 # post-kill drain budget — the
145
+ # pre-cap runner's second
146
+ # communicate() used the same 30 s
147
+
148
+ STATUSES = ("passed", "failed", "timeout", "unavailable",
149
+ "missing", "error")
150
+
151
+ # The only operator environment an evidence child inherits by
152
+ # default — the evidence analogue of providers._AGENT_ENV_BASE.
153
+ # Proxy variables are NOT in the base list: they are added back
154
+ # only for an item that explicitly allows network.
155
+ _ENV_BASE = (
156
+ "PATH", "LANG", "LC_ALL", "LC_CTYPE", "TERM", "USER",
157
+ "LOGNAME", "SHELL", "SYSTEMROOT", "WINDIR", "PATHEXT",
158
+ "COMSPEC",
159
+ )
160
+ _PROXY_VARS = ("HTTP_PROXY", "HTTPS_PROXY", "NO_PROXY",
161
+ "http_proxy", "https_proxy", "no_proxy")
162
+
163
+
164
+ # ------------------------------------------------------------------ config
165
+
166
+ def load_config(path):
167
+ """Load an evidence config (JSON) from `path` -> dict.
168
+
169
+ Raises ValueError with a plain message on any problem; the
170
+ CLI turns that into exit 2 (usage error), the same handling
171
+ as a bad fixture manifest."""
172
+ try:
173
+ with open(os.path.expanduser(path), encoding="utf-8") as fh:
174
+ config = json.load(fh)
175
+ except Exception as exc: # noqa: BLE001
176
+ raise ValueError(f"could not read evidence config: {exc}")
177
+ if not isinstance(config, dict):
178
+ raise ValueError("evidence config must be a JSON object")
179
+ validate_config(config)
180
+ return config
181
+
182
+
183
+ def validate_config(config):
184
+ """Shape-check a config dict. The same dict shape a TOML
185
+ `[evidence]` table produces: lists of tables for `commands`
186
+ and `adapters`, a list of names for `require`."""
187
+ for key in ("commands", "adapters"):
188
+ items = config.get(key) or []
189
+ if not isinstance(items, list):
190
+ raise ValueError(f"evidence config: {key!r} must be a list")
191
+ for item in items:
192
+ if not isinstance(item, dict):
193
+ raise ValueError(
194
+ f"evidence config: each {key} entry must be an object")
195
+ for item in config.get("commands") or []:
196
+ if not str(item.get("command") or "").strip():
197
+ raise ValueError(
198
+ "evidence config: a command entry has no 'command'")
199
+ for item in config.get("adapters") or []:
200
+ name = str(item.get("name") or "").strip()
201
+ if name not in ADAPTERS:
202
+ raise ValueError(
203
+ f"evidence config: unknown adapter {name!r} "
204
+ f"(known: {', '.join(sorted(ADAPTERS))})")
205
+ req = config.get("require") or []
206
+ if not isinstance(req, list):
207
+ raise ValueError("evidence config: 'require' must be a list")
208
+ _checked_timeout(config.get("timeout"), "'timeout'")
209
+ for key in ("commands", "adapters"):
210
+ for item in config.get(key) or []:
211
+ _checked_timeout(item.get("timeout"),
212
+ f"a {key} entry's 'timeout'")
213
+
214
+
215
+ def _checked_timeout(raw, label):
216
+ """Resolve an optional timeout field: absent (None) returns
217
+ None and the caller applies its default; a present value
218
+ must be a positive number. An explicit 0 — like any
219
+ non-positive or non-numeric value — is refused loudly HERE,
220
+ at load, never silently swapped for a default via a falsey
221
+ `or` (9489ee49db6f; the review-v13 contract that
222
+ providers.command_agent documents)."""
223
+ if raw is None:
224
+ return None
225
+ if isinstance(raw, bool) or not isinstance(raw, (int, float)):
226
+ raise ValueError(
227
+ f"evidence config: {label} must be a number of "
228
+ f"seconds, got {raw!r}")
229
+ if raw <= 0:
230
+ raise ValueError(
231
+ f"evidence config: {label} must be positive, got "
232
+ f"{raw!r} — a non-positive timeout is refused, not "
233
+ "silently replaced by the default")
234
+ return raw
235
+
236
+
237
+ def _resolve_timeout(raw, label, default):
238
+ checked = _checked_timeout(raw, label)
239
+ return default if checked is None else checked
240
+
241
+
242
+ # ------------------------------------------------------------------ sandbox
243
+
244
+ def _copy_tree(source, dest):
245
+ """Copy the working tree (minus .git) into the sandbox.
246
+ Symlinks are copied as links, never dereferenced, so a link
247
+ planted in the tree cannot pull outside content into the copy
248
+ (the command may still follow it at run time — inside a
249
+ throwaway cwd, with a scrubbed environment)."""
250
+ def _ignore(_dir, names):
251
+ return [n for n in names if n == ".git"]
252
+ shutil.copytree(source, dest, ignore=_ignore, symlinks=True)
253
+
254
+
255
+ def _sandbox_env(sandbox, allow_network, environ=None):
256
+ """Build the child environment from the allow-list. `environ`
257
+ is injectable for tests; the operator's real environment is
258
+ the default SOURCE of allow-listed names only — nothing else
259
+ crosses."""
260
+ environ = os.environ if environ is None else environ
261
+ env = {n: environ[n] for n in _ENV_BASE if n in environ}
262
+ if allow_network:
263
+ env.update({n: environ[n] for n in _PROXY_VARS
264
+ if n in environ})
265
+ home = os.path.join(sandbox, "home")
266
+ tmp = os.path.join(sandbox, "tmp")
267
+ os.makedirs(home, exist_ok=True)
268
+ os.makedirs(tmp, exist_ok=True)
269
+ env["HOME"] = home
270
+ env["TMPDIR"] = tmp
271
+ env["CODEREVIEW_EVIDENCE"] = "1"
272
+ return env
273
+
274
+
275
+ _UNSHARE_OK = None
276
+
277
+
278
+ def _unshare_ok():
279
+ """Probe once per process whether `unshare -rn` (user +
280
+ network namespace) actually works here; cached. A sandbox or
281
+ kernel that forbids user namespaces yields False and the
282
+ runner falls back to env-only network restriction — and says
283
+ so in the record."""
284
+ global _UNSHARE_OK
285
+ if _UNSHARE_OK is None:
286
+ if sys.platform.startswith("linux") and \
287
+ shutil.which("unshare"):
288
+ try:
289
+ out = subprocess.run(
290
+ ["unshare", "-rn", "true"],
291
+ capture_output=True, timeout=10, check=False)
292
+ _UNSHARE_OK = out.returncode == 0
293
+ except Exception: # noqa: BLE001
294
+ _UNSHARE_OK = False
295
+ else:
296
+ _UNSHARE_OK = False
297
+ return _UNSHARE_OK
298
+
299
+
300
+ def _kill_group(proc):
301
+ """Kill the child's whole process group (it runs in its own
302
+ session), so a timed-out command leaves no grandchildren
303
+ behind — the same discipline as the agent seats."""
304
+ try:
305
+ pgid = os.getpgid(proc.pid)
306
+ except (ProcessLookupError, PermissionError, OSError):
307
+ # The child has exited and been reaped (the normal-exit
308
+ # path, P3-06): its pid is gone, but it WAS its group
309
+ # leader (start_new_session), so the pid IS the pgid —
310
+ # and the group can still hold live grandchildren.
311
+ pgid = proc.pid
312
+ try:
313
+ os.killpg(pgid, signal.SIGKILL)
314
+ except (ProcessLookupError, PermissionError, OSError):
315
+ try:
316
+ proc.kill()
317
+ except Exception: # noqa: BLE001
318
+ pass
319
+
320
+
321
+ def _apply_limits(timeout):
322
+ """preexec_fn: POSIX rlimits for the evidence child. A limit
323
+ that cannot be set must not kill the child before it starts;
324
+ the wall-clock timeout (enforced by the parent) always
325
+ applies regardless."""
326
+ def _set(res, value):
327
+ try:
328
+ resource.setrlimit(res, (value, value))
329
+ except (ValueError, OSError):
330
+ pass
331
+ _set(resource.RLIMIT_CPU, int(timeout) + _CPU_SLACK)
332
+ _set(resource.RLIMIT_FSIZE, _FILE_SIZE_LIMIT)
333
+
334
+
335
+ def _drain(stream, sink):
336
+ """Reader-thread body: pull `stream` to EOF, keeping at
337
+ most _OUTPUT_CAP_BYTES in sink["data"] and draining (and
338
+ discarding) the rest, so a spewing child never blocks on a
339
+ full pipe and never grows the parent's memory without
340
+ bound. sink["truncated"] records that output was dropped."""
341
+ try:
342
+ while True:
343
+ # read1, not read: read(n) waits until n bytes or
344
+ # EOF, which would strand a trickling child's
345
+ # available bytes inside the buffer when EOF never
346
+ # comes (a detached pipe holder); read1 returns
347
+ # whatever one raw read delivers.
348
+ chunk = stream.read1(65536)
349
+ if not chunk:
350
+ break
351
+ room = _OUTPUT_CAP_BYTES - len(sink["data"])
352
+ if room > 0:
353
+ sink["data"] += chunk[:room]
354
+ if len(chunk) > max(room, 0):
355
+ sink["truncated"] = True
356
+ except Exception: # noqa: BLE001 — a dead stream is EOF
357
+ pass
358
+ finally:
359
+ try:
360
+ stream.close()
361
+ except Exception: # noqa: BLE001
362
+ pass
363
+
364
+
365
+ def _captured(out_sink, err_sink, incomplete=False):
366
+ """The combined output bytes the record hashes and tails:
367
+ stdout then stderr, exactly as the pre-cap runner combined
368
+ them, plus markers (visible in the tail, and covered by
369
+ the hash) for the two ways the bytes can be less than what
370
+ the child wrote: a truncation marker when either stream
371
+ hit the cap, and — when a drainer was still blocked after
372
+ the post-kill settle because a detached process holds the
373
+ pipe open — an incomplete-capture marker. Partial output
374
+ is never presented as whole."""
375
+ output = bytes(out_sink["data"]) + bytes(err_sink["data"])
376
+ if out_sink["truncated"] or err_sink["truncated"]:
377
+ output += (
378
+ f"\n[codereview: output truncated at the sandbox "
379
+ f"cap of {_OUTPUT_CAP_BYTES} bytes per stream]\n"
380
+ ).encode("utf-8")
381
+ if incomplete:
382
+ output += (
383
+ "\n[codereview: output capture incomplete — a "
384
+ "detached process still holds the pipe]\n"
385
+ ).encode("utf-8")
386
+ return output
387
+
388
+
389
+ # ------------------------------------------------- env-only exposure note
390
+
391
+ # P3-04 follow-up: env-only mode has no pid namespace, so a
392
+ # sandboxed child running as the operator's uid can read
393
+ # /proc/<pid>/environ of ANY dumpable same-uid process. The
394
+ # gate's own process is hardened (hardening.make_non_
395
+ # dumpable), but sibling processes — the operator's shell,
396
+ # IDE, other agents — are not ours to harden, and namespace
397
+ # mode is the only mode that closes the channel. When a run
398
+ # actually executes env-only while the gate process's own
399
+ # environment carries key-shaped variable NAMES, the run
400
+ # says so: once on stderr, and in the evidence record.
401
+ _KEY_SHAPED_NAME_RE = re.compile(r"API_KEY|SECRET|TOKEN|PASSWORD")
402
+ _ENV_ONLY_WARNING_EMITTED = False
403
+
404
+
405
+ def _key_shaped_env_names(environ=None):
406
+ """Names (never values) of key-shaped variables in the
407
+ gate process's own environment — the names a sandboxed
408
+ child could go hunting for in a sibling process."""
409
+ env = os.environ if environ is None else environ
410
+ return sorted(
411
+ name for name in env
412
+ if _KEY_SHAPED_NAME_RE.search(name)
413
+ or name.endswith("_KEY"))
414
+
415
+
416
+ def _env_only_key_warning(names):
417
+ return (
418
+ "sandbox ran in env-only mode (no namespace): a "
419
+ "sandboxed command running as this user can read the "
420
+ "environment of any dumpable same-user process via "
421
+ "/proc/<pid>/environ; key-shaped environment "
422
+ "variables are present ("
423
+ + ", ".join(names) +
424
+ "), so the 'provider keys never reach an evidence "
425
+ "process' property does not hold in this mode — "
426
+ "see THREAT-MODEL.md residual 7.")
427
+
428
+
429
+ def _note_env_only_exposure(network_mode):
430
+ """Return the env-only exposure warning text when this
431
+ execution ran env-only with key-shaped names in the gate
432
+ environment, else None. The first such execution in the
433
+ process also prints the warning to stderr (once per
434
+ run, not per command)."""
435
+ global _ENV_ONLY_WARNING_EMITTED
436
+ if network_mode != "env-only":
437
+ return None
438
+ names = _key_shaped_env_names()
439
+ if not names:
440
+ return None
441
+ text = _env_only_key_warning(names)
442
+ if not _ENV_ONLY_WARNING_EMITTED:
443
+ _ENV_ONLY_WARNING_EMITTED = True
444
+ print(f"codereview: warning: {text}", file=sys.stderr)
445
+ return text
446
+
447
+
448
+ def _execute(argv, cwd, timeout, env, allow_network,
449
+ settle_s=_KILL_SETTLE_S):
450
+ """Run one argv in the sandbox; return (status, exit_code,
451
+ duration_s, output_bytes, network_mode). Never raises for
452
+ child failures — every outcome is data for the record.
453
+
454
+ Output is read by drainer threads under a per-stream cap
455
+ (see _drain); the wait semantics mirror the communicate()
456
+ contract this replaces: the run ends when the child has
457
+ exited AND both pipes reach EOF, bounded by the timeout —
458
+ a grandchild that inherits a pipe and holds it open past
459
+ the deadline is a timeout, exactly as before."""
460
+ # P3-04 backstop: the child below is a direct child of
461
+ # this process in env-only mode and could read the gate's
462
+ # environment from /proc/$PPID/environ; the CLI/MCP entry
463
+ # points harden at start, this lazy call covers library
464
+ # hosts that never went through them.
465
+ hardening.make_non_dumpable()
466
+ network_mode = "allowed" if allow_network else "env-only"
467
+ run_argv = list(argv)
468
+ if not allow_network and _unshare_ok():
469
+ run_argv = ["unshare", "-rn"] + run_argv
470
+ network_mode = "namespace"
471
+ if network_mode == "env-only":
472
+ # Surface the sibling-/proc residual (THREAT-MODEL
473
+ # residual 7) when it is live for this run.
474
+ _note_env_only_exposure(network_mode)
475
+ started = time.monotonic()
476
+ try:
477
+ proc = subprocess.Popen(
478
+ run_argv, cwd=cwd, env=env, stdout=subprocess.PIPE,
479
+ stderr=subprocess.PIPE, start_new_session=True,
480
+ preexec_fn=lambda: _apply_limits(timeout))
481
+ except Exception as exc: # noqa: BLE001
482
+ return "error", None, 0.0, \
483
+ f"could not start: {exc}".encode("utf-8", "replace"), \
484
+ network_mode
485
+ out_sink = {"data": bytearray(), "truncated": False}
486
+ err_sink = {"data": bytearray(), "truncated": False}
487
+ readers = [
488
+ threading.Thread(target=_drain, args=(proc.stdout,
489
+ out_sink),
490
+ daemon=True),
491
+ threading.Thread(target=_drain, args=(proc.stderr,
492
+ err_sink),
493
+ daemon=True),
494
+ ]
495
+ for reader in readers:
496
+ reader.start()
497
+ return _supervise_sandbox_run(
498
+ proc, readers, timeout, started, out_sink, err_sink,
499
+ network_mode, settle_s=settle_s)
500
+
501
+
502
+ def _supervise_sandbox_run(proc, readers, timeout, started,
503
+ out_sink, err_sink, network_mode,
504
+ settle_s=_KILL_SETTLE_S):
505
+ """The evidence sandbox's supervised wait — the pairing
506
+ the v14 subprocess-timeout guard accepts by name for
507
+ _execute's Popen, as it does providers' seat wait: every
508
+ wait here is deadline-bounded (wait(timeout=...), bounded
509
+ joins) and expiry kills the whole process group. It is a
510
+ stronger bound than communicate(timeout=...), not a
511
+ weaker one: communicate buffers the child's output
512
+ without bound, this wait drains it under the C5 cap."""
513
+ def _settle(deadline):
514
+ """True when the child has exited and both pipes are
515
+ drained by `deadline` (an absolute monotonic time)."""
516
+ for reader in readers:
517
+ reader.join(max(deadline - time.monotonic(), 0.0))
518
+ return proc.poll() is not None and not any(
519
+ reader.is_alive() for reader in readers)
520
+
521
+ timed_out = False
522
+ try:
523
+ proc.wait(timeout=timeout)
524
+ except subprocess.TimeoutExpired:
525
+ timed_out = True
526
+ if not timed_out and not _settle(started + timeout + 2.0):
527
+ # The child exited but a pipe is still not drained
528
+ # shortly after the deadline — a grandchild inherited
529
+ # it and is holding it open. communicate() reported
530
+ # this as a timeout; so does the sandbox. (The 2 s
531
+ # grace covers the final pipe bytes of a child that
532
+ # exited right at its deadline.)
533
+ timed_out = True
534
+ if timed_out:
535
+ _kill_group(proc)
536
+ try:
537
+ proc.wait(timeout=settle_s)
538
+ except Exception: # noqa: BLE001
539
+ pass
540
+ # Post-kill drain: after the group kill the pipes
541
+ # reach EOF and the drainers finish, so the sinks are
542
+ # final before _captured reads them. A drainer still
543
+ # blocked after the settle means a detached process
544
+ # (outside the killed group) holds the pipe — the
545
+ # capture is then partial, and _captured marks it
546
+ # incomplete rather than presenting it as whole.
547
+ final = _settle(time.monotonic() + settle_s)
548
+ return "timeout", None, time.monotonic() - started, \
549
+ _captured(out_sink, err_sink, incomplete=not final), \
550
+ network_mode
551
+ status = "passed" if proc.returncode == 0 else "failed"
552
+ # P3-06: the timeout path kills the process group; the
553
+ # normal path must too — a backgrounded child with
554
+ # redirected stdio (sleep 60 >/dev/null &) otherwise
555
+ # survives a passed run and outlives the sandbox.
556
+ # _kill_group tolerates an already-dead group.
557
+ _kill_group(proc)
558
+ return status, proc.returncode, time.monotonic() - started, \
559
+ _captured(out_sink, err_sink), network_mode
560
+
561
+
562
+ def _result(name, kind, required, status, exit_code, duration,
563
+ output, detail="", command=None, network_mode=None):
564
+ return {
565
+ "name": name, "kind": kind, "required": bool(required),
566
+ "status": status, "exit_code": exit_code,
567
+ "duration_s": round(duration, 3),
568
+ "output_sha256": hashlib.sha256(output).hexdigest(),
569
+ "output_tail": redact_secrets(
570
+ output.decode("utf-8", errors="replace")
571
+ )[-_OUTPUT_TAIL_CHARS:] if output else "",
572
+ "detail": detail,
573
+ "command": command,
574
+ "network_mode": network_mode,
575
+ }
576
+
577
+
578
+ # ------------------------------------------------------------------ adapters (C2)
579
+
580
+ def _adapter_gitleaks(workdir, item, path_env):
581
+ tool = shutil.which("gitleaks", path=path_env)
582
+ if not tool:
583
+ return None, "gitleaks not found on PATH"
584
+ argv = [tool, "detect", "--source", ".", "--no-git",
585
+ "--redact"] + [str(a) for a in item.get("args") or []]
586
+ return argv, ""
587
+
588
+
589
+ def _adapter_semgrep(workdir, item, path_env):
590
+ tool = shutil.which("semgrep", path=path_env)
591
+ if not tool:
592
+ return None, "semgrep not found on PATH"
593
+ # --config auto fetches rules: the item needs
594
+ # "allow_network": true to fetch them under the sandbox; a
595
+ # local rule path passed via args runs fully offline.
596
+ argv = [tool, "scan", "--config", "auto", "--error",
597
+ "--quiet"] + [str(a) for a in item.get("args") or []]
598
+ return argv, ""
599
+
600
+
601
+ def _adapter_dependency_audit(workdir, item, path_env):
602
+ """First available dependency-audit tool wins; a tree with no
603
+ supported ecosystem and no scanner is `unavailable`, never a
604
+ crash. Extra args from the config are appended."""
605
+ args = [str(a) for a in item.get("args") or []]
606
+ tool = shutil.which("osv-scanner", path=path_env)
607
+ if tool:
608
+ return [tool, "-r", "."] + args, ""
609
+ tool = shutil.which("pip-audit", path=path_env)
610
+ if tool:
611
+ return [tool] + args, ""
612
+ if os.path.exists(os.path.join(workdir, "package.json")):
613
+ tool = shutil.which("npm", path=path_env)
614
+ if tool:
615
+ return [tool, "audit", "--json"] + args, ""
616
+ return None, ("no dependency-audit tool available "
617
+ "(looked for: osv-scanner, pip-audit, npm "
618
+ "with a package.json)")
619
+
620
+
621
+ ADAPTERS = {
622
+ "gitleaks": _adapter_gitleaks,
623
+ "semgrep": _adapter_semgrep,
624
+ "dependency-audit": _adapter_dependency_audit,
625
+ }
626
+
627
+
628
+ # ------------------------------------------------------------------ collect (C1)
629
+
630
+ def collect(config, source_dir, environ=None):
631
+ """Run the configured evidence against a sandboxed copy of
632
+ `source_dir` and return the evidence record dict (stored in
633
+ the run JSON under "evidence"). Never raises for tool or
634
+ command failures — every outcome is recorded; a failure to
635
+ even build the sandbox is recorded as an `error` item so a
636
+ required-evidence gate still sees it.
637
+
638
+ Direct library use is protected the same way the CLI path
639
+ is (L11): the process hardening is applied HERE, at
640
+ collect() entry — not only at the CLI/MCP entry points —
641
+ so a host that calls collect() without going through them
642
+ still runs its evidence children with a non-dumpable
643
+ parent (in env-only mode a sandboxed child cannot read
644
+ this process's environment via /proc/<pid>/environ;
645
+ THREAT-MODEL residual 7 covers SIBLING processes, which no
646
+ call here can harden). _execute keeps its own lazy call as
647
+ a backstop for any future entry that bypasses collect()."""
648
+ hardening.make_non_dumpable()
649
+ validate_config(config)
650
+ config = config or {}
651
+ default_timeout = _resolve_timeout(
652
+ config.get("timeout"), "'timeout'", DEFAULT_TIMEOUT)
653
+ default_network = bool(config.get("allow_network"))
654
+ items = []
655
+
656
+ sandbox_root = tempfile.mkdtemp(prefix="codereview-evidence-")
657
+ workdir = os.path.join(sandbox_root, "tree")
658
+ try:
659
+ try:
660
+ _copy_tree(source_dir, workdir)
661
+ except Exception as exc: # noqa: BLE001
662
+ for entry in config.get("commands") or []:
663
+ items.append(_result(
664
+ str(entry.get("name") or entry.get("command")),
665
+ "command", entry.get("required", True), "error",
666
+ None, 0.0, b"",
667
+ detail=f"sandbox copy failed: {exc}",
668
+ command=str(entry.get("command"))))
669
+ for entry in config.get("adapters") or []:
670
+ items.append(_result(
671
+ str(entry.get("name")), "adapter",
672
+ entry.get("required", False), "error", None, 0.0,
673
+ b"", detail=f"sandbox copy failed: {exc}"))
674
+ return _record(config, source_dir, items,
675
+ network_mode="not-run")
676
+
677
+ for entry in config.get("commands") or []:
678
+ command = str(entry.get("command"))
679
+ name = str(entry.get("name") or command)
680
+ timeout = _resolve_timeout(
681
+ entry.get("timeout"),
682
+ f"command {name!r} 'timeout'", default_timeout)
683
+ allow_network = bool(entry.get("allow_network",
684
+ default_network))
685
+ env = _sandbox_env(sandbox_root, allow_network,
686
+ environ=environ)
687
+ status, code, duration, output, net = _execute(
688
+ ["sh", "-c", command], workdir, timeout, env,
689
+ allow_network)
690
+ items.append(_result(
691
+ name, "command", entry.get("required", True),
692
+ status, code, duration, output,
693
+ command=command, network_mode=net))
694
+
695
+ for entry in config.get("adapters") or []:
696
+ name = str(entry.get("name"))
697
+ timeout = _resolve_timeout(
698
+ entry.get("timeout"),
699
+ f"adapter {name!r} 'timeout'", default_timeout)
700
+ allow_network = bool(entry.get("allow_network",
701
+ default_network))
702
+ argv, why = ADAPTERS[name](
703
+ workdir, entry,
704
+ (environ or os.environ).get("PATH", ""))
705
+ if argv is None:
706
+ items.append(_result(
707
+ name, "adapter", entry.get("required", False),
708
+ "unavailable", None, 0.0, b"", detail=why))
709
+ continue
710
+ env = _sandbox_env(sandbox_root, allow_network,
711
+ environ=environ)
712
+ status, code, duration, output, net = _execute(
713
+ argv, workdir, timeout, env, allow_network)
714
+ items.append(_result(
715
+ name, "adapter", entry.get("required", False),
716
+ status, code, duration, output,
717
+ command=" ".join(argv[1:]) if len(argv) > 1
718
+ else argv[0],
719
+ network_mode=net))
720
+
721
+ present = {it["name"] for it in items}
722
+ for name in config.get("require") or []:
723
+ if str(name) not in present:
724
+ items.append(_result(
725
+ str(name), "required", True, "missing", None,
726
+ 0.0, b"",
727
+ detail="required by policy but not configured "
728
+ "or run"))
729
+ modes = {it.get("network_mode") for it in items
730
+ if it.get("network_mode")}
731
+ network_mode = (modes.pop() if len(modes) == 1
732
+ else "mixed" if modes else "not-run")
733
+ sandbox_warning = None
734
+ if any(it.get("network_mode") == "env-only"
735
+ for it in items):
736
+ names = _key_shaped_env_names()
737
+ if names:
738
+ sandbox_warning = _env_only_key_warning(names)
739
+ return _record(config, source_dir, items,
740
+ network_mode=network_mode,
741
+ sandbox_warning=sandbox_warning)
742
+ finally:
743
+ shutil.rmtree(sandbox_root, ignore_errors=True)
744
+
745
+
746
+ def _record(config, source_dir, items, network_mode,
747
+ sandbox_warning=None):
748
+ default_timeout = _resolve_timeout(
749
+ config.get("timeout"), "'timeout'", DEFAULT_TIMEOUT)
750
+ sandbox = {
751
+ "kind": "temp-copy (source tree minus .git, deleted "
752
+ "after the run)",
753
+ "env": "allow-list only (no provider keys or tokens); "
754
+ "HOME/TMPDIR inside the sandbox",
755
+ "network": network_mode,
756
+ "limits": {
757
+ "timeout_s_default": default_timeout,
758
+ "cpu_slack_s": _CPU_SLACK,
759
+ "file_size_limit_bytes": _FILE_SIZE_LIMIT,
760
+ "output_cap_bytes_per_stream": _OUTPUT_CAP_BYTES,
761
+ },
762
+ }
763
+ if sandbox_warning:
764
+ # Present only when the exposure it names was live
765
+ # for this run (env-only mode + key-shaped names in
766
+ # the gate environment); absent otherwise, so a
767
+ # record without it is the unexposed shape.
768
+ sandbox["warning"] = sandbox_warning
769
+ return {
770
+ "schema": SCHEMA,
771
+ "created_at": _now_iso(),
772
+ "source": str(source_dir),
773
+ "sandbox": sandbox,
774
+ "items": items,
775
+ }
776
+
777
+
778
+ # ------------------------------------------------------------------ gate (C3)
779
+
780
+ def gate_effect(items):
781
+ """The C3 gate rule as a pure function over evidence items.
782
+
783
+ Returns (effect, reasons): effect is "blocked",
784
+ "inconclusive", or None (evidence does not move the gate).
785
+ Required items that failed, timed out, errored, or are
786
+ missing block; required items that are merely unavailable
787
+ (tool not installed) make the run inconclusive — the
788
+ distinction between "the change failed its evidence" and
789
+ "this machine could not produce the evidence"."""
790
+ blocked, inconclusive = [], []
791
+ for item in items or []:
792
+ if not item.get("required"):
793
+ continue
794
+ status = item.get("status")
795
+ name = item.get("name")
796
+ if status in ("failed", "timeout", "error", "missing"):
797
+ detail = item.get("detail") or status
798
+ blocked.append(
799
+ f"required evidence {name!r} {status} ({detail})"
800
+ if status in ("error", "missing") else
801
+ f"required evidence {name!r} {status}")
802
+ elif status == "unavailable":
803
+ inconclusive.append(
804
+ f"required evidence {name!r} unavailable "
805
+ f"({item.get('detail') or 'tool not installed'})")
806
+ if blocked:
807
+ return "blocked", blocked + inconclusive
808
+ if inconclusive:
809
+ return "inconclusive", inconclusive
810
+ return None, []
811
+
812
+
813
+ # ------------------------------------------------------------------ builder claims (C4)
814
+
815
+ # A claim is a command the builder's report asserts was run, with
816
+ # a claimed outcome. Two documented forms:
817
+ # A. a backtick-quoted command with an outcome on the same line:
818
+ # Ran `python -m pytest -q` — passed
819
+ # B. a labelled line: "Tests: python -m pytest -q (exit 0)"
820
+ # Anything else — a command with no stated outcome, prose about
821
+ # testing in general — is not a verifiable claim and is ignored,
822
+ # never guessed at.
823
+ _CLAIM_LABEL_RE = re.compile(
824
+ r"^\s*(?:[-*]\s*)?(?:ran|command|cmd|tests?|check)\s*:\s*(.+)$",
825
+ re.IGNORECASE)
826
+ _SPAN_RE = re.compile(r"`([^`\n]{1,300})`")
827
+ _EXIT_RE = re.compile(r"exit(?:\s+code)?\s*(\d+)", re.IGNORECASE)
828
+ # Word-boundary matching throughout: "ok" must not match inside
829
+ # "broken", and an outcome word inside the command itself (e.g.
830
+ # `grep error log`) must not fabricate a claim — for labelled
831
+ # lines the outcome is only read from the tail after the last
832
+ # separator, or from a trailing outcome word.
833
+ _FAIL_WORD_RE = re.compile(r"\b(failed|fail|failure|error)\b",
834
+ re.IGNORECASE)
835
+ _PASS_WORD_RE = re.compile(
836
+ r"\b(passed|pass|success|succeeded|ok)\b", re.IGNORECASE)
837
+ _TAIL_SEPS = (" — ", " – ", " | ", " (")
838
+ # P2-06: outcome words with counts are read count-first — a
839
+ # bare word search reads "12 passed, 0 failed" as FAILED
840
+ # because "failed" appears at all, and cannot see "0 errors"
841
+ # as the pass claim it is.
842
+ _COUNT_RE = re.compile(
843
+ r"(\d+)\s+(passed|failed|failures?|errors?)\b", re.IGNORECASE)
844
+ # A negated failure ("did not fail", "no failures", "never
845
+ # failed", "not failed") is a pass claim, never a failure.
846
+ _NEGATED_FAIL_RE = re.compile(
847
+ r"\b(?:did not|never|not|no)\s+"
848
+ r"(?:fail|failed|failure|failures|error|errors)\b",
849
+ re.IGNORECASE)
850
+ # P2-07: the trailing outcome word of a labelled line is found
851
+ # with this end-anchored search; the separator run before it
852
+ # is walked by hand (see parse_claims). The old single regex
853
+ # (separator-run + word) backtracked quadratically on long
854
+ # separator runs.
855
+ _TRAILING_OUTCOME_RE = re.compile(
856
+ r"(passed|failed|pass|fail|success|succeeded|ok)"
857
+ r"\s*[.!]?\s*$", re.IGNORECASE)
858
+ _TRAILING_SEP_CHARS = " \t-–—"
859
+
860
+
861
+ def _claimed_outcome(text):
862
+ """-> ("passed"|"failed", exit_code|None) or None."""
863
+ match = _EXIT_RE.search(text)
864
+ if match:
865
+ code = int(match.group(1))
866
+ return ("passed" if code == 0 else "failed"), code
867
+ if "✗" in text or "✘" in text:
868
+ return "failed", None
869
+ if "✅" in text or "✓" in text:
870
+ return "passed", None
871
+ counts = {}
872
+ for num, word in _COUNT_RE.findall(text):
873
+ counts[word.lower()] = counts.get(word.lower(), 0) + int(num)
874
+ if counts:
875
+ bad = sum(counts.get(w, 0) for w in
876
+ ("failed", "failure", "failures",
877
+ "error", "errors"))
878
+ if bad > 0:
879
+ return "failed", None
880
+ if counts.get("passed", 0) > 0 or any(
881
+ w in counts for w in
882
+ ("failed", "failure", "failures",
883
+ "error", "errors")):
884
+ # Reached only with bad == 0: some tests passed, or
885
+ # a failure/error count is explicitly zero.
886
+ return "passed", None
887
+ if _NEGATED_FAIL_RE.search(text):
888
+ return "passed", None
889
+ if _FAIL_WORD_RE.search(text):
890
+ return "failed", None
891
+ if _PASS_WORD_RE.search(text):
892
+ return "passed", None
893
+ return None
894
+
895
+
896
+ def parse_claims(report_text):
897
+ """Extract verifiable claims from a builder work report.
898
+ Returns [{command, claimed, claimed_exit_code, line}] with
899
+ the report's line numbers (1-based)."""
900
+ claims = []
901
+ for lineno, line in enumerate(
902
+ (report_text or "").splitlines(), start=1):
903
+ span = _SPAN_RE.search(line)
904
+ label = _CLAIM_LABEL_RE.match(line)
905
+ if span:
906
+ command = span.group(1).strip()
907
+ # The outcome is read from the line with the command
908
+ # span REMOVED, so words inside the command (an
909
+ # `error` argument, a file named ok.txt) never count.
910
+ rest = line[:span.start()] + line[span.end():]
911
+ outcome = _claimed_outcome(rest)
912
+ if command and outcome:
913
+ claims.append({
914
+ "command": command,
915
+ "claimed": outcome[0],
916
+ "claimed_exit_code": outcome[1],
917
+ "line": lineno})
918
+ continue
919
+ if label:
920
+ body = label.group(1).strip().strip("`")
921
+ head, tail = body, ""
922
+ for sep in _TAIL_SEPS:
923
+ if sep in body:
924
+ head, tail = body.rsplit(sep, 1)
925
+ tail = sep + tail
926
+ outcome = _claimed_outcome(tail) if tail else None
927
+ if outcome is None and not tail:
928
+ # P2-07: end-anchored word search + a hand
929
+ # walk over the separator run — linear, where
930
+ # the old separator-run regex backtracked
931
+ # quadratically on long runs.
932
+ end = _TRAILING_OUTCOME_RE.search(body)
933
+ if end:
934
+ start = end.start()
935
+ while start > 0 and body[start - 1] in \
936
+ _TRAILING_SEP_CHARS:
937
+ start -= 1
938
+ if start < end.start():
939
+ outcome = _claimed_outcome(
940
+ body[start:end.end()])
941
+ head = body[:start]
942
+ if outcome:
943
+ command = _EXIT_RE.sub("", head)
944
+ command = " ".join(command.split()).strip()
945
+ command = command.rstrip("—–- ").strip()
946
+ if command:
947
+ claims.append({
948
+ "command": command,
949
+ "claimed": outcome[0],
950
+ "claimed_exit_code": outcome[1],
951
+ "line": lineno})
952
+ return claims
953
+
954
+
955
+ def verify_claims(report_text, source_dir, timeout=DEFAULT_TIMEOUT,
956
+ environ=None, claims=None):
957
+ """Re-run every claimed command in the sandbox and compare.
958
+
959
+ Returns {"schema", "claims": [...], "summary": {...}}. Each
960
+ claim's status is "confirmed" (the re-run matches the claim),
961
+ "downgraded" (the re-run contradicts the claim — the claim is
962
+ recorded as not standing), or "error" (the re-run could not
963
+ be performed). The builder's text is data: the claimed command
964
+ string is executed as-is because re-running what was claimed
965
+ is the point — but it executes under the full C5 sandbox
966
+ (temp copy, scrubbed env, no network, limits), never in the
967
+ reviewed tree and never with secrets.
968
+
969
+ `claims` may carry a pre-parsed claim list (the output of
970
+ parse_claims on the same text) so a caller that already
971
+ parsed the report does not pay for — or diverge on — a
972
+ second parse (P2-07); None parses `report_text` here."""
973
+ if claims is None:
974
+ claims = parse_claims(report_text)
975
+ results = []
976
+ sandbox_root = tempfile.mkdtemp(prefix="codereview-claims-")
977
+ workdir = os.path.join(sandbox_root, "tree")
978
+ try:
979
+ try:
980
+ _copy_tree(source_dir, workdir)
981
+ copy_error = ""
982
+ except Exception as exc: # noqa: BLE001
983
+ copy_error = f"sandbox copy failed: {exc}"
984
+ for claim in claims:
985
+ entry = dict(claim)
986
+ if copy_error:
987
+ entry.update(status="error", actual_exit_code=None,
988
+ detail=copy_error)
989
+ results.append(entry)
990
+ continue
991
+ env = _sandbox_env(sandbox_root, False, environ=environ)
992
+ status, code, duration, output, net = _execute(
993
+ ["sh", "-c", claim["command"]], workdir, timeout,
994
+ env, False)
995
+ if claim["claimed_exit_code"] is not None:
996
+ confirmed = code == claim["claimed_exit_code"]
997
+ elif claim["claimed"] == "passed":
998
+ confirmed = code == 0
999
+ else:
1000
+ confirmed = code not in (0, None)
1001
+ entry.update(
1002
+ status="confirmed" if confirmed else "downgraded",
1003
+ actual_exit_code=code,
1004
+ duration_s=round(duration, 3),
1005
+ output_sha256=hashlib.sha256(output).hexdigest(),
1006
+ detail="" if confirmed else
1007
+ f"claimed {claim['claimed']}"
1008
+ + (f" (exit {claim['claimed_exit_code']})"
1009
+ if claim["claimed_exit_code"] is not None
1010
+ else "")
1011
+ + f"; re-run exited {code} ({status})")
1012
+ results.append(entry)
1013
+ finally:
1014
+ shutil.rmtree(sandbox_root, ignore_errors=True)
1015
+ summary = {
1016
+ "total": len(results),
1017
+ "confirmed": sum(1 for r in results
1018
+ if r["status"] == "confirmed"),
1019
+ "downgraded": sum(1 for r in results
1020
+ if r["status"] == "downgraded"),
1021
+ "error": sum(1 for r in results if r["status"] == "error"),
1022
+ }
1023
+ return {"schema": SCHEMA, "claims": results, "summary": summary}