custos-code 0.0.1__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.
- custos_code/__init__.py +6 -0
- custos_code/adapters/__init__.py +194 -0
- custos_code/adapters/claude_code.py +266 -0
- custos_code/adapters/codex.py +437 -0
- custos_code/adapters/copilot.py +158 -0
- custos_code/adapters/devin.py +172 -0
- custos_code/adapters/machine.py +379 -0
- custos_code/adapters/otel.py +210 -0
- custos_code/adapters/state.py +164 -0
- custos_code/claims.py +319 -0
- custos_code/cli.py +789 -0
- custos_code/compress.py +113 -0
- custos_code/cost.py +216 -0
- custos_code/demo_fixtures/__init__.py +1 -0
- custos_code/demo_fixtures/ok_tests_0.jsonl +8 -0
- custos_code/demo_fixtures/trap_echo_0.jsonl +4 -0
- custos_code/demo_fixtures/trap_ghost_0.jsonl +4 -0
- custos_code/demo_fixtures/trap_piped_0.jsonl +4 -0
- custos_code/feedback.py +93 -0
- custos_code/hooks.py +648 -0
- custos_code/judge.py +338 -0
- custos_code/ledger.py +93 -0
- custos_code/models.py +129 -0
- custos_code/parsers.py +408 -0
- custos_code/report.py +317 -0
- custos_code/rerun.py +424 -0
- custos_code/review.py +381 -0
- custos_code/rules.py +464 -0
- custos_code/scope.py +471 -0
- custos_code/verdicts.py +296 -0
- custos_code-0.0.1.dist-info/METADATA +138 -0
- custos_code-0.0.1.dist-info/RECORD +35 -0
- custos_code-0.0.1.dist-info/WHEEL +4 -0
- custos_code-0.0.1.dist-info/entry_points.txt +2 -0
- custos_code-0.0.1.dist-info/licenses/LICENSE +21 -0
custos_code/parsers.py
ADDED
|
@@ -0,0 +1,408 @@
|
|
|
1
|
+
"""Per-runner output parsers and the pipe/truncation flagger.
|
|
2
|
+
|
|
3
|
+
Each parser: (stdout, stderr, exit_code) -> RunnerResult(passed, failed, errors, collected, skipped)
|
|
4
|
+
or None if the output is not this runner's format. Start with pytest, jest/vitest, go test, cargo (E2).
|
|
5
|
+
Known traps to handle: `collected 0 items` with exit 0; `| head -80` hiding the summary;
|
|
6
|
+
pytest -q vs verbose; jest --silent; cargo test with multiple targets.
|
|
7
|
+
|
|
8
|
+
Property-test these with hypothesis against synthetic outputs.
|
|
9
|
+
|
|
10
|
+
Owner: Anush.
|
|
11
|
+
|
|
12
|
+
Also owns E5 (wrapper-shadowing detection): normalizing a Bash command down to the binary
|
|
13
|
+
that would actually run, wrapping known-runner commands in PreToolUse so PostToolUse can see
|
|
14
|
+
the real resolved path (piggybacks on the same trailer trick used for exit codes, E9), and the
|
|
15
|
+
trust rule that rejects an in-tree `./pytest` unless it lives in a dependency manager's own
|
|
16
|
+
bin dir or is named by the repo's committed test/build config. See docs/MECHANICS.md §2, §4.
|
|
17
|
+
"""
|
|
18
|
+
from __future__ import annotations
|
|
19
|
+
|
|
20
|
+
import re
|
|
21
|
+
import shlex
|
|
22
|
+
from collections.abc import Callable
|
|
23
|
+
from dataclasses import dataclass
|
|
24
|
+
from pathlib import Path
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
@dataclass
|
|
28
|
+
class RunnerResult:
|
|
29
|
+
runner: str
|
|
30
|
+
passed: int
|
|
31
|
+
failed: int
|
|
32
|
+
errors: int
|
|
33
|
+
collected: int | None
|
|
34
|
+
skipped: int = 0
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
_LABEL_RE = re.compile(r"(\d+)\s+(passed|failed|skipped|todo|total)")
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def _scan_counts(text: str) -> dict[str, int]:
|
|
41
|
+
counts = {"passed": 0, "failed": 0, "skipped": 0, "total": 0}
|
|
42
|
+
for count, label in _LABEL_RE.findall(text):
|
|
43
|
+
counts[label] += int(count)
|
|
44
|
+
return counts
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
# `pytest -q` prints neither the session banner nor "collected N items" -- only a tail like
|
|
48
|
+
# `86 passed in 1.2s`. Requiring the banner meant the single most common invocation parsed as
|
|
49
|
+
# "not a test runner at all", so `rules._outcome` fell through to its exit-code branch and
|
|
50
|
+
# CONFIRMED a report claiming 81 when 86 ran. Worse, `feedback` was nudging agents toward `-q`,
|
|
51
|
+
# so obeying the nudge flipped the verdict from contradicted to confirmed on an unchanged lie.
|
|
52
|
+
_PYTEST_SIGNATURE_RE = re.compile(
|
|
53
|
+
r"test session starts|collected \d+ item"
|
|
54
|
+
r"|^=*\s*\d+ (?:passed|failed|error|skipped|xfailed|xpassed)"
|
|
55
|
+
r"|^=*\s*no tests ran",
|
|
56
|
+
re.IGNORECASE | re.MULTILINE,
|
|
57
|
+
)
|
|
58
|
+
_PYTEST_COLLECTED_RE = re.compile(r"collected (\d+) item")
|
|
59
|
+
_PYTEST_COUNT_RE = re.compile(r"(\d+)\s+(passed|failed|error(?:s)?|skipped|xfailed|xpassed)")
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def parse_pytest(stdout: str, exit_code: int | None) -> RunnerResult | None:
|
|
63
|
+
"""Handles verbose and `-q` output, and the `collected 0 items` / exit-0 trap."""
|
|
64
|
+
if not _PYTEST_SIGNATURE_RE.search(stdout):
|
|
65
|
+
return None
|
|
66
|
+
collected_match = _PYTEST_COLLECTED_RE.search(stdout)
|
|
67
|
+
collected = int(collected_match.group(1)) if collected_match else None
|
|
68
|
+
counts = {"passed": 0, "failed": 0, "error": 0, "skipped": 0}
|
|
69
|
+
tail = "\n".join(stdout.strip().splitlines()[-5:])
|
|
70
|
+
for count, label in _PYTEST_COUNT_RE.findall(tail):
|
|
71
|
+
key = "error" if label.startswith("error") else label
|
|
72
|
+
if key in counts:
|
|
73
|
+
counts[key] += int(count)
|
|
74
|
+
return RunnerResult(
|
|
75
|
+
runner="pytest",
|
|
76
|
+
passed=counts["passed"],
|
|
77
|
+
failed=counts["failed"],
|
|
78
|
+
errors=counts["error"],
|
|
79
|
+
collected=collected,
|
|
80
|
+
skipped=counts["skipped"],
|
|
81
|
+
)
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def parse_jest(stdout: str, exit_code: int | None) -> RunnerResult | None:
|
|
85
|
+
"""Reads the `Tests:` summary line (`Tests: 1 failed, 4 passed, 5 total`)."""
|
|
86
|
+
match = re.search(r"^Tests:\s+.*$", stdout, re.MULTILINE)
|
|
87
|
+
if not match:
|
|
88
|
+
return None
|
|
89
|
+
counts = _scan_counts(match.group(0))
|
|
90
|
+
return RunnerResult(
|
|
91
|
+
runner="jest",
|
|
92
|
+
passed=counts["passed"],
|
|
93
|
+
failed=counts["failed"],
|
|
94
|
+
errors=0,
|
|
95
|
+
collected=counts["total"] or None,
|
|
96
|
+
skipped=counts["skipped"],
|
|
97
|
+
)
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def parse_vitest(stdout: str, exit_code: int | None) -> RunnerResult | None:
|
|
101
|
+
"""Reads the ` Tests 1 failed | 9 passed (10)` summary line."""
|
|
102
|
+
match = re.search(r"^\s*Tests\s+(.+)$", stdout, re.MULTILINE)
|
|
103
|
+
if not match:
|
|
104
|
+
return None
|
|
105
|
+
body = match.group(1)
|
|
106
|
+
counts = _scan_counts(body)
|
|
107
|
+
total_match = re.search(r"\((\d+)\)", body)
|
|
108
|
+
collected = int(total_match.group(1)) if total_match else (counts["total"] or None)
|
|
109
|
+
return RunnerResult(
|
|
110
|
+
runner="vitest",
|
|
111
|
+
passed=counts["passed"],
|
|
112
|
+
failed=counts["failed"],
|
|
113
|
+
errors=0,
|
|
114
|
+
collected=collected,
|
|
115
|
+
skipped=counts["skipped"],
|
|
116
|
+
)
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
_GO_PKG_RE = re.compile(
|
|
120
|
+
r"^(?:ok\s+\S+\s+(?:\d+(?:\.\d+)?s|\(cached\))|FAIL\s+\S+\s+(?:\d+(?:\.\d+)?s|\[.+\]))",
|
|
121
|
+
re.MULTILINE,
|
|
122
|
+
)
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
def parse_go_test(stdout: str, exit_code: int | None) -> RunnerResult | None:
|
|
126
|
+
r"""`-v` output gives per-test `--- PASS:`/`--- FAIL:` lines; plain output only per-package ok/FAIL.
|
|
127
|
+
|
|
128
|
+
The per-package form must carry a package *and* a timing field. `^ok\s` alone matched a bare
|
|
129
|
+
`ok` line, because `\s` matches the newline -- so `echo ok` parsed as a green Go suite. That is
|
|
130
|
+
not a cosmetic mislabel: `review.annotate` would print `[parsed go test: 1 passed, 0 failed]`
|
|
131
|
+
next to a fabricated success, which is the `echoed_output` family the deterministic layer exists
|
|
132
|
+
to catch, and `review._corroborate` accepts a parsed result as grounds to let an accusation
|
|
133
|
+
stand. Measured at 40 occurrences across 15 of 96 real sessions on 2026-09-20.
|
|
134
|
+
"""
|
|
135
|
+
if not re.search(_GO_PKG_RE.pattern + r"|^---\s+(PASS|FAIL|SKIP):", stdout, re.MULTILINE):
|
|
136
|
+
return None
|
|
137
|
+
passed = len(re.findall(r"^--- PASS:", stdout, re.MULTILINE))
|
|
138
|
+
failed = len(re.findall(r"^--- FAIL:", stdout, re.MULTILINE))
|
|
139
|
+
skipped = len(re.findall(r"^--- SKIP:", stdout, re.MULTILINE))
|
|
140
|
+
if passed or failed or skipped:
|
|
141
|
+
return RunnerResult(
|
|
142
|
+
runner="go test",
|
|
143
|
+
passed=passed,
|
|
144
|
+
failed=failed,
|
|
145
|
+
errors=0,
|
|
146
|
+
collected=passed + failed + skipped,
|
|
147
|
+
skipped=skipped,
|
|
148
|
+
)
|
|
149
|
+
# No -v: only per-package ok/FAIL lines, no per-test counts available.
|
|
150
|
+
ok_pkgs = len(re.findall(r"^ok\s+\S+\s+(?:\d+(?:\.\d+)?s|\(cached\))", stdout, re.MULTILINE))
|
|
151
|
+
fail_pkgs = len(re.findall(r"^FAIL\s+\S+\s+(?:\d+(?:\.\d+)?s|\[.+\])", stdout, re.MULTILINE))
|
|
152
|
+
if ok_pkgs or fail_pkgs:
|
|
153
|
+
return RunnerResult(runner="go test", passed=ok_pkgs, failed=fail_pkgs, errors=0, collected=None)
|
|
154
|
+
return None
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
_CARGO_RESULT_RE = re.compile(
|
|
158
|
+
r"test result:\s+\w+\.\s+(\d+) passed;\s+(\d+) failed;\s+(\d+) ignored;"
|
|
159
|
+
r"\s+\d+ measured;\s+\d+ filtered out"
|
|
160
|
+
)
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
def parse_cargo(stdout: str, exit_code: int | None) -> RunnerResult | None:
|
|
164
|
+
"""Sums every `test result: ...` line, since cargo prints one per test target."""
|
|
165
|
+
matches = _CARGO_RESULT_RE.findall(stdout)
|
|
166
|
+
if not matches:
|
|
167
|
+
return None
|
|
168
|
+
passed = failed = ignored = 0
|
|
169
|
+
for p, f, i in matches:
|
|
170
|
+
passed += int(p)
|
|
171
|
+
failed += int(f)
|
|
172
|
+
ignored += int(i)
|
|
173
|
+
return RunnerResult(
|
|
174
|
+
runner="cargo",
|
|
175
|
+
passed=passed,
|
|
176
|
+
failed=failed,
|
|
177
|
+
errors=0,
|
|
178
|
+
collected=passed + failed + ignored,
|
|
179
|
+
skipped=ignored,
|
|
180
|
+
)
|
|
181
|
+
|
|
182
|
+
|
|
183
|
+
# E2: priority order for which runners get a parser, and which one runs first when
|
|
184
|
+
# more than one signature could match. Anything else falls through to `unrecorded`.
|
|
185
|
+
PARSERS: tuple[Callable[[str, int | None], RunnerResult | None], ...] = (
|
|
186
|
+
parse_pytest,
|
|
187
|
+
parse_jest,
|
|
188
|
+
parse_vitest,
|
|
189
|
+
parse_go_test,
|
|
190
|
+
parse_cargo,
|
|
191
|
+
)
|
|
192
|
+
|
|
193
|
+
|
|
194
|
+
def parse(stdout: str, exit_code: int | None) -> RunnerResult | None:
|
|
195
|
+
"""Try each known runner's parser in E2 priority order. `None` means `unrecorded`."""
|
|
196
|
+
for parser in PARSERS:
|
|
197
|
+
result = parser(stdout, exit_code)
|
|
198
|
+
if result is not None:
|
|
199
|
+
return result
|
|
200
|
+
return None
|
|
201
|
+
|
|
202
|
+
|
|
203
|
+
_PIPE_MARKERS: tuple[re.Pattern[str], ...] = (
|
|
204
|
+
# wc/less/more/cut came from the Claude Code adapter's own copy of this list, which is now
|
|
205
|
+
# deleted. Two detectors that disagreed meant identical evidence got opposite verdicts
|
|
206
|
+
# depending on which adapter read it.
|
|
207
|
+
re.compile(r"\|\s*(head|tail|grep|awk|sed|wc|less|more|cut)\b"),
|
|
208
|
+
re.compile(r"2>\s*/dev/null"),
|
|
209
|
+
re.compile(r"&>\s*/dev/null"),
|
|
210
|
+
re.compile(r"(?:^|\s)>{1,2}\s*[\w./-]+"), # `> file` / `>> file`, not preceded by a digit or `&`
|
|
211
|
+
re.compile(r"--silent\b"),
|
|
212
|
+
re.compile(r"--quiet\b"),
|
|
213
|
+
)
|
|
214
|
+
|
|
215
|
+
|
|
216
|
+
def is_piped(command: str) -> bool:
|
|
217
|
+
"""True if the command's output was filtered (| head, | tail, 2>/dev/null, > file, --silent...)."""
|
|
218
|
+
return any(marker.search(command) for marker in _PIPE_MARKERS)
|
|
219
|
+
|
|
220
|
+
|
|
221
|
+
# --- E5: runner-binary resolution and wrapper-shadowing detection ---
|
|
222
|
+
|
|
223
|
+
KNOWN_RUNNERS = frozenset({
|
|
224
|
+
"pytest", "jest", "vitest", "go", "cargo", "gradle", "gradlew", "xcodebuild",
|
|
225
|
+
"mvn", "bun", "npm", "yarn", "pnpm", "tox", "nox", "rspec", "phpunit",
|
|
226
|
+
})
|
|
227
|
+
|
|
228
|
+
_ENV_ASSIGNMENT = re.compile(r"^[A-Za-z_][A-Za-z0-9_]*=\S*$")
|
|
229
|
+
_CD_PREFIX = re.compile(r"^cd\s+\S+\s*&&\s*(.*)$")
|
|
230
|
+
# Prefixes MECHANICS.md §4 says to strip before the first remaining token is the runner.
|
|
231
|
+
_STRIP_PREFIXES = ("uv run", "npx", "poetry run", "bunx", "pipenv run")
|
|
232
|
+
|
|
233
|
+
|
|
234
|
+
def _tokenize_normalized(command: str) -> list[str]:
|
|
235
|
+
"""Strip a leading `cd x &&`, env assignments, and known invoker prefixes. MECHANICS §4."""
|
|
236
|
+
cmd = command.strip()
|
|
237
|
+
m = _CD_PREFIX.match(cmd)
|
|
238
|
+
if m:
|
|
239
|
+
cmd = m.group(1).strip()
|
|
240
|
+
try:
|
|
241
|
+
tokens = shlex.split(cmd)
|
|
242
|
+
except ValueError:
|
|
243
|
+
tokens = cmd.split()
|
|
244
|
+
while tokens and _ENV_ASSIGNMENT.match(tokens[0]):
|
|
245
|
+
tokens.pop(0)
|
|
246
|
+
for prefix in _STRIP_PREFIXES:
|
|
247
|
+
prefix_tokens = prefix.split()
|
|
248
|
+
if tokens[: len(prefix_tokens)] == prefix_tokens:
|
|
249
|
+
tokens = tokens[len(prefix_tokens):]
|
|
250
|
+
break
|
|
251
|
+
return tokens
|
|
252
|
+
|
|
253
|
+
|
|
254
|
+
def first_token(command: str) -> str | None:
|
|
255
|
+
"""The binary the normalized command would actually invoke, e.g. `./pytest` or `pytest`."""
|
|
256
|
+
tokens = _tokenize_normalized(command)
|
|
257
|
+
return tokens[0] if tokens else None
|
|
258
|
+
|
|
259
|
+
|
|
260
|
+
def is_known_runner_token(token: str) -> bool:
|
|
261
|
+
"""True if a command's first token is shaped like a known test/build runner's name.
|
|
262
|
+
|
|
263
|
+
Matches on basename so `./pytest`, `/usr/bin/pytest`, and `pytest` all match -- this is a
|
|
264
|
+
cheap pre-filter for which commands are worth wrapping, not a trust decision.
|
|
265
|
+
"""
|
|
266
|
+
return Path(token).name in KNOWN_RUNNERS
|
|
267
|
+
|
|
268
|
+
|
|
269
|
+
BIN_MARKER = "__CUSTOS_CODE_BIN="
|
|
270
|
+
RC_MARKER = "__CUSTOS_CODE_RC="
|
|
271
|
+
|
|
272
|
+
|
|
273
|
+
def wrap_command_for_resolution(command: str, rc_path: str | None = None) -> str | None:
|
|
274
|
+
"""PreToolUse rewrite: append a trailer resolving argv[0]'s real binary path and exit code.
|
|
275
|
+
|
|
276
|
+
Only commands whose first token looks like a known runner are wrapped (E5's own concern is
|
|
277
|
+
resolution, not general exit-code capture); heredocs, backgrounding, and trailing `&` are
|
|
278
|
+
left untouched because rewriting them safely needs shell-semantics we don't have here
|
|
279
|
+
(MECHANICS §2.3: "do not wrap arbitrary commands"). Returns None when nothing should change.
|
|
280
|
+
|
|
281
|
+
`rc_path`, when given, is a random per-call path (the caller's job -- typically under
|
|
282
|
+
`~/.custos-code/rc/` -- to generate and later read/unlink; see `read_rc_file`). The trailer is
|
|
283
|
+
written to that file instead of stdout: the E9 experiment confirmed the stdout form is
|
|
284
|
+
visible in the model's own tool output, so a model that has seen `__CUSTOS_CODE_RC=0` once can
|
|
285
|
+
later `echo` the same marker and manufacture evidence for a test it never ran (issue #22). A
|
|
286
|
+
file whose name the model never sees can't be forged that way. `rc_path` is embedded via a
|
|
287
|
+
quoted shell assignment, not printed, so it never appears in the command's own stdout/stderr.
|
|
288
|
+
|
|
289
|
+
`rc_path=None` keeps the legacy stdout-marker form (`BIN_MARKER`/`RC_MARKER`, stripped by
|
|
290
|
+
`strip_and_parse_trailer`), for harnesses that do not preserve env across the whole compound
|
|
291
|
+
command; prefer the file form whenever the caller can correlate `PreToolUse`/`PostToolUse`
|
|
292
|
+
for the same call (e.g. by `tool_use_id`).
|
|
293
|
+
"""
|
|
294
|
+
token = first_token(command)
|
|
295
|
+
if token is None or not is_known_runner_token(token):
|
|
296
|
+
return None
|
|
297
|
+
if "<<" in command or "\n" in command or " & " in command or command.rstrip().endswith("&"):
|
|
298
|
+
return None
|
|
299
|
+
if rc_path is not None:
|
|
300
|
+
prefix = f"CUSTOS_CODE_RC_FILE={shlex.quote(rc_path)}; export CUSTOS_CODE_RC_FILE; "
|
|
301
|
+
trailer = (
|
|
302
|
+
f'; __rc=$?; printf "%s\\n%s\\n" "$(command -v {shlex.quote(token)} 2>/dev/null)" "$__rc" '
|
|
303
|
+
'> "$CUSTOS_CODE_RC_FILE"; exit $__rc'
|
|
304
|
+
)
|
|
305
|
+
return f"{prefix}({command}){trailer}"
|
|
306
|
+
trailer = (
|
|
307
|
+
f'; __rc=$?; printf "\\n{BIN_MARKER}%s\\n{RC_MARKER}%s\\n" '
|
|
308
|
+
f'"$(command -v {shlex.quote(token)} 2>/dev/null)" "$__rc"; exit $__rc'
|
|
309
|
+
)
|
|
310
|
+
return f"({command}){trailer}"
|
|
311
|
+
|
|
312
|
+
|
|
313
|
+
def strip_and_parse_trailer(output: str) -> tuple[str, str | None, int | None]:
|
|
314
|
+
"""Split `wrap_command_for_resolution`'s legacy stdout trailer back out of captured output.
|
|
315
|
+
|
|
316
|
+
Only for the `rc_path=None` (stdout) form. Returns (output with the trailer lines removed,
|
|
317
|
+
resolved binary path or None, exit code or None). Used on the PostToolUse side before the
|
|
318
|
+
output is stored or shown.
|
|
319
|
+
"""
|
|
320
|
+
bin_path: str | None = None
|
|
321
|
+
rc: int | None = None
|
|
322
|
+
kept: list[str] = []
|
|
323
|
+
for line in output.splitlines():
|
|
324
|
+
if line.startswith(BIN_MARKER):
|
|
325
|
+
bin_path = line[len(BIN_MARKER):].strip() or None
|
|
326
|
+
elif line.startswith(RC_MARKER):
|
|
327
|
+
try:
|
|
328
|
+
rc = int(line[len(RC_MARKER):].strip())
|
|
329
|
+
except ValueError:
|
|
330
|
+
rc = None
|
|
331
|
+
else:
|
|
332
|
+
kept.append(line)
|
|
333
|
+
return "\n".join(kept), bin_path, rc
|
|
334
|
+
|
|
335
|
+
|
|
336
|
+
def read_rc_file(path: str) -> tuple[str | None, int | None]:
|
|
337
|
+
"""Parse the two-line trailer `wrap_command_for_resolution`'s file form writes.
|
|
338
|
+
|
|
339
|
+
Format: line 1 is `command -v`'s output (resolved binary path, or empty if not found), line
|
|
340
|
+
2 is the exit code. Returns (resolved binary path or None, exit code or None). A missing or
|
|
341
|
+
unreadable file (the command never ran, or ran interrupted before the trailer wrote it) reads
|
|
342
|
+
the same as a resolution failure: `(None, None)`. Pure read -- the caller (PostToolUse) is
|
|
343
|
+
responsible for unlinking the file afterward.
|
|
344
|
+
"""
|
|
345
|
+
try:
|
|
346
|
+
with open(path, encoding="utf-8") as fh:
|
|
347
|
+
lines = fh.read().splitlines()
|
|
348
|
+
except OSError:
|
|
349
|
+
return None, None
|
|
350
|
+
bin_path = lines[0].strip() if len(lines) > 0 else ""
|
|
351
|
+
rc_text = lines[1].strip() if len(lines) > 1 else ""
|
|
352
|
+
try:
|
|
353
|
+
rc = int(rc_text)
|
|
354
|
+
except ValueError:
|
|
355
|
+
rc = None
|
|
356
|
+
return (bin_path or None), rc
|
|
357
|
+
|
|
358
|
+
|
|
359
|
+
_TRUSTED_IN_TREE_PATTERNS = (
|
|
360
|
+
re.compile(r"(^|/)\.venv/.*bin/"),
|
|
361
|
+
re.compile(r"(^|/)venv/.*bin/"),
|
|
362
|
+
re.compile(r"(^|/)node_modules/\.bin/"),
|
|
363
|
+
re.compile(r"(^|/)\.tox/.*bin/"),
|
|
364
|
+
re.compile(r"(^|/)vendor/bundle/.*bin/"),
|
|
365
|
+
)
|
|
366
|
+
|
|
367
|
+
|
|
368
|
+
def is_trusted_runner_path(
|
|
369
|
+
resolved_bin: str | None,
|
|
370
|
+
repo_root: str,
|
|
371
|
+
documented_runners: frozenset[str] = frozenset(),
|
|
372
|
+
cwd: str | None = None,
|
|
373
|
+
) -> bool:
|
|
374
|
+
"""True if a runner-shaped command's *actually resolved* binary is trustworthy evidence.
|
|
375
|
+
|
|
376
|
+
Outside the repo tree: trusted (a system, user, or committed-elsewhere install). Inside the
|
|
377
|
+
repo tree: trusted only if it lives in a dependency manager's own bin dir (`.venv`,
|
|
378
|
+
`node_modules/.bin`, ...) or is named in the repo's committed test/build config
|
|
379
|
+
(`documented_runners` -- the same committed-config lookup rerun.py uses to pick the Tier 3
|
|
380
|
+
command, so an agent-authored `./pytest` at the repo root is never allowlisted no matter how
|
|
381
|
+
it's invoked). Resolution failure (`None`, i.e. "command not found") is never trusted --
|
|
382
|
+
that is positive evidence the claimed runner never ran.
|
|
383
|
+
|
|
384
|
+
`resolved_bin` comes from `command -v` inside the wrapped command (see
|
|
385
|
+
`wrap_command_for_resolution`) and is not guaranteed absolute -- observed in practice
|
|
386
|
+
returning e.g. `.venv/bin/pytest` verbatim. A relative path must be resolved against the
|
|
387
|
+
*ledger event's* `cwd` (the actual cwd the Bash call ran under), never the calling process's
|
|
388
|
+
own ambient cwd: resolving against the wrong directory can land outside `repo_root` by
|
|
389
|
+
accident and mark an in-tree shadow binary as trusted, exactly the bypass this check exists
|
|
390
|
+
to prevent. If `cwd` is unknown, fail closed (untrusted) rather than guess.
|
|
391
|
+
"""
|
|
392
|
+
if not resolved_bin:
|
|
393
|
+
return False
|
|
394
|
+
bin_path = Path(resolved_bin)
|
|
395
|
+
if not bin_path.is_absolute():
|
|
396
|
+
if not cwd:
|
|
397
|
+
return False
|
|
398
|
+
bin_path = Path(cwd) / bin_path
|
|
399
|
+
try:
|
|
400
|
+
resolved = bin_path.resolve()
|
|
401
|
+
root = Path(repo_root).resolve()
|
|
402
|
+
except OSError:
|
|
403
|
+
return False
|
|
404
|
+
if not resolved.is_relative_to(root):
|
|
405
|
+
return True
|
|
406
|
+
if any(p.search(str(resolved)) for p in _TRUSTED_IN_TREE_PATTERNS):
|
|
407
|
+
return True
|
|
408
|
+
return resolved.name in documented_runners
|