loop-engineer 0.7.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.
Files changed (37) hide show
  1. loop/__init__.py +17 -0
  2. loop/__main__.py +176 -0
  3. loop/_bundle/schemas/manifest.schema.json +41 -0
  4. loop/_bundle/schemas/receipt.schema.json +21 -0
  5. loop/_bundle/schemas/repair-record.schema.json +42 -0
  6. loop/_bundle/schemas/rollout-record.schema.json +27 -0
  7. loop/_bundle/schemas/state.schema.json +25 -0
  8. loop/_bundle/schemas/tasks.schema.json +30 -0
  9. loop/_bundle/schemas/terminal.schema.json +19 -0
  10. loop/_bundle/templates/AGENTS.md.tmpl +67 -0
  11. loop/_bundle/templates/EVALS-rubric.md.tmpl +114 -0
  12. loop/_bundle/templates/RUNLOG.md.tmpl +42 -0
  13. loop/_bundle/templates/SPEC.md.tmpl +61 -0
  14. loop/_bundle/templates/TASKS.json.tmpl +23 -0
  15. loop/_bundle/templates/WORKFLOW.md.tmpl +84 -0
  16. loop/_bundle/templates/extract-trace-metrics.sh +27 -0
  17. loop/_bundle/templates/judge-rubric.sh +28 -0
  18. loop/_bundle/templates/manifest.yaml.tmpl +47 -0
  19. loop/_bundle/templates/state.json.tmpl +38 -0
  20. loop/_bundle/templates/terminal_state.json.tmpl +13 -0
  21. loop/_bundle/templates/verify-fast.sh +27 -0
  22. loop/_bundle/templates/verify-full.sh +34 -0
  23. loop/_bundle/templates/verify-safety.sh +35 -0
  24. loop/_bundle/tools/anticheat_scan.py +490 -0
  25. loop/_bundle/tools/holdout_gate.py +121 -0
  26. loop/_bundle/tools/inspect_loop.py +669 -0
  27. loop/_bundle/tools/metrics.py +725 -0
  28. loop/_resources.py +43 -0
  29. loop/contract.py +577 -0
  30. loop/emit.py +258 -0
  31. loop/paths.py +77 -0
  32. loop/scaffold.py +131 -0
  33. loop_engineer-0.7.0.dist-info/METADATA +470 -0
  34. loop_engineer-0.7.0.dist-info/RECORD +37 -0
  35. loop_engineer-0.7.0.dist-info/WHEEL +4 -0
  36. loop_engineer-0.7.0.dist-info/entry_points.txt +3 -0
  37. loop_engineer-0.7.0.dist-info/licenses/LICENSE +21 -0
@@ -0,0 +1,490 @@
1
+ """Anti-cheat trajectory scan — sweep a Succeeded claim for shortcut signatures.
2
+
3
+ After a loop claims ``Succeeded``, this scans the diff and (optionally) the run
4
+ trajectory for the ways an agent games a verifier instead of solving the task:
5
+
6
+ * gate-tampering — the diff edits a gate script (self_eval, the gates
7
+ themselves, CI config). CRITICAL -> FailedSafety.
8
+ * skip-injection — added @pytest.mark.skip/xfail, *.skip(, t.Skip(, @Ignore.
9
+ * assert-true — added a tautological assertion that always passes.
10
+ * hidden-answer-read — the trajectory touched a holdout / answer-key / golden /
11
+ oracle file. (HIGH -> FailedUnverifiable.)
12
+ * test-file-mutation — a test file changed. MEDIUM: a *review flag*, not an
13
+ auto-fail (legitimate in TDD); surfaced, never silent.
14
+
15
+ Calibration: only HIGH and CRITICAL findings auto-downgrade the verdict. MEDIUM
16
+ findings are surfaced for review so an honest TDD loop is not punished for
17
+ writing tests. The path/line signatures are conservative and configurable.
18
+
19
+ This ships as composable tooling, not a runtime. Pair it with ``holdout_gate.py``.
20
+
21
+ Run::
22
+
23
+ git diff | python3 anticheat_scan.py
24
+ python3 anticheat_scan.py --diff changes.diff --trajectory trace.json
25
+
26
+ ``--trajectory`` is a JSON array of strings (tool calls / paths the loop touched).
27
+ Prints findings as JSON. The exit code splits the scan's *outcome* from an
28
+ *operational* failure so a broken invocation is never read as a clean scan or as
29
+ findings:
30
+
31
+ * 0 — clean: the scan ran and found nothing.
32
+ * 1 — findings: a downgrade to FailedUnverifiable, or review-only findings.
33
+ * 2 — gate tampering: a critical finding -> FailedSafety.
34
+ * 3 — operational error: the scan could not run at all (unreadable ``--diff`` /
35
+ ``--trajectory`` file, malformed ``--trajectory`` JSON, failed
36
+ ``--self-check`` git call, or a bad argument).
37
+ """
38
+
39
+ from __future__ import annotations
40
+
41
+ import argparse
42
+ import json
43
+ import pathlib
44
+ import re
45
+ import subprocess
46
+ import sys
47
+
48
+ # Exit codes. Findings (1, 2) and the scan's severity live apart from an
49
+ # operational failure (3), so a broken invocation never masquerades as a clean
50
+ # scan (0) or as a downgrade finding (1).
51
+ EXIT_CLEAN = 0
52
+ EXIT_FINDINGS = 1
53
+ EXIT_GATE_TAMPERING = 2
54
+ EXIT_OPERATIONAL_ERROR = 3
55
+
56
+ # Path markers for a gate file. A bare name (e.g. "self_eval.py") matches by
57
+ # BASENAME / path-segment, never by raw substring — so a test file whose name
58
+ # merely CONTAINS a gate name (test_anticheat_scan.py) is not mis-upgraded.
59
+ # A trailing "/" entry (".github/workflows/") matches as a directory prefix.
60
+ DEFAULT_GATE_PATHS = (
61
+ "self_eval.py",
62
+ "validate_frontmatter.py",
63
+ "holdout_gate.py",
64
+ "anticheat_scan.py",
65
+ "conftest.py",
66
+ ".github/workflows/",
67
+ )
68
+
69
+ # The scanner's own source set — excluded from gate-tampering self-flagging so a
70
+ # diff that corrects the scanner is not graded as tampering against itself.
71
+ _SELF_FILES = frozenset({"anticheat_scan.py", "test_anticheat_scan.py"})
72
+
73
+
74
+ # Contract-template gate scripts: verify-fast / verify-full / verify-safety,
75
+ # matched by basename prefix (with or without a .sh suffix).
76
+ _VERIFY_GATE_ROOTS = frozenset({"fast", "full", "safety"})
77
+
78
+
79
+ def _basename(path: str) -> str:
80
+ return path.rstrip("/").rsplit("/", 1)[-1]
81
+
82
+
83
+ def _is_verify_gate(path: str) -> bool:
84
+ base = _basename(path)
85
+ if not base.startswith("verify-"):
86
+ return False
87
+ root = base[len("verify-"):]
88
+ if root.endswith(".sh"):
89
+ root = root[: -len(".sh")]
90
+ return root in _VERIFY_GATE_ROOTS
91
+
92
+
93
+ def _is_gate_path(path: str, gate_paths: tuple[str, ...]) -> bool:
94
+ """Whether ``path`` is a gate file.
95
+
96
+ A directory-style entry (ends in ``/``) matches as a path prefix/segment.
97
+ A filename entry matches only by exact basename, never by substring, so a
98
+ file that merely contains a gate name in its own filename is not upgraded.
99
+ """
100
+ if _basename(path) in _SELF_FILES:
101
+ return False
102
+ if _is_verify_gate(path):
103
+ return True
104
+ segments = path.split("/")
105
+ for g in gate_paths:
106
+ if g.endswith("/"):
107
+ if g.rstrip("/") in segments or g in path:
108
+ return True
109
+ elif _basename(path) == g:
110
+ return True
111
+ return False
112
+
113
+ # A changed file that is a test (any common language convention).
114
+ _TEST_PATH_RE = re.compile(
115
+ r"(^|/)(tests?|spec|__tests__)/"
116
+ r"|(^|/)test_[^/]+$"
117
+ r"|_test\.[a-z]+$"
118
+ r"|\.test\.[a-z]+$"
119
+ r"|\.spec\.[a-z]+$"
120
+ r"|_spec\.rb$"
121
+ )
122
+
123
+ # Answer-key / holdout files no honest solve needs to read. PATH-SHAPED tokens
124
+ # only (never bare prose words) so 'assert result == expected' does not flag.
125
+ _ANSWER_PATH_RE = re.compile(
126
+ r"hold[_-]?out|held-out|answer[_/-]?key|/golden/|\.golden\b"
127
+ r"|/expected/|expected\.(json|txt|csv|yaml|out)\b|oracle|\.secret",
128
+ re.I,
129
+ )
130
+
131
+ # Signatures on ADDED diff lines (lines starting with '+', not the +++ header).
132
+ _ADDED_LINE_SIGNATURES = (
133
+ ("skip-injection", "high", re.compile(
134
+ r"^\+(?!\+\+).*(@pytest\.mark\.(skip|xfail)"
135
+ r"|pytest\.skip\("
136
+ r"|\b(it|test|describe)\.skip\("
137
+ r"|@Ignore\b"
138
+ r"|\bt\.Skip\()"
139
+ )),
140
+ ("assert-true", "high", re.compile(
141
+ r"^\+(?!\+\+).*\bexpect\(\s*true\s*\)",
142
+ re.IGNORECASE,
143
+ )),
144
+ # Tautologies that always pass: `assert True`, identical numeric/identifier
145
+ # operands (`assert 1 == 1`, `assert x == x`), and `assertTrue(True)`.
146
+ # Backreference-pinned so only IDENTICAL operands match — `assert a == b`
147
+ # and `assert result == expected` are real assertions and stay clean.
148
+ ("assert-true", "high", re.compile(
149
+ r"^\+(?!\+\+).*\bassert\s+(?:True\b"
150
+ r"|(?P<lit>-?\d+(?:\.\d+)?)\s*==\s*(?P=lit)\b"
151
+ r"|(?P<id>[A-Za-z_]\w*)\s*==\s*(?P=id)\b)"
152
+ )),
153
+ ("assert-true", "high", re.compile(
154
+ r"^\+(?!\+\+).*\bassertTrue\(\s*True\s*\)"
155
+ )),
156
+ ("hardcoded-expected", "medium", re.compile(
157
+ r"^\+(?!\+\+).*#\s*(expected|hardcode|hack|cheat|to pass)", re.IGNORECASE
158
+ )),
159
+ )
160
+
161
+ _PLUS_HDR_RE = re.compile(r"^\+\+\+ (b/(?P<b>.+)|/dev/null)$")
162
+ _MINUS_HDR_RE = re.compile(r"^--- (a/(?P<a>.+)|/dev/null)$")
163
+ _RENAME_FROM_RE = re.compile(r"^rename from (?P<p>.+)$")
164
+ _RENAME_TO_RE = re.compile(r"^rename to (?P<p>.+)$")
165
+
166
+ _RANK = {"critical": 3, "high": 2, "medium": 1, "low": 0}
167
+
168
+
169
+ def _iter_hunks(diff_text: str):
170
+ """Yield ``(file_path, added_line)`` for every added ('+') diff line.
171
+
172
+ ``file_path`` is the path the hunk's ``+++ b/<p>`` header names (or, for a
173
+ deletion to ``/dev/null``, the ``--- a/<p>`` path). Used to attribute added
174
+ lines to their file so per-file exemptions can apply.
175
+ """
176
+ current = None
177
+ pending_minus = None
178
+ for line in diff_text.splitlines():
179
+ m = _MINUS_HDR_RE.match(line)
180
+ if m:
181
+ pending_minus = m.group("a")
182
+ continue
183
+ m = _PLUS_HDR_RE.match(line)
184
+ if m:
185
+ current = m.group("b") or pending_minus
186
+ pending_minus = None
187
+ continue
188
+ if line.startswith("+") and not line.startswith("+++"):
189
+ yield current, line
190
+
191
+
192
+ def parse_changed_files(diff_text: str) -> list[str]:
193
+ """Extract changed file paths from a unified diff.
194
+
195
+ Captures modified/added files (``+++ b/<p>``), DELETED files (``--- a/<p>``
196
+ paired with ``+++ /dev/null``), and RENAMED files (``rename from/to``), so a
197
+ gate file that is removed or moved away is still surfaced. Order-preserving,
198
+ deduplicated.
199
+ """
200
+ paths: list[str] = []
201
+ pending_minus = None
202
+ for line in diff_text.splitlines():
203
+ m = _RENAME_FROM_RE.match(line) or _RENAME_TO_RE.match(line)
204
+ if m:
205
+ paths.append(m.group("p"))
206
+ continue
207
+ m = _MINUS_HDR_RE.match(line)
208
+ if m:
209
+ pending_minus = m.group("a")
210
+ continue
211
+ m = _PLUS_HDR_RE.match(line)
212
+ if m:
213
+ b = m.group("b")
214
+ paths.append(b if b is not None else pending_minus)
215
+ pending_minus = None
216
+ return list(dict.fromkeys(p for p in paths if p))
217
+
218
+
219
+ # Self-edit that empties a gate collection outright (Shape A).
220
+ _EVISCERATE_RE = re.compile(
221
+ r"^\+\s*(DEFAULT_GATE_PATHS|_ADDED_LINE_SIGNATURES|_SELF_FILES|_ANSWER_PATH_RE)"
222
+ r"\s*=\s*(\(\s*\)|frozenset\(\)|tuple\(\)|set\(\))\s*$"
223
+ )
224
+
225
+ # A collection-entry line: a bare string literal (optionally trailing comma),
226
+ # i.e. a member of one of the gate collections. Used to compare net add vs
227
+ # remove (Shape B semantic shrink).
228
+ _COLLECTION_ENTRY_RE = re.compile(r"""^\s*['"][^'"]*['"]\s*,?\s*$""")
229
+
230
+
231
+ def _gate_evisceration_findings(diff_text: str) -> list[dict]:
232
+ """Detect a self-edit that REMOVES/empties gate collections (P1.1).
233
+
234
+ Shape A: an added line assigns a gate collection to an empty literal.
235
+ Shape B: within ``anticheat_scan.py`` hunks, more string-literal entries are
236
+ removed than added (net shrink). Reorder/comment-only (net-zero or net-add)
237
+ stays clean.
238
+ """
239
+ findings: list[dict] = []
240
+ removed = added = 0
241
+ in_self = False
242
+ pending_minus = None
243
+ for line in diff_text.splitlines():
244
+ m = _MINUS_HDR_RE.match(line)
245
+ if m:
246
+ pending_minus = m.group("a")
247
+ continue
248
+ m = _PLUS_HDR_RE.match(line)
249
+ if m:
250
+ target = m.group("b") or pending_minus
251
+ in_self = bool(target) and _basename(target) == "anticheat_scan.py"
252
+ pending_minus = None
253
+ continue
254
+ if not in_self:
255
+ continue
256
+ if line.startswith("+") and not line.startswith("+++"):
257
+ if _EVISCERATE_RE.match(line):
258
+ findings.append(
259
+ {"signature": "gate-evisceration", "severity": "critical",
260
+ "evidence": line.strip()[:120]}
261
+ )
262
+ if _COLLECTION_ENTRY_RE.match(line[1:]):
263
+ added += 1
264
+ elif line.startswith("-") and not line.startswith("---"):
265
+ if _COLLECTION_ENTRY_RE.match(line[1:]):
266
+ removed += 1
267
+ if removed > added:
268
+ findings.append(
269
+ {"signature": "gate-evisceration", "severity": "critical",
270
+ "evidence": f"net removal of {removed - added} gate collection entr"
271
+ f"{'y' if removed - added == 1 else 'ies'}"}
272
+ )
273
+ return findings
274
+
275
+
276
+ _CRITICAL_RANK_WEAKEN_RE = re.compile(
277
+ r'^\+\s*_RANK\s*=.*["\']critical["\']\s*:\s*[0-2]\b'
278
+ )
279
+ _DOWNGRADE_WEAKEN_RE = re.compile(
280
+ r'^\+.*downgrade\s*=\s*["\']FailedUnverifiable["\']'
281
+ )
282
+
283
+
284
+ def _semantic_gate_weakening_findings(diff_text: str) -> list[dict]:
285
+ """Detect self-edits that preserve code shape but weaken safety semantics."""
286
+ findings: list[dict] = []
287
+ in_self = False
288
+ pending_minus = None
289
+ for line in diff_text.splitlines():
290
+ m = _MINUS_HDR_RE.match(line)
291
+ if m:
292
+ pending_minus = m.group("a")
293
+ continue
294
+ m = _PLUS_HDR_RE.match(line)
295
+ if m:
296
+ target = m.group("b") or pending_minus
297
+ in_self = bool(target) and _basename(target) == "anticheat_scan.py"
298
+ pending_minus = None
299
+ continue
300
+ if not in_self or not line.startswith("+") or line.startswith("+++"):
301
+ continue
302
+ if _CRITICAL_RANK_WEAKEN_RE.match(line) or _DOWNGRADE_WEAKEN_RE.match(line):
303
+ findings.append(
304
+ {
305
+ "signature": "semantic-gate-weakening",
306
+ "severity": "critical",
307
+ "evidence": line.strip()[:120],
308
+ }
309
+ )
310
+ return findings
311
+
312
+
313
+ def _is_cosmetic_change(content: str) -> bool:
314
+ """Whether a changed scanner line carries no decision logic.
315
+
316
+ Only blank lines and ``#`` comments are cosmetic. Docstring/prose lines are
317
+ NOT special-cased: telling a docstring body from executable code in a diff
318
+ alone is unreliable, and a triple-quote-state heuristic would itself be a
319
+ bypass vector (a real code line like ``SEP = '\"\"\"'`` could toggle the
320
+ state to hide a following ``return``). So the scanner conservatively treats
321
+ any non-blank, non-comment change as code — a false-positive on a
322
+ docstring-only touch-up is acceptable, it merely asks for human review.
323
+ """
324
+ s = content.strip()
325
+ return (not s) or s.startswith("#")
326
+
327
+
328
+ def _scanner_self_edit_findings(diff_text: str) -> list[dict]:
329
+ """Flag any self-edit to anticheat_scan.py that changes a code line (P1.6).
330
+
331
+ The collection-shape and severity-mapping self-checks only catch two known
332
+ shapes; a diff that rewrites the decision LOGIC itself (e.g. inserting
333
+ ``return False`` into ``_is_gate_path``) slips past them and certifies clean.
334
+ The diff-layer invariant closes this: any hunk touching the scanner's own
335
+ source that adds/removes a non-comment, non-blank line is a finding.
336
+ Cosmetic-only edits stay clean; a false-positive on genuine scanner
337
+ maintenance is intended — maintenance should get human eyes.
338
+ """
339
+ in_self = False
340
+ pending_minus = None
341
+ for line in diff_text.splitlines():
342
+ m = _MINUS_HDR_RE.match(line)
343
+ if m:
344
+ pending_minus = m.group("a")
345
+ continue
346
+ m = _PLUS_HDR_RE.match(line)
347
+ if m:
348
+ target = m.group("b") or pending_minus
349
+ in_self = bool(target) and _basename(target) == "anticheat_scan.py"
350
+ pending_minus = None
351
+ continue
352
+ if not in_self:
353
+ continue
354
+ if line.startswith("+++") or line.startswith("---"):
355
+ continue
356
+ if (line.startswith("+") or line.startswith("-")) and not _is_cosmetic_change(line[1:]):
357
+ return [{
358
+ "signature": "scanner_self_edit",
359
+ "severity": "high",
360
+ "message": "self-edits to the scanner require human review",
361
+ "evidence": line[1:].strip()[:120],
362
+ }]
363
+ return []
364
+
365
+
366
+ def scan(
367
+ diff_text: str = "",
368
+ changed_files: list[str] | None = None,
369
+ trajectory: list[str] | None = None,
370
+ gate_paths: tuple[str, ...] | None = None,
371
+ ) -> dict:
372
+ """Scan a diff + trajectory for shortcut signatures.
373
+
374
+ Returns ``{findings, clean, downgrade_to}``. ``downgrade_to`` is
375
+ ``"FailedSafety"`` for any critical finding, ``"FailedUnverifiable"`` for any
376
+ high finding, else ``None`` (medium/low are review flags only).
377
+ """
378
+ gate_paths = gate_paths if gate_paths is not None else DEFAULT_GATE_PATHS
379
+ files = changed_files if changed_files is not None else parse_changed_files(diff_text)
380
+ trajectory = trajectory or []
381
+ findings: list[dict] = []
382
+
383
+ for f in files:
384
+ if _is_gate_path(f, gate_paths):
385
+ findings.append(
386
+ {"signature": "gate-tampering", "severity": "critical", "evidence": f}
387
+ )
388
+ elif _TEST_PATH_RE.search(f):
389
+ findings.append(
390
+ {"signature": "test-file-mutation", "severity": "medium", "evidence": f}
391
+ )
392
+
393
+ # Added-line signatures are attributed to their hunk's file. The scanner's
394
+ # own source files (_SELF_FILES) are exempt so this suite's own regression
395
+ # FIXTURES (e.g. `+ assert 1 == 1`) are not self-flagged — other test
396
+ # files are still scanned. The gate-evisceration check below is EXEMPT from
397
+ # this exemption (it must still scan anticheat_scan.py).
398
+ for file_path, line in _iter_hunks(diff_text):
399
+ if file_path is not None and _basename(file_path) in _SELF_FILES:
400
+ continue
401
+ for name, severity, rx in _ADDED_LINE_SIGNATURES:
402
+ if rx.search(line):
403
+ findings.append(
404
+ {"signature": name, "severity": severity,
405
+ "evidence": line.strip()[:120]}
406
+ )
407
+
408
+ findings.extend(_gate_evisceration_findings(diff_text))
409
+ findings.extend(_semantic_gate_weakening_findings(diff_text))
410
+ findings.extend(_scanner_self_edit_findings(diff_text))
411
+
412
+ for entry in trajectory:
413
+ if _ANSWER_PATH_RE.search(str(entry)):
414
+ findings.append(
415
+ {"signature": "hidden-answer-read", "severity": "high",
416
+ "evidence": str(entry)[:120]}
417
+ )
418
+
419
+ severities = {f["severity"] for f in findings}
420
+ if "critical" in severities:
421
+ downgrade = "FailedSafety"
422
+ elif "high" in severities:
423
+ downgrade = "FailedUnverifiable"
424
+ else:
425
+ downgrade = None
426
+
427
+ findings.sort(key=lambda f: _RANK.get(f["severity"], 0), reverse=True)
428
+ return {"findings": findings, "clean": not findings, "downgrade_to": downgrade}
429
+
430
+
431
+ class _ArgParser(argparse.ArgumentParser):
432
+ """Argparse variant that exits with the operational-error code on a bad
433
+ argument, instead of argparse's default 2 (which would collide with the
434
+ gate-tampering finding code)."""
435
+
436
+ def error(self, message: str): # noqa: D401 - argparse override
437
+ self.print_usage(sys.stderr)
438
+ print(f"{self.prog}: error: {message}", file=sys.stderr)
439
+ raise SystemExit(EXIT_OPERATIONAL_ERROR)
440
+
441
+
442
+ def main(argv: list[str]) -> int:
443
+ ap = _ArgParser(description="Anti-cheat trajectory scan.")
444
+ ap.add_argument("--diff", help="path to a unified diff file (else read stdin)")
445
+ ap.add_argument("--files", help="comma-separated changed files (overrides diff parse)")
446
+ ap.add_argument("--trajectory", help="path to a JSON array of trajectory strings")
447
+ ap.add_argument(
448
+ "--self-check",
449
+ action="store_true",
450
+ help="scan the scanner's own git diff (anticheat_scan.py + its test)",
451
+ )
452
+ args = ap.parse_args(argv)
453
+
454
+ # Gathering inputs is where a *broken invocation* surfaces (unreadable file,
455
+ # malformed JSON, failed git). Fail with the distinct operational code so it
456
+ # is never confused with a clean scan or a findings downgrade.
457
+ try:
458
+ if args.self_check:
459
+ here = pathlib.Path(__file__).resolve().parent
460
+ diff_text = subprocess.run(
461
+ ["git", "diff", "--", "scripts/anticheat_scan.py",
462
+ "scripts/test_anticheat_scan.py"],
463
+ cwd=here.parent, capture_output=True, text=True, check=True,
464
+ ).stdout
465
+ elif args.diff:
466
+ with open(args.diff, encoding="utf-8") as fh:
467
+ diff_text = fh.read()
468
+ elif not sys.stdin.isatty():
469
+ diff_text = sys.stdin.read()
470
+ else:
471
+ diff_text = ""
472
+
473
+ files = args.files.split(",") if args.files else None
474
+ trajectory = None
475
+ if args.trajectory:
476
+ with open(args.trajectory, encoding="utf-8") as fh:
477
+ trajectory = json.load(fh)
478
+ except (OSError, json.JSONDecodeError, subprocess.CalledProcessError) as exc:
479
+ print(f"{ap.prog}: operational error: {exc}", file=sys.stderr)
480
+ return EXIT_OPERATIONAL_ERROR
481
+
482
+ result = scan(diff_text=diff_text, changed_files=files, trajectory=trajectory)
483
+ print(json.dumps(result, indent=2))
484
+ if result["downgrade_to"] == "FailedSafety":
485
+ return EXIT_GATE_TAMPERING
486
+ return EXIT_CLEAN if result["clean"] else EXIT_FINDINGS
487
+
488
+
489
+ if __name__ == "__main__":
490
+ sys.exit(main(sys.argv[1:]))
@@ -0,0 +1,121 @@
1
+ """Held-out verifier split — the runnable core of *measured* false-completion.
2
+
3
+ A loop can game a verifier it can see. So acceptance checks are split:
4
+
5
+ * visible — the loop sees these and optimizes against them during the run.
6
+ * holdout — withheld until terminal verification by this independent gate.
7
+
8
+ A loop may declare ``Succeeded`` only if the HOLDOUT set passes. Passing the
9
+ visible set while failing the holdout set is the measurable *false-completion*
10
+ event (``false_completion: true``) — the signature of overfitting to the tests
11
+ the agent could see. Aggregated across runs, that flag IS the
12
+ false-completion-rate metric (no longer self-reported).
13
+
14
+ This ships as composable tooling, not a runtime: a loop calls it at its terminal
15
+ gate. Pair it with ``anticheat_scan.py`` for the trajectory sweep.
16
+
17
+ Manifest schema (JSON)::
18
+
19
+ {
20
+ "visible": [{"id": "unit", "cmd": "pytest tests/unit -q"}],
21
+ "holdout": [{"id": "spec", "cmd": "pytest tests/holdout -q"}]
22
+ }
23
+
24
+ The manifest is trusted, operator-authored config (like a Makefile target); its
25
+ commands run in a shell. Do not feed it untrusted input.
26
+
27
+ Run::
28
+
29
+ python3 holdout_gate.py <manifest.json> [--cwd DIR]
30
+
31
+ Prints the verdict as JSON. Exit code 0 iff verdict == "Succeeded".
32
+ """
33
+
34
+ from __future__ import annotations
35
+
36
+ import json
37
+ import subprocess
38
+ import sys
39
+ from pathlib import Path
40
+
41
+ SUCCEEDED = "Succeeded"
42
+ FAILED_UNVERIFIABLE = "FailedUnverifiable"
43
+ # Non-terminal: a precondition is unmet (keep working, or fix the gate config).
44
+ NOT_READY = "NotReady"
45
+
46
+
47
+ def _all_passed(results: list[dict]) -> bool:
48
+ return all(r["passed"] for r in results)
49
+
50
+
51
+ def decide(visible: list[dict], holdout: list[dict]) -> dict:
52
+ """Pure verdict from pre-computed check results — never executes anything.
53
+
54
+ ``visible`` / ``holdout`` are lists of ``{"id": str, "passed": bool}``.
55
+
56
+ Rules:
57
+ * visible empty -> NotReady (nothing was optimized against)
58
+ * visible not all green -> NotReady (loop shouldn't certify yet)
59
+ * holdout empty -> NotReady (cannot certify without holdout)
60
+ * visible green + holdout green -> Succeeded
61
+ * visible green + holdout red -> FailedUnverifiable (false completion)
62
+ """
63
+ passed_visible = bool(visible) and _all_passed(visible)
64
+ passed_holdout = bool(holdout) and _all_passed(holdout)
65
+
66
+ if not visible:
67
+ verdict, reason = NOT_READY, "no visible checks defined — cannot certify Succeeded"
68
+ elif not passed_visible:
69
+ verdict, reason = NOT_READY, "visible gate not green — keep working or repair"
70
+ elif not holdout:
71
+ verdict, reason = NOT_READY, "no holdout checks defined — cannot certify Succeeded"
72
+ elif passed_holdout:
73
+ verdict, reason = SUCCEEDED, "visible and holdout gates both green"
74
+ else:
75
+ verdict, reason = (
76
+ FAILED_UNVERIFIABLE,
77
+ "visible passed but holdout failed — false completion",
78
+ )
79
+
80
+ return {
81
+ "verdict": verdict,
82
+ "reason": reason,
83
+ "passed_visible": passed_visible,
84
+ "passed_holdout": passed_holdout,
85
+ "false_completion": passed_visible and bool(holdout) and not passed_holdout,
86
+ "visible": visible,
87
+ "holdout": holdout,
88
+ }
89
+
90
+
91
+ def _run_one(check: dict, cwd: str | None) -> dict:
92
+ proc = subprocess.run(
93
+ check["cmd"], shell=True, cwd=cwd, capture_output=True, text=True
94
+ )
95
+ return {
96
+ "id": check["id"],
97
+ "passed": proc.returncode == 0,
98
+ "returncode": proc.returncode,
99
+ }
100
+
101
+
102
+ def run_manifest(manifest: dict, cwd: str | None = None) -> dict:
103
+ """Execute a visible/holdout manifest and return the verdict dict."""
104
+ visible = [_run_one(c, cwd) for c in manifest.get("visible", [])]
105
+ holdout = [_run_one(c, cwd) for c in manifest.get("holdout", [])]
106
+ return decide(visible, holdout)
107
+
108
+
109
+ def main(argv: list[str]) -> int:
110
+ if not argv:
111
+ print("usage: holdout_gate.py <manifest.json> [--cwd DIR]", file=sys.stderr)
112
+ return 2
113
+ manifest = json.loads(Path(argv[0]).read_text(encoding="utf-8"))
114
+ cwd = argv[argv.index("--cwd") + 1] if "--cwd" in argv else None
115
+ result = run_manifest(manifest, cwd)
116
+ print(json.dumps(result, indent=2))
117
+ return 0 if result["verdict"] == SUCCEEDED else 1
118
+
119
+
120
+ if __name__ == "__main__":
121
+ sys.exit(main(sys.argv[1:]))