ltcai 8.9.0 → 9.1.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 (208) hide show
  1. package/README.md +81 -58
  2. package/auto_setup.py +7 -904
  3. package/desktop/electron/README.md +9 -0
  4. package/desktop/electron/main.cjs +5 -3
  5. package/docs/CHANGELOG.md +221 -238
  6. package/docs/COMMUNITY_AND_PLUGINS.md +3 -2
  7. package/docs/DEVELOPMENT.md +53 -14
  8. package/docs/LEGACY_COMPATIBILITY.md +4 -4
  9. package/docs/ONBOARDING.md +10 -2
  10. package/docs/OPERATIONS.md +8 -4
  11. package/docs/PRODUCT_DIRECTION_REVIEW.md +4 -0
  12. package/docs/ROADMAP_RECOMMENDATIONS.md +61 -36
  13. package/docs/TRUST_MODEL.md +5 -2
  14. package/docs/WHY_LATTICE.md +15 -10
  15. package/docs/WORKFLOW_DESIGNER.md +22 -0
  16. package/docs/kg-schema.md +13 -2
  17. package/docs/mcp-tools.md +17 -6
  18. package/docs/privacy.md +19 -3
  19. package/docs/public-deploy.md +32 -3
  20. package/docs/security-model.md +46 -11
  21. package/lattice_brain/__init__.py +1 -1
  22. package/lattice_brain/archive.py +4 -14
  23. package/lattice_brain/context.py +66 -9
  24. package/lattice_brain/embeddings.py +38 -2
  25. package/lattice_brain/graph/_kg_common.py +27 -462
  26. package/lattice_brain/graph/_kg_constants.py +243 -0
  27. package/lattice_brain/graph/_kg_fsutil.py +293 -0
  28. package/lattice_brain/graph/discovery.py +0 -948
  29. package/lattice_brain/graph/discovery_index.py +1126 -0
  30. package/lattice_brain/graph/documents.py +44 -9
  31. package/lattice_brain/graph/ingest.py +47 -20
  32. package/lattice_brain/graph/provenance.py +13 -6
  33. package/lattice_brain/graph/retrieval.py +141 -610
  34. package/lattice_brain/graph/retrieval_docgen.py +243 -0
  35. package/lattice_brain/graph/retrieval_vector.py +460 -0
  36. package/lattice_brain/graph/store.py +6 -0
  37. package/lattice_brain/ingestion.py +69 -4
  38. package/lattice_brain/portability.py +29 -23
  39. package/lattice_brain/quality.py +98 -4
  40. package/lattice_brain/runtime/agent_runtime.py +169 -7
  41. package/lattice_brain/runtime/hooks.py +30 -13
  42. package/lattice_brain/runtime/multi_agent.py +27 -8
  43. package/lattice_brain/runtime/statuses.py +10 -0
  44. package/lattice_brain/utils.py +46 -0
  45. package/lattice_brain/workflow.py +1 -5
  46. package/latticeai/__init__.py +1 -1
  47. package/latticeai/api/admin.py +1 -1
  48. package/latticeai/api/agent_registry.py +10 -8
  49. package/latticeai/api/agents.py +22 -3
  50. package/latticeai/api/auth.py +78 -16
  51. package/latticeai/api/browser.py +303 -34
  52. package/latticeai/api/chat.py +355 -952
  53. package/latticeai/api/chat_agent_http.py +356 -0
  54. package/latticeai/api/chat_contracts.py +54 -0
  55. package/latticeai/api/chat_documents.py +256 -0
  56. package/latticeai/api/chat_helpers.py +250 -0
  57. package/latticeai/api/chat_history.py +99 -0
  58. package/latticeai/api/chat_intents.py +302 -0
  59. package/latticeai/api/chat_stream.py +115 -0
  60. package/latticeai/api/computer_use.py +211 -40
  61. package/latticeai/api/health.py +9 -3
  62. package/latticeai/api/hooks.py +17 -6
  63. package/latticeai/api/knowledge_graph.py +78 -14
  64. package/latticeai/api/local_files.py +7 -2
  65. package/latticeai/api/marketplace.py +11 -0
  66. package/latticeai/api/mcp.py +102 -23
  67. package/latticeai/api/models.py +72 -33
  68. package/latticeai/api/network.py +5 -5
  69. package/latticeai/api/permissions.py +70 -29
  70. package/latticeai/api/plugins.py +21 -7
  71. package/latticeai/api/portability.py +5 -5
  72. package/latticeai/api/realtime.py +33 -5
  73. package/latticeai/api/setup.py +2 -2
  74. package/latticeai/api/static_routes.py +123 -24
  75. package/latticeai/api/tools.py +97 -14
  76. package/latticeai/api/workflow_designer.py +37 -0
  77. package/latticeai/api/workspace.py +2 -1
  78. package/latticeai/app_factory.py +185 -405
  79. package/latticeai/core/agent.py +19 -9
  80. package/latticeai/core/agent_registry.py +1 -5
  81. package/latticeai/core/config.py +23 -3
  82. package/latticeai/core/context_builder.py +21 -3
  83. package/latticeai/core/invitations.py +1 -4
  84. package/latticeai/core/io_utils.py +45 -0
  85. package/latticeai/core/legacy_compatibility.py +24 -3
  86. package/latticeai/core/local_embeddings.py +2 -4
  87. package/latticeai/core/marketplace.py +33 -2
  88. package/latticeai/core/mcp_catalog.py +450 -0
  89. package/latticeai/core/mcp_registry.py +2 -441
  90. package/latticeai/core/plugins.py +15 -0
  91. package/latticeai/core/policy.py +1 -1
  92. package/latticeai/core/realtime.py +38 -15
  93. package/latticeai/core/sessions.py +7 -2
  94. package/latticeai/core/timeutil.py +10 -0
  95. package/latticeai/core/tool_registry.py +90 -18
  96. package/latticeai/core/users.py +5 -14
  97. package/latticeai/core/workspace_graph_trace.py +31 -5
  98. package/latticeai/core/workspace_memory.py +3 -1
  99. package/latticeai/core/workspace_os.py +18 -7
  100. package/latticeai/core/workspace_os_utils.py +2 -21
  101. package/latticeai/core/workspace_permissions.py +2 -1
  102. package/latticeai/core/workspace_plugins.py +1 -1
  103. package/latticeai/core/workspace_runs.py +14 -5
  104. package/latticeai/core/workspace_skills.py +1 -1
  105. package/latticeai/core/workspace_snapshots.py +2 -1
  106. package/latticeai/core/workspace_timeline.py +2 -1
  107. package/latticeai/integrations/telegram_bot.py +96 -40
  108. package/latticeai/models/model_providers.py +111 -0
  109. package/latticeai/models/router.py +189 -173
  110. package/latticeai/runtime/access_runtime.py +62 -4
  111. package/latticeai/runtime/audit_runtime.py +27 -16
  112. package/latticeai/runtime/automation_runtime.py +22 -7
  113. package/latticeai/runtime/brain_runtime.py +19 -7
  114. package/latticeai/runtime/chat_wiring.py +2 -0
  115. package/latticeai/runtime/config_runtime.py +58 -26
  116. package/latticeai/runtime/context_runtime.py +16 -4
  117. package/latticeai/runtime/history_runtime.py +163 -0
  118. package/latticeai/runtime/hooks_runtime.py +1 -1
  119. package/latticeai/runtime/model_wiring.py +33 -5
  120. package/latticeai/runtime/namespace_runtime.py +163 -0
  121. package/latticeai/runtime/network_config_runtime.py +56 -0
  122. package/latticeai/runtime/platform_runtime_wiring.py +4 -3
  123. package/latticeai/runtime/review_wiring.py +1 -1
  124. package/latticeai/runtime/router_registration.py +34 -1
  125. package/latticeai/runtime/security_runtime.py +110 -13
  126. package/latticeai/runtime/sso_config_runtime.py +128 -0
  127. package/latticeai/runtime/stages.py +27 -0
  128. package/latticeai/runtime/user_key_runtime.py +106 -0
  129. package/latticeai/server_app.py +5 -1
  130. package/latticeai/services/architecture_readiness.py +74 -5
  131. package/latticeai/services/brain_automation.py +3 -26
  132. package/latticeai/services/chat_service.py +205 -15
  133. package/latticeai/services/local_knowledge.py +423 -0
  134. package/latticeai/services/memory_service.py +268 -21
  135. package/latticeai/services/model_engines.py +48 -33
  136. package/latticeai/services/model_errors.py +17 -0
  137. package/latticeai/services/model_loading.py +28 -25
  138. package/latticeai/services/model_runtime.py +228 -162
  139. package/latticeai/services/p_reinforce.py +22 -3
  140. package/latticeai/services/platform_runtime.py +92 -24
  141. package/latticeai/services/product_readiness.py +19 -17
  142. package/latticeai/services/review_queue.py +76 -11
  143. package/latticeai/services/router_context.py +1 -0
  144. package/latticeai/services/run_executor.py +25 -9
  145. package/latticeai/services/search_service.py +203 -28
  146. package/latticeai/services/setup_detection.py +80 -0
  147. package/latticeai/services/tool_dispatch.py +28 -5
  148. package/latticeai/services/triggers.py +53 -4
  149. package/latticeai/services/upload_service.py +13 -2
  150. package/latticeai/setup/__init__.py +25 -0
  151. package/latticeai/setup/auto_setup.py +857 -0
  152. package/latticeai/setup/wizard.py +1264 -0
  153. package/latticeai/tools/__init__.py +278 -0
  154. package/{tools → latticeai/tools}/commands.py +67 -7
  155. package/{tools → latticeai/tools}/computer.py +1 -1
  156. package/{tools → latticeai/tools}/documents.py +1 -1
  157. package/{tools → latticeai/tools}/filesystem.py +3 -3
  158. package/latticeai/tools/knowledge.py +178 -0
  159. package/{tools → latticeai/tools}/local_files.py +7 -1
  160. package/{tools → latticeai/tools}/network.py +0 -1
  161. package/local_knowledge_api.py +4 -342
  162. package/package.json +22 -2
  163. package/scripts/brain_quality_eval.py +3 -1
  164. package/scripts/bump_version.py +3 -0
  165. package/scripts/capture_release_evidence.mjs +180 -0
  166. package/scripts/check_current_release_docs.mjs +142 -0
  167. package/scripts/check_i18n_literals.mjs +107 -31
  168. package/scripts/check_openapi_drift.mjs +56 -0
  169. package/scripts/export_openapi.py +67 -7
  170. package/scripts/i18n_literal_allowlist.json +22 -24
  171. package/scripts/run_integration_tests.mjs +72 -10
  172. package/scripts/validate_release_artifacts.py +49 -7
  173. package/scripts/wheel_smoke.py +5 -0
  174. package/setup_wizard.py +3 -1304
  175. package/src-tauri/Cargo.lock +1 -1
  176. package/src-tauri/Cargo.toml +1 -1
  177. package/src-tauri/tauri.conf.json +1 -1
  178. package/static/app/asset-manifest.json +11 -11
  179. package/static/app/assets/Act-Bzz0bUyW.js +1 -0
  180. package/static/app/assets/Brain-Dj2J20YA.js +321 -0
  181. package/static/app/assets/Capture-CqlEl1Ga.js +1 -0
  182. package/static/app/assets/Library-B03FP1Yx.js +1 -0
  183. package/static/app/assets/System-80lHW0Ux.js +1 -0
  184. package/static/app/assets/index-BiMofBTM.js +17 -0
  185. package/static/app/assets/index-Bmx9rzTc.css +2 -0
  186. package/static/app/assets/primitives-Q1A96_7v.js +1 -0
  187. package/static/app/assets/textarea-D13RtnTo.js +1 -0
  188. package/static/app/index.html +2 -2
  189. package/static/css/tokens.css +4 -2
  190. package/static/sw.js +1 -1
  191. package/tools/__init__.py +21 -269
  192. package/docs/CODE_REVIEW_2026-07-06.md +0 -764
  193. package/latticeai/runtime/app_context_runtime.py +0 -13
  194. package/latticeai/runtime/sso_runtime.py +0 -52
  195. package/latticeai/runtime/tail_wiring.py +0 -21
  196. package/scripts/com.pts.claudecode.discord.plist +0 -31
  197. package/scripts/pts-claudecode-discord-bridge.mjs +0 -207
  198. package/scripts/start-pts-claudecode-discord.sh +0 -51
  199. package/static/app/assets/Act-fZokUnC0.js +0 -1
  200. package/static/app/assets/Brain-DtyuWubr.js +0 -321
  201. package/static/app/assets/Capture-D5KV3Cu7.js +0 -1
  202. package/static/app/assets/Library-C9kyFkSt.js +0 -1
  203. package/static/app/assets/System-VbChmX7r.js +0 -1
  204. package/static/app/assets/index-DCh5AoXt.css +0 -2
  205. package/static/app/assets/index-DPdcPoF0.js +0 -17
  206. package/static/app/assets/primitives-DFeanEV6.js +0 -1
  207. package/static/app/assets/textarea-CD8UNKIy.js +0 -1
  208. package/tools/knowledge.py +0 -95
@@ -61,8 +61,10 @@ def create_knowledge_graph_router(
61
61
  require_graph: Callable[[], None],
62
62
  require_user: Callable[[Request], str],
63
63
  static_dir: Path,
64
+ require_admin: Optional[Callable[[Request], Any]] = None,
64
65
  allowed_workspaces_for: Optional[Callable[[Optional[str]], Any]] = None,
65
66
  ingestion_pipeline: Any = None,
67
+ workspace_service: Any = None,
66
68
  ) -> APIRouter:
67
69
  router = APIRouter()
68
70
 
@@ -75,9 +77,9 @@ def create_knowledge_graph_router(
75
77
 
76
78
  Returns ``(graph, allowed)``. ``allowed is None`` means no scoping
77
79
  (single-user / no-auth mode); otherwise it is the set of workspace ids
78
- the caller may read. Legacy-global rows (no workspace) stay visible
79
- the documented pre-v4 compatibility behavior enforced by
80
- ``filter_scoped_nodes``.
80
+ the caller may read. Legacy-global rows are private by default in this
81
+ multi-user path; maintenance callers can opt in only through the store
82
+ API's explicit ``include_legacy_global=True`` argument.
81
83
  """
82
84
  user = require_user(request)
83
85
  allowed = None
@@ -85,6 +87,39 @@ def create_knowledge_graph_router(
85
87
  allowed = allowed_workspaces_for(user)
86
88
  return graph(), allowed
87
89
 
90
+ def _filter_scoped(kg: Any, items: Any, allowed: Any) -> list:
91
+ """Apply the fail-closed v2 scope contract, including to old stores."""
92
+
93
+ candidates = list(items)
94
+ try:
95
+ return kg.filter_scoped_nodes(
96
+ candidates,
97
+ allowed,
98
+ include_legacy_global=False,
99
+ )
100
+ except TypeError:
101
+ # A pre-hardening store may still interpret missing ids as global.
102
+ # Resolve authoritative scopes directly and keep only known rows in
103
+ # an allowed workspace instead of invoking that legacy behavior.
104
+ scopes = kg.workspaces_of([item.get("id") for item in candidates])
105
+ allowed_ids = {str(item) for item in allowed if item}
106
+ return [
107
+ item
108
+ for item in candidates
109
+ if str(item.get("id") or "") in scopes
110
+ and scopes[str(item.get("id") or "")] is not None
111
+ and str(scopes[str(item.get("id") or "")]) in allowed_ids
112
+ ]
113
+
114
+ def _write_workspace(request: Request, user: str) -> Optional[str]:
115
+ requested = _workspace_scope_from_request(request)
116
+ if workspace_service is None:
117
+ return requested
118
+ try:
119
+ return workspace_service.resolve_write_scope(requested, user or None)
120
+ except PermissionError as exc:
121
+ raise HTTPException(status_code=403, detail=str(exc)) from exc
122
+
88
123
  @router.get("/graph")
89
124
  async def knowledge_graph_page(request: Request):
90
125
  """Serve the interactive knowledge graph canvas UI."""
@@ -101,7 +136,11 @@ def create_knowledge_graph_router(
101
136
 
102
137
  @router.post("/knowledge-graph/curate")
103
138
  async def knowledge_graph_curate(request: Request):
104
- require_user(request)
139
+ # Curation rewrites the shared graph and is therefore an administrative
140
+ # operation whenever role-based authentication is configured. The
141
+ # fallback preserves the standalone/local router contract used by
142
+ # embedders that only provide ``require_user``.
143
+ (require_admin or require_user)(request)
105
144
  return graph().curate()
106
145
 
107
146
  @router.get("/knowledge-graph/provenance/coverage")
@@ -129,7 +168,25 @@ def create_knowledge_graph_router(
129
168
  kg, allowed = _scoped(request)
130
169
  if allowed is None:
131
170
  return kg.graph(limit)
132
- return kg.graph(limit, allowed_workspaces=allowed)
171
+ try:
172
+ return kg.graph(
173
+ limit,
174
+ allowed_workspaces=allowed,
175
+ include_legacy_global=False,
176
+ )
177
+ except TypeError:
178
+ payload = kg.graph(limit)
179
+ nodes = _filter_scoped(kg, payload.get("nodes", []), allowed)
180
+ kept = {node.get("id") for node in nodes}
181
+ return {
182
+ **payload,
183
+ "nodes": nodes,
184
+ "edges": [
185
+ edge
186
+ for edge in payload.get("edges", [])
187
+ if edge.get("from") in kept and edge.get("to") in kept
188
+ ],
189
+ }
133
190
 
134
191
  @router.get("/knowledge-graph/documents")
135
192
  async def knowledge_graph_documents(request: Request, limit: int = 200):
@@ -141,7 +198,7 @@ def create_knowledge_graph_router(
141
198
  kg, allowed = _scoped(request)
142
199
  payload = kg.list_documents(limit)
143
200
  if allowed is not None:
144
- documents = kg.filter_scoped_nodes(payload.get("documents", []), allowed)
201
+ documents = _filter_scoped(kg, payload.get("documents", []), allowed)
145
202
  payload = {**payload, "documents": documents, "total": len(documents)}
146
203
  return payload
147
204
 
@@ -152,7 +209,10 @@ def create_knowledge_graph_router(
152
209
  return {"query": q, "matches": []}
153
210
  payload = kg.search(q, limit)
154
211
  if allowed is not None:
155
- payload = {**payload, "matches": kg.filter_scoped_nodes(payload.get("matches", []), allowed)}
212
+ payload = {
213
+ **payload,
214
+ "matches": _filter_scoped(kg, payload.get("matches", []), allowed),
215
+ }
156
216
  return payload
157
217
 
158
218
  @router.get("/knowledge-graph/context")
@@ -162,7 +222,7 @@ def create_knowledge_graph_router(
162
222
  return {"query": q, "context": kg.context_for_query(q, limit)}
163
223
  # Scoped mode: derive context from scope-filtered search matches so the
164
224
  # RAG context never carries content from workspaces the caller can't read.
165
- matches = kg.filter_scoped_nodes(kg.search(q, limit).get("matches", []), allowed)
225
+ matches = _filter_scoped(kg, kg.search(q, limit).get("matches", []), allowed)
166
226
  return {"query": q, "context": _format_context(matches, limit)}
167
227
 
168
228
  @router.get("/knowledge-graph/neighbors/{node_id:path}")
@@ -170,11 +230,11 @@ def create_knowledge_graph_router(
170
230
  kg, allowed = _scoped(request)
171
231
  if not node_id:
172
232
  raise HTTPException(status_code=400, detail="node_id required")
173
- if allowed is not None and not kg.filter_scoped_nodes([{"id": node_id}], allowed):
233
+ if allowed is not None and not _filter_scoped(kg, [{"id": node_id}], allowed):
174
234
  raise HTTPException(status_code=404, detail="node not found")
175
235
  payload = kg.neighbors(node_id)
176
236
  if allowed is not None:
177
- neighbors = kg.filter_scoped_nodes(payload.get("neighbors", []), allowed)
237
+ neighbors = _filter_scoped(kg, payload.get("neighbors", []), allowed)
178
238
  kept = {n.get("id") for n in neighbors}
179
239
  edges = [
180
240
  e for e in payload.get("edges", [])
@@ -187,8 +247,12 @@ def create_knowledge_graph_router(
187
247
  @router.post("/knowledge-graph/ingest")
188
248
  async def knowledge_graph_ingest(req: KnowledgeGraphIngestRequest, request: Request):
189
249
  current_user = require_user(request)
250
+ if current_user and req.user_email:
251
+ if current_user.strip().lower() != req.user_email.strip().lower():
252
+ raise HTTPException(status_code=403, detail="user_email must match the authenticated user.")
253
+ effective_user = current_user or req.user_email or None
190
254
  kg = graph()
191
- workspace_id = _workspace_scope_from_request(request)
255
+ workspace_id = _write_workspace(request, current_user)
192
256
  event_type = (req.type or "").strip().lower()
193
257
  if event_type not in {"message", "ai_response", "note"}:
194
258
  raise HTTPException(status_code=400, detail="지원하는 type: message, ai_response, note")
@@ -201,7 +265,7 @@ def create_knowledge_graph_router(
201
265
  title=req.title,
202
266
  text=req.content,
203
267
  source_uri=req.source,
204
- owner=req.user_email or current_user,
268
+ owner=effective_user,
205
269
  workspace_id=workspace_id,
206
270
  conversation_id=req.conversation_id,
207
271
  metadata={
@@ -219,7 +283,7 @@ def create_knowledge_graph_router(
219
283
  **(req.metadata or {}),
220
284
  },
221
285
  ),
222
- user_email=req.user_email or current_user,
286
+ user_email=effective_user,
223
287
  )
224
288
  if result.status != "ok":
225
289
  raise HTTPException(status_code=500, detail=result.detail or result.status)
@@ -227,7 +291,7 @@ def create_knowledge_graph_router(
227
291
  return kg.ingest_message(
228
292
  role,
229
293
  req.content,
230
- user_email=req.user_email or current_user,
294
+ user_email=effective_user,
231
295
  user_nickname=req.user_nickname,
232
296
  source=req.source or "mcp",
233
297
  conversation_id=req.conversation_id,
@@ -15,8 +15,8 @@ from fastapi.responses import FileResponse
15
15
  from pydantic import BaseModel
16
16
 
17
17
  from latticeai.api.knowledge_graph import create_knowledge_graph_router
18
- from local_knowledge_api import create_local_knowledge_router
19
- from tools import local_list, local_read, local_write
18
+ from latticeai.services.local_knowledge import create_local_knowledge_router
19
+ from latticeai.tools import local_list, local_read, local_write
20
20
 
21
21
  try:
22
22
  from latticeai import __version__ as _LATTICE_VERSION
@@ -40,6 +40,7 @@ class LocalWriteRequest(BaseModel):
40
40
  def create_local_files_router(
41
41
  *,
42
42
  require_user,
43
+ require_admin=None,
43
44
  tool_response,
44
45
  permission_gateway,
45
46
  knowledge_graph,
@@ -50,6 +51,7 @@ def create_local_files_router(
50
51
  hooks=None,
51
52
  data_dir: Optional[Path] = None,
52
53
  allowed_workspaces_for=None,
54
+ workspace_service=None,
53
55
  ) -> APIRouter:
54
56
  router = APIRouter()
55
57
 
@@ -211,9 +213,11 @@ def create_local_files_router(
211
213
  get_graph=lambda: knowledge_graph,
212
214
  require_graph=require_graph,
213
215
  require_user=require_user,
216
+ require_admin=require_admin,
214
217
  static_dir=static_dir,
215
218
  allowed_workspaces_for=allowed_workspaces_for,
216
219
  ingestion_pipeline=ingestion_pipeline,
220
+ workspace_service=workspace_service,
217
221
  )
218
222
  )
219
223
 
@@ -227,6 +231,7 @@ def create_local_files_router(
227
231
  require_local_approval=permission_gateway.require_local_approval,
228
232
  watcher=local_kg_watcher,
229
233
  hooks=hooks,
234
+ workspace_service=workspace_service,
230
235
  )
231
236
  )
232
237
 
@@ -91,4 +91,15 @@ def create_marketplace_router(
91
91
  scope = gate_read(request)
92
92
  return {"registry": store.list_template_registry(workspace_id=scope)}
93
93
 
94
+ @router.get("/marketplace/interop/bridges")
95
+ async def list_interop_bridges(request: Request):
96
+ require_user(request)
97
+ gate_read(request)
98
+ bridges = catalog.list_templates(kind="ingestion_bridge")["templates"]
99
+ return {
100
+ "bridges": bridges,
101
+ "total": len(bridges),
102
+ "pipeline": "unified-ingestion",
103
+ }
104
+
94
105
  return router
@@ -29,9 +29,13 @@ from latticeai.core.mcp_registry import (
29
29
  install_skill,
30
30
  SKILLS_DIR,
31
31
  )
32
- from latticeai.core.tool_registry import MCP_TOOL_DESCRIPTIONS
33
- from latticeai.services.tool_dispatch import enforce_tool_policy
34
- from tools import AGENT_ROOT, execute_tool
32
+ from latticeai.core.tool_registry import (
33
+ KNOWLEDGE_WRITE_TOOLS,
34
+ MCP_TOOL_DESCRIPTIONS,
35
+ SCOPED_KNOWLEDGE_TOOLS,
36
+ )
37
+ from latticeai.services.tool_dispatch import EXPLICIT_CONSENT_TOOLS, enforce_tool_policy
38
+ from latticeai.tools import execute_tool
35
39
 
36
40
 
37
41
  class McpRecommendRequest(BaseModel):
@@ -80,6 +84,8 @@ def create_mcp_router(
80
84
  knowledge_graph: Any,
81
85
  ingestion_pipeline: Any,
82
86
  data_dir: Path,
87
+ allowed_workspaces_for: Optional[Callable[[Optional[str]], Any]] = None,
88
+ workspace_service: Any = None,
83
89
  ) -> APIRouter:
84
90
  router = APIRouter()
85
91
 
@@ -106,30 +112,70 @@ def create_mcp_router(
106
112
  with open(_CUSTOM_MCP_FILE, "w", encoding="utf-8") as f:
107
113
  json.dump(items, f, ensure_ascii=False, indent=2)
108
114
 
115
+ def _public_env_vars(items: Any) -> List[Dict[str, Any]]:
116
+ return [
117
+ {"name": str(item.get("name") or ""), "configured": bool(item.get("value"))}
118
+ for item in (items or [])
119
+ if isinstance(item, dict) and item.get("name")
120
+ ]
121
+
122
+ def _allowed_graph_workspaces(user: Optional[str]):
123
+ return allowed_workspaces_for(user) if allowed_workspaces_for is not None else None
124
+
125
+ def _write_workspace(requested: Optional[str], user: Optional[str]) -> Optional[str]:
126
+ if workspace_service is None:
127
+ return requested
128
+ try:
129
+ return workspace_service.resolve_write_scope(requested, user)
130
+ except PermissionError as exc:
131
+ raise HTTPException(status_code=403, detail=str(exc)) from exc
132
+
133
+ def _read_workspace(requested: Optional[str], user: Optional[str]) -> Optional[str]:
134
+ if workspace_service is not None:
135
+ try:
136
+ return workspace_service.resolve_read_scope(requested, user)
137
+ except PermissionError as exc:
138
+ raise HTTPException(status_code=403, detail=str(exc)) from exc
139
+ workspace_id = requested or "personal"
140
+ allowed = _allowed_graph_workspaces(user)
141
+ if allowed is not None and workspace_id not in set(allowed):
142
+ raise HTTPException(status_code=403, detail=f"workspace '{workspace_id}' is not readable")
143
+ return workspace_id
144
+
109
145
  @router.get("/mcp/tools")
110
- async def mcp_tools():
146
+ async def mcp_tools(request: Request):
147
+ require_user(request)
111
148
  installed = load_mcp_installs().get("installed", {})
112
149
  registry = await _get_combined_registry()
113
150
  tools = []
114
151
  for name, description in MCP_TOOL_DESCRIPTIONS.items():
152
+ if name in EXPLICIT_CONSENT_TOOLS:
153
+ continue
115
154
  policy = TOOL_GOVERNANCE.get(name, _TOOL_GOVERNANCE_DEFAULT)
155
+ governance = {
156
+ "risk": policy["risk"],
157
+ "destructive": policy["destructive"],
158
+ "shell": policy["shell"],
159
+ "network": policy["network"],
160
+ "auto_approve": policy["auto_approve"],
161
+ "sandbox": policy["sandbox"],
162
+ "rollback": policy["rollback"],
163
+ }
164
+ if policy.get("capability"):
165
+ governance["capability"] = policy["capability"]
166
+ if policy.get("scope"):
167
+ governance["scope"] = policy["scope"]
116
168
  tools.append({
117
169
  "name": name,
118
170
  "description": description,
119
171
  "permission": get_tool_permission(name),
120
- "governance": {
121
- "risk": policy["risk"],
122
- "destructive": policy["destructive"],
123
- "shell": policy["shell"],
124
- "network": policy["network"],
125
- "auto_approve": policy["auto_approve"],
126
- "sandbox": policy["sandbox"],
127
- "rollback": policy["rollback"],
128
- },
172
+ "governance": governance,
129
173
  })
130
174
  return {
131
175
  "status": "ok",
132
- "workspace": str(AGENT_ROOT),
176
+ # Do not disclose the host user's absolute checkout path through a
177
+ # discovery endpoint. Tool paths are workspace-relative already.
178
+ "workspace": ".",
133
179
  "installed_mcps": [mcp_public_item(item, installed) for item in registry],
134
180
  "tools": tools,
135
181
  }
@@ -178,7 +224,7 @@ def create_mcp_router(
178
224
  @router.get("/mcp/claude-code-servers")
179
225
  async def mcp_claude_code_servers(request: Request):
180
226
  """Read ~/.claude/settings.json mcpServers and return them as Lattice MCP items."""
181
- require_user(request)
227
+ require_admin(request)
182
228
  settings_path = Path.home() / ".claude" / "settings.json"
183
229
  if not settings_path.exists():
184
230
  return {"servers": []}
@@ -192,7 +238,7 @@ def create_mcp_router(
192
238
  args = cfg.get("args", [])
193
239
  package = " ".join([cmd] + args) if args else cmd
194
240
  env = cfg.get("env", {})
195
- env_vars = [{"name": k, "value": v} for k, v in env.items()]
241
+ env_vars = [{"name": k, "configured": bool(v)} for k, v in env.items()]
196
242
  servers.append({
197
243
  "id": f"claude-code:{name}",
198
244
  "name": name,
@@ -213,7 +259,12 @@ def create_mcp_router(
213
259
  async def mcp_custom_list(request: Request):
214
260
  """Return user-added custom MCP entries."""
215
261
  require_user(request)
216
- return {"custom": _load_custom_mcps()}
262
+ items = []
263
+ for raw in _load_custom_mcps():
264
+ item = dict(raw)
265
+ item["env_vars"] = _public_env_vars(item.get("env_vars"))
266
+ items.append(item)
267
+ return {"custom": items}
217
268
 
218
269
  @router.post("/mcp/custom")
219
270
  async def mcp_custom_add(req: McpCustomRequest, request: Request):
@@ -362,18 +413,26 @@ def create_mcp_router(
362
413
  _require_graph()
363
414
  # v4: MCP messages enter the brain through the unified ingestion
364
415
  # pipeline (provenance + hook lifecycle), not a direct store call.
365
- owner = args.get("user_email") or current_user
416
+ claimed_user = str(args.get("user_email") or "").strip()
417
+ if current_user and claimed_user and claimed_user.lower() != current_user.strip().lower():
418
+ raise HTTPException(status_code=403, detail="user_email must match the authenticated user.")
419
+ owner = current_user or claimed_user or None
420
+ workspace_id = _write_workspace(args.get("workspace_id"), owner)
421
+ raw = dict(args)
422
+ raw["user_email"] = owner
423
+ raw["workspace_id"] = workspace_id
366
424
  result = ingestion_pipeline.ingest(
367
425
  IngestionItem(
368
426
  source_type="mcp_message",
369
427
  text=args.get("content") or "",
370
428
  owner=owner,
429
+ workspace_id=workspace_id,
371
430
  conversation_id=args.get("conversation_id"),
372
431
  metadata={
373
432
  "role": args.get("role") or ("assistant" if args.get("type") == "ai_response" else "user"),
374
433
  "user_nickname": args.get("user_nickname"),
375
434
  "source": args.get("source") or "mcp",
376
- "raw": args,
435
+ "raw": raw,
377
436
  },
378
437
  ),
379
438
  user_email=owner,
@@ -381,19 +440,39 @@ def create_mcp_router(
381
440
  return result.as_dict()
382
441
  if req.action == "knowledge_graph_search":
383
442
  _require_graph()
384
- return KNOWLEDGE_GRAPH.search(args.get("query") or args.get("q") or "", args.get("limit", 30))
443
+ return KNOWLEDGE_GRAPH.search(
444
+ args.get("query") or args.get("q") or "",
445
+ args.get("limit", 30),
446
+ allowed_workspaces=_allowed_graph_workspaces(current_user or None),
447
+ )
385
448
  if req.action == "knowledge_graph_graph":
386
449
  _require_graph()
387
- return KNOWLEDGE_GRAPH.graph(args.get("limit", 300))
450
+ return KNOWLEDGE_GRAPH.graph(
451
+ args.get("limit", 300),
452
+ allowed_workspaces=_allowed_graph_workspaces(current_user or None),
453
+ )
388
454
  if req.action == "knowledge_graph_context":
389
455
  _require_graph()
390
456
  return {
391
457
  "context": KNOWLEDGE_GRAPH.context_for_query(
392
458
  args.get("query") or args.get("q") or "",
393
459
  args.get("limit", 6),
460
+ allowed_workspaces=_allowed_graph_workspaces(current_user or None),
394
461
  )
395
462
  }
396
- enforce_tool_policy(req.action, req.args or {}, current_user=current_user, source="mcp")
397
- return _tool_response(execute_tool, req.action, req.args or {}, source="mcp")
463
+ dispatch_args = dict(req.args or {})
464
+ if req.action in SCOPED_KNOWLEDGE_TOOLS:
465
+ claimed_user = str(dispatch_args.get("user_email") or "").strip().lower()
466
+ if claimed_user and claimed_user != current_user.strip().lower():
467
+ raise HTTPException(status_code=403, detail="user_email must match the authenticated user.")
468
+ requested_workspace = dispatch_args.get("workspace_id")
469
+ if req.action in KNOWLEDGE_WRITE_TOOLS:
470
+ workspace_id = _write_workspace(requested_workspace, current_user)
471
+ else:
472
+ workspace_id = _read_workspace(requested_workspace, current_user)
473
+ dispatch_args["workspace_id"] = workspace_id
474
+ dispatch_args["user_email"] = current_user
475
+ enforce_tool_policy(req.action, dispatch_args, current_user=current_user, source="mcp")
476
+ return _tool_response(execute_tool, req.action, dispatch_args, source="mcp")
398
477
 
399
478
  return router