ltcai 8.7.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 +38 -26
- package/auto_setup.py +73 -8
- package/docs/CHANGELOG.md +63 -0
- package/docs/CODE_REVIEW_2026-07-06.md +764 -0
- package/docs/COMMUNITY_AND_PLUGINS.md +4 -3
- package/docs/DEVELOPMENT.md +10 -10
- package/docs/LEGACY_COMPATIBILITY.md +33 -14
- 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/kg_schema.py +1 -1
- package/knowledge_graph.py +2 -2
- package/lattice_brain/__init__.py +1 -1
- package/lattice_brain/conversations.py +156 -21
- package/lattice_brain/core.py +1 -1
- 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/ingestion.py +35 -20
- package/lattice_brain/runtime/agent_runtime.py +37 -3
- 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 +36 -25
- 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/runtime/persistence_runtime.py +1 -1
- package/latticeai/runtime/platform_services_runtime.py +1 -1
- package/latticeai/services/app_context.py +1 -0
- package/latticeai/services/architecture_readiness.py +2 -2
- package/latticeai/services/memory_service.py +38 -7
- 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 +11 -11
- package/static/app/assets/{Act-_U7mhXir.js → Act-fZokUnC0.js} +1 -1
- package/static/app/assets/{Brain-BxyTHZ21.js → Brain-DtyuWubr.js} +2 -2
- package/static/app/assets/{Capture-DyDKWNh9.js → Capture-D5KV3Cu7.js} +1 -1
- package/static/app/assets/{Library-DJ8KioFM.js → Library-C9kyFkSt.js} +1 -1
- package/static/app/assets/{System-C0FIb3OO.js → System-VbChmX7r.js} +1 -1
- package/static/app/assets/index-DCh5AoXt.css +2 -0
- package/static/app/assets/index-DPdcPoF0.js +17 -0
- package/static/app/assets/{primitives-BywkNS3f.js → primitives-DFeanEV6.js} +1 -1
- package/static/app/assets/{textarea-CQ61Rycp.js → textarea-CD8UNKIy.js} +1 -1
- package/static/app/index.html +2 -2
- package/static/sw.js +1 -1
- package/lattice_brain/_kg_common.py +0 -11
- package/lattice_brain/discovery.py +0 -11
- package/lattice_brain/documents.py +0 -11
- package/lattice_brain/identity.py +0 -11
- package/lattice_brain/ingest.py +0 -11
- package/lattice_brain/network.py +0 -11
- package/lattice_brain/projection.py +0 -11
- package/lattice_brain/provenance.py +0 -11
- package/lattice_brain/retrieval.py +0 -11
- package/lattice_brain/schema.py +0 -11
- package/lattice_brain/store.py +0 -11
- package/lattice_brain/write_master.py +0 -11
- package/latticeai/brain/__init__.py +0 -36
- package/latticeai/brain/_kg_common.py +0 -17
- package/latticeai/brain/context.py +0 -17
- package/latticeai/brain/conversations.py +0 -17
- package/latticeai/brain/discovery.py +0 -17
- package/latticeai/brain/documents.py +0 -17
- package/latticeai/brain/identity.py +0 -17
- package/latticeai/brain/ingest.py +0 -17
- package/latticeai/brain/memory.py +0 -17
- package/latticeai/brain/network.py +0 -17
- package/latticeai/brain/projection.py +0 -17
- package/latticeai/brain/provenance.py +0 -17
- package/latticeai/brain/retrieval.py +0 -17
- package/latticeai/brain/schema.py +0 -17
- package/latticeai/brain/store.py +0 -17
- package/latticeai/brain/write_master.py +0 -17
- package/latticeai/services/agent_runtime.py +0 -11
- package/static/app/assets/index-Bh7IIlyY.js +0 -16
- package/static/app/assets/index-_M5aCv21.css +0 -2
|
@@ -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"})
|
package/latticeai/api/tools.py
CHANGED
|
@@ -3,11 +3,12 @@
|
|
|
3
3
|
from __future__ import annotations
|
|
4
4
|
|
|
5
5
|
import base64
|
|
6
|
+
import inspect
|
|
6
7
|
import io
|
|
7
8
|
import logging
|
|
8
9
|
import shutil
|
|
9
10
|
from pathlib import Path
|
|
10
|
-
from typing import Dict, List, Optional
|
|
11
|
+
from typing import Any, Dict, List, Optional
|
|
11
12
|
|
|
12
13
|
from fastapi import APIRouter, File, HTTPException, Request, UploadFile
|
|
13
14
|
from fastapi.responses import FileResponse
|
|
@@ -24,6 +25,7 @@ from latticeai.services.tool_dispatch import (
|
|
|
24
25
|
TOOL_GOVERNANCE_DEFAULT as _TOOL_GOVERNANCE_DEFAULT,
|
|
25
26
|
check_tool_role as _check_tool_role,
|
|
26
27
|
get_tool_permission,
|
|
28
|
+
enforce_tool_policy,
|
|
27
29
|
list_tool_permissions,
|
|
28
30
|
tool_registry_diagnostics,
|
|
29
31
|
tool_registry_manifest,
|
|
@@ -253,7 +255,21 @@ def create_tools_router(
|
|
|
253
255
|
|
|
254
256
|
# ── Direct Tool API ───────────────────────────────────────────────────────────
|
|
255
257
|
|
|
256
|
-
def
|
|
258
|
+
def _policy_args(fn, *args, **kwargs) -> Dict:
|
|
259
|
+
try:
|
|
260
|
+
bound = inspect.signature(fn).bind_partial(*args, **kwargs)
|
|
261
|
+
return dict(bound.arguments)
|
|
262
|
+
except Exception:
|
|
263
|
+
return dict(kwargs)
|
|
264
|
+
|
|
265
|
+
def _tool_response(
|
|
266
|
+
fn,
|
|
267
|
+
*args,
|
|
268
|
+
current_user: Optional[str] = None,
|
|
269
|
+
source: str = "http",
|
|
270
|
+
trusted_admin: bool = False,
|
|
271
|
+
**kwargs,
|
|
272
|
+
):
|
|
257
273
|
# Shared tool lifecycle (same path as the agent + workflow tool calls):
|
|
258
274
|
# pre_tool (may block) → execute → post_tool. Keyword args are forwarded
|
|
259
275
|
# to the tool and surfaced in the hook payload so read_file / edit_file /
|
|
@@ -261,7 +277,16 @@ def create_tools_router(
|
|
|
261
277
|
# tool instead of bypassing it.
|
|
262
278
|
tool_name = getattr(fn, "__name__", "tool")
|
|
263
279
|
try:
|
|
264
|
-
|
|
280
|
+
policy_args = _policy_args(fn, *args, **kwargs)
|
|
281
|
+
if current_user is not None:
|
|
282
|
+
enforce_tool_policy(
|
|
283
|
+
tool_name,
|
|
284
|
+
policy_args,
|
|
285
|
+
current_user=current_user,
|
|
286
|
+
source=source,
|
|
287
|
+
trusted_admin=trusted_admin,
|
|
288
|
+
)
|
|
289
|
+
result = dispatch_tool(HOOKS, tool_name, dict(kwargs), lambda: fn(*args, **kwargs), source=source)
|
|
265
290
|
except PermissionError as exc:
|
|
266
291
|
raise HTTPException(status_code=403, detail=str(exc))
|
|
267
292
|
except ToolError as exc:
|
|
@@ -277,47 +302,58 @@ def create_tools_router(
|
|
|
277
302
|
raise HTTPException(status_code=403, detail=str(exc))
|
|
278
303
|
except ToolError as exc:
|
|
279
304
|
raise HTTPException(status_code=400, detail=str(exc))
|
|
305
|
+
|
|
306
|
+
def _history_scope(user_email: str) -> Dict[str, Any]:
|
|
307
|
+
require_auth = bool(getattr(CONFIG, "require_auth", False))
|
|
308
|
+
scope: Dict[str, Any] = {
|
|
309
|
+
"user_email": user_email if require_auth else None,
|
|
310
|
+
"allowed_workspaces": None,
|
|
311
|
+
"include_legacy_global": not require_auth,
|
|
312
|
+
}
|
|
313
|
+
if require_auth and user_email and allowed_workspaces_for is not None:
|
|
314
|
+
scope["allowed_workspaces"] = allowed_workspaces_for(user_email)
|
|
315
|
+
return scope
|
|
280
316
|
|
|
281
317
|
|
|
282
318
|
@api_router.post("/tools/list_dir")
|
|
283
319
|
async def tools_list_dir(req: ToolPathRequest, request: Request):
|
|
284
|
-
require_user(request)
|
|
285
|
-
return _tool_response(list_dir, req.path)
|
|
320
|
+
current_user = require_user(request)
|
|
321
|
+
return _tool_response(list_dir, req.path, current_user=current_user)
|
|
286
322
|
|
|
287
323
|
|
|
288
324
|
@api_router.post("/tools/workspace_tree")
|
|
289
325
|
async def tools_workspace_tree(req: ToolWorkspaceTreeRequest, request: Request):
|
|
290
|
-
require_user(request)
|
|
291
|
-
return _tool_response(workspace_tree, req.path, req.max_depth)
|
|
326
|
+
current_user = require_user(request)
|
|
327
|
+
return _tool_response(workspace_tree, req.path, req.max_depth, current_user=current_user)
|
|
292
328
|
|
|
293
329
|
|
|
294
330
|
@api_router.post("/tools/read_file")
|
|
295
331
|
async def tools_read_file(req: ToolReadFileRequest, request: Request):
|
|
296
|
-
require_user(request)
|
|
297
|
-
return _tool_response(read_file, req.path, offset=req.offset, limit=req.limit, line_numbers=req.line_numbers)
|
|
332
|
+
current_user = require_user(request)
|
|
333
|
+
return _tool_response(read_file, req.path, offset=req.offset, limit=req.limit, line_numbers=req.line_numbers, current_user=current_user)
|
|
298
334
|
|
|
299
335
|
|
|
300
336
|
@api_router.post("/tools/write_file")
|
|
301
337
|
async def tools_write_file(req: ToolWriteFileRequest, request: Request):
|
|
302
|
-
require_user(request)
|
|
303
|
-
return _tool_response(write_file, req.path, req.content)
|
|
338
|
+
current_user = require_user(request)
|
|
339
|
+
return _tool_response(write_file, req.path, req.content, current_user=current_user)
|
|
304
340
|
|
|
305
341
|
|
|
306
342
|
@api_router.post("/tools/edit_file")
|
|
307
343
|
async def tools_edit_file(req: ToolEditFileRequest, request: Request):
|
|
308
|
-
require_user(request)
|
|
309
|
-
return _tool_response(edit_file, req.path, req.old_string, req.new_string, replace_all=req.replace_all)
|
|
344
|
+
current_user = require_user(request)
|
|
345
|
+
return _tool_response(edit_file, req.path, req.old_string, req.new_string, replace_all=req.replace_all, current_user=current_user)
|
|
310
346
|
|
|
311
347
|
|
|
312
348
|
@api_router.post("/tools/search_files")
|
|
313
349
|
async def tools_search_files(req: ToolSearchFilesRequest, request: Request):
|
|
314
|
-
require_user(request)
|
|
315
|
-
return _tool_response(search_files, req.query, req.path, req.max_results)
|
|
350
|
+
current_user = require_user(request)
|
|
351
|
+
return _tool_response(search_files, req.query, req.path, req.max_results, current_user=current_user)
|
|
316
352
|
|
|
317
353
|
|
|
318
354
|
@api_router.post("/tools/grep")
|
|
319
355
|
async def tools_grep(req: ToolGrepRequest, request: Request):
|
|
320
|
-
require_user(request)
|
|
356
|
+
current_user = require_user(request)
|
|
321
357
|
return _tool_response(
|
|
322
358
|
grep,
|
|
323
359
|
req.pattern,
|
|
@@ -326,25 +362,31 @@ def create_tools_router(
|
|
|
326
362
|
max_results=req.max_results,
|
|
327
363
|
case_insensitive=req.case_insensitive,
|
|
328
364
|
context_lines=req.context_lines,
|
|
365
|
+
current_user=current_user,
|
|
329
366
|
)
|
|
330
367
|
|
|
331
368
|
|
|
332
369
|
@api_router.post("/tools/todo_read")
|
|
333
370
|
async def tools_todo_read(request: Request):
|
|
334
|
-
require_user(request)
|
|
335
|
-
return _tool_response(todo_read)
|
|
371
|
+
current_user = require_user(request)
|
|
372
|
+
return _tool_response(todo_read, current_user=current_user)
|
|
336
373
|
|
|
337
374
|
|
|
338
375
|
@api_router.post("/tools/todo_write")
|
|
339
376
|
async def tools_todo_write(req: ToolTodoWriteRequest, request: Request):
|
|
340
|
-
require_user(request)
|
|
341
|
-
return _tool_response(todo_write, req.todos)
|
|
377
|
+
current_user = require_user(request)
|
|
378
|
+
return _tool_response(todo_write, req.todos, current_user=current_user)
|
|
342
379
|
|
|
343
380
|
|
|
344
381
|
@api_router.post("/tools/clear_history")
|
|
345
382
|
async def tools_clear_history(req: ToolClearHistoryRequest, request: Request):
|
|
346
383
|
current_user = require_user(request)
|
|
347
|
-
|
|
384
|
+
scope = _history_scope(current_user)
|
|
385
|
+
result = _dispatch(
|
|
386
|
+
"clear_history",
|
|
387
|
+
{"keep_last": req.keep_last, **scope},
|
|
388
|
+
lambda: clear_history(req.keep_last, **scope),
|
|
389
|
+
)
|
|
348
390
|
append_audit_event(
|
|
349
391
|
"history_delete",
|
|
350
392
|
user_email=current_user,
|
|
@@ -358,38 +400,38 @@ def create_tools_router(
|
|
|
358
400
|
|
|
359
401
|
@api_router.post("/tools/inspect_html")
|
|
360
402
|
async def tools_inspect_html(req: ToolPathRequest, request: Request):
|
|
361
|
-
require_user(request)
|
|
362
|
-
return _tool_response(inspect_html, req.path)
|
|
403
|
+
current_user = require_user(request)
|
|
404
|
+
return _tool_response(inspect_html, req.path, current_user=current_user)
|
|
363
405
|
|
|
364
406
|
|
|
365
407
|
@api_router.post("/tools/preview_url")
|
|
366
408
|
async def tools_preview_url(req: ToolPathRequest, request: Request):
|
|
367
|
-
require_user(request)
|
|
368
|
-
return _tool_response(preview_url, req.path)
|
|
409
|
+
current_user = require_user(request)
|
|
410
|
+
return _tool_response(preview_url, req.path, current_user=current_user)
|
|
369
411
|
|
|
370
412
|
|
|
371
413
|
@api_router.post("/tools/create_docx")
|
|
372
414
|
async def tools_create_docx(req: ToolDocxRequest, request: Request):
|
|
373
|
-
require_user(request)
|
|
374
|
-
return _tool_response(create_docx, req.title, req.body, req.filename)
|
|
415
|
+
current_user = require_user(request)
|
|
416
|
+
return _tool_response(create_docx, req.title, req.body, req.filename, current_user=current_user)
|
|
375
417
|
|
|
376
418
|
|
|
377
419
|
@api_router.post("/tools/create_xlsx")
|
|
378
420
|
async def tools_create_xlsx(req: ToolXlsxRequest, request: Request):
|
|
379
|
-
require_user(request)
|
|
380
|
-
return _tool_response(create_xlsx, req.rows, req.filename, req.sheet_name)
|
|
421
|
+
current_user = require_user(request)
|
|
422
|
+
return _tool_response(create_xlsx, req.rows, req.filename, req.sheet_name, current_user=current_user)
|
|
381
423
|
|
|
382
424
|
|
|
383
425
|
@api_router.post("/tools/create_pptx")
|
|
384
426
|
async def tools_create_pptx(req: ToolPptxRequest, request: Request):
|
|
385
|
-
require_user(request)
|
|
386
|
-
return _tool_response(create_pptx, req.title, req.slides, req.filename)
|
|
427
|
+
current_user = require_user(request)
|
|
428
|
+
return _tool_response(create_pptx, req.title, req.slides, req.filename, current_user=current_user)
|
|
387
429
|
|
|
388
430
|
|
|
389
431
|
@api_router.post("/tools/create_pdf")
|
|
390
432
|
async def tools_create_pdf(req: ToolPdfRequest, request: Request):
|
|
391
|
-
require_user(request)
|
|
392
|
-
return _tool_response(create_pdf, req.title, req.body, req.filename)
|
|
433
|
+
current_user = require_user(request)
|
|
434
|
+
return _tool_response(create_pdf, req.title, req.body, req.filename, current_user=current_user)
|
|
393
435
|
|
|
394
436
|
|
|
395
437
|
@api_router.post("/tools/read_document")
|
|
@@ -402,7 +444,7 @@ def create_tools_router(
|
|
|
402
444
|
action="read",
|
|
403
445
|
user_email=current_user,
|
|
404
446
|
)
|
|
405
|
-
return _tool_response(read_document, req.path)
|
|
447
|
+
return _tool_response(read_document, req.path, current_user=current_user)
|
|
406
448
|
|
|
407
449
|
|
|
408
450
|
@api_router.get("/tools/pdf_pages")
|
|
@@ -503,38 +545,38 @@ def create_tools_router(
|
|
|
503
545
|
|
|
504
546
|
@api_router.post("/tools/knowledge_save")
|
|
505
547
|
async def tools_knowledge_save(req: ToolKnowledgeSaveRequest, request: Request):
|
|
506
|
-
require_user(request)
|
|
507
|
-
return _tool_response(knowledge_save, req.content, req.folder, req.title)
|
|
548
|
+
current_user = require_user(request)
|
|
549
|
+
return _tool_response(knowledge_save, req.content, req.folder, req.title, current_user=current_user)
|
|
508
550
|
|
|
509
551
|
|
|
510
552
|
@api_router.post("/tools/knowledge_search")
|
|
511
553
|
async def tools_knowledge_search(req: ToolKnowledgeSearchRequest, request: Request):
|
|
512
|
-
require_user(request)
|
|
513
|
-
return _tool_response(knowledge_search, req.query, req.max_results)
|
|
554
|
+
current_user = require_user(request)
|
|
555
|
+
return _tool_response(knowledge_search, req.query, req.max_results, current_user=current_user)
|
|
514
556
|
|
|
515
557
|
|
|
516
558
|
@api_router.get("/tools/knowledge_tree")
|
|
517
559
|
async def tools_knowledge_tree(request: Request):
|
|
518
|
-
require_user(request)
|
|
519
|
-
return _tool_response(knowledge_tree)
|
|
560
|
+
current_user = require_user(request)
|
|
561
|
+
return _tool_response(knowledge_tree, current_user=current_user)
|
|
520
562
|
|
|
521
563
|
|
|
522
564
|
@api_router.post("/tools/obsidian_save")
|
|
523
565
|
async def tools_obsidian_save(req: ToolKnowledgeSaveRequest, request: Request):
|
|
524
|
-
require_user(request)
|
|
525
|
-
return _tool_response(obsidian_save, req.content, req.folder, req.title)
|
|
566
|
+
current_user = require_user(request)
|
|
567
|
+
return _tool_response(obsidian_save, req.content, req.folder, req.title, current_user=current_user)
|
|
526
568
|
|
|
527
569
|
|
|
528
570
|
@api_router.post("/tools/obsidian_search")
|
|
529
571
|
async def tools_obsidian_search(req: ToolKnowledgeSearchRequest, request: Request):
|
|
530
|
-
require_user(request)
|
|
531
|
-
return _tool_response(obsidian_search, req.query, req.max_results)
|
|
572
|
+
current_user = require_user(request)
|
|
573
|
+
return _tool_response(obsidian_search, req.query, req.max_results, current_user=current_user)
|
|
532
574
|
|
|
533
575
|
|
|
534
576
|
@api_router.get("/tools/obsidian_tree")
|
|
535
577
|
async def tools_obsidian_tree(request: Request):
|
|
536
|
-
require_user(request)
|
|
537
|
-
return _tool_response(obsidian_tree)
|
|
578
|
+
current_user = require_user(request)
|
|
579
|
+
return _tool_response(obsidian_tree, current_user=current_user)
|
|
538
580
|
|
|
539
581
|
|
|
540
582
|
@api_router.get("/obsidian/status")
|
|
@@ -550,50 +592,50 @@ def create_tools_router(
|
|
|
550
592
|
|
|
551
593
|
@api_router.get("/tools/git_status")
|
|
552
594
|
async def tools_git_status(request: Request):
|
|
553
|
-
require_user(request)
|
|
554
|
-
return _tool_response(git_status)
|
|
595
|
+
current_user = require_user(request)
|
|
596
|
+
return _tool_response(git_status, current_user=current_user)
|
|
555
597
|
|
|
556
598
|
|
|
557
599
|
@api_router.post("/tools/git_diff")
|
|
558
600
|
async def tools_git_diff(req: ToolGitDiffRequest, request: Request):
|
|
559
|
-
require_user(request)
|
|
560
|
-
return _tool_response(git_diff, req.path, req.cwd)
|
|
601
|
+
current_user = require_user(request)
|
|
602
|
+
return _tool_response(git_diff, req.path, req.cwd, current_user=current_user)
|
|
561
603
|
|
|
562
604
|
|
|
563
605
|
@api_router.post("/tools/git_log")
|
|
564
606
|
async def tools_git_log(req: ToolGitLogRequest, request: Request):
|
|
565
|
-
require_user(request)
|
|
566
|
-
return _tool_response(git_log, req.max_count, req.cwd)
|
|
607
|
+
current_user = require_user(request)
|
|
608
|
+
return _tool_response(git_log, req.max_count, req.cwd, current_user=current_user)
|
|
567
609
|
|
|
568
610
|
|
|
569
611
|
@api_router.post("/tools/git_show")
|
|
570
612
|
async def tools_git_show(req: ToolGitShowRequest, request: Request):
|
|
571
|
-
require_user(request)
|
|
572
|
-
return _tool_response(git_show, req.revision, req.cwd)
|
|
613
|
+
current_user = require_user(request)
|
|
614
|
+
return _tool_response(git_show, req.revision, req.cwd, current_user=current_user)
|
|
573
615
|
|
|
574
616
|
|
|
575
617
|
@api_router.post("/tools/run_command")
|
|
576
618
|
async def tools_run_command(req: ToolRunCommandRequest, request: Request):
|
|
577
|
-
require_admin(request)
|
|
578
|
-
return _tool_response(run_command, req.command, req.cwd)
|
|
619
|
+
current_user, _users = require_admin(request)
|
|
620
|
+
return _tool_response(run_command, req.command, req.cwd, current_user=current_user, trusted_admin=True)
|
|
579
621
|
|
|
580
622
|
|
|
581
623
|
@api_router.get("/tools/network_status")
|
|
582
624
|
async def tools_network_status(request: Request):
|
|
583
|
-
require_user(request)
|
|
584
|
-
return _tool_response(network_status)
|
|
625
|
+
current_user = require_user(request)
|
|
626
|
+
return _tool_response(network_status, current_user=current_user)
|
|
585
627
|
|
|
586
628
|
|
|
587
629
|
@api_router.post("/tools/build_project")
|
|
588
630
|
async def tools_build_project(req: ToolScriptRequest, request: Request):
|
|
589
|
-
require_admin(request)
|
|
590
|
-
return _tool_response(build_project, req.cwd, req.script)
|
|
631
|
+
current_user, _users = require_admin(request)
|
|
632
|
+
return _tool_response(build_project, req.cwd, req.script, current_user=current_user, trusted_admin=True)
|
|
591
633
|
|
|
592
634
|
|
|
593
635
|
@api_router.post("/tools/deploy_project")
|
|
594
636
|
async def tools_deploy_project(req: ToolScriptRequest, request: Request):
|
|
595
|
-
require_admin(request)
|
|
596
|
-
return _tool_response(deploy_project, req.cwd, req.script)
|
|
637
|
+
current_user, _users = require_admin(request)
|
|
638
|
+
return _tool_response(deploy_project, req.cwd, req.script, current_user=current_user, trusted_admin=True)
|
|
597
639
|
|
|
598
640
|
|
|
599
641
|
@api_router.get("/tools/permissions")
|