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