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/__init__.py
ADDED
|
@@ -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"
|
countersign/__main__.py
ADDED
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
|