ltcai 3.4.1 → 3.6.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 (60) hide show
  1. package/README.md +206 -247
  2. package/docs/CARRYOVER_AUDIT_v3.6.0.md +61 -0
  3. package/docs/CHANGELOG.md +32 -0
  4. package/docs/HANDOVER_v3.6.0.md +46 -0
  5. package/docs/RUNTIME_HOOK_COVERAGE_v3.5.0.md +56 -0
  6. package/docs/RUNTIME_HOOK_COVERAGE_v3.6.0.md +49 -0
  7. package/docs/architecture.md +13 -12
  8. package/docs/kg-schema.md +55 -0
  9. package/docs/privacy.md +18 -2
  10. package/docs/security-model.md +17 -0
  11. package/kg_schema.py +46 -0
  12. package/knowledge_graph.py +520 -1
  13. package/latticeai/__init__.py +1 -1
  14. package/latticeai/api/auth.py +37 -9
  15. package/latticeai/api/browser.py +217 -0
  16. package/latticeai/api/chat.py +4 -1
  17. package/latticeai/api/computer_use.py +21 -8
  18. package/latticeai/api/portability.py +93 -0
  19. package/latticeai/api/tools.py +29 -26
  20. package/latticeai/core/config.py +3 -0
  21. package/latticeai/core/marketplace.py +1 -1
  22. package/latticeai/core/multi_agent.py +1 -1
  23. package/latticeai/core/oidc.py +205 -0
  24. package/latticeai/core/security.py +59 -5
  25. package/latticeai/core/workspace_os.py +1 -1
  26. package/latticeai/server_app.py +39 -0
  27. package/latticeai/services/ingestion.py +271 -0
  28. package/latticeai/services/kg_portability.py +177 -0
  29. package/package.json +5 -4
  30. package/requirements.txt +1 -0
  31. package/scripts/build_vsix.mjs +72 -0
  32. package/scripts/check_python.py +87 -0
  33. package/static/css/reference/account.css +1 -1
  34. package/static/css/reference/admin.css +1 -1
  35. package/static/css/reference/base.css +8 -5
  36. package/static/css/reference/chat.css +8 -8
  37. package/static/css/reference/graph.css +2 -2
  38. package/static/css/responsive.css +2 -2
  39. package/static/v3/asset-manifest.json +9 -9
  40. package/static/v3/css/{lattice.shell.6ceea7c8.css → lattice.shell.8fcc9d33.css} +2 -1
  41. package/static/v3/css/lattice.shell.css +2 -1
  42. package/static/v3/js/{app.d086489d.js → app.c541f955.js} +1 -1
  43. package/static/v3/js/core/{api.12b568ad.js → api.33d6320e.js} +38 -0
  44. package/static/v3/js/core/api.js +38 -0
  45. package/static/v3/js/core/{routes.d214b399.js → routes.2ce3815a.js} +1 -1
  46. package/static/v3/js/core/routes.js +1 -1
  47. package/static/v3/js/core/{shell.d05266f5.js → shell.8c163e0e.js} +2 -2
  48. package/static/v3/js/views/knowledge-graph.a96040a5.js +513 -0
  49. package/static/v3/js/views/knowledge-graph.js +293 -17
  50. package/static/workspace.css +1 -1
  51. package/tools/__init__.py +276 -0
  52. package/tools/commands.py +188 -0
  53. package/tools/computer.py +185 -0
  54. package/tools/documents.py +243 -0
  55. package/tools/filesystem.py +560 -0
  56. package/tools/knowledge.py +97 -0
  57. package/tools/local_files.py +69 -0
  58. package/tools/network.py +66 -0
  59. package/static/v3/js/views/knowledge-graph.a14ea7e7.js +0 -237
  60. package/tools.py +0 -1525
@@ -1,17 +1,21 @@
1
1
  """Authentication API router: register, login, logout, SSO, profile."""
2
2
 
3
- import base64
4
- import json
5
3
  import logging
6
4
  import secrets
7
5
  import time
8
- from typing import Any, Callable, Dict, Optional
6
+ from typing import Any, Awaitable, Callable, Dict, Optional, Tuple
9
7
  from urllib.parse import urlencode
10
8
 
11
9
  from fastapi import APIRouter, HTTPException, Request
12
10
  from fastapi.responses import JSONResponse, RedirectResponse
13
11
  from pydantic import BaseModel
14
12
 
13
+ from latticeai.core.oidc import (
14
+ OIDCValidationError,
15
+ fetch_jwks as _default_fetch_jwks,
16
+ verify_id_token as _default_verify_id_token,
17
+ )
18
+
15
19
 
16
20
  class UserRegister(BaseModel):
17
21
  email: str
@@ -35,7 +39,9 @@ class UpdateProfileRequest(BaseModel):
35
39
  nickname: Optional[str] = None
36
40
 
37
41
 
38
- _sso_states: Dict[str, float] = {}
42
+ # state → (issued_at, nonce). The nonce binds the eventual ID token to *this*
43
+ # login attempt (replay / token-injection defence); the timestamp expires it.
44
+ _sso_states: Dict[str, Tuple[float, str]] = {}
39
45
 
40
46
 
41
47
  def create_auth_router(
@@ -58,6 +64,8 @@ def create_auth_router(
58
64
  open_registration: bool,
59
65
  session_ttl: int,
60
66
  require_auth: bool = True,
67
+ verify_id_token: Callable[..., Dict] = _default_verify_id_token,
68
+ fetch_jwks: Callable[[str], Awaitable[Dict]] = _default_fetch_jwks,
61
69
  ) -> APIRouter:
62
70
  router = APIRouter()
63
71
 
@@ -114,13 +122,15 @@ def create_auth_router(
114
122
  if not settings.get("enabled") or not discovery:
115
123
  raise HTTPException(status_code=503, detail="SSO가 설정되지 않았습니다.")
116
124
  state = secrets.token_urlsafe(16)
117
- _sso_states[state] = time.time()
125
+ nonce = secrets.token_urlsafe(16)
126
+ _sso_states[state] = (time.time(), nonce)
118
127
  params = urlencode({
119
128
  "client_id": settings["client_id"],
120
129
  "response_type": "code",
121
130
  "redirect_uri": settings["redirect_uri"],
122
131
  "scope": settings.get("scopes") or "openid email profile",
123
132
  "state": state,
133
+ "nonce": nonce,
124
134
  })
125
135
  return RedirectResponse(f"{discovery['authorization_endpoint']}?{params}")
126
136
 
@@ -128,9 +138,10 @@ def create_auth_router(
128
138
  async def sso_callback(code: str = "", state: str = "", error: str = ""):
129
139
  if error:
130
140
  return RedirectResponse(f"/?sso_error={error}")
131
- ts = _sso_states.pop(state, None)
132
- if ts is None or time.time() - ts > 300:
141
+ entry = _sso_states.pop(state, None)
142
+ if entry is None or time.time() - entry[0] > 300:
133
143
  raise HTTPException(status_code=400, detail="유효하지 않은 SSO 상태입니다.")
144
+ _, nonce = entry
134
145
  settings = get_sso_settings()
135
146
  discovery = await get_sso_discovery()
136
147
  if not settings.get("enabled") or not discovery:
@@ -148,8 +159,25 @@ def create_auth_router(
148
159
  id_token = tokens.get("id_token")
149
160
  if not id_token:
150
161
  raise HTTPException(status_code=400, detail="ID 토큰을 받지 못했습니다.")
151
- padded = id_token.split(".")[1] + "=="
152
- payload = json.loads(base64.urlsafe_b64decode(padded))
162
+ # Never trust a decoded JWT payload: verify signature (against the
163
+ # provider JWKS), issuer, audience, expiry and the login nonce before
164
+ # using any claim. Any failure is fail-closed (401).
165
+ issuer = discovery.get("issuer") or ""
166
+ try:
167
+ jwks = await fetch_jwks(discovery.get("jwks_uri", ""))
168
+ payload = verify_id_token(
169
+ id_token,
170
+ jwks=jwks,
171
+ issuer=issuer,
172
+ audience=settings["client_id"],
173
+ nonce=nonce,
174
+ )
175
+ except OIDCValidationError as exc:
176
+ logging.warning("SSO ID token rejected: %s", exc)
177
+ raise HTTPException(status_code=401, detail="SSO 토큰 검증에 실패했습니다.")
178
+ except Exception as exc: # discovery/JWKS fetch failure → fail closed
179
+ logging.warning("SSO token validation error: %s", exc)
180
+ raise HTTPException(status_code=502, detail="SSO 공급자 검증에 실패했습니다.")
153
181
  email = payload.get("email") or payload.get("preferred_username") or payload.get("upn") or ""
154
182
  if not email:
155
183
  raise HTTPException(status_code=400, detail="이메일을 확인할 수 없습니다.")
@@ -0,0 +1,217 @@
1
+ """Browser & web ingestion — Knowledge Graph inputs, not standalone features.
2
+
3
+ v3.6.0 Knowledge Graph First: a public URL or an open browser tab is just another
4
+ source that converges into the Knowledge Graph through the unified ingestion
5
+ pipeline. Everything runs on the **local runtime** — the server fetches/reads
6
+ locally, stores into local SQLite, and never uploads to a cloud service.
7
+
8
+ Two layers, both feeding ``IngestionPipeline.ingest``:
9
+
10
+ * ``POST /api/browser/read-url`` — the runtime fetches a public URL locally,
11
+ extracts readable text, stores it as ``source_type=web_url``. Fails gracefully
12
+ on blocked / login-required pages.
13
+ * ``POST /api/browser/ingest-current-tab`` — accepts a payload from the local
14
+ browser extension (url/title/text/selected_text/html), sanitizes + size-limits
15
+ it, stores it as ``source_type=browser_tab``.
16
+ """
17
+
18
+ from __future__ import annotations
19
+
20
+ from html.parser import HTMLParser
21
+ from typing import Any, Callable, Optional, Tuple
22
+ from urllib.parse import urlparse
23
+
24
+ from fastapi import APIRouter, HTTPException, Request
25
+ from pydantic import BaseModel
26
+
27
+ from latticeai.services.ingestion import IngestionItem
28
+
29
+ MAX_TAB_BYTES = 4 * 1024 * 1024 # 4 MB per captured tab payload
30
+ MAX_URL_FETCH_BYTES = 4 * 1024 * 1024 # 4 MB cap on a fetched page
31
+ URL_FETCH_TIMEOUT = 12.0 # seconds
32
+
33
+
34
+ class BrowserFetchError(Exception):
35
+ """A URL could not be fetched (blocked, login-required, timeout, too big)."""
36
+
37
+
38
+ # ── readable-text extraction ─────────────────────────────────────────────────
39
+ _SKIP_TAGS = {"script", "style", "noscript", "template", "svg", "head"}
40
+
41
+
42
+ class _TextExtractor(HTMLParser):
43
+ def __init__(self) -> None:
44
+ super().__init__(convert_charrefs=True)
45
+ self._skip_depth = 0
46
+ self._chunks: list[str] = []
47
+ self.title: str = ""
48
+ self._in_title = False
49
+
50
+ def handle_starttag(self, tag, attrs):
51
+ if tag in _SKIP_TAGS:
52
+ self._skip_depth += 1
53
+ if tag == "title":
54
+ self._in_title = True
55
+
56
+ def handle_endtag(self, tag):
57
+ if tag in _SKIP_TAGS and self._skip_depth > 0:
58
+ self._skip_depth -= 1
59
+ if tag == "title":
60
+ self._in_title = False
61
+ if tag in {"p", "div", "br", "li", "h1", "h2", "h3", "h4", "section", "article"}:
62
+ self._chunks.append("\n")
63
+
64
+ def handle_data(self, data):
65
+ if self._in_title:
66
+ self.title += data
67
+ if self._skip_depth == 0:
68
+ text = data.strip()
69
+ if text:
70
+ self._chunks.append(text)
71
+
72
+ def text(self) -> str:
73
+ raw = " ".join(self._chunks)
74
+ # collapse runs of whitespace while keeping paragraph breaks
75
+ lines = [ln.strip() for ln in raw.replace("\r", "").split("\n")]
76
+ return "\n".join([ln for ln in lines if ln]).strip()
77
+
78
+
79
+ def extract_readable_text(html: str) -> Tuple[str, str]:
80
+ """Return (title, readable_text) from an HTML string. Never raises."""
81
+ parser = _TextExtractor()
82
+ try:
83
+ parser.feed(html or "")
84
+ except Exception: # noqa: BLE001 — malformed HTML must still yield best-effort text
85
+ pass
86
+ return parser.title.strip(), parser.text()
87
+
88
+
89
+ def _default_fetch_url(url: str) -> Tuple[str, str]:
90
+ """Fetch a public URL on the local runtime and extract readable text.
91
+
92
+ Raises :class:`BrowserFetchError` on any non-success (blocked, login wall,
93
+ timeout, oversized, non-HTML) so the route can fail gracefully.
94
+ """
95
+ import httpx
96
+
97
+ try:
98
+ with httpx.Client(
99
+ follow_redirects=True, timeout=URL_FETCH_TIMEOUT,
100
+ headers={"User-Agent": "LatticeAI-local/3.6 (+local-first knowledge graph)"},
101
+ ) as client:
102
+ resp = client.get(url)
103
+ except httpx.HTTPError as exc:
104
+ raise BrowserFetchError(f"Could not reach the page: {exc}") from exc
105
+
106
+ if resp.status_code in (401, 403):
107
+ raise BrowserFetchError("The page is login-required or blocked (HTTP %s)." % resp.status_code)
108
+ if resp.status_code >= 400:
109
+ raise BrowserFetchError(f"The page returned HTTP {resp.status_code}.")
110
+ content_type = resp.headers.get("content-type", "")
111
+ if content_type and "html" not in content_type and "text" not in content_type:
112
+ raise BrowserFetchError(f"Unsupported content type: {content_type or 'unknown'}.")
113
+ body = resp.text or ""
114
+ if len(body.encode("utf-8", "ignore")) > MAX_URL_FETCH_BYTES:
115
+ body = body.encode("utf-8", "ignore")[:MAX_URL_FETCH_BYTES].decode("utf-8", "ignore")
116
+ title, text = extract_readable_text(body)
117
+ return (title or url, text)
118
+
119
+
120
+ def _validate_http_url(url: str) -> str:
121
+ url = (url or "").strip()
122
+ if not url:
123
+ raise HTTPException(status_code=400, detail="url is required.")
124
+ parsed = urlparse(url)
125
+ if parsed.scheme not in ("http", "https"):
126
+ raise HTTPException(status_code=400, detail="Only http(s) URLs are supported.")
127
+ if not parsed.netloc:
128
+ raise HTTPException(status_code=400, detail="Malformed URL.")
129
+ return url
130
+
131
+
132
+ # ── request models ───────────────────────────────────────────────────────────
133
+ class ReadUrlRequest(BaseModel):
134
+ url: str
135
+ workspace_id: Optional[str] = None
136
+
137
+
138
+ class IngestTabRequest(BaseModel):
139
+ url: str
140
+ title: Optional[str] = None
141
+ text: Optional[str] = None
142
+ selected_text: Optional[str] = None
143
+ html: Optional[str] = None
144
+ captured_at: Optional[str] = None
145
+ workspace_id: Optional[str] = None
146
+
147
+
148
+ def create_browser_router(
149
+ *,
150
+ pipeline: Any,
151
+ require_user: Callable[[Request], str],
152
+ fetch_url: Optional[Callable[[str], Tuple[str, str]]] = None,
153
+ max_tab_bytes: int = MAX_TAB_BYTES,
154
+ ) -> APIRouter:
155
+ router = APIRouter()
156
+ _fetch = fetch_url or _default_fetch_url
157
+
158
+ def _require_pipeline():
159
+ if pipeline is None or not pipeline.available():
160
+ raise HTTPException(status_code=503, detail="Knowledge Graph ingestion is disabled.")
161
+
162
+ @router.post("/api/browser/read-url")
163
+ async def read_url(req: ReadUrlRequest, request: Request):
164
+ """Fetch a public URL locally and ingest it as a web_url source."""
165
+ user = require_user(request)
166
+ _require_pipeline()
167
+ url = _validate_http_url(req.url)
168
+ try:
169
+ title, text = _fetch(url)
170
+ except BrowserFetchError as exc:
171
+ # Graceful failure — not a 5xx; the page was simply unreadable.
172
+ raise HTTPException(status_code=422, detail=str(exc))
173
+ if not (text or "").strip():
174
+ return {"status": "empty", "source_type": "web_url", "url": url,
175
+ "detail": "No readable text was extracted from the page."}
176
+ res = pipeline.ingest(
177
+ IngestionItem(
178
+ source_type="web_url", title=title, text=text, source_uri=url,
179
+ owner=user, workspace_id=req.workspace_id,
180
+ ),
181
+ user_email=user,
182
+ )
183
+ return res.as_dict()
184
+
185
+ @router.post("/api/browser/ingest-current-tab")
186
+ async def ingest_current_tab(req: IngestTabRequest, request: Request):
187
+ """Ingest a payload captured from the local browser extension."""
188
+ user = require_user(request)
189
+ _require_pipeline()
190
+ url = _validate_http_url(req.url)
191
+ # Sanitize: reject an oversized payload before doing any work.
192
+ for value in (req.text, req.html, req.selected_text):
193
+ if value and len(value.encode("utf-8", "ignore")) > max_tab_bytes:
194
+ raise HTTPException(status_code=413, detail="Captured payload is too large.")
195
+ text = (req.text or "").strip()
196
+ if not text and req.html:
197
+ _title, text = extract_readable_text(req.html)
198
+ if not text:
199
+ text = (req.selected_text or "").strip()
200
+ if not text:
201
+ raise HTTPException(status_code=400, detail="No text, html, or selected_text provided.")
202
+ res = pipeline.ingest(
203
+ IngestionItem(
204
+ source_type="browser_tab",
205
+ title=req.title or url,
206
+ text=text,
207
+ source_uri=url,
208
+ captured_at=req.captured_at,
209
+ owner=user,
210
+ workspace_id=req.workspace_id,
211
+ metadata={"has_selection": bool(req.selected_text)},
212
+ ),
213
+ user_email=user,
214
+ )
215
+ return res.as_dict()
216
+
217
+ return router
@@ -24,6 +24,7 @@ from PIL import Image
24
24
  from latticeai.core.agent import AgentRunContext, AgentState
25
25
  from latticeai.core.context_builder import format_sources_footnote, retrieve_context_for_generation
26
26
  from latticeai.core.document_generator import DocumentGenerationSession, detect_document_intent
27
+ from latticeai.core.hooks import dispatch_tool
27
28
  from latticeai.services.chat_service import ChatService
28
29
  from latticeai.services.tool_dispatch import build_agent_runtime, collect_created_files
29
30
  from telegram_bot import broadcast_web_chat
@@ -653,7 +654,9 @@ def create_chat_router(
653
654
  for case in eval_cases:
654
655
  case_id = case.get("id", "?")
655
656
  try:
656
- result = execute_tool(action_name, case.get("input", {}))
657
+ case_input = case.get("input", {})
658
+ result = dispatch_tool(hooks, action_name, case_input,
659
+ lambda: execute_tool(action_name, case_input), source="eval")
657
660
  criteria = case.get("pass_criteria", "")
658
661
  if "success == true" in criteria:
659
662
  passed = result.get("success") is True
@@ -11,6 +11,7 @@ from fastapi.responses import StreamingResponse
11
11
  from pydantic import BaseModel
12
12
 
13
13
  from latticeai.core.agent import extract_action as _extract_agent_action
14
+ from latticeai.core.hooks import dispatch_tool
14
15
  from tools import (
15
16
  ToolError,
16
17
  computer_click,
@@ -105,9 +106,15 @@ class CuDragRequest(BaseModel):
105
106
  y2: int
106
107
 
107
108
 
108
- def create_computer_use_router(*, model_router, require_user, tool_response, save_to_history) -> APIRouter:
109
+ def create_computer_use_router(*, model_router, require_user, tool_response, save_to_history, hooks=None) -> APIRouter:
109
110
  router = APIRouter()
110
111
 
112
+ def _dispatch(name, args, fn):
113
+ # Run a computer-use action through the unified pre_tool/post_tool
114
+ # lifecycle. With hooks=None this is a transparent pass-through, so the
115
+ # behaviour is unchanged when hooks are absent.
116
+ return dispatch_tool(hooks, name, dict(args or {}), fn, source="computer_use")
117
+
111
118
  @router.get("/tools/chrome_status")
112
119
  async def tools_chrome_status(request: Request):
113
120
  require_user(request)
@@ -122,7 +129,9 @@ def create_computer_use_router(*, model_router, require_user, tool_response, sav
122
129
  async def cu_status(request: Request):
123
130
  require_user(request)
124
131
  try:
125
- return computer_status()
132
+ return _dispatch("computer_status", {}, computer_status)
133
+ except PermissionError as exc:
134
+ raise HTTPException(status_code=403, detail=str(exc))
126
135
  except ToolError as exc:
127
136
  raise HTTPException(status_code=400, detail=str(exc))
128
137
 
@@ -130,7 +139,9 @@ def create_computer_use_router(*, model_router, require_user, tool_response, sav
130
139
  async def cu_screenshot(request: Request):
131
140
  require_user(request)
132
141
  try:
133
- return computer_screenshot()
142
+ return _dispatch("computer_screenshot", {}, computer_screenshot)
143
+ except PermissionError as exc:
144
+ raise HTTPException(status_code=403, detail=str(exc))
134
145
  except ToolError as exc:
135
146
  raise HTTPException(status_code=400, detail=str(exc))
136
147
 
@@ -196,7 +207,8 @@ def create_computer_use_router(*, model_router, require_user, tool_response, sav
196
207
  "action",
197
208
  {"step": 1, "action": "computer_open_url", "args": {"url": url, "app": "Google Chrome"}},
198
209
  )
199
- result = computer_open_url(url, "Google Chrome")
210
+ result = _dispatch("computer_open_url", {"url": url, "app": "Google Chrome"},
211
+ lambda: computer_open_url(url, "Google Chrome"))
200
212
  yield _send("result", {"step": 1, "action": "computer_open_url", "result": result})
201
213
  message = f"Google Chrome에서 {url}을 열었습니다."
202
214
  action_name = "computer_open_url"
@@ -205,14 +217,15 @@ def create_computer_use_router(*, model_router, require_user, tool_response, sav
205
217
  "action",
206
218
  {"step": 1, "action": "computer_open_app", "args": {"app": "Google Chrome"}},
207
219
  )
208
- result = computer_open_app("Google Chrome")
220
+ result = _dispatch("computer_open_app", {"app": "Google Chrome"},
221
+ lambda: computer_open_app("Google Chrome"))
209
222
  yield _send("result", {"step": 1, "action": "computer_open_app", "result": result})
210
223
  message = "Google Chrome을 열었습니다."
211
224
  action_name = "computer_open_app"
212
225
  save_to_history("user", req.task, source="web", conversation_id=req.conversation_id)
213
226
  save_to_history("assistant", message, source="web", conversation_id=req.conversation_id)
214
227
  yield _send("final", {"message": message, "steps": [{"step": 1, "action": action_name, "result": result}]})
215
- except ToolError as exc:
228
+ except (ToolError, PermissionError) as exc:
216
229
  yield _send("tool_error", {"step": 1, "action": "computer_open_app", "error": str(exc)})
217
230
  return
218
231
 
@@ -256,7 +269,7 @@ def create_computer_use_router(*, model_router, require_user, tool_response, sav
256
269
 
257
270
  yield _send("action", {"step": step + 1, "action": name, "args": args})
258
271
  try:
259
- result = execute_tool(name, args)
272
+ result = _dispatch(name, args, lambda: execute_tool(name, args))
260
273
  if name == "computer_screenshot" and "screenshot_b64" in result:
261
274
  last_screenshot_b64 = result["screenshot_b64"]
262
275
  result_summary = {k: v for k, v in result.items() if k != "screenshot_b64"}
@@ -275,7 +288,7 @@ def create_computer_use_router(*, model_router, require_user, tool_response, sav
275
288
  last_screenshot_b64 = None
276
289
  transcript.append({"step": step + 1, "action": name, "args": args, "result": result})
277
290
  yield _send("result", {"step": step + 1, "action": name, "result": result})
278
- except (ToolError, KeyError, TypeError) as exc:
291
+ except (ToolError, PermissionError, KeyError, TypeError) as exc:
279
292
  error_str = str(exc)
280
293
  transcript.append({"step": step + 1, "action": name, "args": args, "error": error_str})
281
294
  yield _send("tool_error", {"step": step + 1, "action": name, "error": error_str})
@@ -0,0 +1,93 @@
1
+ """Knowledge Graph portability routes — local export / import / backup / restore.
2
+
3
+ Reads (export, status) require a signed-in user. Mutating operations (import,
4
+ backup, restore, file export) require admin because the graph is machine-global,
5
+ not workspace-scoped. Nothing here touches a cloud service.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ from typing import Any, Callable, Optional
11
+
12
+ from fastapi import APIRouter, HTTPException, Request
13
+ from pydantic import BaseModel
14
+
15
+
16
+ class ImportRequest(BaseModel):
17
+ artifact: dict
18
+ mode: str = "merge"
19
+ dry_run: bool = False
20
+
21
+
22
+ class BackupRequest(BaseModel):
23
+ path: Optional[str] = None
24
+
25
+
26
+ class RestoreRequest(BaseModel):
27
+ path: str
28
+ verify: bool = True
29
+
30
+
31
+ def create_portability_router(
32
+ *,
33
+ service: Any,
34
+ require_user: Callable[[Request], str],
35
+ require_admin: Callable[[Request], Any],
36
+ ) -> APIRouter:
37
+ router = APIRouter()
38
+
39
+ def _require_service():
40
+ if service is None or not service.available():
41
+ raise HTTPException(status_code=503, detail="Knowledge Graph is disabled.")
42
+
43
+ @router.get("/api/knowledge-graph/portability")
44
+ async def portability_status(request: Request):
45
+ require_user(request)
46
+ _require_service()
47
+ return service.snapshot_metadata()
48
+
49
+ @router.get("/api/knowledge-graph/provenance")
50
+ async def recent_provenance(request: Request, limit: int = 50, source_type: Optional[str] = None):
51
+ """Recent ingestions (provenance trail) for the ingestion-sources UI."""
52
+ require_user(request)
53
+ _require_service()
54
+ return service.recent_ingestions(limit=limit, source_type=source_type)
55
+
56
+ @router.post("/api/knowledge-graph/export")
57
+ async def export_graph(request: Request):
58
+ """Logical JSON export of the whole graph (read-only)."""
59
+ require_user(request)
60
+ _require_service()
61
+ return service.export()
62
+
63
+ @router.post("/api/knowledge-graph/export-file")
64
+ async def export_graph_file(request: Request):
65
+ require_admin(request)
66
+ _require_service()
67
+ return service.export_to_file()
68
+
69
+ @router.post("/api/knowledge-graph/import")
70
+ async def import_graph(req: ImportRequest, request: Request):
71
+ require_admin(request)
72
+ _require_service()
73
+ try:
74
+ return service.import_data(req.artifact, mode=req.mode, dry_run=req.dry_run)
75
+ except ValueError as exc:
76
+ raise HTTPException(status_code=400, detail=str(exc))
77
+
78
+ @router.post("/api/knowledge-graph/backup")
79
+ async def backup_graph(req: BackupRequest, request: Request):
80
+ require_admin(request)
81
+ _require_service()
82
+ return service.backup(req.path)
83
+
84
+ @router.post("/api/knowledge-graph/restore")
85
+ async def restore_graph(req: RestoreRequest, request: Request):
86
+ require_admin(request)
87
+ _require_service()
88
+ try:
89
+ return service.restore(req.path, verify=req.verify)
90
+ except (ValueError, FileNotFoundError) as exc:
91
+ raise HTTPException(status_code=400, detail=str(exc))
92
+
93
+ return router
@@ -221,17 +221,30 @@ def create_tools_router(
221
221
 
222
222
  # ── Direct Tool API ───────────────────────────────────────────────────────────
223
223
 
224
- def _tool_response(fn, *args):
224
+ def _tool_response(fn, *args, **kwargs):
225
225
  # Shared tool lifecycle (same path as the agent + workflow tool calls):
226
- # pre_tool (may block) → execute → post_tool.
226
+ # pre_tool (may block) → execute → post_tool. Keyword args are forwarded
227
+ # to the tool and surfaced in the hook payload so read_file / edit_file /
228
+ # grep (which need kwargs) run through the SAME lifecycle as every other
229
+ # tool instead of bypassing it.
227
230
  tool_name = getattr(fn, "__name__", "tool")
228
231
  try:
229
- result = dispatch_tool(HOOKS, tool_name, {}, lambda: fn(*args), source="http")
232
+ result = dispatch_tool(HOOKS, tool_name, dict(kwargs), lambda: fn(*args, **kwargs), source="http")
230
233
  except PermissionError as exc:
231
234
  raise HTTPException(status_code=403, detail=str(exc))
232
235
  except ToolError as exc:
233
236
  raise HTTPException(status_code=400, detail=str(exc))
234
237
  return {"status": "ok", "workspace": str(AGENT_ROOT), "result": result}
238
+
239
+ def _dispatch(tool_name, args, fn):
240
+ # Lifecycle wrapper for callables that aren't a plain tools.* function
241
+ # (e.g. the server's clear_history). Same pre_tool/post_tool path.
242
+ try:
243
+ return dispatch_tool(HOOKS, tool_name, dict(args or {}), fn, source="http")
244
+ except PermissionError as exc:
245
+ raise HTTPException(status_code=403, detail=str(exc))
246
+ except ToolError as exc:
247
+ raise HTTPException(status_code=400, detail=str(exc))
235
248
 
236
249
 
237
250
  @api_router.post("/tools/list_dir")
@@ -249,11 +262,7 @@ def create_tools_router(
249
262
  @api_router.post("/tools/read_file")
250
263
  async def tools_read_file(req: ToolReadFileRequest, request: Request):
251
264
  require_user(request)
252
- try:
253
- return {"status": "ok", "workspace": str(AGENT_ROOT),
254
- "result": read_file(req.path, offset=req.offset, limit=req.limit, line_numbers=req.line_numbers)}
255
- except ToolError as exc:
256
- raise HTTPException(status_code=400, detail=str(exc))
265
+ return _tool_response(read_file, req.path, offset=req.offset, limit=req.limit, line_numbers=req.line_numbers)
257
266
 
258
267
 
259
268
  @api_router.post("/tools/write_file")
@@ -265,11 +274,7 @@ def create_tools_router(
265
274
  @api_router.post("/tools/edit_file")
266
275
  async def tools_edit_file(req: ToolEditFileRequest, request: Request):
267
276
  require_user(request)
268
- try:
269
- return {"status": "ok", "workspace": str(AGENT_ROOT),
270
- "result": edit_file(req.path, req.old_string, req.new_string, replace_all=req.replace_all)}
271
- except ToolError as exc:
272
- raise HTTPException(status_code=400, detail=str(exc))
277
+ return _tool_response(edit_file, req.path, req.old_string, req.new_string, replace_all=req.replace_all)
273
278
 
274
279
 
275
280
  @api_router.post("/tools/search_files")
@@ -281,18 +286,15 @@ def create_tools_router(
281
286
  @api_router.post("/tools/grep")
282
287
  async def tools_grep(req: ToolGrepRequest, request: Request):
283
288
  require_user(request)
284
- try:
285
- return {"status": "ok", "workspace": str(AGENT_ROOT),
286
- "result": grep(
287
- req.pattern,
288
- path=req.path,
289
- glob=req.glob,
290
- max_results=req.max_results,
291
- case_insensitive=req.case_insensitive,
292
- context_lines=req.context_lines,
293
- )}
294
- except ToolError as exc:
295
- raise HTTPException(status_code=400, detail=str(exc))
289
+ return _tool_response(
290
+ grep,
291
+ req.pattern,
292
+ path=req.path,
293
+ glob=req.glob,
294
+ max_results=req.max_results,
295
+ case_insensitive=req.case_insensitive,
296
+ context_lines=req.context_lines,
297
+ )
296
298
 
297
299
 
298
300
  @api_router.post("/tools/todo_read")
@@ -310,7 +312,7 @@ def create_tools_router(
310
312
  @api_router.post("/tools/clear_history")
311
313
  async def tools_clear_history(req: ToolClearHistoryRequest, request: Request):
312
314
  current_user = require_user(request)
313
- result = clear_history(req.keep_last)
315
+ result = _dispatch("clear_history", {"keep_last": req.keep_last}, lambda: clear_history(req.keep_last))
314
316
  append_audit_event(
315
317
  "history_delete",
316
318
  user_email=current_user,
@@ -461,6 +463,7 @@ def create_tools_router(
461
463
  require_user=require_user,
462
464
  tool_response=_tool_response,
463
465
  save_to_history=save_to_history,
466
+ hooks=HOOKS,
464
467
  ))
465
468
 
466
469
  @api_router.post("/tools/knowledge_save")
@@ -86,6 +86,7 @@ class Config:
86
86
  invite_code: str
87
87
  invite_gate_enabled: bool
88
88
  admin_emails: List[str]
89
+ trusted_proxies: List[str]
89
90
 
90
91
  # ── models ──────────────────────────────────────────────────────
91
92
  public_model: str
@@ -139,6 +140,7 @@ class Config:
139
140
 
140
141
  cors_extra = [item.strip() for item in _value(env, "LATTICEAI_CORS_ALLOWED_ORIGINS", "").split(",") if item.strip()]
141
142
  admin_emails = [item.strip().lower() for item in _value(env, "LATTICEAI_ADMIN_EMAILS", "").split(",") if item.strip()]
143
+ trusted_proxies = [item.strip() for item in _value(env, "LATTICEAI_TRUSTED_PROXIES", "").split(",") if item.strip()]
142
144
 
143
145
  public_model = _value(env, "LATTICEAI_PUBLIC_MODEL", _value(env, "LATTICEAI_DEFAULT_MODEL", "openai:gpt-4o-mini"))
144
146
  local_model = _value(env, "LATTICEAI_LOCAL_MODEL", "mlx-community/gemma-4-12b-it-4bit")
@@ -170,6 +172,7 @@ class Config:
170
172
  invite_code=_value(env, "LATTICEAI_INVITE_CODE", "gemma-lattice-ai"),
171
173
  invite_gate_enabled=_bool(env, "LATTICEAI_INVITE_GATE_ENABLED", default=False),
172
174
  admin_emails=admin_emails,
175
+ trusted_proxies=trusted_proxies,
173
176
  public_model=public_model,
174
177
  local_model=local_model,
175
178
  local_draft_model=_value(env, "LATTICEAI_LOCAL_DRAFT_MODEL", ""),
@@ -11,7 +11,7 @@ from copy import deepcopy
11
11
  from typing import Any, Dict, List, Optional
12
12
 
13
13
 
14
- MARKETPLACE_VERSION = "3.4.1"
14
+ MARKETPLACE_VERSION = "3.6.0"
15
15
  TEMPLATE_KINDS = ("plugin", "workflow", "agent")
16
16
 
17
17
 
@@ -14,7 +14,7 @@ from datetime import datetime
14
14
  from typing import Any, Callable, Dict, List, Optional
15
15
 
16
16
 
17
- MULTI_AGENT_VERSION = "3.4.1"
17
+ MULTI_AGENT_VERSION = "3.6.0"
18
18
 
19
19
  AGENT_ROLES = ("researcher", "planner", "executor", "reviewer", "release")
20
20
  CORE_PIPELINE = ("planner", "executor", "reviewer")