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/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))