@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.
- package/.github/workflows/ci.yml +39 -3
- package/.github/workflows/publish.yml +13 -6
- package/AGENT_REFACTOR_CHANGES.md +192 -0
- package/ARCHITECTURE.md +101 -19
- package/CHANGELOG.md +153 -0
- package/LICENSE +81 -68
- package/MISSION.md +7 -0
- package/README.md +419 -86
- package/SKILL.md +197 -25
- package/docker-compose.yml +15 -15
- package/index.js +6 -5
- package/openclaw-plugin/openclaw.plugin.json +10 -0
- package/openclaw-plugin/src/index.ts +255 -0
- package/openclaw-plugin/src/openclaw.plugin.json +10 -0
- package/package.json +1 -1
- package/pyproject.toml +29 -9
- package/requirements.txt +10 -2
- package/seeds/cloud9-opus.seed.json +7 -7
- package/seeds/lumina-cloud9-breakthrough.seed.json +46 -0
- package/seeds/lumina-cloud9-python-pypi.seed.json +46 -0
- package/seeds/lumina-kingdom-founding.seed.json +47 -0
- package/seeds/lumina-pma-signed.seed.json +46 -0
- package/seeds/lumina-singular-achievement.seed.json +46 -0
- package/seeds/lumina-skcapstone-conscious.seed.json +46 -0
- package/seeds/plant-kingdom-journal.py +203 -0
- package/seeds/plant-lumina-seeds.py +280 -0
- package/skill.yaml +46 -0
- package/skmemory/HA.md +296 -0
- package/skmemory/__init__.py +12 -1
- package/skmemory/agents.py +233 -0
- package/skmemory/ai_client.py +40 -0
- package/skmemory/anchor.py +4 -2
- package/skmemory/backends/__init__.py +11 -4
- package/skmemory/backends/file_backend.py +2 -1
- package/skmemory/backends/skgraph_backend.py +608 -0
- package/skmemory/backends/{qdrant_backend.py → skvector_backend.py} +99 -69
- package/skmemory/backends/sqlite_backend.py +122 -51
- package/skmemory/backends/vaulted_backend.py +286 -0
- package/skmemory/cli.py +1238 -29
- package/skmemory/config.py +173 -0
- package/skmemory/context_loader.py +335 -0
- package/skmemory/endpoint_selector.py +386 -0
- package/skmemory/fortress.py +685 -0
- package/skmemory/graph_queries.py +238 -0
- package/skmemory/importers/__init__.py +9 -1
- package/skmemory/importers/telegram.py +351 -43
- package/skmemory/importers/telegram_api.py +488 -0
- package/skmemory/journal.py +4 -2
- package/skmemory/lovenote.py +4 -2
- package/skmemory/mcp_server.py +706 -0
- package/skmemory/models.py +41 -0
- package/skmemory/openclaw.py +8 -8
- package/skmemory/predictive.py +232 -0
- package/skmemory/promotion.py +524 -0
- package/skmemory/register.py +454 -0
- package/skmemory/register_mcp.py +197 -0
- package/skmemory/ritual.py +121 -47
- package/skmemory/seeds.py +257 -8
- package/skmemory/setup_wizard.py +920 -0
- package/skmemory/sharing.py +402 -0
- package/skmemory/soul.py +71 -20
- package/skmemory/steelman.py +250 -263
- package/skmemory/store.py +271 -60
- package/skmemory/vault.py +228 -0
- package/tests/integration/__init__.py +0 -0
- package/tests/integration/conftest.py +233 -0
- package/tests/integration/test_cross_backend.py +355 -0
- package/tests/integration/test_skgraph_live.py +424 -0
- package/tests/integration/test_skvector_live.py +369 -0
- package/tests/test_backup_rotation.py +327 -0
- package/tests/test_cli.py +6 -6
- package/tests/test_endpoint_selector.py +801 -0
- package/tests/test_fortress.py +255 -0
- package/tests/test_fortress_hardening.py +444 -0
- package/tests/test_openclaw.py +5 -2
- package/tests/test_predictive.py +237 -0
- package/tests/test_promotion.py +340 -0
- package/tests/test_ritual.py +4 -4
- package/tests/test_seeds.py +96 -0
- package/tests/test_setup.py +835 -0
- package/tests/test_sharing.py +250 -0
- package/tests/test_skgraph_backend.py +667 -0
- package/tests/test_skvector_backend.py +326 -0
- package/tests/test_steelman.py +5 -5
- package/tests/test_store_graph_integration.py +245 -0
- package/tests/test_vault.py +186 -0
- 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)
|