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.
Files changed (63) hide show
  1. package/README.md +34 -27
  2. package/auto_setup.py +73 -8
  3. package/docs/CHANGELOG.md +37 -0
  4. package/docs/CODE_REVIEW_2026-07-06.md +764 -0
  5. package/docs/COMMUNITY_AND_PLUGINS.md +3 -3
  6. package/docs/DEVELOPMENT.md +10 -10
  7. package/docs/LEGACY_COMPATIBILITY.md +1 -1
  8. package/docs/ONBOARDING.md +2 -2
  9. package/docs/PRODUCT_DIRECTION_REVIEW.md +3 -2
  10. package/docs/TRUST_MODEL.md +5 -1
  11. package/docs/WHY_LATTICE.md +4 -3
  12. package/docs/architecture.md +4 -0
  13. package/docs/kg-schema.md +1 -1
  14. package/lattice_brain/__init__.py +1 -1
  15. package/lattice_brain/conversations.py +156 -21
  16. package/lattice_brain/graph/_kg_common.py +12 -34
  17. package/lattice_brain/graph/json_utils.py +25 -0
  18. package/lattice_brain/graph/retrieval.py +66 -27
  19. package/lattice_brain/graph/runtime.py +16 -0
  20. package/lattice_brain/runtime/multi_agent.py +1 -1
  21. package/latticeai/__init__.py +1 -1
  22. package/latticeai/api/chat.py +31 -14
  23. package/latticeai/api/mcp.py +3 -2
  24. package/latticeai/api/models.py +4 -1
  25. package/latticeai/api/permissions.py +69 -30
  26. package/latticeai/api/setup.py +17 -2
  27. package/latticeai/api/tools.py +104 -62
  28. package/latticeai/app_factory.py +93 -10
  29. package/latticeai/core/agent.py +25 -7
  30. package/latticeai/core/legacy_compatibility.py +1 -1
  31. package/latticeai/core/marketplace.py +1 -1
  32. package/latticeai/core/sessions.py +11 -3
  33. package/latticeai/core/tool_registry.py +15 -4
  34. package/latticeai/core/workspace_os.py +1 -1
  35. package/latticeai/runtime/bootstrap.py +1 -1
  36. package/latticeai/services/app_context.py +1 -0
  37. package/latticeai/services/architecture_readiness.py +2 -2
  38. package/latticeai/services/model_engines.py +79 -12
  39. package/latticeai/services/model_runtime.py +24 -4
  40. package/latticeai/services/process_audit.py +208 -0
  41. package/latticeai/services/product_readiness.py +11 -11
  42. package/latticeai/services/search_service.py +106 -30
  43. package/latticeai/services/tool_dispatch.py +66 -0
  44. package/latticeai/services/workspace_service.py +15 -0
  45. package/package.json +1 -1
  46. package/scripts/check_i18n_literals.mjs +20 -8
  47. package/scripts/i18n_literal_allowlist.json +34 -0
  48. package/scripts/lint_frontend.mjs +6 -2
  49. package/setup_wizard.py +185 -19
  50. package/src-tauri/Cargo.lock +1 -1
  51. package/src-tauri/Cargo.toml +1 -1
  52. package/src-tauri/tauri.conf.json +1 -1
  53. package/static/app/asset-manifest.json +10 -10
  54. package/static/app/assets/{Act-C7K9wsO9.js → Act-fZokUnC0.js} +1 -1
  55. package/static/app/assets/{Brain-I1OSzxJu.js → Brain-DtyuWubr.js} +1 -1
  56. package/static/app/assets/{Capture-B3V4_5Xp.js → Capture-D5KV3Cu7.js} +1 -1
  57. package/static/app/assets/{Library-Cgj-EF50.js → Library-C9kyFkSt.js} +1 -1
  58. package/static/app/assets/{System-D1Lkei3I.js → System-VbChmX7r.js} +1 -1
  59. package/static/app/assets/{index--P0ksosz.js → index-DPdcPoF0.js} +5 -5
  60. package/static/app/assets/{primitives-BLqaKk5g.js → primitives-DFeanEV6.js} +1 -1
  61. package/static/app/assets/{textarea-CVQkN2Tk.js → textarea-CD8UNKIy.js} +1 -1
  62. package/static/app/index.html +1 -1
  63. 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
- f"SELECT COUNT(*) AS c FROM {nt} WHERE type='Chunk' AND metadata_json LIKE ?",
59
- (f"%{node_id}%",),
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
- return {
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
- return {
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
- return {
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
- return {
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
- "edges": list(edges_by_id.values()),
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.8.0"
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")
@@ -1,3 +1,3 @@
1
1
  """Lattice AI - modular server package."""
2
2
 
3
- __version__ = "8.8.0"
3
+ __version__ = "8.9.0"
@@ -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(conversation_id, request.query_params.get("started_at"))
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
 
@@ -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
- _check_tool_role(req.action, current_user)
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
@@ -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[[str], Dict],
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
- queue: Dict = {}
43
- if self.perm_queue_file.exists():
44
- try:
45
- queue = json.loads(self.perm_queue_file.read_text(encoding="utf-8"))
46
- except Exception:
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
- if not self.perm_queue_file.exists():
56
- return
57
- queue: Dict = json.loads(self.perm_queue_file.read_text(encoding="utf-8"))
58
- queue.pop(token, None)
59
- self.perm_queue_file.write_text(json.dumps(queue, ensure_ascii=False, indent=2), encoding="utf-8")
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(token)
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(token)
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 tok, rec in list(gateway.local_approvals.items()):
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[tok] = {
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(token)
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(token, None)
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
- "token": token,
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(token, None)
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
- "token": token,
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(token)
354
+ record = gateway.local_approvals.get(key)
316
355
  if not record:
317
- return {"status": "denied_or_expired", "token": token}
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", "token": token}
358
+ return {"status": "expired", "token_hint": gateway.token_hint(token)}
320
359
  if record.get("approved"):
321
- return {"status": "approved", "token": token}
360
+ return {"status": "approved", "token_hint": gateway.token_hint(token)}
322
361
  return {
323
362
  "status": "pending",
324
- "token": token,
363
+ "token_hint": gateway.token_hint(token),
325
364
  "expires_in": round(float(record.get("expires_at", 0)) - now),
326
365
  }
327
366
 
@@ -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(req.items, router):
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"})