verieffect 0.2.0a1__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.
- verieffect/__init__.py +2 -0
- verieffect/cli.py +468 -0
- verieffect/core.py +727 -0
- verieffect/probes.py +1108 -0
- verieffect-0.2.0a1.dist-info/METADATA +405 -0
- verieffect-0.2.0a1.dist-info/RECORD +11 -0
- verieffect-0.2.0a1.dist-info/WHEEL +5 -0
- verieffect-0.2.0a1.dist-info/entry_points.txt +2 -0
- verieffect-0.2.0a1.dist-info/licenses/LICENSE +21 -0
- verieffect-0.2.0a1.dist-info/top_level.txt +2 -0
- verify_receipt.py +412 -0
verieffect/__init__.py
ADDED
verieffect/cli.py
ADDED
|
@@ -0,0 +1,468 @@
|
|
|
1
|
+
"""
|
|
2
|
+
verieffect CLI — claim → probe → signed receipt.
|
|
3
|
+
|
|
4
|
+
Usage:
|
|
5
|
+
verieffect claim file_exists --agent my-agent --target E:/reports/out.csv \
|
|
6
|
+
--expect '{"min_bytes": 1024}'
|
|
7
|
+
verieffect claim http_ok --agent my-agent --target https://example.com \
|
|
8
|
+
--expect '{"status": 200, "body_contains": "Example"}'
|
|
9
|
+
verieffect verify receipt.json # single receipt (offline)
|
|
10
|
+
verieffect audit --agent my-agent # full chain audit
|
|
11
|
+
verieffect checkpoint --agent my-agent # head+count for external anchoring
|
|
12
|
+
verieffect repair --agent my-agent # move a torn chain tail to a sidecar
|
|
13
|
+
verieffect pubkey # print verifier public key (PEM)
|
|
14
|
+
|
|
15
|
+
Probing happens when the claim is filed; `verify`/`audit` check receipt
|
|
16
|
+
authenticity and chain integrity offline — they never re-probe.
|
|
17
|
+
|
|
18
|
+
Exit codes — claim: 0 PASS, 1 FAIL, 3 UNVERIFIABLE, 2 usage error / claim
|
|
19
|
+
aborted (no receipt written), 4 internal error.
|
|
20
|
+
|
|
21
|
+
Data/key layout (default per-user: ~/.verieffect, i.e. %USERPROFILE%\\.verieffect
|
|
22
|
+
on Windows; override with VERIEFFECT_HOME):
|
|
23
|
+
<home>/keys/verieffect_ed25519.{pem,pub}
|
|
24
|
+
<home>/chains/chain_<agent>_<hash8>.jsonl (legacy chain_<agent>.jsonl still read)
|
|
25
|
+
"""
|
|
26
|
+
from __future__ import annotations
|
|
27
|
+
|
|
28
|
+
import argparse
|
|
29
|
+
import json
|
|
30
|
+
import os
|
|
31
|
+
import sys
|
|
32
|
+
import uuid
|
|
33
|
+
from pathlib import Path
|
|
34
|
+
|
|
35
|
+
from .core import (
|
|
36
|
+
ChainStore, Keyring, describe_repair_notes, receipt_body, receipt_hash,
|
|
37
|
+
repair_notes, schema_errors, sign_receipt, strict_loads, verify_chain,
|
|
38
|
+
verify_receipt,
|
|
39
|
+
)
|
|
40
|
+
from .probes import PROBES
|
|
41
|
+
|
|
42
|
+
# Windows consoles may default to cp1252; our output uses ✓/… — force UTF-8
|
|
43
|
+
# so prints never crash with UnicodeEncodeError (this broke CI on windows).
|
|
44
|
+
for _stream in (sys.stdout, sys.stderr):
|
|
45
|
+
try:
|
|
46
|
+
_stream.reconfigure(encoding="utf-8", errors="replace") # type: ignore[attr-defined]
|
|
47
|
+
except (AttributeError, OSError):
|
|
48
|
+
pass
|
|
49
|
+
|
|
50
|
+
EXIT_PASS, EXIT_FAIL, EXIT_USAGE, EXIT_UNVERIFIABLE, EXIT_INTERNAL = 0, 1, 2, 3, 4
|
|
51
|
+
_VERDICT_EXIT = {"PASS": EXIT_PASS, "FAIL": EXIT_FAIL}
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def default_home() -> Path:
|
|
55
|
+
"""Per-user state dir: %USERPROFILE%\\.verieffect on Windows,
|
|
56
|
+
~/.verieffect elsewhere. Independent of the current directory, so every
|
|
57
|
+
invocation uses ONE key and ONE set of chains."""
|
|
58
|
+
return Path.home() / ".verieffect"
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def home() -> Path:
|
|
62
|
+
env = os.environ.get("VERIEFFECT_HOME")
|
|
63
|
+
if env:
|
|
64
|
+
return Path(env)
|
|
65
|
+
return default_home()
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def _legacy_home() -> Path | None:
|
|
69
|
+
"""Through 0.1.0a1 the default was ./data under the CWD. Returns that dir when it
|
|
70
|
+
holds a Verieffect private key (keys/verieffect_ed25519.pem — not merely
|
|
71
|
+
any keys/ folder: an unrelated project's ./data/keys must not block a new
|
|
72
|
+
user's first claim), the per-user home does not exist yet, and
|
|
73
|
+
VERIEFFECT_HOME is unset — i.e. when a migration notice is due."""
|
|
74
|
+
if os.environ.get("VERIEFFECT_HOME"):
|
|
75
|
+
return None
|
|
76
|
+
legacy = Path.cwd() / "data"
|
|
77
|
+
try:
|
|
78
|
+
if ((legacy / "keys" / "verieffect_ed25519.pem").is_file()
|
|
79
|
+
and not default_home().exists()):
|
|
80
|
+
return legacy
|
|
81
|
+
except OSError:
|
|
82
|
+
return None
|
|
83
|
+
return None
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def _migration_check(creates_state: bool) -> int | None:
|
|
87
|
+
"""Print the migration notice when due. For commands that would create a
|
|
88
|
+
NEW key/chain in the per-user home (claim, pubkey, repair) we stop
|
|
89
|
+
instead — silently minting a second key would split the user's chains.
|
|
90
|
+
Never moves anything automatically."""
|
|
91
|
+
legacy = _legacy_home()
|
|
92
|
+
if legacy is None:
|
|
93
|
+
return None
|
|
94
|
+
new = default_home()
|
|
95
|
+
if os.name == "nt":
|
|
96
|
+
move = f'move "{legacy}" "{new}"'
|
|
97
|
+
keep = f'set VERIEFFECT_HOME={legacy}'
|
|
98
|
+
else:
|
|
99
|
+
move = f'mv "{legacy}" "{new}"'
|
|
100
|
+
keep = f'export VERIEFFECT_HOME="{legacy}"'
|
|
101
|
+
print("NOTICE: Verieffect now keeps keys and chains in a per-user directory\n"
|
|
102
|
+
f" {new}\n"
|
|
103
|
+
f"but found existing keys in the old CWD-relative location\n"
|
|
104
|
+
f" {legacy}\n"
|
|
105
|
+
"To migrate (one time), move it:\n"
|
|
106
|
+
f" {move}\n"
|
|
107
|
+
"or keep using it in place:\n"
|
|
108
|
+
f" {keep}", file=sys.stderr)
|
|
109
|
+
if creates_state:
|
|
110
|
+
print("Refusing to create a new signing key in the per-user directory "
|
|
111
|
+
"until you choose (it would split your receipt chains).",
|
|
112
|
+
file=sys.stderr)
|
|
113
|
+
return EXIT_USAGE
|
|
114
|
+
return None
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def _pubkey_text(args: argparse.Namespace) -> str:
|
|
118
|
+
return Path(args.pubkey or (home() / "keys" / "verieffect_ed25519.pub")).read_text(
|
|
119
|
+
encoding="utf-8")
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
def cmd_claim(args: argparse.Namespace) -> int:
|
|
123
|
+
if args.kind not in PROBES:
|
|
124
|
+
print(f"unknown claim kind: {args.kind} (have: {', '.join(PROBES)})",
|
|
125
|
+
file=sys.stderr)
|
|
126
|
+
return EXIT_USAGE
|
|
127
|
+
if args.expect is None:
|
|
128
|
+
expect = {}
|
|
129
|
+
else:
|
|
130
|
+
try:
|
|
131
|
+
# strict: duplicate keys are rejected — plain json.loads lets the
|
|
132
|
+
# LAST duplicate win silently, so {"min_bytes": 999999,
|
|
133
|
+
# "min_bytes": 1} would assert 1 while the user meant 999999
|
|
134
|
+
expect = strict_loads(args.expect)
|
|
135
|
+
except (ValueError, RecursionError) as e:
|
|
136
|
+
print(f"--expect must be valid JSON (no duplicate keys, no "
|
|
137
|
+
f"NaN/Infinity): {type(e).__name__}: {str(e)[:200]}",
|
|
138
|
+
file=sys.stderr)
|
|
139
|
+
return EXIT_USAGE
|
|
140
|
+
# a non-object --expect (e.g. [], 5 or null) is never probed: it is
|
|
141
|
+
# UNVERIFIABLE. It must NOT reach the probe — library probes treat None as
|
|
142
|
+
# {} (no extra assertions), which made `--expect null` PASS. The receipt's
|
|
143
|
+
# expect field must still be an object (schema), so the raw value is
|
|
144
|
+
# recorded under a marker key.
|
|
145
|
+
non_object = not isinstance(expect, dict)
|
|
146
|
+
receipt_expect = {"_non_object_expect": expect} if non_object else expect
|
|
147
|
+
|
|
148
|
+
rc = _migration_check(creates_state=True)
|
|
149
|
+
if rc is not None:
|
|
150
|
+
return rc
|
|
151
|
+
store = ChainStore(home() / "chains")
|
|
152
|
+
keyring = Keyring(home() / "keys")
|
|
153
|
+
priv = keyring.load_or_create()
|
|
154
|
+
|
|
155
|
+
# Probe FIRST, outside the lock — probes can take seconds (http_ok,
|
|
156
|
+
# exit_code) and holding the chain lock that long starves other claimers
|
|
157
|
+
# into timeout. The critical section is only: read prev-hash → sign →
|
|
158
|
+
# append, which is milliseconds.
|
|
159
|
+
# A probe crash (malformed expect, unexpected data) must still produce a
|
|
160
|
+
# receipt — never a traceback with no evidence written.
|
|
161
|
+
try:
|
|
162
|
+
if non_object:
|
|
163
|
+
observed = {"error": f"expect must be a JSON object for {args.kind}, "
|
|
164
|
+
f"got {'null' if expect is None else type(expect).__name__}"
|
|
165
|
+
" — refusing to judge"}
|
|
166
|
+
verdict = "UNVERIFIABLE"
|
|
167
|
+
else:
|
|
168
|
+
observed, verdict = PROBES[args.kind](args.target, expect)
|
|
169
|
+
except Exception as e: # noqa: BLE001
|
|
170
|
+
observed = {"error": f"probe crashed: {type(e).__name__}: {e}"}
|
|
171
|
+
verdict = "UNVERIFIABLE"
|
|
172
|
+
|
|
173
|
+
# lock the whole read-last-hash → append cycle: concurrent claims must
|
|
174
|
+
# serialize or they fork the chain (both would read the same prev hash)
|
|
175
|
+
try:
|
|
176
|
+
with store.transaction(args.agent):
|
|
177
|
+
prev_hash = store.last_hash(args.agent)
|
|
178
|
+
claim_id = str(uuid.uuid4())
|
|
179
|
+
try:
|
|
180
|
+
# receipt_body json_safe()s expect/observed (bytes, inf/nan, ...)
|
|
181
|
+
body = receipt_body(claim_id=claim_id, agent_id=args.agent,
|
|
182
|
+
kind=args.kind, target=args.target,
|
|
183
|
+
expect=receipt_expect, observed=observed,
|
|
184
|
+
verdict=verdict, prev_hash=prev_hash)
|
|
185
|
+
receipt = sign_receipt(body, priv)
|
|
186
|
+
except (TypeError, ValueError, RecursionError) as e:
|
|
187
|
+
# last resort (e.g. pathologically deep nesting): keep the
|
|
188
|
+
# receipt, drop the evidence. A PASS without recordable
|
|
189
|
+
# evidence is downgraded — never silently kept.
|
|
190
|
+
if verdict == "PASS":
|
|
191
|
+
verdict = "UNVERIFIABLE"
|
|
192
|
+
body = receipt_body(
|
|
193
|
+
claim_id=claim_id, agent_id=args.agent, kind=args.kind,
|
|
194
|
+
target=args.target, expect={"_unserializable": True},
|
|
195
|
+
observed={"error": "observed/expect could not be serialized: "
|
|
196
|
+
f"{type(e).__name__}"},
|
|
197
|
+
verdict=verdict, prev_hash=prev_hash)
|
|
198
|
+
receipt = sign_receipt(body, priv)
|
|
199
|
+
store.append(receipt)
|
|
200
|
+
except RuntimeError as e:
|
|
201
|
+
# structured error from a corrupt/torn chain tail — match audit's behavior
|
|
202
|
+
print(f"CLAIM ABORTED: {e}", file=sys.stderr)
|
|
203
|
+
return EXIT_USAGE
|
|
204
|
+
except TimeoutError as e:
|
|
205
|
+
print(f"CLAIM ABORTED: {e}", file=sys.stderr)
|
|
206
|
+
return EXIT_USAGE
|
|
207
|
+
|
|
208
|
+
out = json.dumps(receipt, indent=2, ensure_ascii=False)
|
|
209
|
+
if args.out:
|
|
210
|
+
try:
|
|
211
|
+
Path(args.out).write_text(out + "\n", encoding="utf-8")
|
|
212
|
+
except OSError as e:
|
|
213
|
+
# receipt is already chained — make the export failure loud so the
|
|
214
|
+
# operator knows the chain entry exists but the file doesn't
|
|
215
|
+
print(f"WARNING: receipt appended to chain but --out write failed: "
|
|
216
|
+
f"{e}", file=sys.stderr)
|
|
217
|
+
print(out)
|
|
218
|
+
# exit code: 0 PASS, 1 FAIL, 3 UNVERIFIABLE — scripts can branch on it
|
|
219
|
+
return _VERDICT_EXIT.get(verdict, EXIT_UNVERIFIABLE)
|
|
220
|
+
|
|
221
|
+
|
|
222
|
+
def cmd_verify(args: argparse.Namespace) -> int:
|
|
223
|
+
try:
|
|
224
|
+
receipt = strict_loads(args.receipt.read())
|
|
225
|
+
except (ValueError, RecursionError) as e:
|
|
226
|
+
print(f"INVALID: cannot parse receipt JSON: {type(e).__name__}: "
|
|
227
|
+
f"{str(e)[:200]}", file=sys.stderr)
|
|
228
|
+
return EXIT_USAGE
|
|
229
|
+
_migration_check(creates_state=False)
|
|
230
|
+
try:
|
|
231
|
+
pub = _pubkey_text(args)
|
|
232
|
+
except (OSError, ValueError) as e:
|
|
233
|
+
print(f"INVALID: cannot read public key: {e}", file=sys.stderr)
|
|
234
|
+
return EXIT_USAGE
|
|
235
|
+
ok, why = verify_receipt(receipt, pub)
|
|
236
|
+
schema_errs = schema_errors(receipt)
|
|
237
|
+
get = receipt.get if isinstance(receipt, dict) else (lambda _k: None)
|
|
238
|
+
if ok:
|
|
239
|
+
out = {"signature_ok": True, "detail": why,
|
|
240
|
+
"schema_valid": not schema_errs,
|
|
241
|
+
"verdict": get("verdict"),
|
|
242
|
+
"claim_id": get("claim_id")}
|
|
243
|
+
if schema_errs:
|
|
244
|
+
out["schema_errors"] = schema_errs
|
|
245
|
+
else:
|
|
246
|
+
# signature failed — the receipt's own fields are UNTRUSTED claims;
|
|
247
|
+
# label them as such rather than echoing a possibly forged "PASS"
|
|
248
|
+
out = {"signature_ok": False, "detail": why,
|
|
249
|
+
"schema_valid": not schema_errs,
|
|
250
|
+
"unverified_claim": {"verdict": get("verdict"),
|
|
251
|
+
"claim_id": get("claim_id")}}
|
|
252
|
+
if schema_errs:
|
|
253
|
+
out["schema_errors"] = schema_errs
|
|
254
|
+
print(json.dumps(out, indent=2))
|
|
255
|
+
return 0 if (ok and not schema_errs) else 1
|
|
256
|
+
|
|
257
|
+
|
|
258
|
+
def _last_info(receipts: list) -> tuple[str | None, object, object]:
|
|
259
|
+
"""(head_hash or None, claim_id, checked_at) of the last receipt —
|
|
260
|
+
tolerant of non-object / non-canonicalizable lines."""
|
|
261
|
+
last = receipts[-1]
|
|
262
|
+
if not isinstance(last, dict):
|
|
263
|
+
return None, "?", "?"
|
|
264
|
+
return receipt_hash(last), last.get("claim_id"), last.get("checked_at")
|
|
265
|
+
|
|
266
|
+
|
|
267
|
+
def cmd_audit(args: argparse.Namespace) -> int:
|
|
268
|
+
_migration_check(creates_state=False)
|
|
269
|
+
store = ChainStore(home() / "chains")
|
|
270
|
+
try:
|
|
271
|
+
receipts = store.read_all(args.agent)
|
|
272
|
+
except RuntimeError as e:
|
|
273
|
+
print(f"AUDIT ABORTED: {e}", file=sys.stderr)
|
|
274
|
+
return 2
|
|
275
|
+
if not receipts:
|
|
276
|
+
print(f"no receipts for agent {args.agent}")
|
|
277
|
+
return 2
|
|
278
|
+
try:
|
|
279
|
+
pub = _pubkey_text(args)
|
|
280
|
+
except (OSError, ValueError) as e:
|
|
281
|
+
print(f"AUDIT ABORTED: cannot read public key: {e}", file=sys.stderr)
|
|
282
|
+
return 2
|
|
283
|
+
problems = verify_chain(receipts, pub, expected_agent_id=args.agent)
|
|
284
|
+
head_hash, last_cid, last_ts = _last_info(receipts)
|
|
285
|
+
if head_hash is None:
|
|
286
|
+
problems.append("last receipt cannot be hashed (not an object or not "
|
|
287
|
+
"canonicalizable) — chain head unhashable")
|
|
288
|
+
head_hash = "unhashable"
|
|
289
|
+
print(f"audited {len(receipts)} receipts for agent '{args.agent}'")
|
|
290
|
+
# truncation detection: compare against an externally anchored head/count
|
|
291
|
+
if args.expect_count is not None and len(receipts) != args.expect_count:
|
|
292
|
+
problems.append(
|
|
293
|
+
f"count mismatch — expected {args.expect_count} receipts, found "
|
|
294
|
+
f"{len(receipts)} (possible tail truncation)")
|
|
295
|
+
if args.expect_head and head_hash != args.expect_head.strip().lower():
|
|
296
|
+
problems.append(
|
|
297
|
+
f"head mismatch — expected {args.expect_head.strip()[:16]}…, got "
|
|
298
|
+
f"{head_hash[:16]}… (possible tail truncation or regeneration)")
|
|
299
|
+
print(f"chain head: {head_hash}")
|
|
300
|
+
print(f"last receipt: {last_cid} @ {last_ts}")
|
|
301
|
+
notes = repair_notes(receipts)
|
|
302
|
+
if notes:
|
|
303
|
+
# a repair replaces a torn receipt one-for-one, so the chain is intact
|
|
304
|
+
# and the count unchanged — the lost receipt (possibly a FAIL) would
|
|
305
|
+
# be invisible unless we say so loudly
|
|
306
|
+
print(f"WARNING: {len(notes)} repair note(s) in this chain. Each marks a "
|
|
307
|
+
"torn receipt that `verieffect repair` moved to a .torn sidecar; "
|
|
308
|
+
"that receipt's verdict is UNKNOWN (it may have been a FAIL):")
|
|
309
|
+
for line in describe_repair_notes(notes):
|
|
310
|
+
print(" " + line)
|
|
311
|
+
print(" Inspect the sidecar file(s). An anchored --expect-count cannot "
|
|
312
|
+
"detect a repair (the note keeps the count); compare an anchored "
|
|
313
|
+
"--expect-head.")
|
|
314
|
+
if problems:
|
|
315
|
+
print("CHAIN BROKEN / SIGNATURES INVALID:")
|
|
316
|
+
for p in problems:
|
|
317
|
+
print(" ", p)
|
|
318
|
+
return 1
|
|
319
|
+
suffix = (f" — but {len(notes)} repair note(s), see WARNING above"
|
|
320
|
+
if notes else "")
|
|
321
|
+
print(f"chain intact, all signatures valid ✓{suffix}")
|
|
322
|
+
if notes and args.expect_count is not None and not args.expect_head:
|
|
323
|
+
print("note: --expect-count alone is NOT sufficient here: a repaired "
|
|
324
|
+
"tail keeps the receipt count. Anchor and pass --expect-head.")
|
|
325
|
+
if args.expect_count is None and not args.expect_head:
|
|
326
|
+
print("note: intact ⇒ no edits/mid-chain deletions detected. Tail "
|
|
327
|
+
"truncation is only detectable via --expect-head (anchor the head "
|
|
328
|
+
"externally); --expect-count alone misses a repaired tail.")
|
|
329
|
+
return 0
|
|
330
|
+
|
|
331
|
+
|
|
332
|
+
def cmd_checkpoint(args: argparse.Namespace) -> int:
|
|
333
|
+
"""Print the externally anchorable head for an agent's chain.
|
|
334
|
+
|
|
335
|
+
Intended workflow: cron commits/timestamps this output (git commit,
|
|
336
|
+
OpenTimestamps, a posted message). Auditors later compare with
|
|
337
|
+
audit --expect-head <anchored> / --expect-count <anchored> to detect
|
|
338
|
+
tail truncation and key-holder regeneration.
|
|
339
|
+
"""
|
|
340
|
+
_migration_check(creates_state=False)
|
|
341
|
+
store = ChainStore(home() / "chains")
|
|
342
|
+
try:
|
|
343
|
+
receipts = store.read_all(args.agent)
|
|
344
|
+
except RuntimeError as e:
|
|
345
|
+
print(f"CHECKPOINT ABORTED: {e}", file=sys.stderr)
|
|
346
|
+
return 2
|
|
347
|
+
if not receipts:
|
|
348
|
+
print(f"no receipts for agent {args.agent}", file=sys.stderr)
|
|
349
|
+
return 2
|
|
350
|
+
head, _cid, as_of = _last_info(receipts)
|
|
351
|
+
if head is None:
|
|
352
|
+
print("CHECKPOINT ABORTED: last receipt cannot be hashed (not an object "
|
|
353
|
+
"or not canonicalizable) — run audit; refusing to anchor a "
|
|
354
|
+
"corrupt head", file=sys.stderr)
|
|
355
|
+
return 2
|
|
356
|
+
if args.json:
|
|
357
|
+
print(json.dumps({"agent_id": args.agent, "count": len(receipts),
|
|
358
|
+
"head": head, "as_of": as_of}))
|
|
359
|
+
else:
|
|
360
|
+
print(f"agent: {args.agent}")
|
|
361
|
+
print(f"count: {len(receipts)}")
|
|
362
|
+
print(f"head: {head}")
|
|
363
|
+
print(f"as_of: {as_of}")
|
|
364
|
+
print("# anchor this: commit it, stamp it (OpenTimestamps), or post it.")
|
|
365
|
+
print("# audit later with: audit --agent " + args.agent +
|
|
366
|
+
f" --expect-count {len(receipts)} --expect-head {head}")
|
|
367
|
+
return 0
|
|
368
|
+
|
|
369
|
+
|
|
370
|
+
def cmd_repair(args: argparse.Namespace) -> int:
|
|
371
|
+
"""Recover a chain whose last line was torn by a crash mid-append."""
|
|
372
|
+
rc = _migration_check(creates_state=True)
|
|
373
|
+
if rc is not None:
|
|
374
|
+
return rc
|
|
375
|
+
keyring = Keyring(home() / "keys")
|
|
376
|
+
if not keyring.priv_path.exists():
|
|
377
|
+
print(f"REPAIR ABORTED: no signing key at {keyring.priv_path}",
|
|
378
|
+
file=sys.stderr)
|
|
379
|
+
return 2
|
|
380
|
+
priv = keyring.load_or_create()
|
|
381
|
+
store = ChainStore(home() / "chains")
|
|
382
|
+
try:
|
|
383
|
+
rep = store.repair_tail(args.agent, priv)
|
|
384
|
+
except (RuntimeError, TimeoutError, OSError) as e:
|
|
385
|
+
print(f"REPAIR ABORTED: {e}", file=sys.stderr)
|
|
386
|
+
return 1
|
|
387
|
+
status = rep["status"]
|
|
388
|
+
if status == "repaired":
|
|
389
|
+
print(f"WARNING: torn tail ({rep['torn_bytes']} bytes) moved to "
|
|
390
|
+
f"{rep['sidecar']}", file=sys.stderr)
|
|
391
|
+
print(f"repaired: appended signed repair_note receipt "
|
|
392
|
+
f"{rep['receipt']['claim_id']} to {rep['chain']}")
|
|
393
|
+
elif status == "newline_added":
|
|
394
|
+
print(f"repaired: last receipt was missing its newline ({rep['chain']})")
|
|
395
|
+
elif status == "intact":
|
|
396
|
+
print(f"chain tail is intact — nothing to repair ({rep['chain']})")
|
|
397
|
+
else:
|
|
398
|
+
print(f"nothing to repair: {status} ({rep['chain']})")
|
|
399
|
+
return 0
|
|
400
|
+
|
|
401
|
+
|
|
402
|
+
def cmd_pubkey(_: argparse.Namespace) -> int:
|
|
403
|
+
rc = _migration_check(creates_state=True)
|
|
404
|
+
if rc is not None:
|
|
405
|
+
return rc
|
|
406
|
+
keyring = Keyring(home() / "keys")
|
|
407
|
+
keyring.load_or_create()
|
|
408
|
+
print(keyring.public_key_pem())
|
|
409
|
+
return 0
|
|
410
|
+
|
|
411
|
+
|
|
412
|
+
def main(argv: list[str] | None = None) -> int:
|
|
413
|
+
ap = argparse.ArgumentParser(prog="verieffect")
|
|
414
|
+
sub = ap.add_subparsers(dest="cmd", required=True)
|
|
415
|
+
|
|
416
|
+
c = sub.add_parser("claim", help="run a probe and emit a signed receipt")
|
|
417
|
+
c.add_argument("kind", choices=sorted(PROBES))
|
|
418
|
+
c.add_argument("--agent", required=True)
|
|
419
|
+
c.add_argument("--target", required=True,
|
|
420
|
+
help="for exit_code: the command is RE-RUN by Verieffect when "
|
|
421
|
+
"the claim is filed — use idempotent commands only "
|
|
422
|
+
"(see docs/receipt-schema.md)")
|
|
423
|
+
c.add_argument("--expect", default=None, help="JSON object of expectations")
|
|
424
|
+
c.add_argument("--out", default=None, help="also write receipt to this file")
|
|
425
|
+
c.set_defaults(fn=cmd_claim)
|
|
426
|
+
|
|
427
|
+
v = sub.add_parser("verify", help="verify one receipt's signature (offline)")
|
|
428
|
+
v.add_argument("receipt", type=argparse.FileType("r", encoding="utf-8"))
|
|
429
|
+
v.add_argument("--pubkey", default=None)
|
|
430
|
+
v.set_defaults(fn=cmd_verify)
|
|
431
|
+
|
|
432
|
+
a = sub.add_parser("audit", help="verify a full agent chain")
|
|
433
|
+
a.add_argument("--agent", required=True)
|
|
434
|
+
a.add_argument("--pubkey", default=None)
|
|
435
|
+
a.add_argument("--expect-count", type=int, default=None,
|
|
436
|
+
help="expected receipt count (externally anchored) — detects tail truncation")
|
|
437
|
+
a.add_argument("--expect-head", default=None,
|
|
438
|
+
help="expected sha256 of the last receipt — detects truncation/regeneration")
|
|
439
|
+
a.set_defaults(fn=cmd_audit)
|
|
440
|
+
|
|
441
|
+
p = sub.add_parser("pubkey", help="print the verifier public key")
|
|
442
|
+
p.set_defaults(fn=cmd_pubkey)
|
|
443
|
+
|
|
444
|
+
k = sub.add_parser("checkpoint",
|
|
445
|
+
help="print the chain head + count for external anchoring")
|
|
446
|
+
k.add_argument("--agent", required=True)
|
|
447
|
+
k.add_argument("--json", action="store_true", help="machine-readable output")
|
|
448
|
+
k.set_defaults(fn=cmd_checkpoint)
|
|
449
|
+
|
|
450
|
+
r = sub.add_parser("repair",
|
|
451
|
+
help="move a torn (crash-truncated) chain tail to a .torn "
|
|
452
|
+
"sidecar and append a signed repair note")
|
|
453
|
+
r.add_argument("--agent", required=True)
|
|
454
|
+
r.set_defaults(fn=cmd_repair)
|
|
455
|
+
|
|
456
|
+
args = ap.parse_args(argv)
|
|
457
|
+
try:
|
|
458
|
+
return args.fn(args)
|
|
459
|
+
except Exception as e: # noqa: BLE001 — never a bare traceback; distinct exit code
|
|
460
|
+
print(f"INTERNAL ERROR: {type(e).__name__}: {str(e)[:500]}", file=sys.stderr)
|
|
461
|
+
if os.environ.get("VERIEFFECT_DEBUG"):
|
|
462
|
+
import traceback
|
|
463
|
+
traceback.print_exc()
|
|
464
|
+
return EXIT_INTERNAL
|
|
465
|
+
|
|
466
|
+
|
|
467
|
+
if __name__ == "__main__":
|
|
468
|
+
raise SystemExit(main())
|