countersign-cli 0.2.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- countersign/__init__.py +8 -0
- countersign/__main__.py +4 -0
- countersign/claims.py +245 -0
- countersign/claimsdiff.py +134 -0
- countersign/cli.py +411 -0
- countersign/config.py +213 -0
- countersign/engine.py +309 -0
- countersign/jsscan.py +422 -0
- countersign/pack.py +243 -0
- countersign/plain.py +111 -0
- countersign/receipt.py +255 -0
- countersign/register.py +205 -0
- countersign/reportclaims.py +182 -0
- countersign/reproduce.py +167 -0
- countersign/starter.py +242 -0
- countersign/stubscan.py +257 -0
- countersign_cli-0.2.0.dist-info/METADATA +152 -0
- countersign_cli-0.2.0.dist-info/RECORD +23 -0
- countersign_cli-0.2.0.dist-info/WHEEL +5 -0
- countersign_cli-0.2.0.dist-info/entry_points.txt +2 -0
- countersign_cli-0.2.0.dist-info/licenses/LICENSE +202 -0
- countersign_cli-0.2.0.dist-info/licenses/NOTICE +5 -0
- countersign_cli-0.2.0.dist-info/top_level.txt +1 -0
countersign/plain.py
ADDED
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
# audited on 20260905
|
|
2
|
+
"""The receipt in plain words.
|
|
3
|
+
|
|
4
|
+
The terminal summary and the pack tables are written for engineers. The
|
|
5
|
+
person who most needs a receipt is often the one who cannot read a stack
|
|
6
|
+
trace: they asked an agent for a feature and were told it is done. This
|
|
7
|
+
module renders the same facts as short sentences, one idea each, with no
|
|
8
|
+
jargon the reader has to look up. Every sentence is derived from the run's
|
|
9
|
+
recorded facts; nothing is softened, nothing is inferred.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
from .claims import FAIL, MISSING, PASS, TIMEOUT
|
|
15
|
+
from .engine import FAIL_VERDICT, GateResult
|
|
16
|
+
from .stubscan import RULES
|
|
17
|
+
|
|
18
|
+
# What each finding kind means to someone who did not write the rule. The
|
|
19
|
+
# marker rules carry their own words; the structural kinds are named here.
|
|
20
|
+
KIND_IN_WORDS = {rule.rule_id: rule.plain for rule in RULES}
|
|
21
|
+
KIND_IN_WORDS["empty-body"] = "a function that does nothing"
|
|
22
|
+
KIND_IN_WORDS["unparseable"] = "a Python file that does not even parse"
|
|
23
|
+
|
|
24
|
+
FINDINGS_SHOWN = 5
|
|
25
|
+
CLAIMS_SHOWN = 3
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def _count(n: int, singular: str, plural: str | None = None) -> str:
|
|
29
|
+
return f"{n} {singular if n == 1 else (plural or singular + 's')}"
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def _last_line(text: str) -> str:
|
|
33
|
+
lines = [line.strip() for line in (text or "").strip().splitlines() if line.strip()]
|
|
34
|
+
return lines[-1][:120] if lines else ""
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def plain_sentences(result: GateResult) -> list[str]:
|
|
38
|
+
"""Short sentences, each ending with a full stop, telling a non-engineer
|
|
39
|
+
what was checked, what held, and what did not."""
|
|
40
|
+
sentences: list[str] = []
|
|
41
|
+
results = result.claim_results or []
|
|
42
|
+
failed = [c for c in results if c.status == FAIL]
|
|
43
|
+
timed_out = [c for c in results if c.status == TIMEOUT]
|
|
44
|
+
missing = [c for c in results if c.status == MISSING]
|
|
45
|
+
held = [c for c in results if c.status == PASS]
|
|
46
|
+
weakened = result.weakened_claims
|
|
47
|
+
|
|
48
|
+
if result.verdict == FAIL_VERDICT:
|
|
49
|
+
reasons: list[str] = []
|
|
50
|
+
if result.findings:
|
|
51
|
+
reasons.append(f"{_count(len(result.findings), 'place')} in the code look unfinished")
|
|
52
|
+
if failed or timed_out:
|
|
53
|
+
reasons.append(f"{_count(len(failed) + len(timed_out), 'claim')} out of {len(results)} did not hold")
|
|
54
|
+
if missing:
|
|
55
|
+
reasons.append(f"{_count(len(missing), 'required claim')} {'was' if len(missing) == 1 else 'were'} never declared")
|
|
56
|
+
if weakened:
|
|
57
|
+
reasons.append(f"the claims file was weakened compared with {result.claims_base}")
|
|
58
|
+
sentences.append("Not countersigned: " + "; ".join(reasons) + ".")
|
|
59
|
+
else:
|
|
60
|
+
scanned = f"{_count(result.files_scanned, 'file')} {'was' if result.files_scanned == 1 else 'were'} scanned and none carries unfinished work"
|
|
61
|
+
if result.claim_results is None:
|
|
62
|
+
sentences.append(f"Countersigned on the scan alone: {scanned}.")
|
|
63
|
+
elif held:
|
|
64
|
+
shown = "; ".join(f"'{c.statement}'" for c in held[:CLAIMS_SHOWN])
|
|
65
|
+
more = f" and {len(held) - CLAIMS_SHOWN} more" if len(held) > CLAIMS_SHOWN else ""
|
|
66
|
+
sentences.append(f"Countersigned: {scanned}, and {_count(len(held), 'claim')} held: {shown}{more}.")
|
|
67
|
+
else:
|
|
68
|
+
sentences.append(f"Countersigned: {scanned}, and no claim was declared.")
|
|
69
|
+
|
|
70
|
+
for finding in result.findings[:FINDINGS_SHOWN]:
|
|
71
|
+
kind = KIND_IN_WORDS.get(finding.rule_id, finding.why)
|
|
72
|
+
sentences.append(f"{finding.path} line {finding.line}: {kind}.")
|
|
73
|
+
if len(result.findings) > FINDINGS_SHOWN:
|
|
74
|
+
sentences.append(f"{len(result.findings) - FINDINGS_SHOWN} more such places are listed in the findings table.")
|
|
75
|
+
|
|
76
|
+
for claim in failed:
|
|
77
|
+
tail = _last_line(claim.output_excerpt)
|
|
78
|
+
ending = f"; its last line of output was '{tail}'" if tail else "; it printed nothing"
|
|
79
|
+
code = f"exit code {claim.exit_code}" if claim.exit_code is not None else "no exit code"
|
|
80
|
+
sentences.append(f"The claim '{claim.statement}' did not hold: the command '{claim.command}' ended with {code}{ending}.")
|
|
81
|
+
for claim in timed_out:
|
|
82
|
+
sentences.append(f"The claim '{claim.statement}' was stopped after {max(1, round(claim.duration_ms / 1000))} seconds without finishing.")
|
|
83
|
+
for claim in missing:
|
|
84
|
+
sentences.append(f"The claim '{claim.claim_id}' is required by your configuration but was never declared.")
|
|
85
|
+
if result.verdict == FAIL_VERDICT and held:
|
|
86
|
+
sentences.append(f"{_count(len(held), 'other claim')} held.")
|
|
87
|
+
|
|
88
|
+
if result.claim_results is None:
|
|
89
|
+
sentences.append("No claims were checked: no claims file was found, and a skipped check is not a pass.")
|
|
90
|
+
|
|
91
|
+
if weakened:
|
|
92
|
+
parts: list[str] = []
|
|
93
|
+
for change in weakened:
|
|
94
|
+
if change.kind == "removed":
|
|
95
|
+
parts.append(f"'{change.claim_id}' was removed")
|
|
96
|
+
elif "expect" in change.fields:
|
|
97
|
+
parts.append(f"'{change.claim_id}' now expects the opposite outcome")
|
|
98
|
+
elif "needle" in change.fields:
|
|
99
|
+
parts.append(f"'{change.claim_id}' now looks for a different phrase in the output")
|
|
100
|
+
else:
|
|
101
|
+
parts.append(f"'{change.claim_id}' was changed")
|
|
102
|
+
sentences.append(f"The claims file was weakened compared with {result.claims_base}: " + "; ".join(parts) + ".")
|
|
103
|
+
|
|
104
|
+
if result.git_dirty:
|
|
105
|
+
sentences.append(
|
|
106
|
+
f"The files checked include uncommitted changes, so this receipt describes the working tree, "
|
|
107
|
+
f"not commit {result.git_commit[:8]} exactly."
|
|
108
|
+
)
|
|
109
|
+
|
|
110
|
+
sentences.append("No AI judged anything here; every sentence comes from a command's exit code or a text match you can re-run yourself.")
|
|
111
|
+
return sentences
|
countersign/receipt.py
ADDED
|
@@ -0,0 +1,255 @@
|
|
|
1
|
+
# audited on 20260903
|
|
2
|
+
"""The receipt: what a countersignature actually is.
|
|
3
|
+
|
|
4
|
+
Three renderings of one run:
|
|
5
|
+
|
|
6
|
+
JSON the machine-readable receipt, digest-bound to the register
|
|
7
|
+
Markdown for CI step summaries, pull request comments and status pages
|
|
8
|
+
Terminal for the human who just ran the command
|
|
9
|
+
|
|
10
|
+
The wording rule inherited from the evidence-pack tradition: the receipt
|
|
11
|
+
never says "verified" without saying what was checked, and a skipped check
|
|
12
|
+
is printed as a skip. A receipt that overstates is worse than no receipt.
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
from __future__ import annotations
|
|
16
|
+
|
|
17
|
+
import json
|
|
18
|
+
import re
|
|
19
|
+
from pathlib import Path
|
|
20
|
+
|
|
21
|
+
from . import __version__
|
|
22
|
+
from .claims import FAIL, MISSING, PASS, TIMEOUT
|
|
23
|
+
from .engine import FAIL_VERDICT, GateResult
|
|
24
|
+
from .plain import plain_sentences
|
|
25
|
+
|
|
26
|
+
STATUS_MARK = {PASS: "PASS", FAIL: "FAIL", TIMEOUT: "TIMEOUT", MISSING: "MISSING"}
|
|
27
|
+
|
|
28
|
+
DIRTY_SUFFIX = " (uncommitted changes in the working tree)"
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def commit_label(result: GateResult) -> str:
|
|
32
|
+
"""The commit as it should be read: with the dirty flag attached when the
|
|
33
|
+
scanned files were not exactly that commit."""
|
|
34
|
+
return result.git_commit + (DIRTY_SUFFIX if result.git_dirty else "")
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def _code_span(text: str) -> str:
|
|
38
|
+
"""A Markdown code span that survives backticks and newlines in ``text``.
|
|
39
|
+
|
|
40
|
+
Evidence lines carry template literals and reStructuredText ``code``;
|
|
41
|
+
commands can be multi-line TOML strings. The fence is one backtick
|
|
42
|
+
longer than the longest run inside, which is the CommonMark rule.
|
|
43
|
+
"""
|
|
44
|
+
flat = " ".join(text.split())
|
|
45
|
+
longest_run = max((len(run) for run in re.findall(r"`+", flat)), default=0)
|
|
46
|
+
fence = "`" * (longest_run + 1)
|
|
47
|
+
return f"{fence} {flat} {fence}"
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def receipt_json(result: GateResult) -> dict:
|
|
51
|
+
return {
|
|
52
|
+
"run_id": result.run_id,
|
|
53
|
+
"recorded_at": result.recorded_at,
|
|
54
|
+
"countersign_version": __version__,
|
|
55
|
+
"verdict": result.verdict,
|
|
56
|
+
"config": {"path": result.config_path, "sha256": result.config_sha256},
|
|
57
|
+
"claims_file": (
|
|
58
|
+
{"sha256": result.claims_sha256} if result.claims_sha256 else None
|
|
59
|
+
),
|
|
60
|
+
"git_commit": result.git_commit,
|
|
61
|
+
"git_dirty": result.git_dirty,
|
|
62
|
+
"scan": {
|
|
63
|
+
"files_scanned": result.files_scanned,
|
|
64
|
+
"findings": len(result.findings),
|
|
65
|
+
"exemptions": result.exemptions,
|
|
66
|
+
"tests_excluded": result.tests_excluded,
|
|
67
|
+
},
|
|
68
|
+
"findings": [
|
|
69
|
+
{
|
|
70
|
+
"path": f.path,
|
|
71
|
+
"line": f.line,
|
|
72
|
+
"rule_id": f.rule_id,
|
|
73
|
+
"why": f.why,
|
|
74
|
+
"evidence": f.evidence,
|
|
75
|
+
}
|
|
76
|
+
for f in result.findings
|
|
77
|
+
],
|
|
78
|
+
"claims": (
|
|
79
|
+
None
|
|
80
|
+
if result.claim_results is None
|
|
81
|
+
else [
|
|
82
|
+
{
|
|
83
|
+
"claim_id": c.claim_id,
|
|
84
|
+
"statement": c.statement,
|
|
85
|
+
"command": c.command,
|
|
86
|
+
"expect": c.expect,
|
|
87
|
+
"status": c.status,
|
|
88
|
+
"exit_code": c.exit_code,
|
|
89
|
+
"duration_ms": c.duration_ms,
|
|
90
|
+
"output_excerpt": c.output_excerpt,
|
|
91
|
+
}
|
|
92
|
+
for c in result.claim_results
|
|
93
|
+
]
|
|
94
|
+
),
|
|
95
|
+
"claims_status": result.claims_status,
|
|
96
|
+
"plain": plain_sentences(result),
|
|
97
|
+
"claims_diff": (
|
|
98
|
+
None
|
|
99
|
+
if result.claims_diff is None
|
|
100
|
+
else {
|
|
101
|
+
"base": result.claims_base,
|
|
102
|
+
"base_problem": result.claims_base_problem,
|
|
103
|
+
"weakened": len(result.weakened_claims),
|
|
104
|
+
"changes": [
|
|
105
|
+
{
|
|
106
|
+
"claim_id": c.claim_id,
|
|
107
|
+
"kind": c.kind,
|
|
108
|
+
"fields": list(c.fields),
|
|
109
|
+
"weakened": c.weakened,
|
|
110
|
+
"detail": c.detail,
|
|
111
|
+
}
|
|
112
|
+
for c in result.claims_diff
|
|
113
|
+
],
|
|
114
|
+
}
|
|
115
|
+
),
|
|
116
|
+
"register": {"index": result.register_index, "hash": result.register_hash},
|
|
117
|
+
"duration_ms": result.duration_ms,
|
|
118
|
+
"notes": result.notes,
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
def write_receipt(result: GateResult, path: Path) -> Path:
|
|
123
|
+
path = Path(path)
|
|
124
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
125
|
+
path.write_text(json.dumps(receipt_json(result), indent=2, sort_keys=True), encoding="utf-8")
|
|
126
|
+
return path
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
def load_receipt(path: Path) -> dict:
|
|
130
|
+
with Path(path).open(encoding="utf-8") as handle:
|
|
131
|
+
return json.load(handle)
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
def find_receipt(receipts_root: Path, run_id: str) -> Path | None:
|
|
135
|
+
candidate = Path(receipts_root) / f"{run_id}.json"
|
|
136
|
+
return candidate if candidate.exists() else None
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
def markdown_summary(result: GateResult) -> str:
|
|
140
|
+
lines: list[str] = []
|
|
141
|
+
verdict_word = "COUNTERSIGNED" if result.verdict != FAIL_VERDICT else "NOT COUNTERSIGNED"
|
|
142
|
+
lines.append(f"## Countersign: {verdict_word}")
|
|
143
|
+
lines.append("")
|
|
144
|
+
lines.append(" ".join(plain_sentences(result)))
|
|
145
|
+
lines.append("")
|
|
146
|
+
lines.append("| | |")
|
|
147
|
+
lines.append("|---|---|")
|
|
148
|
+
lines.append(f"| Run | `{result.run_id}` |")
|
|
149
|
+
lines.append(f"| Git commit | `{result.git_commit}`{DIRTY_SUFFIX if result.git_dirty else ''} |")
|
|
150
|
+
lines.append(f"| Files scanned | {result.files_scanned} |")
|
|
151
|
+
lines.append(f"| Marker findings | {len(result.findings)} |")
|
|
152
|
+
lines.append(f"| Line exemptions used | {result.exemptions} |")
|
|
153
|
+
claims_line = "skipped, not passed (see notes)" if result.claim_results is None else f"{len(result.claim_results)} declared"
|
|
154
|
+
lines.append(f"| Claims | {claims_line} |")
|
|
155
|
+
if result.claims_diff is not None:
|
|
156
|
+
lines.append(f"| Claims changed against `{result.claims_base}` | {len(result.claims_diff)}, {len(result.weakened_claims)} weakened |")
|
|
157
|
+
lines.append(f"| Register head | `{result.register_hash[:16]}...` at entry {result.register_index} |")
|
|
158
|
+
lines.append("")
|
|
159
|
+
|
|
160
|
+
if result.findings:
|
|
161
|
+
lines.append("### Findings")
|
|
162
|
+
lines.append("")
|
|
163
|
+
lines.append("| Location | Rule | Evidence |")
|
|
164
|
+
lines.append("|---|---|---|")
|
|
165
|
+
for f in result.findings[:25]:
|
|
166
|
+
evidence = f.evidence.replace("|", "\\|")[:120]
|
|
167
|
+
lines.append(f"| `{f.path}:{f.line}` | {f.rule_id} | {_code_span(evidence)} |")
|
|
168
|
+
if len(result.findings) > 25:
|
|
169
|
+
lines.append(f"| ... | ... | {len(result.findings) - 25} more in the receipt JSON |")
|
|
170
|
+
lines.append("")
|
|
171
|
+
|
|
172
|
+
if result.claim_results is not None:
|
|
173
|
+
lines.append("### Claims")
|
|
174
|
+
lines.append("")
|
|
175
|
+
lines.append("| Status | Claim | Command |")
|
|
176
|
+
lines.append("|---|---|---|")
|
|
177
|
+
for c in result.claim_results:
|
|
178
|
+
command = c.command.replace("|", "\\|")[:100]
|
|
179
|
+
statement = " ".join(c.statement.replace("|", "\\|").split())
|
|
180
|
+
lines.append(f"| {STATUS_MARK.get(c.status, c.status)} | {statement} | {_code_span(command) if command else 'none declared'} |")
|
|
181
|
+
lines.append("")
|
|
182
|
+
|
|
183
|
+
if result.claims_diff:
|
|
184
|
+
lines.append(f"### Claims changed against `{result.claims_base}`")
|
|
185
|
+
lines.append("")
|
|
186
|
+
lines.append("| Claim | Change | Weakened | Detail |")
|
|
187
|
+
lines.append("|---|---|---|---|")
|
|
188
|
+
for change in result.claims_diff:
|
|
189
|
+
detail = " ".join(change.detail.replace("|", "\\|").split())[:200]
|
|
190
|
+
lines.append(f"| `{change.claim_id}` | {change.kind} | {'YES' if change.weakened else 'no'} | {detail} |")
|
|
191
|
+
lines.append("")
|
|
192
|
+
|
|
193
|
+
for note in result.notes:
|
|
194
|
+
lines.append(f"> {note}")
|
|
195
|
+
if result.notes:
|
|
196
|
+
lines.append("")
|
|
197
|
+
return "\n".join(lines)
|
|
198
|
+
|
|
199
|
+
|
|
200
|
+
def terminal_summary(result: GateResult, use_color: bool = True) -> str:
|
|
201
|
+
green, red, yellow, bold, reset = ("", "", "", "", "")
|
|
202
|
+
if use_color:
|
|
203
|
+
green, red, yellow, bold, reset = "\033[32m", "\033[31m", "\033[33m", "\033[1m", "\033[0m"
|
|
204
|
+
|
|
205
|
+
lines: list[str] = []
|
|
206
|
+
lines.append(f"{bold}Countersign{reset} run {result.run_id}")
|
|
207
|
+
lines.append(f" commit {commit_label(result)} · {result.files_scanned} files scanned · {result.duration_ms} ms")
|
|
208
|
+
lines.append("")
|
|
209
|
+
|
|
210
|
+
if result.findings:
|
|
211
|
+
mark = f"{red}✗{reset}"
|
|
212
|
+
lines.append(f"{mark} marker scan: {len(result.findings)} finding(s)")
|
|
213
|
+
for f in result.findings[:15]:
|
|
214
|
+
lines.append(f" {f.path}:{f.line} [{f.rule_id}] {f.evidence[:100]}")
|
|
215
|
+
if len(result.findings) > 15:
|
|
216
|
+
lines.append(f" ... {len(result.findings) - 15} more")
|
|
217
|
+
else:
|
|
218
|
+
lines.append(f"{green}✓{reset} marker scan: clean ({result.files_scanned} files)")
|
|
219
|
+
|
|
220
|
+
if result.claim_results is None:
|
|
221
|
+
lines.append(f"{yellow}–{reset} claims: skipped, not passed (see note below)")
|
|
222
|
+
else:
|
|
223
|
+
for c in result.claim_results:
|
|
224
|
+
if c.status == PASS:
|
|
225
|
+
lines.append(f"{green}✓{reset} claim {c.claim_id}: {c.statement}")
|
|
226
|
+
elif c.status == TIMEOUT:
|
|
227
|
+
lines.append(f"{red}✗{reset} claim {c.claim_id}: TIMED OUT after {c.duration_ms} ms: {c.statement}")
|
|
228
|
+
elif c.status == MISSING:
|
|
229
|
+
lines.append(f"{red}✗{reset} claim {c.claim_id}: MISSING, {c.statement}")
|
|
230
|
+
else:
|
|
231
|
+
excerpt = (c.output_excerpt or "").strip().splitlines()
|
|
232
|
+
tail = excerpt[-1][:120] if excerpt else "no output"
|
|
233
|
+
lines.append(f"{red}✗{reset} claim {c.claim_id}: {c.statement}")
|
|
234
|
+
lines.append(f" command: {c.command}")
|
|
235
|
+
lines.append(f" output ends: {tail}")
|
|
236
|
+
|
|
237
|
+
if result.claims_diff is not None:
|
|
238
|
+
weakened = result.weakened_claims
|
|
239
|
+
mark = f"{red}✗{reset}" if weakened else f"{green}✓{reset}"
|
|
240
|
+
lines.append(f"{mark} claims against {result.claims_base}: {len(result.claims_diff)} change(s), {len(weakened)} weakened")
|
|
241
|
+
for change in result.claims_diff:
|
|
242
|
+
flag = "WEAKENED " if change.weakened else ""
|
|
243
|
+
lines.append(f" {flag}{change.kind} {change.claim_id}: {change.detail[:140]}")
|
|
244
|
+
|
|
245
|
+
for note in result.notes:
|
|
246
|
+
lines.append(f"{yellow}–{reset} note: {note}")
|
|
247
|
+
|
|
248
|
+
lines.append("")
|
|
249
|
+
if result.verdict == FAIL_VERDICT:
|
|
250
|
+
weakened_part = f", {len(result.weakened_claims)} weakened claim(s)" if result.weakened_claims else ""
|
|
251
|
+
lines.append(f"{red}{bold}NOT COUNTERSIGNED{reset} · {len(result.findings)} finding(s), {len(result.failed_claims)} failed claim(s){weakened_part}")
|
|
252
|
+
lines.append("The work did not pass its own declared checks. Fix the code or the claims.")
|
|
253
|
+
else:
|
|
254
|
+
lines.append(f"{green}{bold}COUNTERSIGNED{reset} · register entry {result.register_index}, head {result.register_hash[:16]}...")
|
|
255
|
+
return "\n".join(lines)
|
countersign/register.py
ADDED
|
@@ -0,0 +1,205 @@
|
|
|
1
|
+
# audited on 20260903
|
|
2
|
+
"""The evidence register: append only, hash chained, plain files.
|
|
3
|
+
|
|
4
|
+
Every check Countersign runs, every finding it produces and every claim
|
|
5
|
+
verdict is written here as one line of JSON. Each line carries the hash of
|
|
6
|
+
the line before it, so any later edit to any earlier line breaks the chain
|
|
7
|
+
and ``verify_chain`` says so. This is what makes a receipt tamper evident:
|
|
8
|
+
not a claim on a website, but arithmetic anyone can redo.
|
|
9
|
+
|
|
10
|
+
Adapted from Gaigentic Verify's register (2026), which ran this exact design
|
|
11
|
+
through a file-by-file production review.
|
|
12
|
+
|
|
13
|
+
Deliberately a file, not a database. It has to run inside any repository on
|
|
14
|
+
any machine on day one, and a file is something an auditor can copy, diff
|
|
15
|
+
and keep.
|
|
16
|
+
|
|
17
|
+
Appends take an exclusive lock on a sibling ``.lock`` file for the read-head,
|
|
18
|
+
write-entry pair. Without it, two runs started at the same moment (two CI
|
|
19
|
+
jobs on one checkout, a human and a hook) could both chain onto the same
|
|
20
|
+
head and leave a register that is broken forever, indistinguishable from
|
|
21
|
+
tampering.
|
|
22
|
+
"""
|
|
23
|
+
|
|
24
|
+
from __future__ import annotations
|
|
25
|
+
|
|
26
|
+
import contextlib
|
|
27
|
+
import hashlib
|
|
28
|
+
import json
|
|
29
|
+
import os
|
|
30
|
+
from dataclasses import dataclass
|
|
31
|
+
from datetime import datetime, timezone
|
|
32
|
+
from pathlib import Path
|
|
33
|
+
from typing import Any, Iterator
|
|
34
|
+
|
|
35
|
+
try:
|
|
36
|
+
import fcntl
|
|
37
|
+
except ImportError: # Windows has no fcntl; msvcrt provides byte-range locks
|
|
38
|
+
fcntl = None
|
|
39
|
+
import msvcrt
|
|
40
|
+
|
|
41
|
+
GENESIS = "0" * 64
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
class RegisterDamaged(ValueError):
|
|
45
|
+
"""The file cannot be extended without breaking the chain it carries."""
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def _canonical(payload: dict[str, Any]) -> str:
|
|
49
|
+
"""Stable JSON so the same entry always hashes to the same value."""
|
|
50
|
+
return json.dumps(payload, sort_keys=True, separators=(",", ":"), default=str)
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def entry_hash(previous_hash: str, body: dict[str, Any]) -> str:
|
|
54
|
+
return hashlib.sha256((previous_hash + _canonical(body)).encode("utf-8")).hexdigest()
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
@contextlib.contextmanager
|
|
58
|
+
def _exclusive(lock_path: Path) -> Iterator[None]:
|
|
59
|
+
"""Hold an exclusive advisory lock on ``lock_path`` for the block."""
|
|
60
|
+
lock_path.parent.mkdir(parents=True, exist_ok=True)
|
|
61
|
+
with lock_path.open("a+b") as handle:
|
|
62
|
+
if fcntl is not None:
|
|
63
|
+
fcntl.flock(handle.fileno(), fcntl.LOCK_EX)
|
|
64
|
+
try:
|
|
65
|
+
yield
|
|
66
|
+
finally:
|
|
67
|
+
fcntl.flock(handle.fileno(), fcntl.LOCK_UN)
|
|
68
|
+
else:
|
|
69
|
+
handle.seek(0)
|
|
70
|
+
msvcrt.locking(handle.fileno(), msvcrt.LK_LOCK, 1)
|
|
71
|
+
try:
|
|
72
|
+
yield
|
|
73
|
+
finally:
|
|
74
|
+
handle.seek(0)
|
|
75
|
+
msvcrt.locking(handle.fileno(), msvcrt.LK_UNLCK, 1)
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
@dataclass
|
|
79
|
+
class Register:
|
|
80
|
+
"""Append-only log of everything a verification run did."""
|
|
81
|
+
|
|
82
|
+
path: Path
|
|
83
|
+
|
|
84
|
+
def __post_init__(self) -> None:
|
|
85
|
+
self.path = Path(self.path)
|
|
86
|
+
|
|
87
|
+
@property
|
|
88
|
+
def lock_path(self) -> Path:
|
|
89
|
+
return self.path.parent / (self.path.name + ".lock")
|
|
90
|
+
|
|
91
|
+
# ---- writing ----
|
|
92
|
+
|
|
93
|
+
def append(self, kind: str, body: dict[str, Any], *, at: datetime | None = None) -> dict[str, Any]:
|
|
94
|
+
"""Add one entry and return it, including its position and hash.
|
|
95
|
+
|
|
96
|
+
The entry is on the disk before this returns. Evidence that a caller
|
|
97
|
+
has been told was written, and that a power cut then removes, would
|
|
98
|
+
be worse than evidence never written at all: the register would be
|
|
99
|
+
short an entry and nobody would know which.
|
|
100
|
+
"""
|
|
101
|
+
with _exclusive(self.lock_path):
|
|
102
|
+
previous = self.head()
|
|
103
|
+
previous_hash = previous["hash"] if previous else GENESIS
|
|
104
|
+
index = (previous["index"] + 1) if previous else 0
|
|
105
|
+
recorded_at = (at or datetime.now(timezone.utc)).isoformat()
|
|
106
|
+
|
|
107
|
+
core = {"index": index, "kind": kind, "recorded_at": recorded_at, "body": body}
|
|
108
|
+
entry = {**core, "previous_hash": previous_hash, "hash": entry_hash(previous_hash, core)}
|
|
109
|
+
|
|
110
|
+
with self.path.open("a", encoding="utf-8") as handle:
|
|
111
|
+
handle.write(json.dumps(entry, sort_keys=True, default=str) + "\n")
|
|
112
|
+
handle.flush()
|
|
113
|
+
os.fsync(handle.fileno())
|
|
114
|
+
return entry
|
|
115
|
+
|
|
116
|
+
# ---- reading ----
|
|
117
|
+
|
|
118
|
+
def entries(self) -> Iterator[dict[str, Any]]:
|
|
119
|
+
"""Every entry, in order, without verifying the chain. Use
|
|
120
|
+
``verify_chain`` first when the file may have been touched."""
|
|
121
|
+
if not self.path.exists():
|
|
122
|
+
return
|
|
123
|
+
with self.path.open(encoding="utf-8", errors="replace") as handle:
|
|
124
|
+
for line in handle:
|
|
125
|
+
line = line.strip()
|
|
126
|
+
if line:
|
|
127
|
+
yield json.loads(line)
|
|
128
|
+
|
|
129
|
+
def _last_line(self) -> str | None:
|
|
130
|
+
"""The final line, read by seeking rather than by reading everything.
|
|
131
|
+
|
|
132
|
+
Appending needs only the entry before it. Reading the whole file to
|
|
133
|
+
find that entry makes each append cost more than the last, so a
|
|
134
|
+
register that runs for years gets slower every month it is used.
|
|
135
|
+
"""
|
|
136
|
+
if not self.path.exists():
|
|
137
|
+
return None
|
|
138
|
+
with self.path.open("rb") as handle:
|
|
139
|
+
handle.seek(0, os.SEEK_END)
|
|
140
|
+
position = handle.tell()
|
|
141
|
+
collected = b""
|
|
142
|
+
while position > 0:
|
|
143
|
+
step = min(4096, position)
|
|
144
|
+
position -= step
|
|
145
|
+
handle.seek(position)
|
|
146
|
+
collected = handle.read(step) + collected
|
|
147
|
+
stripped = collected.rstrip(b"\n")
|
|
148
|
+
if b"\n" in stripped:
|
|
149
|
+
return stripped.rsplit(b"\n", 1)[1].decode("utf-8", errors="replace")
|
|
150
|
+
final = collected.strip()
|
|
151
|
+
return final.decode("utf-8", errors="replace") if final else None
|
|
152
|
+
|
|
153
|
+
def head(self) -> dict[str, Any] | None:
|
|
154
|
+
line = self._last_line()
|
|
155
|
+
if line is None:
|
|
156
|
+
return None
|
|
157
|
+
try:
|
|
158
|
+
entry = json.loads(line)
|
|
159
|
+
if not isinstance(entry, dict) or not isinstance(entry.get("index"), int) or not isinstance(entry.get("hash"), str):
|
|
160
|
+
raise ValueError("the line is not a register entry")
|
|
161
|
+
except ValueError as exc: # json.JSONDecodeError is a ValueError
|
|
162
|
+
raise RegisterDamaged(
|
|
163
|
+
f"the last line of {self.path.name} cannot be read as an entry, so nothing can "
|
|
164
|
+
"be added after it without breaking the chain. Keep the file and investigate "
|
|
165
|
+
f"what wrote it: {exc}"
|
|
166
|
+
) from None
|
|
167
|
+
return entry
|
|
168
|
+
|
|
169
|
+
def verify_chain(self) -> tuple[bool, str]:
|
|
170
|
+
"""Recompute every hash. Returns (intact, human readable reason).
|
|
171
|
+
|
|
172
|
+
A line that cannot even be parsed is itself a broken chain, not a
|
|
173
|
+
crash: the likeliest way a register gets corrupted is someone opening
|
|
174
|
+
the file in an editor, and the verdict has to survive whatever they
|
|
175
|
+
saved, including bytes that are not text.
|
|
176
|
+
"""
|
|
177
|
+
previous_hash = GENESIS
|
|
178
|
+
expected_index = 0
|
|
179
|
+
if not self.path.exists():
|
|
180
|
+
return True, "0 entries, chain intact"
|
|
181
|
+
with self.path.open(encoding="utf-8", errors="replace") as handle:
|
|
182
|
+
for line_number, line in enumerate(handle, start=1):
|
|
183
|
+
line = line.strip()
|
|
184
|
+
if not line:
|
|
185
|
+
continue
|
|
186
|
+
try:
|
|
187
|
+
entry = json.loads(line)
|
|
188
|
+
index = entry["index"]
|
|
189
|
+
core = {k: entry[k] for k in ("index", "kind", "recorded_at", "body")}
|
|
190
|
+
recorded_previous = entry["previous_hash"]
|
|
191
|
+
recorded_hash = entry["hash"]
|
|
192
|
+
except (json.JSONDecodeError, KeyError, TypeError):
|
|
193
|
+
return False, (
|
|
194
|
+
f"line {line_number} cannot be read as an entry; the register has been "
|
|
195
|
+
"altered since it was written"
|
|
196
|
+
)
|
|
197
|
+
if index != expected_index:
|
|
198
|
+
return False, f"entry {index} is out of order, expected {expected_index}"
|
|
199
|
+
if recorded_previous != previous_hash:
|
|
200
|
+
return False, f"entry {index} does not follow the entry before it"
|
|
201
|
+
if entry_hash(previous_hash, core) != recorded_hash:
|
|
202
|
+
return False, f"entry {index} has been altered since it was written"
|
|
203
|
+
previous_hash = recorded_hash
|
|
204
|
+
expected_index += 1
|
|
205
|
+
return True, f"{expected_index} entries, chain intact"
|