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.
- package/README.md +80 -46
- package/auto_setup.py +7 -853
- package/desktop/electron/README.md +9 -0
- package/desktop/electron/main.cjs +5 -3
- package/docs/CHANGELOG.md +146 -2
- package/docs/COMMUNITY_AND_PLUGINS.md +3 -2
- package/docs/DEVELOPMENT.md +53 -14
- package/docs/LEGACY_COMPATIBILITY.md +4 -4
- package/docs/ONBOARDING.md +10 -2
- package/docs/OPERATIONS.md +8 -4
- package/docs/PRODUCT_DIRECTION_REVIEW.md +4 -0
- package/docs/TRUST_MODEL.md +5 -2
- package/docs/WHY_LATTICE.md +15 -10
- package/docs/WORKFLOW_DESIGNER.md +22 -0
- package/docs/kg-schema.md +13 -2
- package/docs/mcp-tools.md +17 -6
- package/docs/privacy.md +19 -3
- package/docs/public-deploy.md +32 -3
- package/docs/security-model.md +46 -11
- package/lattice_brain/__init__.py +1 -1
- package/lattice_brain/archive.py +1 -6
- package/lattice_brain/context.py +66 -9
- package/lattice_brain/graph/_kg_fsutil.py +1 -5
- package/lattice_brain/graph/discovery_index.py +174 -20
- package/lattice_brain/graph/documents.py +44 -9
- package/lattice_brain/graph/ingest.py +47 -20
- package/lattice_brain/graph/provenance.py +13 -6
- package/lattice_brain/graph/retrieval.py +140 -39
- package/lattice_brain/graph/retrieval_docgen.py +37 -4
- package/lattice_brain/ingestion.py +4 -7
- package/lattice_brain/portability.py +28 -14
- package/lattice_brain/runtime/agent_runtime.py +5 -9
- package/lattice_brain/runtime/hooks.py +30 -13
- package/lattice_brain/runtime/multi_agent.py +27 -8
- package/lattice_brain/runtime/statuses.py +10 -0
- package/lattice_brain/utils.py +20 -2
- package/lattice_brain/workflow.py +1 -5
- package/latticeai/__init__.py +1 -1
- package/latticeai/api/admin.py +1 -1
- package/latticeai/api/agent_registry.py +10 -8
- package/latticeai/api/agents.py +22 -3
- package/latticeai/api/auth.py +78 -16
- package/latticeai/api/browser.py +303 -34
- package/latticeai/api/chat.py +320 -850
- package/latticeai/api/chat_agent_http.py +356 -0
- package/latticeai/api/chat_contracts.py +54 -0
- package/latticeai/api/chat_documents.py +256 -0
- package/latticeai/api/chat_helpers.py +24 -1
- package/latticeai/api/chat_history.py +99 -0
- package/latticeai/api/chat_intents.py +302 -0
- package/latticeai/api/chat_stream.py +115 -0
- package/latticeai/api/computer_use.py +62 -9
- package/latticeai/api/health.py +9 -3
- package/latticeai/api/hooks.py +17 -6
- package/latticeai/api/knowledge_graph.py +78 -14
- package/latticeai/api/local_files.py +7 -2
- package/latticeai/api/mcp.py +102 -23
- package/latticeai/api/models.py +72 -33
- package/latticeai/api/network.py +5 -5
- package/latticeai/api/permissions.py +67 -26
- package/latticeai/api/plugins.py +21 -7
- package/latticeai/api/portability.py +5 -5
- package/latticeai/api/realtime.py +33 -5
- package/latticeai/api/setup.py +2 -2
- package/latticeai/api/static_routes.py +123 -24
- package/latticeai/api/tools.py +96 -14
- package/latticeai/api/workflow_designer.py +37 -0
- package/latticeai/api/workspace.py +2 -1
- package/latticeai/app_factory.py +110 -52
- package/latticeai/core/agent.py +19 -9
- package/latticeai/core/agent_registry.py +1 -5
- package/latticeai/core/config.py +23 -3
- package/latticeai/core/context_builder.py +21 -3
- package/latticeai/core/invitations.py +1 -4
- package/latticeai/core/io_utils.py +9 -1
- package/latticeai/core/legacy_compatibility.py +24 -3
- package/latticeai/core/marketplace.py +1 -1
- package/latticeai/core/plugins.py +15 -0
- package/latticeai/core/policy.py +1 -1
- package/latticeai/core/realtime.py +38 -15
- package/latticeai/core/sessions.py +7 -2
- package/latticeai/core/timeutil.py +10 -0
- package/latticeai/core/tool_registry.py +90 -18
- package/latticeai/core/users.py +1 -5
- package/latticeai/core/workspace_graph_trace.py +31 -5
- package/latticeai/core/workspace_memory.py +3 -1
- package/latticeai/core/workspace_os.py +18 -7
- package/latticeai/core/workspace_os_utils.py +0 -5
- package/latticeai/core/workspace_permissions.py +2 -1
- package/latticeai/core/workspace_plugins.py +1 -1
- package/latticeai/core/workspace_runs.py +14 -5
- package/latticeai/core/workspace_skills.py +1 -1
- package/latticeai/core/workspace_snapshots.py +2 -1
- package/latticeai/core/workspace_timeline.py +2 -1
- package/latticeai/integrations/telegram_bot.py +94 -43
- package/latticeai/models/router.py +130 -36
- package/latticeai/runtime/access_runtime.py +62 -4
- package/latticeai/runtime/automation_runtime.py +13 -7
- package/latticeai/runtime/brain_runtime.py +19 -7
- package/latticeai/runtime/chat_wiring.py +2 -0
- package/latticeai/runtime/config_runtime.py +58 -26
- package/latticeai/runtime/context_runtime.py +16 -4
- package/latticeai/runtime/hooks_runtime.py +1 -1
- package/latticeai/runtime/model_wiring.py +33 -5
- package/latticeai/runtime/namespace_runtime.py +62 -72
- package/latticeai/runtime/platform_runtime_wiring.py +4 -3
- package/latticeai/runtime/review_wiring.py +1 -1
- package/latticeai/runtime/router_registration.py +34 -1
- package/latticeai/runtime/security_runtime.py +110 -13
- package/latticeai/runtime/stages.py +27 -0
- package/latticeai/server_app.py +5 -1
- package/latticeai/services/architecture_readiness.py +74 -5
- package/latticeai/services/brain_automation.py +3 -26
- package/latticeai/services/chat_service.py +205 -15
- package/latticeai/services/local_knowledge.py +423 -0
- package/latticeai/services/memory_service.py +55 -21
- package/latticeai/services/model_engines.py +48 -33
- package/latticeai/services/model_errors.py +17 -0
- package/latticeai/services/model_loading.py +28 -25
- package/latticeai/services/model_runtime.py +228 -162
- package/latticeai/services/p_reinforce.py +22 -3
- package/latticeai/services/platform_runtime.py +83 -23
- package/latticeai/services/product_readiness.py +19 -17
- package/latticeai/services/review_queue.py +12 -0
- package/latticeai/services/router_context.py +1 -0
- package/latticeai/services/run_executor.py +4 -9
- package/latticeai/services/search_service.py +203 -28
- package/latticeai/services/tool_dispatch.py +28 -5
- package/latticeai/services/triggers.py +53 -4
- package/latticeai/services/upload_service.py +13 -2
- package/latticeai/setup/__init__.py +25 -0
- package/latticeai/setup/auto_setup.py +857 -0
- package/latticeai/setup/wizard.py +1264 -0
- package/latticeai/tools/__init__.py +278 -0
- package/{tools → latticeai/tools}/commands.py +67 -7
- package/{tools → latticeai/tools}/computer.py +1 -1
- package/{tools → latticeai/tools}/documents.py +1 -1
- package/{tools → latticeai/tools}/filesystem.py +3 -3
- package/latticeai/tools/knowledge.py +178 -0
- package/{tools → latticeai/tools}/local_files.py +1 -1
- package/{tools → latticeai/tools}/network.py +0 -1
- package/local_knowledge_api.py +4 -342
- package/package.json +22 -2
- package/scripts/brain_quality_eval.py +3 -1
- package/scripts/bump_version.py +3 -0
- package/scripts/capture_release_evidence.mjs +180 -0
- package/scripts/check_current_release_docs.mjs +142 -0
- package/scripts/check_i18n_literals.mjs +107 -31
- package/scripts/check_openapi_drift.mjs +56 -0
- package/scripts/export_openapi.py +67 -7
- package/scripts/i18n_literal_allowlist.json +22 -24
- package/scripts/run_integration_tests.mjs +72 -10
- package/scripts/validate_release_artifacts.py +49 -7
- package/scripts/wheel_smoke.py +5 -0
- package/setup_wizard.py +3 -1260
- package/src-tauri/Cargo.lock +1 -1
- package/src-tauri/Cargo.toml +1 -1
- package/src-tauri/tauri.conf.json +1 -1
- package/static/app/asset-manifest.json +11 -11
- package/static/app/assets/Act-Bzz0bUyW.js +1 -0
- package/static/app/assets/Brain-Dj2J20YA.js +321 -0
- package/static/app/assets/Capture-CqlEl1Ga.js +1 -0
- package/static/app/assets/Library-B03FP1Yx.js +1 -0
- package/static/app/assets/System-80lHW0Ux.js +1 -0
- package/static/app/assets/index-BiMofBTM.js +17 -0
- package/static/app/assets/index-Bmx9rzTc.css +2 -0
- package/static/app/assets/primitives-Q1A96_7v.js +1 -0
- package/static/app/assets/textarea-D13RtnTo.js +1 -0
- package/static/app/index.html +2 -2
- package/static/sw.js +1 -1
- package/tools/__init__.py +21 -269
- package/docs/CODE_REVIEW_2026-07-06.md +0 -764
- package/latticeai/runtime/app_context_runtime.py +0 -13
- package/latticeai/runtime/tail_wiring.py +0 -21
- package/scripts/com.pts.claudecode.discord.plist +0 -31
- package/scripts/pts-claudecode-discord-bridge.mjs +0 -207
- package/scripts/start-pts-claudecode-discord.sh +0 -51
- package/static/app/assets/Act-21lIXx2E.js +0 -1
- package/static/app/assets/Brain-BqUd5UJJ.js +0 -321
- package/static/app/assets/Capture-BA7Z2Q1u.js +0 -1
- package/static/app/assets/Library-bFMtyni3.js +0 -1
- package/static/app/assets/System-K6krGCqn.js +0 -1
- package/static/app/assets/index-C4R3ws30.js +0 -17
- package/static/app/assets/index-ChSeOB02.css +0 -2
- package/static/app/assets/primitives-sQU3it5I.js +0 -1
- package/static/app/assets/textarea-DK3Fd_lR.js +0 -1
- package/tools/knowledge.py +0 -95
package/latticeai/api/health.py
CHANGED
|
@@ -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
|
package/latticeai/api/hooks.py
CHANGED
|
@@ -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 =
|
|
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 =
|
|
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 =
|
|
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
|
-
|
|
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 =
|
|
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 =
|
|
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
|
|
79
|
-
|
|
80
|
-
``
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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 = {
|
|
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 =
|
|
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
|
|
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
|
|
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 =
|
|
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=
|
|
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=
|
|
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=
|
|
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
|
|
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
|
|
package/latticeai/api/mcp.py
CHANGED
|
@@ -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
|
|
33
|
-
|
|
34
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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, "
|
|
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
|
-
|
|
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
|
-
|
|
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":
|
|
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(
|
|
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(
|
|
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
|
-
|
|
397
|
-
|
|
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
|