ltcai 8.8.0 → 8.9.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 +34 -27
- package/auto_setup.py +73 -8
- package/docs/CHANGELOG.md +37 -0
- package/docs/CODE_REVIEW_2026-07-06.md +764 -0
- package/docs/COMMUNITY_AND_PLUGINS.md +3 -3
- package/docs/DEVELOPMENT.md +10 -10
- package/docs/LEGACY_COMPATIBILITY.md +1 -1
- package/docs/ONBOARDING.md +2 -2
- package/docs/PRODUCT_DIRECTION_REVIEW.md +3 -2
- package/docs/TRUST_MODEL.md +5 -1
- package/docs/WHY_LATTICE.md +4 -3
- package/docs/architecture.md +4 -0
- package/docs/kg-schema.md +1 -1
- package/lattice_brain/__init__.py +1 -1
- package/lattice_brain/conversations.py +156 -21
- package/lattice_brain/graph/_kg_common.py +12 -34
- package/lattice_brain/graph/json_utils.py +25 -0
- package/lattice_brain/graph/retrieval.py +66 -27
- package/lattice_brain/graph/runtime.py +16 -0
- package/lattice_brain/runtime/multi_agent.py +1 -1
- package/latticeai/__init__.py +1 -1
- package/latticeai/api/chat.py +31 -14
- package/latticeai/api/mcp.py +3 -2
- package/latticeai/api/models.py +4 -1
- package/latticeai/api/permissions.py +69 -30
- package/latticeai/api/setup.py +17 -2
- package/latticeai/api/tools.py +104 -62
- package/latticeai/app_factory.py +93 -10
- package/latticeai/core/agent.py +25 -7
- package/latticeai/core/legacy_compatibility.py +1 -1
- package/latticeai/core/marketplace.py +1 -1
- package/latticeai/core/sessions.py +11 -3
- package/latticeai/core/tool_registry.py +15 -4
- package/latticeai/core/workspace_os.py +1 -1
- package/latticeai/runtime/bootstrap.py +1 -1
- package/latticeai/services/app_context.py +1 -0
- package/latticeai/services/architecture_readiness.py +2 -2
- package/latticeai/services/model_engines.py +79 -12
- package/latticeai/services/model_runtime.py +24 -4
- package/latticeai/services/process_audit.py +208 -0
- package/latticeai/services/product_readiness.py +11 -11
- package/latticeai/services/search_service.py +106 -30
- package/latticeai/services/tool_dispatch.py +66 -0
- package/latticeai/services/workspace_service.py +15 -0
- package/package.json +1 -1
- package/scripts/check_i18n_literals.mjs +20 -8
- package/scripts/i18n_literal_allowlist.json +34 -0
- package/scripts/lint_frontend.mjs +6 -2
- package/setup_wizard.py +185 -19
- 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 +10 -10
- package/static/app/assets/{Act-C7K9wsO9.js → Act-fZokUnC0.js} +1 -1
- package/static/app/assets/{Brain-I1OSzxJu.js → Brain-DtyuWubr.js} +1 -1
- package/static/app/assets/{Capture-B3V4_5Xp.js → Capture-D5KV3Cu7.js} +1 -1
- package/static/app/assets/{Library-Cgj-EF50.js → Library-C9kyFkSt.js} +1 -1
- package/static/app/assets/{System-D1Lkei3I.js → System-VbChmX7r.js} +1 -1
- package/static/app/assets/{index--P0ksosz.js → index-DPdcPoF0.js} +5 -5
- package/static/app/assets/{primitives-BLqaKk5g.js → primitives-DFeanEV6.js} +1 -1
- package/static/app/assets/{textarea-CVQkN2Tk.js → textarea-CD8UNKIy.js} +1 -1
- package/static/app/index.html +1 -1
- package/static/sw.js +1 -1
|
@@ -55,9 +55,18 @@ class KnowledgeGraphRetrievalMixin:
|
|
|
55
55
|
extracted = meta.get("extracted") or {}
|
|
56
56
|
node_id = row["id"]
|
|
57
57
|
chunk_count = conn.execute(
|
|
58
|
-
|
|
59
|
-
(
|
|
58
|
+
"SELECT COUNT(*) AS c FROM chunks WHERE source_node=?",
|
|
59
|
+
(node_id,),
|
|
60
60
|
).fetchone()["c"]
|
|
61
|
+
if not chunk_count:
|
|
62
|
+
# Legacy projections represented chunks as graph nodes and
|
|
63
|
+
# linked them only through metadata_json. Keep read
|
|
64
|
+
# compatibility without making the fragile LIKE path the
|
|
65
|
+
# primary query.
|
|
66
|
+
chunk_count = conn.execute(
|
|
67
|
+
f"SELECT COUNT(*) AS c FROM {nt} WHERE type='Chunk' AND metadata_json LIKE ?",
|
|
68
|
+
(f"%{node_id}%",),
|
|
69
|
+
).fetchone()["c"]
|
|
61
70
|
documents.append(
|
|
62
71
|
{
|
|
63
72
|
"id": node_id,
|
|
@@ -255,7 +264,7 @@ class KnowledgeGraphRetrievalMixin:
|
|
|
255
264
|
node.pop("_raw_importance", None)
|
|
256
265
|
return {"nodes": nodes, "edges": edges}
|
|
257
266
|
|
|
258
|
-
def search(self, query: str, limit: int = 30) -> Dict[str, Any]:
|
|
267
|
+
def search(self, query: str, limit: int = 30, *, allowed_workspaces=None) -> Dict[str, Any]:
|
|
259
268
|
query = str(query or "").strip()
|
|
260
269
|
q = f"%{query}%"
|
|
261
270
|
limit = max(1, min(int(limit or 30), 100))
|
|
@@ -347,9 +356,7 @@ class KnowledgeGraphRetrievalMixin:
|
|
|
347
356
|
# the legacy LIKE path regardless of FTS bm25 tie ordering.
|
|
348
357
|
rows = sorted(rows, key=lambda r: r["id"])
|
|
349
358
|
rows = sorted(rows, key=score, reverse=True)[:limit]
|
|
350
|
-
|
|
351
|
-
"query": query,
|
|
352
|
-
"matches": [
|
|
359
|
+
matches = [
|
|
353
360
|
{
|
|
354
361
|
"id": row["id"],
|
|
355
362
|
"type": row["type"],
|
|
@@ -359,15 +366,17 @@ class KnowledgeGraphRetrievalMixin:
|
|
|
359
366
|
"updated_at": row["updated_at"],
|
|
360
367
|
}
|
|
361
368
|
for row in rows
|
|
362
|
-
]
|
|
363
|
-
|
|
369
|
+
]
|
|
370
|
+
if allowed_workspaces is not None:
|
|
371
|
+
matches = self.filter_scoped_nodes(matches, allowed_workspaces)
|
|
372
|
+
return {"query": query, "matches": matches}
|
|
364
373
|
|
|
365
|
-
def context_for_query(self, query: str, limit: int = 6) -> str:
|
|
374
|
+
def context_for_query(self, query: str, limit: int = 6, *, allowed_workspaces=None) -> str:
|
|
366
375
|
"""Return compact graph-backed RAG context for chat generation."""
|
|
367
376
|
query = str(query or "").strip()
|
|
368
377
|
if not query:
|
|
369
378
|
return ""
|
|
370
|
-
matches = self.search(query, limit).get("matches", [])
|
|
379
|
+
matches = self.search(query, limit, allowed_workspaces=allowed_workspaces).get("matches", [])
|
|
371
380
|
if not matches:
|
|
372
381
|
topics = _topic_candidates(query, limit=4)
|
|
373
382
|
if topics:
|
|
@@ -404,6 +413,8 @@ class KnowledgeGraphRetrievalMixin:
|
|
|
404
413
|
)
|
|
405
414
|
if len(matches) >= limit:
|
|
406
415
|
break
|
|
416
|
+
if allowed_workspaces is not None:
|
|
417
|
+
matches = self.filter_scoped_nodes(matches, allowed_workspaces)
|
|
407
418
|
lines = []
|
|
408
419
|
for match in matches[:limit]:
|
|
409
420
|
meta = match.get("metadata") or {}
|
|
@@ -420,8 +431,10 @@ class KnowledgeGraphRetrievalMixin:
|
|
|
420
431
|
)
|
|
421
432
|
return "\n".join(lines)
|
|
422
433
|
|
|
423
|
-
def neighbors(self, node_id: str) -> Dict[str, Any]:
|
|
434
|
+
def neighbors(self, node_id: str, *, allowed_workspaces=None) -> Dict[str, Any]:
|
|
424
435
|
"""Return direct neighbors (1-hop) of a node."""
|
|
436
|
+
if allowed_workspaces is not None and not self.filter_scoped_nodes([{"id": node_id}], allowed_workspaces):
|
|
437
|
+
raise ValueError(f"graph node not found: {node_id}")
|
|
425
438
|
nt, et = self._read_tables()
|
|
426
439
|
with self._connect() as conn:
|
|
427
440
|
edge_rows = conn.execute(
|
|
@@ -458,9 +471,17 @@ class KnowledgeGraphRetrievalMixin:
|
|
|
458
471
|
list(neighbor_ids),
|
|
459
472
|
)
|
|
460
473
|
]
|
|
474
|
+
if allowed_workspaces is not None:
|
|
475
|
+
nodes = self.filter_scoped_nodes(nodes, allowed_workspaces)
|
|
476
|
+
kept = {node.get("id") for node in nodes}
|
|
477
|
+
edges = [
|
|
478
|
+
edge for edge in edges
|
|
479
|
+
if (edge.get("from") == node_id or edge.get("from") in kept)
|
|
480
|
+
and (edge.get("to") == node_id or edge.get("to") in kept)
|
|
481
|
+
]
|
|
461
482
|
return {"node_id": node_id, "neighbors": nodes, "edges": edges}
|
|
462
483
|
|
|
463
|
-
def get_node(self, node_id: str) -> Dict[str, Any]:
|
|
484
|
+
def get_node(self, node_id: str, *, allowed_workspaces=None) -> Dict[str, Any]:
|
|
464
485
|
node_id = str(node_id or "").strip()
|
|
465
486
|
if not node_id:
|
|
466
487
|
raise ValueError("node_id required")
|
|
@@ -480,7 +501,7 @@ class KnowledgeGraphRetrievalMixin:
|
|
|
480
501
|
f"SELECT COUNT(*) AS c FROM {et} WHERE from_node=? OR to_node=?",
|
|
481
502
|
(node_id, node_id),
|
|
482
503
|
).fetchone()["c"]
|
|
483
|
-
|
|
504
|
+
node = {
|
|
484
505
|
"id": row["id"],
|
|
485
506
|
"type": row["type"],
|
|
486
507
|
"title": row["title"],
|
|
@@ -489,6 +510,9 @@ class KnowledgeGraphRetrievalMixin:
|
|
|
489
510
|
"updated_at": row["updated_at"],
|
|
490
511
|
"degree": degree,
|
|
491
512
|
}
|
|
513
|
+
if allowed_workspaces is not None and not self.filter_scoped_nodes([node], allowed_workspaces):
|
|
514
|
+
raise ValueError(f"graph node not found: {node_id}")
|
|
515
|
+
return node
|
|
492
516
|
|
|
493
517
|
def relationship_search(
|
|
494
518
|
self,
|
|
@@ -497,6 +521,7 @@ class KnowledgeGraphRetrievalMixin:
|
|
|
497
521
|
node_id: str = "",
|
|
498
522
|
relationship_type: str = "",
|
|
499
523
|
limit: int = 30,
|
|
524
|
+
allowed_workspaces=None,
|
|
500
525
|
) -> Dict[str, Any]:
|
|
501
526
|
query = str(query or "").strip()
|
|
502
527
|
node_id = str(node_id or "").strip()
|
|
@@ -535,11 +560,7 @@ class KnowledgeGraphRetrievalMixin:
|
|
|
535
560
|
""",
|
|
536
561
|
(*params, limit),
|
|
537
562
|
).fetchall()
|
|
538
|
-
|
|
539
|
-
"query": query,
|
|
540
|
-
"node_id": node_id,
|
|
541
|
-
"relationship_type": relationship_type,
|
|
542
|
-
"relationships": [
|
|
563
|
+
relationships = [
|
|
543
564
|
{
|
|
544
565
|
"id": row["id"],
|
|
545
566
|
"type": row["type"],
|
|
@@ -562,15 +583,32 @@ class KnowledgeGraphRetrievalMixin:
|
|
|
562
583
|
},
|
|
563
584
|
}
|
|
564
585
|
for row in rows
|
|
565
|
-
]
|
|
586
|
+
]
|
|
587
|
+
if allowed_workspaces is not None:
|
|
588
|
+
kept = []
|
|
589
|
+
for rel in relationships:
|
|
590
|
+
endpoints = [
|
|
591
|
+
{"id": (rel.get("source") or {}).get("id")},
|
|
592
|
+
{"id": (rel.get("target") or {}).get("id")},
|
|
593
|
+
]
|
|
594
|
+
if len(self.filter_scoped_nodes(endpoints, allowed_workspaces)) == 2:
|
|
595
|
+
kept.append(rel)
|
|
596
|
+
relationships = kept
|
|
597
|
+
return {
|
|
598
|
+
"query": query,
|
|
599
|
+
"node_id": node_id,
|
|
600
|
+
"relationship_type": relationship_type,
|
|
601
|
+
"relationships": relationships,
|
|
566
602
|
}
|
|
567
603
|
|
|
568
604
|
def traverse(
|
|
569
|
-
self, node_id: str, *, depth: int = 1, limit: int = 100
|
|
605
|
+
self, node_id: str, *, depth: int = 1, limit: int = 100, allowed_workspaces=None
|
|
570
606
|
) -> Dict[str, Any]:
|
|
571
607
|
node_id = str(node_id or "").strip()
|
|
572
608
|
if not node_id:
|
|
573
609
|
raise ValueError("node_id required")
|
|
610
|
+
if allowed_workspaces is not None and not self.filter_scoped_nodes([{"id": node_id}], allowed_workspaces):
|
|
611
|
+
raise ValueError(f"graph node not found: {node_id}")
|
|
574
612
|
depth = max(0, min(int(depth or 1), 4))
|
|
575
613
|
limit = max(1, min(int(limit or 100), 500))
|
|
576
614
|
nt, et = self._read_tables()
|
|
@@ -617,10 +655,7 @@ class KnowledgeGraphRetrievalMixin:
|
|
|
617
655
|
""",
|
|
618
656
|
list(visited),
|
|
619
657
|
).fetchall()
|
|
620
|
-
|
|
621
|
-
"root": node_id,
|
|
622
|
-
"depth": depth,
|
|
623
|
-
"nodes": [
|
|
658
|
+
nodes = [
|
|
624
659
|
{
|
|
625
660
|
"id": row["id"],
|
|
626
661
|
"type": row["type"],
|
|
@@ -630,9 +665,13 @@ class KnowledgeGraphRetrievalMixin:
|
|
|
630
665
|
"updated_at": row["updated_at"],
|
|
631
666
|
}
|
|
632
667
|
for row in node_rows
|
|
633
|
-
]
|
|
634
|
-
|
|
635
|
-
|
|
668
|
+
]
|
|
669
|
+
edges = list(edges_by_id.values())
|
|
670
|
+
if allowed_workspaces is not None:
|
|
671
|
+
nodes = self.filter_scoped_nodes(nodes, allowed_workspaces)
|
|
672
|
+
kept = {node.get("id") for node in nodes}
|
|
673
|
+
edges = [edge for edge in edges if edge.get("from") in kept and edge.get("to") in kept]
|
|
674
|
+
return {"root": node_id, "depth": depth, "nodes": nodes, "edges": edges}
|
|
636
675
|
|
|
637
676
|
def _iter_vector_source_items(
|
|
638
677
|
self,
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
"""Runtime hooks for the knowledge graph package."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import Any
|
|
6
|
+
|
|
7
|
+
_llm_router_ref: Any = None
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def set_llm_router(router_instance: Any) -> None:
|
|
11
|
+
global _llm_router_ref
|
|
12
|
+
_llm_router_ref = router_instance
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def get_llm_router() -> Any:
|
|
16
|
+
return _llm_router_ref
|
|
@@ -21,7 +21,7 @@ from typing import Any, Callable, Dict, List, Optional
|
|
|
21
21
|
from .contracts import multi_agent_contract
|
|
22
22
|
|
|
23
23
|
|
|
24
|
-
MULTI_AGENT_VERSION = "8.
|
|
24
|
+
MULTI_AGENT_VERSION = "8.9.0"
|
|
25
25
|
|
|
26
26
|
AGENT_ROLES = ("researcher", "planner", "executor", "reviewer", "release")
|
|
27
27
|
CORE_PIPELINE = ("planner", "executor", "reviewer")
|
package/latticeai/__init__.py
CHANGED
package/latticeai/api/chat.py
CHANGED
|
@@ -286,6 +286,7 @@ def create_chat_router(context: AppContext) -> APIRouter:
|
|
|
286
286
|
group_history_conversations = context.group_history_conversations
|
|
287
287
|
get_conversation_messages = context.get_conversation_messages
|
|
288
288
|
conversation_title = context.conversation_title
|
|
289
|
+
allowed_workspaces_for = context.allowed_workspaces_for
|
|
289
290
|
|
|
290
291
|
CONFIG = context.config
|
|
291
292
|
CHAT_SERVICE = context.chat_service
|
|
@@ -314,6 +315,18 @@ def create_chat_router(context: AppContext) -> APIRouter:
|
|
|
314
315
|
except Exception as exc:
|
|
315
316
|
logging.warning("chat message bridge failed: %s", exc)
|
|
316
317
|
|
|
318
|
+
def history_scope_for_user(user_email: Optional[str]) -> Dict:
|
|
319
|
+
require_auth = bool(getattr(CONFIG, "require_auth", False))
|
|
320
|
+
scoped_user = user_email if require_auth else None
|
|
321
|
+
allowed = None
|
|
322
|
+
if require_auth and scoped_user and allowed_workspaces_for is not None:
|
|
323
|
+
allowed = allowed_workspaces_for(scoped_user)
|
|
324
|
+
return {
|
|
325
|
+
"user_email": scoped_user,
|
|
326
|
+
"allowed_workspaces": allowed,
|
|
327
|
+
"include_legacy_global": not require_auth,
|
|
328
|
+
}
|
|
329
|
+
|
|
317
330
|
def recent_chat_context(
|
|
318
331
|
limit: int = 10,
|
|
319
332
|
include_image_missing_replies: bool = True,
|
|
@@ -449,14 +462,14 @@ def create_chat_router(context: AppContext) -> APIRouter:
|
|
|
449
462
|
except Exception as e:
|
|
450
463
|
logging.warning("knowledge graph clear event ingest failed: %s", e)
|
|
451
464
|
if command == "/clear_all":
|
|
452
|
-
result = clear_history(0)
|
|
465
|
+
result = clear_history(0, **history_scope_for_user(effective_email))
|
|
453
466
|
answer = f"채팅창을 정리했습니다. 화면에서 제거 {result.get('removed', 0)}개. 감사 로그와 지식 그래프/RAG 데이터는 유지됩니다."
|
|
454
467
|
else:
|
|
455
468
|
if req.conversation_id:
|
|
456
|
-
result = clear_conversation(req.conversation_id)
|
|
469
|
+
result = clear_conversation(req.conversation_id, **history_scope_for_user(effective_email))
|
|
457
470
|
answer = f"현재 대화방 채팅창을 정리했습니다. 화면에서 제거 {result.get('removed', 0)}개. 감사 로그와 지식 그래프/RAG 데이터는 유지됩니다."
|
|
458
471
|
else:
|
|
459
|
-
result = clear_history(0)
|
|
472
|
+
result = clear_history(0, **history_scope_for_user(effective_email))
|
|
460
473
|
answer = f"채팅창을 정리했습니다. 화면에서 제거 {result.get('removed', 0)}개. 감사 로그와 지식 그래프/RAG 데이터는 유지됩니다."
|
|
461
474
|
append_audit_event(
|
|
462
475
|
"clear_command",
|
|
@@ -791,20 +804,20 @@ def create_chat_router(context: AppContext) -> APIRouter:
|
|
|
791
804
|
@api_router.get("/history")
|
|
792
805
|
async def fetch_history(request: Request):
|
|
793
806
|
"""웹 화면에서 이전 대화를 불러올 수 있도록 히스토리를 반환합니다."""
|
|
794
|
-
require_user(request)
|
|
795
|
-
return get_history()
|
|
807
|
+
current_user = require_user(request)
|
|
808
|
+
return get_history(**history_scope_for_user(current_user))
|
|
796
809
|
|
|
797
810
|
@api_router.get("/history/conversations")
|
|
798
811
|
async def fetch_history_conversations(request: Request):
|
|
799
812
|
"""저장된 히스토리를 대화 단위로 묶어 반환합니다."""
|
|
800
|
-
require_user(request)
|
|
801
|
-
return group_history_conversations()
|
|
813
|
+
current_user = require_user(request)
|
|
814
|
+
return group_history_conversations(get_history(**history_scope_for_user(current_user)))
|
|
802
815
|
|
|
803
816
|
@api_router.get("/history/conversations/{conversation_id:path}")
|
|
804
817
|
async def fetch_history_conversation(conversation_id: str, request: Request):
|
|
805
818
|
"""선택한 대화의 메시지를 반환합니다."""
|
|
806
|
-
require_user(request)
|
|
807
|
-
messages = get_conversation_messages(conversation_id)
|
|
819
|
+
current_user = require_user(request)
|
|
820
|
+
messages = get_conversation_messages(conversation_id, **history_scope_for_user(current_user))
|
|
808
821
|
if not messages:
|
|
809
822
|
raise HTTPException(status_code=404, detail="대화를 찾을 수 없습니다.")
|
|
810
823
|
return {"id": conversation_id, "messages": messages}
|
|
@@ -814,7 +827,11 @@ def create_chat_router(context: AppContext) -> APIRouter:
|
|
|
814
827
|
async def delete_history_conversation(conversation_id: str, request: Request):
|
|
815
828
|
"""선택한 대화방의 메시지만 삭제합니다."""
|
|
816
829
|
email = require_user(request)
|
|
817
|
-
result = clear_conversation(
|
|
830
|
+
result = clear_conversation(
|
|
831
|
+
conversation_id,
|
|
832
|
+
request.query_params.get("started_at"),
|
|
833
|
+
**history_scope_for_user(email),
|
|
834
|
+
)
|
|
818
835
|
append_audit_event(
|
|
819
836
|
"conversation_delete",
|
|
820
837
|
user_email=email,
|
|
@@ -829,7 +846,7 @@ def create_chat_router(context: AppContext) -> APIRouter:
|
|
|
829
846
|
@api_router.delete("/history")
|
|
830
847
|
async def delete_history(request: Request, keep_last: int = 0):
|
|
831
848
|
email = require_user(request)
|
|
832
|
-
result = clear_history(keep_last)
|
|
849
|
+
result = clear_history(keep_last, **history_scope_for_user(email))
|
|
833
850
|
append_audit_event(
|
|
834
851
|
"history_delete",
|
|
835
852
|
user_email=email,
|
|
@@ -842,11 +859,11 @@ def create_chat_router(context: AppContext) -> APIRouter:
|
|
|
842
859
|
@api_router.get("/history/search")
|
|
843
860
|
async def search_history(q: str, request: Request):
|
|
844
861
|
"""키워드로 채팅 히스토리를 검색합니다."""
|
|
845
|
-
require_user(request)
|
|
862
|
+
current_user = require_user(request)
|
|
846
863
|
if not q or not q.strip():
|
|
847
864
|
return {"results": [], "query": q}
|
|
848
865
|
q_lower = q.strip().lower()
|
|
849
|
-
history = get_history()
|
|
866
|
+
history = get_history(**history_scope_for_user(current_user))
|
|
850
867
|
matches = [item for item in history if q_lower in (item.get("content") or "").lower()]
|
|
851
868
|
grouped: Dict[str, Dict] = {}
|
|
852
869
|
for item in matches:
|
|
@@ -991,7 +1008,7 @@ def create_chat_router(context: AppContext) -> APIRouter:
|
|
|
991
1008
|
}
|
|
992
1009
|
|
|
993
1010
|
# Auto-approve and run to completion (default behaviour)
|
|
994
|
-
_AGENT_RUNTIME.approve(ctx, current_user)
|
|
1011
|
+
_AGENT_RUNTIME.approve(ctx, current_user, approved_by_human=True)
|
|
995
1012
|
return await _agent_finish(ctx, req, lang_hint, current_user, max_steps, max_retry)
|
|
996
1013
|
|
|
997
1014
|
|
package/latticeai/api/mcp.py
CHANGED
|
@@ -30,6 +30,7 @@ from latticeai.core.mcp_registry import (
|
|
|
30
30
|
SKILLS_DIR,
|
|
31
31
|
)
|
|
32
32
|
from latticeai.core.tool_registry import MCP_TOOL_DESCRIPTIONS
|
|
33
|
+
from latticeai.services.tool_dispatch import enforce_tool_policy
|
|
33
34
|
from tools import AGENT_ROOT, execute_tool
|
|
34
35
|
|
|
35
36
|
|
|
@@ -392,7 +393,7 @@ def create_mcp_router(
|
|
|
392
393
|
args.get("limit", 6),
|
|
393
394
|
)
|
|
394
395
|
}
|
|
395
|
-
|
|
396
|
-
return _tool_response(execute_tool, req.action, req.args or {})
|
|
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")
|
|
397
398
|
|
|
398
399
|
return router
|
package/latticeai/api/models.py
CHANGED
|
@@ -67,6 +67,7 @@ class LoadModelRequest(BaseModel):
|
|
|
67
67
|
|
|
68
68
|
class InstallEngineRequest(BaseModel):
|
|
69
69
|
engine: str
|
|
70
|
+
confirmation_token: Optional[str] = None
|
|
70
71
|
|
|
71
72
|
|
|
72
73
|
class SetApiKeyRequest(BaseModel):
|
|
@@ -99,7 +100,7 @@ def create_models_router(
|
|
|
99
100
|
get_current_user: Callable[[Request], Optional[str]],
|
|
100
101
|
load_users: Callable[[], Dict],
|
|
101
102
|
get_user_role: Callable[..., str],
|
|
102
|
-
install_engine: Callable[
|
|
103
|
+
install_engine: Callable[..., Dict],
|
|
103
104
|
verify_cloud_models: Callable[..., Any],
|
|
104
105
|
normalize_local_model_request: Callable[..., str],
|
|
105
106
|
download_hf_model: Callable[..., Dict],
|
|
@@ -281,6 +282,8 @@ def create_models_router(
|
|
|
281
282
|
@router.post("/engines/install")
|
|
282
283
|
async def engines_install(req: InstallEngineRequest, request: Request):
|
|
283
284
|
require_user(request)
|
|
285
|
+
if req.confirmation_token:
|
|
286
|
+
return install_engine(req.engine, confirmation_token=req.confirmation_token)
|
|
284
287
|
return install_engine(req.engine)
|
|
285
288
|
|
|
286
289
|
@router.post("/engines/verify-cloud")
|
|
@@ -30,6 +30,7 @@ class PermissionGateway:
|
|
|
30
30
|
self.get_current_user = get_current_user
|
|
31
31
|
self.local_approval_ttl_seconds = 5 * 60
|
|
32
32
|
self.local_approval_lock = threading.Lock()
|
|
33
|
+
self.perm_queue_lock = threading.Lock()
|
|
33
34
|
self.local_approvals: Dict[str, Dict[str, object]] = {}
|
|
34
35
|
self.discord_permission_webhook_url = config.discord_permission_webhook
|
|
35
36
|
self.discord_bot_token = config.discord_bot_token
|
|
@@ -37,26 +38,46 @@ class PermissionGateway:
|
|
|
37
38
|
self.permission_monitor_secret = config.permission_monitor_secret
|
|
38
39
|
self.perm_queue_file = data_dir / "permission_queue.json"
|
|
39
40
|
|
|
41
|
+
@staticmethod
|
|
42
|
+
def token_hash(token: str) -> str:
|
|
43
|
+
return hashlib.sha256(str(token or "").encode("utf-8")).hexdigest()
|
|
44
|
+
|
|
45
|
+
@staticmethod
|
|
46
|
+
def token_hint(token: str) -> str:
|
|
47
|
+
value = str(token or "")
|
|
48
|
+
return value[:8] if value else ""
|
|
49
|
+
|
|
50
|
+
def _read_queue(self) -> Dict:
|
|
51
|
+
if not self.perm_queue_file.exists():
|
|
52
|
+
return {}
|
|
53
|
+
try:
|
|
54
|
+
return json.loads(self.perm_queue_file.read_text(encoding="utf-8"))
|
|
55
|
+
except Exception:
|
|
56
|
+
return {}
|
|
57
|
+
|
|
58
|
+
def _write_queue(self, queue: Dict) -> None:
|
|
59
|
+
self.perm_queue_file.parent.mkdir(parents=True, exist_ok=True)
|
|
60
|
+
tmp = self.perm_queue_file.with_suffix(self.perm_queue_file.suffix + ".tmp")
|
|
61
|
+
tmp.write_text(json.dumps(queue, ensure_ascii=False, indent=2), encoding="utf-8")
|
|
62
|
+
tmp.replace(self.perm_queue_file)
|
|
63
|
+
|
|
40
64
|
def _perm_queue_write(self, token: str, record: Dict[str, object]) -> None:
|
|
41
65
|
try:
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
queue = {}
|
|
48
|
-
queue[token] = {**record, "notified": False}
|
|
49
|
-
self.perm_queue_file.write_text(json.dumps(queue, ensure_ascii=False, indent=2), encoding="utf-8")
|
|
66
|
+
key = self.token_hash(token)
|
|
67
|
+
with self.perm_queue_lock:
|
|
68
|
+
queue = self._read_queue()
|
|
69
|
+
queue[key] = {**record, "token_hint": self.token_hint(token), "notified": False}
|
|
70
|
+
self._write_queue(queue)
|
|
50
71
|
except Exception as exc:
|
|
51
72
|
logging.warning("perm_queue_write failed: %s", exc)
|
|
52
73
|
|
|
53
74
|
def _perm_queue_remove(self, token: str) -> None:
|
|
54
75
|
try:
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
76
|
+
key = self.token_hash(token)
|
|
77
|
+
with self.perm_queue_lock:
|
|
78
|
+
queue = self._read_queue()
|
|
79
|
+
queue.pop(key, None)
|
|
80
|
+
self._write_queue(queue)
|
|
60
81
|
except Exception as exc:
|
|
61
82
|
logging.warning("perm_queue_remove failed: %s", exc)
|
|
62
83
|
|
|
@@ -156,9 +177,10 @@ class PermissionGateway:
|
|
|
156
177
|
"approved": False,
|
|
157
178
|
}
|
|
158
179
|
if action == "write":
|
|
180
|
+
self.ensure_path_allowed(path, action="write")
|
|
159
181
|
record["content_hash"] = self.content_fingerprint(content)
|
|
160
182
|
with self.local_approval_lock:
|
|
161
|
-
self.local_approvals[token] = record
|
|
183
|
+
self.local_approvals[self.token_hash(token)] = {**record, "token_hint": self.token_hint(token)}
|
|
162
184
|
self._perm_queue_write(token, record)
|
|
163
185
|
action_label = _PERMISSION_ACTION_LABELS.get(action, action)
|
|
164
186
|
return {
|
|
@@ -190,12 +212,14 @@ class PermissionGateway:
|
|
|
190
212
|
if not token:
|
|
191
213
|
raise HTTPException(status_code=403, detail="파일 접근 승인 토큰이 필요합니다.")
|
|
192
214
|
normalized = self.normalize_local_path_for_approval(path)
|
|
215
|
+
self.ensure_path_allowed(normalized, action=action)
|
|
193
216
|
now = time.time()
|
|
217
|
+
key = self.token_hash(token)
|
|
194
218
|
with self.local_approval_lock:
|
|
195
219
|
expired = [key for key, value in self.local_approvals.items() if float(value.get("expires_at", 0)) < now]
|
|
196
220
|
for key in expired:
|
|
197
221
|
self.local_approvals.pop(key, None)
|
|
198
|
-
record = self.local_approvals.get(
|
|
222
|
+
record = self.local_approvals.get(key)
|
|
199
223
|
if not record:
|
|
200
224
|
raise HTTPException(status_code=403, detail="파일 접근 승인이 만료되었거나 유효하지 않습니다.")
|
|
201
225
|
if not record.get("approved"):
|
|
@@ -213,13 +237,25 @@ class PermissionGateway:
|
|
|
213
237
|
if auth_header == f"Bearer {self.permission_monitor_secret}":
|
|
214
238
|
return
|
|
215
239
|
if token:
|
|
240
|
+
key = self.token_hash(token)
|
|
216
241
|
current_user = self.get_current_user(request)
|
|
217
242
|
with self.local_approval_lock:
|
|
218
|
-
record = self.local_approvals.get(
|
|
243
|
+
record = self.local_approvals.get(key)
|
|
219
244
|
if current_user and record and record.get("user_email") == current_user:
|
|
220
245
|
return
|
|
221
246
|
self.require_admin(request)
|
|
222
247
|
|
|
248
|
+
def ensure_path_allowed(self, path: str, *, action: str) -> None:
|
|
249
|
+
if action != "write":
|
|
250
|
+
return
|
|
251
|
+
normalized = self.normalize_local_path_for_approval(path)
|
|
252
|
+
from latticeai.services.tool_dispatch import LOCAL_WRITE_BLOCKED_PREFIXES
|
|
253
|
+
|
|
254
|
+
for prefix in LOCAL_WRITE_BLOCKED_PREFIXES:
|
|
255
|
+
normalized_prefix = str(Path(prefix).expanduser()).rstrip("/")
|
|
256
|
+
if normalized == normalized_prefix or normalized.startswith(normalized_prefix + "/"):
|
|
257
|
+
raise HTTPException(status_code=403, detail=f"쓰기 금지 경로입니다: {prefix}")
|
|
258
|
+
|
|
223
259
|
|
|
224
260
|
def create_permissions_router(
|
|
225
261
|
*,
|
|
@@ -243,11 +279,11 @@ def create_permissions_router(
|
|
|
243
279
|
now = time.time()
|
|
244
280
|
with gateway.local_approval_lock:
|
|
245
281
|
result = {}
|
|
246
|
-
for
|
|
282
|
+
for token_hash, rec in list(gateway.local_approvals.items()):
|
|
247
283
|
expires_at = float(rec.get("expires_at", 0))
|
|
248
284
|
if expires_at < now:
|
|
249
285
|
continue
|
|
250
|
-
result[
|
|
286
|
+
result[str(rec.get("token_hint") or token_hash[:8])] = {
|
|
251
287
|
"path": rec.get("path"),
|
|
252
288
|
"action": rec.get("action"),
|
|
253
289
|
"action_label": _PERMISSION_ACTION_LABELS.get(str(rec.get("action", "")), str(rec.get("action", ""))),
|
|
@@ -260,25 +296,26 @@ def create_permissions_router(
|
|
|
260
296
|
@router.post("/permissions/approve/{token}")
|
|
261
297
|
async def permissions_approve(token: str, request: Request):
|
|
262
298
|
gateway.check_permission_auth(request, token)
|
|
299
|
+
key = gateway.token_hash(token)
|
|
263
300
|
with gateway.local_approval_lock:
|
|
264
|
-
record = gateway.local_approvals.get(
|
|
301
|
+
record = gateway.local_approvals.get(key)
|
|
265
302
|
if not record:
|
|
266
303
|
raise HTTPException(status_code=404, detail="토큰이 없거나 만료되었습니다.")
|
|
267
304
|
if float(record.get("expires_at", 0)) < time.time():
|
|
268
|
-
gateway.local_approvals.pop(
|
|
305
|
+
gateway.local_approvals.pop(key, None)
|
|
269
306
|
raise HTTPException(status_code=410, detail="토큰이 만료되었습니다.")
|
|
270
307
|
record["approved"] = True
|
|
271
308
|
gateway._perm_queue_remove(token)
|
|
272
309
|
logging.info(
|
|
273
310
|
"Permission approved: token=%s path=%s action=%s user=%s",
|
|
274
|
-
token,
|
|
311
|
+
gateway.token_hint(token),
|
|
275
312
|
record.get("path"),
|
|
276
313
|
record.get("action"),
|
|
277
314
|
record.get("user_email"),
|
|
278
315
|
)
|
|
279
316
|
return {
|
|
280
317
|
"ok": True,
|
|
281
|
-
"
|
|
318
|
+
"token_hint": gateway.token_hint(token),
|
|
282
319
|
"path": record.get("path"),
|
|
283
320
|
"action": record.get("action"),
|
|
284
321
|
"user_email": record.get("user_email"),
|
|
@@ -287,14 +324,15 @@ def create_permissions_router(
|
|
|
287
324
|
@router.post("/permissions/deny/{token}")
|
|
288
325
|
async def permissions_deny(token: str, request: Request):
|
|
289
326
|
gateway.check_permission_auth(request, token)
|
|
327
|
+
key = gateway.token_hash(token)
|
|
290
328
|
with gateway.local_approval_lock:
|
|
291
|
-
record = gateway.local_approvals.pop(
|
|
329
|
+
record = gateway.local_approvals.pop(key, None)
|
|
292
330
|
gateway._perm_queue_remove(token)
|
|
293
331
|
if not record:
|
|
294
332
|
raise HTTPException(status_code=404, detail="토큰이 없거나 이미 처리되었습니다.")
|
|
295
333
|
logging.info(
|
|
296
334
|
"Permission denied: token=%s path=%s action=%s user=%s",
|
|
297
|
-
token,
|
|
335
|
+
gateway.token_hint(token),
|
|
298
336
|
record.get("path"),
|
|
299
337
|
record.get("action"),
|
|
300
338
|
record.get("user_email"),
|
|
@@ -302,7 +340,7 @@ def create_permissions_router(
|
|
|
302
340
|
return {
|
|
303
341
|
"ok": True,
|
|
304
342
|
"denied": True,
|
|
305
|
-
"
|
|
343
|
+
"token_hint": gateway.token_hint(token),
|
|
306
344
|
"path": record.get("path"),
|
|
307
345
|
"action": record.get("action"),
|
|
308
346
|
}
|
|
@@ -311,17 +349,18 @@ def create_permissions_router(
|
|
|
311
349
|
async def permissions_status(token: str, request: Request):
|
|
312
350
|
require_user(request)
|
|
313
351
|
now = time.time()
|
|
352
|
+
key = gateway.token_hash(token)
|
|
314
353
|
with gateway.local_approval_lock:
|
|
315
|
-
record = gateway.local_approvals.get(
|
|
354
|
+
record = gateway.local_approvals.get(key)
|
|
316
355
|
if not record:
|
|
317
|
-
return {"status": "denied_or_expired", "
|
|
356
|
+
return {"status": "denied_or_expired", "token_hint": gateway.token_hint(token)}
|
|
318
357
|
if float(record.get("expires_at", 0)) < now:
|
|
319
|
-
return {"status": "expired", "
|
|
358
|
+
return {"status": "expired", "token_hint": gateway.token_hint(token)}
|
|
320
359
|
if record.get("approved"):
|
|
321
|
-
return {"status": "approved", "
|
|
360
|
+
return {"status": "approved", "token_hint": gateway.token_hint(token)}
|
|
322
361
|
return {
|
|
323
362
|
"status": "pending",
|
|
324
|
-
"
|
|
363
|
+
"token_hint": gateway.token_hint(token),
|
|
325
364
|
"expires_in": round(float(record.get("expires_at", 0)) - now),
|
|
326
365
|
}
|
|
327
366
|
|
package/latticeai/api/setup.py
CHANGED
|
@@ -8,6 +8,7 @@ from fastapi import APIRouter, HTTPException, Request
|
|
|
8
8
|
from fastapi.responses import StreamingResponse
|
|
9
9
|
from pydantic import BaseModel
|
|
10
10
|
|
|
11
|
+
from latticeai.services.process_audit import command_plan
|
|
11
12
|
from auto_setup import (
|
|
12
13
|
plan as auto_setup_plan,
|
|
13
14
|
preset as auto_setup_preset,
|
|
@@ -21,6 +22,7 @@ from setup_wizard import get_recommendations, install_stream, open_url, scan_env
|
|
|
21
22
|
|
|
22
23
|
class SetupInstallRequest(BaseModel):
|
|
23
24
|
items: List[Dict]
|
|
25
|
+
confirmation_token: Optional[str] = None
|
|
24
26
|
|
|
25
27
|
|
|
26
28
|
def create_setup_router(*, model_router, require_user) -> APIRouter:
|
|
@@ -83,11 +85,19 @@ def create_setup_router(*, model_router, require_user) -> APIRouter:
|
|
|
83
85
|
command = ["lattice-ai", "models", "load", str(model_id)]
|
|
84
86
|
else:
|
|
85
87
|
command = ["huggingface-cli", "download", str(model_id), "--quiet"]
|
|
88
|
+
step_plan = command_plan(
|
|
89
|
+
command,
|
|
90
|
+
name=f"weights:{model_id}",
|
|
91
|
+
purpose="auto_setup_install",
|
|
92
|
+
metadata={"model_id": str(model_id)},
|
|
93
|
+
)
|
|
86
94
|
zero_config["plan"]["steps"] = [{
|
|
87
95
|
"name": f"weights:{model_id}",
|
|
88
96
|
"why": "추론에 사용할 모델 가중치",
|
|
89
97
|
"command": command,
|
|
90
98
|
"requires_admin": False,
|
|
99
|
+
"command_plan": step_plan,
|
|
100
|
+
"confirmation_token": step_plan["confirmation_token"],
|
|
91
101
|
}]
|
|
92
102
|
if isinstance(zero_config.get("preset"), dict):
|
|
93
103
|
zero_config["preset"].setdefault("model", {})["id"] = model_id
|
|
@@ -107,9 +117,14 @@ def create_setup_router(*, model_router, require_user) -> APIRouter:
|
|
|
107
117
|
@api_router.post("/setup/install")
|
|
108
118
|
async def setup_install(req: SetupInstallRequest, request: Request):
|
|
109
119
|
"""선택된 항목을 순서대로 설치 · 로드하는 SSE 스트림."""
|
|
110
|
-
require_user(request)
|
|
120
|
+
user_email = require_user(request)
|
|
111
121
|
async def _gen():
|
|
112
|
-
async for chunk in install_stream(
|
|
122
|
+
async for chunk in install_stream(
|
|
123
|
+
req.items,
|
|
124
|
+
router,
|
|
125
|
+
confirmation_token=req.confirmation_token,
|
|
126
|
+
user_email=user_email,
|
|
127
|
+
):
|
|
113
128
|
yield chunk
|
|
114
129
|
return StreamingResponse(_gen(), media_type="text/event-stream",
|
|
115
130
|
headers={"Cache-Control": "no-cache", "X-Accel-Buffering": "no"})
|