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 ADDED
@@ -0,0 +1,2 @@
1
+ """verieffect: deterministic claim verification with signed receipts."""
2
+ __version__ = "0.2.0a1"
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())