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.
Files changed (56) hide show
  1. package/README.md +175 -225
  2. package/docs/RUNTIME_HOOK_COVERAGE_v3.5.0.md +56 -0
  3. package/docs/assets/v3.4.1/e2e_runtime_log.txt +42 -0
  4. package/docs/assets/v3.4.1/hooks-dispatch.png +0 -0
  5. package/docs/assets/v3.4.1/local-agent.png +0 -0
  6. package/latticeai/__init__.py +1 -1
  7. package/latticeai/api/auth.py +37 -9
  8. package/latticeai/api/chat.py +6 -1
  9. package/latticeai/api/computer_use.py +21 -8
  10. package/latticeai/api/local_files.py +76 -10
  11. package/latticeai/api/tools.py +35 -35
  12. package/latticeai/core/agent.py +13 -2
  13. package/latticeai/core/builtin_hooks.py +106 -0
  14. package/latticeai/core/config.py +3 -0
  15. package/latticeai/core/hooks.py +76 -2
  16. package/latticeai/core/marketplace.py +1 -1
  17. package/latticeai/core/multi_agent.py +1 -1
  18. package/latticeai/core/oidc.py +205 -0
  19. package/latticeai/core/security.py +59 -5
  20. package/latticeai/core/workflow_engine.py +3 -3
  21. package/latticeai/core/workspace_os.py +1 -1
  22. package/latticeai/server_app.py +22 -34
  23. package/latticeai/services/platform_runtime.py +18 -6
  24. package/latticeai/services/tool_dispatch.py +2 -0
  25. package/latticeai/services/upload_service.py +24 -4
  26. package/local_knowledge_api.py +27 -1
  27. package/package.json +3 -3
  28. package/requirements.txt +1 -0
  29. package/scripts/check_python.py +87 -0
  30. package/static/css/reference/account.css +1 -1
  31. package/static/css/reference/admin.css +1 -1
  32. package/static/css/reference/base.css +8 -5
  33. package/static/css/reference/chat.css +8 -8
  34. package/static/css/reference/graph.css +2 -2
  35. package/static/css/responsive.css +2 -2
  36. package/static/v3/asset-manifest.json +9 -9
  37. package/static/v3/css/{lattice.shell.6ceea7c8.css → lattice.shell.8fcc9d33.css} +2 -1
  38. package/static/v3/css/lattice.shell.css +2 -1
  39. package/static/v3/js/{app.c4acfdd8.js → app.d086489d.js} +1 -1
  40. package/static/v3/js/core/{components.35f02e4c.js → components.f25b3b93.js} +1 -1
  41. package/static/v3/js/core/components.js +1 -1
  42. package/static/v3/js/core/{shell.80a6ad82.js → shell.d05266f5.js} +1 -1
  43. package/static/v3/js/views/{hooks.13845954.js → hooks.37895880.js} +12 -7
  44. package/static/v3/js/views/hooks.js +12 -7
  45. package/static/v3/js/views/{my-computer.c3ef5283.js → my-computer.d9d9ae1c.js} +7 -4
  46. package/static/v3/js/views/my-computer.js +7 -4
  47. package/static/workspace.css +1 -1
  48. package/tools/__init__.py +276 -0
  49. package/tools/commands.py +188 -0
  50. package/tools/computer.py +185 -0
  51. package/tools/documents.py +243 -0
  52. package/tools/filesystem.py +560 -0
  53. package/tools/knowledge.py +97 -0
  54. package/tools/local_files.py +69 -0
  55. package/tools/network.py +66 -0
  56. package/tools.py +0 -1525
@@ -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", ""),
@@ -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:
@@ -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.0"
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.0"
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]] = {
@@ -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
- "workflow", "workflow.start",
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
- "workflow", "workflow.end",
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
- "workflow", "workflow.end",
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.4.0"
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,
@@ -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
- LOCAL_KG_WATCHER = LocalKnowledgeWatcher(lambda: KNOWLEDGE_GRAPH) if ENABLE_GRAPH else None
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 = HooksRegistry(DATA_DIR / "hooks.json")
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
- def _hook_redact_secrets(context):
1294
- """pre_run — strip secret-like keys from the agent context packet."""
1295
- secret_re = ("token", "password", "passwd", "secret", "api_key", "apikey",
1296
- "authorization", "auth", "cookie", "session", "private_key")
1297
- redacted = []
1298
- payload = context.payload if isinstance(context.payload, dict) else {}
1299
- for key in list(payload.keys()):
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
- try:
81
- permission = dict(self.get_tool_permission(name))
82
- except Exception:
83
- permission = {"tool": name, "risk": "unknown"}
84
- return {"tool": name, "args": cfg.get("args") or {}, "recorded": True, "permission": permission}
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