@ccoalm/ccl-skills 0.9.0 → 0.10.0
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.
- package/dist/assets/marketplace/plugins/ccl-skills/skills/code-review/references/staged-review-contract.md +5 -0
- package/dist/assets/marketplace/plugins/ccl-skills/skills/product-rd-workflow/SKILL.md +7 -7
- package/dist/assets/marketplace/plugins/ccl-skills/skills/skill-extraction-workflow/SKILL.md +8 -11
- package/dist/assets/marketplace/plugins/ccl-skills/skills/skill-extraction-workflow/references/attention-budget-ratchet.md +37 -0
- package/dist/assets/marketplace/plugins/ccl-skills/skills/skill-extraction-workflow/references/description-authoring.md +9 -0
- package/dist/assets/marketplace/plugins/ccl-skills/skills/skill-extraction-workflow/references/dual-track-review-gate.md +29 -31
- package/dist/assets/marketplace/plugins/ccl-skills/skills/skill-extraction-workflow/references/eval-routing.md +24 -3
- package/dist/assets/marketplace/plugins/ccl-skills/skills/skill-extraction-workflow/references/extraction-quickstart.md +5 -5
- package/dist/assets/marketplace/plugins/ccl-skills/skills/skill-extraction-workflow/references/rule-consolidation.md +1 -1
- package/dist/assets/marketplace/plugins/ccl-skills/skills/skill-extraction-workflow/references/source-register.md +32 -0
- package/dist/assets/marketplace/plugins/ccl-skills/skills/skill-extraction-workflow/references/validation-and-landing.md +1 -1
- package/dist/assets/marketplace/plugins/ccl-skills/skills/skill-extraction-workflow/scripts/check-ccl-skills.sh +30 -0
- package/dist/assets/marketplace/plugins/ccl-skills/skills/skill-extraction-workflow/scripts/check-contract-anchors.sh +126 -0
- package/dist/assets/marketplace/plugins/ccl-skills/skills/skill-extraction-workflow/scripts/check-size-budget.sh +197 -1
- package/dist/assets/marketplace/plugins/ccl-skills/skills/skill-extraction-workflow/scripts/contract-anchors.tsv +15 -0
- package/dist/assets/marketplace/plugins/ccl-skills/skills/skill-extraction-workflow/scripts/eval-routing-bank.rb +210 -36
- package/dist/assets/marketplace/plugins/ccl-skills/skills/skill-extraction-workflow/scripts/extraction_review_gate.sh +3 -3
- package/dist/assets/marketplace/plugins/ccl-skills/skills/skill-extraction-workflow/scripts/gate_receipt.py +576 -0
- package/dist/assets/marketplace/plugins/ccl-skills/skills/skill-extraction-workflow/scripts/test_antipattern_grep_panel.sh +80 -0
- package/dist/assets/marketplace/plugins/ccl-skills/skills/skill-extraction-workflow/scripts/test_body_compliance_grading.sh +99 -0
- package/dist/assets/marketplace/plugins/ccl-skills/skills/skill-extraction-workflow/scripts/test_check_ccl_regressions.sh +25 -0
- package/dist/assets/marketplace/plugins/ccl-skills/skills/skill-extraction-workflow/scripts/test_check_ccl_size_budget.sh +251 -0
- package/dist/assets/marketplace/plugins/ccl-skills/skills/skill-extraction-workflow/scripts/test_check_contract_anchors.sh +196 -0
- package/dist/assets/marketplace/plugins/ccl-skills/skills/skill-extraction-workflow/scripts/test_eval_routing_bank_grader_diagnostics.sh +222 -0
- package/dist/assets/marketplace/plugins/ccl-skills/skills/skill-extraction-workflow/scripts/test_extraction_review_gate.sh +16 -10
- package/dist/assets/marketplace/plugins/ccl-skills/skills/skill-extraction-workflow/scripts/test_frozen_case_sanctity.sh +178 -0
- package/dist/assets/marketplace/plugins/ccl-skills/skills/skill-extraction-workflow/scripts/test_frozen_case_sanctity_selfproof.sh +108 -0
- package/dist/assets/marketplace/plugins/ccl-skills/skills/skill-extraction-workflow/scripts/test_gate_receipt.sh +431 -0
- package/dist/assets/marketplace/plugins/ccl-skills/skills/skill-extraction-workflow/scripts/test_pinned_phrase_mutation_walk.sh +151 -0
- package/dist/assets/marketplace/plugins/ccl-skills/skills/skill-extraction-workflow/scripts/test_routing_bank_integrity.sh +86 -5
- package/dist/assets/marketplace/plugins/ccl-skills/skills/skill-extraction-workflow/scripts/test_validate_extraction_review_state.sh +27 -21
- package/dist/assets/marketplace/plugins/ccl-skills/skills/skill-extraction-workflow/scripts/validate_extraction_review_state.py +25 -15
- package/dist/assets/release.json +79 -24
- package/package.json +1 -1
|
@@ -0,0 +1,576 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Mint and verify candidate-SHA-bound receipts of deterministic-gate output.
|
|
3
|
+
|
|
4
|
+
A gate receipt binds one deterministic command run to the exact committed
|
|
5
|
+
candidate state it ran against, so a historical-process claim ("the suite was
|
|
6
|
+
RED before the fix", "heavy lane was green at landing") can ride the review
|
|
7
|
+
packet as falsifiable evidence instead of implementer testimony.
|
|
8
|
+
|
|
9
|
+
Trust model (deliberately narrow — do not oversell in referencing surfaces):
|
|
10
|
+
- A receipt is candidate-bound, re-runnable CONSISTENCY evidence: anyone at
|
|
11
|
+
the recorded commit can re-execute the command and compare exit code and
|
|
12
|
+
output hash (`verify --rerun`).
|
|
13
|
+
- A receipt does NOT authenticate who ran the command or that the recorded
|
|
14
|
+
output was not fabricated; the minter and the candidate author are the same
|
|
15
|
+
party. Deterministic authority stays with CI re-running the gate on the
|
|
16
|
+
actual branch. A receipt upgrades a process claim from "unverifiable" to
|
|
17
|
+
"falsifiable"; it never substitutes for the CI lane.
|
|
18
|
+
- Minting refuses a dirty tree: an uncommitted candidate has no stable SHA to
|
|
19
|
+
bind, and a receipt minted against drifting files would be unfalsifiable.
|
|
20
|
+
|
|
21
|
+
Commands run as given (argv, no shell). Receipts of RED runs are first-class:
|
|
22
|
+
`mint` records the exit code, it does not require success — a pre-fix RED
|
|
23
|
+
receipt is the canonical use case.
|
|
24
|
+
|
|
25
|
+
Usage:
|
|
26
|
+
gate_receipt.py mint --out FILE [--tail-bytes N] [--timeout SECONDS] -- CMD [ARG...]
|
|
27
|
+
gate_receipt.py verify FILE [--rerun [--exit-only] -- CMD [ARG...]]
|
|
28
|
+
|
|
29
|
+
A receipt is untrusted data, so `verify --rerun` NEVER executes the recorded
|
|
30
|
+
argv: the verifier supplies the command they intend to re-run, and the tool
|
|
31
|
+
compares it against the recorded argv (mismatch is a named rc 1) before
|
|
32
|
+
executing the verifier's own words. A hostile receipt can therefore misdescribe
|
|
33
|
+
a gate but cannot make the verifier run anything they did not type.
|
|
34
|
+
|
|
35
|
+
The receipt records the repository-relative working directory and re-runs
|
|
36
|
+
execute from it (same argv elsewhere is a different command). The candidate is
|
|
37
|
+
re-read after the run on both sides; HEAD or cleanliness moving mid-run refuses
|
|
38
|
+
a result. The output tail is OFF by default (--tail-bytes 0): exit code plus
|
|
39
|
+
output hash suffice for verification, and a verbatim excerpt would copy
|
|
40
|
+
whatever the gate printed — including a leaked token — into the ledger; opt in
|
|
41
|
+
deliberately, and receipts are created 0600.
|
|
42
|
+
|
|
43
|
+
Exit codes: 0 ok; 1 receipt fails verification (structural or rerun mismatch,
|
|
44
|
+
named reason on stderr); 2 usage/environment error (not a verdict on the
|
|
45
|
+
receipt: dirty tree, wrong checked-out candidate, timeout, bad invocation).
|
|
46
|
+
"""
|
|
47
|
+
|
|
48
|
+
from __future__ import annotations
|
|
49
|
+
|
|
50
|
+
import argparse
|
|
51
|
+
import hashlib
|
|
52
|
+
import json
|
|
53
|
+
import os
|
|
54
|
+
import re
|
|
55
|
+
import signal
|
|
56
|
+
import subprocess
|
|
57
|
+
import sys
|
|
58
|
+
import threading
|
|
59
|
+
import time
|
|
60
|
+
from datetime import datetime, timezone
|
|
61
|
+
from pathlib import Path
|
|
62
|
+
|
|
63
|
+
SCHEMA_VERSION = 1
|
|
64
|
+
KIND = "gate-receipt"
|
|
65
|
+
RECEIPT_KEYS = {
|
|
66
|
+
"schema_version",
|
|
67
|
+
"kind",
|
|
68
|
+
"candidate_commit",
|
|
69
|
+
"tree_clean",
|
|
70
|
+
"cwd",
|
|
71
|
+
"tail_bytes",
|
|
72
|
+
"command",
|
|
73
|
+
"exit_code",
|
|
74
|
+
"output_bytes",
|
|
75
|
+
"output_sha256",
|
|
76
|
+
"output_tail",
|
|
77
|
+
"minted_at",
|
|
78
|
+
}
|
|
79
|
+
MAX_RECEIPT_BYTES = 65536
|
|
80
|
+
MAX_TAIL_BYTES = 16384
|
|
81
|
+
COMMIT_RE = re.compile(r"^[0-9a-f]{40}([0-9a-f]{24})?$")
|
|
82
|
+
SHA256_RE = re.compile(r"^[0-9a-f]{64}$")
|
|
83
|
+
RFC3339_RE = re.compile(r"^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(\.\d+)?(Z|[+-]\d{2}:\d{2})$")
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def infra(message: str) -> "int":
|
|
87
|
+
print(f"gate_receipt_error: {message}", file=sys.stderr)
|
|
88
|
+
return 2
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def invalid(message: str) -> int:
|
|
92
|
+
print(f"gate_receipt_invalid: {message}", file=sys.stderr)
|
|
93
|
+
return 1
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def git_output(args: list[str]) -> str:
|
|
97
|
+
# Bytes in, tolerant decode out: with core.quotePath=false an untracked
|
|
98
|
+
# filename holding invalid UTF-8 would make text-mode decoding raise, and
|
|
99
|
+
# an uncaught decode error exits with the status reserved for a failed
|
|
100
|
+
# receipt verdict. The callers only compare/strip this output, so
|
|
101
|
+
# replacement characters are safe — non-empty stays non-empty.
|
|
102
|
+
result = subprocess.run(["git", *args], capture_output=True, check=False)
|
|
103
|
+
if result.returncode != 0:
|
|
104
|
+
stderr = result.stderr.decode("utf-8", errors="replace").strip()
|
|
105
|
+
raise RuntimeError(f"git {' '.join(args)} failed: {stderr}")
|
|
106
|
+
return result.stdout.decode("utf-8", errors="replace")
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def candidate_state() -> tuple[str, bool]:
|
|
110
|
+
head = git_output(["rev-parse", "HEAD"]).strip()
|
|
111
|
+
# Explicit flags: a repo config of status.showUntrackedFiles=no (or a
|
|
112
|
+
# submodule-ignoring setting) would otherwise report clean while untracked
|
|
113
|
+
# gate inputs exist — content absent from the recorded commit.
|
|
114
|
+
porcelain = git_output(
|
|
115
|
+
["status", "--porcelain", "--untracked-files=all", "--ignore-submodules=none"]
|
|
116
|
+
)
|
|
117
|
+
return head, porcelain == ""
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
def repo_relative_cwd() -> str:
|
|
121
|
+
"""Current directory relative to the repository toplevel ('.' at the root).
|
|
122
|
+
|
|
123
|
+
Recorded in the receipt so a re-run executes the command from the same
|
|
124
|
+
place — the same argv from a different directory is a different command.
|
|
125
|
+
"""
|
|
126
|
+
top = git_output(["rev-parse", "--show-toplevel"]).strip()
|
|
127
|
+
rel = os.path.relpath(os.getcwd(), top)
|
|
128
|
+
if rel == ".." or rel.startswith(".." + os.sep):
|
|
129
|
+
raise RuntimeError("working directory escapes the repository toplevel")
|
|
130
|
+
return rel
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
def bounded_utf8_tail(tail: bytes, budget: int) -> str:
|
|
134
|
+
"""Decode with replacement, then trim from the FRONT until the encoded
|
|
135
|
+
UTF-8 size fits the budget — replacement characters can expand invalid
|
|
136
|
+
bytes threefold, and a receipt whose own tail fails the verifier's size
|
|
137
|
+
check would be minted broken."""
|
|
138
|
+
text = tail.decode("utf-8", errors="replace")
|
|
139
|
+
while text and len(text.encode("utf-8")) > budget:
|
|
140
|
+
overshoot = len(text.encode("utf-8")) - budget
|
|
141
|
+
text = text[max(1, overshoot // 4):]
|
|
142
|
+
return text
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
def normalize_exit(code: int) -> int:
|
|
146
|
+
"""Map a signal-terminated child (negative Popen returncode) to the shell
|
|
147
|
+
convention 128+N, so a legitimately RED signal-killed gate still yields a
|
|
148
|
+
structurally valid receipt (0..255) and re-runs compare consistently."""
|
|
149
|
+
if code < 0:
|
|
150
|
+
return 128 + (-code)
|
|
151
|
+
return code & 0xFF
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
def run_and_capture(command: list[str], tail_bytes: int, timeout: int, cwd: str | None = None):
|
|
155
|
+
"""Run argv, stream-hash combined stdout+stderr, keep a bounded tail.
|
|
156
|
+
|
|
157
|
+
The child gets its own process group and /dev/null stdin; a reader thread
|
|
158
|
+
drains the pipe while the main thread holds the deadline, so a silent
|
|
159
|
+
hanging gate (or a descendant keeping the pipe open) cannot block past
|
|
160
|
+
--timeout — on expiry the whole group is killed and no result is returned.
|
|
161
|
+
"""
|
|
162
|
+
digest = hashlib.sha256()
|
|
163
|
+
tail = bytearray()
|
|
164
|
+
total = 0
|
|
165
|
+
proc = subprocess.Popen(
|
|
166
|
+
command,
|
|
167
|
+
stdin=subprocess.DEVNULL,
|
|
168
|
+
stdout=subprocess.PIPE,
|
|
169
|
+
stderr=subprocess.STDOUT,
|
|
170
|
+
start_new_session=True,
|
|
171
|
+
cwd=cwd,
|
|
172
|
+
)
|
|
173
|
+
assert proc.stdout is not None
|
|
174
|
+
|
|
175
|
+
def drain() -> None:
|
|
176
|
+
nonlocal total
|
|
177
|
+
while True:
|
|
178
|
+
chunk = proc.stdout.read(65536)
|
|
179
|
+
if not chunk:
|
|
180
|
+
break
|
|
181
|
+
digest.update(chunk)
|
|
182
|
+
total += len(chunk)
|
|
183
|
+
tail.extend(chunk)
|
|
184
|
+
if len(tail) > tail_bytes:
|
|
185
|
+
del tail[: len(tail) - tail_bytes]
|
|
186
|
+
|
|
187
|
+
reader = threading.Thread(target=drain, daemon=True)
|
|
188
|
+
reader.start()
|
|
189
|
+
deadline = time.monotonic() + timeout
|
|
190
|
+
|
|
191
|
+
def kill_group() -> None:
|
|
192
|
+
try:
|
|
193
|
+
os.killpg(proc.pid, signal.SIGKILL)
|
|
194
|
+
except (ProcessLookupError, PermissionError):
|
|
195
|
+
proc.kill()
|
|
196
|
+
|
|
197
|
+
try:
|
|
198
|
+
exit_code = proc.wait(timeout=timeout)
|
|
199
|
+
except subprocess.TimeoutExpired:
|
|
200
|
+
kill_group()
|
|
201
|
+
proc.wait()
|
|
202
|
+
reader.join(timeout=10)
|
|
203
|
+
raise
|
|
204
|
+
# The child exited; the pipe closes only when every writer (including any
|
|
205
|
+
# surviving descendant) has closed it. Give the reader the remaining
|
|
206
|
+
# deadline, then treat a still-open pipe as a timeout: output would be
|
|
207
|
+
# incomplete, so no receipt/verdict may be produced from it.
|
|
208
|
+
reader.join(timeout=max(0.0, deadline - time.monotonic()))
|
|
209
|
+
if reader.is_alive():
|
|
210
|
+
kill_group()
|
|
211
|
+
reader.join(timeout=10)
|
|
212
|
+
raise subprocess.TimeoutExpired(command, timeout)
|
|
213
|
+
return normalize_exit(exit_code), total, digest.hexdigest(), bytes(tail)
|
|
214
|
+
|
|
215
|
+
|
|
216
|
+
def mint(args: argparse.Namespace) -> int:
|
|
217
|
+
out = Path(args.out)
|
|
218
|
+
if not args.command:
|
|
219
|
+
return infra("mint requires a command after --")
|
|
220
|
+
if not (0 <= args.tail_bytes <= MAX_TAIL_BYTES):
|
|
221
|
+
return infra(f"--tail-bytes must be in 0..{MAX_TAIL_BYTES}")
|
|
222
|
+
try:
|
|
223
|
+
head, clean = candidate_state()
|
|
224
|
+
rel_cwd = repo_relative_cwd()
|
|
225
|
+
top = git_output(["rev-parse", "--show-toplevel"]).strip()
|
|
226
|
+
except RuntimeError as exc:
|
|
227
|
+
return infra(str(exc))
|
|
228
|
+
# The documented contract is receipts-outside-the-candidate-tree; enforce
|
|
229
|
+
# it instead of trusting it. An in-tree receipt dirties the tree AFTER the
|
|
230
|
+
# cleanliness checks ran (created last), so it would mint "successfully"
|
|
231
|
+
# and then fail every re-run as dirty_tree — and a path under .git could
|
|
232
|
+
# mutate repository metadata porcelain status never shows.
|
|
233
|
+
out_abs = os.path.realpath(os.path.join(os.getcwd(), str(out)))
|
|
234
|
+
top_real = os.path.realpath(top)
|
|
235
|
+
if os.path.commonpath([top_real, out_abs]) == top_real:
|
|
236
|
+
return infra(
|
|
237
|
+
f"--out {out} resolves inside the candidate repository; "
|
|
238
|
+
"write receipts OUTSIDE the tree (e.g. the chain ledger directory)"
|
|
239
|
+
)
|
|
240
|
+
if not clean:
|
|
241
|
+
return infra(
|
|
242
|
+
"tree not clean — a receipt binds to a committed candidate; "
|
|
243
|
+
"commit first, then mint (and write receipts OUTSIDE the candidate "
|
|
244
|
+
"tree, e.g. the chain ledger directory — an in-tree receipt dirties "
|
|
245
|
+
"the tree it binds)"
|
|
246
|
+
)
|
|
247
|
+
try:
|
|
248
|
+
exit_code, total, output_sha, tail = run_and_capture(
|
|
249
|
+
args.command, args.tail_bytes, args.timeout
|
|
250
|
+
)
|
|
251
|
+
except FileNotFoundError as exc:
|
|
252
|
+
return infra(f"command not found: {exc}")
|
|
253
|
+
except subprocess.TimeoutExpired:
|
|
254
|
+
return infra(f"command exceeded --timeout {args.timeout}s; no receipt minted")
|
|
255
|
+
# Re-read the candidate AFTER the run: HEAD or cleanliness moving mid-run
|
|
256
|
+
# (a concurrent checkout/rebase, or the gate itself committing) means the
|
|
257
|
+
# output belongs to no single candidate — refuse rather than mislabel.
|
|
258
|
+
try:
|
|
259
|
+
head_after, clean_after = candidate_state()
|
|
260
|
+
except RuntimeError as exc:
|
|
261
|
+
return infra(str(exc))
|
|
262
|
+
if head_after != head or not clean_after:
|
|
263
|
+
return infra(
|
|
264
|
+
"candidate changed during the run "
|
|
265
|
+
f"(HEAD {head} -> {head_after}, clean={clean_after}); no receipt minted"
|
|
266
|
+
)
|
|
267
|
+
receipt = {
|
|
268
|
+
"schema_version": SCHEMA_VERSION,
|
|
269
|
+
"kind": KIND,
|
|
270
|
+
"candidate_commit": head,
|
|
271
|
+
"tree_clean": True,
|
|
272
|
+
"cwd": rel_cwd,
|
|
273
|
+
"command": list(args.command),
|
|
274
|
+
"exit_code": exit_code,
|
|
275
|
+
"output_bytes": total,
|
|
276
|
+
"output_sha256": output_sha,
|
|
277
|
+
"tail_bytes": args.tail_bytes,
|
|
278
|
+
# Off by default: exit code + output hash suffice for verification, and
|
|
279
|
+
# a verbatim tail would copy whatever the gate printed — including a
|
|
280
|
+
# leaked token — into ledger/packet-adjacent evidence. Opt in with
|
|
281
|
+
# --tail-bytes N when the excerpt is genuinely needed.
|
|
282
|
+
"output_tail": bounded_utf8_tail(tail, args.tail_bytes) if args.tail_bytes else "",
|
|
283
|
+
"minted_at": datetime.now(timezone.utc).isoformat(timespec="seconds"),
|
|
284
|
+
}
|
|
285
|
+
encoded = json.dumps(
|
|
286
|
+
receipt, ensure_ascii=False, sort_keys=True, indent=1
|
|
287
|
+
).encode("utf-8")
|
|
288
|
+
if len(encoded) + 1 > MAX_RECEIPT_BYTES:
|
|
289
|
+
return infra(
|
|
290
|
+
f"receipt would be {len(encoded) + 1} bytes, over the "
|
|
291
|
+
f"{MAX_RECEIPT_BYTES} cap its own verifier enforces (oversized "
|
|
292
|
+
"argv?); no receipt minted"
|
|
293
|
+
)
|
|
294
|
+
# Re-check containment AFTER the gate ran: a parent directory swapped to a
|
|
295
|
+
# symlink into the repository between the pre-run check and this write
|
|
296
|
+
# would otherwise land the receipt in-tree (O_NOFOLLOW guards only the
|
|
297
|
+
# final component). A swap in the instant between this check and os.open
|
|
298
|
+
# remains possible; anything able to race writes in the ledger directory
|
|
299
|
+
# could already rewrite receipts, so that residue is inside the existing
|
|
300
|
+
# trust boundary.
|
|
301
|
+
out_abs = os.path.realpath(os.path.join(os.getcwd(), str(out)))
|
|
302
|
+
if os.path.commonpath([top_real, out_abs]) == top_real:
|
|
303
|
+
return infra(
|
|
304
|
+
f"--out {out} resolves inside the candidate repository after the "
|
|
305
|
+
"run (parent directory changed?); no receipt minted"
|
|
306
|
+
)
|
|
307
|
+
# Write a unique 0600 temp file in the destination directory, then publish
|
|
308
|
+
# with a no-overwrite atomic link. A failed/partial write therefore never
|
|
309
|
+
# occupies the final name (O_EXCL retries stay possible), and existence of
|
|
310
|
+
# the final name is decided by the same syscall that creates it.
|
|
311
|
+
tmp = Path(f"{out}.tmp.{os.getpid()}")
|
|
312
|
+
open_flags = os.O_WRONLY | os.O_CREAT | os.O_EXCL
|
|
313
|
+
open_flags |= getattr(os, "O_NOFOLLOW", 0)
|
|
314
|
+
try:
|
|
315
|
+
fd = os.open(tmp, open_flags, 0o600)
|
|
316
|
+
except OSError as exc:
|
|
317
|
+
return infra(f"cannot create temp receipt {tmp}: {exc}")
|
|
318
|
+
publish_error: str | None = None
|
|
319
|
+
try:
|
|
320
|
+
with os.fdopen(fd, "wb") as handle:
|
|
321
|
+
handle.write(encoded + b"\n")
|
|
322
|
+
try:
|
|
323
|
+
os.link(tmp, out)
|
|
324
|
+
except FileExistsError:
|
|
325
|
+
publish_error = f"--out {out} already exists; receipts are never overwritten"
|
|
326
|
+
except OSError as exc:
|
|
327
|
+
publish_error = f"cannot publish receipt {out}: {exc}"
|
|
328
|
+
except OSError as exc:
|
|
329
|
+
publish_error = f"failed to write receipt {tmp}: {exc}"
|
|
330
|
+
finally:
|
|
331
|
+
try:
|
|
332
|
+
os.unlink(tmp)
|
|
333
|
+
except OSError as exc:
|
|
334
|
+
# Report honestly instead of claiming a clean state.
|
|
335
|
+
cleanup_note = f"; temp file {tmp} could not be removed: {exc}"
|
|
336
|
+
publish_error = (publish_error or "receipt published") + cleanup_note
|
|
337
|
+
if publish_error.startswith("receipt published"):
|
|
338
|
+
print(f"gate_receipt_warning: {cleanup_note.lstrip('; ')}", file=sys.stderr)
|
|
339
|
+
publish_error = None
|
|
340
|
+
if publish_error:
|
|
341
|
+
return infra(publish_error)
|
|
342
|
+
receipt_sha = hashlib.sha256(encoded + b"\n").hexdigest()
|
|
343
|
+
print(
|
|
344
|
+
f"gate_receipt_minted: {out} sha256={receipt_sha} "
|
|
345
|
+
f"candidate={head} exit={exit_code}"
|
|
346
|
+
)
|
|
347
|
+
return 0
|
|
348
|
+
|
|
349
|
+
|
|
350
|
+
def load_receipt(path: Path) -> dict | int:
|
|
351
|
+
"""Return the parsed receipt dict, or an int exit code on failure."""
|
|
352
|
+
if path.is_symlink() or not path.is_file():
|
|
353
|
+
return infra(f"{path} must be a regular non-linked file")
|
|
354
|
+
# Bounded no-follow read: never pull an oversized file into memory just to
|
|
355
|
+
# discover it is over the cap.
|
|
356
|
+
read_flags = os.O_RDONLY | getattr(os, "O_NOFOLLOW", 0)
|
|
357
|
+
try:
|
|
358
|
+
fd = os.open(path, read_flags)
|
|
359
|
+
except OSError as exc:
|
|
360
|
+
return infra(f"cannot open {path}: {exc}")
|
|
361
|
+
with os.fdopen(fd, "rb") as handle:
|
|
362
|
+
raw = handle.read(MAX_RECEIPT_BYTES + 1)
|
|
363
|
+
if len(raw) > MAX_RECEIPT_BYTES:
|
|
364
|
+
return invalid(f"receipt exceeds {MAX_RECEIPT_BYTES} bytes")
|
|
365
|
+
try:
|
|
366
|
+
payload = json.loads(raw.decode("utf-8"))
|
|
367
|
+
except (UnicodeError, json.JSONDecodeError) as exc:
|
|
368
|
+
return invalid(f"receipt is not valid UTF-8 JSON: {exc}")
|
|
369
|
+
if not isinstance(payload, dict) or set(payload) != RECEIPT_KEYS:
|
|
370
|
+
return invalid("receipt must contain exactly the gate-receipt key set")
|
|
371
|
+
if payload["schema_version"] != SCHEMA_VERSION or type(payload["schema_version"]) is not int:
|
|
372
|
+
return invalid("schema_version must be 1")
|
|
373
|
+
if payload["kind"] != KIND:
|
|
374
|
+
return invalid("kind must be gate-receipt")
|
|
375
|
+
if not isinstance(payload["candidate_commit"], str) or not COMMIT_RE.match(
|
|
376
|
+
payload["candidate_commit"]
|
|
377
|
+
):
|
|
378
|
+
return invalid("candidate_commit must be a full lowercase git commit hash")
|
|
379
|
+
if payload["tree_clean"] is not True:
|
|
380
|
+
return invalid("tree_clean must be true — a dirty-tree receipt binds nothing")
|
|
381
|
+
rel_cwd = payload["cwd"]
|
|
382
|
+
if (
|
|
383
|
+
not isinstance(rel_cwd, str)
|
|
384
|
+
or not rel_cwd
|
|
385
|
+
or os.path.isabs(rel_cwd)
|
|
386
|
+
or ".." in rel_cwd.split("/")
|
|
387
|
+
):
|
|
388
|
+
return invalid("cwd must be a repository-relative path with no parent escapes")
|
|
389
|
+
command = payload["command"]
|
|
390
|
+
if (
|
|
391
|
+
not isinstance(command, list)
|
|
392
|
+
or not command
|
|
393
|
+
or not all(isinstance(part, str) for part in command)
|
|
394
|
+
or not command[0]
|
|
395
|
+
):
|
|
396
|
+
return invalid(
|
|
397
|
+
"command must be a non-empty list of strings with a non-empty "
|
|
398
|
+
"executable (later arguments may legitimately be empty)"
|
|
399
|
+
)
|
|
400
|
+
if type(payload["exit_code"]) is not int or not (0 <= payload["exit_code"] <= 255):
|
|
401
|
+
return invalid("exit_code must be an integer in 0..255")
|
|
402
|
+
if type(payload["output_bytes"]) is not int or payload["output_bytes"] < 0:
|
|
403
|
+
return invalid("output_bytes must be a non-negative integer")
|
|
404
|
+
if not isinstance(payload["output_sha256"], str) or not SHA256_RE.match(
|
|
405
|
+
payload["output_sha256"]
|
|
406
|
+
):
|
|
407
|
+
return invalid("output_sha256 must be 64 lowercase hex chars")
|
|
408
|
+
tail_budget = payload["tail_bytes"]
|
|
409
|
+
if type(tail_budget) is not int or not (0 <= tail_budget <= MAX_TAIL_BYTES):
|
|
410
|
+
return invalid(f"tail_bytes must be an integer in 0..{MAX_TAIL_BYTES}")
|
|
411
|
+
tail = payload["output_tail"]
|
|
412
|
+
if not isinstance(tail, str) or len(tail.encode("utf-8")) > MAX_TAIL_BYTES:
|
|
413
|
+
return invalid(f"output_tail must be a string of at most {MAX_TAIL_BYTES} UTF-8 bytes")
|
|
414
|
+
if tail_budget == 0 and tail:
|
|
415
|
+
return invalid("output_tail must be empty when tail_bytes is 0")
|
|
416
|
+
if not isinstance(payload["minted_at"], str) or not RFC3339_RE.match(
|
|
417
|
+
payload["minted_at"]
|
|
418
|
+
):
|
|
419
|
+
return invalid("minted_at must be an RFC3339 timestamp")
|
|
420
|
+
return payload
|
|
421
|
+
|
|
422
|
+
|
|
423
|
+
def verify(args: argparse.Namespace) -> int:
|
|
424
|
+
path = Path(args.receipt)
|
|
425
|
+
loaded = load_receipt(path)
|
|
426
|
+
if isinstance(loaded, int):
|
|
427
|
+
return loaded
|
|
428
|
+
if not args.rerun:
|
|
429
|
+
if args.exit_only:
|
|
430
|
+
return infra("--exit-only requires --rerun")
|
|
431
|
+
if args.command:
|
|
432
|
+
return infra("a command after -- requires --rerun")
|
|
433
|
+
print(f"gate_receipt_structural_ok: {path}")
|
|
434
|
+
return 0
|
|
435
|
+
# The receipt is untrusted input: never execute its recorded argv. The
|
|
436
|
+
# verifier states the command; a mismatch against the record is a named
|
|
437
|
+
# verification failure, and only the verifier-typed argv ever runs.
|
|
438
|
+
supplied = list(args.command)
|
|
439
|
+
if not supplied:
|
|
440
|
+
return infra(
|
|
441
|
+
"--rerun requires the expected gate command after -- ; "
|
|
442
|
+
"re-running the receipt's own recorded argv would execute "
|
|
443
|
+
"candidate-controlled input"
|
|
444
|
+
)
|
|
445
|
+
if supplied != loaded["command"]:
|
|
446
|
+
return invalid(
|
|
447
|
+
"command_mismatch: receipt records "
|
|
448
|
+
f"{loaded['command']!r}, verifier supplied {supplied!r}"
|
|
449
|
+
)
|
|
450
|
+
try:
|
|
451
|
+
head, clean = candidate_state()
|
|
452
|
+
except RuntimeError as exc:
|
|
453
|
+
return infra(str(exc))
|
|
454
|
+
if head != loaded["candidate_commit"]:
|
|
455
|
+
return infra(
|
|
456
|
+
f"wrong_candidate: HEAD is {head}, receipt binds "
|
|
457
|
+
f"{loaded['candidate_commit']} — check out the recorded commit to re-run"
|
|
458
|
+
)
|
|
459
|
+
if not clean:
|
|
460
|
+
return infra("dirty_tree: re-run verification requires a clean tree")
|
|
461
|
+
try:
|
|
462
|
+
top = git_output(["rev-parse", "--show-toplevel"]).strip()
|
|
463
|
+
except RuntimeError as exc:
|
|
464
|
+
return infra(str(exc))
|
|
465
|
+
rundir = os.path.normpath(os.path.join(top, loaded["cwd"]))
|
|
466
|
+
# The recorded cwd is untrusted receipt data: resolve it and require the
|
|
467
|
+
# REAL path to stay inside the repository — a committed in-repo symlink
|
|
468
|
+
# pointing outside would otherwise make the verifier's relative argv
|
|
469
|
+
# execute from an attacker-chosen external directory.
|
|
470
|
+
top_real = os.path.realpath(top)
|
|
471
|
+
rundir_real = os.path.realpath(rundir)
|
|
472
|
+
if os.path.commonpath([top_real, rundir_real]) != top_real:
|
|
473
|
+
return infra(
|
|
474
|
+
f"recorded cwd resolves outside the repository: {loaded['cwd']}"
|
|
475
|
+
)
|
|
476
|
+
if not os.path.isdir(rundir_real):
|
|
477
|
+
return infra(f"recorded cwd does not exist in this checkout: {loaded['cwd']}")
|
|
478
|
+
try:
|
|
479
|
+
# Capture the tail with the RECORDED budget so the reconstruction
|
|
480
|
+
# below runs the exact pipeline mint ran — identical output bytes then
|
|
481
|
+
# reconstruct byte-identically, with no window-shape divergence.
|
|
482
|
+
exit_code, total, output_sha, observed_tail = run_and_capture(
|
|
483
|
+
supplied, loaded["tail_bytes"], args.timeout, cwd=rundir
|
|
484
|
+
)
|
|
485
|
+
except FileNotFoundError as exc:
|
|
486
|
+
return infra(f"command not found: {exc}")
|
|
487
|
+
except subprocess.TimeoutExpired:
|
|
488
|
+
return infra(f"re-run exceeded --timeout {args.timeout}s; no verdict")
|
|
489
|
+
try:
|
|
490
|
+
head_after, clean_after = candidate_state()
|
|
491
|
+
except RuntimeError as exc:
|
|
492
|
+
return infra(str(exc))
|
|
493
|
+
if head_after != head or not clean_after:
|
|
494
|
+
return infra(
|
|
495
|
+
"candidate changed during the re-run "
|
|
496
|
+
f"(HEAD {head} -> {head_after}, clean={clean_after}); no verdict"
|
|
497
|
+
)
|
|
498
|
+
if exit_code != loaded["exit_code"]:
|
|
499
|
+
return invalid(
|
|
500
|
+
f"exit_code_mismatch: observed {exit_code}, recorded {loaded['exit_code']}"
|
|
501
|
+
)
|
|
502
|
+
if not args.exit_only:
|
|
503
|
+
if output_sha != loaded["output_sha256"]:
|
|
504
|
+
return invalid(
|
|
505
|
+
"output_hash_mismatch: observed "
|
|
506
|
+
f"{output_sha}, recorded {loaded['output_sha256']} "
|
|
507
|
+
f"(observed_bytes={total}, recorded_bytes={loaded['output_bytes']}); "
|
|
508
|
+
"if the gate's output is legitimately nondeterministic, "
|
|
509
|
+
"re-verify with --exit-only and say so in the referencing row"
|
|
510
|
+
)
|
|
511
|
+
# Every recorded field must be compared, or a forged value in it rides
|
|
512
|
+
# a "full" pass: byte count exactly, and a non-empty recorded tail must
|
|
513
|
+
# be a suffix of the observed output's decoded tail window.
|
|
514
|
+
if total != loaded["output_bytes"]:
|
|
515
|
+
return invalid(
|
|
516
|
+
f"output_bytes_mismatch: observed {total}, "
|
|
517
|
+
f"recorded {loaded['output_bytes']}"
|
|
518
|
+
)
|
|
519
|
+
# Reconstruct the tail with the SAME deterministic function and the
|
|
520
|
+
# RECORDED budget, then require exact equality: a truncated, emptied,
|
|
521
|
+
# or padded tail all fail — a suffix check would accept truncation and
|
|
522
|
+
# an empty tail would skip comparison entirely.
|
|
523
|
+
tail_budget = loaded["tail_bytes"]
|
|
524
|
+
reconstructed = (
|
|
525
|
+
bounded_utf8_tail(observed_tail, tail_budget) if tail_budget else ""
|
|
526
|
+
)
|
|
527
|
+
if reconstructed != loaded["output_tail"]:
|
|
528
|
+
return invalid(
|
|
529
|
+
"output_tail_mismatch: reconstructing the tail at the "
|
|
530
|
+
f"recorded tail_bytes={tail_budget} does not reproduce the "
|
|
531
|
+
"recorded output_tail (same capture window and trim pipeline "
|
|
532
|
+
"as mint, so identical output implies identical tails)"
|
|
533
|
+
)
|
|
534
|
+
scope = "exit-only" if args.exit_only else "full"
|
|
535
|
+
print(f"gate_receipt_rerun_ok: {path} scope={scope}")
|
|
536
|
+
return 0
|
|
537
|
+
|
|
538
|
+
|
|
539
|
+
def main() -> int:
|
|
540
|
+
parser = argparse.ArgumentParser(prog="gate_receipt.py", add_help=True)
|
|
541
|
+
sub = parser.add_subparsers(dest="mode", required=True)
|
|
542
|
+
mint_parser = sub.add_parser("mint")
|
|
543
|
+
mint_parser.add_argument("--out", required=True)
|
|
544
|
+
mint_parser.add_argument("--tail-bytes", type=int, default=0)
|
|
545
|
+
mint_parser.add_argument("--timeout", type=int, default=3600)
|
|
546
|
+
verify_parser = sub.add_parser("verify")
|
|
547
|
+
verify_parser.add_argument("receipt")
|
|
548
|
+
verify_parser.add_argument("--rerun", action="store_true")
|
|
549
|
+
verify_parser.add_argument("--exit-only", action="store_true")
|
|
550
|
+
verify_parser.add_argument("--timeout", type=int, default=3600)
|
|
551
|
+
# The gate command is everything after the first standalone `--`, split
|
|
552
|
+
# BEFORE argparse sees it: argparse.REMAINDER is greedy and would swallow
|
|
553
|
+
# flags like --rerun that appear between the positional and the `--`.
|
|
554
|
+
argv = sys.argv[1:]
|
|
555
|
+
command_tail: list[str] = []
|
|
556
|
+
if "--" in argv:
|
|
557
|
+
split_at = argv.index("--")
|
|
558
|
+
command_tail = argv[split_at + 1:]
|
|
559
|
+
argv = argv[:split_at]
|
|
560
|
+
args = parser.parse_args(argv)
|
|
561
|
+
args.command = command_tail
|
|
562
|
+
# An environment failure (permission denied on the gate binary or the
|
|
563
|
+
# receipt path, a vanished directory, a full disk) must never surface as
|
|
564
|
+
# the exit status the CLI contract reserves for "the receipt failed
|
|
565
|
+
# verification" — an uncaught traceback exits 1, which would be a false
|
|
566
|
+
# verdict. Everything unexpected at the OS layer is rc 2, no verdict.
|
|
567
|
+
try:
|
|
568
|
+
if args.mode == "mint":
|
|
569
|
+
return mint(args)
|
|
570
|
+
return verify(args)
|
|
571
|
+
except (OSError, UnicodeError) as exc:
|
|
572
|
+
return infra(f"environment failure, no verdict: {exc}")
|
|
573
|
+
|
|
574
|
+
|
|
575
|
+
if __name__ == "__main__":
|
|
576
|
+
sys.exit(main())
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
#!/usr/bin/env bash
|
|
2
|
+
# Structural completeness check for the recurring-anti-patterns grep panel.
|
|
3
|
+
#
|
|
4
|
+
# Invariant pinned (074): every `## Anti-pattern N — ...` section in
|
|
5
|
+
# references/recurring-anti-patterns-checklist.md carries a `**Grep**` line —
|
|
6
|
+
# the runnable-or-manual detection recipe the panel's "How to use" contract
|
|
7
|
+
# promises per entry. A new anti-pattern landed without its Grep recipe is the
|
|
8
|
+
# drift this catches; the panel itself stays manual-by-design (its own
|
|
9
|
+
# "Promoting a symptom to a mechanical gate" growth rule), so this test does
|
|
10
|
+
# NOT execute or compile the grep patterns. Pattern-compilation validation was
|
|
11
|
+
# considered and discarded: the recipes mix GNU-BRE commands with prose
|
|
12
|
+
# instructions by design, so a compile check would false-red on platform
|
|
13
|
+
# regex-dialect differences without protecting a real contract.
|
|
14
|
+
#
|
|
15
|
+
# Self-proof (mutant must-hit): a fixture copy with one Grep line removed must
|
|
16
|
+
# turn this check red for exactly that section; a fixture with an extra
|
|
17
|
+
# non-anti-pattern section stays green (benign neighbor).
|
|
18
|
+
set -u
|
|
19
|
+
|
|
20
|
+
script_dir=$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd -P)
|
|
21
|
+
checklist="$script_dir/../references/recurring-anti-patterns-checklist.md"
|
|
22
|
+
fail=0
|
|
23
|
+
|
|
24
|
+
check_panel() { # check_panel <file>; prints missing sections, returns 1 if any
|
|
25
|
+
awk '
|
|
26
|
+
/^## Anti-pattern / {
|
|
27
|
+
if (in_section && !seen_grep) { print "missing_grep_line: " section; bad = 1 }
|
|
28
|
+
in_section = 1; seen_grep = 0; section = $0; count += 1; next
|
|
29
|
+
}
|
|
30
|
+
/^## / {
|
|
31
|
+
if (in_section && !seen_grep) { print "missing_grep_line: " section; bad = 1 }
|
|
32
|
+
in_section = 0; next
|
|
33
|
+
}
|
|
34
|
+
/^\*\*Grep\*\*/ { if (in_section) seen_grep = 1 }
|
|
35
|
+
END {
|
|
36
|
+
if (in_section && !seen_grep) { print "missing_grep_line: " section; bad = 1 }
|
|
37
|
+
if (count == 0) { print "no_anti_pattern_sections_found"; bad = 1 }
|
|
38
|
+
exit bad
|
|
39
|
+
}
|
|
40
|
+
' "$1"
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
if [[ ! -f "$checklist" ]]; then
|
|
44
|
+
echo "test_antipattern_grep_panel: FAIL (checklist missing: $checklist)"
|
|
45
|
+
exit 1
|
|
46
|
+
fi
|
|
47
|
+
|
|
48
|
+
# Real-panel leg
|
|
49
|
+
if ! out=$(check_panel "$checklist"); then
|
|
50
|
+
echo "$out"
|
|
51
|
+
echo "test_antipattern_grep_panel: FAIL (panel section without a Grep recipe)"
|
|
52
|
+
exit 1
|
|
53
|
+
fi
|
|
54
|
+
echo "ok panel ($(grep -c '^## Anti-pattern ' "$checklist") sections, each with a Grep recipe)"
|
|
55
|
+
|
|
56
|
+
tmp=$(mktemp -d)
|
|
57
|
+
trap 'rm -rf "$tmp"' EXIT
|
|
58
|
+
|
|
59
|
+
# Mutant must-hit: strip the Grep line from Anti-pattern 27 -> must red on it
|
|
60
|
+
awk '/^## Anti-pattern 27 /{inap=1} inap && /^\*\*Grep\*\*/{inap=0; next} {print}' \
|
|
61
|
+
"$checklist" > "$tmp/mutant.md"
|
|
62
|
+
if out=$(check_panel "$tmp/mutant.md"); then
|
|
63
|
+
echo "test_antipattern_grep_panel: FAIL (mutant with stripped Grep line passed)"
|
|
64
|
+
exit 1
|
|
65
|
+
fi
|
|
66
|
+
if [[ "$out" != *"Anti-pattern 27"* ]]; then
|
|
67
|
+
echo "test_antipattern_grep_panel: FAIL (mutant red but wrong section: $out)"
|
|
68
|
+
exit 1
|
|
69
|
+
fi
|
|
70
|
+
echo "ok mutant (stripped Grep line detected on the right section)"
|
|
71
|
+
|
|
72
|
+
# Benign neighbor: an extra non-anti-pattern section must stay green
|
|
73
|
+
{ cat "$checklist"; printf '\n## A closing note\n\nProse only.\n'; } > "$tmp/benign.md"
|
|
74
|
+
if ! check_panel "$tmp/benign.md" >/dev/null; then
|
|
75
|
+
echo "test_antipattern_grep_panel: FAIL (benign extra section turned the check red)"
|
|
76
|
+
exit 1
|
|
77
|
+
fi
|
|
78
|
+
echo "ok benign (non-anti-pattern section ignored)"
|
|
79
|
+
|
|
80
|
+
echo "test_antipattern_grep_panel: ok"
|