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