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.
- loop/__init__.py +17 -0
- loop/__main__.py +176 -0
- loop/_bundle/schemas/manifest.schema.json +41 -0
- loop/_bundle/schemas/receipt.schema.json +21 -0
- loop/_bundle/schemas/repair-record.schema.json +42 -0
- loop/_bundle/schemas/rollout-record.schema.json +27 -0
- loop/_bundle/schemas/state.schema.json +25 -0
- loop/_bundle/schemas/tasks.schema.json +30 -0
- loop/_bundle/schemas/terminal.schema.json +19 -0
- loop/_bundle/templates/AGENTS.md.tmpl +67 -0
- loop/_bundle/templates/EVALS-rubric.md.tmpl +114 -0
- loop/_bundle/templates/RUNLOG.md.tmpl +42 -0
- loop/_bundle/templates/SPEC.md.tmpl +61 -0
- loop/_bundle/templates/TASKS.json.tmpl +23 -0
- loop/_bundle/templates/WORKFLOW.md.tmpl +84 -0
- loop/_bundle/templates/extract-trace-metrics.sh +27 -0
- loop/_bundle/templates/judge-rubric.sh +28 -0
- loop/_bundle/templates/manifest.yaml.tmpl +47 -0
- loop/_bundle/templates/state.json.tmpl +38 -0
- loop/_bundle/templates/terminal_state.json.tmpl +13 -0
- loop/_bundle/templates/verify-fast.sh +27 -0
- loop/_bundle/templates/verify-full.sh +34 -0
- loop/_bundle/templates/verify-safety.sh +35 -0
- loop/_bundle/tools/anticheat_scan.py +490 -0
- loop/_bundle/tools/holdout_gate.py +121 -0
- loop/_bundle/tools/inspect_loop.py +669 -0
- loop/_bundle/tools/metrics.py +725 -0
- loop/_resources.py +43 -0
- loop/contract.py +577 -0
- loop/emit.py +258 -0
- loop/paths.py +77 -0
- loop/scaffold.py +131 -0
- loop_engineer-0.7.0.dist-info/METADATA +470 -0
- loop_engineer-0.7.0.dist-info/RECORD +37 -0
- loop_engineer-0.7.0.dist-info/WHEEL +4 -0
- loop_engineer-0.7.0.dist-info/entry_points.txt +3 -0
- 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:]))
|