ltcai 8.8.0 → 9.0.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 +33 -37
- package/auto_setup.py +84 -70
- package/docs/CHANGELOG.md +113 -237
- package/docs/CODE_REVIEW_2026-07-06.md +764 -0
- package/docs/COMMUNITY_AND_PLUGINS.md +3 -3
- package/docs/DEVELOPMENT.md +10 -10
- package/docs/LEGACY_COMPATIBILITY.md +1 -1
- package/docs/ONBOARDING.md +2 -2
- package/docs/PRODUCT_DIRECTION_REVIEW.md +3 -2
- package/docs/ROADMAP_RECOMMENDATIONS.md +61 -36
- package/docs/TRUST_MODEL.md +5 -1
- package/docs/WHY_LATTICE.md +4 -3
- package/docs/architecture.md +4 -0
- package/docs/kg-schema.md +1 -1
- package/lattice_brain/__init__.py +1 -1
- package/lattice_brain/archive.py +4 -9
- package/lattice_brain/conversations.py +156 -21
- package/lattice_brain/embeddings.py +38 -2
- package/lattice_brain/graph/_kg_common.py +39 -496
- package/lattice_brain/graph/_kg_constants.py +243 -0
- package/lattice_brain/graph/_kg_fsutil.py +297 -0
- package/lattice_brain/graph/discovery.py +0 -948
- package/lattice_brain/graph/discovery_index.py +972 -0
- package/lattice_brain/graph/json_utils.py +25 -0
- package/lattice_brain/graph/retrieval.py +66 -597
- package/lattice_brain/graph/retrieval_docgen.py +210 -0
- package/lattice_brain/graph/retrieval_vector.py +460 -0
- package/lattice_brain/graph/runtime.py +16 -0
- package/lattice_brain/graph/store.py +6 -0
- package/lattice_brain/ingestion.py +68 -0
- package/lattice_brain/portability.py +1 -9
- package/lattice_brain/quality.py +98 -4
- package/lattice_brain/runtime/agent_runtime.py +166 -0
- package/lattice_brain/runtime/multi_agent.py +1 -1
- package/lattice_brain/utils.py +28 -0
- package/latticeai/__init__.py +1 -1
- package/latticeai/api/chat.py +368 -418
- package/latticeai/api/chat_helpers.py +227 -0
- package/latticeai/api/computer_use.py +149 -31
- package/latticeai/api/marketplace.py +11 -0
- package/latticeai/api/mcp.py +3 -2
- package/latticeai/api/models.py +4 -1
- package/latticeai/api/permissions.py +72 -33
- package/latticeai/api/setup.py +17 -2
- package/latticeai/api/tools.py +105 -62
- package/latticeai/app_factory.py +101 -296
- package/latticeai/core/agent.py +25 -7
- package/latticeai/core/io_utils.py +37 -0
- package/latticeai/core/legacy_compatibility.py +1 -1
- package/latticeai/core/local_embeddings.py +2 -4
- package/latticeai/core/marketplace.py +33 -2
- package/latticeai/core/mcp_catalog.py +450 -0
- package/latticeai/core/mcp_registry.py +2 -441
- package/latticeai/core/sessions.py +11 -3
- package/latticeai/core/tool_registry.py +15 -4
- package/latticeai/core/users.py +4 -9
- package/latticeai/core/workspace_os.py +1 -1
- package/latticeai/core/workspace_os_utils.py +3 -17
- package/latticeai/integrations/telegram_bot.py +7 -2
- package/latticeai/models/model_providers.py +111 -0
- package/latticeai/models/router.py +58 -136
- package/latticeai/runtime/audit_runtime.py +27 -16
- package/latticeai/runtime/automation_runtime.py +9 -0
- package/latticeai/runtime/bootstrap.py +1 -1
- package/latticeai/runtime/history_runtime.py +163 -0
- package/latticeai/runtime/namespace_runtime.py +173 -0
- package/latticeai/runtime/network_config_runtime.py +56 -0
- package/latticeai/runtime/sso_config_runtime.py +128 -0
- package/latticeai/runtime/user_key_runtime.py +106 -0
- package/latticeai/services/app_context.py +1 -0
- package/latticeai/services/architecture_readiness.py +2 -2
- package/latticeai/services/memory_service.py +213 -0
- package/latticeai/services/model_engines.py +79 -12
- package/latticeai/services/model_runtime.py +24 -4
- package/latticeai/services/platform_runtime.py +9 -1
- package/latticeai/services/process_audit.py +208 -0
- package/latticeai/services/product_readiness.py +11 -11
- package/latticeai/services/review_queue.py +64 -11
- package/latticeai/services/run_executor.py +21 -0
- package/latticeai/services/search_service.py +106 -30
- package/latticeai/services/setup_detection.py +80 -0
- 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 +196 -74
- 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-C7K9wsO9.js → Act-21lIXx2E.js} +1 -1
- package/static/app/assets/Brain-BqUd5UJJ.js +321 -0
- package/static/app/assets/{Capture-B3V4_5Xp.js → Capture-BA7Z2Q1u.js} +1 -1
- package/static/app/assets/{Library-Cgj-EF50.js → Library-bFMtyni3.js} +1 -1
- package/static/app/assets/System-K6krGCqn.js +1 -0
- package/static/app/assets/index-C4R3ws30.js +17 -0
- package/static/app/assets/index-ChSeOB02.css +2 -0
- package/static/app/assets/primitives-sQU3it5I.js +1 -0
- package/static/app/assets/{textarea-CVQkN2Tk.js → textarea-DK3Fd_lR.js} +1 -1
- package/static/app/index.html +2 -2
- package/static/css/tokens.css +4 -2
- package/static/sw.js +1 -1
- package/tools/local_files.py +6 -0
- package/latticeai/runtime/sso_runtime.py +0 -52
- package/static/app/assets/Brain-I1OSzxJu.js +0 -321
- package/static/app/assets/System-D1Lkei3I.js +0 -1
- package/static/app/assets/index--P0ksosz.js +0 -17
- package/static/app/assets/index-DCh5AoXt.css +0 -2
- package/static/app/assets/primitives-BLqaKk5g.js +0 -1
|
@@ -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
|
-
expired = [
|
|
196
|
-
for
|
|
197
|
-
self.local_approvals.pop(
|
|
198
|
-
record = self.local_approvals.get(
|
|
219
|
+
expired = [approval_key for approval_key, value in self.local_approvals.items() if float(value.get("expires_at", 0)) < now]
|
|
220
|
+
for expired_key in expired:
|
|
221
|
+
self.local_approvals.pop(expired_key, None)
|
|
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")
|
|
@@ -499,42 +541,43 @@ def create_tools_router(
|
|
|
499
541
|
tool_response=_tool_response,
|
|
500
542
|
save_to_history=save_to_history,
|
|
501
543
|
hooks=HOOKS,
|
|
544
|
+
append_audit_event=append_audit_event,
|
|
502
545
|
))
|
|
503
546
|
|
|
504
547
|
@api_router.post("/tools/knowledge_save")
|
|
505
548
|
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)
|
|
549
|
+
current_user = require_user(request)
|
|
550
|
+
return _tool_response(knowledge_save, req.content, req.folder, req.title, current_user=current_user)
|
|
508
551
|
|
|
509
552
|
|
|
510
553
|
@api_router.post("/tools/knowledge_search")
|
|
511
554
|
async def tools_knowledge_search(req: ToolKnowledgeSearchRequest, request: Request):
|
|
512
|
-
require_user(request)
|
|
513
|
-
return _tool_response(knowledge_search, req.query, req.max_results)
|
|
555
|
+
current_user = require_user(request)
|
|
556
|
+
return _tool_response(knowledge_search, req.query, req.max_results, current_user=current_user)
|
|
514
557
|
|
|
515
558
|
|
|
516
559
|
@api_router.get("/tools/knowledge_tree")
|
|
517
560
|
async def tools_knowledge_tree(request: Request):
|
|
518
|
-
require_user(request)
|
|
519
|
-
return _tool_response(knowledge_tree)
|
|
561
|
+
current_user = require_user(request)
|
|
562
|
+
return _tool_response(knowledge_tree, current_user=current_user)
|
|
520
563
|
|
|
521
564
|
|
|
522
565
|
@api_router.post("/tools/obsidian_save")
|
|
523
566
|
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)
|
|
567
|
+
current_user = require_user(request)
|
|
568
|
+
return _tool_response(obsidian_save, req.content, req.folder, req.title, current_user=current_user)
|
|
526
569
|
|
|
527
570
|
|
|
528
571
|
@api_router.post("/tools/obsidian_search")
|
|
529
572
|
async def tools_obsidian_search(req: ToolKnowledgeSearchRequest, request: Request):
|
|
530
|
-
require_user(request)
|
|
531
|
-
return _tool_response(obsidian_search, req.query, req.max_results)
|
|
573
|
+
current_user = require_user(request)
|
|
574
|
+
return _tool_response(obsidian_search, req.query, req.max_results, current_user=current_user)
|
|
532
575
|
|
|
533
576
|
|
|
534
577
|
@api_router.get("/tools/obsidian_tree")
|
|
535
578
|
async def tools_obsidian_tree(request: Request):
|
|
536
|
-
require_user(request)
|
|
537
|
-
return _tool_response(obsidian_tree)
|
|
579
|
+
current_user = require_user(request)
|
|
580
|
+
return _tool_response(obsidian_tree, current_user=current_user)
|
|
538
581
|
|
|
539
582
|
|
|
540
583
|
@api_router.get("/obsidian/status")
|
|
@@ -550,50 +593,50 @@ def create_tools_router(
|
|
|
550
593
|
|
|
551
594
|
@api_router.get("/tools/git_status")
|
|
552
595
|
async def tools_git_status(request: Request):
|
|
553
|
-
require_user(request)
|
|
554
|
-
return _tool_response(git_status)
|
|
596
|
+
current_user = require_user(request)
|
|
597
|
+
return _tool_response(git_status, current_user=current_user)
|
|
555
598
|
|
|
556
599
|
|
|
557
600
|
@api_router.post("/tools/git_diff")
|
|
558
601
|
async def tools_git_diff(req: ToolGitDiffRequest, request: Request):
|
|
559
|
-
require_user(request)
|
|
560
|
-
return _tool_response(git_diff, req.path, req.cwd)
|
|
602
|
+
current_user = require_user(request)
|
|
603
|
+
return _tool_response(git_diff, req.path, req.cwd, current_user=current_user)
|
|
561
604
|
|
|
562
605
|
|
|
563
606
|
@api_router.post("/tools/git_log")
|
|
564
607
|
async def tools_git_log(req: ToolGitLogRequest, request: Request):
|
|
565
|
-
require_user(request)
|
|
566
|
-
return _tool_response(git_log, req.max_count, req.cwd)
|
|
608
|
+
current_user = require_user(request)
|
|
609
|
+
return _tool_response(git_log, req.max_count, req.cwd, current_user=current_user)
|
|
567
610
|
|
|
568
611
|
|
|
569
612
|
@api_router.post("/tools/git_show")
|
|
570
613
|
async def tools_git_show(req: ToolGitShowRequest, request: Request):
|
|
571
|
-
require_user(request)
|
|
572
|
-
return _tool_response(git_show, req.revision, req.cwd)
|
|
614
|
+
current_user = require_user(request)
|
|
615
|
+
return _tool_response(git_show, req.revision, req.cwd, current_user=current_user)
|
|
573
616
|
|
|
574
617
|
|
|
575
618
|
@api_router.post("/tools/run_command")
|
|
576
619
|
async def tools_run_command(req: ToolRunCommandRequest, request: Request):
|
|
577
|
-
require_admin(request)
|
|
578
|
-
return _tool_response(run_command, req.command, req.cwd)
|
|
620
|
+
current_user, _users = require_admin(request)
|
|
621
|
+
return _tool_response(run_command, req.command, req.cwd, current_user=current_user, trusted_admin=True)
|
|
579
622
|
|
|
580
623
|
|
|
581
624
|
@api_router.get("/tools/network_status")
|
|
582
625
|
async def tools_network_status(request: Request):
|
|
583
|
-
require_user(request)
|
|
584
|
-
return _tool_response(network_status)
|
|
626
|
+
current_user = require_user(request)
|
|
627
|
+
return _tool_response(network_status, current_user=current_user)
|
|
585
628
|
|
|
586
629
|
|
|
587
630
|
@api_router.post("/tools/build_project")
|
|
588
631
|
async def tools_build_project(req: ToolScriptRequest, request: Request):
|
|
589
|
-
require_admin(request)
|
|
590
|
-
return _tool_response(build_project, req.cwd, req.script)
|
|
632
|
+
current_user, _users = require_admin(request)
|
|
633
|
+
return _tool_response(build_project, req.cwd, req.script, current_user=current_user, trusted_admin=True)
|
|
591
634
|
|
|
592
635
|
|
|
593
636
|
@api_router.post("/tools/deploy_project")
|
|
594
637
|
async def tools_deploy_project(req: ToolScriptRequest, request: Request):
|
|
595
|
-
require_admin(request)
|
|
596
|
-
return _tool_response(deploy_project, req.cwd, req.script)
|
|
638
|
+
current_user, _users = require_admin(request)
|
|
639
|
+
return _tool_response(deploy_project, req.cwd, req.script, current_user=current_user, trusted_admin=True)
|
|
597
640
|
|
|
598
641
|
|
|
599
642
|
@api_router.get("/tools/permissions")
|