ltcai 9.0.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 (187) hide show
  1. package/README.md +80 -46
  2. package/auto_setup.py +7 -853
  3. package/desktop/electron/README.md +9 -0
  4. package/desktop/electron/main.cjs +5 -3
  5. package/docs/CHANGELOG.md +146 -2
  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/TRUST_MODEL.md +5 -2
  13. package/docs/WHY_LATTICE.md +15 -10
  14. package/docs/WORKFLOW_DESIGNER.md +22 -0
  15. package/docs/kg-schema.md +13 -2
  16. package/docs/mcp-tools.md +17 -6
  17. package/docs/privacy.md +19 -3
  18. package/docs/public-deploy.md +32 -3
  19. package/docs/security-model.md +46 -11
  20. package/lattice_brain/__init__.py +1 -1
  21. package/lattice_brain/archive.py +1 -6
  22. package/lattice_brain/context.py +66 -9
  23. package/lattice_brain/graph/_kg_fsutil.py +1 -5
  24. package/lattice_brain/graph/discovery_index.py +174 -20
  25. package/lattice_brain/graph/documents.py +44 -9
  26. package/lattice_brain/graph/ingest.py +47 -20
  27. package/lattice_brain/graph/provenance.py +13 -6
  28. package/lattice_brain/graph/retrieval.py +140 -39
  29. package/lattice_brain/graph/retrieval_docgen.py +37 -4
  30. package/lattice_brain/ingestion.py +4 -7
  31. package/lattice_brain/portability.py +28 -14
  32. package/lattice_brain/runtime/agent_runtime.py +5 -9
  33. package/lattice_brain/runtime/hooks.py +30 -13
  34. package/lattice_brain/runtime/multi_agent.py +27 -8
  35. package/lattice_brain/runtime/statuses.py +10 -0
  36. package/lattice_brain/utils.py +20 -2
  37. package/lattice_brain/workflow.py +1 -5
  38. package/latticeai/__init__.py +1 -1
  39. package/latticeai/api/admin.py +1 -1
  40. package/latticeai/api/agent_registry.py +10 -8
  41. package/latticeai/api/agents.py +22 -3
  42. package/latticeai/api/auth.py +78 -16
  43. package/latticeai/api/browser.py +303 -34
  44. package/latticeai/api/chat.py +320 -850
  45. package/latticeai/api/chat_agent_http.py +356 -0
  46. package/latticeai/api/chat_contracts.py +54 -0
  47. package/latticeai/api/chat_documents.py +256 -0
  48. package/latticeai/api/chat_helpers.py +24 -1
  49. package/latticeai/api/chat_history.py +99 -0
  50. package/latticeai/api/chat_intents.py +302 -0
  51. package/latticeai/api/chat_stream.py +115 -0
  52. package/latticeai/api/computer_use.py +62 -9
  53. package/latticeai/api/health.py +9 -3
  54. package/latticeai/api/hooks.py +17 -6
  55. package/latticeai/api/knowledge_graph.py +78 -14
  56. package/latticeai/api/local_files.py +7 -2
  57. package/latticeai/api/mcp.py +102 -23
  58. package/latticeai/api/models.py +72 -33
  59. package/latticeai/api/network.py +5 -5
  60. package/latticeai/api/permissions.py +67 -26
  61. package/latticeai/api/plugins.py +21 -7
  62. package/latticeai/api/portability.py +5 -5
  63. package/latticeai/api/realtime.py +33 -5
  64. package/latticeai/api/setup.py +2 -2
  65. package/latticeai/api/static_routes.py +123 -24
  66. package/latticeai/api/tools.py +96 -14
  67. package/latticeai/api/workflow_designer.py +37 -0
  68. package/latticeai/api/workspace.py +2 -1
  69. package/latticeai/app_factory.py +110 -52
  70. package/latticeai/core/agent.py +19 -9
  71. package/latticeai/core/agent_registry.py +1 -5
  72. package/latticeai/core/config.py +23 -3
  73. package/latticeai/core/context_builder.py +21 -3
  74. package/latticeai/core/invitations.py +1 -4
  75. package/latticeai/core/io_utils.py +9 -1
  76. package/latticeai/core/legacy_compatibility.py +24 -3
  77. package/latticeai/core/marketplace.py +1 -1
  78. package/latticeai/core/plugins.py +15 -0
  79. package/latticeai/core/policy.py +1 -1
  80. package/latticeai/core/realtime.py +38 -15
  81. package/latticeai/core/sessions.py +7 -2
  82. package/latticeai/core/timeutil.py +10 -0
  83. package/latticeai/core/tool_registry.py +90 -18
  84. package/latticeai/core/users.py +1 -5
  85. package/latticeai/core/workspace_graph_trace.py +31 -5
  86. package/latticeai/core/workspace_memory.py +3 -1
  87. package/latticeai/core/workspace_os.py +18 -7
  88. package/latticeai/core/workspace_os_utils.py +0 -5
  89. package/latticeai/core/workspace_permissions.py +2 -1
  90. package/latticeai/core/workspace_plugins.py +1 -1
  91. package/latticeai/core/workspace_runs.py +14 -5
  92. package/latticeai/core/workspace_skills.py +1 -1
  93. package/latticeai/core/workspace_snapshots.py +2 -1
  94. package/latticeai/core/workspace_timeline.py +2 -1
  95. package/latticeai/integrations/telegram_bot.py +94 -43
  96. package/latticeai/models/router.py +130 -36
  97. package/latticeai/runtime/access_runtime.py +62 -4
  98. package/latticeai/runtime/automation_runtime.py +13 -7
  99. package/latticeai/runtime/brain_runtime.py +19 -7
  100. package/latticeai/runtime/chat_wiring.py +2 -0
  101. package/latticeai/runtime/config_runtime.py +58 -26
  102. package/latticeai/runtime/context_runtime.py +16 -4
  103. package/latticeai/runtime/hooks_runtime.py +1 -1
  104. package/latticeai/runtime/model_wiring.py +33 -5
  105. package/latticeai/runtime/namespace_runtime.py +62 -72
  106. package/latticeai/runtime/platform_runtime_wiring.py +4 -3
  107. package/latticeai/runtime/review_wiring.py +1 -1
  108. package/latticeai/runtime/router_registration.py +34 -1
  109. package/latticeai/runtime/security_runtime.py +110 -13
  110. package/latticeai/runtime/stages.py +27 -0
  111. package/latticeai/server_app.py +5 -1
  112. package/latticeai/services/architecture_readiness.py +74 -5
  113. package/latticeai/services/brain_automation.py +3 -26
  114. package/latticeai/services/chat_service.py +205 -15
  115. package/latticeai/services/local_knowledge.py +423 -0
  116. package/latticeai/services/memory_service.py +55 -21
  117. package/latticeai/services/model_engines.py +48 -33
  118. package/latticeai/services/model_errors.py +17 -0
  119. package/latticeai/services/model_loading.py +28 -25
  120. package/latticeai/services/model_runtime.py +228 -162
  121. package/latticeai/services/p_reinforce.py +22 -3
  122. package/latticeai/services/platform_runtime.py +83 -23
  123. package/latticeai/services/product_readiness.py +19 -17
  124. package/latticeai/services/review_queue.py +12 -0
  125. package/latticeai/services/router_context.py +1 -0
  126. package/latticeai/services/run_executor.py +4 -9
  127. package/latticeai/services/search_service.py +203 -28
  128. package/latticeai/services/tool_dispatch.py +28 -5
  129. package/latticeai/services/triggers.py +53 -4
  130. package/latticeai/services/upload_service.py +13 -2
  131. package/latticeai/setup/__init__.py +25 -0
  132. package/latticeai/setup/auto_setup.py +857 -0
  133. package/latticeai/setup/wizard.py +1264 -0
  134. package/latticeai/tools/__init__.py +278 -0
  135. package/{tools → latticeai/tools}/commands.py +67 -7
  136. package/{tools → latticeai/tools}/computer.py +1 -1
  137. package/{tools → latticeai/tools}/documents.py +1 -1
  138. package/{tools → latticeai/tools}/filesystem.py +3 -3
  139. package/latticeai/tools/knowledge.py +178 -0
  140. package/{tools → latticeai/tools}/local_files.py +1 -1
  141. package/{tools → latticeai/tools}/network.py +0 -1
  142. package/local_knowledge_api.py +4 -342
  143. package/package.json +22 -2
  144. package/scripts/brain_quality_eval.py +3 -1
  145. package/scripts/bump_version.py +3 -0
  146. package/scripts/capture_release_evidence.mjs +180 -0
  147. package/scripts/check_current_release_docs.mjs +142 -0
  148. package/scripts/check_i18n_literals.mjs +107 -31
  149. package/scripts/check_openapi_drift.mjs +56 -0
  150. package/scripts/export_openapi.py +67 -7
  151. package/scripts/i18n_literal_allowlist.json +22 -24
  152. package/scripts/run_integration_tests.mjs +72 -10
  153. package/scripts/validate_release_artifacts.py +49 -7
  154. package/scripts/wheel_smoke.py +5 -0
  155. package/setup_wizard.py +3 -1260
  156. package/src-tauri/Cargo.lock +1 -1
  157. package/src-tauri/Cargo.toml +1 -1
  158. package/src-tauri/tauri.conf.json +1 -1
  159. package/static/app/asset-manifest.json +11 -11
  160. package/static/app/assets/Act-Bzz0bUyW.js +1 -0
  161. package/static/app/assets/Brain-Dj2J20YA.js +321 -0
  162. package/static/app/assets/Capture-CqlEl1Ga.js +1 -0
  163. package/static/app/assets/Library-B03FP1Yx.js +1 -0
  164. package/static/app/assets/System-80lHW0Ux.js +1 -0
  165. package/static/app/assets/index-BiMofBTM.js +17 -0
  166. package/static/app/assets/index-Bmx9rzTc.css +2 -0
  167. package/static/app/assets/primitives-Q1A96_7v.js +1 -0
  168. package/static/app/assets/textarea-D13RtnTo.js +1 -0
  169. package/static/app/index.html +2 -2
  170. package/static/sw.js +1 -1
  171. package/tools/__init__.py +21 -269
  172. package/docs/CODE_REVIEW_2026-07-06.md +0 -764
  173. package/latticeai/runtime/app_context_runtime.py +0 -13
  174. package/latticeai/runtime/tail_wiring.py +0 -21
  175. package/scripts/com.pts.claudecode.discord.plist +0 -31
  176. package/scripts/pts-claudecode-discord-bridge.mjs +0 -207
  177. package/scripts/start-pts-claudecode-discord.sh +0 -51
  178. package/static/app/assets/Act-21lIXx2E.js +0 -1
  179. package/static/app/assets/Brain-BqUd5UJJ.js +0 -321
  180. package/static/app/assets/Capture-BA7Z2Q1u.js +0 -1
  181. package/static/app/assets/Library-bFMtyni3.js +0 -1
  182. package/static/app/assets/System-K6krGCqn.js +0 -1
  183. package/static/app/assets/index-C4R3ws30.js +0 -17
  184. package/static/app/assets/index-ChSeOB02.css +0 -2
  185. package/static/app/assets/primitives-sQU3it5I.js +0 -1
  186. package/static/app/assets/textarea-DK3Fd_lR.js +0 -1
  187. package/tools/knowledge.py +0 -95
@@ -10,7 +10,7 @@ from __future__ import annotations
10
10
  import asyncio
11
11
  from typing import Callable, List, Optional
12
12
 
13
- from fastapi import APIRouter, Request
13
+ from fastapi import APIRouter, HTTPException, Request
14
14
 
15
15
 
16
16
  def create_health_router(
@@ -25,6 +25,10 @@ def create_health_router(
25
25
  router = APIRouter()
26
26
  svc = model_service
27
27
 
28
+ def _require_sensitive_status_access(request: Request) -> None:
29
+ if require_auth and not get_current_user(request):
30
+ raise HTTPException(status_code=401, detail="인증이 필요합니다.")
31
+
28
32
  @router.get("/health")
29
33
  async def health(request: Request):
30
34
  base = svc.health_base(version=app_version, mode=app_mode)
@@ -35,11 +39,13 @@ def create_health_router(
35
39
 
36
40
  @router.get("/mode")
37
41
  @router.get("/runtime_features")
38
- async def mode():
42
+ async def mode(request: Request):
43
+ _require_sensitive_status_access(request)
39
44
  return svc.runtime()
40
45
 
41
46
  @router.get("/engines")
42
- async def engines():
47
+ async def engines(request: Request):
48
+ _require_sensitive_status_access(request)
43
49
  return svc.engines_payload(await asyncio.to_thread(engine_status))
44
50
 
45
51
  return router
@@ -46,10 +46,21 @@ def create_hooks_router(
46
46
  *,
47
47
  registry: HooksRegistry,
48
48
  require_user: Callable[[Request], str],
49
+ require_admin: Callable[[Request], tuple],
49
50
  append_audit_event: Callable[..., None],
50
51
  ) -> APIRouter:
51
52
  router = APIRouter()
52
53
 
54
+ def _require_admin_email(request: Request) -> str:
55
+ """Authorize a hook mutation and return the acting administrator."""
56
+ result = require_admin(request)
57
+ if isinstance(result, tuple):
58
+ return str(result[0] or "")
59
+ # Keep the router tolerant of small test/runtime adapters that return
60
+ # only the identity while the production access runtime returns
61
+ # ``(email, users)``.
62
+ return str(result or "")
63
+
53
64
  @router.get("/api/hooks")
54
65
  async def list_hooks(request: Request, kind: Optional[str] = None):
55
66
  require_user(request)
@@ -67,7 +78,7 @@ def create_hooks_router(
67
78
  """Execute hooks now — by ``kind`` (all enabled hooks of that kind) or a
68
79
  single ``hook_id``. Returns the dispatch record so callers can see what
69
80
  ran, in what order, and whether the action was blocked."""
70
- user = require_user(request)
81
+ user = _require_admin_email(request)
71
82
  try:
72
83
  if req.hook_id:
73
84
  result = registry.run_hook(req.hook_id, event=req.event or None, payload=req.payload, user_email=user)
@@ -97,7 +108,7 @@ def create_hooks_router(
97
108
 
98
109
  @router.post("/api/hooks/enable")
99
110
  async def enable_hook(req: HookToggleRequest, request: Request):
100
- user = require_user(request)
111
+ user = _require_admin_email(request)
101
112
  try:
102
113
  hook = registry.set_enabled(req.hook_id, req.enabled)
103
114
  except KeyError as exc:
@@ -107,7 +118,7 @@ def create_hooks_router(
107
118
 
108
119
  @router.post("/api/hooks/disable")
109
120
  async def disable_hook(req: HookToggleRequest, request: Request):
110
- user = require_user(request)
121
+ user = _require_admin_email(request)
111
122
  try:
112
123
  hook = registry.set_enabled(req.hook_id, False)
113
124
  except KeyError as exc:
@@ -117,12 +128,12 @@ def create_hooks_router(
117
128
 
118
129
  @router.post("/api/hooks/reorder")
119
130
  async def reorder_hooks(req: HookReorderRequest, request: Request):
120
- require_user(request)
131
+ _require_admin_email(request)
121
132
  return registry.reorder(req.kind, req.ordered_ids)
122
133
 
123
134
  @router.post("/api/hooks/register")
124
135
  async def register_hook(req: HookRegisterRequest, request: Request):
125
- user = require_user(request)
136
+ user = _require_admin_email(request)
126
137
  try:
127
138
  entry = registry.register(
128
139
  name=req.name,
@@ -139,7 +150,7 @@ def create_hooks_router(
139
150
 
140
151
  @router.delete("/api/hooks/{hook_id:path}")
141
152
  async def remove_hook(hook_id: str, request: Request):
142
- user = require_user(request)
153
+ user = _require_admin_email(request)
143
154
  try:
144
155
  result = registry.remove(hook_id)
145
156
  except KeyError as exc:
@@ -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
 
@@ -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