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/cli.py
ADDED
|
@@ -0,0 +1,411 @@
|
|
|
1
|
+
# audited on 20260903
|
|
2
|
+
"""The command line.
|
|
3
|
+
|
|
4
|
+
Five verbs, no config ceremony to start:
|
|
5
|
+
|
|
6
|
+
countersign init write countersign.toml, a starter claims.toml and, on GitHub, the workflow
|
|
7
|
+
countersign verify run the gate, write receipt + pack
|
|
8
|
+
countersign check verify the evidence register's chain
|
|
9
|
+
countersign reproduce --run ID re-derive a recorded run
|
|
10
|
+
countersign claims diff --base REF what changed in the claims file, weakenings named
|
|
11
|
+
countersign claims from-report F propose claims from an agent's own "done" message
|
|
12
|
+
|
|
13
|
+
Exit codes: 0 clean or reproduced, 1 the work did not pass (or the register
|
|
14
|
+
is damaged, or the run did not reproduce), 2 usage error (including a config
|
|
15
|
+
or claims file that cannot be honoured), 130 interrupted. A CI system can
|
|
16
|
+
trust the exit code; a human should read the receipt.
|
|
17
|
+
"""
|
|
18
|
+
|
|
19
|
+
from __future__ import annotations
|
|
20
|
+
|
|
21
|
+
import argparse
|
|
22
|
+
import json
|
|
23
|
+
import os
|
|
24
|
+
import sys
|
|
25
|
+
from pathlib import Path
|
|
26
|
+
|
|
27
|
+
from . import __version__
|
|
28
|
+
from .claims import ClaimsError, load_claims
|
|
29
|
+
from .config import Config, ConfigError
|
|
30
|
+
from .engine import FAIL_VERDICT, run_gate
|
|
31
|
+
from .pack import build_pack
|
|
32
|
+
from .receipt import markdown_summary, receipt_json, terminal_summary, write_receipt
|
|
33
|
+
from .claimsdiff import diff_against_ref
|
|
34
|
+
from .register import Register, RegisterDamaged
|
|
35
|
+
from .reportclaims import claims_from_report, render_proposals_toml, without_ids
|
|
36
|
+
from .reproduce import reproduce_run
|
|
37
|
+
from .starter import WORKFLOW_RELATIVE_PATH, detect_github_repository, detect_starter_claims, render_claims_toml, render_workflow
|
|
38
|
+
|
|
39
|
+
EXIT_OK = 0
|
|
40
|
+
EXIT_FAIL = 1
|
|
41
|
+
EXIT_USAGE = 2
|
|
42
|
+
EXIT_INTERRUPTED = 130
|
|
43
|
+
|
|
44
|
+
DEFAULT_CONFIG_TEMPLATE = """\
|
|
45
|
+
# Countersign: deterministic verification of agent completion claims.
|
|
46
|
+
# Docs: the README in this repository. Everything below has a working default.
|
|
47
|
+
|
|
48
|
+
[scan]
|
|
49
|
+
# Which paths to scan for unfinished-work markers. Relative to this file.
|
|
50
|
+
# A path that does not exist is an error, not an empty scan.
|
|
51
|
+
paths = ["."]
|
|
52
|
+
# Directories never scanned. The defaults cover build output and vendored
|
|
53
|
+
# dependencies; add yours here (the defaults are replaced, so keep the ones
|
|
54
|
+
# you want).
|
|
55
|
+
ignore_dirs = [
|
|
56
|
+
".git", ".hg", ".svn", "node_modules", "__pycache__", ".venv", "venv",
|
|
57
|
+
".mypy_cache", ".pytest_cache", ".ruff_cache", "dist", "build", "target",
|
|
58
|
+
".next", ".nuxt", ".wrangler", ".cache", ".countersign", "coverage", ".tox",
|
|
59
|
+
".idea", ".vscode", "vendor",
|
|
60
|
+
]
|
|
61
|
+
# File extensions scanned. Only add extensions where the rules behave.
|
|
62
|
+
extensions = [
|
|
63
|
+
".py", ".ts", ".tsx", ".js", ".jsx", ".mjs", ".cjs", ".go", ".rs", ".rb",
|
|
64
|
+
".java", ".kt", ".swift", ".php", ".cs", ".scala",
|
|
65
|
+
]
|
|
66
|
+
# Append this marker to a line that is a genuine false positive.
|
|
67
|
+
exempt_marker = "countersign: exempt"
|
|
68
|
+
# Test files legitimately fabricate data; they are excluded by default.
|
|
69
|
+
exclude_tests = true
|
|
70
|
+
|
|
71
|
+
[claims]
|
|
72
|
+
# Declaration of what is true about this repository, each claim with the
|
|
73
|
+
# command that fails if the claim is false, in claims.toml next to this
|
|
74
|
+
# file. Set file = "" to run scan-only (reported as skipped, not passed).
|
|
75
|
+
file = "claims.toml"
|
|
76
|
+
# Claim ids that must be declared. A required claim nobody wrote is recorded
|
|
77
|
+
# as missing and fails the gate, so the standard cannot be lowered by
|
|
78
|
+
# deleting the claim.
|
|
79
|
+
required = {required}
|
|
80
|
+
# When verify is given a base revision (--claims-base, which the GitHub
|
|
81
|
+
# action does on pull requests), a removed claim or a changed expectation
|
|
82
|
+
# or needle is a weakening. true fails the gate on it; false only records it.
|
|
83
|
+
fail_on_weakened = true
|
|
84
|
+
|
|
85
|
+
[receipts]
|
|
86
|
+
# Where receipts, the register and evidence packs are written.
|
|
87
|
+
dir = ".countersign"
|
|
88
|
+
|
|
89
|
+
[run]
|
|
90
|
+
# Per-claim command timeout, seconds. A claim that runs longer is killed,
|
|
91
|
+
# with everything it spawned, and recorded as timed out.
|
|
92
|
+
timeout_s = 300
|
|
93
|
+
# How much captured command output a receipt keeps, in characters. Longer
|
|
94
|
+
# output is kept from both ends with the middle cut out.
|
|
95
|
+
max_output_bytes = 20000
|
|
96
|
+
"""
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def render_config(required: list[str]) -> str:
|
|
100
|
+
return DEFAULT_CONFIG_TEMPLATE.replace("{required}", json.dumps(required))
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def _cmd_init(args: argparse.Namespace) -> int:
|
|
104
|
+
target = Path(args.config).resolve()
|
|
105
|
+
if target.exists() and not args.force:
|
|
106
|
+
print(f"{target} already exists; use --force to overwrite", file=sys.stderr)
|
|
107
|
+
return EXIT_USAGE
|
|
108
|
+
root = target.parent
|
|
109
|
+
root.mkdir(parents=True, exist_ok=True)
|
|
110
|
+
starters = detect_starter_claims(root)
|
|
111
|
+
required = [c.claim_id for c in starters if c.claim_id == "tests-pass"]
|
|
112
|
+
target.write_text(render_config(required), encoding="utf-8")
|
|
113
|
+
print(f"wrote {target}")
|
|
114
|
+
|
|
115
|
+
claims_target = root / "claims.toml"
|
|
116
|
+
if claims_target.exists():
|
|
117
|
+
print(f"kept existing {claims_target}")
|
|
118
|
+
else:
|
|
119
|
+
claims_target.write_text(render_claims_toml(starters), encoding="utf-8")
|
|
120
|
+
print(f"wrote {claims_target}")
|
|
121
|
+
for claim in starters:
|
|
122
|
+
print(f" proposed claim {claim.claim_id}: {claim.command} (from {claim.source})")
|
|
123
|
+
if not starters:
|
|
124
|
+
print(" no build files recognised; claims.toml holds a commented example to edit")
|
|
125
|
+
if required:
|
|
126
|
+
print(f" required in countersign.toml: {', '.join(required)}")
|
|
127
|
+
|
|
128
|
+
workflow_status = _write_workflow(root, target, args)
|
|
129
|
+
if workflow_status is not None:
|
|
130
|
+
return workflow_status
|
|
131
|
+
print("next: review claims.toml, then run: countersign verify")
|
|
132
|
+
return EXIT_OK
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
def _write_workflow(root: Path, config_target: Path, args: argparse.Namespace) -> int | None:
|
|
136
|
+
"""Write the GitHub Actions workflow when the repository lives on GitHub.
|
|
137
|
+
|
|
138
|
+
Returns an exit code to stop with, or None to carry on. Written by
|
|
139
|
+
default when origin is on github.com; ``--github`` insists (and is a
|
|
140
|
+
usage error when there is no GitHub repository to write into);
|
|
141
|
+
``--no-github`` skips.
|
|
142
|
+
"""
|
|
143
|
+
if args.no_github:
|
|
144
|
+
return None
|
|
145
|
+
repository = detect_github_repository(root)
|
|
146
|
+
if repository is None:
|
|
147
|
+
if args.github:
|
|
148
|
+
print("cannot write a workflow: this directory is not inside a git repository whose origin is on github.com", file=sys.stderr)
|
|
149
|
+
return EXIT_USAGE
|
|
150
|
+
print(" no GitHub origin found; no workflow written (run again with --github once the repository is on GitHub)")
|
|
151
|
+
return None
|
|
152
|
+
workflow_path = repository.toplevel / WORKFLOW_RELATIVE_PATH
|
|
153
|
+
if workflow_path.exists() and not args.force:
|
|
154
|
+
print(f"kept existing {workflow_path}")
|
|
155
|
+
return None
|
|
156
|
+
config_in_repo = config_target.resolve().relative_to(repository.toplevel).as_posix()
|
|
157
|
+
workflow_path.parent.mkdir(parents=True, exist_ok=True)
|
|
158
|
+
workflow_path.write_text(render_workflow(config_in_repo, repository.default_branch), encoding="utf-8")
|
|
159
|
+
print(f"wrote {workflow_path}")
|
|
160
|
+
print(f" runs countersign verify on every push to {repository.default_branch} and every pull request; commit it and push")
|
|
161
|
+
return None
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
def _use_color(args: argparse.Namespace) -> bool:
|
|
165
|
+
if args.no_color:
|
|
166
|
+
return False
|
|
167
|
+
if os.environ.get("NO_COLOR"):
|
|
168
|
+
return False
|
|
169
|
+
return sys.stdout.isatty()
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
def _load_config(config_path: Path) -> Config | None:
|
|
173
|
+
try:
|
|
174
|
+
return Config.load(config_path)
|
|
175
|
+
except ConfigError as exc:
|
|
176
|
+
print(f"config cannot be used as written: {exc}", file=sys.stderr)
|
|
177
|
+
return None
|
|
178
|
+
|
|
179
|
+
|
|
180
|
+
def _cmd_verify(args: argparse.Namespace) -> int:
|
|
181
|
+
config_path = Path(args.config).resolve()
|
|
182
|
+
if not config_path.exists():
|
|
183
|
+
print(f"no config at {config_path}; run: countersign init", file=sys.stderr)
|
|
184
|
+
return EXIT_USAGE
|
|
185
|
+
config = _load_config(config_path)
|
|
186
|
+
if config is None:
|
|
187
|
+
return EXIT_USAGE
|
|
188
|
+
if args.no_claims:
|
|
189
|
+
config.claims_file = None
|
|
190
|
+
|
|
191
|
+
register = Register(config.register_path())
|
|
192
|
+
try:
|
|
193
|
+
intact, chain_note = register.verify_chain()
|
|
194
|
+
except OSError as exc:
|
|
195
|
+
print(f"the evidence register cannot be read: {exc}", file=sys.stderr)
|
|
196
|
+
return EXIT_FAIL
|
|
197
|
+
if not intact:
|
|
198
|
+
print(f"the evidence register is damaged: {chain_note}", file=sys.stderr)
|
|
199
|
+
print("nothing can be countersigned on top of a broken chain; investigate before running again", file=sys.stderr)
|
|
200
|
+
return EXIT_FAIL
|
|
201
|
+
|
|
202
|
+
try:
|
|
203
|
+
result = run_gate(config, register=register, claims_base=args.claims_base or None)
|
|
204
|
+
except (ConfigError, ClaimsError) as exc:
|
|
205
|
+
print(f"verification could not run: {exc}", file=sys.stderr)
|
|
206
|
+
return EXIT_USAGE
|
|
207
|
+
except (RegisterDamaged, OSError) as exc:
|
|
208
|
+
print(f"verification could not run: {exc}", file=sys.stderr)
|
|
209
|
+
return EXIT_FAIL
|
|
210
|
+
|
|
211
|
+
receipt_path = write_receipt(result, config.receipts_root() / f"{result.run_id}.json")
|
|
212
|
+
pack_path = None
|
|
213
|
+
if not args.no_pack:
|
|
214
|
+
pack_path = build_pack(result, config.receipts_root() / f"{result.run_id}.html")
|
|
215
|
+
summary_path = None
|
|
216
|
+
if args.summary_file:
|
|
217
|
+
summary_path = Path(args.summary_file)
|
|
218
|
+
summary_path.parent.mkdir(parents=True, exist_ok=True)
|
|
219
|
+
summary_path.write_text(markdown_summary(result), encoding="utf-8")
|
|
220
|
+
|
|
221
|
+
if args.json:
|
|
222
|
+
print(json.dumps(receipt_json(result), indent=2, sort_keys=True))
|
|
223
|
+
else:
|
|
224
|
+
print(terminal_summary(result, use_color=_use_color(args)))
|
|
225
|
+
print(f"\nreceipt: {receipt_path}")
|
|
226
|
+
if pack_path:
|
|
227
|
+
print(f"pack: {pack_path}")
|
|
228
|
+
if summary_path:
|
|
229
|
+
print(f"summary: {summary_path}")
|
|
230
|
+
|
|
231
|
+
return EXIT_FAIL if result.verdict == FAIL_VERDICT else EXIT_OK
|
|
232
|
+
|
|
233
|
+
|
|
234
|
+
def _cmd_check(args: argparse.Namespace) -> int:
|
|
235
|
+
config_path = Path(args.config).resolve()
|
|
236
|
+
config = _load_config(config_path)
|
|
237
|
+
if config is None:
|
|
238
|
+
return EXIT_USAGE
|
|
239
|
+
register = Register(config.register_path())
|
|
240
|
+
try:
|
|
241
|
+
intact, note = register.verify_chain()
|
|
242
|
+
except OSError as exc:
|
|
243
|
+
print(f"the evidence register cannot be read: {exc}", file=sys.stderr)
|
|
244
|
+
return EXIT_FAIL
|
|
245
|
+
print(f"{config.register_path()}: {note}")
|
|
246
|
+
return EXIT_OK if intact else EXIT_FAIL
|
|
247
|
+
|
|
248
|
+
|
|
249
|
+
def _cmd_reproduce(args: argparse.Namespace) -> int:
|
|
250
|
+
config_path = Path(args.config).resolve()
|
|
251
|
+
if not config_path.exists():
|
|
252
|
+
print(f"no config at {config_path}", file=sys.stderr)
|
|
253
|
+
return EXIT_USAGE
|
|
254
|
+
config = _load_config(config_path)
|
|
255
|
+
if config is None:
|
|
256
|
+
return EXIT_USAGE
|
|
257
|
+
try:
|
|
258
|
+
reproduced, notes = reproduce_run(config, args.run)
|
|
259
|
+
except OSError as exc:
|
|
260
|
+
print(f"reproduce could not run: {exc}", file=sys.stderr)
|
|
261
|
+
return EXIT_FAIL
|
|
262
|
+
for note in notes:
|
|
263
|
+
print(note)
|
|
264
|
+
return EXIT_OK if reproduced else EXIT_FAIL
|
|
265
|
+
|
|
266
|
+
|
|
267
|
+
def _cmd_claims_diff(args: argparse.Namespace) -> int:
|
|
268
|
+
config_path = Path(args.config).resolve()
|
|
269
|
+
if not config_path.exists():
|
|
270
|
+
print(f"no config at {config_path}", file=sys.stderr)
|
|
271
|
+
return EXIT_USAGE
|
|
272
|
+
config = _load_config(config_path)
|
|
273
|
+
if config is None:
|
|
274
|
+
return EXIT_USAGE
|
|
275
|
+
if not config.claims_file:
|
|
276
|
+
print("no claims file is configured; nothing to diff", file=sys.stderr)
|
|
277
|
+
return EXIT_USAGE
|
|
278
|
+
try:
|
|
279
|
+
head = load_claims(config.claims_path())
|
|
280
|
+
changes, problem = diff_against_ref(config.root, args.base, config.claims_file, head)
|
|
281
|
+
except ClaimsError as exc:
|
|
282
|
+
print(f"claims diff could not run: {exc}", file=sys.stderr)
|
|
283
|
+
return EXIT_USAGE
|
|
284
|
+
if problem:
|
|
285
|
+
print(f"note: {problem}; every current claim is shown as added")
|
|
286
|
+
if not changes:
|
|
287
|
+
print(f"claims unchanged against {args.base}")
|
|
288
|
+
return EXIT_OK
|
|
289
|
+
weakened = [c for c in changes if c.weakened]
|
|
290
|
+
print(f"{len(changes)} change(s) against {args.base}, {len(weakened)} weakened")
|
|
291
|
+
for change in changes:
|
|
292
|
+
flag = "WEAKENED " if change.weakened else ""
|
|
293
|
+
print(f" {flag}{change.kind} {change.claim_id}: {change.detail}")
|
|
294
|
+
if weakened and config.fail_on_weakened:
|
|
295
|
+
return EXIT_FAIL
|
|
296
|
+
return EXIT_OK
|
|
297
|
+
|
|
298
|
+
|
|
299
|
+
def _cmd_claims_from_report(args: argparse.Namespace) -> int:
|
|
300
|
+
config_path = Path(args.config).resolve()
|
|
301
|
+
config = _load_config(config_path)
|
|
302
|
+
if config is None:
|
|
303
|
+
return EXIT_USAGE
|
|
304
|
+
if args.report == "-":
|
|
305
|
+
text = sys.stdin.read()
|
|
306
|
+
else:
|
|
307
|
+
report_path = Path(args.report)
|
|
308
|
+
if not report_path.is_file():
|
|
309
|
+
print(f"no report at {report_path}", file=sys.stderr)
|
|
310
|
+
return EXIT_USAGE
|
|
311
|
+
text = report_path.read_text(encoding="utf-8", errors="replace")
|
|
312
|
+
|
|
313
|
+
proposals = claims_from_report(text, config.root)
|
|
314
|
+
for item in proposals.unresolved:
|
|
315
|
+
print(f"unresolved: \"{item.sentence}\": {item.reason}")
|
|
316
|
+
if not proposals.claims:
|
|
317
|
+
print("nothing checkable was found in the report: no sentence about tests, build, lint, types, a file or a URL with a command that can be derived from this repository", file=sys.stderr)
|
|
318
|
+
return EXIT_FAIL
|
|
319
|
+
|
|
320
|
+
print(f"{len(proposals.claims)} claim(s) proposed from the report:")
|
|
321
|
+
print(render_proposals_toml(proposals))
|
|
322
|
+
if not args.write:
|
|
323
|
+
print("run again with --write to append them to the claims file")
|
|
324
|
+
return EXIT_OK
|
|
325
|
+
|
|
326
|
+
claims_target = config.root / (config.claims_file or "claims.toml")
|
|
327
|
+
existing: set[str] = set()
|
|
328
|
+
if claims_target.exists():
|
|
329
|
+
try:
|
|
330
|
+
existing = {c.claim_id for c in (load_claims(claims_target) or [])}
|
|
331
|
+
except ClaimsError as exc:
|
|
332
|
+
print(f"cannot append to {claims_target}: {exc}", file=sys.stderr)
|
|
333
|
+
return EXIT_USAGE
|
|
334
|
+
fresh = without_ids(proposals, existing)
|
|
335
|
+
for claim_id in sorted(set(p.claim.claim_id for p in proposals.claims) & existing):
|
|
336
|
+
print(f"skipped {claim_id}: already declared in {claims_target.name}")
|
|
337
|
+
if not fresh.claims:
|
|
338
|
+
print("nothing new to write")
|
|
339
|
+
return EXIT_OK
|
|
340
|
+
block = render_proposals_toml(fresh)
|
|
341
|
+
if claims_target.exists():
|
|
342
|
+
current = claims_target.read_text(encoding="utf-8")
|
|
343
|
+
claims_target.write_text(current + ("" if current.endswith("\n") or not current else "\n") + "\n" + block, encoding="utf-8")
|
|
344
|
+
else:
|
|
345
|
+
claims_target.write_text("# Claims proposed from the agent's own report by countersign claims from-report.\n\n" + block, encoding="utf-8")
|
|
346
|
+
print(f"wrote {len(fresh.claims)} claim(s) to {claims_target}")
|
|
347
|
+
return EXIT_OK
|
|
348
|
+
|
|
349
|
+
|
|
350
|
+
def build_parser() -> argparse.ArgumentParser:
|
|
351
|
+
parser = argparse.ArgumentParser(
|
|
352
|
+
prog="countersign",
|
|
353
|
+
description="Your agent signs. Countersign proves it. Deterministic verification of agent completion claims.",
|
|
354
|
+
)
|
|
355
|
+
parser.add_argument("--version", action="version", version=f"countersign {__version__}")
|
|
356
|
+
sub = parser.add_subparsers(dest="command", required=True)
|
|
357
|
+
|
|
358
|
+
p_init = sub.add_parser("init", help="write a countersign.toml for this repository")
|
|
359
|
+
p_init.add_argument("--config", default="countersign.toml", help="config path (default: countersign.toml)")
|
|
360
|
+
p_init.add_argument("--force", action="store_true", help="overwrite an existing config (and workflow)")
|
|
361
|
+
p_init.add_argument("--github", action="store_true", help="insist on writing the GitHub Actions workflow; an error when no GitHub repository is found")
|
|
362
|
+
p_init.add_argument("--no-github", action="store_true", help="do not write a GitHub Actions workflow even when origin is on github.com")
|
|
363
|
+
p_init.set_defaults(func=_cmd_init)
|
|
364
|
+
|
|
365
|
+
p_verify = sub.add_parser("verify", help="run the gate; write receipt, pack and register entries")
|
|
366
|
+
p_verify.add_argument("--config", default="countersign.toml", help="config path (default: countersign.toml)")
|
|
367
|
+
p_verify.add_argument("--json", action="store_true", help="print the receipt JSON instead of the terminal summary")
|
|
368
|
+
p_verify.add_argument("--no-pack", action="store_true", help="skip writing the HTML evidence pack")
|
|
369
|
+
p_verify.add_argument("--no-claims", action="store_true", help="run the marker scan only; skip the claims check (reported as skipped)")
|
|
370
|
+
p_verify.add_argument("--no-color", action="store_true", help="disable colored output")
|
|
371
|
+
p_verify.add_argument("--summary-file", default=None, help="also write a Markdown summary to this path")
|
|
372
|
+
p_verify.add_argument("--claims-base", default=None, metavar="REF", help="git revision to diff the claims file against; weakened claims fail the gate unless the config says otherwise")
|
|
373
|
+
p_verify.set_defaults(func=_cmd_verify)
|
|
374
|
+
|
|
375
|
+
p_check = sub.add_parser("check", help="verify the evidence register's hash chain")
|
|
376
|
+
p_check.add_argument("--config", default="countersign.toml", help="config path (default: countersign.toml)")
|
|
377
|
+
p_check.set_defaults(func=_cmd_check)
|
|
378
|
+
|
|
379
|
+
p_repro = sub.add_parser("reproduce", help="re-derive a recorded run and compare")
|
|
380
|
+
p_repro.add_argument("--config", default="countersign.toml", help="config path (default: countersign.toml)")
|
|
381
|
+
p_repro.add_argument("--run", required=True, help="run id from the receipt filename")
|
|
382
|
+
p_repro.set_defaults(func=_cmd_reproduce)
|
|
383
|
+
|
|
384
|
+
p_claims = sub.add_parser("claims", help="work with the claims file")
|
|
385
|
+
claims_sub = p_claims.add_subparsers(dest="claims_command", required=True)
|
|
386
|
+
p_diff = claims_sub.add_parser("diff", help="what changed in the claims file against a git revision, weakenings named")
|
|
387
|
+
p_diff.add_argument("--config", default="countersign.toml", help="config path (default: countersign.toml)")
|
|
388
|
+
p_diff.add_argument("--base", required=True, metavar="REF", help="git revision to compare with, for example origin/main")
|
|
389
|
+
p_diff.set_defaults(func=_cmd_claims_diff)
|
|
390
|
+
|
|
391
|
+
p_report = claims_sub.add_parser("from-report", help="propose claims from an agent's completion message (a file, or - for stdin)")
|
|
392
|
+
p_report.add_argument("report", help="path to the agent's report, or - to read standard input")
|
|
393
|
+
p_report.add_argument("--config", default="countersign.toml", help="config path (default: countersign.toml)")
|
|
394
|
+
p_report.add_argument("--write", action="store_true", help="append the proposed claims to the claims file; existing ids are kept as they are")
|
|
395
|
+
p_report.set_defaults(func=_cmd_claims_from_report)
|
|
396
|
+
|
|
397
|
+
return parser
|
|
398
|
+
|
|
399
|
+
|
|
400
|
+
def main(argv: list[str] | None = None) -> int:
|
|
401
|
+
parser = build_parser()
|
|
402
|
+
args = parser.parse_args(argv)
|
|
403
|
+
try:
|
|
404
|
+
return args.func(args)
|
|
405
|
+
except KeyboardInterrupt:
|
|
406
|
+
print("interrupted", file=sys.stderr)
|
|
407
|
+
return EXIT_INTERRUPTED
|
|
408
|
+
|
|
409
|
+
|
|
410
|
+
if __name__ == "__main__":
|
|
411
|
+
sys.exit(main())
|
countersign/config.py
ADDED
|
@@ -0,0 +1,213 @@
|
|
|
1
|
+
# audited on 20260903
|
|
2
|
+
"""Configuration: one TOML file per repository, everything overridable.
|
|
3
|
+
|
|
4
|
+
The defaults are the ones the underlying checks were tuned against (a
|
|
5
|
+
production tree of 546+ source files, reviewed file by file, producing zero
|
|
6
|
+
false positives). Repositories can narrow paths, extend ignores, or turn off
|
|
7
|
+
the test-file exclusion, but the exemption marker mechanism is fixed: it is
|
|
8
|
+
the honest way to say "this line is a false positive" in the file itself,
|
|
9
|
+
where a reviewer sees it.
|
|
10
|
+
|
|
11
|
+
A config that cannot be honoured as written raises ConfigError with the
|
|
12
|
+
reason; it never degrades into a scan of nothing that then passes.
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
from __future__ import annotations
|
|
16
|
+
|
|
17
|
+
import hashlib
|
|
18
|
+
import tomllib
|
|
19
|
+
from dataclasses import dataclass, field
|
|
20
|
+
from pathlib import Path
|
|
21
|
+
from typing import Any
|
|
22
|
+
|
|
23
|
+
DEFAULT_EXEMPT_MARKER = "countersign: exempt"
|
|
24
|
+
|
|
25
|
+
# Directories never scanned, in any repository. Build output and vendored
|
|
26
|
+
# dependencies are not the agent's work; scanning them only produces noise.
|
|
27
|
+
DEFAULT_IGNORE_DIRS: frozenset[str] = frozenset({
|
|
28
|
+
".git", ".hg", ".svn", "node_modules", "__pycache__", ".venv", "venv",
|
|
29
|
+
".mypy_cache", ".pytest_cache", ".ruff_cache", "dist", "build", "target",
|
|
30
|
+
".next", ".nuxt", ".wrangler", ".cache", ".countersign", "coverage",
|
|
31
|
+
".tox", ".idea", ".vscode", "vendor",
|
|
32
|
+
})
|
|
33
|
+
|
|
34
|
+
# Extensions the marker rules are known to behave on: source files where the
|
|
35
|
+
# rules were tuned. Markdown, JSON and prose are deliberately absent; the
|
|
36
|
+
# rules are for code.
|
|
37
|
+
DEFAULT_EXTENSIONS: frozenset[str] = frozenset({
|
|
38
|
+
".py", ".ts", ".tsx", ".js", ".jsx", ".mjs", ".cjs",
|
|
39
|
+
".go", ".rs", ".rb", ".java", ".kt", ".swift", ".php", ".cs", ".scala",
|
|
40
|
+
})
|
|
41
|
+
|
|
42
|
+
# Test files legitimately fabricate data. They are excluded from the marker
|
|
43
|
+
# scan by default and the receipt says so; a repository can override with
|
|
44
|
+
# exclude_tests = false.
|
|
45
|
+
TEST_DIR_NAMES: frozenset[str] = frozenset({"tests", "test", "__tests__", "spec"})
|
|
46
|
+
|
|
47
|
+
TEST_FILE_PREFIXES: tuple[str, ...] = ("test_",)
|
|
48
|
+
TEST_FILE_SUFFIXES: tuple[str, ...] = (
|
|
49
|
+
"_test.py", "_test.go", "_test.rs", "_spec.rb", "_spec.exs",
|
|
50
|
+
".test.ts", ".test.tsx", ".test.js", ".test.jsx", ".spec.ts", ".spec.tsx",
|
|
51
|
+
".spec.js", ".spec.jsx", ".test.mjs", ".spec.mjs",
|
|
52
|
+
)
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
class ConfigError(ValueError):
|
|
56
|
+
"""The config file exists but cannot be honoured as written."""
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def file_sha256(path: Path) -> str:
|
|
60
|
+
digest = hashlib.sha256()
|
|
61
|
+
with Path(path).open("rb") as handle:
|
|
62
|
+
for chunk in iter(lambda: handle.read(65536), b""):
|
|
63
|
+
digest.update(chunk)
|
|
64
|
+
return digest.hexdigest()
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def is_test_file(relative: Path) -> bool:
|
|
68
|
+
name = relative.name
|
|
69
|
+
if any(name.startswith(prefix) for prefix in TEST_FILE_PREFIXES):
|
|
70
|
+
return True
|
|
71
|
+
if any(name.endswith(suffix) for suffix in TEST_FILE_SUFFIXES):
|
|
72
|
+
return True
|
|
73
|
+
return any(part in TEST_DIR_NAMES for part in relative.parts[:-1])
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def _table(raw: dict[str, Any], name: str) -> dict[str, Any]:
|
|
77
|
+
value = raw.get(name, {})
|
|
78
|
+
if not isinstance(value, dict):
|
|
79
|
+
raise ConfigError(f"[{name}] must be a table")
|
|
80
|
+
return value
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def _string_list(table: dict[str, Any], section: str, key: str, default: frozenset[str] | list[str]) -> list[str]:
|
|
84
|
+
value = table.get(key, list(default))
|
|
85
|
+
if not isinstance(value, list) or not all(isinstance(item, str) for item in value):
|
|
86
|
+
raise ConfigError(f"[{section}] {key} must be a list of strings")
|
|
87
|
+
return list(value)
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def _string(table: dict[str, Any], section: str, key: str, default: str) -> str:
|
|
91
|
+
value = table.get(key, default)
|
|
92
|
+
if not isinstance(value, str) or not value.strip():
|
|
93
|
+
raise ConfigError(f"[{section}] {key} must be a non-empty string")
|
|
94
|
+
return value
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def _boolean(table: dict[str, Any], section: str, key: str, default: bool) -> bool:
|
|
98
|
+
value = table.get(key, default)
|
|
99
|
+
if not isinstance(value, bool):
|
|
100
|
+
raise ConfigError(f"[{section}] {key} must be true or false")
|
|
101
|
+
return value
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def _integer(table: dict[str, Any], section: str, key: str, default: int, *, minimum: int) -> int:
|
|
105
|
+
value = table.get(key, default)
|
|
106
|
+
if isinstance(value, bool) or not isinstance(value, int) or value < minimum:
|
|
107
|
+
raise ConfigError(f"[{section}] {key} must be an integer of at least {minimum}")
|
|
108
|
+
return value
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
@dataclass
|
|
112
|
+
class Config:
|
|
113
|
+
"""Everything one verification run needs, resolved from countersign.toml."""
|
|
114
|
+
|
|
115
|
+
root: Path
|
|
116
|
+
config_path: Path
|
|
117
|
+
paths: list[str] = field(default_factory=lambda: ["."])
|
|
118
|
+
ignore_dirs: set[str] = field(default_factory=lambda: set(DEFAULT_IGNORE_DIRS))
|
|
119
|
+
extensions: set[str] = field(default_factory=lambda: set(DEFAULT_EXTENSIONS))
|
|
120
|
+
exempt_marker: str = DEFAULT_EXEMPT_MARKER
|
|
121
|
+
exclude_tests: bool = True
|
|
122
|
+
claims_file: str | None = "claims.toml"
|
|
123
|
+
required_claims: list[str] = field(default_factory=list)
|
|
124
|
+
fail_on_weakened: bool = True
|
|
125
|
+
receipt_dir: str = ".countersign"
|
|
126
|
+
timeout_s: int = 300
|
|
127
|
+
max_output_bytes: int = 20000
|
|
128
|
+
extra: dict = field(default_factory=dict)
|
|
129
|
+
|
|
130
|
+
@classmethod
|
|
131
|
+
def load(cls, path: Path) -> "Config":
|
|
132
|
+
path = Path(path).resolve()
|
|
133
|
+
raw: dict[str, Any] = {}
|
|
134
|
+
if path.exists():
|
|
135
|
+
try:
|
|
136
|
+
with path.open("rb") as handle:
|
|
137
|
+
raw = tomllib.load(handle)
|
|
138
|
+
except tomllib.TOMLDecodeError as exc:
|
|
139
|
+
raise ConfigError(f"{path.name} is not valid TOML: {exc}") from None
|
|
140
|
+
scan = _table(raw, "scan")
|
|
141
|
+
claims = _table(raw, "claims")
|
|
142
|
+
receipts = _table(raw, "receipts")
|
|
143
|
+
run = _table(raw, "run")
|
|
144
|
+
|
|
145
|
+
claims_file_raw = claims.get("file", "claims.toml")
|
|
146
|
+
if not isinstance(claims_file_raw, str):
|
|
147
|
+
raise ConfigError('[claims] file must be a string; use "" to run the marker scan only')
|
|
148
|
+
claims_file = claims_file_raw.strip() or None
|
|
149
|
+
|
|
150
|
+
return cls(
|
|
151
|
+
root=path.parent,
|
|
152
|
+
config_path=path,
|
|
153
|
+
paths=_string_list(scan, "scan", "paths", ["."]),
|
|
154
|
+
ignore_dirs=set(_string_list(scan, "scan", "ignore_dirs", DEFAULT_IGNORE_DIRS)),
|
|
155
|
+
extensions=set(_string_list(scan, "scan", "extensions", DEFAULT_EXTENSIONS)),
|
|
156
|
+
exempt_marker=_string(scan, "scan", "exempt_marker", DEFAULT_EXEMPT_MARKER),
|
|
157
|
+
exclude_tests=_boolean(scan, "scan", "exclude_tests", True),
|
|
158
|
+
claims_file=claims_file,
|
|
159
|
+
required_claims=_string_list(claims, "claims", "required", []),
|
|
160
|
+
fail_on_weakened=_boolean(claims, "claims", "fail_on_weakened", True),
|
|
161
|
+
receipt_dir=_string(receipts, "receipts", "dir", ".countersign"),
|
|
162
|
+
timeout_s=_integer(run, "run", "timeout_s", 300, minimum=1),
|
|
163
|
+
max_output_bytes=_integer(run, "run", "max_output_bytes", 20000, minimum=0),
|
|
164
|
+
extra=raw,
|
|
165
|
+
)
|
|
166
|
+
|
|
167
|
+
def claims_path(self) -> Path | None:
|
|
168
|
+
if not self.claims_file:
|
|
169
|
+
return None
|
|
170
|
+
candidate = (self.root / self.claims_file).resolve()
|
|
171
|
+
return candidate if candidate.exists() else None
|
|
172
|
+
|
|
173
|
+
def register_path(self) -> Path:
|
|
174
|
+
return self.root / self.receipt_dir / "register.jsonl"
|
|
175
|
+
|
|
176
|
+
def receipts_root(self) -> Path:
|
|
177
|
+
return self.root / self.receipt_dir / "receipts"
|
|
178
|
+
|
|
179
|
+
def collect_files(self) -> list[Path]:
|
|
180
|
+
"""Every in-scope source file, deterministically ordered.
|
|
181
|
+
|
|
182
|
+
The root is resolved once and everything is computed against the
|
|
183
|
+
resolved form, so symlinked roots (/var against /private/var on
|
|
184
|
+
macOS) cannot split one tree into two spellings.
|
|
185
|
+
|
|
186
|
+
A scan path that does not exist, or that points outside the root, is
|
|
187
|
+
a ConfigError: a typo in ``paths`` must not become a scan of nothing
|
|
188
|
+
that then passes.
|
|
189
|
+
"""
|
|
190
|
+
root = Path(self.root).resolve()
|
|
191
|
+
collected: list[Path] = []
|
|
192
|
+
for base in self.paths:
|
|
193
|
+
base_path = (root / base).resolve()
|
|
194
|
+
if not base_path.is_relative_to(root):
|
|
195
|
+
raise ConfigError(f"scan path '{base}' is outside the repository root {root}")
|
|
196
|
+
if base_path.is_file():
|
|
197
|
+
candidates = [base_path]
|
|
198
|
+
elif base_path.is_dir():
|
|
199
|
+
candidates = sorted(base_path.rglob("*"))
|
|
200
|
+
else:
|
|
201
|
+
raise ConfigError(f"scan path '{base}' does not exist under {root}")
|
|
202
|
+
for candidate in candidates:
|
|
203
|
+
if not candidate.is_file():
|
|
204
|
+
continue
|
|
205
|
+
relative = candidate.relative_to(root)
|
|
206
|
+
if any(part in self.ignore_dirs for part in relative.parts):
|
|
207
|
+
continue
|
|
208
|
+
if candidate.suffix not in self.extensions:
|
|
209
|
+
continue
|
|
210
|
+
if self.exclude_tests and is_test_file(relative):
|
|
211
|
+
continue
|
|
212
|
+
collected.append(candidate)
|
|
213
|
+
return sorted(set(collected))
|