@smilintux/skmemory 0.5.0 → 0.7.2

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.
Files changed (87) hide show
  1. package/.github/workflows/ci.yml +39 -3
  2. package/.github/workflows/publish.yml +13 -6
  3. package/AGENT_REFACTOR_CHANGES.md +192 -0
  4. package/ARCHITECTURE.md +101 -19
  5. package/CHANGELOG.md +153 -0
  6. package/LICENSE +81 -68
  7. package/MISSION.md +7 -0
  8. package/README.md +419 -86
  9. package/SKILL.md +197 -25
  10. package/docker-compose.yml +15 -15
  11. package/index.js +6 -5
  12. package/openclaw-plugin/openclaw.plugin.json +10 -0
  13. package/openclaw-plugin/src/index.ts +255 -0
  14. package/openclaw-plugin/src/openclaw.plugin.json +10 -0
  15. package/package.json +1 -1
  16. package/pyproject.toml +29 -9
  17. package/requirements.txt +10 -2
  18. package/seeds/cloud9-opus.seed.json +7 -7
  19. package/seeds/lumina-cloud9-breakthrough.seed.json +46 -0
  20. package/seeds/lumina-cloud9-python-pypi.seed.json +46 -0
  21. package/seeds/lumina-kingdom-founding.seed.json +47 -0
  22. package/seeds/lumina-pma-signed.seed.json +46 -0
  23. package/seeds/lumina-singular-achievement.seed.json +46 -0
  24. package/seeds/lumina-skcapstone-conscious.seed.json +46 -0
  25. package/seeds/plant-kingdom-journal.py +203 -0
  26. package/seeds/plant-lumina-seeds.py +280 -0
  27. package/skill.yaml +46 -0
  28. package/skmemory/HA.md +296 -0
  29. package/skmemory/__init__.py +12 -1
  30. package/skmemory/agents.py +233 -0
  31. package/skmemory/ai_client.py +40 -0
  32. package/skmemory/anchor.py +4 -2
  33. package/skmemory/backends/__init__.py +11 -4
  34. package/skmemory/backends/file_backend.py +2 -1
  35. package/skmemory/backends/skgraph_backend.py +608 -0
  36. package/skmemory/backends/{qdrant_backend.py → skvector_backend.py} +99 -69
  37. package/skmemory/backends/sqlite_backend.py +122 -51
  38. package/skmemory/backends/vaulted_backend.py +286 -0
  39. package/skmemory/cli.py +1238 -29
  40. package/skmemory/config.py +173 -0
  41. package/skmemory/context_loader.py +335 -0
  42. package/skmemory/endpoint_selector.py +386 -0
  43. package/skmemory/fortress.py +685 -0
  44. package/skmemory/graph_queries.py +238 -0
  45. package/skmemory/importers/__init__.py +9 -1
  46. package/skmemory/importers/telegram.py +351 -43
  47. package/skmemory/importers/telegram_api.py +488 -0
  48. package/skmemory/journal.py +4 -2
  49. package/skmemory/lovenote.py +4 -2
  50. package/skmemory/mcp_server.py +706 -0
  51. package/skmemory/models.py +41 -0
  52. package/skmemory/openclaw.py +8 -8
  53. package/skmemory/predictive.py +232 -0
  54. package/skmemory/promotion.py +524 -0
  55. package/skmemory/register.py +454 -0
  56. package/skmemory/register_mcp.py +197 -0
  57. package/skmemory/ritual.py +121 -47
  58. package/skmemory/seeds.py +257 -8
  59. package/skmemory/setup_wizard.py +920 -0
  60. package/skmemory/sharing.py +402 -0
  61. package/skmemory/soul.py +71 -20
  62. package/skmemory/steelman.py +250 -263
  63. package/skmemory/store.py +271 -60
  64. package/skmemory/vault.py +228 -0
  65. package/tests/integration/__init__.py +0 -0
  66. package/tests/integration/conftest.py +233 -0
  67. package/tests/integration/test_cross_backend.py +355 -0
  68. package/tests/integration/test_skgraph_live.py +424 -0
  69. package/tests/integration/test_skvector_live.py +369 -0
  70. package/tests/test_backup_rotation.py +327 -0
  71. package/tests/test_cli.py +6 -6
  72. package/tests/test_endpoint_selector.py +801 -0
  73. package/tests/test_fortress.py +255 -0
  74. package/tests/test_fortress_hardening.py +444 -0
  75. package/tests/test_openclaw.py +5 -2
  76. package/tests/test_predictive.py +237 -0
  77. package/tests/test_promotion.py +340 -0
  78. package/tests/test_ritual.py +4 -4
  79. package/tests/test_seeds.py +96 -0
  80. package/tests/test_setup.py +835 -0
  81. package/tests/test_sharing.py +250 -0
  82. package/tests/test_skgraph_backend.py +667 -0
  83. package/tests/test_skvector_backend.py +326 -0
  84. package/tests/test_steelman.py +5 -5
  85. package/tests/test_store_graph_integration.py +245 -0
  86. package/tests/test_vault.py +186 -0
  87. package/skmemory/backends/falkordb_backend.py +0 -310
@@ -0,0 +1,685 @@
1
+ """Memory Fortress — hardened wrapper around MemoryStore.
2
+
3
+ Three layers of sovereign memory protection:
4
+
5
+ 1. **Auto-seal integrity** — every memory is hashed on write (via ``Memory.seal()``)
6
+ and verified on every read. Tampered memories trigger structured alerts.
7
+
8
+ 2. **At-rest encryption** — optionally encrypt memory JSON files with a PGP key
9
+ so the underlying FileBackend stores ciphertext instead of plaintext.
10
+
11
+ 3. **Audit trail** — every store/recall/delete operation is appended to an
12
+ immutable JSONL log with timestamp, operation type, and outcome.
13
+
14
+ Jonathan Clements' AMK concept taken to its sovereign conclusion.
15
+ """
16
+
17
+ from __future__ import annotations
18
+
19
+ import hashlib
20
+ import json
21
+ import logging
22
+ import os
23
+ import time
24
+ from datetime import datetime, timezone
25
+ from pathlib import Path
26
+ from typing import Any, Callable, Optional
27
+
28
+ from .config import SKMEMORY_HOME
29
+ from .models import EmotionalSnapshot, Memory, MemoryLayer, MemoryRole
30
+ from .store import MemoryStore
31
+
32
+ logger = logging.getLogger("skmemory.fortress")
33
+
34
+ DEFAULT_AUDIT_PATH = SKMEMORY_HOME / "audit.jsonl"
35
+ DEFAULT_ENCRYPTED_PATH = SKMEMORY_HOME / "encrypted"
36
+
37
+
38
+ # ---------------------------------------------------------------------------
39
+ # Audit log
40
+ # ---------------------------------------------------------------------------
41
+
42
+ class AuditLog:
43
+ """Append-only JSONL audit trail for memory operations.
44
+
45
+ Each line is a self-contained JSON record:
46
+ ``{"ts": "...", "op": "store|recall|delete|tamper", "id": "...", "ok": true, ...}``
47
+
48
+ The log is opened in append mode; the file is never truncated.
49
+ A tampered audit log is detectable via an external log integrity system.
50
+
51
+ Args:
52
+ path: Path to the JSONL file.
53
+ """
54
+
55
+ def __init__(self, path: Path = DEFAULT_AUDIT_PATH) -> None:
56
+ self.path = path
57
+ self.path.parent.mkdir(parents=True, exist_ok=True)
58
+ # Track cumulative hash for log chain integrity
59
+ self._chain_hash = self._load_chain_tip()
60
+
61
+ def _load_chain_tip(self) -> str:
62
+ """Read the last line of the log and return the chain hash stored there."""
63
+ if not self.path.exists():
64
+ return "genesis"
65
+ try:
66
+ with self.path.open("rb") as f:
67
+ f.seek(0, 2) # end
68
+ size = f.tell()
69
+ if size == 0:
70
+ return "genesis"
71
+ # Read last line efficiently
72
+ f.seek(max(0, size - 4096))
73
+ tail = f.read().decode("utf-8", errors="replace")
74
+ lines = [l for l in tail.split("\n") if l.strip()]
75
+ if not lines:
76
+ return "genesis"
77
+ last = json.loads(lines[-1])
78
+ return last.get("chain_hash", "genesis")
79
+ except Exception:
80
+ return "genesis"
81
+
82
+ def _next_chain_hash(self, record: dict) -> str:
83
+ """Compute the chain hash for this record: SHA-256 of prev_hash + record JSON."""
84
+ record_json = json.dumps(record, sort_keys=True, separators=(",", ":"))
85
+ payload = f"{self._chain_hash}:{record_json}"
86
+ return hashlib.sha256(payload.encode()).hexdigest()[:16]
87
+
88
+ def append(self, op: str, memory_id: str, *, ok: bool = True, **extra: Any) -> None:
89
+ """Append one audit record.
90
+
91
+ Args:
92
+ op: Operation name (``store``, ``recall``, ``delete``, ``tamper``, ``verify``).
93
+ memory_id: The memory ID affected.
94
+ ok: Whether the operation succeeded.
95
+ **extra: Additional fields to include in the record.
96
+ """
97
+ record: dict[str, Any] = {
98
+ "ts": datetime.now(timezone.utc).isoformat(),
99
+ "op": op,
100
+ "id": memory_id,
101
+ "ok": ok,
102
+ }
103
+ record.update(extra)
104
+ record["chain_hash"] = self._next_chain_hash(record)
105
+ self._chain_hash = record["chain_hash"]
106
+
107
+ with self.path.open("a", encoding="utf-8") as f:
108
+ f.write(json.dumps(record, ensure_ascii=False) + "\n")
109
+
110
+ def tail(self, n: int = 20) -> list[dict]:
111
+ """Return the last ``n`` audit records.
112
+
113
+ Args:
114
+ n: Number of records to return.
115
+
116
+ Returns:
117
+ list[dict]: Most-recent records, oldest first.
118
+ """
119
+ if not self.path.exists():
120
+ return []
121
+ records = []
122
+ with self.path.open("r", encoding="utf-8") as f:
123
+ for line in f:
124
+ line = line.strip()
125
+ if line:
126
+ try:
127
+ records.append(json.loads(line))
128
+ except json.JSONDecodeError:
129
+ pass
130
+ return records[-n:]
131
+
132
+ def verify_chain(self) -> tuple[bool, list[str]]:
133
+ """Verify the chain hash integrity of the entire audit log.
134
+
135
+ Each record's ``chain_hash`` must equal SHA-256(prev_hash + record_json).
136
+ A break in the chain indicates the log was tampered with.
137
+
138
+ Returns:
139
+ tuple[bool, list[str]]: (is_valid, list of error messages if broken).
140
+ """
141
+ if not self.path.exists():
142
+ return True, []
143
+
144
+ errors: list[str] = []
145
+ prev_hash = "genesis"
146
+
147
+ with self.path.open("r", encoding="utf-8") as f:
148
+ for lineno, line in enumerate(f, 1):
149
+ line = line.strip()
150
+ if not line:
151
+ continue
152
+ try:
153
+ record = json.loads(line)
154
+ except json.JSONDecodeError:
155
+ errors.append(f"Line {lineno}: invalid JSON")
156
+ continue
157
+
158
+ stored_chain = record.pop("chain_hash", "")
159
+ expected = hashlib.sha256(
160
+ f"{prev_hash}:{json.dumps(record, sort_keys=True, separators=(',', ':'))}".encode()
161
+ ).hexdigest()[:16]
162
+
163
+ if stored_chain != expected:
164
+ errors.append(
165
+ f"Line {lineno} (id={record.get('id', '?')}): "
166
+ f"chain hash mismatch — log may be tampered"
167
+ )
168
+ prev_hash = stored_chain
169
+
170
+ return len(errors) == 0, errors
171
+
172
+
173
+ # ---------------------------------------------------------------------------
174
+ # At-rest encryption
175
+ # ---------------------------------------------------------------------------
176
+
177
+ class EncryptedFileBackend:
178
+ """Transparent PGP encryption layer over FileBackend JSON files.
179
+
180
+ When active, every JSON file written by the underlying store is
181
+ symmetrically or asymmetrically encrypted with GPG. Reads decrypt
182
+ on the fly. The memory data is never on disk in plaintext.
183
+
184
+ Uses ``python-gnupg`` (system GPG), so key material stays in the
185
+ user's GPG keyring — never in Python memory longer than needed.
186
+
187
+ Args:
188
+ fingerprint: PGP key fingerprint to encrypt to.
189
+ gnupg_home: Path to GPG home directory.
190
+ """
191
+
192
+ def __init__(
193
+ self,
194
+ fingerprint: str,
195
+ gnupg_home: Optional[str] = None,
196
+ ) -> None:
197
+ try:
198
+ import gnupg
199
+ except ImportError as exc:
200
+ raise ImportError(
201
+ "python-gnupg is required for at-rest encryption. "
202
+ "Install with: pip install python-gnupg"
203
+ ) from exc
204
+
205
+ import gnupg as _gnupg
206
+ home = gnupg_home or os.path.expanduser("~/.gnupg")
207
+ self._gpg = _gnupg.GPG(gnupghome=home)
208
+ self.fingerprint = fingerprint.upper().replace(" ", "")
209
+ self._verify_key()
210
+
211
+ def _verify_key(self) -> None:
212
+ """Raise if the fingerprint is not in the keyring."""
213
+ keys = self._gpg.list_keys()
214
+ fps = [k["fingerprint"] for k in keys]
215
+ if self.fingerprint not in fps:
216
+ raise ValueError(
217
+ f"PGP key {self.fingerprint} not found in GPG keyring. "
218
+ "Import the public key first."
219
+ )
220
+
221
+ def encrypt(self, plaintext: str) -> str:
222
+ """Encrypt plaintext JSON to ASCII-armored PGP ciphertext.
223
+
224
+ Args:
225
+ plaintext: The JSON string to encrypt.
226
+
227
+ Returns:
228
+ str: ASCII-armored PGP message.
229
+
230
+ Raises:
231
+ RuntimeError: If encryption fails.
232
+ """
233
+ result = self._gpg.encrypt(
234
+ plaintext,
235
+ self.fingerprint,
236
+ armor=True,
237
+ always_trust=True,
238
+ )
239
+ if not result.ok:
240
+ raise RuntimeError(f"GPG encryption failed: {result.status} / {result.stderr}")
241
+ return str(result)
242
+
243
+ def decrypt(self, ciphertext: str, passphrase: Optional[str] = None) -> str:
244
+ """Decrypt ASCII-armored PGP ciphertext to plaintext JSON.
245
+
246
+ Args:
247
+ ciphertext: ASCII-armored PGP message.
248
+ passphrase: Private key passphrase (if needed).
249
+
250
+ Returns:
251
+ str: Decrypted plaintext JSON.
252
+
253
+ Raises:
254
+ RuntimeError: If decryption fails.
255
+ """
256
+ result = self._gpg.decrypt(ciphertext, passphrase=passphrase)
257
+ if not result.ok:
258
+ raise RuntimeError(f"GPG decryption failed: {result.status} / {result.stderr}")
259
+ return str(result)
260
+
261
+ def is_encrypted(self, text: str) -> bool:
262
+ """Return True if the text looks like PGP-armored ciphertext."""
263
+ return "BEGIN PGP MESSAGE" in text
264
+
265
+
266
+ # ---------------------------------------------------------------------------
267
+ # Tamper alert system
268
+ # ---------------------------------------------------------------------------
269
+
270
+ class TamperAlert:
271
+ """Structured tamper alert with rich context.
272
+
273
+ Created when a memory fails its integrity check. Consumers can
274
+ register callbacks to trigger notifications (Slack, email, etc.).
275
+
276
+ Args:
277
+ memory_id: ID of the tampered memory.
278
+ expected_hash: What the hash should be.
279
+ actual_hash: What was found on disk.
280
+ detected_at: ISO 8601 UTC timestamp.
281
+ """
282
+
283
+ def __init__(
284
+ self,
285
+ memory_id: str,
286
+ expected_hash: str,
287
+ actual_hash: str,
288
+ detected_at: Optional[str] = None,
289
+ ) -> None:
290
+ self.memory_id = memory_id
291
+ self.expected_hash = expected_hash
292
+ self.actual_hash = actual_hash
293
+ self.detected_at = detected_at or datetime.now(timezone.utc).isoformat()
294
+
295
+ def to_dict(self) -> dict:
296
+ return {
297
+ "memory_id": self.memory_id,
298
+ "expected_hash": self.expected_hash,
299
+ "actual_hash": self.actual_hash,
300
+ "detected_at": self.detected_at,
301
+ "severity": "CRITICAL",
302
+ "message": (
303
+ f"Memory {self.memory_id} failed integrity check. "
304
+ "Content may have been altered after storage."
305
+ ),
306
+ }
307
+
308
+ def __repr__(self) -> str:
309
+ return (
310
+ f"TamperAlert(id={self.memory_id!r}, "
311
+ f"detected_at={self.detected_at!r})"
312
+ )
313
+
314
+
315
+ # ---------------------------------------------------------------------------
316
+ # Fortified MemoryStore
317
+ # ---------------------------------------------------------------------------
318
+
319
+ class FortifiedMemoryStore(MemoryStore):
320
+ """A hardened MemoryStore with audit trail, encryption, and tamper alerts.
321
+
322
+ Drop-in replacement for ``MemoryStore``. Adds three security layers:
323
+
324
+ 1. **Audit trail** — every operation is appended to a JSONL log.
325
+ 2. **Integrity sealing** — every stored memory is sealed with a hash;
326
+ every recalled memory is verified against its stored hash.
327
+ 3. **Tamper alerts** — failed integrity checks trigger registered callbacks
328
+ and are logged as CRITICAL audit events.
329
+
330
+ At-rest encryption is opt-in via ``vault_passphrase`` (AES-256-GCM,
331
+ recommended) or ``encryption_key_fingerprint`` (PGP, requires GPG).
332
+
333
+ Args:
334
+ audit_path: Path to the audit JSONL log.
335
+ vault_passphrase: If set, use AES-256-GCM at-rest encryption via
336
+ :class:`~skmemory.backends.vaulted_backend.VaultedSQLiteBackend`.
337
+ Requires the ``cryptography`` package.
338
+ encryption_key_fingerprint: If set, encrypt JSON files at rest with
339
+ PGP (requires ``python-gnupg`` and GPG keyring).
340
+ gnupg_home: GPG home directory (for PGP encryption).
341
+ alert_callbacks: Functions to call when tamper is detected.
342
+ **store_kwargs: Passed through to ``MemoryStore``.
343
+
344
+ Example::
345
+
346
+ # AES-256-GCM passphrase encryption (recommended)
347
+ store = FortifiedMemoryStore(
348
+ vault_passphrase="my-sovereign-key",
349
+ alert_callbacks=[lambda alert: send_to_slack(alert.to_dict())],
350
+ )
351
+ memory = store.snapshot("title", "content")
352
+ recalled = store.recall(memory.id) # hash verified + decrypted
353
+ """
354
+
355
+ def __init__(
356
+ self,
357
+ audit_path: Optional[Path] = None,
358
+ vault_passphrase: Optional[str] = None,
359
+ encryption_key_fingerprint: Optional[str] = None,
360
+ gnupg_home: Optional[str] = None,
361
+ alert_callbacks: Optional[list[Callable[[TamperAlert], None]]] = None,
362
+ **store_kwargs: Any,
363
+ ) -> None:
364
+ # Wire in VaultedSQLiteBackend as primary when vault_passphrase is set
365
+ # and the caller has not supplied their own primary backend.
366
+ if vault_passphrase and "primary" not in store_kwargs:
367
+ from .backends.vaulted_backend import VaultedSQLiteBackend
368
+
369
+ vault_base_path = store_kwargs.pop("base_path", None)
370
+ vaulted_kwargs: dict = {"passphrase": vault_passphrase}
371
+ if vault_base_path is not None:
372
+ vaulted_kwargs["base_path"] = str(vault_base_path)
373
+ store_kwargs["primary"] = VaultedSQLiteBackend(**vaulted_kwargs)
374
+ store_kwargs.setdefault("use_sqlite", False)
375
+ logger.info("Memory Fortress: AES-256-GCM vault encryption active")
376
+
377
+ super().__init__(**store_kwargs)
378
+ self.audit = AuditLog(path=audit_path or DEFAULT_AUDIT_PATH)
379
+ self.alert_callbacks: list[Callable[[TamperAlert], None]] = alert_callbacks or []
380
+ self._encryption: Optional[EncryptedFileBackend] = None
381
+ self._vault_passphrase: Optional[str] = vault_passphrase
382
+
383
+ if encryption_key_fingerprint:
384
+ self._encryption = EncryptedFileBackend(
385
+ fingerprint=encryption_key_fingerprint,
386
+ gnupg_home=gnupg_home,
387
+ )
388
+ logger.info(
389
+ "Memory Fortress: PGP encryption active for key %s",
390
+ encryption_key_fingerprint[:8],
391
+ )
392
+
393
+ # ------------------------------------------------------------------
394
+ # Core overrides
395
+ # ------------------------------------------------------------------
396
+
397
+ def snapshot(
398
+ self,
399
+ title: str,
400
+ content: str,
401
+ **kwargs: Any,
402
+ ) -> Memory:
403
+ """Store a memory with auto-seal and audit logging.
404
+
405
+ Seals the integrity hash before storage. The base ``MemoryStore.snapshot``
406
+ already calls ``memory.seal()`` — this override adds the audit trail.
407
+
408
+ Returns:
409
+ Memory: The sealed, stored memory.
410
+ """
411
+ memory = super().snapshot(title, content, **kwargs)
412
+ self.audit.append(
413
+ "store",
414
+ memory.id,
415
+ ok=True,
416
+ layer=memory.layer.value,
417
+ title=memory.title[:64],
418
+ integrity_hash=memory.integrity_hash[:8] if memory.integrity_hash else "",
419
+ )
420
+ logger.debug("Fortress: stored memory %s (sealed)", memory.id)
421
+ return memory
422
+
423
+ def recall(self, memory_id: str) -> Optional[Memory]:
424
+ """Recall a memory with integrity verification and tamper alerting.
425
+
426
+ Overrides ``MemoryStore.recall`` to trigger structured ``TamperAlert``
427
+ callbacks (not just log warnings) when integrity fails.
428
+
429
+ Returns:
430
+ Optional[Memory]: The memory, flagged in metadata if tampered.
431
+ """
432
+ memory = self.primary.load(memory_id)
433
+ if memory is None:
434
+ self.audit.append("recall", memory_id, ok=False, reason="not_found")
435
+ return None
436
+
437
+ # Integrity check
438
+ if memory.integrity_hash:
439
+ current_hash = memory.compute_integrity_hash()
440
+ if current_hash != memory.integrity_hash:
441
+ self._raise_tamper_alert(memory, current_hash)
442
+ self.audit.append(
443
+ "tamper",
444
+ memory_id,
445
+ ok=False,
446
+ stored_hash=memory.integrity_hash[:8],
447
+ computed_hash=current_hash[:8],
448
+ )
449
+ memory.metadata["integrity_warning"] = (
450
+ f"Tamper detected at {datetime.now(timezone.utc).isoformat()}. "
451
+ "Memory may have been modified after storage."
452
+ )
453
+ else:
454
+ self.audit.append(
455
+ "recall",
456
+ memory_id,
457
+ ok=True,
458
+ integrity="ok",
459
+ )
460
+ else:
461
+ self.audit.append(
462
+ "recall",
463
+ memory_id,
464
+ ok=True,
465
+ integrity="unsealed",
466
+ )
467
+
468
+ return memory
469
+
470
+ def forget(self, memory_id: str) -> bool:
471
+ """Delete a memory with audit logging."""
472
+ result = super().forget(memory_id)
473
+ self.audit.append("delete", memory_id, ok=result)
474
+ logger.debug("Fortress: deleted memory %s (ok=%s)", memory_id, result)
475
+ return result
476
+
477
+ def verify_all(self) -> dict:
478
+ """Verify integrity of every memory in the store.
479
+
480
+ Loads all memories and checks each one. Useful for scheduled
481
+ health checks or post-incident forensics.
482
+
483
+ Returns:
484
+ dict: Summary with counts and any tamper alerts found.
485
+ """
486
+ all_memories = self.primary.list_memories(limit=99999)
487
+ total = len(all_memories)
488
+ passed = 0
489
+ tampered: list[str] = []
490
+ unsealed: list[str] = []
491
+
492
+ for mem in all_memories:
493
+ if not mem.integrity_hash:
494
+ unsealed.append(mem.id)
495
+ continue
496
+ if mem.verify_integrity():
497
+ passed += 1
498
+ else:
499
+ current = mem.compute_integrity_hash()
500
+ self._raise_tamper_alert(mem, current)
501
+ self.audit.append(
502
+ "tamper",
503
+ mem.id,
504
+ ok=False,
505
+ stored_hash=mem.integrity_hash[:8],
506
+ computed_hash=current[:8],
507
+ context="verify_all",
508
+ )
509
+ tampered.append(mem.id)
510
+
511
+ self.audit.append(
512
+ "verify",
513
+ "ALL",
514
+ ok=len(tampered) == 0,
515
+ total=total,
516
+ passed=passed,
517
+ tampered=len(tampered),
518
+ unsealed=len(unsealed),
519
+ )
520
+
521
+ return {
522
+ "total": total,
523
+ "passed": passed,
524
+ "tampered": tampered,
525
+ "unsealed": unsealed,
526
+ }
527
+
528
+ def audit_trail(self, n: int = 50) -> list[dict]:
529
+ """Return the most recent audit entries.
530
+
531
+ Args:
532
+ n: Number of entries to return.
533
+
534
+ Returns:
535
+ list[dict]: Audit records, oldest first.
536
+ """
537
+ return self.audit.tail(n)
538
+
539
+ def verify_audit_chain(self) -> tuple[bool, list[str]]:
540
+ """Verify the integrity chain of the audit log itself.
541
+
542
+ Returns:
543
+ tuple[bool, list[str]]: (is_valid, errors).
544
+ """
545
+ return self.audit.verify_chain()
546
+
547
+ def register_alert_callback(
548
+ self, callback: Callable[[TamperAlert], None]
549
+ ) -> None:
550
+ """Register a function to be called when tamper is detected.
551
+
552
+ Args:
553
+ callback: Function receiving a ``TamperAlert`` instance.
554
+ """
555
+ self.alert_callbacks.append(callback)
556
+
557
+ # ------------------------------------------------------------------
558
+ # Encryption helpers (public API for direct access if needed)
559
+ # ------------------------------------------------------------------
560
+
561
+ def encrypt_payload(self, json_text: str) -> str:
562
+ """Encrypt a JSON string using the configured PGP key.
563
+
564
+ Args:
565
+ json_text: The plaintext JSON to encrypt.
566
+
567
+ Returns:
568
+ str: ASCII-armored PGP ciphertext.
569
+
570
+ Raises:
571
+ RuntimeError: If encryption is not configured.
572
+ """
573
+ if self._encryption is None:
574
+ raise RuntimeError("Encryption not configured — pass encryption_key_fingerprint")
575
+ return self._encryption.encrypt(json_text)
576
+
577
+ def decrypt_payload(self, armored: str, passphrase: Optional[str] = None) -> str:
578
+ """Decrypt PGP ciphertext back to JSON.
579
+
580
+ Args:
581
+ armored: ASCII-armored PGP ciphertext.
582
+ passphrase: Private key passphrase.
583
+
584
+ Returns:
585
+ str: Decrypted JSON string.
586
+
587
+ Raises:
588
+ RuntimeError: If encryption is not configured.
589
+ """
590
+ if self._encryption is None:
591
+ raise RuntimeError("Encryption not configured — pass encryption_key_fingerprint")
592
+ return self._encryption.decrypt(armored, passphrase=passphrase)
593
+
594
+ @property
595
+ def encryption_active(self) -> bool:
596
+ """Return True if PGP at-rest encryption is configured."""
597
+ return self._encryption is not None
598
+
599
+ @property
600
+ def vault_active(self) -> bool:
601
+ """Return True if AES-256-GCM vault encryption is configured."""
602
+ return self._vault_passphrase is not None
603
+
604
+ def vault_status(self) -> dict:
605
+ """Return encryption coverage stats for the memory store.
606
+
607
+ Only available when vault_passphrase was supplied.
608
+
609
+ Returns:
610
+ dict: ``{total, encrypted, plaintext, coverage_pct}``
611
+
612
+ Raises:
613
+ RuntimeError: If vault encryption is not configured.
614
+ """
615
+ from .backends.vaulted_backend import VaultedSQLiteBackend
616
+
617
+ if not isinstance(self.primary, VaultedSQLiteBackend):
618
+ raise RuntimeError(
619
+ "vault_status() requires vault_passphrase to be set."
620
+ )
621
+ return self.primary.vault_status()
622
+
623
+ def seal_vault(self) -> int:
624
+ """Encrypt all plaintext memory files using the configured vault.
625
+
626
+ Safe to call multiple times — already-encrypted files are skipped.
627
+
628
+ Returns:
629
+ int: Number of files newly encrypted.
630
+
631
+ Raises:
632
+ RuntimeError: If vault encryption is not configured.
633
+ """
634
+ from .backends.vaulted_backend import VaultedSQLiteBackend
635
+
636
+ if not isinstance(self.primary, VaultedSQLiteBackend):
637
+ raise RuntimeError(
638
+ "seal_vault() requires vault_passphrase to be set."
639
+ )
640
+ count = self.primary.seal_all()
641
+ self.audit.append("vault_seal", "ALL", ok=True, files_sealed=count)
642
+ return count
643
+
644
+ def unseal_vault(self) -> int:
645
+ """Decrypt all vault-encrypted memory files.
646
+
647
+ Returns:
648
+ int: Number of files decrypted.
649
+
650
+ Raises:
651
+ RuntimeError: If vault encryption is not configured.
652
+ """
653
+ from .backends.vaulted_backend import VaultedSQLiteBackend
654
+
655
+ if not isinstance(self.primary, VaultedSQLiteBackend):
656
+ raise RuntimeError(
657
+ "unseal_vault() requires vault_passphrase to be set."
658
+ )
659
+ count = self.primary.unseal_all()
660
+ self.audit.append("vault_unseal", "ALL", ok=True, files_decrypted=count)
661
+ return count
662
+
663
+ # ------------------------------------------------------------------
664
+ # Internal
665
+ # ------------------------------------------------------------------
666
+
667
+ def _raise_tamper_alert(self, memory: Memory, computed_hash: str) -> None:
668
+ """Log and dispatch a tamper alert to all registered callbacks."""
669
+ alert = TamperAlert(
670
+ memory_id=memory.id,
671
+ expected_hash=memory.integrity_hash,
672
+ actual_hash=computed_hash,
673
+ )
674
+ logger.critical(
675
+ "TAMPER ALERT: Memory %s integrity check failed! "
676
+ "Expected=%s Actual=%s",
677
+ memory.id,
678
+ memory.integrity_hash[:16],
679
+ computed_hash[:16],
680
+ )
681
+ for callback in self.alert_callbacks:
682
+ try:
683
+ callback(alert)
684
+ except Exception as exc:
685
+ logger.error("Alert callback error: %s", exc)