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/jsscan.py
ADDED
|
@@ -0,0 +1,422 @@
|
|
|
1
|
+
# audited on 20260903
|
|
2
|
+
"""Structural check for TypeScript and JavaScript: functions that do nothing.
|
|
3
|
+
|
|
4
|
+
Python gets this check from the ``ast`` module. There is no parser for
|
|
5
|
+
TypeScript in the standard library, and a dependency is not on the table, so
|
|
6
|
+
this module does the one narrow thing the check needs without parsing the
|
|
7
|
+
language: it blanks out every comment, string, template literal and regular
|
|
8
|
+
expression literal (keeping line breaks), then looks for the three shapes
|
|
9
|
+
an agent leaves behind when it declares a function and never writes it:
|
|
10
|
+
|
|
11
|
+
function name(...) {} declarations, async, generators, default
|
|
12
|
+
name(...) {} class and object literal methods
|
|
13
|
+
export const name = (...) => {} exported arrow and function expressions
|
|
14
|
+
|
|
15
|
+
Only a body that is empty before blanking counts: a block holding nothing
|
|
16
|
+
but a comment is a documented no-op, which is a decision, not unfinished
|
|
17
|
+
work (and whatever the comment admits is the marker rules' business). The
|
|
18
|
+
check is tuned to never fire on honest code:
|
|
19
|
+
|
|
20
|
+
- constructors are skipped (parameter properties make an empty body normal),
|
|
21
|
+
- Angular lifecycle hooks (``ngOnInit`` and friends) are skipped,
|
|
22
|
+
- control flow keywords are never treated as a method name,
|
|
23
|
+
- unexported arrow functions are skipped (callbacks and defaults are
|
|
24
|
+
legitimately empty), as is an exported ``noop``,
|
|
25
|
+
- declaration files (``.d.ts``) and minified sources are skipped outright.
|
|
26
|
+
|
|
27
|
+
Overloads, abstract methods and interface methods have no body and match
|
|
28
|
+
nothing. Anything this cannot see is a miss, never a false positive.
|
|
29
|
+
"""
|
|
30
|
+
|
|
31
|
+
from __future__ import annotations
|
|
32
|
+
|
|
33
|
+
import re
|
|
34
|
+
|
|
35
|
+
# A line longer than this is generated or minified code, where empty
|
|
36
|
+
# functions are polyfill noise rather than an agent's unfinished work.
|
|
37
|
+
MINIFIED_LINE_LENGTH = 1000
|
|
38
|
+
|
|
39
|
+
# Words that can precede ``(`` and a block without being a method name.
|
|
40
|
+
NOT_A_METHOD = frozenset({
|
|
41
|
+
"if", "for", "while", "switch", "catch", "with", "function", "return",
|
|
42
|
+
"await", "yield", "typeof", "new", "throw", "void", "delete", "in", "of",
|
|
43
|
+
"case", "else", "do", "try", "finally", "constructor", "super", "import",
|
|
44
|
+
"export", "as", "from", "declare", "abstract", "class", "interface",
|
|
45
|
+
"namespace", "module", "enum", "type", "let", "const", "var", "instanceof",
|
|
46
|
+
})
|
|
47
|
+
|
|
48
|
+
# Method names whose empty body is a framework convention, not unfinished work.
|
|
49
|
+
FRAMEWORK_HOOK = re.compile(r"^ng[A-Z]\w*$")
|
|
50
|
+
NOOP_NAME = re.compile(r"^no[_-]?op$", re.IGNORECASE)
|
|
51
|
+
|
|
52
|
+
MODIFIERS = frozenset({"public", "private", "protected", "static", "async", "override", "readonly", "get", "set", "declare", "abstract"})
|
|
53
|
+
|
|
54
|
+
_IDENT = r"[A-Za-z_$][\w$]*"
|
|
55
|
+
_FUNCTION_DECL = re.compile(r"\bfunction\b\s*(\*)?\s*(" + _IDENT + r")?\s*(?=[<(])")
|
|
56
|
+
_METHOD_LINE = re.compile(r"^[ \t]*((?:(?:" + "|".join(sorted(MODIFIERS)) + r")\s+)*)(" + _IDENT + r")\s*(?=[<(])", re.MULTILINE)
|
|
57
|
+
_EXPORTED_ARROW = re.compile(r"\bexport\s+(?:const|let|var)\s+(" + _IDENT + r")\b")
|
|
58
|
+
_IDENT_AT = re.compile(_IDENT)
|
|
59
|
+
|
|
60
|
+
# Characters after which a ``/`` begins a regular expression literal rather
|
|
61
|
+
# than a division. Good enough for blanking; a wrong guess only affects
|
|
62
|
+
# what is blanked on that line.
|
|
63
|
+
_REGEX_PRECEDERS = set("(,=:[!&|?{};+-*%<>~^")
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def mask_source(source: str) -> str:
|
|
67
|
+
"""Blank every comment, string, template literal and regex literal.
|
|
68
|
+
|
|
69
|
+
Every blanked character becomes a space; line breaks and everything
|
|
70
|
+
else are kept, so offsets and line numbers in the result are the
|
|
71
|
+
offsets and line numbers of the source.
|
|
72
|
+
"""
|
|
73
|
+
out = list(source)
|
|
74
|
+
n = len(source)
|
|
75
|
+
i = 0
|
|
76
|
+
last_significant: str | None = None
|
|
77
|
+
# Stack of template-literal expression depths: inside ``${ ... }`` code
|
|
78
|
+
# resumes, and a ``}`` at depth zero returns to the template.
|
|
79
|
+
template_depths: list[int] = []
|
|
80
|
+
|
|
81
|
+
def blank(start: int, end: int) -> None:
|
|
82
|
+
for k in range(start, end):
|
|
83
|
+
if out[k] not in "\r\n":
|
|
84
|
+
out[k] = " "
|
|
85
|
+
|
|
86
|
+
while i < n:
|
|
87
|
+
c = source[i]
|
|
88
|
+
nxt = source[i + 1] if i + 1 < n else ""
|
|
89
|
+
if c == "/" and nxt == "/":
|
|
90
|
+
j = i
|
|
91
|
+
while j < n and source[j] not in "\r\n":
|
|
92
|
+
j += 1
|
|
93
|
+
blank(i, j)
|
|
94
|
+
i = j
|
|
95
|
+
continue
|
|
96
|
+
if c == "/" and nxt == "*":
|
|
97
|
+
j = source.find("*/", i + 2)
|
|
98
|
+
j = n if j < 0 else j + 2
|
|
99
|
+
blank(i, j)
|
|
100
|
+
i = j
|
|
101
|
+
continue
|
|
102
|
+
if c in "'\"":
|
|
103
|
+
j = i + 1
|
|
104
|
+
while j < n and source[j] != c and source[j] not in "\r\n":
|
|
105
|
+
if source[j] == "\\":
|
|
106
|
+
j += 1
|
|
107
|
+
j += 1
|
|
108
|
+
blank(i + 1, min(j, n))
|
|
109
|
+
i = min(j + 1, n)
|
|
110
|
+
last_significant = c
|
|
111
|
+
continue
|
|
112
|
+
if c == "`":
|
|
113
|
+
i = _mask_template(source, out, i, template_depths)
|
|
114
|
+
last_significant = "`"
|
|
115
|
+
continue
|
|
116
|
+
if template_depths:
|
|
117
|
+
if c == "{":
|
|
118
|
+
template_depths[-1] += 1
|
|
119
|
+
elif c == "}":
|
|
120
|
+
if template_depths[-1] == 0:
|
|
121
|
+
template_depths.pop()
|
|
122
|
+
i = _mask_template(source, out, i, template_depths, resume=True)
|
|
123
|
+
last_significant = "`"
|
|
124
|
+
continue
|
|
125
|
+
template_depths[-1] -= 1
|
|
126
|
+
if c == "/" and (last_significant is None or last_significant in _REGEX_PRECEDERS or last_significant == "return"):
|
|
127
|
+
j = i + 1
|
|
128
|
+
in_class = False
|
|
129
|
+
while j < n and source[j] not in "\r\n":
|
|
130
|
+
ch = source[j]
|
|
131
|
+
if ch == "\\":
|
|
132
|
+
j += 2
|
|
133
|
+
continue
|
|
134
|
+
if in_class:
|
|
135
|
+
if ch == "]":
|
|
136
|
+
in_class = False
|
|
137
|
+
elif ch == "[":
|
|
138
|
+
in_class = True
|
|
139
|
+
elif ch == "/":
|
|
140
|
+
break
|
|
141
|
+
j += 1
|
|
142
|
+
blank(i + 1, min(j, n))
|
|
143
|
+
i = min(j + 1, n)
|
|
144
|
+
last_significant = "/"
|
|
145
|
+
continue
|
|
146
|
+
if not c.isspace():
|
|
147
|
+
if c.isalnum() or c in "_$":
|
|
148
|
+
k = i
|
|
149
|
+
while k < n and (source[k].isalnum() or source[k] in "_$"):
|
|
150
|
+
k += 1
|
|
151
|
+
word = source[i:k]
|
|
152
|
+
last_significant = "return" if word == "return" else word[-1]
|
|
153
|
+
i = k
|
|
154
|
+
continue
|
|
155
|
+
last_significant = c
|
|
156
|
+
i += 1
|
|
157
|
+
return "".join(out)
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
def _mask_template(source: str, out: list[str], start: int, depths: list[int], *, resume: bool = False) -> int:
|
|
161
|
+
"""Blank a template literal from ``start`` (a backtick, or the ``}``
|
|
162
|
+
closing an expression when resuming). Returns the index after it."""
|
|
163
|
+
n = len(source)
|
|
164
|
+
i = start + 1
|
|
165
|
+
if resume:
|
|
166
|
+
out[start] = " " # the ``}`` that closed the expression
|
|
167
|
+
while i < n:
|
|
168
|
+
c = source[i]
|
|
169
|
+
if c == "\\":
|
|
170
|
+
out[i] = " "
|
|
171
|
+
if i + 1 < n and source[i + 1] not in "\r\n":
|
|
172
|
+
out[i + 1] = " "
|
|
173
|
+
i += 2
|
|
174
|
+
continue
|
|
175
|
+
if c == "`":
|
|
176
|
+
return i + 1
|
|
177
|
+
if c == "$" and i + 1 < n and source[i + 1] == "{":
|
|
178
|
+
out[i] = " "
|
|
179
|
+
out[i + 1] = " "
|
|
180
|
+
depths.append(0)
|
|
181
|
+
return i + 2
|
|
182
|
+
if c not in "\r\n":
|
|
183
|
+
out[i] = " "
|
|
184
|
+
i += 1
|
|
185
|
+
return n
|
|
186
|
+
|
|
187
|
+
|
|
188
|
+
def _matching(text: str, open_index: int) -> int:
|
|
189
|
+
"""Index of the bracket closing the one at ``open_index``, or -1."""
|
|
190
|
+
pairs = {"(": ")", "[": "]", "{": "}", "<": ">"}
|
|
191
|
+
stack = [pairs[text[open_index]]]
|
|
192
|
+
i = open_index + 1
|
|
193
|
+
n = len(text)
|
|
194
|
+
while i < n:
|
|
195
|
+
c = text[i]
|
|
196
|
+
if c in pairs:
|
|
197
|
+
stack.append(pairs[c])
|
|
198
|
+
elif c in ")]}>":
|
|
199
|
+
if c == stack[-1]:
|
|
200
|
+
stack.pop()
|
|
201
|
+
if not stack:
|
|
202
|
+
return i
|
|
203
|
+
elif c == ">":
|
|
204
|
+
pass # an arrow or comparison inside a type; not a bracket
|
|
205
|
+
else:
|
|
206
|
+
return -1
|
|
207
|
+
i += 1
|
|
208
|
+
return -1
|
|
209
|
+
|
|
210
|
+
|
|
211
|
+
def _skip_space(text: str, i: int) -> int:
|
|
212
|
+
n = len(text)
|
|
213
|
+
while i < n and text[i].isspace():
|
|
214
|
+
i += 1
|
|
215
|
+
return i
|
|
216
|
+
|
|
217
|
+
|
|
218
|
+
def _body_after_signature(text: str, i: int) -> tuple[int, int] | None:
|
|
219
|
+
"""Given ``i`` at ``(`` or ``<`` of a signature, find the body braces.
|
|
220
|
+
|
|
221
|
+
Returns (open_brace, close_brace) or None when there is no body (an
|
|
222
|
+
overload, an abstract method, a call).
|
|
223
|
+
"""
|
|
224
|
+
if i < len(text) and text[i] == "<":
|
|
225
|
+
close = _matching(text, i)
|
|
226
|
+
if close < 0:
|
|
227
|
+
return None
|
|
228
|
+
i = _skip_space(text, close + 1)
|
|
229
|
+
if i >= len(text) or text[i] != "(":
|
|
230
|
+
return None
|
|
231
|
+
close = _matching(text, i)
|
|
232
|
+
if close < 0:
|
|
233
|
+
return None
|
|
234
|
+
i = _skip_space(text, close + 1)
|
|
235
|
+
if i < len(text) and text[i] == ":":
|
|
236
|
+
i = _skip_type(text, i + 1)
|
|
237
|
+
return _block_at(text, i)
|
|
238
|
+
|
|
239
|
+
|
|
240
|
+
def _skip_type(text: str, i: int) -> int:
|
|
241
|
+
"""Skip a type annotation, returning the index of the body's ``{`` or
|
|
242
|
+
``len(text)`` when the signature has no body.
|
|
243
|
+
|
|
244
|
+
Types can contain braces (object types), so the first depth-zero ``{``
|
|
245
|
+
is not necessarily the body. What follows its matching ``}`` decides:
|
|
246
|
+
another ``{`` means the first was a type and the body comes next; a
|
|
247
|
+
type continuation (``|``, ``&``, ``[]``, ``=>``) means keep skipping;
|
|
248
|
+
a terminator (``;`` ``,`` ``)`` ``>`` ``=``) means the signature ended
|
|
249
|
+
without a body, as in an interface method returning ``{}``.
|
|
250
|
+
"""
|
|
251
|
+
n = len(text)
|
|
252
|
+
depth = 0
|
|
253
|
+
while i < n:
|
|
254
|
+
c = text[i]
|
|
255
|
+
if c in "([<":
|
|
256
|
+
depth += 1
|
|
257
|
+
elif c in ")]>":
|
|
258
|
+
depth -= 1
|
|
259
|
+
elif c == "{" and depth == 0:
|
|
260
|
+
close = _matching(text, i)
|
|
261
|
+
if close < 0:
|
|
262
|
+
return n
|
|
263
|
+
after = _skip_space(text, close + 1)
|
|
264
|
+
following = text[after] if after < n else ""
|
|
265
|
+
if following == "{":
|
|
266
|
+
return after
|
|
267
|
+
if following in ("|", "&", "[") or text.startswith("=>", after):
|
|
268
|
+
i = close + 1
|
|
269
|
+
continue
|
|
270
|
+
if following in (";", ",", ")", ">", "="):
|
|
271
|
+
return n
|
|
272
|
+
return i
|
|
273
|
+
elif c == ";" and depth == 0:
|
|
274
|
+
return n
|
|
275
|
+
i += 1
|
|
276
|
+
return n
|
|
277
|
+
|
|
278
|
+
|
|
279
|
+
def _block_at(text: str, i: int) -> tuple[int, int] | None:
|
|
280
|
+
if i >= len(text) or text[i] != "{":
|
|
281
|
+
return None
|
|
282
|
+
close = _matching(text, i)
|
|
283
|
+
if close < 0:
|
|
284
|
+
return None
|
|
285
|
+
return i, close
|
|
286
|
+
|
|
287
|
+
|
|
288
|
+
def _is_unexplained_empty_block(original: str, masked: str, open_brace: int, close_brace: int) -> bool:
|
|
289
|
+
"""Empty in the source itself, not merely empty once comments are blanked."""
|
|
290
|
+
return masked[open_brace + 1:close_brace].strip() == "" and original[open_brace + 1:close_brace].strip() == ""
|
|
291
|
+
|
|
292
|
+
|
|
293
|
+
def _line_of(text: str, index: int) -> int:
|
|
294
|
+
return text.count("\n", 0, index) + 1
|
|
295
|
+
|
|
296
|
+
|
|
297
|
+
def empty_functions(source: str) -> list[tuple[int, str]]:
|
|
298
|
+
"""(line, name) for every function whose body does nothing."""
|
|
299
|
+
if any(len(line) > MINIFIED_LINE_LENGTH for line in source.split("\n")):
|
|
300
|
+
return []
|
|
301
|
+
original = source.replace("\r\n", "\n").replace("\r", "\n")
|
|
302
|
+
text = mask_source(original)
|
|
303
|
+
found: dict[int, tuple[int, str]] = {}
|
|
304
|
+
|
|
305
|
+
for match in _FUNCTION_DECL.finditer(text):
|
|
306
|
+
name = match.group(2) or _expression_name(text, match.start())
|
|
307
|
+
if name is None:
|
|
308
|
+
continue
|
|
309
|
+
body = _body_after_signature(text, match.end())
|
|
310
|
+
if body and _is_unexplained_empty_block(original, text, *body):
|
|
311
|
+
found.setdefault(body[0], (_line_of(text, match.start()), name))
|
|
312
|
+
|
|
313
|
+
for match in _METHOD_LINE.finditer(text):
|
|
314
|
+
name = match.group(2)
|
|
315
|
+
if name in NOT_A_METHOD or FRAMEWORK_HOOK.match(name):
|
|
316
|
+
continue
|
|
317
|
+
body = _body_after_signature(text, match.end())
|
|
318
|
+
if body and _is_unexplained_empty_block(original, text, *body):
|
|
319
|
+
found.setdefault(body[0], (_line_of(text, match.start(2)), name))
|
|
320
|
+
|
|
321
|
+
for match in _EXPORTED_ARROW.finditer(text):
|
|
322
|
+
name = match.group(1)
|
|
323
|
+
if NOOP_NAME.match(name):
|
|
324
|
+
continue
|
|
325
|
+
body = _arrow_body(text, match.end())
|
|
326
|
+
if body and _is_unexplained_empty_block(original, text, *body):
|
|
327
|
+
found.setdefault(body[0], (_line_of(text, match.start()), name))
|
|
328
|
+
|
|
329
|
+
return sorted(found.values())
|
|
330
|
+
|
|
331
|
+
|
|
332
|
+
_EXPORT_DEFAULT_BEFORE = re.compile(r"export\s+default\s*$")
|
|
333
|
+
_EXPORTED_BINDING_BEFORE = re.compile(r"\bexport\s+(?:const|let|var)\s+(" + _IDENT + r")\s*(?::[^=;]*)?=\s*$")
|
|
334
|
+
|
|
335
|
+
|
|
336
|
+
def _expression_name(text: str, function_index: int) -> str | None:
|
|
337
|
+
"""Name for an anonymous ``function`` expression, following the arrow
|
|
338
|
+
rule: ``export default`` and exported bindings count, callbacks and
|
|
339
|
+
local bindings do not, and an exported noop is exempt."""
|
|
340
|
+
before = text[max(0, function_index - 200):function_index]
|
|
341
|
+
if _EXPORT_DEFAULT_BEFORE.search(before):
|
|
342
|
+
return "default"
|
|
343
|
+
binding = _EXPORTED_BINDING_BEFORE.search(before)
|
|
344
|
+
if binding and not NOOP_NAME.match(binding.group(1)):
|
|
345
|
+
return binding.group(1)
|
|
346
|
+
return None
|
|
347
|
+
|
|
348
|
+
|
|
349
|
+
def _arrow_body(text: str, i: int) -> tuple[int, int] | None:
|
|
350
|
+
"""From just after an exported binding's name, the block of the arrow
|
|
351
|
+
function the binding is initialised with, or None when the initialiser
|
|
352
|
+
is not itself an arrow function (a call, a ternary, an object).
|
|
353
|
+
|
|
354
|
+
Shape accepted: ``[: type] = [async] [<generics>] (params) | ident
|
|
355
|
+
[: return type] => {``. Anything else is not this rule's business.
|
|
356
|
+
"""
|
|
357
|
+
n = len(text)
|
|
358
|
+
depth = 0
|
|
359
|
+
while i < n:
|
|
360
|
+
c = text[i]
|
|
361
|
+
if c in "([{":
|
|
362
|
+
depth += 1
|
|
363
|
+
elif c in ")]}":
|
|
364
|
+
depth -= 1
|
|
365
|
+
elif c == ";" and depth == 0:
|
|
366
|
+
return None
|
|
367
|
+
elif c == "=" and depth == 0:
|
|
368
|
+
if text.startswith("=>", i): # an arrow inside the binding's type annotation
|
|
369
|
+
i += 2
|
|
370
|
+
continue
|
|
371
|
+
break
|
|
372
|
+
i += 1
|
|
373
|
+
else:
|
|
374
|
+
return None
|
|
375
|
+
j = _skip_space(text, i + 1)
|
|
376
|
+
if text.startswith("async", j) and j + 5 < n and text[j + 5].isspace():
|
|
377
|
+
j = _skip_space(text, j + 5)
|
|
378
|
+
if j < n and text[j] == "<":
|
|
379
|
+
close = _matching(text, j)
|
|
380
|
+
if close < 0:
|
|
381
|
+
return None
|
|
382
|
+
j = _skip_space(text, close + 1)
|
|
383
|
+
if j < n and text[j] == "(":
|
|
384
|
+
close = _matching(text, j)
|
|
385
|
+
if close < 0:
|
|
386
|
+
return None
|
|
387
|
+
j = _skip_space(text, close + 1)
|
|
388
|
+
else:
|
|
389
|
+
ident = _IDENT_AT.match(text, j)
|
|
390
|
+
if not ident:
|
|
391
|
+
return None
|
|
392
|
+
j = _skip_space(text, ident.end())
|
|
393
|
+
if j < n and text[j] == ":":
|
|
394
|
+
arrow = _return_type_end(text, j + 1)
|
|
395
|
+
if arrow is None:
|
|
396
|
+
return None
|
|
397
|
+
j = arrow
|
|
398
|
+
if not text.startswith("=>", j):
|
|
399
|
+
return None
|
|
400
|
+
return _block_at(text, _skip_space(text, j + 2))
|
|
401
|
+
|
|
402
|
+
|
|
403
|
+
def _return_type_end(text: str, i: int) -> int | None:
|
|
404
|
+
"""Index of the ``=>`` that ends an arrow function's return type, or
|
|
405
|
+
None when the statement ends first."""
|
|
406
|
+
n = len(text)
|
|
407
|
+
depth = 0
|
|
408
|
+
while i < n:
|
|
409
|
+
c = text[i]
|
|
410
|
+
if text.startswith("=>", i):
|
|
411
|
+
if depth == 0:
|
|
412
|
+
return i
|
|
413
|
+
i += 2
|
|
414
|
+
continue
|
|
415
|
+
if c in "([{<":
|
|
416
|
+
depth += 1
|
|
417
|
+
elif c in ")]}>":
|
|
418
|
+
depth -= 1
|
|
419
|
+
elif c == ";" and depth == 0:
|
|
420
|
+
return None
|
|
421
|
+
i += 1
|
|
422
|
+
return None
|
countersign/pack.py
ADDED
|
@@ -0,0 +1,243 @@
|
|
|
1
|
+
# audited on 20260903
|
|
2
|
+
"""The evidence pack: one self-contained HTML file per run.
|
|
3
|
+
|
|
4
|
+
This is the artifact someone hands to someone else: a lead engineer
|
|
5
|
+
reviewing an agent's pull request, an agency delivering to a client, an
|
|
6
|
+
auditor asking what was actually checked. It has to say four things without
|
|
7
|
+
being asked: what was checked, how, what was found, and what was not
|
|
8
|
+
covered.
|
|
9
|
+
|
|
10
|
+
It has no external assets, so it opens anywhere and prints to PDF from any
|
|
11
|
+
browser.
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
import html
|
|
17
|
+
from datetime import datetime, timezone
|
|
18
|
+
from pathlib import Path
|
|
19
|
+
|
|
20
|
+
from . import __version__
|
|
21
|
+
from .claims import PASS
|
|
22
|
+
from .engine import FAIL_VERDICT, TEST_EXCLUSION_NOTE, GateResult
|
|
23
|
+
from .plain import plain_sentences
|
|
24
|
+
from .receipt import commit_label
|
|
25
|
+
from .stubscan import RULES
|
|
26
|
+
|
|
27
|
+
NOT_COVERED_ALWAYS: tuple[str, ...] = (
|
|
28
|
+
"This pack reports deterministic checks run in this repository, on this machine, at the "
|
|
29
|
+
"time stated. It is not a security audit, not a code review, and not a statement of "
|
|
30
|
+
"fitness for any purpose.",
|
|
31
|
+
"Only the claims declared in the claims file were verified. Anything nobody declared was "
|
|
32
|
+
"not checked.",
|
|
33
|
+
"Claim commands are the declaring team's own commands, run exactly as declared. Whether "
|
|
34
|
+
"they are deterministic, and whether they test what their statement says, is the team's "
|
|
35
|
+
"responsibility; Countersign records what they did.",
|
|
36
|
+
"Line exemptions are honored in the source itself and counted here. Each one is a human "
|
|
37
|
+
"decision that a finding was a false positive; audit them like any other review decision.",
|
|
38
|
+
"The register this pack cites lives on the machine that ran the checks. It proves that no "
|
|
39
|
+
"entry was altered after it was written by anyone who did not also rewrite every entry after "
|
|
40
|
+
"it. On its own that is evidence against accident and against third parties, not against "
|
|
41
|
+
"the machine's owner; evidence against the owner requires the register head to be anchored "
|
|
42
|
+
"outside the machine.",
|
|
43
|
+
)
|
|
44
|
+
|
|
45
|
+
NOT_COVERED_TESTS_EXCLUDED = (
|
|
46
|
+
"Test files were excluded from the marker scan by policy (exclude_tests = true): test "
|
|
47
|
+
"code legitimately fabricates data. Unfinished work inside test files was not looked for."
|
|
48
|
+
)
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def _esc(value: object) -> str:
|
|
52
|
+
return html.escape(str(value))
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
STYLE = """
|
|
56
|
+
:root { color-scheme: light; }
|
|
57
|
+
* { box-sizing: border-box; }
|
|
58
|
+
body { margin: 0; padding: 48px; background: #E9E8E2; color: #121410;
|
|
59
|
+
font: 15px/1.55 "Helvetica Neue", Arial, sans-serif; }
|
|
60
|
+
.sheet { max-width: 920px; margin: 0 auto; background: #F2F1EC; border: 1px solid #D3D2C8;
|
|
61
|
+
border-radius: 10px; padding: 40px; }
|
|
62
|
+
h1 { font-size: 26px; letter-spacing: -0.02em; margin: 0 0 4px; }
|
|
63
|
+
h2 { font-size: 13px; text-transform: uppercase; letter-spacing: 0.18em; color: #6E7168;
|
|
64
|
+
margin: 36px 0 12px; font-weight: 600; }
|
|
65
|
+
.mono { font-family: "SFMono-Regular", Menlo, Consolas, monospace; font-size: 12px; }
|
|
66
|
+
.faint { color: #6E7168; }
|
|
67
|
+
.meta { display: grid; grid-template-columns: 210px 1fr; gap: 6px 16px; margin-top: 8px; }
|
|
68
|
+
.meta span:nth-child(2n) { word-break: break-all; }
|
|
69
|
+
table { width: 100%; border-collapse: collapse; margin-top: 8px; }
|
|
70
|
+
th, td { text-align: left; padding: 9px 10px; border-bottom: 1px solid #D3D2C8; vertical-align: top; }
|
|
71
|
+
th { font-size: 11px; text-transform: uppercase; letter-spacing: 0.14em; color: #6E7168; font-weight: 600; }
|
|
72
|
+
tr:last-child td { border-bottom: none; }
|
|
73
|
+
.verdict-pass { color: #1E5B41; font-weight: 700; }
|
|
74
|
+
.verdict-fail { color: #C2402A; font-weight: 700; }
|
|
75
|
+
.counts { display: flex; gap: 28px; margin-top: 10px; }
|
|
76
|
+
.count b { display: block; font-size: 30px; letter-spacing: -0.02em; }
|
|
77
|
+
.note { border-left: 3px solid #1E5B41; padding: 4px 0 4px 14px; margin-top: 10px; color: #3C3F38; }
|
|
78
|
+
.chain { background: #E9E8E2; border: 1px solid #D3D2C8; border-radius: 6px; padding: 12px;
|
|
79
|
+
word-break: break-all; }
|
|
80
|
+
footer { max-width: 920px; margin: 18px auto 0; color: #6E7168; font-size: 12px; }
|
|
81
|
+
@media print { body { padding: 0; background: #fff; } .sheet { border: none; background: #fff; } }
|
|
82
|
+
"""
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def _esc_lines(items: tuple[str, ...]) -> str:
|
|
86
|
+
return "\n".join(f" <li>{_esc(item)}</li>" for item in items)
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def build_pack(result: GateResult, path: Path) -> Path:
|
|
90
|
+
generated = datetime.now(timezone.utc)
|
|
91
|
+
verdict_class = "verdict-pass" if result.verdict != FAIL_VERDICT else "verdict-fail"
|
|
92
|
+
verdict_word = "COUNTERSIGNED" if result.verdict != FAIL_VERDICT else "NOT COUNTERSIGNED"
|
|
93
|
+
|
|
94
|
+
finding_rows = "\n".join(
|
|
95
|
+
f"""<tr>
|
|
96
|
+
<td class="mono">{_esc(f.path)}:{_esc(f.line)}</td>
|
|
97
|
+
<td class="mono">{_esc(f.rule_id)}</td>
|
|
98
|
+
<td>{_esc(f.why)}</td>
|
|
99
|
+
<td class="mono faint">{_esc(f.evidence)}</td>
|
|
100
|
+
</tr>"""
|
|
101
|
+
for f in result.findings
|
|
102
|
+
) or '<tr><td colspan="4" class="faint">No marker findings.</td></tr>'
|
|
103
|
+
|
|
104
|
+
if result.claim_results is None:
|
|
105
|
+
claim_rows = '<tr><td colspan="5" class="faint">The claims check was skipped, not passed. The notes below say why.</td></tr>'
|
|
106
|
+
else:
|
|
107
|
+
claim_rows = "\n".join(
|
|
108
|
+
f"""<tr>
|
|
109
|
+
<td class="mono {_esc('verdict-pass' if c.status == PASS else 'verdict-fail')}">{_esc(c.status.upper())}</td>
|
|
110
|
+
<td>{_esc(c.statement)}</td>
|
|
111
|
+
<td class="mono">{_esc(c.command or 'none declared')}</td>
|
|
112
|
+
<td class="mono">{_esc(c.exit_code if c.exit_code is not None else 'n/a')}</td>
|
|
113
|
+
<td class="mono faint">{_esc(f"{c.duration_ms} ms")}</td>
|
|
114
|
+
</tr>"""
|
|
115
|
+
for c in result.claim_results
|
|
116
|
+
)
|
|
117
|
+
|
|
118
|
+
method_rows = "\n".join(
|
|
119
|
+
f"""<tr>
|
|
120
|
+
<td class="mono">{_esc(rule.rule_id)}</td>
|
|
121
|
+
<td>{_esc(rule.why)}</td>
|
|
122
|
+
<td class="mono">{_esc(len([f for f in result.findings if f.rule_id == rule.rule_id]))}</td>
|
|
123
|
+
</tr>"""
|
|
124
|
+
for rule in RULES
|
|
125
|
+
) + """
|
|
126
|
+
<tr><td class="mono">empty-body</td><td>functions whose body does nothing and explains nothing: a bare pass, ellipsis or empty braces with no docstring or comment (structural: Python via ast, TypeScript and JavaScript via a comment-and-string-aware scan)</td>
|
|
127
|
+
<td class="mono">{}</td></tr>
|
|
128
|
+
<tr><td class="mono">unparseable</td><td>Python files the parser rejects (Python, structural)</td>
|
|
129
|
+
<td class="mono">{}</td></tr>
|
|
130
|
+
<tr><td class="mono">claims</td><td>each declared claim's disproof command was executed and judged as declared; a claim the config requires but the file omits counts as failed</td>
|
|
131
|
+
<td class="mono">{}</td></tr>
|
|
132
|
+
<tr><td class="mono">claims-diff</td><td>claims compared with the base revision, when one was given; weakened claims counted here</td>
|
|
133
|
+
<td class="mono">{}</td></tr>
|
|
134
|
+
""".format(
|
|
135
|
+
len([f for f in result.findings if f.rule_id == "empty-body"]),
|
|
136
|
+
len([f for f in result.findings if f.rule_id == "unparseable"]),
|
|
137
|
+
len(result.failed_claims),
|
|
138
|
+
len(result.weakened_claims) if result.claims_diff is not None else "n/a",
|
|
139
|
+
)
|
|
140
|
+
|
|
141
|
+
if result.claims_diff is None:
|
|
142
|
+
diff_section = ""
|
|
143
|
+
else:
|
|
144
|
+
diff_rows = "\n".join(
|
|
145
|
+
f"""<tr>
|
|
146
|
+
<td class="mono">{_esc(c.claim_id)}</td>
|
|
147
|
+
<td class="mono">{_esc(c.kind)}</td>
|
|
148
|
+
<td class="mono {_esc('verdict-fail' if c.weakened else '')}">{_esc('YES' if c.weakened else 'no')}</td>
|
|
149
|
+
<td class="mono faint">{_esc(c.detail)}</td>
|
|
150
|
+
</tr>"""
|
|
151
|
+
for c in result.claims_diff
|
|
152
|
+
) or '<tr><td colspan="4" class="faint">No claim changed.</td></tr>'
|
|
153
|
+
diff_section = f"""
|
|
154
|
+
<h2>Claims changed against {_esc(result.claims_base)}</h2>
|
|
155
|
+
<p class="faint">The claims file was compared with the one at the base revision. A removed claim, a
|
|
156
|
+
changed expectation or a changed needle is a weakening; a changed command is listed for review.</p>
|
|
157
|
+
<table>
|
|
158
|
+
<tr><th>Claim</th><th>Change</th><th>Weakened</th><th>Detail</th></tr>
|
|
159
|
+
{diff_rows}
|
|
160
|
+
</table>
|
|
161
|
+
"""
|
|
162
|
+
plain = "\n".join(f" <li>{_esc(sentence)}</li>" for sentence in plain_sentences(result))
|
|
163
|
+
not_covered = NOT_COVERED_ALWAYS + ((NOT_COVERED_TESTS_EXCLUDED,) if result.tests_excluded else ())
|
|
164
|
+
# The test exclusion is stated in the not-covered list above when it
|
|
165
|
+
# applies, so the run note that says the same thing is not repeated.
|
|
166
|
+
notes = "\n".join(f" <li>{_esc(note)}</li>" for note in result.notes if note != TEST_EXCLUSION_NOTE)
|
|
167
|
+
|
|
168
|
+
document = f"""<!doctype html>
|
|
169
|
+
<html lang="en"><head><meta charset="utf-8">
|
|
170
|
+
<title>Countersign evidence pack, {_esc(result.run_id)}</title>
|
|
171
|
+
<style>{STYLE}</style></head>
|
|
172
|
+
<body><div class="sheet">
|
|
173
|
+
|
|
174
|
+
<p class="mono faint">COUNTERSIGN · EVIDENCE PACK</p>
|
|
175
|
+
<h1>Agent work verification</h1>
|
|
176
|
+
<p class="{verdict_class} mono" style="font-size:18px; letter-spacing:0.08em;">{verdict_word}</p>
|
|
177
|
+
|
|
178
|
+
<div class="meta mono">
|
|
179
|
+
<span class="faint">Run reference</span><span>{_esc(result.run_id)}</span>
|
|
180
|
+
<span class="faint">Git commit</span><span>{_esc(commit_label(result))}</span>
|
|
181
|
+
<span class="faint">Generated</span><span>{generated:%d %b %Y %H:%M} UTC</span>
|
|
182
|
+
<span class="faint">Countersign version</span><span>{_esc(__version__)}</span>
|
|
183
|
+
<span class="faint">Files scanned</span><span>{_esc(result.files_scanned)}</span>
|
|
184
|
+
<span class="faint">Config (SHA-256)</span><span>{_esc(result.config_sha256)}</span>
|
|
185
|
+
<span class="faint">Claims file (SHA-256)</span><span>{_esc(result.claims_sha256 or 'none read; the claims check was skipped')}</span>
|
|
186
|
+
</div>
|
|
187
|
+
|
|
188
|
+
<h2>In plain words</h2>
|
|
189
|
+
<ul>
|
|
190
|
+
{plain}
|
|
191
|
+
</ul>
|
|
192
|
+
|
|
193
|
+
<h2>What was found</h2>
|
|
194
|
+
<div class="counts">
|
|
195
|
+
<div class="count"><b class="verdict-fail">{len(result.findings)}</b><span class="mono faint">MARKER FINDINGS</span></div>
|
|
196
|
+
<div class="count"><b>{result.exemptions}</b><span class="mono faint">EXEMPTIONS USED</span></div>
|
|
197
|
+
<div class="count"><b class="verdict-fail">{len(result.failed_claims)}</b><span class="mono faint">FAILED CLAIMS</span></div>
|
|
198
|
+
</div>
|
|
199
|
+
<table>
|
|
200
|
+
<tr><th>Location</th><th>Rule</th><th>Why it is a problem</th><th>Evidence</th></tr>
|
|
201
|
+
{finding_rows}
|
|
202
|
+
</table>
|
|
203
|
+
|
|
204
|
+
<h2>Claims, as declared and as judged</h2>
|
|
205
|
+
<table>
|
|
206
|
+
<tr><th>Status</th><th>Claim</th><th>Disproof command</th><th>Exit</th><th>Duration</th></tr>
|
|
207
|
+
{claim_rows}
|
|
208
|
+
</table>
|
|
209
|
+
|
|
210
|
+
{diff_section}
|
|
211
|
+
<h2>How it was checked</h2>
|
|
212
|
+
<table>
|
|
213
|
+
<tr><th>Check</th><th>What it detects</th><th>Findings</th></tr>
|
|
214
|
+
{method_rows}
|
|
215
|
+
</table>
|
|
216
|
+
<p class="note">The marker scan is deterministic and re-runnable: the same files under the same
|
|
217
|
+
rule versions give the same findings. Claims were run exactly as declared and judged only by
|
|
218
|
+
exit code or output, as declared. No model judgement participates in any verdict on this pack.</p>
|
|
219
|
+
|
|
220
|
+
<h2>What this pack does not cover</h2>
|
|
221
|
+
<ul>
|
|
222
|
+
{_esc_lines(not_covered)}
|
|
223
|
+
</ul>
|
|
224
|
+
{f'<ul>{notes}</ul>' if notes else ''}
|
|
225
|
+
|
|
226
|
+
<h2>Integrity and reproducibility</h2>
|
|
227
|
+
<p class="faint">Every check, finding and claim verdict was written to an append-only register,
|
|
228
|
+
each entry sealed against the one before it. The run recorded the SHA-256 of the config and
|
|
229
|
+
claims files it read, so the same run can be re-derived later from the same files and compared
|
|
230
|
+
result for result with <span class="mono">countersign reproduce</span>.</p>
|
|
231
|
+
<div class="chain mono">
|
|
232
|
+
<div>Register entry: {_esc(result.register_index)}</div>
|
|
233
|
+
<div>Head: {_esc(result.register_hash)}</div>
|
|
234
|
+
</div>
|
|
235
|
+
|
|
236
|
+
</div>
|
|
237
|
+
<footer class="mono">Countersign {_esc(__version__)} · run inside this repository's own environment · Countersign itself made no network requests; claim commands are the declaring team's own</footer>
|
|
238
|
+
</body></html>"""
|
|
239
|
+
|
|
240
|
+
path = Path(path)
|
|
241
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
242
|
+
path.write_text(document, encoding="utf-8")
|
|
243
|
+
return path
|