superlocalmemory 3.7.8 → 3.8.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 (260) hide show
  1. package/.claude-plugin/marketplace.json +1 -1
  2. package/ATTRIBUTION.md +1 -3
  3. package/CHANGELOG.md +69 -0
  4. package/README.md +199 -29
  5. package/package.json +4 -2
  6. package/plugin/.claude-plugin/plugin.json +2 -2
  7. package/plugin/CLAUDE.md +8 -8
  8. package/plugin/agents/slm-governance-advisor.md +80 -0
  9. package/plugin/agents/slm-loop-runner.md +71 -0
  10. package/plugin/agents/slm-memory-advisor.md +10 -5
  11. package/plugin/agents/slm-optimize-advisor.md +9 -3
  12. package/plugin/commands/slm-loop.md +31 -0
  13. package/plugin/hooks/hooks.json +79 -0
  14. package/plugin/requirements.txt +1 -1
  15. package/plugin/scripts/slm-launch +46 -7
  16. package/plugin/settings.json +9 -0
  17. package/plugin/skills/slm-cache/SKILL.md +9 -1
  18. package/plugin/skills/slm-compress/SKILL.md +8 -1
  19. package/plugin/skills/slm-governance/SKILL.md +248 -0
  20. package/plugin/skills/slm-graph/SKILL.md +17 -3
  21. package/plugin/skills/slm-loop/SKILL.md +99 -0
  22. package/plugin/skills/slm-mesh/SKILL.md +282 -0
  23. package/plugin/skills/slm-profile/SKILL.md +148 -0
  24. package/plugin/skills/slm-recall/SKILL.md +46 -10
  25. package/plugin/skills/slm-remember/SKILL.md +48 -1
  26. package/plugin/skills/slm-scope/SKILL.md +176 -0
  27. package/plugin/skills/slm-session/SKILL.md +24 -1
  28. package/plugin/skills/slm-status/SKILL.md +18 -1
  29. package/plugin-src/agents/slm-governance-advisor.md +80 -0
  30. package/plugin-src/agents/slm-loop-runner.md +71 -0
  31. package/plugin-src/agents/slm-memory-advisor.md +10 -5
  32. package/plugin-src/agents/slm-optimize-advisor.md +9 -3
  33. package/plugin-src/commands/slm-loop.md +31 -0
  34. package/plugin-src/hooks/hooks.json +79 -0
  35. package/plugin-src/manifest.json +7 -2
  36. package/plugin-src/requirements.txt +1 -1
  37. package/plugin-src/rules/AGENTS.md +57 -18
  38. package/plugin-src/rules/CLAUDE.md.fragment +8 -8
  39. package/plugin-src/scripts/slm-launch +46 -7
  40. package/plugin-src/settings.json +9 -0
  41. package/plugin-src/skills/slm-cache/SKILL.md +9 -1
  42. package/plugin-src/skills/slm-compress/SKILL.md +8 -1
  43. package/plugin-src/skills/slm-governance/SKILL.md +248 -0
  44. package/plugin-src/skills/slm-graph/SKILL.md +17 -3
  45. package/plugin-src/skills/slm-loop/SKILL.md +99 -0
  46. package/plugin-src/skills/slm-mesh/SKILL.md +282 -0
  47. package/plugin-src/skills/slm-profile/SKILL.md +148 -0
  48. package/plugin-src/skills/slm-recall/SKILL.md +46 -10
  49. package/plugin-src/skills/slm-remember/SKILL.md +48 -1
  50. package/plugin-src/skills/slm-scope/SKILL.md +176 -0
  51. package/plugin-src/skills/slm-session/SKILL.md +24 -1
  52. package/plugin-src/skills/slm-status/SKILL.md +18 -1
  53. package/pyproject.toml +1 -1
  54. package/scripts/postinstall/validation.js +2 -0
  55. package/scripts/postinstall-interactive.js +74 -2
  56. package/src/superlocalmemory/__init__.py +1 -1
  57. package/src/superlocalmemory/access/__init__.py +3 -0
  58. package/src/superlocalmemory/access/rbac.py +477 -0
  59. package/src/superlocalmemory/cli/commands.py +94 -10
  60. package/src/superlocalmemory/cli/compress_cmd.py +17 -7
  61. package/src/superlocalmemory/cli/loop_cmd.py +192 -0
  62. package/src/superlocalmemory/cli/main.py +39 -4
  63. package/src/superlocalmemory/cli/mesh_cmd.py +38 -0
  64. package/src/superlocalmemory/cli/optimize_cmd.py +3 -0
  65. package/src/superlocalmemory/cli/pending_store.py +49 -13
  66. package/src/superlocalmemory/cli/proxy_cmd.py +4 -0
  67. package/src/superlocalmemory/cli/scale_engine_cmd.py +6 -0
  68. package/src/superlocalmemory/cli/setup_wizard.py +22 -13
  69. package/src/superlocalmemory/compliance/audit.py +6 -0
  70. package/src/superlocalmemory/compliance/gdpr.py +128 -138
  71. package/src/superlocalmemory/compliance/retention.py +176 -45
  72. package/src/superlocalmemory/core/backend_orchestrator.py +5 -43
  73. package/src/superlocalmemory/core/community_summary.py +267 -0
  74. package/src/superlocalmemory/core/config.py +216 -3
  75. package/src/superlocalmemory/core/consolidation_engine.py +95 -22
  76. package/src/superlocalmemory/core/context_cache.py +61 -18
  77. package/src/superlocalmemory/core/embedding_worker.py +17 -2
  78. package/src/superlocalmemory/core/embeddings.py +12 -1
  79. package/src/superlocalmemory/core/engine.py +17 -1
  80. package/src/superlocalmemory/core/engine_ingestion.py +29 -0
  81. package/src/superlocalmemory/core/engine_wiring.py +13 -0
  82. package/src/superlocalmemory/core/entity_community.py +178 -0
  83. package/src/superlocalmemory/core/graph_analyzer.py +39 -2
  84. package/src/superlocalmemory/core/graph_pruner.py +13 -8
  85. package/src/superlocalmemory/core/key_expander.py +138 -0
  86. package/src/superlocalmemory/core/maintenance.py +23 -0
  87. package/src/superlocalmemory/core/modes.py +1 -1
  88. package/src/superlocalmemory/core/mutations.py +2 -2
  89. package/src/superlocalmemory/core/pii.py +105 -0
  90. package/src/superlocalmemory/core/progressive_abstraction.py +208 -0
  91. package/src/superlocalmemory/core/recall_pipeline.py +2 -0
  92. package/src/superlocalmemory/core/recall_worker.py +20 -6
  93. package/src/superlocalmemory/core/scale_engine.py +60 -1
  94. package/src/superlocalmemory/core/security_primitives.py +40 -2
  95. package/src/superlocalmemory/core/store_pipeline.py +35 -11
  96. package/src/superlocalmemory/core/worker_pool.py +21 -6
  97. package/src/superlocalmemory/encoding/entity_reflexion.py +200 -0
  98. package/src/superlocalmemory/encoding/entity_resolver.py +34 -24
  99. package/src/superlocalmemory/encoding/fact_extractor.py +26 -1
  100. package/src/superlocalmemory/encoding/temporal_validator.py +64 -1
  101. package/src/superlocalmemory/evolution/evolution_store.py +122 -45
  102. package/src/superlocalmemory/evolution/llm_dispatch.py +12 -1
  103. package/src/superlocalmemory/evolution/model_selection.py +160 -0
  104. package/src/superlocalmemory/evolution/mutation_generator.py +16 -0
  105. package/src/superlocalmemory/evolution/skill_evolver.py +127 -42
  106. package/src/superlocalmemory/evolution/triggers.py +22 -13
  107. package/src/superlocalmemory/graph/cozo_backend.py +43 -20
  108. package/src/superlocalmemory/hooks/adapter_base.py +5 -1
  109. package/src/superlocalmemory/hooks/auto_recall.py +13 -1
  110. package/src/superlocalmemory/hooks/claude_code_hooks.py +11 -0
  111. package/src/superlocalmemory/hooks/codex_assets.py +64 -5
  112. package/src/superlocalmemory/hooks/hook_daemon.py +20 -3
  113. package/src/superlocalmemory/hooks/memory_protocol.py +54 -0
  114. package/src/superlocalmemory/hooks/portable_kit.py +114 -1
  115. package/src/superlocalmemory/infra/backup.py +12 -1
  116. package/src/superlocalmemory/infra/daemon_identity.py +40 -4
  117. package/src/superlocalmemory/infra/data_root.py +43 -4
  118. package/src/superlocalmemory/infra/event_bus.py +107 -24
  119. package/src/superlocalmemory/infra/rate_limiter.py +93 -0
  120. package/src/superlocalmemory/ingestion/adapter_manager.py +4 -1
  121. package/src/superlocalmemory/ingestion/credentials.py +1 -1
  122. package/src/superlocalmemory/learning/cross_project.py +28 -19
  123. package/src/superlocalmemory/learning/reward_proxy.py +42 -9
  124. package/src/superlocalmemory/loops/__init__.py +56 -0
  125. package/src/superlocalmemory/loops/budget.py +58 -0
  126. package/src/superlocalmemory/loops/engine.py +164 -0
  127. package/src/superlocalmemory/loops/ledger.py +243 -0
  128. package/src/superlocalmemory/loops/models.py +152 -0
  129. package/src/superlocalmemory/loops/rules.py +52 -0
  130. package/src/superlocalmemory/mcp/_daemon_proxy.py +3 -0
  131. package/src/superlocalmemory/mcp/_pool_adapter.py +2 -0
  132. package/src/superlocalmemory/mcp/profiles.py +103 -0
  133. package/src/superlocalmemory/mcp/server.py +21 -49
  134. package/src/superlocalmemory/mcp/tools_active.py +4 -7
  135. package/src/superlocalmemory/mcp/tools_code_graph.py +51 -5
  136. package/src/superlocalmemory/mcp/tools_core.py +8 -1
  137. package/src/superlocalmemory/mcp/tools_evolution.py +6 -3
  138. package/src/superlocalmemory/mcp/tools_loops.py +300 -0
  139. package/src/superlocalmemory/mcp/tools_mesh.py +140 -4
  140. package/src/superlocalmemory/mcp/tools_optimize.py +15 -8
  141. package/src/superlocalmemory/mesh/broker.py +237 -129
  142. package/src/superlocalmemory/mesh/remote_sync.py +50 -8
  143. package/src/superlocalmemory/optimize/NOTICE +1 -6
  144. package/src/superlocalmemory/optimize/adapters/anthropic_adapter.py +1 -4
  145. package/src/superlocalmemory/optimize/adapters/openai_adapter.py +1 -4
  146. package/src/superlocalmemory/optimize/cache/semantic.py +27 -19
  147. package/src/superlocalmemory/optimize/compress/align.py +32 -26
  148. package/src/superlocalmemory/optimize/compress/ccr.py +14 -71
  149. package/src/superlocalmemory/optimize/compress/router.py +105 -22
  150. package/src/superlocalmemory/optimize/config/defaults.py +1 -1
  151. package/src/superlocalmemory/optimize/config/schema.py +87 -4
  152. package/src/superlocalmemory/optimize/metrics/counters.py +13 -4
  153. package/src/superlocalmemory/optimize/metrics/estimator.py +0 -3
  154. package/src/superlocalmemory/optimize/proxy/_helpers.py +31 -4
  155. package/src/superlocalmemory/optimize/storage/db.py +38 -9
  156. package/src/superlocalmemory/optimize/storage/schema.py +10 -0
  157. package/src/superlocalmemory/parameterization/pattern_extractor.py +6 -3
  158. package/src/superlocalmemory/retrieval/agentic.py +1 -1
  159. package/src/superlocalmemory/retrieval/bm25_channel.py +68 -10
  160. package/src/superlocalmemory/retrieval/engine.py +168 -26
  161. package/src/superlocalmemory/retrieval/entity_channel.py +7 -5
  162. package/src/superlocalmemory/retrieval/hopfield_channel.py +9 -2
  163. package/src/superlocalmemory/retrieval/semantic_channel.py +114 -21
  164. package/src/superlocalmemory/retrieval/spreading_activation.py +11 -2
  165. package/src/superlocalmemory/retrieval/temporal_channel.py +48 -9
  166. package/src/superlocalmemory/retrieval/temporal_frame.py +102 -0
  167. package/src/superlocalmemory/retrieval/temporal_validity_filter.py +135 -0
  168. package/src/superlocalmemory/retrieval/time_window.py +181 -0
  169. package/src/superlocalmemory/server/api.py +4 -4
  170. package/src/superlocalmemory/server/profile_runtime.py +125 -8
  171. package/src/superlocalmemory/server/rbac_enforce.py +142 -0
  172. package/src/superlocalmemory/server/recall_health.py +24 -3
  173. package/src/superlocalmemory/server/recall_serializer.py +19 -1
  174. package/src/superlocalmemory/server/routes/abstraction.py +115 -0
  175. package/src/superlocalmemory/server/routes/agents.py +128 -38
  176. package/src/superlocalmemory/server/routes/backup.py +34 -10
  177. package/src/superlocalmemory/server/routes/behavioral.py +13 -12
  178. package/src/superlocalmemory/server/routes/brain.py +21 -5
  179. package/src/superlocalmemory/server/routes/chat.py +10 -5
  180. package/src/superlocalmemory/server/routes/compliance.py +171 -21
  181. package/src/superlocalmemory/server/routes/config_api.py +436 -0
  182. package/src/superlocalmemory/server/routes/data_io.py +30 -8
  183. package/src/superlocalmemory/server/routes/entity.py +9 -4
  184. package/src/superlocalmemory/server/routes/events.py +24 -8
  185. package/src/superlocalmemory/server/routes/evolution.py +135 -17
  186. package/src/superlocalmemory/server/routes/helpers.py +16 -1
  187. package/src/superlocalmemory/server/routes/ingest.py +7 -4
  188. package/src/superlocalmemory/server/routes/insights.py +3 -3
  189. package/src/superlocalmemory/server/routes/learning.py +14 -14
  190. package/src/superlocalmemory/server/routes/lifecycle.py +59 -8
  191. package/src/superlocalmemory/server/routes/memories.py +182 -57
  192. package/src/superlocalmemory/server/routes/mesh.py +95 -15
  193. package/src/superlocalmemory/server/routes/optimize.py +33 -1
  194. package/src/superlocalmemory/server/routes/prewarm.py +2 -0
  195. package/src/superlocalmemory/server/routes/profiles.py +63 -17
  196. package/src/superlocalmemory/server/routes/ratelimit.py +124 -0
  197. package/src/superlocalmemory/server/routes/rbac.py +367 -0
  198. package/src/superlocalmemory/server/routes/stats.py +13 -6
  199. package/src/superlocalmemory/server/routes/tiers.py +11 -9
  200. package/src/superlocalmemory/server/routes/v3_api.py +183 -69
  201. package/src/superlocalmemory/server/routes/ws.py +5 -2
  202. package/src/superlocalmemory/server/security_middleware.py +12 -5
  203. package/src/superlocalmemory/server/ui.py +20 -5
  204. package/src/superlocalmemory/server/unified_daemon.py +384 -56
  205. package/src/superlocalmemory/server/write_identity.py +38 -8
  206. package/src/superlocalmemory/storage/database.py +265 -53
  207. package/src/superlocalmemory/storage/migration_runner.py +53 -0
  208. package/src/superlocalmemory/storage/migrations/M021_ingestion_log_profile.py +108 -0
  209. package/src/superlocalmemory/storage/migrations/M022_entity_aliases_profile.py +86 -0
  210. package/src/superlocalmemory/storage/migrations/M023_mesh_profile_isolation.py +194 -0
  211. package/src/superlocalmemory/storage/migrations/M024_rbac_users_roles.py +87 -0
  212. package/src/superlocalmemory/storage/migrations/M025_perf_indexes.py +90 -0
  213. package/src/superlocalmemory/storage/migrations/M026_rbac_memberships_fk.py +136 -0
  214. package/src/superlocalmemory/storage/migrations/M027_transferable_patterns_profile.py +163 -0
  215. package/src/superlocalmemory/storage/models.py +4 -0
  216. package/src/superlocalmemory/storage/schema.py +87 -0
  217. package/src/superlocalmemory/storage/schema_v343.py +24 -12
  218. package/src/superlocalmemory/trust/gate.py +49 -8
  219. package/src/superlocalmemory/ui/assets/slm-icon-white.svg +64 -0
  220. package/src/superlocalmemory/ui/assets/slm-icon.svg +36 -0
  221. package/src/superlocalmemory/ui/css/design-system.css +621 -0
  222. package/src/superlocalmemory/ui/css/neural-glass.css +6 -0
  223. package/src/superlocalmemory/ui/css/od-bridge.css +158 -0
  224. package/src/superlocalmemory/ui/favicon.svg +35 -4
  225. package/src/superlocalmemory/ui/index.html +306 -173
  226. package/src/superlocalmemory/ui/js/brain.js +5 -20
  227. package/src/superlocalmemory/ui/js/core.js +47 -31
  228. package/src/superlocalmemory/ui/js/dashboard.js +314 -63
  229. package/src/superlocalmemory/ui/js/event-delegation.js +102 -0
  230. package/src/superlocalmemory/ui/js/knowledge-graph.js +11 -11
  231. package/src/superlocalmemory/ui/js/math-health.js +1 -1
  232. package/src/superlocalmemory/ui/js/memories.js +15 -4
  233. package/src/superlocalmemory/ui/js/memory-chat.js +7 -7
  234. package/src/superlocalmemory/ui/js/ng-entities.js +6 -8
  235. package/src/superlocalmemory/ui/js/ng-ingestion.js +4 -4
  236. package/src/superlocalmemory/ui/js/ng-mesh.js +4 -9
  237. package/src/superlocalmemory/ui/js/ng-shell.js +8 -8
  238. package/src/superlocalmemory/ui/js/ng-skills.js +54 -2
  239. package/src/superlocalmemory/ui/js/od-agents.js +544 -0
  240. package/src/superlocalmemory/ui/js/od-auth-gate.js +257 -0
  241. package/src/superlocalmemory/ui/js/od-backup.js +780 -0
  242. package/src/superlocalmemory/ui/js/od-brain.js +779 -0
  243. package/src/superlocalmemory/ui/js/od-entities.js +579 -0
  244. package/src/superlocalmemory/ui/js/od-graph.js +593 -0
  245. package/src/superlocalmemory/ui/js/od-health.js +539 -0
  246. package/src/superlocalmemory/ui/js/od-mcp.js +508 -0
  247. package/src/superlocalmemory/ui/js/od-memories.js +887 -0
  248. package/src/superlocalmemory/ui/js/od-mesh.js +539 -0
  249. package/src/superlocalmemory/ui/js/od-operations.js +1250 -0
  250. package/src/superlocalmemory/ui/js/od-optimize.js +787 -0
  251. package/src/superlocalmemory/ui/js/od-settings.js +1053 -0
  252. package/src/superlocalmemory/ui/js/od-shell.js +593 -0
  253. package/src/superlocalmemory/ui/js/od-skills.js +573 -0
  254. package/src/superlocalmemory/ui/js/od-team.js +258 -0
  255. package/src/superlocalmemory/ui/js/profiles.js +159 -46
  256. package/src/superlocalmemory/ui/js/settings.js +2 -2
  257. package/src/superlocalmemory/ui/js/timeline.js +34 -5
  258. package/src/superlocalmemory/ui/js/trust-dashboard.js +2 -2
  259. package/src/superlocalmemory/vector/lancedb_backend.py +8 -6
  260. package/src/superlocalmemory/learning/behavioral_listener.py +0 -94
@@ -28,11 +28,18 @@ from .helpers import (
28
28
  from superlocalmemory.server.profile_runtime import (
29
29
  commit_daemon_profile_switch,
30
30
  get_profile_runtime,
31
+ TransitionDrainTimeout,
31
32
  )
32
33
 
33
34
  logger = logging.getLogger("superlocalmemory.routes.profiles")
34
35
  router = APIRouter()
35
36
 
37
+
38
+ def _internal_error(detail: str = "Internal server error") -> HTTPException:
39
+ """SEC-H-02: log full traceback server-side; return a generic message to the client."""
40
+ logger.exception("profiles route error")
41
+ return HTTPException(status_code=500, detail=detail)
42
+
36
43
  # WebSocket manager reference (set by ui_server.py at startup)
37
44
  ws_manager = None
38
45
 
@@ -85,8 +92,8 @@ async def list_profiles(request: Request):
85
92
  "total_profiles": len(profiles),
86
93
  }
87
94
 
88
- except Exception as e:
89
- raise HTTPException(status_code=500, detail=f"Profile list error: {str(e)}")
95
+ except Exception:
96
+ raise _internal_error("Profile list error")
90
97
 
91
98
 
92
99
  @router.post("/api/profiles/{name}/switch")
@@ -112,17 +119,36 @@ async def switch_profile(name: str, request: Request):
112
119
  source_agent_id="http-profile-switch",
113
120
  profile_id=name,
114
121
  )
122
+ # RBAC (C4): a logged-in user may only activate a workspace they belong
123
+ # to — this is the read boundary. Switching sets the active tenant for
124
+ # subsequent reads, so a non-member must not be able to enter it. The
125
+ # machine owner (no user session) switches freely.
126
+ from superlocalmemory.server.rbac_enforce import resolve_principal
127
+ _principal = resolve_principal(request)
128
+ if _principal.get("kind") == "user":
129
+ _rbac = getattr(request.app.state, "rbac", None)
130
+ if _rbac is not None and _rbac.get_role(_principal["user_id"], name) is None:
131
+ raise HTTPException(
132
+ status_code=403,
133
+ detail="You are not a member of this workspace.",
134
+ )
115
135
  runtime = get_profile_runtime(request.app.state)
116
136
  previous = runtime.snapshot.profile_id
117
- snapshot = await asyncio.to_thread(
118
- runtime.transition,
119
- name,
120
- lambda prior, target: commit_daemon_profile_switch(
121
- request.app.state,
122
- prior,
123
- target,
124
- ),
125
- )
137
+ try:
138
+ snapshot = await asyncio.to_thread(
139
+ runtime.transition,
140
+ name,
141
+ lambda prior, target: commit_daemon_profile_switch(
142
+ request.app.state,
143
+ prior,
144
+ target,
145
+ ),
146
+ )
147
+ except TransitionDrainTimeout as exc:
148
+ raise HTTPException(
149
+ status_code=503,
150
+ detail=str(exc),
151
+ )
126
152
 
127
153
  count = _get_memory_count(name)
128
154
 
@@ -143,8 +169,8 @@ async def switch_profile(name: str, request: Request):
143
169
 
144
170
  except HTTPException:
145
171
  raise
146
- except Exception as e:
147
- raise HTTPException(status_code=500, detail=f"Profile switch error: {str(e)}")
172
+ except Exception:
173
+ raise _internal_error("Profile switch error")
148
174
 
149
175
 
150
176
  @router.post("/api/profiles/create")
@@ -166,18 +192,34 @@ async def create_profile(body: ProfileSwitch, request: Request):
166
192
  operation="update",
167
193
  source_agent_id="http-profile-create",
168
194
  )
195
+ # RBAC (C3): creating a tenant is an administrative action.
196
+ from superlocalmemory.access.rbac import Permission as _Perm
197
+ from superlocalmemory.server.rbac_enforce import require_manage as _rbac_manage
198
+ _principal = _rbac_manage(request)
169
199
  # Write to BOTH stores atomically
170
200
  desc = f'Memory profile: {name}'
171
201
  ensure_profile_in_db(name, desc)
172
202
  ensure_profile_in_json(name, desc)
173
203
 
204
+ # A logged-in user who creates a workspace becomes its admin so they
205
+ # can manage it immediately (profile_id == name here). The machine
206
+ # owner needs no membership row (implicit root).
207
+ if _principal.get("kind") == "user":
208
+ rbac = getattr(request.app.state, "rbac", None)
209
+ if rbac is not None:
210
+ try:
211
+ rbac.set_membership(name, _principal["user_id"], "admin",
212
+ added_by=_principal["username"])
213
+ except Exception:
214
+ pass
215
+
174
216
  authorization.complete()
175
217
  return {"success": True, "profile": name, "message": f"Profile '{name}' created"}
176
218
 
177
219
  except HTTPException:
178
220
  raise
179
- except Exception as e:
180
- raise HTTPException(status_code=500, detail=f"Profile create error: {str(e)}")
221
+ except Exception:
222
+ raise _internal_error("Profile create error")
181
223
 
182
224
 
183
225
  @router.delete("/api/profiles/{name}")
@@ -204,6 +246,10 @@ async def delete_profile(name: str, request: Request):
204
246
  source_agent_id="http-profile-delete",
205
247
  profile_id=name,
206
248
  )
249
+ # RBAC (C3): deleting a tenant is administrative. Check MANAGE on the
250
+ # profile being deleted (not the active one).
251
+ from superlocalmemory.server.rbac_enforce import require_manage as _rbac_manage
252
+ _rbac_manage(request, profile=name)
207
253
  # Move data to default before deleting (bypasses CASCADE)
208
254
  conn = get_db_connection()
209
255
  cursor = conn.cursor()
@@ -243,5 +289,5 @@ async def delete_profile(name: str, request: Request):
243
289
 
244
290
  except HTTPException:
245
291
  raise
246
- except Exception as e:
247
- raise HTTPException(status_code=500, detail=f"Profile delete error: {str(e)}")
292
+ except Exception:
293
+ raise _internal_error("Profile delete error")
@@ -0,0 +1,124 @@
1
+ # Copyright (c) 2026 Varun Pratap Bhardwaj / Qualixar
2
+ # Licensed under AGPL-3.0-or-later — see LICENSE file
3
+ # Part of SuperLocalMemory V3 | https://qualixar.com
4
+
5
+ """Dashboard-editable rate limits (task #47).
6
+
7
+ The soak found the loopback write limiter (correctly) returning 429 under
8
+ heavy multi-system load. This exposes the write/read/window ceilings so an
9
+ operator can raise them from the Governance panel:
10
+
11
+ GET /api/v3/ratelimit — current effective limits (+ derived loopback)
12
+ PUT /api/v3/ratelimit — set write/read/window; applied at runtime (no
13
+ restart) and persisted to config.json
14
+
15
+ Runtime apply goes through rate_limiter.set_limits(), which reconfigures every
16
+ registered enforcement limiter live. Persistence reuses config_api's config.json
17
+ helpers; load_persisted_limits() re-applies the saved override at daemon start.
18
+ Admin-only (RBAC MANAGE), matching config_api.
19
+ """
20
+
21
+ from __future__ import annotations
22
+
23
+ import logging
24
+
25
+ from fastapi import APIRouter, Request
26
+ from fastapi.responses import JSONResponse
27
+ from pydantic import BaseModel, ConfigDict, Field
28
+
29
+ from superlocalmemory.infra.rate_limiter import (
30
+ _loopback_read,
31
+ _loopback_write,
32
+ get_limits,
33
+ set_limits,
34
+ )
35
+ from superlocalmemory.server.routes.config_api import (
36
+ _atomic_write,
37
+ _read_config,
38
+ _require_admin,
39
+ )
40
+
41
+ logger = logging.getLogger(__name__)
42
+
43
+ router = APIRouter(prefix="/api/v3", tags=["ratelimit"])
44
+
45
+ _CONFIG_KEY = "rate_limit"
46
+ _MIN, _MAX_REQ, _MAX_WINDOW = 1, 100_000, 3600
47
+
48
+
49
+ class RateLimitUpdate(BaseModel):
50
+ model_config = ConfigDict(extra="forbid")
51
+ write: int | None = Field(default=None, ge=_MIN, le=_MAX_REQ)
52
+ read: int | None = Field(default=None, ge=_MIN, le=_MAX_REQ)
53
+ window: int | None = Field(default=None, ge=_MIN, le=_MAX_WINDOW)
54
+
55
+
56
+ def _effective() -> dict:
57
+ cur = get_limits()
58
+ return {
59
+ "write": cur["write"],
60
+ "read": cur["read"],
61
+ "window": cur["window"],
62
+ "loopback_write": _loopback_write(cur["write"]),
63
+ "loopback_read": _loopback_read(cur["read"]),
64
+ }
65
+
66
+
67
+ def load_persisted_limits() -> None:
68
+ """Re-apply the persisted rate-limit override at daemon start (fail-open)."""
69
+ try:
70
+ saved = (_read_config().get(_CONFIG_KEY) or {})
71
+ if not isinstance(saved, dict):
72
+ return
73
+ w = saved.get("write")
74
+ r = saved.get("read")
75
+ win = saved.get("window")
76
+ if w is None and r is None and win is None:
77
+ return
78
+ set_limits(write=w, read=r, window=win)
79
+ logger.info("Rate limits restored from config.json: %s", get_limits())
80
+ except Exception as exc: # pragma: no cover - defensive
81
+ logger.debug("load_persisted_limits skipped: %s", exc)
82
+
83
+
84
+ @router.get("/ratelimit")
85
+ def get_ratelimit() -> JSONResponse:
86
+ return JSONResponse(_effective())
87
+
88
+
89
+ @router.put("/ratelimit")
90
+ async def put_ratelimit(request: Request) -> JSONResponse:
91
+ _require_admin(request)
92
+ try:
93
+ raw = await request.json()
94
+ except Exception:
95
+ raw = {}
96
+ try:
97
+ payload = RateLimitUpdate(**(raw or {}))
98
+ except Exception as exc:
99
+ return JSONResponse(status_code=422, content={"error": str(exc)})
100
+
101
+ if payload.write is None and payload.read is None and payload.window is None:
102
+ return JSONResponse(
103
+ status_code=422,
104
+ content={"error": "Provide at least one of write, read, window."},
105
+ )
106
+
107
+ # Runtime apply (reconfigures every live limiter — no restart).
108
+ effective = set_limits(
109
+ write=payload.write, read=payload.read, window=payload.window,
110
+ )
111
+
112
+ # Persist so the override survives restart.
113
+ try:
114
+ cfg = _read_config()
115
+ cfg[_CONFIG_KEY] = {
116
+ "write": effective["write"],
117
+ "read": effective["read"],
118
+ "window": effective["window"],
119
+ }
120
+ _atomic_write(cfg)
121
+ except Exception as exc:
122
+ logger.warning("rate-limit persist failed (applied at runtime): %s", exc)
123
+
124
+ return JSONResponse({"success": True, **_effective()})
@@ -0,0 +1,367 @@
1
+ # Copyright (c) 2026 Varun Pratap Bhardwaj / Qualixar
2
+ # Licensed under AGPL-3.0-or-later - see LICENSE file
3
+ # Part of SuperLocalMemory V3 — RBAC / teams (C3)
4
+
5
+ """Dashboard-facing RBAC API: login/logout/whoami + user & role administration.
6
+
7
+ Mounted at /api/rbac/*. Every route first proves machine auth (the dashboard
8
+ holds the install token / the operator is on loopback) via
9
+ require_http_mutation_actor, then applies RBAC where relevant:
10
+
11
+ * login/logout/whoami/status — machine auth only (identity bootstrap).
12
+ * user + membership + policy admin — MANAGE permission (owner or an admin).
13
+
14
+ Creating the first user works out of the box: with zero users the caller is the
15
+ implicit owner, who has MANAGE.
16
+ """
17
+
18
+ from __future__ import annotations
19
+
20
+ import logging
21
+ import os
22
+ import threading
23
+ import time
24
+ from typing import Optional
25
+
26
+ from fastapi import APIRouter, HTTPException, Request, Response
27
+ from pydantic import BaseModel
28
+
29
+ from superlocalmemory.access.rbac import RbacError
30
+ from superlocalmemory.server.rbac_enforce import (
31
+ get_rbac_engine, principal_info, require_manage, resolve_principal,
32
+ )
33
+
34
+ logger = logging.getLogger("superlocalmemory.routes.rbac")
35
+ router = APIRouter(prefix="/api/rbac", tags=["rbac"])
36
+
37
+ _SESSION_COOKIE = "slm_session"
38
+
39
+ # Login throttle: lock an account after repeated failures within a window, to
40
+ # blunt password spraying / brute force (scrypt alone allows ~20 tries/s).
41
+ _LOGIN_MAX_FAILS = 5
42
+ _LOGIN_WINDOW_SEC = 300
43
+ # SEC-M-01: cap tracked usernames so a spray of unique usernames (one failed
44
+ # login each) cannot grow this dict without bound (memory-exhaustion DoS).
45
+ _LOGIN_MAX_TRACKED = 10_000
46
+ _login_fails: dict[str, list] = {}
47
+ _login_lock = threading.Lock()
48
+
49
+
50
+ def _login_blocked(username: str) -> bool:
51
+ now = time.time()
52
+ with _login_lock:
53
+ fails = [t for t in _login_fails.get(username, []) if now - t < _LOGIN_WINDOW_SEC]
54
+ # SEC-M-01: evict entries that decayed to empty instead of parking a
55
+ # zero-length list forever (only successful logins used to pop them).
56
+ if fails:
57
+ _login_fails[username] = fails
58
+ else:
59
+ _login_fails.pop(username, None)
60
+ # Overflow guard: drop the oldest-inserted key if we exceed the cap.
61
+ if len(_login_fails) > _LOGIN_MAX_TRACKED:
62
+ _login_fails.pop(next(iter(_login_fails)), None)
63
+ return len(fails) >= _LOGIN_MAX_FAILS
64
+
65
+
66
+ def _login_note(username: str, ok: bool) -> None:
67
+ with _login_lock:
68
+ if ok:
69
+ _login_fails.pop(username, None)
70
+ else:
71
+ _login_fails.setdefault(username, []).append(time.time())
72
+
73
+
74
+ def _is_browser(request: Request) -> bool:
75
+ h = request.headers
76
+ return bool(h.get("referer") or h.get("origin") or h.get("cookie")
77
+ or h.get("sec-fetch-mode"))
78
+
79
+
80
+ def _cookie_secure(request: Request) -> bool:
81
+ return (request.headers.get("x-forwarded-proto", "").lower() == "https"
82
+ or os.environ.get("SLM_DASHBOARD_HTTPS") == "1")
83
+
84
+
85
+ # -- models --
86
+
87
+ class LoginRequest(BaseModel):
88
+ username: str
89
+ password: str
90
+
91
+
92
+ class CreateUserRequest(BaseModel):
93
+ username: str
94
+ password: str
95
+ display_name: str = ""
96
+ role: Optional[str] = None # optional membership on the active profile
97
+ profile_id: Optional[str] = None
98
+
99
+
100
+ class UpdateUserRequest(BaseModel):
101
+ display_name: Optional[str] = None
102
+ password: Optional[str] = None
103
+ status: Optional[str] = None
104
+
105
+
106
+ class MemberRequest(BaseModel):
107
+ user_id: str
108
+ role: str
109
+ profile_id: Optional[str] = None
110
+
111
+
112
+ class RemoveMemberRequest(BaseModel):
113
+ user_id: str
114
+ profile_id: Optional[str] = None
115
+
116
+
117
+ class PolicyRequest(BaseModel):
118
+ require_login: bool
119
+
120
+
121
+ # -- helpers --
122
+
123
+ def _machine_guard(request: Request) -> None:
124
+ """Prove machine auth (operator / dashboard) before any RBAC route."""
125
+ from superlocalmemory.server.write_identity import require_http_mutation_actor
126
+
127
+ broker = getattr(request.app.state, "mesh_broker", None)
128
+ require_http_mutation_actor(
129
+ request,
130
+ getattr(request.app.state, "daemon_descriptor", None),
131
+ actor_kind="rbac-route",
132
+ mesh_secret=getattr(broker, "_shared_secret", None) if broker else None,
133
+ )
134
+
135
+
136
+ def _engine(request: Request):
137
+ rbac = get_rbac_engine(request.app.state)
138
+ if rbac is None:
139
+ raise HTTPException(503, detail="RBAC subsystem not initialized")
140
+ return rbac
141
+
142
+
143
+ def _active_profile() -> str:
144
+ from superlocalmemory.server.routes.helpers import get_active_profile
145
+
146
+ return get_active_profile()
147
+
148
+
149
+ def _session_token(request: Request) -> str:
150
+ return (request.headers.get("X-SLM-User-Session", "")
151
+ or (request.cookies.get(_SESSION_COOKIE, "") or ""))
152
+
153
+
154
+ def _require_authority_over_user(request: Request, target_user_id: str) -> None:
155
+ """Reject modifying a user the caller has no authority over (IDOR guard).
156
+
157
+ The machine owner is root. A logged-in admin may only act on a user who
158
+ shares at least one workspace on which the admin holds MANAGE.
159
+ """
160
+ from superlocalmemory.access.rbac import Permission
161
+ from superlocalmemory.server.rbac_enforce import resolve_principal
162
+
163
+ principal = resolve_principal(request)
164
+ if principal["kind"] == "owner":
165
+ return
166
+ rbac = _engine(request)
167
+ target_profiles = rbac.list_user_profiles(target_user_id)
168
+ if any(rbac.has_permission(principal["user_id"], p["profile_id"], Permission.MANAGE)
169
+ for p in target_profiles):
170
+ return
171
+ raise HTTPException(403, detail="You have no authority over this user.")
172
+
173
+
174
+ # -- identity --
175
+
176
+ @router.post("/login")
177
+ async def login(req: LoginRequest, request: Request, response: Response):
178
+ _machine_guard(request)
179
+ uname = (req.username or "").strip()
180
+ if _login_blocked(uname):
181
+ raise HTTPException(429, detail="Too many attempts. Try again later.")
182
+ rbac = _engine(request)
183
+ user = rbac.verify_credentials(req.username, req.password)
184
+ if not user:
185
+ _login_note(uname, ok=False)
186
+ raise HTTPException(401, detail="Invalid username or password")
187
+ _login_note(uname, ok=True)
188
+ token = rbac.create_session(user["user_id"])
189
+ # HttpOnly cookie so dashboard JS never has to hold the session token;
190
+ # Secure when served over HTTPS (behind a TLS proxy).
191
+ response.set_cookie(
192
+ _SESSION_COOKIE, token, httponly=True, samesite="strict", path="/",
193
+ secure=_cookie_secure(request),
194
+ )
195
+ # Only echo the raw token to non-browser (CLI) clients that cannot use the
196
+ # cookie — browsers rely on the HttpOnly cookie and must not see it in the
197
+ # response body (proxy logs / devtools capture).
198
+ body = {"ok": True, "user": user}
199
+ if not _is_browser(request):
200
+ body["token"] = token
201
+ return body
202
+
203
+
204
+ @router.post("/logout")
205
+ async def logout(request: Request, response: Response):
206
+ _machine_guard(request)
207
+ rbac = _engine(request)
208
+ token = _session_token(request)
209
+ if token:
210
+ rbac.revoke_session(token)
211
+ response.delete_cookie(_SESSION_COOKIE, path="/")
212
+ return {"ok": True}
213
+
214
+
215
+ @router.get("/whoami")
216
+ async def whoami(request: Request):
217
+ _machine_guard(request)
218
+ return principal_info(request)
219
+
220
+
221
+ @router.get("/status")
222
+ async def status(request: Request):
223
+ _machine_guard(request)
224
+ rbac = _engine(request)
225
+ return {
226
+ "rbac_active": rbac.user_count() > 0,
227
+ "require_login": rbac.require_login(),
228
+ "user_count": rbac.user_count(),
229
+ }
230
+
231
+
232
+ # -- user administration (MANAGE) --
233
+
234
+ @router.get("/users")
235
+ async def list_users(request: Request):
236
+ _machine_guard(request)
237
+ principal = require_manage(request)
238
+ rbac = _engine(request)
239
+ all_users = rbac.list_users()
240
+ if principal.get("kind") == "owner":
241
+ return {"users": all_users}
242
+ # A workspace admin only sees users who share a workspace they MANAGE
243
+ # (usernames/display names are PII — don't leak other tenants' rosters).
244
+ from superlocalmemory.access.rbac import Permission
245
+ mgr = {p["profile_id"] for p in rbac.list_user_profiles(principal["user_id"])
246
+ if rbac.has_permission(principal["user_id"], p["profile_id"], Permission.MANAGE)}
247
+ visible = [
248
+ u for u in all_users
249
+ if u["user_id"] == principal["user_id"]
250
+ or any(m["profile_id"] in mgr for m in rbac.list_user_profiles(u["user_id"]))
251
+ ]
252
+ return {"users": visible}
253
+
254
+
255
+ @router.post("/users")
256
+ async def create_user(req: CreateUserRequest, request: Request):
257
+ _machine_guard(request)
258
+ principal = require_manage(request)
259
+ rbac = _engine(request)
260
+ # Granting a role on a workspace requires MANAGE on THAT workspace.
261
+ if req.role:
262
+ require_manage(request, profile=req.profile_id or _active_profile())
263
+ try:
264
+ user = rbac.create_user(
265
+ req.username, req.password, display_name=req.display_name,
266
+ created_by=principal["username"],
267
+ )
268
+ if req.role:
269
+ rbac.set_membership(
270
+ req.profile_id or _active_profile(), user["user_id"],
271
+ req.role, added_by=principal["username"],
272
+ )
273
+ except RbacError as e:
274
+ # SEC-H-02 carve-out: RbacError messages are intentional domain
275
+ # validation messages (e.g. "Username already exists") and are safe
276
+ # to surface to the admin console. Log for audit trail.
277
+ logger.warning("rbac create_user rejected: %s", e)
278
+ raise HTTPException(400, detail=str(e))
279
+ return {"ok": True, "user": user}
280
+
281
+
282
+ @router.patch("/users/{user_id}")
283
+ async def update_user(user_id: str, req: UpdateUserRequest, request: Request):
284
+ _machine_guard(request)
285
+ require_manage(request)
286
+ _require_authority_over_user(request, user_id)
287
+ rbac = _engine(request)
288
+ try:
289
+ if req.display_name is not None or req.status is not None:
290
+ if req.status is not None:
291
+ rbac.set_status(user_id, req.status)
292
+ if req.display_name is not None:
293
+ # display_name update via direct engine call
294
+ conn = rbac._conn()
295
+ try:
296
+ conn.execute(
297
+ "UPDATE rbac_users SET display_name=? WHERE user_id=?",
298
+ (req.display_name, user_id),
299
+ )
300
+ conn.commit()
301
+ finally:
302
+ conn.close()
303
+ if req.password is not None:
304
+ rbac.set_password(user_id, req.password)
305
+ except RbacError as e:
306
+ logger.warning("rbac update_user rejected: %s", e)
307
+ raise HTTPException(400, detail=str(e))
308
+ return {"ok": True, "user": rbac.get_user(user_id)}
309
+
310
+
311
+ @router.delete("/users/{user_id}")
312
+ async def delete_user(user_id: str, request: Request):
313
+ _machine_guard(request)
314
+ require_manage(request)
315
+ _require_authority_over_user(request, user_id)
316
+ try:
317
+ _engine(request).delete_user(user_id)
318
+ except RbacError as e:
319
+ logger.warning("rbac delete_user not found: %s", e)
320
+ raise HTTPException(404, detail=str(e))
321
+ return {"ok": True}
322
+
323
+
324
+ # -- membership administration (MANAGE) --
325
+
326
+ @router.get("/members")
327
+ async def list_members(request: Request, profile_id: Optional[str] = None):
328
+ _machine_guard(request)
329
+ prof = profile_id or _active_profile()
330
+ # MANAGE on the TARGET workspace, not merely the active one — otherwise an
331
+ # admin of one workspace could enumerate another's members.
332
+ require_manage(request, profile=prof)
333
+ return {"profile_id": prof, "members": _engine(request).list_members(prof)}
334
+
335
+
336
+ @router.post("/members")
337
+ async def set_member(req: MemberRequest, request: Request):
338
+ _machine_guard(request)
339
+ prof = req.profile_id or _active_profile()
340
+ principal = require_manage(request, profile=prof)
341
+ rbac = _engine(request)
342
+ try:
343
+ m = rbac.set_membership(prof, req.user_id, req.role,
344
+ added_by=principal["username"])
345
+ except RbacError as e:
346
+ logger.warning("rbac set_membership rejected: %s", e)
347
+ raise HTTPException(400, detail=str(e))
348
+ return {"ok": True, "membership": m}
349
+
350
+
351
+ @router.delete("/members")
352
+ async def remove_member(req: RemoveMemberRequest, request: Request):
353
+ _machine_guard(request)
354
+ prof = req.profile_id or _active_profile()
355
+ require_manage(request, profile=prof)
356
+ _engine(request).remove_membership(prof, req.user_id)
357
+ return {"ok": True}
358
+
359
+
360
+ # -- policy (MANAGE) --
361
+
362
+ @router.post("/policy")
363
+ async def set_policy(req: PolicyRequest, request: Request):
364
+ _machine_guard(request)
365
+ require_manage(request)
366
+ _engine(request).set_require_login(req.require_login)
367
+ return {"ok": True, "require_login": req.require_login}
@@ -19,6 +19,12 @@ logger = logging.getLogger("superlocalmemory.routes.stats")
19
19
  router = APIRouter()
20
20
 
21
21
 
22
+ def _internal_error(detail: str = "Internal server error") -> HTTPException:
23
+ """SEC-H-02: log full traceback server-side; return a generic message to the client."""
24
+ logger.exception("stats route error")
25
+ return HTTPException(status_code=500, detail=detail)
26
+
27
+
22
28
  @router.get("/api/stats")
23
29
  async def get_stats():
24
30
  """Get comprehensive system statistics."""
@@ -237,8 +243,8 @@ async def get_stats():
237
243
  },
238
244
  }
239
245
 
240
- except Exception as e:
241
- raise HTTPException(status_code=500, detail=f"Stats error: {str(e)}")
246
+ except Exception:
247
+ raise _internal_error("Stats error")
242
248
 
243
249
 
244
250
  @router.get("/api/timeline")
@@ -305,8 +311,8 @@ async def get_timeline(
305
311
  "parameters": {"days": days, "group_by": group_by},
306
312
  }
307
313
 
308
- except Exception as e:
309
- raise HTTPException(status_code=500, detail=f"Timeline error: {str(e)}")
314
+ except Exception:
315
+ raise _internal_error("Timeline error")
310
316
 
311
317
 
312
318
  @router.get("/api/patterns")
@@ -436,5 +442,6 @@ async def get_patterns():
436
442
  "pattern_types": list(grouped.keys()), "confidence_stats": confidence_stats,
437
443
  }
438
444
 
439
- except Exception as e:
440
- return {"patterns": {}, "total_patterns": 0, "error": str(e)}
445
+ except Exception:
446
+ logger.exception("patterns route error")
447
+ return {"patterns": {}, "total_patterns": 0, "error": "Internal server error"}