@ccoalm/ccl-skills 0.9.0 → 0.11.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/SKILL.md +4 -3
- package/dist/assets/marketplace/plugins/ccl-skills/skills/code-review/references/staged-review-contract.md +23 -0
- package/dist/assets/marketplace/plugins/ccl-skills/skills/code-review/scripts/review_gate.py +178 -4
- package/dist/assets/marketplace/plugins/ccl-skills/skills/code-review/scripts/test_review_gate.sh +127 -0
- package/dist/assets/marketplace/plugins/ccl-skills/skills/defect-diagnosis/SKILL.md +2 -1
- package/dist/assets/marketplace/plugins/ccl-skills/skills/go-microservice-dev/SKILL.md +1 -1
- package/dist/assets/marketplace/plugins/ccl-skills/skills/llm-inference-integration/SKILL.md +1 -0
- package/dist/assets/marketplace/plugins/ccl-skills/skills/nodejs-service-dev/SKILL.md +11 -1
- package/dist/assets/marketplace/plugins/ccl-skills/skills/nodejs-service-dev/references/async-lifecycle-and-performance.md +16 -0
- package/dist/assets/marketplace/plugins/ccl-skills/skills/nodejs-service-dev/references/source-map.md +1 -0
- package/dist/assets/marketplace/plugins/ccl-skills/skills/product-rd-workflow/SKILL.md +8 -8
- package/dist/assets/marketplace/plugins/ccl-skills/skills/product-rd-workflow/references/delivery-lifecycle.md +1 -1
- package/dist/assets/marketplace/plugins/ccl-skills/skills/product-rd-workflow/references/dispatch-owner-skills.md +9 -1
- package/dist/assets/marketplace/plugins/ccl-skills/skills/product-rd-workflow/references/problem-resolution-and-learning.md +2 -0
- package/dist/assets/marketplace/plugins/ccl-skills/skills/python-service-dev/SKILL.md +1 -1
- package/dist/assets/marketplace/plugins/ccl-skills/skills/release-coordination/references/tag-and-prod-pipeline-gate.md +9 -0
- package/dist/assets/marketplace/plugins/ccl-skills/skills/skill-extraction-workflow/SKILL.md +17 -20
- 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 +26 -44
- 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/external-practice-controls.md +16 -0
- 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 +3 -1
- package/dist/assets/marketplace/plugins/ccl-skills/skills/skill-extraction-workflow/references/source-register.md +59 -0
- package/dist/assets/marketplace/plugins/ccl-skills/skills/skill-extraction-workflow/references/source-to-skill-extraction.md +2 -2
- package/dist/assets/marketplace/plugins/ccl-skills/skills/skill-extraction-workflow/references/validation-and-landing.md +3 -1
- package/dist/assets/marketplace/plugins/ccl-skills/skills/skill-extraction-workflow/scripts/check-ccl-skills.sh +35 -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 +16 -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/impact-chain-gate.rb +35 -6
- package/dist/assets/marketplace/plugins/ccl-skills/skills/skill-extraction-workflow/scripts/review_ledger_binding.py +476 -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_impact_chain_refscripts.sh +81 -1
- package/dist/assets/marketplace/plugins/ccl-skills/skills/skill-extraction-workflow/scripts/test_check_ccl_regressions.sh +28 -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_review_ledger_binding.sh +336 -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_routing_pointer_integrity.sh +41 -1
- package/dist/assets/marketplace/plugins/ccl-skills/skills/skill-extraction-workflow/scripts/test_validate_extraction_review_state.sh +141 -28
- package/dist/assets/marketplace/plugins/ccl-skills/skills/skill-extraction-workflow/scripts/validate_extraction_review_state.py +139 -38
- package/dist/assets/marketplace/plugins/ccl-skills/skills/test-artifact-management/SKILL.md +1 -1
- package/dist/assets/marketplace/plugins/ccl-skills/skills/testing-strategy/SKILL.md +9 -8
- package/dist/assets/release.json +110 -45
- 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())
|
|
@@ -1327,19 +1327,48 @@ if upstream.any? || routing_entrypoint_changed || changed_paths.include?(LEDGER_
|
|
|
1327
1327
|
# path is broken gets the firing-path message; every other failure shape
|
|
1328
1328
|
# gets the behavior-evidence message. (A declared wording-only row can
|
|
1329
1329
|
# never be a RED row, so no exclusion is needed here.)
|
|
1330
|
-
|
|
1331
|
-
|
|
1332
|
-
|
|
1333
|
-
|
|
1334
|
-
|
|
1330
|
+
# Name the OFFENDING ROWS, not just the owner. The owner-level `all?` above
|
|
1331
|
+
# means one bad row reddens the package, so an owner-only diagnostic sends the
|
|
1332
|
+
# author to inspect whichever row they were just writing — which is usually the
|
|
1333
|
+
# correct one. Observed: a round whose new row was fine failed because an
|
|
1334
|
+
# unrelated ALREADY-COMMITTED ledger row had been edited by an unbounded string
|
|
1335
|
+
# replace; the edit made that old row an added row of this round, so its own
|
|
1336
|
+
# original anchor no longer resolved here. Three iterations went into rewriting
|
|
1337
|
+
# the good anchor before the real offender was found by patching debug output
|
|
1338
|
+
# into this script. The row identity is what closes that gap.
|
|
1339
|
+
offending_rows = evaluated.each_index.select do |index|
|
|
1340
|
+
evaluated[index][:red_declared] && !evaluated[index][:firing_path_valid]
|
|
1341
|
+
end.map { |index| owner_rows[index] }
|
|
1342
|
+
if offending_rows.any?
|
|
1343
|
+
firing_path_failures << { path: path, rows: offending_rows }
|
|
1335
1344
|
else
|
|
1336
1345
|
behavior_failures << path
|
|
1337
1346
|
end
|
|
1338
1347
|
end
|
|
1348
|
+
# First cell of a ledger row, bounded: enough to recognise which row is meant
|
|
1349
|
+
# without dumping a multi-thousand-character evidence cell into the diagnostic.
|
|
1350
|
+
# This renders REPOSITORY TEXT a contributor controls onto a terminal/CI channel,
|
|
1351
|
+
# so it is scrubbed before it is printed, not merely bounded. C0/C1 controls,
|
|
1352
|
+
# bidi overrides, and zero-width characters can erase earlier output, restyle it,
|
|
1353
|
+
# or reorder the rendered row so the named offender is not the one a reader sees —
|
|
1354
|
+
# a length cap does nothing about any of that. Invalid bytes are replaced first so
|
|
1355
|
+
# the escape pass cannot raise on a malformed cell. Both reviewer lanes raised this
|
|
1356
|
+
# independently.
|
|
1357
|
+
CONTROL_OR_INVISIBLE = /[\u0000-\u001F\u007F-\u009F\u200B-\u200F\u2028-\u202E\u2060-\u2064\u206A-\u206F\uFEFF]/.freeze
|
|
1358
|
+
row_label = lambda do |row|
|
|
1359
|
+
cell = row[:line].to_s.sub(/\A\+/, "").split("|")[1].to_s.strip
|
|
1360
|
+
cell = cell.empty? ? row[:line].to_s.strip : cell
|
|
1361
|
+
visible = cell.scrub("?").gsub(CONTROL_OR_INVISIBLE) { |ch| format("\\u%04X", ch.ord) }
|
|
1362
|
+
visible.length > 120 ? "#{visible[0, 117]}..." : visible
|
|
1363
|
+
end
|
|
1339
1364
|
unless firing_path_failures.empty?
|
|
1340
1365
|
warn "impact_chain_firing_path_missing: RED-baseline row has no owner-scoped firing path in this committed diff"
|
|
1341
1366
|
warn " fix: point to this owner with `firing-path: command:<changed repo executable>` or `firing-path: file:<changed markdown>#<unique token on a changed numbered/list rule with a normative action>`"
|
|
1342
|
-
|
|
1367
|
+
warn " note: the rows named below are the ones that failed — every RED row bound to an owner must resolve, so a row you did not intend to touch can redden the owner. If a named row is one you only EDITED, it became an added row of this round and its original anchor no longer resolves against this round's diff: restore that row rather than rewriting an anchor that was already correct."
|
|
1368
|
+
firing_path_failures.each do |entry|
|
|
1369
|
+
warn " incomplete: #{entry[:path]}"
|
|
1370
|
+
entry[:rows].each { |row| warn " offending row: #{row_label.call(row)}" }
|
|
1371
|
+
end
|
|
1343
1372
|
exit 1
|
|
1344
1373
|
end
|
|
1345
1374
|
unless behavior_failures.empty?
|