superlocalmemory 3.8.13 → 4.0.0

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 (212) hide show
  1. package/ATTRIBUTION.md +4 -4
  2. package/CHANGELOG.md +113 -121
  3. package/README.md +65 -63
  4. package/docs/pi-dev-integration.md +1 -1
  5. package/package.json +6 -1
  6. package/plugin/.claude-plugin/plugin.json +1 -1
  7. package/plugin/CLAUDE.md +3 -3
  8. package/plugin/agents/slm-governance-advisor.md +1 -1
  9. package/plugin/agents/slm-loop-runner.md +1 -1
  10. package/plugin/agents/slm-memory-advisor.md +1 -1
  11. package/plugin/agents/slm-optimize-advisor.md +1 -1
  12. package/plugin/requirements.txt +1 -1
  13. package/plugin/skills/slm-cache/SKILL.md +1 -1
  14. package/plugin/skills/slm-compress/SKILL.md +1 -1
  15. package/plugin/skills/slm-governance/SKILL.md +1 -1
  16. package/plugin/skills/slm-graph/SKILL.md +1 -1
  17. package/plugin/skills/slm-loop/SKILL.md +1 -1
  18. package/plugin/skills/slm-mesh/SKILL.md +1 -1
  19. package/plugin/skills/slm-profile/SKILL.md +1 -1
  20. package/plugin/skills/slm-recall/SKILL.md +1 -1
  21. package/plugin/skills/slm-remember/SKILL.md +1 -1
  22. package/plugin/skills/slm-scope/SKILL.md +1 -1
  23. package/plugin/skills/slm-session/SKILL.md +1 -1
  24. package/plugin/skills/slm-status/SKILL.md +1 -1
  25. package/plugin-src/rules/AGENTS.md +1 -1
  26. package/plugin-src/skills/slm-cache/SKILL.md +1 -1
  27. package/plugin-src/skills/slm-compress/SKILL.md +1 -1
  28. package/plugin-src/skills/slm-governance/SKILL.md +248 -0
  29. package/plugin-src/skills/slm-graph/SKILL.md +1 -1
  30. package/plugin-src/skills/slm-loop/SKILL.md +99 -0
  31. package/plugin-src/skills/slm-mesh/SKILL.md +282 -0
  32. package/plugin-src/skills/slm-profile/SKILL.md +148 -0
  33. package/plugin-src/skills/slm-recall/SKILL.md +1 -1
  34. package/plugin-src/skills/slm-remember/SKILL.md +1 -1
  35. package/plugin-src/skills/slm-scope/SKILL.md +176 -0
  36. package/plugin-src/skills/slm-session/SKILL.md +1 -1
  37. package/plugin-src/skills/slm-status/SKILL.md +1 -1
  38. package/pyproject.toml +11 -4
  39. package/src/superlocalmemory/__init__.py +1 -1
  40. package/src/superlocalmemory/cli/commands.py +125 -11
  41. package/src/superlocalmemory/cli/daemon.py +5 -1
  42. package/src/superlocalmemory/cli/main.py +35 -2
  43. package/src/superlocalmemory/cli/ops_cmd.py +281 -0
  44. package/src/superlocalmemory/cli/setup_wizard.py +1 -1
  45. package/src/superlocalmemory/compliance/audit.py +65 -0
  46. package/src/superlocalmemory/compliance/eu_ai_act.py +27 -57
  47. package/src/superlocalmemory/compliance/gdpr.py +416 -20
  48. package/src/superlocalmemory/compliance/retention.py +74 -22
  49. package/src/superlocalmemory/compliance/scheduler.py +78 -9
  50. package/src/superlocalmemory/core/actor_context.py +166 -0
  51. package/src/superlocalmemory/core/admission.py +549 -0
  52. package/src/superlocalmemory/core/backend_orchestrator.py +23 -10
  53. package/src/superlocalmemory/core/config.py +202 -24
  54. package/src/superlocalmemory/core/consolidation_engine.py +13 -13
  55. package/src/superlocalmemory/core/context_cache.py +28 -0
  56. package/src/superlocalmemory/core/embeddings.py +64 -2
  57. package/src/superlocalmemory/core/engine.py +7 -2
  58. package/src/superlocalmemory/core/engine_ingestion.py +65 -3
  59. package/src/superlocalmemory/core/engine_wiring.py +36 -9
  60. package/src/superlocalmemory/core/ingest_policy.py +38 -0
  61. package/src/superlocalmemory/core/maintenance.py +255 -0
  62. package/src/superlocalmemory/core/modes.py +40 -13
  63. package/src/superlocalmemory/core/mutations.py +437 -44
  64. package/src/superlocalmemory/core/operation_policy.py +92 -0
  65. package/src/superlocalmemory/core/operation_policy_registry.py +542 -0
  66. package/src/superlocalmemory/core/operation_request.py +127 -0
  67. package/src/superlocalmemory/core/ops_remediation.py +542 -0
  68. package/src/superlocalmemory/core/recall_pipeline.py +7 -0
  69. package/src/superlocalmemory/core/remember_runtime.py +202 -4
  70. package/src/superlocalmemory/core/remote_mode.py +20 -5
  71. package/src/superlocalmemory/core/store_pipeline.py +150 -0
  72. package/src/superlocalmemory/core/topic_signature.py +19 -4
  73. package/src/superlocalmemory/core/transactions/__init__.py +78 -0
  74. package/src/superlocalmemory/core/transactions/concrete_owners.py +597 -0
  75. package/src/superlocalmemory/core/transactions/erasure.py +825 -0
  76. package/src/superlocalmemory/core/transactions/manifest.py +255 -0
  77. package/src/superlocalmemory/core/transactions/manifest_key.py +155 -0
  78. package/src/superlocalmemory/core/transactions/obligations.py +272 -0
  79. package/src/superlocalmemory/core/transactions/owners.py +114 -0
  80. package/src/superlocalmemory/core/transactions/reconciler.py +285 -0
  81. package/src/superlocalmemory/core/transactions/service.py +330 -0
  82. package/src/superlocalmemory/core/worker_pool.py +33 -5
  83. package/src/superlocalmemory/encoding/cognitive_consolidator.py +70 -28
  84. package/src/superlocalmemory/encoding/emotional.py +75 -14
  85. package/src/superlocalmemory/encoding/scene_builder.py +115 -13
  86. package/src/superlocalmemory/encoding/temporal_parser.py +4 -0
  87. package/src/superlocalmemory/evolution/blind_verifier.py +11 -4
  88. package/src/superlocalmemory/evolution/evolution_store.py +244 -4
  89. package/src/superlocalmemory/evolution/llm_dispatch.py +40 -0
  90. package/src/superlocalmemory/evolution/model_selection.py +18 -3
  91. package/src/superlocalmemory/evolution/mutation_generator.py +3 -0
  92. package/src/superlocalmemory/evolution/skill_activator.py +270 -0
  93. package/src/superlocalmemory/evolution/skill_evolver.py +281 -59
  94. package/src/superlocalmemory/evolution/types.py +30 -8
  95. package/src/superlocalmemory/graph/cozo_backend.py +17 -9
  96. package/src/superlocalmemory/hooks/auto_invoker.py +2 -1
  97. package/src/superlocalmemory/hooks/auto_recall.py +64 -30
  98. package/src/superlocalmemory/hooks/codex_assets.py +14 -1
  99. package/src/superlocalmemory/infra/backup.py +434 -7
  100. package/src/superlocalmemory/infra/process_reaper.py +18 -0
  101. package/src/superlocalmemory/infra/self_heal.py +401 -0
  102. package/src/superlocalmemory/learning/feedback.py +52 -9
  103. package/src/superlocalmemory/loops/engine.py +10 -0
  104. package/src/superlocalmemory/mcp/_daemon_proxy.py +3 -0
  105. package/src/superlocalmemory/mcp/http_transport.py +30 -331
  106. package/src/superlocalmemory/mcp/profiles.py +5 -0
  107. package/src/superlocalmemory/mcp/resources.py +8 -0
  108. package/src/superlocalmemory/mcp/server.py +51 -4
  109. package/src/superlocalmemory/mcp/shared.py +19 -0
  110. package/src/superlocalmemory/mcp/tools_active.py +25 -4
  111. package/src/superlocalmemory/mcp/tools_code_graph.py +26 -18
  112. package/src/superlocalmemory/mcp/tools_context.py +50 -8
  113. package/src/superlocalmemory/mcp/tools_core.py +69 -21
  114. package/src/superlocalmemory/mcp/tools_evolution.py +9 -2
  115. package/src/superlocalmemory/mcp/tools_learning.py +21 -10
  116. package/src/superlocalmemory/mcp/tools_loops.py +29 -18
  117. package/src/superlocalmemory/mcp/tools_mesh.py +8 -0
  118. package/src/superlocalmemory/mcp/tools_ops.py +115 -0
  119. package/src/superlocalmemory/mcp/tools_optimize.py +4 -0
  120. package/src/superlocalmemory/mcp/tools_v28.py +10 -3
  121. package/src/superlocalmemory/mcp/tools_v3.py +34 -14
  122. package/src/superlocalmemory/mcp/tools_v33.py +18 -33
  123. package/src/superlocalmemory/mesh/broker.py +124 -46
  124. package/src/superlocalmemory/mesh/broker_security.py +470 -0
  125. package/src/superlocalmemory/mesh/discovery.py +365 -0
  126. package/src/superlocalmemory/mesh/lock_protocol.py +313 -0
  127. package/src/superlocalmemory/mesh/node_identity.py +97 -0
  128. package/src/superlocalmemory/mesh/outbox_remote.py +429 -0
  129. package/src/superlocalmemory/mesh/remote_sync.py +511 -28
  130. package/src/superlocalmemory/mesh/state_sync.py +286 -0
  131. package/src/superlocalmemory/optimize/config/store.py +45 -0
  132. package/src/superlocalmemory/parameterization/cross_project.py +12 -0
  133. package/src/superlocalmemory/parameterization/prompt_injector.py +13 -11
  134. package/src/superlocalmemory/parameterization/prompt_lifecycle.py +8 -2
  135. package/src/superlocalmemory/parameterization/workflow_miner.py +17 -0
  136. package/src/superlocalmemory/retrieval/ann_index.py +5 -0
  137. package/src/superlocalmemory/retrieval/bm25_channel.py +49 -2
  138. package/src/superlocalmemory/retrieval/engine.py +19 -4
  139. package/src/superlocalmemory/retrieval/fusion.py +4 -1
  140. package/src/superlocalmemory/retrieval/hopfield_channel.py +9 -3
  141. package/src/superlocalmemory/retrieval/remote_reranker.py +47 -22
  142. package/src/superlocalmemory/retrieval/reranker.py +32 -1
  143. package/src/superlocalmemory/retrieval/temporal_channel.py +16 -3
  144. package/src/superlocalmemory/retrieval/temporal_utils.py +107 -0
  145. package/src/superlocalmemory/retrieval/temporal_validity_filter.py +155 -42
  146. package/src/superlocalmemory/retrieval/vector_store.py +214 -8
  147. package/src/superlocalmemory/server/api.py +5 -5
  148. package/src/superlocalmemory/server/egress_policy.py +258 -0
  149. package/src/superlocalmemory/server/rbac_enforce.py +32 -0
  150. package/src/superlocalmemory/server/route_mutations.py +20 -0
  151. package/src/superlocalmemory/server/routes/compliance.py +153 -7
  152. package/src/superlocalmemory/server/routes/data_io.py +43 -2
  153. package/src/superlocalmemory/server/routes/events.py +15 -0
  154. package/src/superlocalmemory/server/routes/memories.py +56 -3
  155. package/src/superlocalmemory/server/routes/mesh.py +82 -1
  156. package/src/superlocalmemory/server/routes/mesh_lock.py +54 -0
  157. package/src/superlocalmemory/server/routes/mesh_state.py +63 -0
  158. package/src/superlocalmemory/server/routes/v3_api.py +50 -27
  159. package/src/superlocalmemory/server/routes/ws.py +86 -0
  160. package/src/superlocalmemory/server/ui.py +6 -6
  161. package/src/superlocalmemory/server/unified_daemon.py +942 -119
  162. package/src/superlocalmemory/storage/_migration_internals.py +568 -0
  163. package/src/superlocalmemory/storage/_schema_version.py +110 -0
  164. package/src/superlocalmemory/storage/database.py +329 -24
  165. package/src/superlocalmemory/storage/embedding_migrator.py +246 -51
  166. package/src/superlocalmemory/storage/erasure_fence.py +45 -0
  167. package/src/superlocalmemory/storage/generation_fence.py +63 -0
  168. package/src/superlocalmemory/storage/migration_runner.py +140 -417
  169. package/src/superlocalmemory/storage/migrations/M009_model_lineage.py +40 -0
  170. package/src/superlocalmemory/storage/migrations/M033_projection_transactions.py +148 -0
  171. package/src/superlocalmemory/storage/migrations/M034_obligation_integrity.py +58 -0
  172. package/src/superlocalmemory/storage/migrations/M035_erasure_receipts.py +113 -0
  173. package/src/superlocalmemory/storage/migrations/M036_vector_row_map.py +107 -0
  174. package/src/superlocalmemory/storage/migrations/M037_manifest_hmac_version.py +162 -0
  175. package/src/superlocalmemory/storage/migrations/{M033_learning_feedback_channel.py → M038_learning_feedback_channel.py} +3 -3
  176. package/src/superlocalmemory/storage/migrations/M039_scene_fact_members.py +137 -0
  177. package/src/superlocalmemory/storage/migrations/__init__.py +4 -2
  178. package/src/superlocalmemory/storage/schema.py +67 -0
  179. package/src/superlocalmemory/storage/write_coordinator.py +125 -0
  180. package/src/superlocalmemory/trust/scorer.py +28 -4
  181. package/src/superlocalmemory/ui/index.html +14 -3
  182. package/src/superlocalmemory/ui/js/auto-settings.js +12 -1
  183. package/src/superlocalmemory/ui/js/brain.js +6 -4
  184. package/src/superlocalmemory/ui/js/compliance.js +66 -12
  185. package/src/superlocalmemory/ui/js/dashboard.js +13 -3
  186. package/src/superlocalmemory/ui/js/feedback.js +8 -2
  187. package/src/superlocalmemory/ui/js/lifecycle.js +7 -1
  188. package/src/superlocalmemory/ui/js/modal.js +272 -5
  189. package/src/superlocalmemory/ui/js/od-backup.js +9 -2
  190. package/src/superlocalmemory/ui/js/od-compliance-ext.js +301 -0
  191. package/src/superlocalmemory/ui/js/od-operations.js +154 -23
  192. package/src/superlocalmemory/ui/js/od-ops-health.js +417 -0
  193. package/src/superlocalmemory/ui/js/od-optimize.js +35 -21
  194. package/src/superlocalmemory/ui/js/od-team.js +9 -2
  195. package/src/superlocalmemory/ui/js/optimize.js +13 -16
  196. package/src/superlocalmemory/ui/js/profiles.js +7 -3
  197. package/src/superlocalmemory/ui/js/settings.js +7 -1
  198. package/src/superlocalmemory/vector/lancedb_backend.py +19 -9
  199. package/src/superlocalmemory/attribution/mathematical_dna.py +0 -235
  200. package/src/superlocalmemory/cli/post_install.py +0 -114
  201. package/src/superlocalmemory/core/clock_monitor.py +0 -45
  202. package/src/superlocalmemory/core/db_pool.py +0 -80
  203. package/src/superlocalmemory/core/error_catalog.py +0 -113
  204. package/src/superlocalmemory/core/loop_watchdog.py +0 -56
  205. package/src/superlocalmemory/core/priority_queue.py +0 -61
  206. package/src/superlocalmemory/core/pruning_engine.py +0 -216
  207. package/src/superlocalmemory/core/queue_dispatcher.py +0 -73
  208. package/src/superlocalmemory/core/slmignore.py +0 -125
  209. package/src/superlocalmemory/infra/heartbeat_monitor.py +0 -140
  210. package/src/superlocalmemory/infra/webhook_dispatcher.py +0 -247
  211. package/src/superlocalmemory/learning/quantization_scheduler.py +0 -320
  212. package/src/superlocalmemory/storage/access_control.py +0 -182
@@ -0,0 +1,470 @@
1
+ # Copyright (c) 2026 Varun Pratap Bhardwaj / Qualixar
2
+ # Licensed under AGPL-3.0-or-later - see LICENSE file
3
+
4
+ """Mesh broker security helpers — startup integrity, state guards, fencing.
5
+
6
+ Pure functions so they can be unit-tested independently of the broker.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import hashlib
12
+ import hmac as _hmac
13
+ import logging
14
+ import os
15
+ import secrets
16
+ import sqlite3
17
+ import threading
18
+ import time
19
+ from pathlib import Path
20
+
21
+ logger = logging.getLogger("superlocalmemory.mesh")
22
+
23
+ # ---------------------------------------------------------------------------
24
+ # 3a-1 Per-message HMAC identity (sign / verify / replay defense)
25
+ # ---------------------------------------------------------------------------
26
+
27
+ #: Seconds of acceptable clock skew between signer and verifier.
28
+ MESH_SIG_SKEW_SECONDS: int = int(os.environ.get("MESH_SIG_SKEW_SECONDS", "300"))
29
+
30
+ #: Hard cap on the in-memory nonce table to prevent memory exhaustion.
31
+ _NONCE_STORE_MAX: int = 10_000
32
+
33
+ _nonce_lock = threading.Lock()
34
+ # Maps nonce → expiry monotonic timestamp (in-memory fast path)
35
+ _nonce_store: dict[str, float] = {}
36
+ # SQLite DB path wired by MeshBroker.__init__ for durable replay defense (SEC-3)
37
+ _nonce_db_path: str | None = None
38
+
39
+
40
+ def _set_nonce_db_path(db_path: str) -> None:
41
+ """Wire the SQLite path for durable nonce storage. Called from MeshBroker.__init__."""
42
+ global _nonce_db_path
43
+ _nonce_db_path = db_path
44
+
45
+
46
+ def _clear_nonce_store() -> None:
47
+ """Purge the nonce table — for use in tests only. Clears both memory and SQLite."""
48
+ global _nonce_db_path
49
+ with _nonce_lock:
50
+ _nonce_store.clear()
51
+ if _nonce_db_path:
52
+ try:
53
+ conn = sqlite3.connect(_nonce_db_path, timeout=3)
54
+ conn.execute("DELETE FROM mesh_nonces")
55
+ conn.commit()
56
+ conn.close()
57
+ except sqlite3.Error:
58
+ pass
59
+
60
+
61
+ def _prune_nonces(now: float) -> None:
62
+ """Evict expired entries (caller must hold _nonce_lock). SEC-1: strict < not <=."""
63
+ expired = [n for n, exp in _nonce_store.items() if exp < now]
64
+ for n in expired:
65
+ del _nonce_store[n]
66
+
67
+
68
+ def _register_nonce(nonce: str, now: float) -> bool:
69
+ """Register *nonce*; return False if already seen (replay), True if fresh.
70
+
71
+ SEC-3: When SQLite path is wired, persists to mesh_nonces for durability
72
+ across process restarts. The INSERT OR IGNORE is atomic; rowcount==0 means
73
+ a prior process already registered this nonce (cross-restart replay detected).
74
+ """
75
+ with _nonce_lock:
76
+ _prune_nonces(now)
77
+ if nonce in _nonce_store:
78
+ return False
79
+ # Emergency eviction when the store is full (bounded)
80
+ if len(_nonce_store) >= _NONCE_STORE_MAX:
81
+ oldest = min(_nonce_store, key=lambda k: _nonce_store[k])
82
+ del _nonce_store[oldest]
83
+ exp = now + MESH_SIG_SKEW_SECONDS
84
+ # SEC-3: persist to SQLite when wired — atomic INSERT OR IGNORE detects cross-restart replays
85
+ if _nonce_db_path:
86
+ try:
87
+ conn = sqlite3.connect(_nonce_db_path, timeout=3)
88
+ # P2: bound table growth — drop nonces whose replay window has passed.
89
+ conn.execute("DELETE FROM mesh_nonces WHERE expires_at < ?", (now,))
90
+ cursor = conn.execute(
91
+ "INSERT OR IGNORE INTO mesh_nonces (nonce, expires_at) VALUES (?, ?)",
92
+ (nonce, exp),
93
+ )
94
+ conn.commit()
95
+ rowcount = cursor.rowcount
96
+ conn.close()
97
+ if rowcount == 0:
98
+ return False # SQLite already has this nonce — cross-restart replay
99
+ except sqlite3.Error:
100
+ # P1: FAIL CLOSED. A durable nonce store is configured but the write
101
+ # could not be confirmed, so replay defense cannot be guaranteed for
102
+ # this signed message — reject rather than silently degrade to
103
+ # process-local memory (which reopens the multi-worker replay hole).
104
+ return False
105
+ _nonce_store[nonce] = exp
106
+ return True
107
+
108
+
109
+ def _canonical_payload(
110
+ from_peer: str, to: str, content: str, nonce: str, ts: str
111
+ ) -> str:
112
+ """Deterministic string signed by HMAC. Covers SHA-256 of content for integrity.
113
+
114
+ SEC-2: Fields joined with NUL byte (\\x00) to prevent field injection collisions
115
+ that pipe (|) allows. E.g. ('a|b','c') and ('a','b|c') produce identical pipe
116
+ payloads but distinct NUL payloads — no forgery via field boundaries.
117
+ """
118
+ content_hash = hashlib.sha256(content.encode("utf-8", errors="replace")).hexdigest()
119
+ return "\x00".join([from_peer, to, content_hash, nonce, ts])
120
+
121
+
122
+ def sign_mesh_message(
123
+ secret: str,
124
+ from_peer: str,
125
+ to: str,
126
+ content: str,
127
+ nonce: str,
128
+ ts: str,
129
+ ) -> str:
130
+ """Return hex HMAC-SHA256 over the canonical mesh payload."""
131
+ payload = _canonical_payload(from_peer, to, content, nonce, ts)
132
+ return _hmac.new(
133
+ secret.encode("utf-8"), payload.encode("utf-8"), hashlib.sha256
134
+ ).hexdigest()
135
+
136
+
137
+ def verify_mesh_message(
138
+ secret: str,
139
+ from_peer: str,
140
+ to: str,
141
+ content: str,
142
+ nonce: str,
143
+ ts: str,
144
+ sig: str,
145
+ ) -> bool:
146
+ """Constant-time compare of presented sig vs expected. Returns False on any mismatch."""
147
+ try:
148
+ expected = sign_mesh_message(secret, from_peer, to, content, nonce, ts)
149
+ return _hmac.compare_digest(expected, sig)
150
+ except Exception:
151
+ return False
152
+
153
+
154
+ def is_strict_identity(config: object | None = None) -> bool:
155
+ """Return True if strict per-message HMAC is required for inbound remote messages.
156
+
157
+ SEC-7: Checks env vars first (SLM_MESH_PRODUCTION=1 or SLM_MESH_STRICT_IDENTITY=1),
158
+ then falls back to config.mesh_strict_identity if a config object is passed.
159
+ Default False — preserves backward compat for existing deployments.
160
+ """
161
+ _truthy = frozenset({"1", "true", "yes", "on", "production", "prod"})
162
+ if os.environ.get("SLM_MESH_PRODUCTION", "").strip().lower() in _truthy:
163
+ return True
164
+ if os.environ.get("SLM_MESH_STRICT_IDENTITY", "").strip().lower() in _truthy:
165
+ return True
166
+ if config is not None and getattr(config, "mesh_strict_identity", False):
167
+ return True
168
+ return False
169
+
170
+
171
+ def _has_forbidden_chars(field: str) -> bool:
172
+ """Return True if the field contains NUL bytes or ASCII control characters < 0x20.
173
+
174
+ SEC-2: These characters could be used to inject extra NUL-delimited fields into
175
+ the canonical payload, bypassing per-field binding. Reject them at the gate.
176
+ """
177
+ return any(ord(c) < 0x20 for c in field)
178
+
179
+
180
+ def check_mesh_message_signature(
181
+ secret: str | None,
182
+ from_peer: str,
183
+ to: str,
184
+ content: str,
185
+ sig_header: str | None,
186
+ nonce_header: str | None,
187
+ ts_header: str | None,
188
+ *,
189
+ is_loopback: bool,
190
+ strict: bool,
191
+ ) -> dict | None:
192
+ """Gate inbound mesh message signatures.
193
+
194
+ Returns None on acceptance, or ``{"ok": False, "error": "..."}`` on rejection.
195
+
196
+ Backward-compat rules (NON-NEGOTIABLE):
197
+ - Loopback is always trusted; no sig required regardless of strict.
198
+ - strict=False + no sig → accepted (unsigned legacy remote).
199
+ - strict=False + bad sig present → rejected (reject known-bad, not silent swallow).
200
+ - strict=True + no sig → rejected.
201
+ - strict=True + valid sig → accepted.
202
+ """
203
+ if is_loopback:
204
+ return None # Always trusted; existing require_write_actor path unchanged.
205
+
206
+ # SEC-2: Reject control characters (including NUL) in canonicalized fields.
207
+ # NUL is the canonical delimiter — a NUL in a field injects a phantom field boundary.
208
+ for field_name, field_val in (("from_peer", from_peer), ("nonce", nonce_header or ""),
209
+ ("to", to)):
210
+ if _has_forbidden_chars(field_val):
211
+ return {"ok": False, "error": f"field '{field_name}' contains forbidden control characters"}
212
+
213
+ has_sig = bool(sig_header)
214
+
215
+ if not has_sig:
216
+ if strict:
217
+ return {"ok": False, "error": "missing message signature (strict identity mode)"}
218
+ return None # unsigned legacy remote accepted in compat mode
219
+
220
+ # Signature present — must be well-formed and valid regardless of strict flag.
221
+ if not secret:
222
+ # A sig was presented but we have no secret to verify against.
223
+ if strict:
224
+ return {"ok": False, "error": "signature present but no shared secret configured"}
225
+ return None # compat mode: can't verify, treat as unsigned legacy
226
+
227
+ if not nonce_header or not ts_header:
228
+ return {"ok": False, "error": "X-Mesh-Sig present but X-Mesh-Nonce or X-Mesh-Ts missing"}
229
+
230
+ # Timestamp skew check
231
+ try:
232
+ ts_float = float(ts_header)
233
+ except (ValueError, TypeError):
234
+ return {"ok": False, "error": "X-Mesh-Ts is not a valid unix timestamp"}
235
+
236
+ now = time.time()
237
+ if abs(now - ts_float) > MESH_SIG_SKEW_SECONDS:
238
+ return {"ok": False, "error": "message timestamp outside acceptable skew window"}
239
+
240
+ # HMAC verification (before nonce registration — prevent timing oracle)
241
+ if not verify_mesh_message(secret, from_peer, to, content, nonce_header, ts_header, sig_header):
242
+ return {"ok": False, "error": "message signature verification failed"}
243
+
244
+ # Nonce replay check (register after HMAC so only valid sigs consume a slot)
245
+ if not _register_nonce(nonce_header, now):
246
+ return {"ok": False, "error": "message nonce has been used before (replay rejected)"}
247
+
248
+ return None # All checks passed
249
+
250
+
251
+ # ---------------------------------------------------------------------------
252
+ # 3a-2 Content scrub helper
253
+ # ---------------------------------------------------------------------------
254
+
255
+
256
+ def scrub_message_content(content: str) -> str:
257
+ """Redact known secret patterns from message content before durable storage.
258
+
259
+ Fail-open: on any import/runtime error the original content is returned
260
+ unchanged so a storage failure never loses a message.
261
+ """
262
+ try:
263
+ from superlocalmemory.core.security_primitives import redact_secrets
264
+
265
+ return redact_secrets(content)
266
+ except Exception:
267
+ return content
268
+
269
+
270
+ # ---------------------------------------------------------------------------
271
+ # 3a-3 Restart-safe fencing counter seed
272
+ # ---------------------------------------------------------------------------
273
+
274
+
275
+ def seed_fencing_counter(db_path: str) -> int:
276
+ """Return MAX(fencing_token) from mesh_locks so post-restart tokens exceed
277
+ any surviving DB value.
278
+
279
+ SEC-6 fail-closed semantics:
280
+ - DB connect error OR table missing → return 0 (first-run / degraded, safe default).
281
+ - Table EXISTS but MAX query errors → raise RuntimeError (split-brain prevention:
282
+ starting at 0 after a query failure on an existing lock table would issue
283
+ fencing tokens below the highest previously-issued value, invalidating live locks).
284
+ """
285
+ try:
286
+ conn = sqlite3.connect(db_path, timeout=5)
287
+ conn.row_factory = sqlite3.Row
288
+ except sqlite3.Error:
289
+ return 0 # Can't even open the DB — first-run or degraded
290
+
291
+ try:
292
+ # Check if the table exists first so we can distinguish "no table" from "query error"
293
+ table_exists_row = conn.execute(
294
+ "SELECT 1 FROM sqlite_master WHERE type='table' AND name='mesh_locks'"
295
+ ).fetchone()
296
+ if not table_exists_row:
297
+ return 0 # First run — no table yet, starting from 0 is safe
298
+ # Table exists: query MUST succeed. Fail-closed if it errors.
299
+ try:
300
+ row = conn.execute(
301
+ "SELECT COALESCE(MAX(COALESCE(fencing_token, 0)), 0) AS max_tok "
302
+ "FROM mesh_locks"
303
+ ).fetchone()
304
+ return int(row["max_tok"]) if row else 0
305
+ except sqlite3.OperationalError as exc:
306
+ err_lower = str(exc).lower()
307
+ if "no such column" in err_lower:
308
+ # Table exists but fencing_token column not yet added (pre-migration schema).
309
+ # Starting at 0 is safe because no tokens were ever issued.
310
+ return 0
311
+ raise RuntimeError(
312
+ f"mesh_locks table exists but MAX(fencing_token) query failed: {exc}. "
313
+ "Cannot seed fencing counter safely — refusing to start at 0 (split-brain risk)."
314
+ ) from exc
315
+ finally:
316
+ conn.close()
317
+
318
+ # Pattern matches key names that imply secret material.
319
+ import re as _re
320
+ STATE_SECRET_KEY = _re.compile(
321
+ r"(?:^|[_\-.])(api[_\-.]?key|secret|token|password|credential)(?:$|[_\-.]|$)",
322
+ _re.IGNORECASE,
323
+ )
324
+
325
+ _SCHEMA_ALTERS = (
326
+ "ALTER TABLE mesh_locks ADD COLUMN fencing_token INTEGER DEFAULT 0",
327
+ "ALTER TABLE mesh_state ADD COLUMN revision INTEGER DEFAULT 0",
328
+ "ALTER TABLE mesh_peers ADD COLUMN peer_key TEXT", # SEC-4: per-peer HMAC key
329
+ # 3c-1: LWW provenance. '' = local-origin (set by broker.set_state);
330
+ # a merged row stores the winning remote node_id. See mesh/state_sync.py.
331
+ "ALTER TABLE mesh_state ADD COLUMN origin_node TEXT NOT NULL DEFAULT ''",
332
+ )
333
+ _SENT_OPS_DDL = """
334
+ CREATE TABLE IF NOT EXISTS mesh_sent_ops (
335
+ operation_id TEXT PRIMARY KEY,
336
+ message_id INTEGER NOT NULL,
337
+ created_at TEXT NOT NULL
338
+ )"""
339
+ # SEC-3: durable nonce store — survives process restarts
340
+ _NONCES_DDL = """
341
+ CREATE TABLE IF NOT EXISTS mesh_nonces (
342
+ nonce TEXT PRIMARY KEY,
343
+ expires_at REAL NOT NULL
344
+ )"""
345
+
346
+
347
+ def ensure_db_healthy(db_path: str) -> bool:
348
+ """Return True (degraded) if the DB was corrupt and had to be quarantined.
349
+
350
+ Quarantine = rename to ``<name>.quarantine-<ms>``. The original bytes
351
+ are preserved; the caller receives a fresh empty DB on the same path.
352
+ A missing DB is a normal first-run situation and is not an error.
353
+ """
354
+ path = Path(db_path)
355
+ if not path.exists():
356
+ return False
357
+ try:
358
+ conn = sqlite3.connect(db_path, timeout=5)
359
+ conn.execute("SELECT count(*) FROM sqlite_master")
360
+ conn.close()
361
+ return False
362
+ except (sqlite3.DatabaseError, sqlite3.OperationalError) as exc:
363
+ ts = int(time.monotonic() * 1_000)
364
+ quarantine = path.with_name(f"{path.name}.quarantine-{ts}")
365
+ try:
366
+ path.rename(quarantine)
367
+ except OSError as rename_err:
368
+ logger.error("mesh db corrupt and could not be quarantined: %s", rename_err)
369
+ return False
370
+ logger.warning(
371
+ "mesh db corrupt (%s); quarantined to %s; starting fresh",
372
+ exc, quarantine.name,
373
+ )
374
+ return True
375
+
376
+
377
+ def apply_security_schema(conn: sqlite3.Connection) -> None:
378
+ """Apply idempotent schema additions (fencing_token, revision, mesh_sent_ops, mesh_nonces, peer_key)."""
379
+ for sql in _SCHEMA_ALTERS:
380
+ try:
381
+ conn.execute(sql)
382
+ except sqlite3.OperationalError:
383
+ pass # column already exists
384
+ try:
385
+ conn.executescript(_SENT_OPS_DDL)
386
+ except sqlite3.OperationalError:
387
+ pass
388
+ try:
389
+ conn.executescript(_NONCES_DDL)
390
+ except sqlite3.OperationalError:
391
+ pass
392
+ conn.commit()
393
+
394
+
395
+ def get_or_create_peer_key(
396
+ conn: sqlite3.Connection, peer_id: str, profile_id: str
397
+ ) -> str:
398
+ """Return the stored peer_key for *peer_id*, minting a new one if absent.
399
+
400
+ SEC-4: Each registered peer gets a unique 32-byte (256-bit) HMAC key that only
401
+ they receive at registration time. Signatures in strict mode are verified against
402
+ this key — not the shared fleet secret — so knowing the fleet secret cannot forge
403
+ another peer's identity.
404
+ """
405
+ row = conn.execute(
406
+ "SELECT peer_key FROM mesh_peers WHERE peer_id=? AND profile_id=?",
407
+ (peer_id, profile_id),
408
+ ).fetchone()
409
+ if row and row["peer_key"]:
410
+ return str(row["peer_key"])
411
+ key = secrets.token_hex(32) # 256-bit random key
412
+ conn.execute(
413
+ "UPDATE mesh_peers SET peer_key=? WHERE peer_id=? AND profile_id=?",
414
+ (key, peer_id, profile_id),
415
+ )
416
+ conn.commit()
417
+ return key
418
+
419
+
420
+ def reject_secret_state(key: str, value: str) -> dict | None:
421
+ """Return an error dict if key or value looks like a secret, else None."""
422
+ if STATE_SECRET_KEY.search(key):
423
+ return {"ok": False, "error": "mesh state is coordination metadata; secret key names are prohibited"}
424
+ try:
425
+ from superlocalmemory.core.security_primitives import redact_secrets
426
+ if redact_secrets(value) != value:
427
+ return {"ok": False, "error": "mesh state is coordination metadata; secret values are prohibited"}
428
+ except ImportError:
429
+ pass
430
+ return None
431
+
432
+
433
+ def check_cross_profile_sender(
434
+ conn: sqlite3.Connection, from_peer: str, profile_id: str
435
+ ) -> dict | None:
436
+ """Return an error dict if from_peer is a known peer in a different profile.
437
+
438
+ Arbitrary label strings (not registered anywhere) are allowed — they are
439
+ metadata, not identity claims. Only a server-assigned peer_id that
440
+ belongs to a different profile is rejected (cross-profile impersonation).
441
+ """
442
+ if not from_peer:
443
+ return None
444
+ row = conn.execute(
445
+ "SELECT profile_id FROM mesh_peers WHERE peer_id=? LIMIT 1",
446
+ (from_peer,),
447
+ ).fetchone()
448
+ if row is not None and row["profile_id"] != profile_id:
449
+ return {"ok": False, "error": "from_peer belongs to a different profile"}
450
+ return None
451
+
452
+
453
+ def validate_lock_fence_query(
454
+ conn: sqlite3.Connection,
455
+ file_path: str,
456
+ fencing_token: int,
457
+ profile_id: str,
458
+ ) -> dict:
459
+ """Compare presented fencing_token against the current lock record."""
460
+ row = conn.execute(
461
+ "SELECT COALESCE(fencing_token, 0) AS fencing_token "
462
+ "FROM mesh_locks WHERE profile_id=? AND file_path=?",
463
+ (profile_id, file_path),
464
+ ).fetchone()
465
+ if row is None:
466
+ return {"ok": False, "error": "no lock held for this resource"}
467
+ current = row["fencing_token"]
468
+ if fencing_token < current:
469
+ return {"ok": False, "error": f"fencing token {fencing_token} is stale; current token is {current}"}
470
+ return {"ok": True, "fencing_token": current}