@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,255 @@
1
+ """Tests for Memory Fortress — auto-seal, audit trail, tamper alerts.
2
+
3
+ Tests the FortifiedMemoryStore, AuditLog, and TamperAlert classes.
4
+ All tests use in-memory/temp-path backends — no GPG required for basic tests.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import json
10
+ import tempfile
11
+ from pathlib import Path
12
+
13
+ import pytest
14
+
15
+ from skmemory.fortress import AuditLog, FortifiedMemoryStore, TamperAlert
16
+ from skmemory.models import Memory, MemoryLayer
17
+
18
+
19
+ # ---------------------------------------------------------------------------
20
+ # Fixtures
21
+ # ---------------------------------------------------------------------------
22
+
23
+ @pytest.fixture
24
+ def tmp_audit(tmp_path):
25
+ """Return a temporary audit log."""
26
+ return AuditLog(path=tmp_path / "audit.jsonl")
27
+
28
+
29
+ @pytest.fixture
30
+ def fortress(tmp_path):
31
+ """Return a FortifiedMemoryStore using a temp directory."""
32
+ from skmemory.backends.sqlite_backend import SQLiteBackend
33
+ backend = SQLiteBackend(base_path=str(tmp_path / "memories"))
34
+ return FortifiedMemoryStore(
35
+ primary=backend,
36
+ use_sqlite=False,
37
+ audit_path=tmp_path / "audit.jsonl",
38
+ )
39
+
40
+
41
+ # ---------------------------------------------------------------------------
42
+ # AuditLog tests
43
+ # ---------------------------------------------------------------------------
44
+
45
+ class TestAuditLog:
46
+ def test_append_creates_file(self, tmp_audit, tmp_path):
47
+ tmp_audit.append("store", "abc123", ok=True)
48
+ assert (tmp_path / "audit.jsonl").exists()
49
+
50
+ def test_record_format(self, tmp_audit):
51
+ tmp_audit.append("recall", "mem1", ok=True, integrity="ok")
52
+ records = tmp_audit.tail(1)
53
+ assert len(records) == 1
54
+ r = records[0]
55
+ assert r["op"] == "recall"
56
+ assert r["id"] == "mem1"
57
+ assert r["ok"] is True
58
+ assert r["integrity"] == "ok"
59
+ assert "ts" in r
60
+ assert "chain_hash" in r
61
+
62
+ def test_chain_hash_progresses(self, tmp_audit):
63
+ tmp_audit.append("store", "a")
64
+ tmp_audit.append("store", "b")
65
+ tmp_audit.append("recall", "a")
66
+ records = tmp_audit.tail(10)
67
+ assert len(records) == 3
68
+ # Each chain hash should be different
69
+ hashes = [r["chain_hash"] for r in records]
70
+ assert len(set(hashes)) == 3
71
+
72
+ def test_verify_chain_valid(self, tmp_audit):
73
+ for i in range(5):
74
+ tmp_audit.append("store", f"mem{i}", ok=True)
75
+ ok, errors = tmp_audit.verify_chain()
76
+ assert ok, f"Chain should be valid but got errors: {errors}"
77
+
78
+ def test_verify_chain_tampered(self, tmp_audit, tmp_path):
79
+ for i in range(3):
80
+ tmp_audit.append("store", f"mem{i}", ok=True)
81
+
82
+ # Tamper with the file — alter the second line
83
+ audit_path = tmp_path / "audit.jsonl"
84
+ lines = audit_path.read_text().splitlines()
85
+ record = json.loads(lines[1])
86
+ record["op"] = "delete" # tamper!
87
+ lines[1] = json.dumps(record)
88
+ audit_path.write_text("\n".join(lines) + "\n")
89
+
90
+ ok, errors = tmp_audit.verify_chain()
91
+ assert not ok
92
+ assert len(errors) > 0
93
+
94
+ def test_tail_respects_limit(self, tmp_audit):
95
+ for i in range(10):
96
+ tmp_audit.append("store", f"mem{i}")
97
+ records = tmp_audit.tail(3)
98
+ assert len(records) == 3
99
+
100
+ def test_empty_log_verify(self, tmp_audit):
101
+ ok, errors = tmp_audit.verify_chain()
102
+ assert ok
103
+ assert errors == []
104
+
105
+
106
+ # ---------------------------------------------------------------------------
107
+ # TamperAlert tests
108
+ # ---------------------------------------------------------------------------
109
+
110
+ class TestTamperAlert:
111
+ def test_to_dict(self):
112
+ alert = TamperAlert(
113
+ memory_id="abc",
114
+ expected_hash="aaa",
115
+ actual_hash="bbb",
116
+ )
117
+ d = alert.to_dict()
118
+ assert d["memory_id"] == "abc"
119
+ assert d["expected_hash"] == "aaa"
120
+ assert d["actual_hash"] == "bbb"
121
+ assert d["severity"] == "CRITICAL"
122
+ assert "tamper" in d["message"].lower() or "integrity" in d["message"].lower()
123
+ assert "detected_at" in d
124
+
125
+ def test_repr(self):
126
+ alert = TamperAlert("x", "a", "b")
127
+ assert "x" in repr(alert)
128
+
129
+
130
+ # ---------------------------------------------------------------------------
131
+ # FortifiedMemoryStore tests
132
+ # ---------------------------------------------------------------------------
133
+
134
+ class TestFortifiedMemoryStore:
135
+ def test_snapshot_seals_memory(self, fortress):
136
+ mem = fortress.snapshot("Test title", "Test content")
137
+ assert mem.integrity_hash != "", "Memory should be sealed on write"
138
+
139
+ def test_recall_passes_clean_memory(self, fortress):
140
+ mem = fortress.snapshot("Clean", "No tampering here")
141
+ recalled = fortress.recall(mem.id)
142
+ assert recalled is not None
143
+ assert "integrity_warning" not in recalled.metadata
144
+
145
+ def test_recall_missing_returns_none(self, fortress):
146
+ result = fortress.recall("nonexistent-id")
147
+ assert result is None
148
+
149
+ def test_tamper_detection_triggers_callback(self, fortress, tmp_path):
150
+ alerts_received: list[TamperAlert] = []
151
+ fortress.register_alert_callback(alerts_received.append)
152
+
153
+ mem = fortress.snapshot("Secret", "Original content")
154
+
155
+ # Tamper: directly modify the stored memory file
156
+ # We need to find and corrupt the JSON
157
+ from skmemory.backends.sqlite_backend import SQLiteBackend
158
+ backend = fortress.primary
159
+ # Load raw, mutate, save back bypassing seal
160
+ raw = backend.load(mem.id)
161
+ assert raw is not None
162
+ raw.content = "TAMPERED CONTENT"
163
+ raw.integrity_hash = mem.integrity_hash # keep old hash
164
+ backend.save(raw) # save tampered version with original hash
165
+
166
+ # Now recall — should trigger tamper alert
167
+ recalled = fortress.recall(mem.id)
168
+ assert recalled is not None
169
+ assert "integrity_warning" in recalled.metadata
170
+ assert len(alerts_received) == 1
171
+ alert = alerts_received[0]
172
+ assert alert.memory_id == mem.id
173
+ assert alert.expected_hash == mem.integrity_hash
174
+
175
+ def test_forget_audited(self, fortress):
176
+ mem = fortress.snapshot("Temp", "Will be deleted")
177
+ fortress.forget(mem.id)
178
+ trail = fortress.audit_trail(10)
179
+ ops = [r["op"] for r in trail]
180
+ assert "delete" in ops
181
+
182
+ def test_audit_trail_records_all_ops(self, fortress):
183
+ mem = fortress.snapshot("Op tracking", "content")
184
+ fortress.recall(mem.id)
185
+ fortress.forget(mem.id)
186
+
187
+ trail = fortress.audit_trail(10)
188
+ ops = [r["op"] for r in trail]
189
+ assert "store" in ops
190
+ assert "recall" in ops
191
+ assert "delete" in ops
192
+
193
+ def test_verify_all_clean_store(self, fortress):
194
+ for i in range(5):
195
+ fortress.snapshot(f"Memory {i}", f"Content {i}")
196
+ result = fortress.verify_all()
197
+ assert result["total"] == 5
198
+ assert result["passed"] == 5
199
+ assert result["tampered"] == []
200
+
201
+ def test_verify_all_finds_tampered(self, fortress):
202
+ alerts: list[TamperAlert] = []
203
+ fortress.register_alert_callback(alerts.append)
204
+
205
+ mem = fortress.snapshot("Good memory", "Original")
206
+
207
+ # Tamper via backend
208
+ raw = fortress.primary.load(mem.id)
209
+ raw.content = "CORRUPTED"
210
+ raw.integrity_hash = mem.integrity_hash
211
+ fortress.primary.save(raw)
212
+
213
+ result = fortress.verify_all()
214
+ assert mem.id in result["tampered"]
215
+ assert len(alerts) > 0
216
+
217
+ def test_verify_audit_chain(self, fortress):
218
+ fortress.snapshot("A", "a")
219
+ fortress.snapshot("B", "b")
220
+ ok, errors = fortress.verify_audit_chain()
221
+ assert ok, errors
222
+
223
+ def test_encryption_not_configured_raises(self, fortress):
224
+ with pytest.raises(RuntimeError, match="not configured"):
225
+ fortress.encrypt_payload('{"test": 1}')
226
+
227
+ def test_encryption_active_false_by_default(self, fortress):
228
+ assert fortress.encryption_active is False
229
+
230
+ def test_multiple_callbacks(self, fortress):
231
+ c1, c2 = [], []
232
+ fortress.register_alert_callback(c1.append)
233
+ fortress.register_alert_callback(c2.append)
234
+
235
+ mem = fortress.snapshot("Multi", "content")
236
+ raw = fortress.primary.load(mem.id)
237
+ raw.content = "bad"
238
+ raw.integrity_hash = mem.integrity_hash
239
+ fortress.primary.save(raw)
240
+
241
+ fortress.recall(mem.id)
242
+ assert len(c1) == 1
243
+ assert len(c2) == 1
244
+
245
+ def test_unsealed_memory_passes_silently(self, fortress):
246
+ """A memory with no integrity_hash is not flagged as tampered."""
247
+ mem = fortress.snapshot("Unsealed", "content")
248
+ raw = fortress.primary.load(mem.id)
249
+ raw.integrity_hash = "" # strip the seal
250
+ fortress.primary.save(raw)
251
+
252
+ recalled = fortress.recall(mem.id)
253
+ assert recalled is not None
254
+ # Should not have a warning — unsealed != tampered
255
+ assert "integrity_warning" not in recalled.metadata
@@ -0,0 +1,444 @@
1
+ """Tests for Memory Fortress hardening — Sprint 6 Layer 3.
2
+
3
+ Covers:
4
+ - VaultedSQLiteBackend: transparent AES-256-GCM at-rest encryption
5
+ - FortifiedMemoryStore with vault_passphrase
6
+ - seal_all / unseal_all / vault_status
7
+ - Mixed (partially-encrypted) store migration
8
+ - CLI fortress and vault commands
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ import json
14
+ import tempfile
15
+ from pathlib import Path
16
+
17
+ import pytest
18
+
19
+ try:
20
+ from cryptography.hazmat.primitives.ciphers.aead import AESGCM
21
+
22
+ CRYPTO_AVAILABLE = True
23
+ except ImportError:
24
+ CRYPTO_AVAILABLE = False
25
+
26
+ from skmemory.vault import VAULT_HEADER, MemoryVault
27
+
28
+ pytestmark = pytest.mark.skipif(
29
+ not CRYPTO_AVAILABLE,
30
+ reason="cryptography package not installed",
31
+ )
32
+
33
+
34
+ # ---------------------------------------------------------------------------
35
+ # Fixtures
36
+ # ---------------------------------------------------------------------------
37
+
38
+
39
+ @pytest.fixture
40
+ def vaulted_backend(tmp_path):
41
+ """A VaultedSQLiteBackend with a temp directory."""
42
+ from skmemory.backends.vaulted_backend import VaultedSQLiteBackend
43
+
44
+ return VaultedSQLiteBackend(
45
+ passphrase="pengu-nation-test-key", base_path=str(tmp_path / "memories")
46
+ )
47
+
48
+
49
+ @pytest.fixture
50
+ def plain_backend(tmp_path):
51
+ """A plain SQLiteBackend for comparison / migration tests."""
52
+ from skmemory.backends.sqlite_backend import SQLiteBackend
53
+
54
+ return SQLiteBackend(base_path=str(tmp_path / "memories"))
55
+
56
+
57
+ @pytest.fixture
58
+ def fortress_vaulted(tmp_path):
59
+ """A FortifiedMemoryStore backed by VaultedSQLiteBackend."""
60
+ from skmemory.fortress import FortifiedMemoryStore
61
+
62
+ return FortifiedMemoryStore(
63
+ vault_passphrase="pengu-test-passphrase",
64
+ audit_path=tmp_path / "audit.jsonl",
65
+ use_sqlite=False,
66
+ base_path=str(tmp_path / "memories"),
67
+ )
68
+
69
+
70
+ # ---------------------------------------------------------------------------
71
+ # VaultedSQLiteBackend tests
72
+ # ---------------------------------------------------------------------------
73
+
74
+
75
+ class TestVaultedSQLiteBackend:
76
+ def test_save_produces_encrypted_file(self, vaulted_backend, tmp_path):
77
+ """Saved memory files must start with the SKMV1 vault header."""
78
+ from skmemory.models import Memory
79
+
80
+ mem = Memory(title="Secret", content="Classified info")
81
+ mem.seal()
82
+ vaulted_backend.save(mem)
83
+
84
+ # Find the written file
85
+ files = list((tmp_path / "memories").rglob("*.json"))
86
+ assert len(files) == 1, "Expected exactly one memory file"
87
+ raw = files[0].read_bytes()
88
+ assert raw[: len(VAULT_HEADER)] == VAULT_HEADER, "File must be vault-encrypted"
89
+
90
+ def test_load_roundtrip(self, vaulted_backend):
91
+ """Save then load should return the original memory content."""
92
+ from skmemory.models import Memory
93
+
94
+ mem = Memory(title="Trip Memory", content="The roundtrip works perfectly")
95
+ mem.seal()
96
+ vaulted_backend.save(mem)
97
+
98
+ loaded = vaulted_backend.load(mem.id)
99
+ assert loaded is not None
100
+ assert loaded.title == "Trip Memory"
101
+ assert loaded.content == "The roundtrip works perfectly"
102
+
103
+ def test_list_memories_with_encryption(self, vaulted_backend):
104
+ """list_memories should decrypt transparently."""
105
+ from skmemory.models import Memory
106
+
107
+ for i in range(3):
108
+ mem = Memory(title=f"Memory {i}", content=f"Content {i}")
109
+ mem.seal()
110
+ vaulted_backend.save(mem)
111
+
112
+ results = vaulted_backend.list_memories(limit=10)
113
+ assert len(results) == 3
114
+ titles = {m.title for m in results}
115
+ assert titles == {"Memory 0", "Memory 1", "Memory 2"}
116
+
117
+ def test_reindex_with_encrypted_files(self, vaulted_backend):
118
+ """reindex() should correctly parse encrypted files."""
119
+ from skmemory.models import Memory
120
+
121
+ for i in range(4):
122
+ mem = Memory(title=f"Reindex {i}", content=f"Data {i}")
123
+ mem.seal()
124
+ vaulted_backend.save(mem)
125
+
126
+ count = vaulted_backend.reindex()
127
+ assert count == 4
128
+
129
+ def test_seal_all_encrypts_plaintext(self, tmp_path):
130
+ """seal_all() should encrypt any plaintext JSON files."""
131
+ from skmemory.backends.sqlite_backend import SQLiteBackend
132
+ from skmemory.backends.vaulted_backend import VaultedSQLiteBackend
133
+ from skmemory.models import Memory
134
+
135
+ mem_path = tmp_path / "memories"
136
+
137
+ # First write plaintext via plain backend
138
+ plain = SQLiteBackend(base_path=str(mem_path))
139
+ for i in range(3):
140
+ mem = Memory(title=f"Plain {i}", content=f"Unencrypted {i}")
141
+ mem.seal()
142
+ plain.save(mem)
143
+ plain.close()
144
+
145
+ # Now create vaulted backend and seal_all
146
+ vaulted = VaultedSQLiteBackend(passphrase="seal-test", base_path=str(mem_path))
147
+ count = vaulted.seal_all()
148
+ assert count == 3
149
+
150
+ # All files should now have vault header
151
+ for json_file in mem_path.rglob("*.json"):
152
+ raw = json_file.read_bytes()
153
+ assert raw[: len(VAULT_HEADER)] == VAULT_HEADER, f"{json_file} not encrypted"
154
+
155
+ def test_seal_all_idempotent(self, vaulted_backend):
156
+ """seal_all() on an already-encrypted store should encrypt 0 files."""
157
+ from skmemory.models import Memory
158
+
159
+ mem = Memory(title="Already sealed", content="content")
160
+ mem.seal()
161
+ vaulted_backend.save(mem)
162
+
163
+ count = vaulted_backend.seal_all()
164
+ assert count == 0, "Re-sealing should skip already-encrypted files"
165
+
166
+ def test_unseal_all_decrypts(self, tmp_path):
167
+ """unseal_all() should decrypt all vault files back to plaintext JSON."""
168
+ from skmemory.backends.vaulted_backend import VaultedSQLiteBackend
169
+ from skmemory.models import Memory
170
+
171
+ mem_path = tmp_path / "memories"
172
+ vaulted = VaultedSQLiteBackend(passphrase="unseal-test", base_path=str(mem_path))
173
+
174
+ for i in range(2):
175
+ mem = Memory(title=f"Sealed {i}", content=f"Encrypted {i}")
176
+ mem.seal()
177
+ vaulted.save(mem)
178
+
179
+ count = vaulted.unseal_all()
180
+ assert count == 2
181
+
182
+ # Files should now be valid JSON (not encrypted)
183
+ for json_file in mem_path.rglob("*.json"):
184
+ raw = json_file.read_bytes()
185
+ assert raw[: len(VAULT_HEADER)] != VAULT_HEADER, f"{json_file} still encrypted"
186
+ parsed = json.loads(raw.decode("utf-8"))
187
+ assert "title" in parsed
188
+
189
+ def test_vault_status_all_encrypted(self, vaulted_backend):
190
+ """vault_status() should report 100% coverage when all files are encrypted."""
191
+ from skmemory.models import Memory
192
+
193
+ for i in range(3):
194
+ mem = Memory(title=f"Status {i}", content=f"Data {i}")
195
+ mem.seal()
196
+ vaulted_backend.save(mem)
197
+
198
+ status = vaulted_backend.vault_status()
199
+ assert status["total"] == 3
200
+ assert status["encrypted"] == 3
201
+ assert status["plaintext"] == 0
202
+ assert status["coverage_pct"] == 100.0
203
+
204
+ def test_vault_status_empty_store(self, vaulted_backend):
205
+ """vault_status() on an empty store should report 100% (trivially)."""
206
+ status = vaulted_backend.vault_status()
207
+ assert status["total"] == 0
208
+ assert status["coverage_pct"] == 100.0
209
+
210
+ def test_wrong_passphrase_fails_load(self, tmp_path):
211
+ """Loading with wrong passphrase should return None (graceful failure)."""
212
+ from skmemory.backends.vaulted_backend import VaultedSQLiteBackend
213
+ from skmemory.models import Memory
214
+
215
+ mem_path = tmp_path / "memories"
216
+ correct = VaultedSQLiteBackend(passphrase="correct-key", base_path=str(mem_path))
217
+ mem = Memory(title="Locked", content="Top secret")
218
+ mem.seal()
219
+ correct.save(mem)
220
+
221
+ wrong = VaultedSQLiteBackend(passphrase="wrong-key", base_path=str(mem_path))
222
+ result = wrong.load(mem.id)
223
+ assert result is None, "Wrong passphrase should return None, not raise"
224
+
225
+ def test_export_all_decrypts(self, vaulted_backend, tmp_path):
226
+ """export_all() should produce a plaintext JSON backup."""
227
+ from skmemory.models import Memory
228
+
229
+ mem = Memory(title="Export Test", content="Exportable content")
230
+ mem.seal()
231
+ vaulted_backend.save(mem)
232
+
233
+ backup_path = str(tmp_path / "backup.json")
234
+ out_path = vaulted_backend.export_all(output_path=backup_path)
235
+
236
+ backup = json.loads(Path(out_path).read_text())
237
+ assert backup["memory_count"] == 1
238
+ assert backup["memories"][0]["title"] == "Export Test"
239
+
240
+
241
+ # ---------------------------------------------------------------------------
242
+ # FortifiedMemoryStore + vault_passphrase integration
243
+ # ---------------------------------------------------------------------------
244
+
245
+
246
+ class TestFortifiedMemoryStoreVault:
247
+ def test_vault_passphrase_activates_encryption(self, fortress_vaulted, tmp_path):
248
+ """FortifiedMemoryStore with vault_passphrase should encrypt files."""
249
+ mem = fortress_vaulted.snapshot("Vaulted Title", "Encrypted content")
250
+
251
+ # Find the physical file
252
+ base = fortress_vaulted.primary.base_path
253
+ files = list(base.rglob("*.json"))
254
+ assert len(files) == 1
255
+ raw = files[0].read_bytes()
256
+ assert raw[: len(VAULT_HEADER)] == VAULT_HEADER, "File should be vault-encrypted"
257
+
258
+ def test_vault_active_property(self, fortress_vaulted):
259
+ """vault_active should be True when vault_passphrase is set."""
260
+ assert fortress_vaulted.vault_active is True
261
+
262
+ def test_vault_active_false_by_default(self, tmp_path):
263
+ """vault_active should be False without vault_passphrase."""
264
+ from skmemory.backends.sqlite_backend import SQLiteBackend
265
+ from skmemory.fortress import FortifiedMemoryStore
266
+
267
+ backend = SQLiteBackend(base_path=str(tmp_path / "memories"))
268
+ fortress = FortifiedMemoryStore(
269
+ primary=backend,
270
+ use_sqlite=False,
271
+ audit_path=tmp_path / "audit.jsonl",
272
+ )
273
+ assert fortress.vault_active is False
274
+
275
+ def test_recall_after_vault_store(self, fortress_vaulted):
276
+ """recall() should transparently decrypt and verify integrity."""
277
+ mem = fortress_vaulted.snapshot("Recall Test", "Content for recall")
278
+ recalled = fortress_vaulted.recall(mem.id)
279
+
280
+ assert recalled is not None
281
+ assert recalled.title == "Recall Test"
282
+ assert recalled.content == "Content for recall"
283
+ assert "integrity_warning" not in recalled.metadata
284
+
285
+ def test_tamper_alert_on_encrypted_store(self, fortress_vaulted, tmp_path):
286
+ """Tamper alert should fire even when vault is active."""
287
+ alerts = []
288
+ fortress_vaulted.register_alert_callback(alerts.append)
289
+
290
+ mem = fortress_vaulted.snapshot("Tamper Me", "Original")
291
+
292
+ # Directly corrupt the stored file (bypass encryption by writing junk)
293
+ raw = fortress_vaulted.primary.load(mem.id)
294
+ raw.content = "TAMPERED"
295
+ raw.integrity_hash = mem.integrity_hash # old hash
296
+ fortress_vaulted.primary.save(raw)
297
+
298
+ recalled = fortress_vaulted.recall(mem.id)
299
+ assert recalled is not None
300
+ assert "integrity_warning" in recalled.metadata
301
+ assert len(alerts) == 1
302
+
303
+ def test_vault_status_method(self, fortress_vaulted):
304
+ """vault_status() on FortifiedMemoryStore should report coverage."""
305
+ fortress_vaulted.snapshot("Coverage Test", "data")
306
+ status = fortress_vaulted.vault_status()
307
+ assert status["total"] == 1
308
+ assert status["encrypted"] == 1
309
+ assert status["coverage_pct"] == 100.0
310
+
311
+ def test_vault_status_raises_without_vault(self, tmp_path):
312
+ """vault_status() raises when no vault is configured."""
313
+ from skmemory.backends.sqlite_backend import SQLiteBackend
314
+ from skmemory.fortress import FortifiedMemoryStore
315
+
316
+ backend = SQLiteBackend(base_path=str(tmp_path / "memories"))
317
+ fortress = FortifiedMemoryStore(
318
+ primary=backend,
319
+ use_sqlite=False,
320
+ audit_path=tmp_path / "audit.jsonl",
321
+ )
322
+ with pytest.raises(RuntimeError, match="vault_passphrase"):
323
+ fortress.vault_status()
324
+
325
+ def test_seal_vault_audited(self, fortress_vaulted):
326
+ """seal_vault() should append an audit record."""
327
+ fortress_vaulted.snapshot("Pre-existing", "data")
328
+ fortress_vaulted.seal_vault()
329
+
330
+ trail = fortress_vaulted.audit_trail(10)
331
+ ops = [r["op"] for r in trail]
332
+ assert "vault_seal" in ops
333
+
334
+ def test_unseal_vault(self, fortress_vaulted, tmp_path):
335
+ """unseal_vault() should decrypt all files and audit the action."""
336
+ fortress_vaulted.snapshot("Will unseal", "data")
337
+ count = fortress_vaulted.unseal_vault()
338
+ assert count >= 0 # Unseal ran without error
339
+
340
+ trail = fortress_vaulted.audit_trail(10)
341
+ ops = [r["op"] for r in trail]
342
+ assert "vault_unseal" in ops
343
+
344
+ def test_verify_all_with_vault(self, fortress_vaulted):
345
+ """verify_all() should work correctly on an encrypted store."""
346
+ for i in range(3):
347
+ fortress_vaulted.snapshot(f"Verify {i}", f"Content {i}")
348
+
349
+ result = fortress_vaulted.verify_all()
350
+ assert result["total"] == 3
351
+ assert result["passed"] == 3
352
+ assert result["tampered"] == []
353
+
354
+
355
+ # ---------------------------------------------------------------------------
356
+ # CLI integration tests
357
+ # ---------------------------------------------------------------------------
358
+
359
+
360
+ class TestFortressCLI:
361
+ def test_fortress_verify_clean(self, tmp_path):
362
+ """skmemory fortress verify should exit 0 for a clean store."""
363
+ from click.testing import CliRunner
364
+ from skmemory.cli import cli
365
+ from skmemory.backends.sqlite_backend import SQLiteBackend
366
+ from skmemory.store import MemoryStore
367
+
368
+ runner = CliRunner()
369
+
370
+ result = runner.invoke(
371
+ cli,
372
+ ["fortress", "verify"],
373
+ obj={
374
+ "store": MemoryStore(
375
+ primary=SQLiteBackend(base_path=str(tmp_path / "memories"))
376
+ ),
377
+ "ai": None,
378
+ },
379
+ )
380
+ assert result.exit_code == 0, result.output
381
+ assert "Total memories" in result.output
382
+
383
+ def test_vault_status_cli(self, tmp_path):
384
+ """skmemory vault status should show encryption coverage."""
385
+ from click.testing import CliRunner
386
+ from skmemory.cli import cli
387
+ from skmemory.backends.sqlite_backend import SQLiteBackend
388
+ from skmemory.store import MemoryStore
389
+
390
+ runner = CliRunner()
391
+ result = runner.invoke(
392
+ cli,
393
+ ["vault", "status"],
394
+ obj={
395
+ "store": MemoryStore(
396
+ primary=SQLiteBackend(base_path=str(tmp_path / "memories"))
397
+ ),
398
+ "ai": None,
399
+ },
400
+ )
401
+ assert result.exit_code == 0, result.output
402
+ assert "Total files" in result.output
403
+
404
+ def test_fortress_audit_cli(self, tmp_path):
405
+ """skmemory fortress audit should show audit entries."""
406
+ from click.testing import CliRunner
407
+ from skmemory.cli import cli
408
+ from skmemory.fortress import AuditLog
409
+
410
+ # Seed an audit entry
411
+ audit = AuditLog(path=tmp_path / "audit.jsonl")
412
+ audit.append("store", "test-id", ok=True)
413
+
414
+ runner = CliRunner()
415
+ result = runner.invoke(
416
+ cli,
417
+ ["fortress", "audit"],
418
+ obj={"store": None, "ai": None},
419
+ env={"SKMEMORY_HOME": str(tmp_path)},
420
+ )
421
+ # May fail if SKMEMORY_HOME is not picked up in test, but should not crash
422
+ assert result.exit_code in (0, 1)
423
+
424
+ def test_vault_seal_cli_requires_passphrase(self, tmp_path):
425
+ """vault seal without passphrase should prompt or fail."""
426
+ from click.testing import CliRunner
427
+ from skmemory.cli import cli
428
+ from skmemory.backends.sqlite_backend import SQLiteBackend
429
+ from skmemory.store import MemoryStore
430
+
431
+ runner = CliRunner()
432
+ result = runner.invoke(
433
+ cli,
434
+ ["vault", "seal", "--yes"],
435
+ input="badpass\nbadpass\n",
436
+ obj={
437
+ "store": MemoryStore(
438
+ primary=SQLiteBackend(base_path=str(tmp_path / "memories"))
439
+ ),
440
+ "ai": None,
441
+ },
442
+ )
443
+ # Should succeed (0 files to seal in empty store) or error out cleanly
444
+ assert result.exit_code in (0, 1, 2)