ltcai 3.4.0 → 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 +175 -225
- package/docs/RUNTIME_HOOK_COVERAGE_v3.5.0.md +56 -0
- package/docs/assets/v3.4.1/e2e_runtime_log.txt +42 -0
- package/docs/assets/v3.4.1/hooks-dispatch.png +0 -0
- package/docs/assets/v3.4.1/local-agent.png +0 -0
- package/latticeai/__init__.py +1 -1
- package/latticeai/api/auth.py +37 -9
- package/latticeai/api/chat.py +6 -1
- package/latticeai/api/computer_use.py +21 -8
- package/latticeai/api/local_files.py +76 -10
- package/latticeai/api/tools.py +35 -35
- package/latticeai/core/agent.py +13 -2
- package/latticeai/core/builtin_hooks.py +106 -0
- package/latticeai/core/config.py +3 -0
- package/latticeai/core/hooks.py +76 -2
- 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/workflow_engine.py +3 -3
- package/latticeai/core/workspace_os.py +1 -1
- package/latticeai/server_app.py +22 -34
- package/latticeai/services/platform_runtime.py +18 -6
- package/latticeai/services/tool_dispatch.py +2 -0
- package/latticeai/services/upload_service.py +24 -4
- package/local_knowledge_api.py +27 -1
- 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 +9 -9
- 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/v3/js/{app.c4acfdd8.js → app.d086489d.js} +1 -1
- package/static/v3/js/core/{components.35f02e4c.js → components.f25b3b93.js} +1 -1
- package/static/v3/js/core/components.js +1 -1
- package/static/v3/js/core/{shell.80a6ad82.js → shell.d05266f5.js} +1 -1
- package/static/v3/js/views/{hooks.13845954.js → hooks.37895880.js} +12 -7
- package/static/v3/js/views/hooks.js +12 -7
- package/static/v3/js/views/{my-computer.c3ef5283.js → my-computer.d9d9ae1c.js} +7 -4
- package/static/v3/js/views/my-computer.js +7 -4
- 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/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", ""),
|
package/latticeai/core/hooks.py
CHANGED
|
@@ -36,11 +36,23 @@ HOOK_KINDS = (
|
|
|
36
36
|
"post_run",
|
|
37
37
|
"pre_tool",
|
|
38
38
|
"post_tool",
|
|
39
|
+
"pre_workflow",
|
|
40
|
+
"post_workflow",
|
|
41
|
+
"pre_upload",
|
|
42
|
+
"post_upload",
|
|
43
|
+
"pre_index",
|
|
44
|
+
"post_index",
|
|
39
45
|
"agent",
|
|
40
|
-
"pipeline",
|
|
41
|
-
"workflow",
|
|
42
46
|
)
|
|
43
47
|
|
|
48
|
+
# Kinds retired in v3.4.1 in favour of the explicit pre_/post_ lifecycle pairs.
|
|
49
|
+
# Accepted on input and mapped forward so older callers / persisted custom hooks
|
|
50
|
+
# never break.
|
|
51
|
+
LEGACY_KIND_ALIASES = {
|
|
52
|
+
"workflow": "post_workflow",
|
|
53
|
+
"pipeline": "post_index",
|
|
54
|
+
}
|
|
55
|
+
|
|
44
56
|
# Hook statuses a dispatch can record.
|
|
45
57
|
HOOK_STATUSES = ("ok", "blocked", "error", "skipped", "advisory")
|
|
46
58
|
|
|
@@ -172,6 +184,55 @@ def hook_result(**kwargs: Any) -> HookResult:
|
|
|
172
184
|
return HookResult(**kwargs)
|
|
173
185
|
|
|
174
186
|
|
|
187
|
+
def dispatch_tool(
|
|
188
|
+
hooks: Any,
|
|
189
|
+
tool_name: str,
|
|
190
|
+
args: Any,
|
|
191
|
+
run_fn: Callable[[], Any],
|
|
192
|
+
*,
|
|
193
|
+
user_email: Optional[str] = None,
|
|
194
|
+
workspace_id: Optional[str] = None,
|
|
195
|
+
source: str = "",
|
|
196
|
+
) -> Any:
|
|
197
|
+
"""Run a tool through the shared ``pre_tool`` → execute → ``post_tool`` lifecycle.
|
|
198
|
+
|
|
199
|
+
This is the single tool-dispatch path so every caller (the HTTP ``/tools/*``
|
|
200
|
+
routes, the single-agent runtime in :mod:`latticeai.core.agent`, and the
|
|
201
|
+
workflow tool node) fires the same hooks. A blocking ``pre_tool`` hook raises
|
|
202
|
+
:class:`PermissionError`; a tool error still fires ``post_tool`` (status
|
|
203
|
+
``error``) before re-raising. With ``hooks=None`` it is a transparent
|
|
204
|
+
pass-through, so the tool path is unchanged when hooks are absent.
|
|
205
|
+
"""
|
|
206
|
+
if hooks is None:
|
|
207
|
+
return run_fn()
|
|
208
|
+
try:
|
|
209
|
+
arg_keys = list(args.keys()) if isinstance(args, dict) else []
|
|
210
|
+
except Exception:
|
|
211
|
+
arg_keys = []
|
|
212
|
+
pre = hooks.fire_hook(
|
|
213
|
+
"pre_tool", f"tool.{tool_name}",
|
|
214
|
+
payload={"tool": tool_name, "args_keys": arg_keys, "source": source},
|
|
215
|
+
user_email=user_email, workspace_id=workspace_id,
|
|
216
|
+
)
|
|
217
|
+
if pre.get("blocked"):
|
|
218
|
+
raise PermissionError(pre.get("block_reason") or f"Tool '{tool_name}' blocked by a pre_tool hook.")
|
|
219
|
+
try:
|
|
220
|
+
result = run_fn()
|
|
221
|
+
except Exception as exc:
|
|
222
|
+
hooks.fire_hook(
|
|
223
|
+
"post_tool", f"tool.{tool_name}",
|
|
224
|
+
payload={"tool": tool_name, "status": "error", "detail": str(exc), "source": source},
|
|
225
|
+
user_email=user_email, workspace_id=workspace_id,
|
|
226
|
+
)
|
|
227
|
+
raise
|
|
228
|
+
hooks.fire_hook(
|
|
229
|
+
"post_tool", f"tool.{tool_name}",
|
|
230
|
+
payload={"tool": tool_name, "status": "ok", "source": source},
|
|
231
|
+
user_email=user_email, workspace_id=workspace_id,
|
|
232
|
+
)
|
|
233
|
+
return result
|
|
234
|
+
|
|
235
|
+
|
|
175
236
|
# Built-in hooks describe lifecycle points the platform already exercises. They
|
|
176
237
|
# are honest reflections of existing behaviour (see the `binding` field), made
|
|
177
238
|
# visible and orderable here. Disabling a `managed="platform"` hook is recorded
|
|
@@ -243,6 +304,11 @@ BUILTIN_HOOKS: List[Dict[str, Any]] = [
|
|
|
243
304
|
},
|
|
244
305
|
]
|
|
245
306
|
|
|
307
|
+
# Built-in hooks now bucket onto the v3.4.1 lifecycle pairs.
|
|
308
|
+
for _hook in BUILTIN_HOOKS:
|
|
309
|
+
_hook["kind"] = LEGACY_KIND_ALIASES.get(_hook["kind"], _hook["kind"])
|
|
310
|
+
del _hook
|
|
311
|
+
|
|
246
312
|
|
|
247
313
|
class HooksRegistry:
|
|
248
314
|
"""Persisted registry of lifecycle hooks (built-in + user-registered)."""
|
|
@@ -305,6 +371,12 @@ class HooksRegistry:
|
|
|
305
371
|
hook["enabled"] = bool(custom.get("enabled", True))
|
|
306
372
|
hook["removable"] = True
|
|
307
373
|
hooks.append(hook)
|
|
374
|
+
# Honest execution flag: a hook actually runs only if a runner is bound
|
|
375
|
+
# (built-ins) or it carries a command (user hooks); otherwise it is
|
|
376
|
+
# advisory (listed + ordered, but a no-op when fired).
|
|
377
|
+
for hook in hooks:
|
|
378
|
+
hook["executable"] = self.has_runner(hook["id"]) or bool(str(hook.get("command") or "").strip())
|
|
379
|
+
hook["advisory"] = not hook["executable"]
|
|
308
380
|
hooks.sort(key=lambda h: (HOOK_KINDS.index(h["kind"]) if h["kind"] in HOOK_KINDS else 99, h.get("order", 100), h["id"]))
|
|
309
381
|
return hooks
|
|
310
382
|
|
|
@@ -389,6 +461,7 @@ class HooksRegistry:
|
|
|
389
461
|
) -> Dict[str, Any]:
|
|
390
462
|
if not str(name).strip():
|
|
391
463
|
raise ValueError("name is required")
|
|
464
|
+
kind = LEGACY_KIND_ALIASES.get(kind, kind)
|
|
392
465
|
if kind not in HOOK_KINDS:
|
|
393
466
|
raise ValueError(f"kind must be one of {', '.join(HOOK_KINDS)}")
|
|
394
467
|
slug = str(name).strip().lower().replace(" ", "-")
|
|
@@ -466,6 +539,7 @@ class HooksRegistry:
|
|
|
466
539
|
was blocked, and a per-hook result list. A ``pre_*`` hook that blocks
|
|
467
540
|
short-circuits the remaining hooks (fail-closed gate semantics).
|
|
468
541
|
"""
|
|
542
|
+
kind = LEGACY_KIND_ALIASES.get(kind, kind)
|
|
469
543
|
if kind not in HOOK_KINDS:
|
|
470
544
|
raise ValueError(f"kind must be one of {', '.join(HOOK_KINDS)}")
|
|
471
545
|
if context is None:
|
|
@@ -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]] = {
|
|
@@ -227,7 +227,7 @@ class WorkflowEngine:
|
|
|
227
227
|
run = WorkflowRun(workflow_id=definition.get("id"), name=definition.get("name") or "workflow")
|
|
228
228
|
if self.hooks is not None:
|
|
229
229
|
self.hooks.fire_hook(
|
|
230
|
-
"
|
|
230
|
+
"pre_workflow", "workflow.start",
|
|
231
231
|
payload={"workflow_id": definition.get("id"), "name": definition.get("name"), "valid": not errors},
|
|
232
232
|
)
|
|
233
233
|
if errors:
|
|
@@ -236,7 +236,7 @@ class WorkflowEngine:
|
|
|
236
236
|
run.finished_at = _now()
|
|
237
237
|
if self.hooks is not None:
|
|
238
238
|
self.hooks.fire_hook(
|
|
239
|
-
"
|
|
239
|
+
"post_workflow", "workflow.end",
|
|
240
240
|
payload={"workflow_id": definition.get("id"), "status": run.status},
|
|
241
241
|
)
|
|
242
242
|
return run
|
|
@@ -316,7 +316,7 @@ class WorkflowEngine:
|
|
|
316
316
|
run.finished_at = _now()
|
|
317
317
|
if self.hooks is not None:
|
|
318
318
|
self.hooks.fire_hook(
|
|
319
|
-
"
|
|
319
|
+
"post_workflow", "workflow.end",
|
|
320
320
|
payload={"workflow_id": definition.get("id"), "name": definition.get("name"),
|
|
321
321
|
"status": run.status, "steps": steps},
|
|
322
322
|
)
|
|
@@ -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,
|
|
@@ -115,6 +116,7 @@ from latticeai.api.garden import create_garden_router
|
|
|
115
116
|
from latticeai.api.setup import create_setup_router
|
|
116
117
|
from latticeai.api.hooks import create_hooks_router
|
|
117
118
|
from latticeai.core.hooks import HooksRegistry
|
|
119
|
+
from latticeai.core.builtin_hooks import register_builtin_hook_runners
|
|
118
120
|
from latticeai.api.agent_registry import create_agent_registry_router
|
|
119
121
|
from latticeai.core.agent_registry import AgentRegistry
|
|
120
122
|
from latticeai.api.memory import create_memory_router
|
|
@@ -156,6 +158,12 @@ from datetime import datetime
|
|
|
156
158
|
CONFIG = Config.from_env()
|
|
157
159
|
APP_VERSION = WORKSPACE_OS_VERSION
|
|
158
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
|
+
|
|
159
167
|
APP_MODE = CONFIG.app_mode
|
|
160
168
|
IS_PUBLIC_MODE = CONFIG.is_public
|
|
161
169
|
DEFAULT_HOST = CONFIG.host
|
|
@@ -286,7 +294,10 @@ KNOWLEDGE_GRAPH = KnowledgeGraphStore(
|
|
|
286
294
|
DATA_DIR / "knowledge_graph_blobs",
|
|
287
295
|
embedder=EMBEDDER.provider,
|
|
288
296
|
) if ENABLE_GRAPH else None
|
|
289
|
-
|
|
297
|
+
# Hooks registry is constructed here (ahead of the watcher) so folder-watch
|
|
298
|
+
# reindexes can fire the pre_index/post_index lifecycle hooks.
|
|
299
|
+
HOOKS_REGISTRY = HooksRegistry(DATA_DIR / "hooks.json")
|
|
300
|
+
LOCAL_KG_WATCHER = LocalKnowledgeWatcher(lambda: KNOWLEDGE_GRAPH, hooks=HOOKS_REGISTRY) if ENABLE_GRAPH else None
|
|
290
301
|
# ── v2 Realtime bus: constructed first so the store can fan every timeline
|
|
291
302
|
# event into the realtime feed via a single additive sink (no per-call wiring).
|
|
292
303
|
REALTIME_BUS = RealtimeBus()
|
|
@@ -300,7 +311,7 @@ PLUGIN_REGISTRY = PluginRegistry(PLUGINS_DIR, store=WORKSPACE_OS)
|
|
|
300
311
|
TEMPLATE_CATALOG = TemplateCatalog()
|
|
301
312
|
# ── v3.2 platform registries: lifecycle hooks + agent registry, persisted under
|
|
302
313
|
# DATA_DIR so the /app Hooks and Agent Registry views read/write real state.
|
|
303
|
-
HOOKS_REGISTRY
|
|
314
|
+
# (HOOKS_REGISTRY is constructed earlier, before the local-knowledge watcher.)
|
|
304
315
|
AGENT_REGISTRY = AgentRegistry(DATA_DIR / "agent_registry.json")
|
|
305
316
|
# Unified long-term memory platform fronting workspace memories, agent
|
|
306
317
|
# snapshots, conversation history, and the KG graph/vector index.
|
|
@@ -1275,6 +1286,7 @@ PLATFORM = PlatformRuntime(
|
|
|
1275
1286
|
workspace_graph=_workspace_graph,
|
|
1276
1287
|
workspace_scope_from_request=_workspace_scope_from_request,
|
|
1277
1288
|
get_tool_permission=get_tool_permission,
|
|
1289
|
+
hooks=HOOKS_REGISTRY,
|
|
1278
1290
|
)
|
|
1279
1291
|
|
|
1280
1292
|
# Single AgentRuntime boundary over the orchestrator + run store.
|
|
@@ -1290,38 +1302,13 @@ AGENT_RUNTIME = AgentRuntime(
|
|
|
1290
1302
|
# The registry lists built-in hooks; binding a runner here makes them *execute*
|
|
1291
1303
|
# real platform behaviour when fired (not a placeholder). Runners take a
|
|
1292
1304
|
# HookContext and may mutate its payload, return a status dict, or block.
|
|
1293
|
-
|
|
1294
|
-
|
|
1295
|
-
|
|
1296
|
-
|
|
1297
|
-
|
|
1298
|
-
|
|
1299
|
-
|
|
1300
|
-
if any(s in str(key).lower() for s in secret_re):
|
|
1301
|
-
payload[key] = "***redacted***"
|
|
1302
|
-
redacted.append(key)
|
|
1303
|
-
return {"status": "ok", "output": f"redacted {len(redacted)} field(s)" if redacted else "no secrets present"}
|
|
1304
|
-
|
|
1305
|
-
def _hook_audit_agent_run(context):
|
|
1306
|
-
"""post_run — append the completed agent run to the workspace audit log."""
|
|
1307
|
-
p = context.payload if isinstance(context.payload, dict) else {}
|
|
1308
|
-
append_audit_event(
|
|
1309
|
-
"hook_post_run",
|
|
1310
|
-
user_email=context.user_email,
|
|
1311
|
-
run_id=p.get("run_id"),
|
|
1312
|
-
agent_id=p.get("agent_id"),
|
|
1313
|
-
status=p.get("status"),
|
|
1314
|
-
)
|
|
1315
|
-
return {"status": "ok", "output": f"audited run {p.get('run_id') or ''}".strip()}
|
|
1316
|
-
|
|
1317
|
-
def _hook_pipeline_index_status(context):
|
|
1318
|
-
"""pipeline — record ingest/embed/graph-build pipeline state for the index."""
|
|
1319
|
-
p = context.payload if isinstance(context.payload, dict) else {}
|
|
1320
|
-
return {"status": "ok", "output": f"pipeline {context.event}: indexed={p.get('indexed')}"}
|
|
1321
|
-
|
|
1322
|
-
HOOKS_REGISTRY.register_hook("builtin:redact-secrets", _hook_redact_secrets)
|
|
1323
|
-
HOOKS_REGISTRY.register_hook("builtin:audit-agent-run", _hook_audit_agent_run)
|
|
1324
|
-
HOOKS_REGISTRY.register_hook("builtin:pipeline-index-status", _hook_pipeline_index_status)
|
|
1305
|
+
# Bind a real runner to every built-in hook so none is a silent no-op.
|
|
1306
|
+
register_builtin_hook_runners(
|
|
1307
|
+
HOOKS_REGISTRY,
|
|
1308
|
+
append_audit_event=append_audit_event,
|
|
1309
|
+
get_tool_permission=get_tool_permission,
|
|
1310
|
+
classify_sensitive_message=classify_sensitive_message,
|
|
1311
|
+
)
|
|
1325
1312
|
|
|
1326
1313
|
app.include_router(create_plugins_router(
|
|
1327
1314
|
registry=PLUGIN_REGISTRY,
|
|
@@ -1435,6 +1422,7 @@ app.include_router(create_models_router(
|
|
|
1435
1422
|
|
|
1436
1423
|
app.include_router(create_chat_router(
|
|
1437
1424
|
config=CONFIG,
|
|
1425
|
+
hooks=HOOKS_REGISTRY,
|
|
1438
1426
|
model_router=router,
|
|
1439
1427
|
chat_service=CHAT_SERVICE,
|
|
1440
1428
|
workspace_store=WORKSPACE_OS,
|
|
@@ -17,6 +17,7 @@ from typing import Any, Callable, Dict, Optional, Set
|
|
|
17
17
|
|
|
18
18
|
from fastapi import HTTPException, Request
|
|
19
19
|
|
|
20
|
+
from latticeai.core.hooks import dispatch_tool
|
|
20
21
|
from latticeai.core.multi_agent import MultiAgentOrchestrator, default_role_runner
|
|
21
22
|
from latticeai.core.workflow_engine import WorkflowEngine
|
|
22
23
|
|
|
@@ -32,6 +33,7 @@ class PlatformRuntime:
|
|
|
32
33
|
workspace_graph: Callable[[], Any],
|
|
33
34
|
workspace_scope_from_request: Callable[[Request], Optional[str]],
|
|
34
35
|
get_tool_permission: Callable[..., Dict[str, Any]],
|
|
36
|
+
hooks: Any = None,
|
|
35
37
|
):
|
|
36
38
|
self.store = store
|
|
37
39
|
self.svc = workspace_service
|
|
@@ -40,6 +42,9 @@ class PlatformRuntime:
|
|
|
40
42
|
self.workspace_graph = workspace_graph
|
|
41
43
|
self.scope_from_request = workspace_scope_from_request
|
|
42
44
|
self.get_tool_permission = get_tool_permission
|
|
45
|
+
# Lifecycle hooks registry — wires the workflow runtime + workflow tool
|
|
46
|
+
# nodes into the same pre_*/post_* lifecycle as the HTTP + agent paths.
|
|
47
|
+
self.hooks = hooks
|
|
43
48
|
|
|
44
49
|
# ── request gating ────────────────────────────────────────────────────
|
|
45
50
|
|
|
@@ -77,11 +82,18 @@ class PlatformRuntime:
|
|
|
77
82
|
def runner(*, node, context):
|
|
78
83
|
cfg = node.get("config") or {}
|
|
79
84
|
name = cfg.get("tool") or ""
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
+
args = cfg.get("args") or {}
|
|
86
|
+
|
|
87
|
+
def _record():
|
|
88
|
+
try:
|
|
89
|
+
permission = dict(self.get_tool_permission(name))
|
|
90
|
+
except Exception:
|
|
91
|
+
permission = {"tool": name, "risk": "unknown"}
|
|
92
|
+
return {"tool": name, "args": args, "recorded": True, "permission": permission}
|
|
93
|
+
|
|
94
|
+
# Same tool lifecycle as the HTTP + agent paths (a pre_tool block
|
|
95
|
+
# raises PermissionError, surfaced as the node error by the engine).
|
|
96
|
+
return dispatch_tool(self.hooks, name or "tool", args, _record, source="workflow")
|
|
85
97
|
return runner
|
|
86
98
|
|
|
87
99
|
def _skill_node_runner(self):
|
|
@@ -158,7 +170,7 @@ class PlatformRuntime:
|
|
|
158
170
|
}
|
|
159
171
|
if with_agent:
|
|
160
172
|
runners["agent"] = self._agent_node_runner(user, scope)
|
|
161
|
-
result = WorkflowEngine(runners).run(workflow, inputs=inputs or {})
|
|
173
|
+
result = WorkflowEngine(runners, hooks=self.hooks).run(workflow, inputs=inputs or {})
|
|
162
174
|
run = self.store.record_workflow_run(
|
|
163
175
|
workflow_id=workflow_id, name=workflow.get("name") or "workflow",
|
|
164
176
|
status=result.status, timeline=result.timeline, outputs=result.outputs,
|
|
@@ -103,6 +103,7 @@ def build_agent_runtime(
|
|
|
103
103
|
clear_history: Callable[[int], Dict[str, Any]],
|
|
104
104
|
knowledge_save: Callable[..., Dict[str, Any]],
|
|
105
105
|
audit: Callable[..., None],
|
|
106
|
+
hooks: Any = None,
|
|
106
107
|
) -> AgentRuntime:
|
|
107
108
|
ensure_agent_root()
|
|
108
109
|
deps = AgentDeps(
|
|
@@ -123,6 +124,7 @@ def build_agent_runtime(
|
|
|
123
124
|
critic_prompt=CRITIC_PROMPT,
|
|
124
125
|
memory_updater_prompt=MEMORY_UPDATER_PROMPT,
|
|
125
126
|
agent_root=AGENT_ROOT,
|
|
127
|
+
hooks=hooks,
|
|
126
128
|
)
|
|
127
129
|
return AgentRuntime(deps)
|
|
128
130
|
|