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.
@@ -0,0 +1,8 @@
1
+ # audited on 20260903
2
+ """Countersign: deterministic verification of agent completion claims.
3
+
4
+ The agent signs (claims the work is done). Countersign only countersigns
5
+ after the claim survived deterministic checks it can re-run later.
6
+ """
7
+
8
+ __version__ = "0.2.0"
@@ -0,0 +1,4 @@
1
+ # audited on 20260903
2
+ from .cli import main
3
+
4
+ raise SystemExit(main())
countersign/claims.py ADDED
@@ -0,0 +1,245 @@
1
+ # audited on 20260903
2
+ """The claims protocol: every completion claim must be falsifiable.
3
+
4
+ An agent (or a human) declares what is true about the work in claims.toml.
5
+ Each claim carries the command that would fail if the claim were false.
6
+ Countersign runs those commands and records the verdict. Nothing here
7
+ interprets prose or trusts a summary: a claim either survived its command
8
+ or it did not.
9
+
10
+ The three expectations a claim can declare:
11
+
12
+ expect = "exit 0" the command must succeed (default)
13
+ expect = "nonzero exit" the command must fail (negative tests)
14
+ expect = "output contains" the needle must appear in combined output
15
+
16
+ This is the protocol that makes "done" a testable statement: if nobody can
17
+ say what command would disprove the claim, the claim was not a claim.
18
+
19
+ Commands run through the shell in the repository root, in their own process
20
+ group. A claim that times out is killed together with everything it
21
+ spawned, so a hung test runner cannot outlive the verdict that recorded it.
22
+ """
23
+
24
+ from __future__ import annotations
25
+
26
+ import os
27
+ import signal
28
+ import subprocess
29
+ import time
30
+ import tomllib
31
+ from dataclasses import dataclass
32
+ from pathlib import Path
33
+ from typing import Any
34
+
35
+ PASS = "pass"
36
+ FAIL = "fail"
37
+ TIMEOUT = "timeout"
38
+ # A claim the repository's config requires but the claims file does not
39
+ # declare. It fails the gate: a required claim that nobody wrote is the
40
+ # quietest way to weaken a repository's own standard.
41
+ MISSING = "missing"
42
+ NOT_PASSED = frozenset({FAIL, TIMEOUT, MISSING})
43
+
44
+ VALID_EXPECTATIONS = frozenset({"exit 0", "nonzero exit", "output contains"})
45
+
46
+ # How long to wait for a killed command's pipes to drain before giving up
47
+ # on collecting its output. A grandchild that escaped its process group can
48
+ # hold the pipe open; the verdict must not hang on it.
49
+ _DRAIN_AFTER_KILL_S = 5
50
+
51
+
52
+ @dataclass(frozen=True)
53
+ class Claim:
54
+ claim_id: str
55
+ statement: str
56
+ command: str
57
+ expect: str = "exit 0"
58
+ needle: str | None = None
59
+ timeout_s: int | None = None
60
+
61
+
62
+ @dataclass
63
+ class ClaimResult:
64
+ claim_id: str
65
+ statement: str
66
+ command: str
67
+ expect: str
68
+ status: str
69
+ exit_code: int | None = None
70
+ duration_ms: int = 0
71
+ output_excerpt: str = ""
72
+
73
+
74
+ class ClaimsError(ValueError):
75
+ """The claims file exists but cannot be honoured as written."""
76
+
77
+
78
+ def load_claims(path: Path | None) -> list[Claim] | None:
79
+ """Parse claims.toml. None means no file (a reported skip, not silence)."""
80
+ if path is None:
81
+ return None
82
+ with Path(path).open("rb") as handle:
83
+ data = handle.read()
84
+ return parse_claims(data, Path(path).name)
85
+
86
+
87
+ def parse_claims(data: bytes | str, source_name: str = "claims.toml") -> list[Claim]:
88
+ """Parse the text of a claims file. Raises ClaimsError for anything that
89
+ cannot be honoured as written."""
90
+ if isinstance(data, bytes):
91
+ try:
92
+ # TOML is UTF-8 by definition; a byte order mark is tolerated,
93
+ # anything undecodable is refused rather than silently repaired,
94
+ # because a repaired byte inside a command would change what runs.
95
+ data = data.decode("utf-8-sig")
96
+ except UnicodeDecodeError as exc:
97
+ raise ClaimsError(f"{source_name} is not valid UTF-8: {exc}") from None
98
+ try:
99
+ raw = tomllib.loads(data)
100
+ except tomllib.TOMLDecodeError as exc:
101
+ raise ClaimsError(f"{source_name} is not valid TOML: {exc}") from None
102
+ declared: Any = raw.get("claim", [])
103
+ if not isinstance(declared, list) or not all(isinstance(entry, dict) for entry in declared):
104
+ raise ClaimsError("claims must be declared as an array of tables: one [[claim]] block per claim")
105
+ claims: list[Claim] = []
106
+ seen: set[str] = set()
107
+ for index, entry in enumerate(declared, start=1):
108
+ claim_id = str(entry.get("id", "")).strip()
109
+ if not claim_id:
110
+ raise ClaimsError(f"claim {index} has no id")
111
+ if claim_id in seen:
112
+ raise ClaimsError(f"claim id '{claim_id}' is declared twice")
113
+ seen.add(claim_id)
114
+ statement = str(entry.get("statement", "")).strip()
115
+ if not statement:
116
+ raise ClaimsError(f"claim '{claim_id}' has no statement")
117
+ command = str(entry.get("command", "")).strip()
118
+ if not command:
119
+ raise ClaimsError(f"claim '{claim_id}' declares no command; a claim without a disproof command is not falsifiable")
120
+ expect = str(entry.get("expect", "exit 0"))
121
+ if expect not in VALID_EXPECTATIONS:
122
+ raise ClaimsError(
123
+ f"claim '{claim_id}' uses expect = '{expect}', which is not one of {sorted(VALID_EXPECTATIONS)}"
124
+ )
125
+ needle = entry.get("needle")
126
+ if expect == "output contains" and not needle:
127
+ raise ClaimsError(f"claim '{claim_id}' expects 'output contains' but declares no needle")
128
+ timeout_s: int | None = None
129
+ if "timeout_s" in entry:
130
+ raw_timeout = entry["timeout_s"]
131
+ if isinstance(raw_timeout, bool) or not isinstance(raw_timeout, int) or raw_timeout < 1:
132
+ raise ClaimsError(f"claim '{claim_id}' has timeout_s = {raw_timeout!r}; it must be a whole number of seconds, at least 1")
133
+ timeout_s = raw_timeout
134
+ claims.append(
135
+ Claim(
136
+ claim_id=claim_id,
137
+ statement=statement,
138
+ command=command,
139
+ expect=expect,
140
+ needle=str(needle) if needle is not None else None,
141
+ timeout_s=timeout_s,
142
+ )
143
+ )
144
+ return claims
145
+
146
+
147
+ def missing_claim(claim_id: str) -> ClaimResult:
148
+ """The verdict for a required claim that was never declared."""
149
+ return ClaimResult(
150
+ claim_id=claim_id,
151
+ statement="required by countersign.toml but not declared in the claims file",
152
+ command="",
153
+ expect="exit 0",
154
+ status=MISSING,
155
+ )
156
+
157
+
158
+ def _truncate(text: str, limit: int) -> str:
159
+ if len(text) <= limit:
160
+ return text
161
+ half = limit // 2
162
+ return text[:half] + f"\n... [{len(text) - limit} characters truncated] ...\n" + text[-half:]
163
+
164
+
165
+ def _decode(data: bytes | None) -> str:
166
+ """Command output as text, whatever bytes the command produced."""
167
+ if not data:
168
+ return ""
169
+ return data.decode("utf-8", errors="replace")
170
+
171
+
172
+ def _kill_tree(process: subprocess.Popen) -> None:
173
+ """Kill the command and everything it started."""
174
+ try:
175
+ if os.name == "nt":
176
+ subprocess.run(
177
+ ["taskkill", "/F", "/T", "/PID", str(process.pid)],
178
+ capture_output=True, check=False, timeout=15,
179
+ )
180
+ else:
181
+ os.killpg(process.pid, signal.SIGKILL)
182
+ except (OSError, subprocess.SubprocessError):
183
+ pass
184
+ try:
185
+ process.kill()
186
+ except OSError:
187
+ pass
188
+
189
+
190
+ def run_claim(claim: Claim, cwd: Path, default_timeout_s: int, max_output_bytes: int) -> ClaimResult:
191
+ """Run one claim's command and judge it exactly as declared."""
192
+ timeout_s = claim.timeout_s if claim.timeout_s is not None else default_timeout_s
193
+ started = time.monotonic()
194
+ isolation: dict[str, Any] = (
195
+ {"creationflags": subprocess.CREATE_NEW_PROCESS_GROUP}
196
+ if os.name == "nt"
197
+ else {"start_new_session": True}
198
+ )
199
+ process = subprocess.Popen(
200
+ claim.command,
201
+ shell=True,
202
+ cwd=str(cwd),
203
+ stdout=subprocess.PIPE,
204
+ stderr=subprocess.PIPE,
205
+ **isolation,
206
+ )
207
+ try:
208
+ stdout, stderr = process.communicate(timeout=timeout_s)
209
+ except subprocess.TimeoutExpired:
210
+ _kill_tree(process)
211
+ try:
212
+ stdout, stderr = process.communicate(timeout=_DRAIN_AFTER_KILL_S)
213
+ except subprocess.TimeoutExpired:
214
+ stdout, stderr = b"", b""
215
+ return ClaimResult(
216
+ claim_id=claim.claim_id,
217
+ statement=claim.statement,
218
+ command=claim.command,
219
+ expect=claim.expect,
220
+ status=TIMEOUT,
221
+ duration_ms=int((time.monotonic() - started) * 1000),
222
+ output_excerpt=_truncate(_decode(stdout) + _decode(stderr), max_output_bytes),
223
+ )
224
+
225
+ duration_ms = int((time.monotonic() - started) * 1000)
226
+ combined = _decode(stdout) + _decode(stderr)
227
+ excerpt = _truncate(combined, max_output_bytes)
228
+
229
+ if claim.expect == "exit 0":
230
+ status = PASS if process.returncode == 0 else FAIL
231
+ elif claim.expect == "nonzero exit":
232
+ status = PASS if process.returncode != 0 else FAIL
233
+ else:
234
+ status = PASS if (claim.needle or "") in combined else FAIL
235
+
236
+ return ClaimResult(
237
+ claim_id=claim.claim_id,
238
+ statement=claim.statement,
239
+ command=claim.command,
240
+ expect=claim.expect,
241
+ status=status,
242
+ exit_code=process.returncode,
243
+ duration_ms=duration_ms,
244
+ output_excerpt=excerpt,
245
+ )
@@ -0,0 +1,134 @@
1
+ # audited on 20260903
2
+ """What changed in the claims file, judged against a base revision.
3
+
4
+ The agent that wrote the code can also write the claims. The quiet way to
5
+ pass a gate is not to fix the code but to soften the claim: drop the test
6
+ claim, change ``exit 0`` to ``nonzero exit``, point the needle at a string
7
+ that is always there. This module makes that visible where a reviewer
8
+ looks: as a diff of claims between a base revision (the branch a pull
9
+ request targets) and the working tree, with every weakening named.
10
+
11
+ What counts as weakened, deterministically:
12
+
13
+ removed the claim is gone
14
+ expect changed the judgement rule changed
15
+ needle changed what the output must contain changed
16
+
17
+ A changed command is reported as changed and left to the reviewer: the
18
+ engine cannot know whether ``npm test`` became stricter or looser. A
19
+ changed statement or timeout is reported as wording.
20
+ """
21
+
22
+ from __future__ import annotations
23
+
24
+ import subprocess
25
+ from dataclasses import dataclass
26
+ from pathlib import Path
27
+
28
+ from .claims import Claim, ClaimsError, parse_claims
29
+
30
+ ADDED = "added"
31
+ REMOVED = "removed"
32
+ CHANGED = "changed"
33
+
34
+ WEAKENING_FIELDS = ("expect", "needle")
35
+
36
+
37
+ @dataclass(frozen=True)
38
+ class ClaimChange:
39
+ claim_id: str
40
+ kind: str
41
+ fields: tuple[str, ...]
42
+ weakened: bool
43
+ detail: str
44
+
45
+
46
+ def claims_text_at(root: Path, ref: str, claims_file: str) -> bytes | None:
47
+ """The claims file as it was at ``ref``; None when it did not exist there.
48
+
49
+ Raises ClaimsError when git cannot answer at all (no repository, no such
50
+ ref, git missing): a base that cannot be read must not pass as "no
51
+ claims at base".
52
+ """
53
+ try:
54
+ top = subprocess.run(
55
+ ["git", "rev-parse", "--show-toplevel"], cwd=str(root),
56
+ capture_output=True, text=True, timeout=15,
57
+ )
58
+ except (OSError, subprocess.TimeoutExpired) as exc:
59
+ raise ClaimsError(f"cannot read claims at {ref}: git is not available ({exc})") from None
60
+ if top.returncode != 0:
61
+ raise ClaimsError(f"cannot read claims at {ref}: {root} is not inside a git repository")
62
+ toplevel = Path(top.stdout.strip()).resolve()
63
+ target = (Path(root).resolve() / claims_file).resolve()
64
+ if not target.is_relative_to(toplevel):
65
+ raise ClaimsError(f"cannot read claims at {ref}: {target} is outside the repository {toplevel}")
66
+ relative = target.relative_to(toplevel).as_posix()
67
+ # ls-tree answers "does this path exist at that revision" with its exit
68
+ # code and output alone, so no error message has to be parsed (git
69
+ # localises its messages).
70
+ listed = _git_bytes(toplevel, ref, "ls-tree", ref, "--", relative)
71
+ if listed.returncode != 0:
72
+ raise ClaimsError(f"cannot read claims at {ref}: {listed.stderr.decode('utf-8', errors='replace').strip() or 'not a valid revision'}")
73
+ if not listed.stdout.strip():
74
+ return None
75
+ shown = _git_bytes(toplevel, ref, "show", f"{ref}:{relative}")
76
+ if shown.returncode != 0:
77
+ raise ClaimsError(f"cannot read claims at {ref}: {shown.stderr.decode('utf-8', errors='replace').strip() or 'git show failed'}")
78
+ return shown.stdout
79
+
80
+
81
+ def _git_bytes(cwd: Path, ref: str, *args: str) -> subprocess.CompletedProcess[bytes]:
82
+ try:
83
+ return subprocess.run(["git", *args], cwd=str(cwd), capture_output=True, timeout=30)
84
+ except (OSError, subprocess.TimeoutExpired) as exc:
85
+ raise ClaimsError(f"cannot read claims at {ref}: {exc}") from None
86
+
87
+
88
+ def diff_claims(base: list[Claim] | None, head: list[Claim] | None) -> list[ClaimChange]:
89
+ """Changes from ``base`` to ``head``, ordered by claim id. None means the
90
+ file did not exist on that side."""
91
+ base_by_id = {c.claim_id: c for c in (base or [])}
92
+ head_by_id = {c.claim_id: c for c in (head or [])}
93
+ changes: list[ClaimChange] = []
94
+ for claim_id in sorted(set(base_by_id) | set(head_by_id)):
95
+ before = base_by_id.get(claim_id)
96
+ after = head_by_id.get(claim_id)
97
+ if before is None and after is not None:
98
+ changes.append(ClaimChange(claim_id, ADDED, (), False, f"added: {after.statement}"))
99
+ continue
100
+ if before is not None and after is None:
101
+ changes.append(ClaimChange(claim_id, REMOVED, (), True, f"removed: {before.statement}"))
102
+ continue
103
+ if before is None or after is None:
104
+ continue # unreachable: the id came from one of the two sides
105
+ fields = tuple(
106
+ name for name in ("statement", "command", "expect", "needle", "timeout_s")
107
+ if getattr(before, name) != getattr(after, name)
108
+ )
109
+ if not fields:
110
+ continue
111
+ weakened = any(name in WEAKENING_FIELDS for name in fields)
112
+ parts = []
113
+ for name in fields:
114
+ parts.append(f"{name}: {getattr(before, name)!r} to {getattr(after, name)!r}")
115
+ changes.append(ClaimChange(claim_id, CHANGED, fields, weakened, "; ".join(parts)))
116
+ return changes
117
+
118
+
119
+ def diff_against_ref(root: Path, ref: str, claims_file: str, head: list[Claim] | None) -> tuple[list[ClaimChange], str | None]:
120
+ """Diff the working tree's claims against those at ``ref``.
121
+
122
+ Returns (changes, base_problem). ``base_problem`` names a base claims
123
+ file that exists but cannot be parsed; the diff then treats the base
124
+ as empty so that every head claim shows as added, and the problem is
125
+ reported next to it rather than hidden.
126
+ """
127
+ text = claims_text_at(root, ref, claims_file)
128
+ if text is None:
129
+ return diff_claims(None, head), None
130
+ try:
131
+ base = parse_claims(text, f"{claims_file} at {ref}")
132
+ except ClaimsError as exc:
133
+ return diff_claims(None, head), str(exc)
134
+ return diff_claims(base, head), None