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,549 @@
1
+ # Copyright (c) 2026 Varun Pratap Bhardwaj / Qualixar
2
+ # Licensed under AGPL-3.0-or-later
3
+
4
+ """Phase 1 admission gateway — resolve_actor + admit + @admits decorator.
5
+
6
+ This module is the single shared admission API for MCP, CLI, and HTTP transports.
7
+ HTTP already uses the registry directly (unified_daemon.py:3740); this module
8
+ wires the same evaluation to MCP tools and CLI commands.
9
+
10
+ INVARIANT: resolve_actor() derives ActorContext from server-side facts only.
11
+ Never call it with data from the MCP/CLI request body.
12
+
13
+ Key design choices
14
+ ------------------
15
+ - Personal/single-user mode → OWNER (frictionless). Zero new friction.
16
+ - Enterprise mode + no principal → ANONYMOUS → denied for mutations.
17
+ - admit() raises AdmissionDenied on deny so the caller can return a clean error.
18
+ - @admits(kind) is a thin async decorator for MCP tools.
19
+ - coverage_self_check() is called at daemon startup.
20
+ - Fail-closed: config.toml present but unreadable → enterprise (not personal).
21
+
22
+ Part of SuperLocalMemory V4 | Phase 1: Admission Gateway
23
+ """
24
+
25
+ from __future__ import annotations
26
+
27
+ import functools
28
+ import logging
29
+ from typing import TYPE_CHECKING, FrozenSet
30
+
31
+ from superlocalmemory.core.actor_context import ActorContext, ActorRole, Transport
32
+ from superlocalmemory.core.operation_policy_registry import (
33
+ PolicyDecision,
34
+ _DEFAULT_REGISTRY,
35
+ )
36
+ from superlocalmemory.core.operation_request import OperationKind
37
+
38
+ if TYPE_CHECKING:
39
+ from superlocalmemory.core.config import DeploymentConfig
40
+ from superlocalmemory.core.operation_policy_registry import OperationPolicyRegistry
41
+
42
+ logger = logging.getLogger(__name__)
43
+
44
+ _COMPANY_MODES: frozenset[str] = frozenset({
45
+ "company", "remote", "enterprise", "multi-user", "multi_user",
46
+ })
47
+
48
+
49
+ def _tool_read_only_hint(tool: object) -> object:
50
+ """Return the tool's read-only annotation under mcp 1.x or 2.x naming.
51
+
52
+ MCP wire protocol uses camelCase ``readOnlyHint``. mcp==2.0.0's
53
+ ``ToolAnnotations`` pydantic model stores the field as snake_case
54
+ ``read_only_hint`` (alias ``readOnlyHint``) — attribute access by alias
55
+ is not available, so a camelCase-only getattr always returns None and
56
+ every annotated read tool is misclassified as a mutator.
57
+ """
58
+ ann = getattr(tool, "annotations", None)
59
+ if ann is None:
60
+ return None
61
+ val = getattr(ann, "readOnlyHint", None)
62
+ if val is not None:
63
+ return val
64
+ return getattr(ann, "read_only_hint", None)
65
+
66
+ # ---------------------------------------------------------------------------
67
+ # Tool inventory tracking (populated at decoration time by @admits)
68
+ # ---------------------------------------------------------------------------
69
+
70
+ # Auto-populated as each @admits decorator is applied. Checked at startup.
71
+ _GATED_MCP_TOOLS: set[str] = set()
72
+
73
+ # Minimal set of MCP mutating tools that MUST be decorated with @admits.
74
+ # Extend this set when new mutating tools are added (Tranche B extends it).
75
+ # coverage_self_check verifies _REQUIRED_MCP_GATES ⊆ _GATED_MCP_TOOLS.
76
+ _REQUIRED_MCP_GATES: frozenset[str] = frozenset({
77
+ # Core mutations (tools_core.py)
78
+ "remember",
79
+ "delete_memory",
80
+ "update_memory",
81
+ "correct_pattern",
82
+ "switch_profile",
83
+ "build_graph",
84
+ # Forgetting / consolidation (tools_v33.py)
85
+ "forget",
86
+ "consolidate_cognitive",
87
+ "quantize",
88
+ # Mode mutations (tools_v3.py)
89
+ "set_mode",
90
+ # Mesh mutations (tools_mesh.py)
91
+ "mesh_send",
92
+ "mesh_lock",
93
+ "mesh_state",
94
+ "mesh_summary",
95
+ # Evolution (tools_evolution.py)
96
+ "evolve_skill",
97
+ # Learning / feedback (tools_learning.py, tools_v28.py, tools_active.py)
98
+ "reinforce_assertion",
99
+ "contradict_assertion",
100
+ "report_outcome",
101
+ "report_feedback",
102
+ "observe",
103
+ "close_session",
104
+ # Optimize / cache (tools_optimize.py)
105
+ "slm_cache_set",
106
+ "slm_compress",
107
+ # Code graph (tools_code_graph.py)
108
+ "update_code_graph",
109
+ # Scoped reads (Tranche C — tools_core.py)
110
+ "recall",
111
+ "search",
112
+ # Tranche E — remaining MCP mutators
113
+ "slm_loop_run",
114
+ "set_retention_policy",
115
+ "compact_memories",
116
+ "log_tool_event",
117
+ "core_memory",
118
+ "run_maintenance",
119
+ "reap_processes",
120
+ "build_code_graph",
121
+ "apply_refactor",
122
+ "link_memory_to_code",
123
+ # Tranche G — mesh_inbox marks messages as read (POST to mesh broker)
124
+ "mesh_inbox",
125
+ })
126
+
127
+
128
+ # ---------------------------------------------------------------------------
129
+ # Exception
130
+ # ---------------------------------------------------------------------------
131
+
132
+ class AdmissionDenied(Exception):
133
+ """Raised by admit() when the policy evaluation returns allowed=False.
134
+
135
+ Callers map this to the appropriate transport error:
136
+ - MCP → {"success": False, "error": "not_authorized", "reason": ...}
137
+ - CLI → sys.exit(1) + message
138
+ - HTTP → already handled via PermissionError→403 in unified_daemon.py
139
+ """
140
+
141
+ def __init__(self, decision: PolicyDecision) -> None:
142
+ super().__init__(decision.reason)
143
+ self.decision: PolicyDecision = decision
144
+
145
+
146
+ # ---------------------------------------------------------------------------
147
+ # Deployment config resolution (fail-closed on present-but-unreadable)
148
+ # ---------------------------------------------------------------------------
149
+
150
+ def _resolve_deployment() -> "DeploymentConfig":
151
+ """Load DeploymentConfig. Fail-closed when config.toml is present but unreadable.
152
+
153
+ Distinction:
154
+ - config.toml absent → legitimate fresh personal install → PERSONAL (frictionless)
155
+ - config.toml present + unreadable/corrupt → unknown enterprise state → ENTERPRISE
156
+ - config.toml present + readable → use canonical loader result
157
+ """
158
+ from superlocalmemory.core.config import DEPLOYMENT_ENTERPRISE, DEPLOYMENT_PERSONAL
159
+
160
+ # Step 1: resolve the expected config path (same root as load_deployment_config).
161
+ try:
162
+ from superlocalmemory.infra.data_root import state_path
163
+ config_path = state_path("config.toml")
164
+ config_exists = config_path.exists()
165
+ except Exception as exc:
166
+ logger.debug("admission: cannot resolve config path, personal default: %s", exc)
167
+ return DEPLOYMENT_PERSONAL
168
+
169
+ # Step 2: absent → personal (fresh install, no config ever written).
170
+ if not config_exists:
171
+ return DEPLOYMENT_PERSONAL
172
+
173
+ # Step 3: present → probe raw to detect corrupt/unreadable before delegating.
174
+ try:
175
+ import tomllib
176
+ raw = config_path.read_text(encoding="utf-8")
177
+ parsed_toml = tomllib.loads(raw) # raises on corrupt TOML
178
+ except Exception as exc:
179
+ # File exists but cannot be parsed → fail-closed (treat as enterprise).
180
+ logger.warning(
181
+ "admission: config.toml present but unreadable — fail-closed "
182
+ "(treating as enterprise). Cause: %s", exc,
183
+ )
184
+ return DEPLOYMENT_ENTERPRISE
185
+
186
+ # Step 4: readable → delegate to canonical loader (handles mode/fields).
187
+ try:
188
+ from superlocalmemory.core.config import load_deployment_config
189
+ result = load_deployment_config(config_toml_path=config_path)
190
+ except Exception as exc:
191
+ logger.debug("admission: load_deployment_config raised (unexpected): %s", exc)
192
+ return DEPLOYMENT_PERSONAL
193
+
194
+ # D1 fail-closed: [deployment] section present but mode is unrecognized or
195
+ # absent means someone tried to configure enterprise and a typo/omission
196
+ # should not silently grant personal (OWNER) access on an enterprise box.
197
+ # Explicit mode="personal" is a deliberate choice — honour it.
198
+ # Explicit mode="enterprise" → canonical loader already returned ENTERPRISE.
199
+ _KNOWN_MODES = ("personal", "enterprise")
200
+ dep_section = parsed_toml.get("deployment", {})
201
+ declared_mode = str(dep_section.get("mode", "")).strip().lower()
202
+ if "deployment" in parsed_toml and declared_mode not in _KNOWN_MODES:
203
+ logger.warning(
204
+ "admission: config.toml has [deployment] section but mode %r is "
205
+ "unrecognized/absent — fail-closed → ENTERPRISE.", declared_mode or "<missing>",
206
+ )
207
+ return DEPLOYMENT_ENTERPRISE
208
+ return result
209
+
210
+
211
+ # ---------------------------------------------------------------------------
212
+ # resolve_actor
213
+ # ---------------------------------------------------------------------------
214
+
215
+ def resolve_actor(
216
+ transport: Transport,
217
+ *,
218
+ profile: str = "",
219
+ principal: str = "",
220
+ session: str = "",
221
+ tier: str = "personal",
222
+ mode: str = "personal",
223
+ client_host: str = "",
224
+ roles: FrozenSet[ActorRole] | None = None,
225
+ ) -> ActorContext:
226
+ """Build a server-derived ActorContext for MCP or CLI transport.
227
+
228
+ Rules
229
+ -----
230
+ - Personal tier (default) → OWNER, no authentication required.
231
+ This preserves the existing single-user UX with zero new friction.
232
+ - Enterprise tier + no principal → ANONYMOUS.
233
+ Mutations will be denied (authentication_required) by admit().
234
+ - Enterprise tier + principal → use supplied roles (default: MEMBER).
235
+
236
+ Parameters
237
+ ----------
238
+ transport : MCP, CLI, INTERNAL, etc.
239
+ profile : Active profile id (metadata only).
240
+ principal : Authenticated principal id (from session store, never from
241
+ request body). Empty string → anonymous.
242
+ session : Session token (only the first 16 hex chars are stored).
243
+ tier : Deployment tier: "personal" or "enterprise".
244
+ mode : Deployment mode string; "company"/"remote"/"enterprise" are
245
+ treated as enterprise. Checked in addition to ``tier``.
246
+ client_host : Resolved remote address (for is_local check).
247
+ roles : Explicit role set for authenticated enterprise actor.
248
+ When None, defaults to {ActorRole.MEMBER}.
249
+ """
250
+ is_enterprise = tier == "enterprise" or mode in _COMPANY_MODES
251
+
252
+ if not is_enterprise:
253
+ return ActorContext(
254
+ principal_id="local-operator",
255
+ roles=frozenset({ActorRole.OWNER}),
256
+ active_profile_id=profile,
257
+ transport=transport,
258
+ client_host=client_host,
259
+ )
260
+
261
+ if not principal:
262
+ return ActorContext(
263
+ principal_id="",
264
+ roles=frozenset({ActorRole.ANONYMOUS}),
265
+ active_profile_id=profile,
266
+ transport=transport,
267
+ client_host=client_host,
268
+ )
269
+
270
+ effective_roles: FrozenSet[ActorRole] = (
271
+ roles if roles is not None else frozenset({ActorRole.MEMBER})
272
+ )
273
+ # Store the SHA-256 prefix of the session token (matching the canonical HTTP
274
+ # actor), never the raw token material, for audit-log attribution only.
275
+ import hashlib as _hashlib
276
+
277
+ _session_hash = (
278
+ _hashlib.sha256(session.encode("utf-8")).hexdigest()[:16] if session else ""
279
+ )
280
+ return ActorContext(
281
+ principal_id=principal,
282
+ roles=effective_roles,
283
+ active_profile_id=profile,
284
+ transport=transport,
285
+ client_host=client_host,
286
+ session_token_hash=_session_hash,
287
+ )
288
+
289
+
290
+ # ---------------------------------------------------------------------------
291
+ # admit
292
+ # ---------------------------------------------------------------------------
293
+
294
+ def admit(
295
+ kind: OperationKind,
296
+ actor: ActorContext,
297
+ *,
298
+ resource_ids: tuple[str, ...] = (),
299
+ scope: str | None = None,
300
+ mode: str = "local",
301
+ registry: "OperationPolicyRegistry | None" = None,
302
+ ) -> PolicyDecision:
303
+ """Evaluate kind + actor against the policy registry.
304
+
305
+ Raises AdmissionDenied on deny. Returns PolicyDecision on allow.
306
+
307
+ Parameters
308
+ ----------
309
+ kind : Operation being requested.
310
+ actor : Server-derived ActorContext (never from request body).
311
+ resource_ids : Resource identifiers for future ownership checks.
312
+ scope : Scope label for future scoped-read checks.
313
+ mode : Deployment mode string forwarded to registry.evaluate().
314
+ "local"/"personal" → fail-open for unknown kinds.
315
+ "company"/"remote"/"enterprise" → fail-closed.
316
+ registry : Override the default registry (for testing).
317
+ """
318
+ reg = registry if registry is not None else _DEFAULT_REGISTRY
319
+ decision = reg.evaluate(kind, actor, mode)
320
+ if not decision.allowed:
321
+ raise AdmissionDenied(decision)
322
+ return decision
323
+
324
+
325
+ # ---------------------------------------------------------------------------
326
+ # @admits decorator for async MCP tools
327
+ # ---------------------------------------------------------------------------
328
+
329
+ def admits(kind: OperationKind):
330
+ """Decorator that gates an async MCP tool function via the policy registry.
331
+
332
+ Usage (inside register_*_tools):
333
+
334
+ @server.tool()
335
+ @admits(OperationKind.REMEMBER)
336
+ async def remember(content: str, ...) -> dict:
337
+ ...
338
+
339
+ Also registers the tool name in _GATED_MCP_TOOLS at decoration time,
340
+ enabling coverage_self_check() to verify tool inventory at startup.
341
+
342
+ On AdmissionDenied the decorator returns the error dict directly without
343
+ calling the wrapped function.
344
+ """
345
+ def decorator(fn):
346
+ _GATED_MCP_TOOLS.add(fn.__name__) # register at decoration time
347
+
348
+ @functools.wraps(fn)
349
+ async def wrapper(*args, **kwargs):
350
+ deployment = _resolve_deployment()
351
+ tier = "enterprise" if deployment.is_enterprise else "personal"
352
+ mode = "company" if deployment.is_enterprise else "local"
353
+ actor = resolve_actor(Transport.MCP, tier=tier, mode=mode)
354
+ try:
355
+ admit(kind, actor, mode=mode)
356
+ except AdmissionDenied as exc:
357
+ return {
358
+ "success": False,
359
+ "error": "not_authorized",
360
+ "reason": exc.decision.reason,
361
+ }
362
+ return await fn(*args, **kwargs)
363
+ return wrapper
364
+ return decorator
365
+
366
+
367
+ # ---------------------------------------------------------------------------
368
+ # CLI gate helper
369
+ # ---------------------------------------------------------------------------
370
+
371
+ def gate_cli_mutation(
372
+ kind: OperationKind,
373
+ *,
374
+ principal: str = "",
375
+ roles: FrozenSet[ActorRole] | None = None,
376
+ ) -> None:
377
+ """Gate a CLI mutation command. Exits with code 1 if denied.
378
+
379
+ Call this at the top of any CLI mutation handler that bypasses the daemon.
380
+ In personal mode this is a no-op (OWNER always admitted). In enterprise
381
+ mode without a principal it exits with a clear message.
382
+
383
+ Parameters
384
+ ----------
385
+ kind : Operation being performed.
386
+ principal : Authenticated CLI principal (from session store / login token).
387
+ roles : Explicit roles for an authenticated enterprise user.
388
+ """
389
+ import sys
390
+ deployment = _resolve_deployment()
391
+ tier = "enterprise" if deployment.is_enterprise else "personal"
392
+ mode = "company" if deployment.is_enterprise else "local"
393
+ actor = resolve_actor(
394
+ Transport.CLI,
395
+ tier=tier,
396
+ mode=mode,
397
+ principal=principal,
398
+ roles=roles,
399
+ )
400
+ try:
401
+ admit(kind, actor, mode=mode)
402
+ except AdmissionDenied as exc:
403
+ print(
404
+ f"[slm] Operation denied ({exc.decision.reason}). "
405
+ "This workspace requires authentication. "
406
+ "Log in with 'slm login' or contact your workspace administrator.",
407
+ flush=True,
408
+ )
409
+ sys.exit(1)
410
+
411
+
412
+ # ---------------------------------------------------------------------------
413
+ # Startup coverage self-check (non-vacuous)
414
+ # ---------------------------------------------------------------------------
415
+
416
+ def coverage_self_check(
417
+ deployment: "DeploymentConfig",
418
+ registry: "OperationPolicyRegistry | None" = None,
419
+ server: "object | None" = None,
420
+ ) -> None:
421
+ """Assert comprehensive policy coverage at daemon startup.
422
+
423
+ Checks:
424
+ 1. Every OperationKind has a registered policy (existing check).
425
+ 2. No policy has an empty allowed_transports set (unreachable = bug).
426
+ 3. Every tool in _REQUIRED_MCP_GATES appears in _GATED_MCP_TOOLS.
427
+ 4. (F1) Dynamic: every mutating tool in server._tool_manager._tools
428
+ that is NOT flagged readOnlyHint=True must be in _GATED_MCP_TOOLS.
429
+
430
+ In personal/local mode: logs warnings for any gap (non-fatal).
431
+ In enterprise mode: raises RuntimeError on the first gap (fatal startup).
432
+
433
+ Parameters
434
+ ----------
435
+ deployment : Loaded DeploymentConfig (from unified_daemon startup).
436
+ registry : Override the default registry (for testing).
437
+ server : Optional FastMCP server; when supplied, its tool registry
438
+ is enumerated for check 4 (dynamic mutator coverage).
439
+ """
440
+ reg = registry if registry is not None else _DEFAULT_REGISTRY
441
+ is_enterprise = deployment.is_enterprise
442
+ messages: list[str] = []
443
+
444
+ # Check 1: every OperationKind has a policy entry.
445
+ cov = reg.coverage()
446
+ missing_kinds = [
447
+ kind.value
448
+ for kind in OperationKind
449
+ if not cov.get(kind.value, {}).get("has_policy", False)
450
+ ]
451
+ if missing_kinds:
452
+ messages.append(f"policy coverage gap — no policy for: {missing_kinds}")
453
+
454
+ # Check 2: no policy has empty allowed_transports (unreachable).
455
+ empty_transport_kinds = [
456
+ info["kind"]
457
+ for info in cov.values()
458
+ if info.get("has_policy") and not info.get("has_transports", True)
459
+ ]
460
+ if empty_transport_kinds:
461
+ messages.append(
462
+ f"empty_transports in policies for: {empty_transport_kinds} "
463
+ "(no reachable transport — these operations can never be invoked)"
464
+ )
465
+
466
+ # Check 3: tool inventory — every required MCP gate is wired.
467
+ ungated = sorted(_REQUIRED_MCP_GATES - _GATED_MCP_TOOLS)
468
+ if ungated:
469
+ messages.append(f"ungated MCP tools (missing @admits): {ungated}")
470
+
471
+ # Check 4 (F1): dynamic discovery — enumerate server tool registry and flag
472
+ # any mutating tool (readOnlyHint / read_only_hint != True) not in
473
+ # _GATED_MCP_TOOLS. See _tool_read_only_hint for mcp 2.0 naming.
474
+ if server is not None:
475
+ try:
476
+ tool_dict = server._tool_manager._tools # type: ignore[attr-defined]
477
+ dynamic_ungated = sorted(
478
+ name
479
+ for name, tool in tool_dict.items()
480
+ if name not in _GATED_MCP_TOOLS
481
+ and _tool_read_only_hint(tool) is not True
482
+ )
483
+ if dynamic_ungated:
484
+ messages.append(
485
+ f"dynamic ungated MCP mutators (not in _GATED_MCP_TOOLS): {dynamic_ungated}"
486
+ )
487
+ except AttributeError:
488
+ logger.debug(
489
+ "admission: server does not expose _tool_manager._tools — skipping dynamic check"
490
+ )
491
+
492
+ if not messages:
493
+ logger.debug("admission: coverage self-check passed (%d kinds)", len(list(OperationKind)))
494
+ return
495
+
496
+ for msg in messages:
497
+ full = f"admission: {msg}"
498
+ if is_enterprise:
499
+ raise RuntimeError(full)
500
+ logger.warning(full)
501
+
502
+
503
+ def enforce_read_scope(
504
+ include_global: "bool | None",
505
+ include_shared: "bool | None",
506
+ *,
507
+ registry: "OperationPolicyRegistry | None" = None,
508
+ ) -> "tuple[bool | None, bool | None]":
509
+ """Clamp cross-profile read flags to the RECALL policy's ``allow_cross_profile``.
510
+
511
+ In personal mode the OWNER is unrestricted — flags pass through unchanged.
512
+ In enterprise mode with ``allow_cross_profile=False`` (the default), any
513
+ explicit ``True`` is silently clamped to ``False`` to prevent client
514
+ escalation beyond the configured scope authority. ``None`` (not specified)
515
+ is left alone so the server default applies.
516
+ """
517
+ deployment = _resolve_deployment()
518
+ if not deployment.is_enterprise:
519
+ return include_global, include_shared
520
+
521
+ reg = registry if registry is not None else _DEFAULT_REGISTRY
522
+ from superlocalmemory.core.operation_request import OperationKind as _OK
523
+ policy = reg._policies.get(_OK.RECALL)
524
+ if policy is None or policy.allow_cross_profile:
525
+ return include_global, include_shared
526
+
527
+ clamped_global = False if include_global is True else include_global
528
+ clamped_shared = False if include_shared is True else include_shared
529
+ if clamped_global is not include_global or clamped_shared is not include_shared:
530
+ logger.debug(
531
+ "admission: enforce_read_scope clamped cross-profile flags "
532
+ "(include_global=%s→%s, include_shared=%s→%s)",
533
+ include_global, clamped_global, include_shared, clamped_shared,
534
+ )
535
+ return clamped_global, clamped_shared
536
+
537
+
538
+ __all__ = [
539
+ "AdmissionDenied",
540
+ "admit",
541
+ "admits",
542
+ "coverage_self_check",
543
+ "enforce_read_scope",
544
+ "gate_cli_mutation",
545
+ "resolve_actor",
546
+ "_resolve_deployment",
547
+ "_GATED_MCP_TOOLS",
548
+ "_REQUIRED_MCP_GATES",
549
+ ]
@@ -16,6 +16,7 @@ Part of Qualixar | Author: Varun Pratap Bhardwaj
16
16
 
17
17
  from __future__ import annotations
18
18
 
19
+ import importlib.util
19
20
  import logging
20
21
  from pathlib import Path
21
22
  from typing import TYPE_CHECKING, Any
@@ -26,6 +27,20 @@ if TYPE_CHECKING:
26
27
 
27
28
  logger = logging.getLogger(__name__)
28
29
 
30
+
31
+ def _module_spec_present(module: str) -> bool:
32
+ """True if ``module`` can be imported, WITHOUT importing it.
33
+
34
+ ``find_spec`` only resolves the loader; it never executes the module, so
35
+ native packages (lancedb, pycozo) cannot spawn background runtimes during
36
+ availability probes. Mirrors component_registry._module_present.
37
+ """
38
+ try:
39
+ return importlib.util.find_spec(module) is not None
40
+ except Exception:
41
+ # A broken/partial install can raise inside find_spec — treat as absent.
42
+ return False
43
+
29
44
  # ---------------------------------------------------------------------------
30
45
  # Global singleton (set by daemon, read by store_pipeline)
31
46
  # ---------------------------------------------------------------------------
@@ -407,11 +422,10 @@ class BackendOrchestrator:
407
422
  if gb == "sqlite":
408
423
  return False
409
424
  if gb in ("auto", "cozo"):
410
- try:
411
- import pycozo # noqa: F401
412
- return True
413
- except ImportError:
414
- return False
425
+ # find_spec only resolves the loader — never executes the module.
426
+ # Native pycozo import can spawn background runtimes; do not probe
427
+ # availability by importing (Python 3.14 GC race class).
428
+ return _module_spec_present("pycozo")
415
429
  return False
416
430
 
417
431
  def _detect_lancedb(self) -> bool:
@@ -419,11 +433,10 @@ class BackendOrchestrator:
419
433
  if vb == "sqlite-vec":
420
434
  return False
421
435
  if vb in ("auto", "lancedb"):
422
- try:
423
- import lancedb # noqa: F401
424
- return True
425
- except ImportError:
426
- return False
436
+ # find_spec never executes the module. `import lancedb` starts
437
+ # LanceDBBackgroundEventLoop and segfaults under Python 3.14 GC
438
+ # when the full suite races cleanup — never import for a yes/no.
439
+ return _module_spec_present("lancedb")
427
440
  return False
428
441
 
429
442
  # ------------------------------------------------------------------