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.
- package/ATTRIBUTION.md +4 -4
- package/CHANGELOG.md +113 -121
- package/README.md +65 -63
- package/docs/pi-dev-integration.md +1 -1
- package/package.json +6 -1
- package/plugin/.claude-plugin/plugin.json +1 -1
- package/plugin/CLAUDE.md +3 -3
- package/plugin/agents/slm-governance-advisor.md +1 -1
- package/plugin/agents/slm-loop-runner.md +1 -1
- package/plugin/agents/slm-memory-advisor.md +1 -1
- package/plugin/agents/slm-optimize-advisor.md +1 -1
- package/plugin/requirements.txt +1 -1
- package/plugin/skills/slm-cache/SKILL.md +1 -1
- package/plugin/skills/slm-compress/SKILL.md +1 -1
- package/plugin/skills/slm-governance/SKILL.md +1 -1
- package/plugin/skills/slm-graph/SKILL.md +1 -1
- package/plugin/skills/slm-loop/SKILL.md +1 -1
- package/plugin/skills/slm-mesh/SKILL.md +1 -1
- package/plugin/skills/slm-profile/SKILL.md +1 -1
- package/plugin/skills/slm-recall/SKILL.md +1 -1
- package/plugin/skills/slm-remember/SKILL.md +1 -1
- package/plugin/skills/slm-scope/SKILL.md +1 -1
- package/plugin/skills/slm-session/SKILL.md +1 -1
- package/plugin/skills/slm-status/SKILL.md +1 -1
- package/plugin-src/rules/AGENTS.md +1 -1
- package/plugin-src/skills/slm-cache/SKILL.md +1 -1
- package/plugin-src/skills/slm-compress/SKILL.md +1 -1
- package/plugin-src/skills/slm-governance/SKILL.md +248 -0
- package/plugin-src/skills/slm-graph/SKILL.md +1 -1
- package/plugin-src/skills/slm-loop/SKILL.md +99 -0
- package/plugin-src/skills/slm-mesh/SKILL.md +282 -0
- package/plugin-src/skills/slm-profile/SKILL.md +148 -0
- package/plugin-src/skills/slm-recall/SKILL.md +1 -1
- package/plugin-src/skills/slm-remember/SKILL.md +1 -1
- package/plugin-src/skills/slm-scope/SKILL.md +176 -0
- package/plugin-src/skills/slm-session/SKILL.md +1 -1
- package/plugin-src/skills/slm-status/SKILL.md +1 -1
- package/pyproject.toml +11 -4
- package/src/superlocalmemory/__init__.py +1 -1
- package/src/superlocalmemory/cli/commands.py +125 -11
- package/src/superlocalmemory/cli/daemon.py +5 -1
- package/src/superlocalmemory/cli/main.py +35 -2
- package/src/superlocalmemory/cli/ops_cmd.py +281 -0
- package/src/superlocalmemory/cli/setup_wizard.py +1 -1
- package/src/superlocalmemory/compliance/audit.py +65 -0
- package/src/superlocalmemory/compliance/eu_ai_act.py +27 -57
- package/src/superlocalmemory/compliance/gdpr.py +416 -20
- package/src/superlocalmemory/compliance/retention.py +74 -22
- package/src/superlocalmemory/compliance/scheduler.py +78 -9
- package/src/superlocalmemory/core/actor_context.py +166 -0
- package/src/superlocalmemory/core/admission.py +549 -0
- package/src/superlocalmemory/core/backend_orchestrator.py +23 -10
- package/src/superlocalmemory/core/config.py +202 -24
- package/src/superlocalmemory/core/consolidation_engine.py +13 -13
- package/src/superlocalmemory/core/context_cache.py +28 -0
- package/src/superlocalmemory/core/embeddings.py +64 -2
- package/src/superlocalmemory/core/engine.py +7 -2
- package/src/superlocalmemory/core/engine_ingestion.py +65 -3
- package/src/superlocalmemory/core/engine_wiring.py +36 -9
- package/src/superlocalmemory/core/ingest_policy.py +38 -0
- package/src/superlocalmemory/core/maintenance.py +255 -0
- package/src/superlocalmemory/core/modes.py +40 -13
- package/src/superlocalmemory/core/mutations.py +437 -44
- package/src/superlocalmemory/core/operation_policy.py +92 -0
- package/src/superlocalmemory/core/operation_policy_registry.py +542 -0
- package/src/superlocalmemory/core/operation_request.py +127 -0
- package/src/superlocalmemory/core/ops_remediation.py +542 -0
- package/src/superlocalmemory/core/recall_pipeline.py +7 -0
- package/src/superlocalmemory/core/remember_runtime.py +202 -4
- package/src/superlocalmemory/core/remote_mode.py +20 -5
- package/src/superlocalmemory/core/store_pipeline.py +150 -0
- package/src/superlocalmemory/core/topic_signature.py +19 -4
- package/src/superlocalmemory/core/transactions/__init__.py +78 -0
- package/src/superlocalmemory/core/transactions/concrete_owners.py +597 -0
- package/src/superlocalmemory/core/transactions/erasure.py +825 -0
- package/src/superlocalmemory/core/transactions/manifest.py +255 -0
- package/src/superlocalmemory/core/transactions/manifest_key.py +155 -0
- package/src/superlocalmemory/core/transactions/obligations.py +272 -0
- package/src/superlocalmemory/core/transactions/owners.py +114 -0
- package/src/superlocalmemory/core/transactions/reconciler.py +285 -0
- package/src/superlocalmemory/core/transactions/service.py +330 -0
- package/src/superlocalmemory/core/worker_pool.py +33 -5
- package/src/superlocalmemory/encoding/cognitive_consolidator.py +70 -28
- package/src/superlocalmemory/encoding/emotional.py +75 -14
- package/src/superlocalmemory/encoding/scene_builder.py +115 -13
- package/src/superlocalmemory/encoding/temporal_parser.py +4 -0
- package/src/superlocalmemory/evolution/blind_verifier.py +11 -4
- package/src/superlocalmemory/evolution/evolution_store.py +244 -4
- package/src/superlocalmemory/evolution/llm_dispatch.py +40 -0
- package/src/superlocalmemory/evolution/model_selection.py +18 -3
- package/src/superlocalmemory/evolution/mutation_generator.py +3 -0
- package/src/superlocalmemory/evolution/skill_activator.py +270 -0
- package/src/superlocalmemory/evolution/skill_evolver.py +281 -59
- package/src/superlocalmemory/evolution/types.py +30 -8
- package/src/superlocalmemory/graph/cozo_backend.py +17 -9
- package/src/superlocalmemory/hooks/auto_invoker.py +2 -1
- package/src/superlocalmemory/hooks/auto_recall.py +64 -30
- package/src/superlocalmemory/hooks/codex_assets.py +14 -1
- package/src/superlocalmemory/infra/backup.py +434 -7
- package/src/superlocalmemory/infra/process_reaper.py +18 -0
- package/src/superlocalmemory/infra/self_heal.py +401 -0
- package/src/superlocalmemory/learning/feedback.py +52 -9
- package/src/superlocalmemory/loops/engine.py +10 -0
- package/src/superlocalmemory/mcp/_daemon_proxy.py +3 -0
- package/src/superlocalmemory/mcp/http_transport.py +30 -331
- package/src/superlocalmemory/mcp/profiles.py +5 -0
- package/src/superlocalmemory/mcp/resources.py +8 -0
- package/src/superlocalmemory/mcp/server.py +51 -4
- package/src/superlocalmemory/mcp/shared.py +19 -0
- package/src/superlocalmemory/mcp/tools_active.py +25 -4
- package/src/superlocalmemory/mcp/tools_code_graph.py +26 -18
- package/src/superlocalmemory/mcp/tools_context.py +50 -8
- package/src/superlocalmemory/mcp/tools_core.py +69 -21
- package/src/superlocalmemory/mcp/tools_evolution.py +9 -2
- package/src/superlocalmemory/mcp/tools_learning.py +21 -10
- package/src/superlocalmemory/mcp/tools_loops.py +29 -18
- package/src/superlocalmemory/mcp/tools_mesh.py +8 -0
- package/src/superlocalmemory/mcp/tools_ops.py +115 -0
- package/src/superlocalmemory/mcp/tools_optimize.py +4 -0
- package/src/superlocalmemory/mcp/tools_v28.py +10 -3
- package/src/superlocalmemory/mcp/tools_v3.py +34 -14
- package/src/superlocalmemory/mcp/tools_v33.py +18 -33
- package/src/superlocalmemory/mesh/broker.py +124 -46
- package/src/superlocalmemory/mesh/broker_security.py +470 -0
- package/src/superlocalmemory/mesh/discovery.py +365 -0
- package/src/superlocalmemory/mesh/lock_protocol.py +313 -0
- package/src/superlocalmemory/mesh/node_identity.py +97 -0
- package/src/superlocalmemory/mesh/outbox_remote.py +429 -0
- package/src/superlocalmemory/mesh/remote_sync.py +511 -28
- package/src/superlocalmemory/mesh/state_sync.py +286 -0
- package/src/superlocalmemory/optimize/config/store.py +45 -0
- package/src/superlocalmemory/parameterization/cross_project.py +12 -0
- package/src/superlocalmemory/parameterization/prompt_injector.py +13 -11
- package/src/superlocalmemory/parameterization/prompt_lifecycle.py +8 -2
- package/src/superlocalmemory/parameterization/workflow_miner.py +17 -0
- package/src/superlocalmemory/retrieval/ann_index.py +5 -0
- package/src/superlocalmemory/retrieval/bm25_channel.py +49 -2
- package/src/superlocalmemory/retrieval/engine.py +19 -4
- package/src/superlocalmemory/retrieval/fusion.py +4 -1
- package/src/superlocalmemory/retrieval/hopfield_channel.py +9 -3
- package/src/superlocalmemory/retrieval/remote_reranker.py +47 -22
- package/src/superlocalmemory/retrieval/reranker.py +32 -1
- package/src/superlocalmemory/retrieval/temporal_channel.py +16 -3
- package/src/superlocalmemory/retrieval/temporal_utils.py +107 -0
- package/src/superlocalmemory/retrieval/temporal_validity_filter.py +155 -42
- package/src/superlocalmemory/retrieval/vector_store.py +214 -8
- package/src/superlocalmemory/server/api.py +5 -5
- package/src/superlocalmemory/server/egress_policy.py +258 -0
- package/src/superlocalmemory/server/rbac_enforce.py +32 -0
- package/src/superlocalmemory/server/route_mutations.py +20 -0
- package/src/superlocalmemory/server/routes/compliance.py +153 -7
- package/src/superlocalmemory/server/routes/data_io.py +43 -2
- package/src/superlocalmemory/server/routes/events.py +15 -0
- package/src/superlocalmemory/server/routes/memories.py +56 -3
- package/src/superlocalmemory/server/routes/mesh.py +82 -1
- package/src/superlocalmemory/server/routes/mesh_lock.py +54 -0
- package/src/superlocalmemory/server/routes/mesh_state.py +63 -0
- package/src/superlocalmemory/server/routes/v3_api.py +50 -27
- package/src/superlocalmemory/server/routes/ws.py +86 -0
- package/src/superlocalmemory/server/ui.py +6 -6
- package/src/superlocalmemory/server/unified_daemon.py +942 -119
- package/src/superlocalmemory/storage/_migration_internals.py +568 -0
- package/src/superlocalmemory/storage/_schema_version.py +110 -0
- package/src/superlocalmemory/storage/database.py +329 -24
- package/src/superlocalmemory/storage/embedding_migrator.py +246 -51
- package/src/superlocalmemory/storage/erasure_fence.py +45 -0
- package/src/superlocalmemory/storage/generation_fence.py +63 -0
- package/src/superlocalmemory/storage/migration_runner.py +140 -417
- package/src/superlocalmemory/storage/migrations/M009_model_lineage.py +40 -0
- package/src/superlocalmemory/storage/migrations/M033_projection_transactions.py +148 -0
- package/src/superlocalmemory/storage/migrations/M034_obligation_integrity.py +58 -0
- package/src/superlocalmemory/storage/migrations/M035_erasure_receipts.py +113 -0
- package/src/superlocalmemory/storage/migrations/M036_vector_row_map.py +107 -0
- package/src/superlocalmemory/storage/migrations/M037_manifest_hmac_version.py +162 -0
- package/src/superlocalmemory/storage/migrations/{M033_learning_feedback_channel.py → M038_learning_feedback_channel.py} +3 -3
- package/src/superlocalmemory/storage/migrations/M039_scene_fact_members.py +137 -0
- package/src/superlocalmemory/storage/migrations/__init__.py +4 -2
- package/src/superlocalmemory/storage/schema.py +67 -0
- package/src/superlocalmemory/storage/write_coordinator.py +125 -0
- package/src/superlocalmemory/trust/scorer.py +28 -4
- package/src/superlocalmemory/ui/index.html +14 -3
- package/src/superlocalmemory/ui/js/auto-settings.js +12 -1
- package/src/superlocalmemory/ui/js/brain.js +6 -4
- package/src/superlocalmemory/ui/js/compliance.js +66 -12
- package/src/superlocalmemory/ui/js/dashboard.js +13 -3
- package/src/superlocalmemory/ui/js/feedback.js +8 -2
- package/src/superlocalmemory/ui/js/lifecycle.js +7 -1
- package/src/superlocalmemory/ui/js/modal.js +272 -5
- package/src/superlocalmemory/ui/js/od-backup.js +9 -2
- package/src/superlocalmemory/ui/js/od-compliance-ext.js +301 -0
- package/src/superlocalmemory/ui/js/od-operations.js +154 -23
- package/src/superlocalmemory/ui/js/od-ops-health.js +417 -0
- package/src/superlocalmemory/ui/js/od-optimize.js +35 -21
- package/src/superlocalmemory/ui/js/od-team.js +9 -2
- package/src/superlocalmemory/ui/js/optimize.js +13 -16
- package/src/superlocalmemory/ui/js/profiles.js +7 -3
- package/src/superlocalmemory/ui/js/settings.js +7 -1
- package/src/superlocalmemory/vector/lancedb_backend.py +19 -9
- package/src/superlocalmemory/attribution/mathematical_dna.py +0 -235
- package/src/superlocalmemory/cli/post_install.py +0 -114
- package/src/superlocalmemory/core/clock_monitor.py +0 -45
- package/src/superlocalmemory/core/db_pool.py +0 -80
- package/src/superlocalmemory/core/error_catalog.py +0 -113
- package/src/superlocalmemory/core/loop_watchdog.py +0 -56
- package/src/superlocalmemory/core/priority_queue.py +0 -61
- package/src/superlocalmemory/core/pruning_engine.py +0 -216
- package/src/superlocalmemory/core/queue_dispatcher.py +0 -73
- package/src/superlocalmemory/core/slmignore.py +0 -125
- package/src/superlocalmemory/infra/heartbeat_monitor.py +0 -140
- package/src/superlocalmemory/infra/webhook_dispatcher.py +0 -247
- package/src/superlocalmemory/learning/quantization_scheduler.py +0 -320
- 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}
|