ltcai 3.4.1 → 3.5.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 +173 -248
- package/docs/RUNTIME_HOOK_COVERAGE_v3.5.0.md +56 -0
- package/latticeai/__init__.py +1 -1
- package/latticeai/api/auth.py +37 -9
- package/latticeai/api/chat.py +4 -1
- package/latticeai/api/computer_use.py +21 -8
- package/latticeai/api/tools.py +29 -26
- package/latticeai/core/config.py +3 -0
- package/latticeai/core/marketplace.py +1 -1
- package/latticeai/core/multi_agent.py +1 -1
- package/latticeai/core/oidc.py +205 -0
- package/latticeai/core/security.py +59 -5
- package/latticeai/core/workspace_os.py +1 -1
- package/latticeai/server_app.py +7 -0
- package/package.json +3 -3
- package/requirements.txt +1 -0
- package/scripts/check_python.py +87 -0
- package/static/css/reference/account.css +1 -1
- package/static/css/reference/admin.css +1 -1
- package/static/css/reference/base.css +8 -5
- package/static/css/reference/chat.css +8 -8
- package/static/css/reference/graph.css +2 -2
- package/static/css/responsive.css +2 -2
- package/static/v3/asset-manifest.json +3 -3
- package/static/v3/css/{lattice.shell.6ceea7c8.css → lattice.shell.8fcc9d33.css} +2 -1
- package/static/v3/css/lattice.shell.css +2 -1
- package/static/workspace.css +1 -1
- package/tools/__init__.py +276 -0
- package/tools/commands.py +188 -0
- package/tools/computer.py +185 -0
- package/tools/documents.py +243 -0
- package/tools/filesystem.py +560 -0
- package/tools/knowledge.py +97 -0
- package/tools/local_files.py +69 -0
- package/tools/network.py +66 -0
- package/tools.py +0 -1525
package/latticeai/api/chat.py
CHANGED
|
@@ -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
|
-
|
|
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
|
|
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 =
|
|
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})
|
package/latticeai/api/tools.py
CHANGED
|
@@ -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,
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
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")
|
package/latticeai/core/config.py
CHANGED
|
@@ -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", ""),
|
|
@@ -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.
|
|
17
|
+
MULTI_AGENT_VERSION = "3.5.0"
|
|
18
18
|
|
|
19
19
|
AGENT_ROLES = ("researcher", "planner", "executor", "reviewer", "release")
|
|
20
20
|
CORE_PIPELINE = ("planner", "executor", "reviewer")
|
|
@@ -0,0 +1,205 @@
|
|
|
1
|
+
"""Self-contained OIDC ID-token validation.
|
|
2
|
+
|
|
3
|
+
A JWT is ``base64url(header).base64url(claims).base64url(signature)``. The login
|
|
4
|
+
flow must *never* trust a decoded payload on its own — without verifying the
|
|
5
|
+
signature an attacker can forge any ``email``/``sub`` claim. This module verifies
|
|
6
|
+
the RSA signature against the provider's published JWKS and then validates the
|
|
7
|
+
standard registered claims plus the login ``nonce``.
|
|
8
|
+
|
|
9
|
+
Design goals:
|
|
10
|
+
|
|
11
|
+
* No third-party JWT dependency — only the standard library plus ``cryptography``
|
|
12
|
+
(already a transitive dependency, and pinned explicitly in ``pyproject.toml``).
|
|
13
|
+
* **Fail-closed**: any anomaly raises :class:`OIDCValidationError`; the caller
|
|
14
|
+
must reject the login. There is no "best effort accept".
|
|
15
|
+
* Asymmetric algorithms only (``RS256``/``RS384``/``RS512``). ``alg: none`` and
|
|
16
|
+
symmetric ``HS*`` tokens are rejected outright — the classic OIDC bypasses.
|
|
17
|
+
* Pure and injectable: :func:`verify_id_token` takes the JWKS and clock as
|
|
18
|
+
arguments so every rejection path is unit-testable offline.
|
|
19
|
+
"""
|
|
20
|
+
|
|
21
|
+
from __future__ import annotations
|
|
22
|
+
|
|
23
|
+
import base64
|
|
24
|
+
import json
|
|
25
|
+
import time
|
|
26
|
+
from typing import Any, Dict, List, Optional
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
class OIDCValidationError(Exception):
|
|
30
|
+
"""Raised when an OIDC ID token fails any validation step (fail-closed)."""
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
# Asymmetric RSA algorithms only. Excluding HS*/none is deliberate: an attacker
|
|
34
|
+
# who can set ``alg`` must not be able to downgrade to a symmetric or unsigned
|
|
35
|
+
# token. Maps JWT alg name → cryptography hash name.
|
|
36
|
+
_ALLOWED_ALGS: Dict[str, str] = {"RS256": "sha256", "RS384": "sha384", "RS512": "sha512"}
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def _b64url_decode(segment: str) -> bytes:
|
|
40
|
+
if not isinstance(segment, str) or not segment:
|
|
41
|
+
raise OIDCValidationError("empty JWT segment")
|
|
42
|
+
padding = "=" * (-len(segment) % 4)
|
|
43
|
+
try:
|
|
44
|
+
return base64.urlsafe_b64decode(segment + padding)
|
|
45
|
+
except Exception as exc: # malformed base64
|
|
46
|
+
raise OIDCValidationError(f"invalid base64url segment: {exc}")
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def _b64url_uint(value: str) -> int:
|
|
50
|
+
return int.from_bytes(_b64url_decode(value), "big")
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def _split(token: str) -> List[str]:
|
|
54
|
+
parts = str(token or "").split(".")
|
|
55
|
+
if len(parts) != 3 or not all(parts):
|
|
56
|
+
raise OIDCValidationError("malformed JWT: expected three non-empty segments")
|
|
57
|
+
return parts
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def decode_unverified_header(token: str) -> Dict[str, Any]:
|
|
61
|
+
"""Decode the JWT header WITHOUT verifying it (used only to pick the key)."""
|
|
62
|
+
header_b64 = _split(token)[0]
|
|
63
|
+
try:
|
|
64
|
+
header = json.loads(_b64url_decode(header_b64))
|
|
65
|
+
except Exception as exc:
|
|
66
|
+
raise OIDCValidationError(f"invalid JWT header: {exc}")
|
|
67
|
+
if not isinstance(header, dict):
|
|
68
|
+
raise OIDCValidationError("JWT header is not an object")
|
|
69
|
+
return header
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def _public_key_from_jwk(jwk: Dict[str, Any]):
|
|
73
|
+
if not isinstance(jwk, dict) or jwk.get("kty") != "RSA":
|
|
74
|
+
raise OIDCValidationError("unsupported JWK key type (RSA required)")
|
|
75
|
+
if not jwk.get("n") or not jwk.get("e"):
|
|
76
|
+
raise OIDCValidationError("JWK missing modulus/exponent")
|
|
77
|
+
from cryptography.hazmat.primitives.asymmetric.rsa import RSAPublicNumbers
|
|
78
|
+
|
|
79
|
+
numbers = RSAPublicNumbers(_b64url_uint(jwk["e"]), _b64url_uint(jwk["n"]))
|
|
80
|
+
return numbers.public_key()
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def _candidate_keys(jwks: Any, kid: Optional[str]) -> List[Dict[str, Any]]:
|
|
84
|
+
keys = jwks.get("keys") if isinstance(jwks, dict) else jwks
|
|
85
|
+
if not isinstance(keys, list) or not keys:
|
|
86
|
+
raise OIDCValidationError("JWKS contains no keys")
|
|
87
|
+
rsa_keys = [k for k in keys if isinstance(k, dict) and k.get("kty") == "RSA"]
|
|
88
|
+
if kid:
|
|
89
|
+
matched = [k for k in rsa_keys if k.get("kid") == kid]
|
|
90
|
+
if not matched:
|
|
91
|
+
raise OIDCValidationError("no JWKS key matches the token 'kid'")
|
|
92
|
+
return matched
|
|
93
|
+
return rsa_keys
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def _verify_signature(token: str, jwks: Any) -> Dict[str, Any]:
|
|
97
|
+
header_b64, payload_b64, sig_b64 = _split(token)
|
|
98
|
+
header = decode_unverified_header(token)
|
|
99
|
+
alg = header.get("alg")
|
|
100
|
+
if alg not in _ALLOWED_ALGS:
|
|
101
|
+
# Rejects 'none' and symmetric HS* — the canonical signature-bypass attacks.
|
|
102
|
+
raise OIDCValidationError(f"unsupported or unsafe JWT alg: {alg!r}")
|
|
103
|
+
|
|
104
|
+
from cryptography.exceptions import InvalidSignature
|
|
105
|
+
from cryptography.hazmat.primitives import hashes
|
|
106
|
+
from cryptography.hazmat.primitives.asymmetric import padding
|
|
107
|
+
|
|
108
|
+
hash_obj = {"sha256": hashes.SHA256(), "sha384": hashes.SHA384(), "sha512": hashes.SHA512()}[
|
|
109
|
+
_ALLOWED_ALGS[alg]
|
|
110
|
+
]
|
|
111
|
+
signature = _b64url_decode(sig_b64)
|
|
112
|
+
signing_input = f"{header_b64}.{payload_b64}".encode("ascii")
|
|
113
|
+
|
|
114
|
+
for jwk in _candidate_keys(jwks, header.get("kid")):
|
|
115
|
+
try:
|
|
116
|
+
public_key = _public_key_from_jwk(jwk)
|
|
117
|
+
public_key.verify(signature, signing_input, padding.PKCS1v15(), hash_obj)
|
|
118
|
+
except (InvalidSignature, OIDCValidationError):
|
|
119
|
+
continue
|
|
120
|
+
# Signature verified — now it is safe to parse the claims.
|
|
121
|
+
try:
|
|
122
|
+
claims = json.loads(_b64url_decode(payload_b64))
|
|
123
|
+
except Exception as exc:
|
|
124
|
+
raise OIDCValidationError(f"invalid JWT claims JSON: {exc}")
|
|
125
|
+
if not isinstance(claims, dict):
|
|
126
|
+
raise OIDCValidationError("JWT claims are not an object")
|
|
127
|
+
return claims
|
|
128
|
+
|
|
129
|
+
raise OIDCValidationError("signature verification failed against all JWKS keys")
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
def verify_id_token(
|
|
133
|
+
id_token: str,
|
|
134
|
+
*,
|
|
135
|
+
jwks: Any,
|
|
136
|
+
issuer: str,
|
|
137
|
+
audience: str,
|
|
138
|
+
nonce: Optional[str] = None,
|
|
139
|
+
now: Optional[float] = None,
|
|
140
|
+
leeway: int = 60,
|
|
141
|
+
) -> Dict[str, Any]:
|
|
142
|
+
"""Verify an OIDC ID token and return its claims, or raise ``OIDCValidationError``.
|
|
143
|
+
|
|
144
|
+
Validates, in order: signature (against ``jwks``, RSA only), ``iss``, ``aud``
|
|
145
|
+
(and ``azp`` when multi-audience), ``exp``, ``iat``/``nbf``, and ``nonce``.
|
|
146
|
+
All checks are fail-closed.
|
|
147
|
+
"""
|
|
148
|
+
if not id_token:
|
|
149
|
+
raise OIDCValidationError("missing id_token")
|
|
150
|
+
if not issuer:
|
|
151
|
+
raise OIDCValidationError("issuer not configured")
|
|
152
|
+
if not audience:
|
|
153
|
+
raise OIDCValidationError("audience (client_id) not configured")
|
|
154
|
+
|
|
155
|
+
claims = _verify_signature(id_token, jwks)
|
|
156
|
+
current = int(now if now is not None else time.time())
|
|
157
|
+
|
|
158
|
+
if claims.get("iss") != issuer:
|
|
159
|
+
raise OIDCValidationError("issuer mismatch")
|
|
160
|
+
|
|
161
|
+
aud = claims.get("aud")
|
|
162
|
+
audiences = aud if isinstance(aud, list) else [aud]
|
|
163
|
+
if audience not in audiences:
|
|
164
|
+
raise OIDCValidationError("audience mismatch")
|
|
165
|
+
if isinstance(aud, list) and len(aud) > 1 and claims.get("azp") not in (None, audience):
|
|
166
|
+
raise OIDCValidationError("azp (authorized party) mismatch")
|
|
167
|
+
|
|
168
|
+
exp = claims.get("exp")
|
|
169
|
+
if exp is None:
|
|
170
|
+
raise OIDCValidationError("token missing 'exp'")
|
|
171
|
+
if current > int(exp) + leeway:
|
|
172
|
+
raise OIDCValidationError("token expired")
|
|
173
|
+
|
|
174
|
+
iat = claims.get("iat")
|
|
175
|
+
if iat is not None and int(iat) - leeway > current:
|
|
176
|
+
raise OIDCValidationError("token 'iat' is in the future")
|
|
177
|
+
|
|
178
|
+
nbf = claims.get("nbf")
|
|
179
|
+
if nbf is not None and int(nbf) - leeway > current:
|
|
180
|
+
raise OIDCValidationError("token not yet valid ('nbf')")
|
|
181
|
+
|
|
182
|
+
if nonce is not None and claims.get("nonce") != nonce:
|
|
183
|
+
raise OIDCValidationError("nonce mismatch")
|
|
184
|
+
|
|
185
|
+
return claims
|
|
186
|
+
|
|
187
|
+
|
|
188
|
+
async def fetch_jwks(jwks_uri: str, *, timeout: float = 15.0) -> Dict[str, Any]:
|
|
189
|
+
"""Fetch a provider JWKS document. Network-only; injectable in tests."""
|
|
190
|
+
if not jwks_uri:
|
|
191
|
+
raise OIDCValidationError("discovery document has no 'jwks_uri'")
|
|
192
|
+
import httpx
|
|
193
|
+
|
|
194
|
+
async with httpx.AsyncClient() as client:
|
|
195
|
+
resp = await client.get(jwks_uri, timeout=timeout)
|
|
196
|
+
resp.raise_for_status()
|
|
197
|
+
return resp.json()
|
|
198
|
+
|
|
199
|
+
|
|
200
|
+
__all__ = [
|
|
201
|
+
"OIDCValidationError",
|
|
202
|
+
"verify_id_token",
|
|
203
|
+
"fetch_jwks",
|
|
204
|
+
"decode_unverified_header",
|
|
205
|
+
]
|
|
@@ -35,12 +35,66 @@ def host_is_loopback(host: str) -> bool:
|
|
|
35
35
|
return False
|
|
36
36
|
|
|
37
37
|
|
|
38
|
+
# ── Trusted-proxy handling ────────────────────────────────────────────────────
|
|
39
|
+
# ``client_ip`` is the key used for IP rate limiting (login / register) and for
|
|
40
|
+
# audit logging. A forwarded header (``X-Forwarded-For`` / ``CF-Connecting-IP``)
|
|
41
|
+
# is *client-controllable*, so honoring it unconditionally lets anyone spoof
|
|
42
|
+
# their source IP and bypass per-IP rate limits. We therefore trust those headers
|
|
43
|
+
# ONLY when the direct peer is a configured trusted proxy (e.g. the Cloudflare /
|
|
44
|
+
# Vercel edge in front of the app). Default: no trusted proxies → use the peer
|
|
45
|
+
# address, which is the safe, local-first behaviour.
|
|
46
|
+
_FORWARDED_HEADERS = ("CF-Connecting-IP", "X-Forwarded-For")
|
|
47
|
+
_trusted_proxies: List["ipaddress._BaseNetwork"] = []
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def configure_trusted_proxies(values) -> int:
|
|
51
|
+
"""Set the trusted-proxy allowlist from IPs / CIDRs. Returns the count parsed.
|
|
52
|
+
|
|
53
|
+
Accepts a comma-separated string or an iterable of IPs/CIDRs. Invalid entries
|
|
54
|
+
are skipped. Passing an empty value disables forwarded-header trust entirely.
|
|
55
|
+
"""
|
|
56
|
+
global _trusted_proxies
|
|
57
|
+
if isinstance(values, str):
|
|
58
|
+
items = [v.strip() for v in values.split(",")]
|
|
59
|
+
else:
|
|
60
|
+
items = [str(v).strip() for v in (values or [])]
|
|
61
|
+
networks: List["ipaddress._BaseNetwork"] = []
|
|
62
|
+
for item in items:
|
|
63
|
+
if not item:
|
|
64
|
+
continue
|
|
65
|
+
try:
|
|
66
|
+
networks.append(ipaddress.ip_network(item, strict=False))
|
|
67
|
+
except ValueError:
|
|
68
|
+
continue
|
|
69
|
+
_trusted_proxies = networks
|
|
70
|
+
return len(networks)
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def _peer_is_trusted_proxy(peer: str) -> bool:
|
|
74
|
+
if not peer or not _trusted_proxies:
|
|
75
|
+
return False
|
|
76
|
+
try:
|
|
77
|
+
addr = ipaddress.ip_address(peer)
|
|
78
|
+
except ValueError:
|
|
79
|
+
return False
|
|
80
|
+
return any(addr in net for net in _trusted_proxies)
|
|
81
|
+
|
|
82
|
+
|
|
38
83
|
def client_ip(request) -> str:
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
84
|
+
peer = request.client.host if request.client else ""
|
|
85
|
+
# Only a trusted proxy's forwarded headers are honoured; otherwise the
|
|
86
|
+
# client-supplied header is ignored so per-IP rate limits cannot be spoofed.
|
|
87
|
+
if _peer_is_trusted_proxy(peer):
|
|
88
|
+
for header in _FORWARDED_HEADERS:
|
|
89
|
+
val = request.headers.get(header)
|
|
90
|
+
if val:
|
|
91
|
+
candidate = val.split(",")[0].strip()
|
|
92
|
+
try:
|
|
93
|
+
ipaddress.ip_address(candidate)
|
|
94
|
+
return candidate
|
|
95
|
+
except ValueError:
|
|
96
|
+
continue
|
|
97
|
+
return peer or "unknown"
|
|
44
98
|
|
|
45
99
|
|
|
46
100
|
_FILE_MAGIC: Dict[str, List[bytes]] = {
|
|
@@ -18,7 +18,7 @@ from pathlib import Path
|
|
|
18
18
|
from typing import Any, Callable, Dict, Iterable, List, Optional
|
|
19
19
|
|
|
20
20
|
|
|
21
|
-
WORKSPACE_OS_VERSION = "3.
|
|
21
|
+
WORKSPACE_OS_VERSION = "3.5.0"
|
|
22
22
|
|
|
23
23
|
# Workspace types separate single-user Personal workspaces from shared
|
|
24
24
|
# Organization workspaces. Both keep the same local-first JSON store; the type
|
package/latticeai/server_app.py
CHANGED
|
@@ -40,6 +40,7 @@ from latticeai.core.security import (
|
|
|
40
40
|
verify_password,
|
|
41
41
|
host_is_loopback as _host_is_loopback_impl,
|
|
42
42
|
client_ip as _client_ip_impl,
|
|
43
|
+
configure_trusted_proxies as _configure_trusted_proxies,
|
|
43
44
|
bytes_match_extension as _bytes_match_extension_impl,
|
|
44
45
|
redact_secret_text as _redact_secret_text,
|
|
45
46
|
check_ip_rate_limit as _check_ip_rate_limit,
|
|
@@ -157,6 +158,12 @@ from datetime import datetime
|
|
|
157
158
|
CONFIG = Config.from_env()
|
|
158
159
|
APP_VERSION = WORKSPACE_OS_VERSION
|
|
159
160
|
|
|
161
|
+
# Forwarded headers (X-Forwarded-For / CF-Connecting-IP) are only honoured for
|
|
162
|
+
# IP rate limiting when the direct peer is one of these trusted proxies. Empty by
|
|
163
|
+
# default (local-first): the peer address is used and client-supplied headers are
|
|
164
|
+
# ignored, so per-IP rate limits cannot be spoofed.
|
|
165
|
+
_configure_trusted_proxies(CONFIG.trusted_proxies)
|
|
166
|
+
|
|
160
167
|
APP_MODE = CONFIG.app_mode
|
|
161
168
|
IS_PUBLIC_MODE = CONFIG.is_public
|
|
162
169
|
DEFAULT_HOST = CONFIG.host
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "ltcai",
|
|
3
|
-
"version": "3.
|
|
3
|
+
"version": "3.5.0",
|
|
4
4
|
"description": "Lattice AI v3 local-first AI workspace platform with knowledge graph, vector index, hybrid search, agents, and workspace modes.",
|
|
5
5
|
"homepage": "https://github.com/TaeSooPark-PTS/LatticeAI#readme",
|
|
6
6
|
"repository": {
|
|
@@ -20,7 +20,7 @@
|
|
|
20
20
|
"build": "npm run build:assets && npm run build:python",
|
|
21
21
|
"build:assets": "node scripts/build_v3_assets.mjs",
|
|
22
22
|
"build:python": "python3 -m build",
|
|
23
|
-
"check:python": "python3
|
|
23
|
+
"check:python": "python3 scripts/check_python.py",
|
|
24
24
|
"lint": "node --check static/scripts/account.js && node --check static/scripts/admin.js && node --check static/scripts/chat.js && node --check static/scripts/graph.js && node --check static/scripts/platform.js && node --check static/scripts/ux.js && node --check static/scripts/workspace.js && node --check tests/visual/mock_server.cjs && node --check tests/visual/v3.spec.js && npm run lint:v3",
|
|
25
25
|
"lint:v3": "node scripts/lint_v3.mjs",
|
|
26
26
|
"typecheck": "cd vscode-extension && npm run build",
|
|
@@ -70,7 +70,7 @@
|
|
|
70
70
|
"llm_router.py",
|
|
71
71
|
"p_reinforce.py",
|
|
72
72
|
"telegram_bot.py",
|
|
73
|
-
"tools
|
|
73
|
+
"tools/",
|
|
74
74
|
"codex_telegram_bot.py",
|
|
75
75
|
"mcp_registry.py",
|
|
76
76
|
"latticeai/**/*.py",
|