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.
@@ -24,6 +24,7 @@ from PIL import Image
24
24
  from latticeai.core.agent import AgentRunContext, AgentState
25
25
  from latticeai.core.context_builder import format_sources_footnote, retrieve_context_for_generation
26
26
  from latticeai.core.document_generator import DocumentGenerationSession, detect_document_intent
27
+ from latticeai.core.hooks import dispatch_tool
27
28
  from latticeai.services.chat_service import ChatService
28
29
  from latticeai.services.tool_dispatch import build_agent_runtime, collect_created_files
29
30
  from telegram_bot import broadcast_web_chat
@@ -653,7 +654,9 @@ def create_chat_router(
653
654
  for case in eval_cases:
654
655
  case_id = case.get("id", "?")
655
656
  try:
656
- result = execute_tool(action_name, case.get("input", {}))
657
+ case_input = case.get("input", {})
658
+ result = dispatch_tool(hooks, action_name, case_input,
659
+ lambda: execute_tool(action_name, case_input), source="eval")
657
660
  criteria = case.get("pass_criteria", "")
658
661
  if "success == true" in criteria:
659
662
  passed = result.get("success") is True
@@ -11,6 +11,7 @@ from fastapi.responses import StreamingResponse
11
11
  from pydantic import BaseModel
12
12
 
13
13
  from latticeai.core.agent import extract_action as _extract_agent_action
14
+ from latticeai.core.hooks import dispatch_tool
14
15
  from tools import (
15
16
  ToolError,
16
17
  computer_click,
@@ -105,9 +106,15 @@ class CuDragRequest(BaseModel):
105
106
  y2: int
106
107
 
107
108
 
108
- def create_computer_use_router(*, model_router, require_user, tool_response, save_to_history) -> APIRouter:
109
+ def create_computer_use_router(*, model_router, require_user, tool_response, save_to_history, hooks=None) -> APIRouter:
109
110
  router = APIRouter()
110
111
 
112
+ def _dispatch(name, args, fn):
113
+ # Run a computer-use action through the unified pre_tool/post_tool
114
+ # lifecycle. With hooks=None this is a transparent pass-through, so the
115
+ # behaviour is unchanged when hooks are absent.
116
+ return dispatch_tool(hooks, name, dict(args or {}), fn, source="computer_use")
117
+
111
118
  @router.get("/tools/chrome_status")
112
119
  async def tools_chrome_status(request: Request):
113
120
  require_user(request)
@@ -122,7 +129,9 @@ def create_computer_use_router(*, model_router, require_user, tool_response, sav
122
129
  async def cu_status(request: Request):
123
130
  require_user(request)
124
131
  try:
125
- return computer_status()
132
+ return _dispatch("computer_status", {}, computer_status)
133
+ except PermissionError as exc:
134
+ raise HTTPException(status_code=403, detail=str(exc))
126
135
  except ToolError as exc:
127
136
  raise HTTPException(status_code=400, detail=str(exc))
128
137
 
@@ -130,7 +139,9 @@ def create_computer_use_router(*, model_router, require_user, tool_response, sav
130
139
  async def cu_screenshot(request: Request):
131
140
  require_user(request)
132
141
  try:
133
- return computer_screenshot()
142
+ return _dispatch("computer_screenshot", {}, computer_screenshot)
143
+ except PermissionError as exc:
144
+ raise HTTPException(status_code=403, detail=str(exc))
134
145
  except ToolError as exc:
135
146
  raise HTTPException(status_code=400, detail=str(exc))
136
147
 
@@ -196,7 +207,8 @@ def create_computer_use_router(*, model_router, require_user, tool_response, sav
196
207
  "action",
197
208
  {"step": 1, "action": "computer_open_url", "args": {"url": url, "app": "Google Chrome"}},
198
209
  )
199
- result = computer_open_url(url, "Google Chrome")
210
+ result = _dispatch("computer_open_url", {"url": url, "app": "Google Chrome"},
211
+ lambda: computer_open_url(url, "Google Chrome"))
200
212
  yield _send("result", {"step": 1, "action": "computer_open_url", "result": result})
201
213
  message = f"Google Chrome에서 {url}을 열었습니다."
202
214
  action_name = "computer_open_url"
@@ -205,14 +217,15 @@ def create_computer_use_router(*, model_router, require_user, tool_response, sav
205
217
  "action",
206
218
  {"step": 1, "action": "computer_open_app", "args": {"app": "Google Chrome"}},
207
219
  )
208
- result = computer_open_app("Google Chrome")
220
+ result = _dispatch("computer_open_app", {"app": "Google Chrome"},
221
+ lambda: computer_open_app("Google Chrome"))
209
222
  yield _send("result", {"step": 1, "action": "computer_open_app", "result": result})
210
223
  message = "Google Chrome을 열었습니다."
211
224
  action_name = "computer_open_app"
212
225
  save_to_history("user", req.task, source="web", conversation_id=req.conversation_id)
213
226
  save_to_history("assistant", message, source="web", conversation_id=req.conversation_id)
214
227
  yield _send("final", {"message": message, "steps": [{"step": 1, "action": action_name, "result": result}]})
215
- except ToolError as exc:
228
+ except (ToolError, PermissionError) as exc:
216
229
  yield _send("tool_error", {"step": 1, "action": "computer_open_app", "error": str(exc)})
217
230
  return
218
231
 
@@ -256,7 +269,7 @@ def create_computer_use_router(*, model_router, require_user, tool_response, sav
256
269
 
257
270
  yield _send("action", {"step": step + 1, "action": name, "args": args})
258
271
  try:
259
- result = execute_tool(name, args)
272
+ result = _dispatch(name, args, lambda: execute_tool(name, args))
260
273
  if name == "computer_screenshot" and "screenshot_b64" in result:
261
274
  last_screenshot_b64 = result["screenshot_b64"]
262
275
  result_summary = {k: v for k, v in result.items() if k != "screenshot_b64"}
@@ -275,7 +288,7 @@ def create_computer_use_router(*, model_router, require_user, tool_response, sav
275
288
  last_screenshot_b64 = None
276
289
  transcript.append({"step": step + 1, "action": name, "args": args, "result": result})
277
290
  yield _send("result", {"step": step + 1, "action": name, "result": result})
278
- except (ToolError, KeyError, TypeError) as exc:
291
+ except (ToolError, PermissionError, KeyError, TypeError) as exc:
279
292
  error_str = str(exc)
280
293
  transcript.append({"step": step + 1, "action": name, "args": args, "error": error_str})
281
294
  yield _send("tool_error", {"step": step + 1, "action": name, "error": error_str})
@@ -221,17 +221,30 @@ def create_tools_router(
221
221
 
222
222
  # ── Direct Tool API ───────────────────────────────────────────────────────────
223
223
 
224
- def _tool_response(fn, *args):
224
+ def _tool_response(fn, *args, **kwargs):
225
225
  # Shared tool lifecycle (same path as the agent + workflow tool calls):
226
- # pre_tool (may block) → execute → post_tool.
226
+ # pre_tool (may block) → execute → post_tool. Keyword args are forwarded
227
+ # to the tool and surfaced in the hook payload so read_file / edit_file /
228
+ # grep (which need kwargs) run through the SAME lifecycle as every other
229
+ # tool instead of bypassing it.
227
230
  tool_name = getattr(fn, "__name__", "tool")
228
231
  try:
229
- result = dispatch_tool(HOOKS, tool_name, {}, lambda: fn(*args), source="http")
232
+ result = dispatch_tool(HOOKS, tool_name, dict(kwargs), lambda: fn(*args, **kwargs), source="http")
230
233
  except PermissionError as exc:
231
234
  raise HTTPException(status_code=403, detail=str(exc))
232
235
  except ToolError as exc:
233
236
  raise HTTPException(status_code=400, detail=str(exc))
234
237
  return {"status": "ok", "workspace": str(AGENT_ROOT), "result": result}
238
+
239
+ def _dispatch(tool_name, args, fn):
240
+ # Lifecycle wrapper for callables that aren't a plain tools.* function
241
+ # (e.g. the server's clear_history). Same pre_tool/post_tool path.
242
+ try:
243
+ return dispatch_tool(HOOKS, tool_name, dict(args or {}), fn, source="http")
244
+ except PermissionError as exc:
245
+ raise HTTPException(status_code=403, detail=str(exc))
246
+ except ToolError as exc:
247
+ raise HTTPException(status_code=400, detail=str(exc))
235
248
 
236
249
 
237
250
  @api_router.post("/tools/list_dir")
@@ -249,11 +262,7 @@ def create_tools_router(
249
262
  @api_router.post("/tools/read_file")
250
263
  async def tools_read_file(req: ToolReadFileRequest, request: Request):
251
264
  require_user(request)
252
- try:
253
- return {"status": "ok", "workspace": str(AGENT_ROOT),
254
- "result": read_file(req.path, offset=req.offset, limit=req.limit, line_numbers=req.line_numbers)}
255
- except ToolError as exc:
256
- raise HTTPException(status_code=400, detail=str(exc))
265
+ return _tool_response(read_file, req.path, offset=req.offset, limit=req.limit, line_numbers=req.line_numbers)
257
266
 
258
267
 
259
268
  @api_router.post("/tools/write_file")
@@ -265,11 +274,7 @@ def create_tools_router(
265
274
  @api_router.post("/tools/edit_file")
266
275
  async def tools_edit_file(req: ToolEditFileRequest, request: Request):
267
276
  require_user(request)
268
- try:
269
- return {"status": "ok", "workspace": str(AGENT_ROOT),
270
- "result": edit_file(req.path, req.old_string, req.new_string, replace_all=req.replace_all)}
271
- except ToolError as exc:
272
- raise HTTPException(status_code=400, detail=str(exc))
277
+ return _tool_response(edit_file, req.path, req.old_string, req.new_string, replace_all=req.replace_all)
273
278
 
274
279
 
275
280
  @api_router.post("/tools/search_files")
@@ -281,18 +286,15 @@ def create_tools_router(
281
286
  @api_router.post("/tools/grep")
282
287
  async def tools_grep(req: ToolGrepRequest, request: Request):
283
288
  require_user(request)
284
- try:
285
- return {"status": "ok", "workspace": str(AGENT_ROOT),
286
- "result": grep(
287
- req.pattern,
288
- path=req.path,
289
- glob=req.glob,
290
- max_results=req.max_results,
291
- case_insensitive=req.case_insensitive,
292
- context_lines=req.context_lines,
293
- )}
294
- except ToolError as exc:
295
- raise HTTPException(status_code=400, detail=str(exc))
289
+ return _tool_response(
290
+ grep,
291
+ req.pattern,
292
+ path=req.path,
293
+ glob=req.glob,
294
+ max_results=req.max_results,
295
+ case_insensitive=req.case_insensitive,
296
+ context_lines=req.context_lines,
297
+ )
296
298
 
297
299
 
298
300
  @api_router.post("/tools/todo_read")
@@ -310,7 +312,7 @@ def create_tools_router(
310
312
  @api_router.post("/tools/clear_history")
311
313
  async def tools_clear_history(req: ToolClearHistoryRequest, request: Request):
312
314
  current_user = require_user(request)
313
- result = clear_history(req.keep_last)
315
+ result = _dispatch("clear_history", {"keep_last": req.keep_last}, lambda: clear_history(req.keep_last))
314
316
  append_audit_event(
315
317
  "history_delete",
316
318
  user_email=current_user,
@@ -461,6 +463,7 @@ def create_tools_router(
461
463
  require_user=require_user,
462
464
  tool_response=_tool_response,
463
465
  save_to_history=save_to_history,
466
+ hooks=HOOKS,
464
467
  ))
465
468
 
466
469
  @api_router.post("/tools/knowledge_save")
@@ -86,6 +86,7 @@ class Config:
86
86
  invite_code: str
87
87
  invite_gate_enabled: bool
88
88
  admin_emails: List[str]
89
+ trusted_proxies: List[str]
89
90
 
90
91
  # ── models ──────────────────────────────────────────────────────
91
92
  public_model: str
@@ -139,6 +140,7 @@ class Config:
139
140
 
140
141
  cors_extra = [item.strip() for item in _value(env, "LATTICEAI_CORS_ALLOWED_ORIGINS", "").split(",") if item.strip()]
141
142
  admin_emails = [item.strip().lower() for item in _value(env, "LATTICEAI_ADMIN_EMAILS", "").split(",") if item.strip()]
143
+ trusted_proxies = [item.strip() for item in _value(env, "LATTICEAI_TRUSTED_PROXIES", "").split(",") if item.strip()]
142
144
 
143
145
  public_model = _value(env, "LATTICEAI_PUBLIC_MODEL", _value(env, "LATTICEAI_DEFAULT_MODEL", "openai:gpt-4o-mini"))
144
146
  local_model = _value(env, "LATTICEAI_LOCAL_MODEL", "mlx-community/gemma-4-12b-it-4bit")
@@ -170,6 +172,7 @@ class Config:
170
172
  invite_code=_value(env, "LATTICEAI_INVITE_CODE", "gemma-lattice-ai"),
171
173
  invite_gate_enabled=_bool(env, "LATTICEAI_INVITE_GATE_ENABLED", default=False),
172
174
  admin_emails=admin_emails,
175
+ trusted_proxies=trusted_proxies,
173
176
  public_model=public_model,
174
177
  local_model=local_model,
175
178
  local_draft_model=_value(env, "LATTICEAI_LOCAL_DRAFT_MODEL", ""),
@@ -11,7 +11,7 @@ from copy import deepcopy
11
11
  from typing import Any, Dict, List, Optional
12
12
 
13
13
 
14
- MARKETPLACE_VERSION = "3.4.1"
14
+ MARKETPLACE_VERSION = "3.5.0"
15
15
  TEMPLATE_KINDS = ("plugin", "workflow", "agent")
16
16
 
17
17
 
@@ -14,7 +14,7 @@ from datetime import datetime
14
14
  from typing import Any, Callable, Dict, List, Optional
15
15
 
16
16
 
17
- MULTI_AGENT_VERSION = "3.4.1"
17
+ MULTI_AGENT_VERSION = "3.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
- for header in ("CF-Connecting-IP", "X-Forwarded-For"):
40
- val = request.headers.get(header)
41
- if val:
42
- return val.split(",")[0].strip()
43
- return request.client.host if request.client else "unknown"
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.4.1"
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
@@ -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.4.1",
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 -m py_compile ltcai_cli.py server.py latticeai/server_app.py latticeai/api/auth.py latticeai/api/chat.py latticeai/api/computer_use.py latticeai/api/deps.py latticeai/api/garden.py latticeai/api/local_files.py latticeai/api/permissions.py latticeai/api/setup.py latticeai/api/static_routes.py latticeai/api/tools.py latticeai/api/plugins.py latticeai/api/workflow_designer.py latticeai/api/agents.py latticeai/api/realtime.py latticeai/api/marketplace.py latticeai/api/search.py latticeai/services/search_service.py latticeai/core/local_embeddings.py latticeai/core/embedding_providers.py latticeai/services/agent_runtime.py latticeai/core/config.py latticeai/api/admin.py latticeai/services/app_context.py latticeai/services/model_runtime.py latticeai/services/model_catalog.py latticeai/services/model_recommendation.py latticeai/services/tool_dispatch.py latticeai/services/upload_service.py latticeai/core/tool_registry.py latticeai/core/enterprise.py latticeai/core/enterprise_admin.py latticeai/core/agent_prompts.py latticeai/core/workspace_os.py latticeai/core/plugins.py latticeai/core/marketplace.py latticeai/core/workflow_engine.py latticeai/core/multi_agent.py latticeai/core/realtime.py latticeai/api/mcp.py latticeai/core/hooks.py latticeai/core/builtin_hooks.py latticeai/api/hooks.py latticeai/core/agent_registry.py latticeai/api/agent_registry.py latticeai/services/memory_service.py latticeai/api/memory.py knowledge_graph.py knowledge_graph_api.py local_knowledge_api.py llm_router.py p_reinforce.py telegram_bot.py tools.py codex_telegram_bot.py",
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.py",
73
+ "tools/",
74
74
  "codex_telegram_bot.py",
75
75
  "mcp_registry.py",
76
76
  "latticeai/**/*.py",
package/requirements.txt CHANGED
@@ -10,6 +10,7 @@ python-pptx
10
10
  python-multipart
11
11
  keyring
12
12
  authlib
13
+ cryptography
13
14
  pdfplumber
14
15
  pypdfium2
15
16
  watchdog