aevum-cli 0.6.0__tar.gz → 0.7.1__tar.gz

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.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: aevum-cli
3
- Version: 0.6.0
3
+ Version: 0.7.1
4
4
  Summary: Aevum -- command-line interface for operating Aevum nodes.
5
5
  Project-URL: Homepage, https://aevum.build
6
6
  Project-URL: Repository, https://github.com/aevum-labs/aevum
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "aevum-cli"
3
- version = "0.6.0"
3
+ version = "0.7.1"
4
4
  description = "Aevum -- command-line interface for operating Aevum nodes."
5
5
  readme = "README.md"
6
6
  requires-python = ">=3.11"
@@ -1,3 +1,4 @@
1
+ # SPDX-License-Identifier: Apache-2.0
1
2
  """
2
3
  aevum.cli -- Command-line interface for operating Aevum nodes.
3
4
 
@@ -1,3 +1,4 @@
1
+ # SPDX-License-Identifier: Apache-2.0
1
2
  """
2
3
  Entry point: aevum <command>
3
4
  Invoked via [project.scripts] aevum = "aevum.cli.__main__:app"
@@ -0,0 +1,470 @@
1
+ # SPDX-License-Identifier: Apache-2.0
2
+ # Copyright 2024-2026 Aevum Labs contributors
3
+ """
4
+ Top-level typer app — CLI v2. Sub-commands and direct commands registered here.
5
+ """
6
+ from __future__ import annotations
7
+
8
+ import hashlib
9
+ import json
10
+ from pathlib import Path
11
+ from typing import Annotated, Any
12
+
13
+ import typer
14
+
15
+ from aevum.cli.commands import complication, conformance, server, store, version
16
+
17
+ # Module-level import for mock.patch patchability (Rule 57).
18
+ # Soft import: aevum-conformance is a workspace package not on PyPI, so
19
+ # callers without it installed still get a usable CLI (conform command
20
+ # shows a helpful error instead of crashing at startup).
21
+ try:
22
+ from aevum.conformance.suite import ConformanceSuite
23
+ except ImportError: # pragma: no cover
24
+ ConformanceSuite = None # type: ignore[assignment,misc]
25
+
26
+ app = typer.Typer(
27
+ name="aevum",
28
+ help="Aevum governed context kernel — CLI v2",
29
+ no_args_is_help=True,
30
+ pretty_exceptions_enable=False,
31
+ rich_markup_mode="markdown",
32
+ )
33
+
34
+ app.add_typer(server.app, name="server")
35
+ app.add_typer(store.app, name="store")
36
+ app.add_typer(complication.app, name="complication")
37
+ app.add_typer(conformance.app, name="conformance")
38
+ app.command(name="version")(version.version_command)
39
+
40
+ _DEFAULT_STATE = Path.home() / ".aevum"
41
+
42
+
43
+ @app.command()
44
+ def init(
45
+ state_dir: Annotated[
46
+ Path,
47
+ typer.Option("--state-dir", "-s", help="State directory path"),
48
+ ] = _DEFAULT_STATE,
49
+ principles: Annotated[
50
+ Path,
51
+ typer.Option("--principles", "-p", help="Path to signed_principles.yaml"),
52
+ ] = Path("signed_principles.yaml"),
53
+ ) -> None:
54
+ """
55
+ Initialize Aevum state directory and verify principles.
56
+
57
+ Creates the state directory, generates dual signing keys (Ed25519 +
58
+ ML-DSA-65), and verifies the signed_principles.yaml file.
59
+ """
60
+ typer.echo(f"Initializing Aevum state at {state_dir}...")
61
+
62
+ try:
63
+ from aevum.core.principles import PrinciplesVerifier
64
+ verifier = PrinciplesVerifier(principles)
65
+ p = verifier.verify()
66
+ typer.echo(f" Principles: OK (sequence={p.sequence}, signed_by={p.signed_by[:30]}...)")
67
+ except Exception as exc: # noqa: BLE001
68
+ typer.echo(f" Principles: FAILED — {exc}", err=True)
69
+ raise typer.Exit(code=1) from None
70
+
71
+ try:
72
+ from aevum.core.kernel import Kernel
73
+ kernel = Kernel.local(
74
+ state_dir=state_dir,
75
+ principles_path=principles,
76
+ tsa_enabled=False,
77
+ )
78
+ ed25519_pub = kernel.signer.ed25519_public_key.hex()[:16]
79
+ typer.echo(f" Keys: OK (ed25519={ed25519_pub}...)")
80
+ typer.echo(f" Canaries: PASS ({len(kernel.principles.immutable_ids())} immutable principles)")
81
+ except Exception as exc: # noqa: BLE001
82
+ typer.echo(f" Kernel init: FAILED — {exc}", err=True)
83
+ raise typer.Exit(code=1) from None
84
+
85
+ typer.echo(typer.style("Aevum initialized successfully.", fg=typer.colors.GREEN))
86
+
87
+
88
+ @app.command()
89
+ def verify(
90
+ session_id: Annotated[str, typer.Argument(help="Session ID to verify")],
91
+ state_dir: Annotated[
92
+ Path,
93
+ typer.Option("--state-dir", "-s"),
94
+ ] = _DEFAULT_STATE,
95
+ ) -> None:
96
+ """
97
+ Verify a session's Merkle root and signatures.
98
+
99
+ Re-reads the stored session events from SQLite, recomputes the
100
+ Merkle root, and compares it to the signed root in the sigchain.
101
+ """
102
+ db_path = state_dir / "aevum.db"
103
+ if not db_path.exists():
104
+ typer.echo(f"Database not found: {db_path}", err=True)
105
+ raise typer.Exit(code=1)
106
+
107
+ try:
108
+ from aevum.core.replay import ReplayEngine
109
+ engine = ReplayEngine(db_path)
110
+ result = engine.replay(session_id)
111
+
112
+ if result.all_matched:
113
+ typer.echo(typer.style(
114
+ f"Session {session_id[:8]}... VERIFIED",
115
+ fg=typer.colors.GREEN,
116
+ ))
117
+ typer.echo(f" Merkle root: {result.original_merkle_root[:16]}...")
118
+ typer.echo(f" Events: {len(result.event_results)}")
119
+ else:
120
+ typer.echo(typer.style(
121
+ f"Session {session_id[:8]}... TAMPERED",
122
+ fg=typer.colors.RED,
123
+ ), err=True)
124
+ typer.echo(
125
+ f" First divergence: event #{result.first_divergence}", err=True
126
+ )
127
+ raise typer.Exit(code=1)
128
+
129
+ except ValueError as exc:
130
+ typer.echo(f"Session not found: {exc}", err=True)
131
+ raise typer.Exit(code=1) from None
132
+
133
+
134
+ @app.command(name="audit-pack")
135
+ def audit_pack(
136
+ session_id: Annotated[str, typer.Argument(help="Session ID")],
137
+ output: Annotated[
138
+ Path | None,
139
+ typer.Option("--output", "-o", help="Output file path (default: stdout)"),
140
+ ] = None,
141
+ state_dir: Annotated[
142
+ Path,
143
+ typer.Option("--state-dir", "-s"),
144
+ ] = _DEFAULT_STATE,
145
+ ) -> None:
146
+ """
147
+ Export EU AI Act Article 12 audit pack for a session.
148
+
149
+ Produces a JSON-LD document using the PROV-O vocabulary.
150
+ """
151
+ db_path = state_dir / "aevum.db"
152
+ if not db_path.exists():
153
+ typer.echo(f"Database not found: {db_path}", err=True)
154
+ raise typer.Exit(code=1)
155
+
156
+ try:
157
+ from aevum.core.audit.audit_pack import AuditPackExporter
158
+ exporter = AuditPackExporter(db_path)
159
+ json_text = exporter.export_json(session_id)
160
+
161
+ if output:
162
+ output.write_text(json_text, encoding="utf-8")
163
+ typer.echo(f"Audit pack written to {output}")
164
+ else:
165
+ typer.echo(json_text)
166
+
167
+ except Exception as exc: # noqa: BLE001
168
+ typer.echo(f"Audit pack error: {exc}", err=True)
169
+ raise typer.Exit(code=1) from None
170
+
171
+
172
+ @app.command()
173
+ def conform(
174
+ output: Annotated[
175
+ str,
176
+ typer.Option("--output", "-o", help="Output format: text or json"),
177
+ ] = "text",
178
+ ) -> None:
179
+ """
180
+ Run the 9-invariant conformance suite.
181
+
182
+ Tests all required Aevum behavioral invariants and prints a report.
183
+ Exit code 0 = all pass, 1 = one or more fail.
184
+ """
185
+ if ConformanceSuite is None:
186
+ typer.echo(
187
+ "aevum-conformance is not installed. Install it with: pip install aevum-conformance",
188
+ err=True,
189
+ )
190
+ raise typer.Exit(code=1)
191
+ suite = ConformanceSuite()
192
+ result = suite.run_all()
193
+
194
+ if output == "json":
195
+ typer.echo(json.dumps(result.to_dict(), indent=2))
196
+ else:
197
+ typer.echo(result.render())
198
+
199
+ if not result.all_passed:
200
+ raise typer.Exit(code=1)
201
+
202
+
203
+ @app.command(name="verify-receipt")
204
+ def verify_receipt(
205
+ receipt_file: Annotated[
206
+ Path | None,
207
+ typer.Argument(help="Path to COSE_Sign1 receipt file"),
208
+ ] = None,
209
+ receipt_hash: Annotated[
210
+ str | None,
211
+ typer.Option("--hash", help="SHA3-256 hex hash — lookup from AEVUM_RECEIPT_DB"),
212
+ ] = None,
213
+ ) -> None:
214
+ """
215
+ Verify an Aevum COSE_Sign1 receipt file or hash.
216
+
217
+ Decodes the receipt, verifies the Ed25519 signature over the canonical payload,
218
+ and prints a human-readable summary. Exit 0 on valid, exit 1 on invalid signature.
219
+ Exit 2 on unsupported algorithm or hash not found.
220
+
221
+ Examples:
222
+ aevum verify-receipt receipt.cbor
223
+ aevum verify-receipt --hash <sha3-256-hex>
224
+ """
225
+ import cbor2
226
+ import nacl.exceptions
227
+ import nacl.signing
228
+ from aevum.core.receipt import AevumReceipt
229
+
230
+ if receipt_file is None and receipt_hash is None:
231
+ typer.echo("Provide a receipt file path or --hash <hash>.", err=True)
232
+ raise typer.Exit(code=1)
233
+ if receipt_file is not None and receipt_hash is not None:
234
+ typer.echo("Provide either a file path or --hash, not both.", err=True)
235
+ raise typer.Exit(code=1)
236
+
237
+ store_info: dict[str, object] | None = None
238
+
239
+ if receipt_hash is not None:
240
+ try:
241
+ from aevum.core.sqlite_store import SqliteReceiptStore
242
+ store = SqliteReceiptStore.from_env()
243
+ raw = store.get(receipt_hash)
244
+ if raw is None:
245
+ typer.echo(f"RECEIPT NOT FOUND: {receipt_hash}", err=True)
246
+ raise typer.Exit(code=2)
247
+ store_info = store.get_receipt_info(receipt_hash)
248
+ except typer.Exit:
249
+ raise
250
+ except RuntimeError as exc:
251
+ typer.echo(f"Store error: {exc}", err=True)
252
+ raise typer.Exit(code=1) from None
253
+ else:
254
+ assert receipt_file is not None
255
+ if not receipt_file.exists():
256
+ typer.echo(f"File not found: {receipt_file}", err=True)
257
+ raise typer.Exit(code=1)
258
+ raw = receipt_file.read_bytes()
259
+
260
+ try:
261
+ cose = cbor2.loads(raw)
262
+ except Exception as exc: # noqa: BLE001
263
+ typer.echo(f"INVALID: not a valid CBOR file: {exc}", err=True)
264
+ raise typer.Exit(code=1) from None
265
+
266
+ if not isinstance(cose, list) or len(cose) != 4:
267
+ typer.echo("INVALID: not a 4-element COSE_Sign1 array", err=True)
268
+ raise typer.Exit(code=1)
269
+
270
+ protected_bstr, unprotected, payload_bstr, signature_bytes = cose
271
+
272
+ try:
273
+ protected = cbor2.loads(protected_bstr)
274
+ except Exception as exc: # noqa: BLE001
275
+ typer.echo(f"INVALID: cannot decode protected header: {exc}", err=True)
276
+ raise typer.Exit(code=1) from None
277
+
278
+ alg = protected.get(1)
279
+ if alg != -8:
280
+ typer.echo(f"UNSUPPORTED ALGORITHM: alg={alg!r} (expected -8 for EdDSA/Ed25519)", err=True)
281
+ raise typer.Exit(code=2) from None
282
+
283
+ try:
284
+ receipt_data = cbor2.loads(payload_bstr)
285
+ receipt = AevumReceipt.model_validate(receipt_data)
286
+ except Exception as exc: # noqa: BLE001
287
+ typer.echo(f"INVALID: cannot decode receipt payload: {exc}", err=True)
288
+ raise typer.Exit(code=1) from None
289
+
290
+ # Reconstruct Sig_Structure and verify signature
291
+ sig_structure = cbor2.dumps(["Signature1", protected_bstr, b"", payload_bstr])
292
+ digest = hashlib.sha3_256(sig_structure).digest()
293
+
294
+ # Try to find Ed25519 public key from state dir (kid field in protected header reserved for future)
295
+ pub_key_bytes: bytes | None = None
296
+ state_dir = Path.home() / ".aevum"
297
+ ed25519_pub_path = state_dir / "ed25519.pub"
298
+ if ed25519_pub_path.exists():
299
+ pub_key_bytes = ed25519_pub_path.read_bytes()
300
+
301
+ if pub_key_bytes is None:
302
+ typer.echo(
303
+ "WARNING: no public key found in ~/.aevum/ed25519.pub — skipping signature check.",
304
+ err=True,
305
+ )
306
+ typer.echo("WARNING: receipt content is displayed but authenticity is NOT verified.", err=True)
307
+ verified = False
308
+ else:
309
+ try:
310
+ verify_key = nacl.signing.VerifyKey(pub_key_bytes)
311
+ verify_key.verify(digest, bytes(signature_bytes))
312
+ verified = True
313
+ except nacl.exceptions.BadSignatureError:
314
+ typer.echo("SIGNATURE INVALID", err=True)
315
+ _print_receipt_summary(receipt, unprotected, verified=False, store_info=store_info)
316
+ raise typer.Exit(code=1) from None
317
+ except Exception as exc: # noqa: BLE001
318
+ typer.echo(f"SIGNATURE INVALID: {exc}", err=True)
319
+ raise typer.Exit(code=1) from None
320
+
321
+ _print_receipt_summary(receipt, unprotected, verified=verified, store_info=store_info)
322
+
323
+
324
+ def _print_receipt_summary(
325
+ receipt: Any,
326
+ unprotected: dict, # type: ignore[type-arg]
327
+ verified: bool,
328
+ store_info: dict[str, object] | None = None,
329
+ ) -> None:
330
+ """Print human-readable receipt summary."""
331
+
332
+ status_line = "✓ Aevum Receipt Verified" if verified else "! Aevum Receipt (unverified)"
333
+ typer.echo(status_line)
334
+ typer.echo("─" * 36)
335
+
336
+ tsa_info = "none"
337
+ if 9 in unprotected:
338
+ tsa_info = f"<RFC 3161 token, {len(unprotected[9])} bytes>"
339
+
340
+ barriers_summary = (
341
+ ", ".join(f"{k}:{v}" for k, v in receipt.barrier_evaluations.items())
342
+ if receipt.barrier_evaluations
343
+ else "none"
344
+ )
345
+
346
+ prior_display = receipt.prior_hash[:12] if len(receipt.prior_hash) >= 12 else receipt.prior_hash
347
+ model_display = receipt.model_identity_hash[:12] if len(receipt.model_identity_hash) >= 12 else receipt.model_identity_hash
348
+
349
+ typer.echo(f"Action: {receipt.action}")
350
+ typer.echo(f"Agent: {receipt.agent_id}")
351
+ typer.echo(f"Principal: {receipt.principal}")
352
+ typer.echo(f"Occurred at: {receipt.occurred_at}")
353
+ typer.echo(f"Sequence: {receipt.sequence}")
354
+ typer.echo(f"Prior hash: {prior_display}...")
355
+ typer.echo(f"Handoff type: {receipt.handoff_type or 'none'}")
356
+ typer.echo(f"Model hash: {model_display}...")
357
+ typer.echo(f"Policy version: {receipt.policy_version}")
358
+ typer.echo(f"TSA timestamp: {tsa_info}")
359
+ typer.echo(f"Barriers: {barriers_summary}")
360
+
361
+ if store_info is not None:
362
+ tier = store_info.get("tier", "unknown")
363
+ locked = store_info.get("locked", False)
364
+ rekor_ref = store_info.get("rekor_entry_ref", "") or "not submitted"
365
+ typer.echo(f"Tier: {tier}")
366
+ typer.echo(f"Crash-protected:{' yes' if locked else ' no'}")
367
+ typer.echo(f"Rekor ref: {rekor_ref}")
368
+
369
+
370
+ @app.command(name="vault-check")
371
+ def vault_check() -> None:
372
+ """
373
+ Verify Vault Transit connectivity with a sign/verify round-trip.
374
+
375
+ Reads VAULT_ADDR, VAULT_TOKEN, and AEVUM_VAULT_KEY_NAME from the environment.
376
+ Exits 0 on success, exits 1 on failure.
377
+ """
378
+ import os
379
+
380
+ vault_addr = os.environ.get("VAULT_ADDR", "http://127.0.0.1:8200")
381
+ vault_token = os.environ.get("VAULT_TOKEN", "")
382
+ key_name = os.environ.get("AEVUM_VAULT_KEY_NAME", "aevum-signing")
383
+
384
+ if not vault_token:
385
+ typer.echo("VAULT_TOKEN is not set.", err=True)
386
+ raise typer.Exit(code=1)
387
+
388
+ typer.echo(f"Vault address : {vault_addr}")
389
+ typer.echo(f"Key name : {key_name}")
390
+
391
+ try:
392
+ from aevum.core.audit.signer import VaultTransitSigner
393
+ signer = VaultTransitSigner(key_name=key_name, vault_addr=vault_addr, token=vault_token)
394
+ except Exception as exc:
395
+ typer.echo(f"Failed to create VaultTransitSigner: {exc}", err=True)
396
+ raise typer.Exit(code=1) from None
397
+
398
+ payload = b"aevum vault-check probe"
399
+ try:
400
+ sig = signer.sign(payload)
401
+ typer.echo(typer.style(" sign() PASS", fg=typer.colors.GREEN))
402
+ except Exception as exc:
403
+ typer.echo(typer.style(" sign() FAIL", fg=typer.colors.RED))
404
+ typer.echo(f" {exc}", err=True)
405
+ raise typer.Exit(code=1) from None
406
+
407
+ try:
408
+ valid = signer.verify(payload, sig)
409
+ if not valid:
410
+ raise RuntimeError("verify() returned False for a freshly signed payload")
411
+ typer.echo(typer.style(" verify() PASS", fg=typer.colors.GREEN))
412
+ except Exception as exc:
413
+ typer.echo(typer.style(" verify() FAIL", fg=typer.colors.RED))
414
+ typer.echo(f" {exc}", err=True)
415
+ raise typer.Exit(code=1) from None
416
+
417
+ typer.echo(typer.style("Vault Transit check PASSED.", fg=typer.colors.GREEN))
418
+
419
+
420
+ @app.command()
421
+ def replay(
422
+ session_id: Annotated[str, typer.Argument(help="Session ID to replay")],
423
+ verbose: Annotated[
424
+ bool,
425
+ typer.Option("--verbose", "-v", help="Show per-event results"),
426
+ ] = False,
427
+ state_dir: Annotated[
428
+ Path,
429
+ typer.Option("--state-dir", "-s"),
430
+ ] = _DEFAULT_STATE,
431
+ ) -> None:
432
+ """
433
+ Replay a session and verify Merkle chain integrity.
434
+
435
+ Re-reads all events and recomputes the Merkle root. Reports any
436
+ divergence from the stored root (indicating tampering).
437
+ """
438
+ db_path = state_dir / "aevum.db"
439
+ if not db_path.exists():
440
+ typer.echo(f"Database not found: {db_path}", err=True)
441
+ raise typer.Exit(code=1)
442
+
443
+ try:
444
+ from aevum.core.replay import ReplayEngine
445
+ engine = ReplayEngine(db_path)
446
+ result = engine.replay(session_id)
447
+
448
+ status = (
449
+ typer.style("PASS", fg=typer.colors.GREEN)
450
+ if result.all_matched
451
+ else typer.style("FAIL", fg=typer.colors.RED)
452
+ )
453
+ typer.echo(f"Replay {session_id[:8]}...: {status}")
454
+ typer.echo(f" Events: {len(result.event_results)}")
455
+ typer.echo(f" Merkle root: {result.original_merkle_root[:16]}...")
456
+
457
+ if verbose:
458
+ for ev in result.event_results:
459
+ ev_status = "OK" if ev.matched else "DIVERGED"
460
+ typer.echo(f" [{ev.sequence:3d}] {ev.event_type:<12} {ev_status}")
461
+
462
+ if not result.all_matched:
463
+ typer.echo(
464
+ f" First divergence: event #{result.first_divergence}", err=True
465
+ )
466
+ raise typer.Exit(code=1)
467
+
468
+ except ValueError as exc:
469
+ typer.echo(f"Session not found: {exc}", err=True)
470
+ raise typer.Exit(code=1) from None
@@ -1 +1,2 @@
1
+ # SPDX-License-Identifier: Apache-2.0
1
2
  """aevum.cli.commands -- sub-command modules."""
@@ -1,3 +1,4 @@
1
+ # SPDX-License-Identifier: Apache-2.0
1
2
  """
2
3
  aevum complication list/suspend/resume -- manage complications.
3
4
  """
@@ -1,3 +1,4 @@
1
+ # SPDX-License-Identifier: Apache-2.0
1
2
  """
2
3
  aevum conformance run -- run the conformance suite.
3
4
  """
@@ -1,3 +1,4 @@
1
+ # SPDX-License-Identifier: Apache-2.0
1
2
  """
2
3
  aevum server start -- start the Aevum HTTP API server.
3
4
  """
@@ -0,0 +1,121 @@
1
+ # SPDX-License-Identifier: Apache-2.0
2
+ """
3
+ aevum store — manage graph store backends and receipt storage.
4
+ """
5
+
6
+ from __future__ import annotations
7
+
8
+ from typing import Annotated
9
+
10
+ import typer
11
+
12
+ app = typer.Typer(help="Manage graph store backends and receipt storage.")
13
+
14
+
15
+ @app.command("migrate")
16
+ def migrate(
17
+ from_backend: Annotated[str, typer.Option("--from", help="Source backend (oxigraph:<path>)")] = "",
18
+ to_backend: Annotated[str, typer.Option("--to", help="Target backend (postgres:<dsn>)")] = "",
19
+ ) -> None:
20
+ """Migrate graph data between backends."""
21
+ if not from_backend or not to_backend:
22
+ typer.echo("Both --from and --to are required.", err=True)
23
+ raise typer.Exit(code=1)
24
+
25
+ if not from_backend.startswith("oxigraph:"):
26
+ typer.echo(f"Unsupported source backend: {from_backend!r}", err=True)
27
+ typer.echo("Currently supported source: oxigraph:<path>", err=True)
28
+ raise typer.Exit(code=1)
29
+
30
+ if not to_backend.startswith("postgres:"):
31
+ typer.echo(f"Unsupported target backend: {to_backend!r}", err=True)
32
+ typer.echo("Currently supported target: postgres:<dsn>", err=True)
33
+ raise typer.Exit(code=1)
34
+
35
+ try:
36
+ from aevum.store.postgres.migrate import migrate_from_oxigraph
37
+ except ImportError:
38
+ typer.echo("Error: aevum-store-postgres is not installed.", err=True)
39
+ raise typer.Exit(code=1) from None
40
+
41
+ oxigraph_path = from_backend[len("oxigraph:"):]
42
+ postgres_dsn = to_backend[len("postgres:"):]
43
+
44
+ typer.echo(f"Migrating: {oxigraph_path} -> PostgreSQL")
45
+ try:
46
+ import psycopg
47
+ conn = psycopg.connect(postgres_dsn)
48
+ migrated = migrate_from_oxigraph(oxigraph_path, conn) # type: ignore[arg-type] # Phase 2: wire store construction
49
+ typer.echo(f"Migration complete: {migrated} entities transferred.")
50
+ except Exception as e:
51
+ typer.echo(f"Migration failed: {e}", err=True)
52
+ raise typer.Exit(code=1) from e
53
+
54
+
55
+ @app.command("migrate-receipts")
56
+ def migrate_receipts(
57
+ oxigraph_path: Annotated[
58
+ str,
59
+ typer.Option("--oxigraph", help="Path to Oxigraph store directory"),
60
+ ] = "",
61
+ ) -> None:
62
+ """
63
+ Migrate receipt blobs from Oxigraph provenance graph to SQLite receipt store.
64
+
65
+ Checks for receipt blobs stored as xsd:base64Binary literals in the Oxigraph
66
+ provenance graph and moves them to the SQLite receipt store (AEVUM_RECEIPT_DB).
67
+
68
+ This is a one-time idempotent migration. For new deployments (no receipts in
69
+ Oxigraph), this is a no-op. Receipt blobs were never stored in Oxigraph in
70
+ versions <= 0.6.0, so this command is a no-op for all current deployments.
71
+ """
72
+ try:
73
+ from aevum.core.sqlite_store import SqliteReceiptStore
74
+ store = SqliteReceiptStore.from_env()
75
+ except RuntimeError as exc:
76
+ typer.echo(f"Store error: {exc}", err=True)
77
+ raise typer.Exit(code=1) from None
78
+
79
+ if not oxigraph_path:
80
+ typer.echo("No receipts to migrate. SQLite store is current.")
81
+ raise typer.Exit(code=0)
82
+
83
+ try:
84
+ from aevum.store.oxigraph.store import OxigraphStore
85
+ graph_store = OxigraphStore(path=oxigraph_path)
86
+ except ImportError:
87
+ typer.echo("Error: aevum-store-oxigraph is not installed.", err=True)
88
+ raise typer.Exit(code=1) from None
89
+
90
+ # Query for any receipt blobs stored as literals in the provenance graph.
91
+ # Receipt blobs were never stored in Oxigraph in v0.6.0 or earlier, so
92
+ # this query returns zero rows for all current deployments.
93
+ sparql = """
94
+ SELECT ?h ?b WHERE {
95
+ GRAPH <urn:aevum:provenance> {
96
+ ?h <https://aevum.build/vocab/receiptBlob> ?b
97
+ }
98
+ }
99
+ """
100
+ rows = graph_store.sparql_select(sparql)
101
+
102
+ if not rows:
103
+ typer.echo("No receipts to migrate. SQLite store is current.")
104
+ raise typer.Exit(code=0)
105
+
106
+ import base64
107
+ migrated = 0
108
+ for row in rows:
109
+ h = row.get("h", "")
110
+ b_str = row.get("b", "")
111
+ if not h or not b_str:
112
+ continue
113
+ try:
114
+ blob = base64.b64decode(b_str)
115
+ receipt_hash = h.split(":")[-1] if ":" in h else h
116
+ store.put(receipt_hash=receipt_hash, blob=blob)
117
+ migrated += 1
118
+ except Exception as exc: # noqa: BLE001
119
+ typer.echo(f" Skipped {h}: {exc}", err=True)
120
+
121
+ typer.echo(f"Migrated {migrated} receipts from Oxigraph to SQLite.")
@@ -1,3 +1,4 @@
1
+ # SPDX-License-Identifier: Apache-2.0
1
2
  """
2
3
  aevum version -- print installed package versions.
3
4
  """
@@ -1,3 +1,4 @@
1
+ # SPDX-License-Identifier: Apache-2.0
1
2
  """
2
3
  Tests for aevum-cli commands.
3
4
  Uses typer's CliRunner -- no real server, no real graph backend.
@@ -0,0 +1,143 @@
1
+ # SPDX-License-Identifier: Apache-2.0
2
+ """
3
+ Tests for aevum verify-receipt CLI command.
4
+ Uses typer's CliRunner.
5
+ """
6
+ from __future__ import annotations
7
+
8
+ import re
9
+ from pathlib import Path
10
+
11
+ import cbor2
12
+ import nacl.signing
13
+ import pytest
14
+ from typer.testing import CliRunner
15
+
16
+ from aevum.cli.app import app
17
+
18
+ runner = CliRunner()
19
+
20
+ _ANSI = re.compile(r"\x1b\[[0-9;]*[mGKH]")
21
+
22
+
23
+ def plain(text: str) -> str:
24
+ return _ANSI.sub("", text)
25
+
26
+
27
+ def _make_valid_receipt_file(tmp_path: Path) -> tuple[Path, nacl.signing.SigningKey]:
28
+ """Create a valid COSE_Sign1 receipt file and return its path + signing key."""
29
+ from aevum.core.audit.event import AuditEvent
30
+ from aevum.core.receipt import AevumReceipt
31
+ from aevum.publish.encoder import ReceiptEncoder
32
+
33
+ signer_key = nacl.signing.SigningKey.generate()
34
+
35
+ class _NaClSigner:
36
+ key_id = "test-verify-key"
37
+ provenance = "test"
38
+ def sign(self, digest: bytes) -> bytes:
39
+ return bytes(signer_key.sign(digest).signature)
40
+ def public_key_bytes(self) -> bytes:
41
+ return bytes(signer_key.verify_key)
42
+
43
+ encoder = ReceiptEncoder(signer=_NaClSigner(), dev_mode=True) # type: ignore[arg-type]
44
+ event = AuditEvent(
45
+ event_id="ev-cli-test-01",
46
+ episode_id="ep-cli-01",
47
+ sequence=1,
48
+ event_type="cli.test.action",
49
+ schema_version="1.0",
50
+ valid_from="2026-05-24T00:00:00+00:00",
51
+ valid_to=None,
52
+ system_time=0,
53
+ causation_id=None,
54
+ correlation_id=None,
55
+ actor="cli-test-agent",
56
+ trace_id=None,
57
+ span_id=None,
58
+ payload={},
59
+ payload_hash=AuditEvent.hash_payload({}),
60
+ prior_hash="c" * 64,
61
+ signature="dGVzdA",
62
+ signer_key_id="test-key",
63
+ )
64
+ receipt = AevumReceipt.from_sigchain_event(event)
65
+ receipt_cbor = encoder.encode(receipt)
66
+
67
+ receipt_file = tmp_path / "test_receipt.cose"
68
+ receipt_file.write_bytes(receipt_cbor)
69
+
70
+ pub_key_path = tmp_path / ".aevum" / "ed25519.pub"
71
+ pub_key_path.parent.mkdir(parents=True, exist_ok=True)
72
+ pub_key_path.write_bytes(bytes(signer_key.verify_key))
73
+
74
+ return receipt_file, signer_key
75
+
76
+
77
+ def test_verify_receipt_help() -> None:
78
+ result = runner.invoke(app, ["verify-receipt", "--help"])
79
+ assert result.exit_code == 0
80
+ assert "receipt" in plain(result.output).lower()
81
+
82
+
83
+ def test_verify_receipt_file_not_found(tmp_path: Path) -> None:
84
+ result = runner.invoke(app, ["verify-receipt", str(tmp_path / "nonexistent.cose")])
85
+ assert result.exit_code == 1
86
+ assert "not found" in plain(result.output + (result.stdout or "")).lower()
87
+
88
+
89
+ def test_verify_receipt_invalid_cbor(tmp_path: Path) -> None:
90
+ bad_file = tmp_path / "bad.cose"
91
+ bad_file.write_bytes(b"\xff\xff\xff not valid cbor")
92
+ result = runner.invoke(app, ["verify-receipt", str(bad_file)])
93
+ assert result.exit_code == 1
94
+
95
+
96
+ def test_verify_receipt_wrong_algorithm(tmp_path: Path) -> None:
97
+ protected_bstr = cbor2.dumps({1: -7, 3: "application/aevum-receipt+cbor", 4: b"kid"})
98
+ payload = cbor2.dumps({"action": "test"})
99
+ cose = [protected_bstr, {}, payload, b"sig"]
100
+ bad_file = tmp_path / "wrong_alg.cose"
101
+ bad_file.write_bytes(cbor2.dumps(cose))
102
+ result = runner.invoke(app, ["verify-receipt", str(bad_file)])
103
+ assert result.exit_code == 2
104
+ assert "UNSUPPORTED ALGORITHM" in plain(result.output + (result.stdout or ""))
105
+
106
+
107
+ def test_verify_receipt_no_public_key_shows_warning(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
108
+ from aevum.core.audit.event import AuditEvent
109
+ from aevum.core.audit.signer import InProcessSigner
110
+ from aevum.core.receipt import AevumReceipt
111
+ from aevum.publish.encoder import ReceiptEncoder
112
+
113
+ monkeypatch.setenv("HOME", str(tmp_path))
114
+ signer = InProcessSigner()
115
+ encoder = ReceiptEncoder(signer=signer, dev_mode=True)
116
+ event = AuditEvent(
117
+ event_id="ev-warn-01",
118
+ episode_id="ep-warn-01",
119
+ sequence=1,
120
+ event_type="warn.test",
121
+ schema_version="1.0",
122
+ valid_from="2026-05-24T00:00:00+00:00",
123
+ valid_to=None,
124
+ system_time=0,
125
+ causation_id=None,
126
+ correlation_id=None,
127
+ actor="warn-agent",
128
+ trace_id=None,
129
+ span_id=None,
130
+ payload={},
131
+ payload_hash=AuditEvent.hash_payload({}),
132
+ prior_hash="d" * 64,
133
+ signature="dGVzdA",
134
+ signer_key_id="test-key",
135
+ )
136
+ receipt = AevumReceipt.from_sigchain_event(event)
137
+ receipt_cbor = encoder.encode(receipt)
138
+ receipt_file = tmp_path / "no_key_receipt.cose"
139
+ receipt_file.write_bytes(receipt_cbor)
140
+
141
+ result = runner.invoke(app, ["verify-receipt", str(receipt_file)])
142
+ output = plain(result.output + (result.stdout or ""))
143
+ assert "WARNING" in output or "unverified" in output.lower()
@@ -1,252 +0,0 @@
1
- # SPDX-License-Identifier: Apache-2.0
2
- # Copyright 2024-2026 Aevum Labs contributors
3
- """
4
- Top-level typer app — CLI v2. Sub-commands and direct commands registered here.
5
- """
6
- from __future__ import annotations
7
-
8
- import json
9
- from pathlib import Path
10
- from typing import Annotated
11
-
12
- import typer
13
-
14
- from aevum.cli.commands import complication, conformance, server, store, version
15
-
16
- # Module-level import for mock.patch patchability (Rule 57).
17
- # Soft import: aevum-conformance is a workspace package not on PyPI, so
18
- # callers without it installed still get a usable CLI (conform command
19
- # shows a helpful error instead of crashing at startup).
20
- try:
21
- from aevum.conformance.suite import ConformanceSuite
22
- except ImportError: # pragma: no cover
23
- ConformanceSuite = None # type: ignore[assignment,misc]
24
-
25
- app = typer.Typer(
26
- name="aevum",
27
- help="Aevum governed context kernel — CLI v2",
28
- no_args_is_help=True,
29
- pretty_exceptions_enable=False,
30
- rich_markup_mode="markdown",
31
- )
32
-
33
- app.add_typer(server.app, name="server")
34
- app.add_typer(store.app, name="store")
35
- app.add_typer(complication.app, name="complication")
36
- app.add_typer(conformance.app, name="conformance")
37
- app.command(name="version")(version.version_command)
38
-
39
- _DEFAULT_STATE = Path.home() / ".aevum"
40
-
41
-
42
- @app.command()
43
- def init(
44
- state_dir: Annotated[
45
- Path,
46
- typer.Option("--state-dir", "-s", help="State directory path"),
47
- ] = _DEFAULT_STATE,
48
- principles: Annotated[
49
- Path,
50
- typer.Option("--principles", "-p", help="Path to signed_principles.yaml"),
51
- ] = Path("signed_principles.yaml"),
52
- ) -> None:
53
- """
54
- Initialize Aevum state directory and verify principles.
55
-
56
- Creates the state directory, generates dual signing keys (Ed25519 +
57
- ML-DSA-65), and verifies the signed_principles.yaml file.
58
- """
59
- typer.echo(f"Initializing Aevum state at {state_dir}...")
60
-
61
- try:
62
- from aevum.core.principles import PrinciplesVerifier
63
- verifier = PrinciplesVerifier(principles)
64
- p = verifier.verify()
65
- typer.echo(f" Principles: OK (sequence={p.sequence}, signed_by={p.signed_by[:30]}...)")
66
- except Exception as exc: # noqa: BLE001
67
- typer.echo(f" Principles: FAILED — {exc}", err=True)
68
- raise typer.Exit(code=1) from None
69
-
70
- try:
71
- from aevum.core.kernel import Kernel
72
- kernel = Kernel.local(
73
- state_dir=state_dir,
74
- principles_path=principles,
75
- tsa_enabled=False,
76
- )
77
- ed25519_pub = kernel.signer.ed25519_public_key.hex()[:16]
78
- typer.echo(f" Keys: OK (ed25519={ed25519_pub}...)")
79
- typer.echo(f" Canaries: PASS ({len(kernel.principles.immutable_ids())} immutable principles)")
80
- except Exception as exc: # noqa: BLE001
81
- typer.echo(f" Kernel init: FAILED — {exc}", err=True)
82
- raise typer.Exit(code=1) from None
83
-
84
- typer.echo(typer.style("Aevum initialized successfully.", fg=typer.colors.GREEN))
85
-
86
-
87
- @app.command()
88
- def verify(
89
- session_id: Annotated[str, typer.Argument(help="Session ID to verify")],
90
- state_dir: Annotated[
91
- Path,
92
- typer.Option("--state-dir", "-s"),
93
- ] = _DEFAULT_STATE,
94
- ) -> None:
95
- """
96
- Verify a session's Merkle root and signatures.
97
-
98
- Re-reads the stored session events from SQLite, recomputes the
99
- Merkle root, and compares it to the signed root in the sigchain.
100
- """
101
- db_path = state_dir / "aevum.db"
102
- if not db_path.exists():
103
- typer.echo(f"Database not found: {db_path}", err=True)
104
- raise typer.Exit(code=1)
105
-
106
- try:
107
- from aevum.core.replay import ReplayEngine
108
- engine = ReplayEngine(db_path)
109
- result = engine.replay(session_id)
110
-
111
- if result.all_matched:
112
- typer.echo(typer.style(
113
- f"Session {session_id[:8]}... VERIFIED",
114
- fg=typer.colors.GREEN,
115
- ))
116
- typer.echo(f" Merkle root: {result.original_merkle_root[:16]}...")
117
- typer.echo(f" Events: {len(result.event_results)}")
118
- else:
119
- typer.echo(typer.style(
120
- f"Session {session_id[:8]}... TAMPERED",
121
- fg=typer.colors.RED,
122
- ), err=True)
123
- typer.echo(
124
- f" First divergence: event #{result.first_divergence}", err=True
125
- )
126
- raise typer.Exit(code=1)
127
-
128
- except ValueError as exc:
129
- typer.echo(f"Session not found: {exc}", err=True)
130
- raise typer.Exit(code=1) from None
131
-
132
-
133
- @app.command(name="audit-pack")
134
- def audit_pack(
135
- session_id: Annotated[str, typer.Argument(help="Session ID")],
136
- output: Annotated[
137
- Path | None,
138
- typer.Option("--output", "-o", help="Output file path (default: stdout)"),
139
- ] = None,
140
- state_dir: Annotated[
141
- Path,
142
- typer.Option("--state-dir", "-s"),
143
- ] = _DEFAULT_STATE,
144
- ) -> None:
145
- """
146
- Export EU AI Act Article 12 audit pack for a session.
147
-
148
- Produces a JSON-LD document using the PROV-O vocabulary.
149
- """
150
- db_path = state_dir / "aevum.db"
151
- if not db_path.exists():
152
- typer.echo(f"Database not found: {db_path}", err=True)
153
- raise typer.Exit(code=1)
154
-
155
- try:
156
- from aevum.core.audit.audit_pack import AuditPackExporter
157
- exporter = AuditPackExporter(db_path)
158
- json_text = exporter.export_json(session_id)
159
-
160
- if output:
161
- output.write_text(json_text, encoding="utf-8")
162
- typer.echo(f"Audit pack written to {output}")
163
- else:
164
- typer.echo(json_text)
165
-
166
- except Exception as exc: # noqa: BLE001
167
- typer.echo(f"Audit pack error: {exc}", err=True)
168
- raise typer.Exit(code=1) from None
169
-
170
-
171
- @app.command()
172
- def conform(
173
- output: Annotated[
174
- str,
175
- typer.Option("--output", "-o", help="Output format: text or json"),
176
- ] = "text",
177
- ) -> None:
178
- """
179
- Run the 9-invariant conformance suite.
180
-
181
- Tests all required Aevum behavioral invariants and prints a report.
182
- Exit code 0 = all pass, 1 = one or more fail.
183
- """
184
- if ConformanceSuite is None:
185
- typer.echo(
186
- "aevum-conformance is not installed. Install it with: pip install aevum-conformance",
187
- err=True,
188
- )
189
- raise typer.Exit(code=1)
190
- suite = ConformanceSuite()
191
- result = suite.run_all()
192
-
193
- if output == "json":
194
- typer.echo(json.dumps(result.to_dict(), indent=2))
195
- else:
196
- typer.echo(result.render())
197
-
198
- if not result.all_passed:
199
- raise typer.Exit(code=1)
200
-
201
-
202
- @app.command()
203
- def replay(
204
- session_id: Annotated[str, typer.Argument(help="Session ID to replay")],
205
- verbose: Annotated[
206
- bool,
207
- typer.Option("--verbose", "-v", help="Show per-event results"),
208
- ] = False,
209
- state_dir: Annotated[
210
- Path,
211
- typer.Option("--state-dir", "-s"),
212
- ] = _DEFAULT_STATE,
213
- ) -> None:
214
- """
215
- Replay a session and verify Merkle chain integrity.
216
-
217
- Re-reads all events and recomputes the Merkle root. Reports any
218
- divergence from the stored root (indicating tampering).
219
- """
220
- db_path = state_dir / "aevum.db"
221
- if not db_path.exists():
222
- typer.echo(f"Database not found: {db_path}", err=True)
223
- raise typer.Exit(code=1)
224
-
225
- try:
226
- from aevum.core.replay import ReplayEngine
227
- engine = ReplayEngine(db_path)
228
- result = engine.replay(session_id)
229
-
230
- status = (
231
- typer.style("PASS", fg=typer.colors.GREEN)
232
- if result.all_matched
233
- else typer.style("FAIL", fg=typer.colors.RED)
234
- )
235
- typer.echo(f"Replay {session_id[:8]}...: {status}")
236
- typer.echo(f" Events: {len(result.event_results)}")
237
- typer.echo(f" Merkle root: {result.original_merkle_root[:16]}...")
238
-
239
- if verbose:
240
- for ev in result.event_results:
241
- ev_status = "OK" if ev.matched else "DIVERGED"
242
- typer.echo(f" [{ev.sequence:3d}] {ev.event_type:<12} {ev_status}")
243
-
244
- if not result.all_matched:
245
- typer.echo(
246
- f" First divergence: event #{result.first_divergence}", err=True
247
- )
248
- raise typer.Exit(code=1)
249
-
250
- except ValueError as exc:
251
- typer.echo(f"Session not found: {exc}", err=True)
252
- raise typer.Exit(code=1) from None
@@ -1,51 +0,0 @@
1
- """
2
- aevum store migrate -- migrate between graph backends.
3
- """
4
-
5
- from __future__ import annotations
6
-
7
- from typing import Annotated
8
-
9
- import typer
10
-
11
- app = typer.Typer(help="Manage graph store backends.")
12
-
13
-
14
- @app.command("migrate")
15
- def migrate(
16
- from_backend: Annotated[str, typer.Option("--from", help="Source backend (oxigraph:<path>)")] = "",
17
- to_backend: Annotated[str, typer.Option("--to", help="Target backend (postgres:<dsn>)")] = "",
18
- ) -> None:
19
- """Migrate graph data between backends."""
20
- if not from_backend or not to_backend:
21
- typer.echo("Both --from and --to are required.", err=True)
22
- raise typer.Exit(code=1)
23
-
24
- if not from_backend.startswith("oxigraph:"):
25
- typer.echo(f"Unsupported source backend: {from_backend!r}", err=True)
26
- typer.echo("Currently supported source: oxigraph:<path>", err=True)
27
- raise typer.Exit(code=1)
28
-
29
- if not to_backend.startswith("postgres:"):
30
- typer.echo(f"Unsupported target backend: {to_backend!r}", err=True)
31
- typer.echo("Currently supported target: postgres:<dsn>", err=True)
32
- raise typer.Exit(code=1)
33
-
34
- try:
35
- from aevum.store.postgres.migrate import migrate_from_oxigraph
36
- except ImportError:
37
- typer.echo("Error: aevum-store-postgres is not installed.", err=True)
38
- raise typer.Exit(code=1) from None
39
-
40
- oxigraph_path = from_backend[len("oxigraph:"):]
41
- postgres_dsn = to_backend[len("postgres:"):]
42
-
43
- typer.echo(f"Migrating: {oxigraph_path} -> PostgreSQL")
44
- try:
45
- import psycopg
46
- conn = psycopg.connect(postgres_dsn)
47
- migrated = migrate_from_oxigraph(oxigraph_path, conn) # type: ignore[arg-type] # Phase 2: wire store construction
48
- typer.echo(f"Migration complete: {migrated} entities transferred.")
49
- except Exception as e:
50
- typer.echo(f"Migration failed: {e}", err=True)
51
- raise typer.Exit(code=1) from e
File without changes
File without changes