asexec 0.3.14__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.
asexec/__init__.py ADDED
@@ -0,0 +1,17 @@
1
+ """asexec - a pre-registration & notarization primitive for AI evaluations.
2
+
3
+ Local-first, pseudonymous, offline-verifiable. An evaluator signs a
4
+ *pre-registration* before a run and *receipts* after, publishing them to a
5
+ public git repo whose witnessed history is the (social) ceiling that gives
6
+ "pre" its meaning. A third party can verify the commitment -> fulfillment /
7
+ gap offline, trusting only a keypair and the published files.
8
+
9
+ See the module docstrings and the README for what this does!
10
+ """
11
+
12
+ __version__ = "0.3.14"
13
+
14
+ SCHEMA_VERSION = "asexec"
15
+ PREDICATE_TYPE = "https://asexec.dev/manifest"
16
+
17
+ __all__ = ["__version__", "SCHEMA_VERSION", "PREDICATE_TYPE"]
asexec/__main__.py ADDED
@@ -0,0 +1,4 @@
1
+ from .cli import main
2
+
3
+ if __name__ == "__main__": # pragma: no cover
4
+ raise SystemExit(main())
asexec/canonical.py ADDED
@@ -0,0 +1,61 @@
1
+ """Canonical serialization and the PAE signing input.
2
+
3
+ We sign the PAE (Pre-Authentication Encoding, borrowed from DSSE) of a fixed,
4
+ documented canonical byte serialization of the payload.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import json
10
+ from typing import Any
11
+
12
+ # Media type of the signed payload (an asexec manifest body).
13
+ PAYLOAD_TYPE = b"application/vnd.asexec+json"
14
+
15
+ # Domain-separation prefix for our PAE construction (cf. DSSE's "DSSEv1").
16
+ _PAE_PREFIX = b"asexec-PAE/v1"
17
+
18
+
19
+ def canonical_bytes(payload: Any) -> bytes:
20
+ """Deterministically serialize a JSON-compatible object to bytes.
21
+
22
+ Rules (documented so any implementation can reproduce them):
23
+ - UTF-8
24
+ - object keys sorted (lexicographic, by Unicode code point)
25
+ - no insignificant whitespace (compact separators)
26
+ - non-ASCII preserved (ensure_ascii=False)
27
+
28
+ The payload passed here must NOT contain the outer signature block; only
29
+ the manifest body is signed.
30
+ """
31
+ return json.dumps(
32
+ payload,
33
+ sort_keys=True,
34
+ separators=(",", ":"),
35
+ ensure_ascii=False,
36
+ ).encode("utf-8")
37
+
38
+
39
+ def pae(payload_type: bytes, body: bytes) -> bytes:
40
+ """Pre-Authentication Encoding.
41
+
42
+ PAE(t, b) = PREFIX SP LEN(t) SP t SP LEN(b) SP b
43
+
44
+ where SP is a single ASCII space and LEN is ASCII decimal of the byte
45
+ length. Signing/verifying always happens over this string, never over raw
46
+ JSON.
47
+ """
48
+ return b" ".join(
49
+ [
50
+ _PAE_PREFIX,
51
+ str(len(payload_type)).encode("ascii"),
52
+ payload_type,
53
+ str(len(body)).encode("ascii"),
54
+ body,
55
+ ]
56
+ )
57
+
58
+
59
+ def signing_input(payload: Any) -> bytes:
60
+ """The exact bytes to sign/verify for a manifest payload."""
61
+ return pae(PAYLOAD_TYPE, canonical_bytes(payload))
asexec/cli.py ADDED
@@ -0,0 +1,455 @@
1
+ """asexec command-line interface.
2
+
3
+ Commands: keygen, prereg, postreg, verify, identity.
4
+
5
+ ``verify`` is fully offline; only the sign-time drand (``--floor``)/Roughtime (``--ceiling``)
6
+ fetches and ``identity match`` touch the network.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import argparse
12
+ import json
13
+ import os
14
+ import sys
15
+ import uuid
16
+ from collections.abc import Callable
17
+ from datetime import datetime
18
+ from typing import TypeVar
19
+
20
+ from . import __version__, drand, hashing, identity, keys, manifest, verifier
21
+ from .errors import VerificationError
22
+
23
+ OK = "✓"
24
+ NO = "✗"
25
+ T = TypeVar("T")
26
+
27
+
28
+ # --------------------------------------------------------------------------- #
29
+ # input helpers
30
+ # --------------------------------------------------------------------------- #
31
+ def _load_json(path: str) -> str | dict:
32
+ """Load JSON from the given path."""
33
+ try:
34
+ with open(path) as f:
35
+ return json.load(f)
36
+ except json.JSONDecodeError as e:
37
+ raise SystemExit(f"error: {path} must contain valid JSON: {e}") from e
38
+
39
+
40
+ def _get_arg_or_file(
41
+ args, arg_name: str, file_arg_name: str, load_file: Callable[[str], T]
42
+ ) -> str | T | None:
43
+ """Load a value from either ``--<arg>`` or ``--<file-arg>``, but not both."""
44
+ value = getattr(args, arg_name, None)
45
+ file_path = getattr(args, file_arg_name, None)
46
+ if value and file_path:
47
+ raise SystemExit(
48
+ f"error: provide --{arg_name.replace('_', '-')} "
49
+ f"OR --{file_arg_name.replace('_', '-')}, not both"
50
+ )
51
+ if value:
52
+ return value
53
+ if file_path:
54
+ try:
55
+ return load_file(file_path)
56
+ except FileNotFoundError:
57
+ raise SystemExit(
58
+ f"error: --{file_arg_name.replace('_', '-')} not found: {file_path}"
59
+ ) from None
60
+ return None
61
+
62
+
63
+ def _get_target(args) -> str | dict | None:
64
+ """Retrieve the target value (from arg xor file), if any."""
65
+ return _get_arg_or_file(args, "target", "target_file", _load_json)
66
+
67
+
68
+ def _get_declaration(args) -> str | dict | None:
69
+ """Retrieve the declaration value (from arg xor file), if any."""
70
+ return _get_arg_or_file(args, "declaration", "declaration_file", _load_json)
71
+
72
+
73
+ def _get_notes(args) -> str | dict | None:
74
+ """Retrieve the notes value (from arg xor file), if any."""
75
+ return _get_arg_or_file(args, "notes", "notes_file", _load_json)
76
+
77
+
78
+ def _get_due(args) -> str | None:
79
+ """Validate the provided ``--due`` ISO-8601 deadline; return it verbatim."""
80
+ if not args.due:
81
+ return None
82
+ try:
83
+ datetime.fromisoformat(args.due.replace("Z", "+00:00"))
84
+ except ValueError:
85
+ raise SystemExit(f"error: --due is not a valid ISO-8601 timestamp: {args.due}") from None
86
+ return args.due
87
+
88
+
89
+ def _get_floor(args) -> dict | None:
90
+ """Fetch a drand freshness floor (anchor.floor) at sign time, or None."""
91
+ if not args.floor:
92
+ return None
93
+ try:
94
+ return drand.fetch_floor()
95
+ except Exception as e:
96
+ sys.stderr.write(f"warning: drand fetch failed ({e}); continuing without freshness floor\n")
97
+ return None
98
+
99
+
100
+ def _build_subject(paths: list[str] | None, hash_alg: str) -> list | None:
101
+ """Construct subject data (a list of filenames/dirnames and their associated
102
+ digest hashes) given source paths and a hash algorithm."""
103
+ return hashing.build_subject(paths, hash_alg) if paths else None
104
+
105
+
106
+ def _resolve_ref(value: str) -> str:
107
+ """A --fulfills/--prev value may be a manifest file path or a literal ref."""
108
+ if os.path.isfile(value):
109
+ return manifest.ref(manifest.get_body(manifest.load(value)))
110
+ return value
111
+
112
+
113
+ def _attach_ceiling(mani: dict, body: dict, want_ceiling: bool) -> None:
114
+ """Optionally fetch a Roughtime ceiling witness and attach it (sign-time,
115
+ network). The nonce is the body ref, so this must run *after* signing; it
116
+ does not perturb ref."""
117
+ if not want_ceiling:
118
+ return
119
+ from . import roughtime
120
+
121
+ nonce = manifest.ref(body) # sha-256:<hex>
122
+ try:
123
+ ceiling = roughtime.fetch_ceiling(nonce)
124
+ except Exception as e:
125
+ sys.stderr.write(
126
+ f"warning: ceiling witness fetch failed ({e}); continuing without a ceiling\n"
127
+ )
128
+ return
129
+ manifest.attach_ceiling(mani, ceiling)
130
+ print(
131
+ f" ceiling : {ceiling.get('ceiling_type')} witness {ceiling.get('witness_id')} "
132
+ f"@ {ceiling.get('midpoint')} (±{ceiling.get('radius')}s)"
133
+ )
134
+
135
+
136
+ # --------------------------------------------------------------------------- #
137
+ # commands
138
+ # --------------------------------------------------------------------------- #
139
+ def cmd_keygen(args) -> int:
140
+ """Generate a cryptographic key for signing pre- and post-registrations."""
141
+ out = args.out or f"asexec-{uuid.uuid4()}.key"
142
+ priv, _pub = keys.generate()
143
+ kid = keys.save(priv, out)
144
+ print(f"{OK} generated ed25519 key")
145
+ print(f" secret : {out} (keep private; mode 0600)")
146
+ print(f" public : {out}.pub")
147
+ print(f" keyid : {kid}")
148
+ return 0
149
+
150
+
151
+ def cmd_prereg(args) -> int:
152
+ """Write a signed pre-registration, signalling the intended execution."""
153
+ target = _get_target(args)
154
+ if target is None:
155
+ raise SystemExit("error: give --target or --target-file (the only mandatory claim)")
156
+ priv, pub = keys.load_signing_key(args.key)
157
+ subject = _build_subject(args.subject, args.hash_alg)
158
+ body = manifest.build_prereg(
159
+ target,
160
+ due=_get_due(args),
161
+ declaration=_get_declaration(args),
162
+ subject=subject,
163
+ hash_alg=args.hash_alg,
164
+ floor=_get_floor(args),
165
+ notes=_get_notes(args),
166
+ )
167
+ mani = manifest.sign(body, priv, pub)
168
+ _attach_ceiling(mani, body, args.ceiling)
169
+ manifest.save(mani, args.out)
170
+ print(f"{OK} pre-registration written: {args.out}")
171
+ print(f" ref : {manifest.ref(body)}")
172
+ if body.get("due"):
173
+ print(f" due : {body['due']}")
174
+ return 0
175
+
176
+
177
+ def cmd_postreg(args) -> int:
178
+ """Write a signed post-registration, documenting the execution and fulfilling
179
+ a pre-registration as promised."""
180
+ priv, pub = keys.load_signing_key(args.key)
181
+
182
+ prereg_body = None
183
+ if os.path.isfile(args.fulfills):
184
+ prereg_body = manifest.get_body(manifest.load(args.fulfills))
185
+
186
+ # target / due / declaration / hash_alg inherit from the fulfilled prereg
187
+ # unless overridden on this postreg.
188
+ target = _get_target(args)
189
+ if target is None:
190
+ if prereg_body is None:
191
+ raise SystemExit("error: no --target and --fulfills is not a readable prereg")
192
+ target = prereg_body["target"]
193
+
194
+ due = _get_due(args) or (prereg_body or {}).get("due")
195
+ declaration = _get_declaration(args) or (prereg_body or {}).get("declaration")
196
+ hash_alg = args.hash_alg or (prereg_body or {}).get("hash_alg") or hashing.DEFAULT_ALG
197
+
198
+ subject = _build_subject(args.subject, hash_alg)
199
+ body = manifest.build_postreg(
200
+ target,
201
+ fulfills=_resolve_ref(args.fulfills),
202
+ due=due,
203
+ declaration=declaration,
204
+ subject=subject,
205
+ hash_alg=hash_alg,
206
+ prev_hash=_resolve_ref(args.prev) if args.prev else None,
207
+ floor=_get_floor(args),
208
+ notes=_get_notes(args),
209
+ provenance=args.provenance,
210
+ repro_recipe=json.loads(args.repro_recipe) if args.repro_recipe else None,
211
+ )
212
+ mani = manifest.sign(body, priv, pub)
213
+ _attach_ceiling(mani, body, args.ceiling)
214
+ manifest.save(mani, args.out)
215
+ print(f"{OK} post-registration written: {args.out}")
216
+ print(f" ref : {manifest.ref(body)}")
217
+ print(f" fulfills : {body['fulfills']}")
218
+ if body.get("prev_hash"):
219
+ print(f" prev : {body['prev_hash']}")
220
+ return 0
221
+
222
+
223
+ def cmd_verify(args) -> int:
224
+ """Run a series of verification tests on the prereg-postreg cycle to prove that
225
+ this execution met the desired standards. Produces a code summarizing the tests
226
+ performed and the results of each."""
227
+ try:
228
+ tests = verifier.parse_tests(args.tests)
229
+ except VerificationError as e:
230
+ raise SystemExit(f"error: {e}") from e
231
+
232
+ report = verifier.verify_paths(args.paths, tests, artifacts_dir=args.artifacts)
233
+
234
+ print("=== manifests ===")
235
+ for m in report["manifests"]:
236
+ sig = m["signature"]
237
+ s = OK if (sig.get("signature_ok") and sig.get("keyid_ok")) else NO
238
+ print(f"{s} {m['path']} [{m.get('phase')}] keyid={m.get('keyid')}")
239
+ fl = m["floor"]
240
+ if fl["status"] != "absent":
241
+ fs = OK if fl["status"] == "verified" else NO
242
+ extra = (
243
+ f" (created no earlier than {fl.get('created_no_earlier_than')})"
244
+ if fl["status"] == "verified"
245
+ else ""
246
+ )
247
+ print(f" {fs} drand freshness floor round {fl.get('round', '?')}{extra}")
248
+ cl = m["ceiling"]
249
+ if cl["status"] != "absent":
250
+ cs = OK if cl["status"] == "verified" else NO
251
+ extra = (
252
+ f" (created no later than {cl.get('midpoint')} ±{cl.get('radius')}s, "
253
+ f"witness {cl.get('witness_id')})"
254
+ if cl["status"] == "verified"
255
+ else f" ({cl.get('error', cl['status'])})"
256
+ )
257
+ print(f" {cs} ceiling witness{extra}")
258
+ c = m["content"]
259
+ if c["status"] not in ("skipped",):
260
+ cs = OK if c["status"] == "ok" else NO
261
+ print(f" {cs} content hashes {c['status']}")
262
+ for e in c.get("entries", []):
263
+ if not e["ok"]:
264
+ print(f" {NO} {e['name']}: {e.get('reason', 'digest mismatch')}")
265
+
266
+ print("\n=== commitments ===")
267
+ if not report["commitments"]:
268
+ print(" (no pre-registrations among the provided manifests)")
269
+ for c in report["commitments"]:
270
+ print(f" [{c['state'].upper()}] prereg {c['ref']}")
271
+ print(f" due : {c.get('due') or '(none declared)'}")
272
+ print(
273
+ f" postregs : {len(c['receipts'])}"
274
+ + ("" if c["chain_ok"] else f" {NO} {c['chain_note']}")
275
+ )
276
+ if not c["key_consistent"]:
277
+ print(f" {NO} postregs signed by a different key than the pre-registration")
278
+ if report["notarization_only"]:
279
+ print("\n=== notarization-only (postregs with no matching pre-registration) ===")
280
+ for n in report["notarization_only"]:
281
+ print(f" {n['ref']} (fulfills {n.get('fulfills')})")
282
+
283
+ print("\n=== tests ===")
284
+ for t in report["tests"]:
285
+ r = report["results"][t]
286
+ s = OK if r["result"] == "PASS" else NO
287
+ print(f" {s} {t}={r['result']} ({r['reason']})")
288
+
289
+ if report["ceiling_trust"]:
290
+ print("\n=== ceiling trust (what you are accepting) ===")
291
+ for line in report["ceiling_trust"]:
292
+ print(f" - {line}")
293
+
294
+ print("\n=== what this does NOT prove ===")
295
+ for nc in report["non_claims"]:
296
+ print(f" - {nc}")
297
+
298
+ print(f"\n{report['code']}")
299
+ print(f"DISCLAIMER: {report['disclaimer']}")
300
+ return 0 if report["ok"] else 2
301
+
302
+
303
+ def cmd_identity(args) -> int:
304
+ """Standalone emit/match function pair for tying a known public identity via
305
+ domain to a public key, no certificate authority (CA) needed. ``emit`` a json file
306
+ which embeds the key and domain and publish it. Anyone may then check via
307
+ ``match`` that the asserted identity is placed at the given domain."""
308
+ if args.identity_cmd == "emit":
309
+ _priv, pub = keys.load_signing_key(args.key)
310
+ pair = {"keyid": keys.keyid_for(pub), "pubkey": pub.hex()}
311
+ doc = identity.build_wellknown([pair], domain=args.domain)
312
+ identity.write_wellknown(doc, args.out)
313
+ print(f"{OK} wrote {args.out}")
314
+ print(" publish at: https://<your-domain>/.well-known/asexec.json")
315
+ print(f" keyid : {pair['keyid']}")
316
+ return 0
317
+ if args.identity_cmd == "match":
318
+ keyid = args.keyid
319
+ pubkey = args.pubkey
320
+ if args.key:
321
+ _priv, pub = keys.load_signing_key(args.key)
322
+ keyid = keys.keyid_for(pub)
323
+ res = identity.match_binding(args.domain, keyid=keyid, pubkey_hex=pubkey)
324
+ s = OK if res["bound"] else NO
325
+ print(
326
+ f"{s} key {'IS' if res['bound'] else 'is NOT'} asserted by {args.domain} "
327
+ f"({res['listed_count']} key(s) listed)"
328
+ )
329
+ print(f" caveat: {res['caveat']}")
330
+ return 0 if res["bound"] else 2
331
+ raise SystemExit("error: use 'identity emit' or 'identity match'")
332
+
333
+
334
+ # --------------------------------------------------------------------------- #
335
+ # parser
336
+ # --------------------------------------------------------------------------- #
337
+ def _add_anchor_flags(p):
338
+ """Time-anchor parsing helper, shared by prereg and postreg."""
339
+ p.add_argument(
340
+ "--floor",
341
+ action="store_true",
342
+ help="attach a drand freshness floor (proves created no earlier than T; network)",
343
+ )
344
+ p.add_argument(
345
+ "--ceiling",
346
+ action="store_true",
347
+ help="attach a Roughtime ceiling witness (proves created no later than T; network)",
348
+ )
349
+
350
+
351
+ def build_parser() -> argparse.ArgumentParser:
352
+ """``asexec`` parsing core."""
353
+ p = argparse.ArgumentParser(
354
+ prog="asexec",
355
+ description="Pre-registration & notarization primitive for AI evaluations. "
356
+ "Does NOT prove identity, eval quality, provenance, or cryptographic "
357
+ "'pre' (see 'verify' output).",
358
+ )
359
+ p.add_argument("--version", action="version", version=f"asexec {__version__}")
360
+ sub = p.add_subparsers(dest="cmd", required=True)
361
+
362
+ # asexec keygen
363
+ kg = sub.add_parser("keygen", help="generate an ed25519 keypair (no CA)")
364
+ kg.add_argument("--out", default=None, help="secret key file (default: asexec-<uuid>.key)")
365
+ kg.set_defaults(func=cmd_keygen)
366
+
367
+ # asexec prereg
368
+ pr = sub.add_parser("prereg", help="sign a pre-registration before a run")
369
+ pr.add_argument("--key", required=True, help="key that signs the prereg")
370
+ pr.add_argument("--target", help="plain-text target details (what you commit to run)")
371
+ pr.add_argument("--target-file", help="structured JSON target details")
372
+ pr.add_argument(
373
+ "--due",
374
+ help="disclosure deadline, ISO-8601 (optional; e.g. 2026-08-30T00:00:00Z)",
375
+ )
376
+ pr.add_argument("--declaration", help="plain-language commitment text")
377
+ pr.add_argument("--declaration-file", help="structured JSON declaration")
378
+ pr.add_argument(
379
+ "--subject",
380
+ nargs="+",
381
+ help="path(s) to harness/eval to hash (optional at prereg time)",
382
+ )
383
+ pr.add_argument(
384
+ "--hash-alg",
385
+ default=hashing.DEFAULT_ALG,
386
+ choices=hashing.available_algorithms(),
387
+ )
388
+ pr.add_argument("--notes", help="free-form context (hypothesis/methodology)")
389
+ pr.add_argument("--notes-file", help="structured JSON notes")
390
+ _add_anchor_flags(pr)
391
+ pr.add_argument("--out", default="preregistration.json")
392
+ pr.set_defaults(func=cmd_prereg)
393
+
394
+ # asexec postreg
395
+ po = sub.add_parser("postreg", help="sign a post-registration (receipt) after a run")
396
+ po.add_argument("--key", required=True)
397
+ po.add_argument(
398
+ "--fulfills",
399
+ required=True,
400
+ help="pre-registration file (or literal ref) this fulfils",
401
+ )
402
+ po.add_argument("--target", help="override target (default: inherit from prereg)")
403
+ po.add_argument("--target-file")
404
+ po.add_argument("--due", help="override disclosure deadline (default: inherit from prereg)")
405
+ po.add_argument("--declaration")
406
+ po.add_argument("--declaration-file")
407
+ po.add_argument("--subject", nargs="+", help="path(s) to outputs/transcript/harness to hash")
408
+ po.add_argument("--hash-alg", default=None, choices=hashing.available_algorithms())
409
+ po.add_argument("--prev", help="prior postreg file (or ref) in this commitment's chain")
410
+ po.add_argument("--provenance", choices=["asserted", "reproducible"], default="asserted")
411
+ po.add_argument(
412
+ "--repro-recipe",
413
+ help="JSON: {seed, decode, runtime} if provenance=reproducible",
414
+ )
415
+ po.add_argument("--notes")
416
+ po.add_argument("--notes-file")
417
+ _add_anchor_flags(po)
418
+ po.add_argument("--out", default="postregistration.json")
419
+ po.set_defaults(func=cmd_postreg)
420
+
421
+ # asexec verify
422
+ vy = sub.add_parser("verify", help="verify manifests offline; produce a canonical verify code")
423
+ vy.add_argument("paths", nargs="+", help="manifest file(s) or a directory of them")
424
+ vy.add_argument(
425
+ "--tests",
426
+ required=True,
427
+ help="comma-separated tests to run (MUST include 'BDR'). "
428
+ f"available: {', '.join(verifier.TEST_CATALOG)}",
429
+ )
430
+ vy.add_argument("--artifacts", help="directory of original artifacts, to check content hashes")
431
+ vy.set_defaults(func=cmd_verify)
432
+
433
+ # asexec identity
434
+ idp = sub.add_parser("identity", help="key<->domain binding via .well-known (no CA)")
435
+ isub = idp.add_subparsers(dest="identity_cmd", required=True)
436
+ ie = isub.add_parser("emit", help="write a .well-known/asexec.json for your key")
437
+ ie.add_argument("--key", required=True)
438
+ ie.add_argument("--domain")
439
+ ie.add_argument("--out", default="asexec.json")
440
+ ie.set_defaults(func=cmd_identity)
441
+ iv = isub.add_parser(
442
+ "match", help="check a key matches that which is asserted by a domain (network)"
443
+ )
444
+ iv.add_argument("--domain", required=True)
445
+ iv.add_argument("--keyid")
446
+ iv.add_argument("--pubkey")
447
+ iv.add_argument("--key", help="a key file, to derive the keyid")
448
+ iv.set_defaults(func=cmd_identity)
449
+
450
+ return p
451
+
452
+
453
+ def main(argv: list[str] | None = None) -> int:
454
+ args = build_parser().parse_args(argv)
455
+ return args.func(args)