ltcai 10.5.0 → 10.6.2

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 (101) hide show
  1. package/README.md +62 -45
  2. package/docs/CHANGELOG.md +212 -0
  3. package/docs/COMMUNITY_AND_PLUGINS.md +1 -1
  4. package/docs/DEVELOPMENT.md +1 -1
  5. package/docs/ONBOARDING.md +1 -1
  6. package/docs/OPERATIONS.md +1 -1
  7. package/docs/TRUST_MODEL.md +1 -1
  8. package/docs/WHY_LATTICE.md +1 -1
  9. package/docs/kg-schema.md +1 -1
  10. package/lattice_brain/__init__.py +1 -1
  11. package/lattice_brain/graph/_kg_contract.py +15 -0
  12. package/lattice_brain/graph/provenance.py +61 -5
  13. package/lattice_brain/graph/retrieval.py +16 -4
  14. package/lattice_brain/graph/retrieval_reads.py +136 -23
  15. package/lattice_brain/graph/retrieval_vector.py +156 -8
  16. package/lattice_brain/ingestion.py +14 -1
  17. package/lattice_brain/ingestion_jobs.py +255 -5
  18. package/lattice_brain/portability.py +5 -2
  19. package/lattice_brain/runtime/multi_agent.py +1 -1
  20. package/latticeai/__init__.py +1 -1
  21. package/latticeai/api/browser.py +8 -14
  22. package/latticeai/api/knowledge_graph.py +46 -16
  23. package/latticeai/api/workspace.py +4 -11
  24. package/latticeai/api/workspace_scope.py +125 -0
  25. package/latticeai/core/agent.py +68 -3
  26. package/latticeai/core/config.py +6 -0
  27. package/latticeai/core/csrf.py +293 -0
  28. package/latticeai/core/legacy_compatibility.py +1 -1
  29. package/latticeai/core/marketplace.py +1 -1
  30. package/latticeai/core/workspace_os_constants.py +1 -1
  31. package/latticeai/models/router.py +61 -16
  32. package/latticeai/runtime/build_phases.py +2 -0
  33. package/latticeai/runtime/config_runtime.py +2 -0
  34. package/latticeai/runtime/runtime_context.py +1 -0
  35. package/latticeai/runtime/web_runtime.py +31 -4
  36. package/latticeai/services/architecture_readiness.py +1 -1
  37. package/latticeai/services/product_readiness.py +1 -1
  38. package/latticeai/services/search_service.py +9 -1
  39. package/latticeai/services/tool_dispatch.py +13 -7
  40. package/latticeai/tools/__init__.py +1 -0
  41. package/latticeai/tools/documents.py +44 -13
  42. package/package.json +1 -1
  43. package/scripts/build_frontend_assets.mjs +21 -4
  44. package/scripts/capture_release_evidence.mjs +13 -5
  45. package/scripts/check_current_release_docs.mjs +1 -1
  46. package/scripts/check_frontend_build_freshness.mjs +170 -0
  47. package/src-tauri/Cargo.lock +1 -1
  48. package/src-tauri/Cargo.toml +1 -1
  49. package/src-tauri/tauri.conf.json +1 -1
  50. package/static/app/asset-manifest.json +40 -38
  51. package/static/app/assets/{Act-CLsUUE4V.js → Act-aNud-lKL.js} +1 -1
  52. package/static/app/assets/AdminConsole-QiCTH68K.js +1 -0
  53. package/static/app/assets/Brain-CMFh5q6k.js +321 -0
  54. package/static/app/assets/{BrainHome-DMMhKb8j.js → BrainHome-B4Ar3atu.js} +2 -2
  55. package/static/app/assets/{BrainSignals-CcvYpBGn.js → BrainSignals-RVMlmTGC.js} +1 -1
  56. package/static/app/assets/Capture-DEBo-0vZ.js +1 -0
  57. package/static/app/assets/CommandPalette-bW5WxWWZ.js +1 -0
  58. package/static/app/assets/Library-5gFexm83.js +1 -0
  59. package/static/app/assets/{LivingBrain-BVFU-llN.js → LivingBrain-FlHFfu9i.js} +1 -1
  60. package/static/app/assets/ProductFlow-CFUNDOHu.js +1 -0
  61. package/static/app/assets/{ReviewCard-DODh3z_D.js → ReviewCard-BHj86h2Z.js} +2 -2
  62. package/static/app/assets/System-CeNHoZRu.js +1 -0
  63. package/static/app/assets/{activity-DYlZpTrt.js → activity-7_ZmZqN0.js} +1 -1
  64. package/static/app/assets/arrow-left-Dv2Tiwhe.js +1 -0
  65. package/static/app/assets/{bot-BBmfPYKv.js → bot-D1gX4xks.js} +1 -1
  66. package/static/app/assets/brain-Dn_bDfl4.js +1 -0
  67. package/static/app/assets/{button-DXSLgXUl.js → button-DPFZ9lGw.js} +1 -1
  68. package/static/app/assets/{circle-pause-B8TPRvnt.js → circle-pause-B2P72YOO.js} +1 -1
  69. package/static/app/assets/{circle-play-C5aQnYUv.js → circle-play-BiC8z2vg.js} +1 -1
  70. package/static/app/assets/{cpu-BvOUS0hg.js → cpu-Cx-wRR_V.js} +1 -1
  71. package/static/app/assets/{download-BM2Y4baa.js → download-CKxlzxsK.js} +1 -1
  72. package/static/app/assets/{folder-open-DATq5giq.js → folder-open-Bbjju0tt.js} +1 -1
  73. package/static/app/assets/{hard-drive-D2iumtyP.js → hard-drive-Bon1VBvq.js} +1 -1
  74. package/static/app/assets/index-DYUs0cWy.css +2 -0
  75. package/static/app/assets/index-mLP0-YNO.js +10 -0
  76. package/static/app/assets/{input-Ptpha52f.js → input-DrMc0Xns.js} +1 -1
  77. package/static/app/assets/{permissionCopy-DLSOKW1h.js → permissionCopy-BGUsI7vw.js} +1 -1
  78. package/static/app/assets/{primitives-BvxTiz4R.js → primitives-CEMTjBz1.js} +1 -1
  79. package/static/app/assets/search-HsIji1wY.js +1 -0
  80. package/static/app/assets/share-2-D7THHq5K.js +1 -0
  81. package/static/app/assets/{shield-alert-B6RHxSXi.js → shield-alert-BoU4_8r9.js} +1 -1
  82. package/static/app/assets/{textarea-CCZqVitr.js → textarea-rU1Lb7ia.js} +1 -1
  83. package/static/app/assets/{useFocusTrap-QR9BiUDX.js → useFocusTrap-BgvK4Nkx.js} +1 -1
  84. package/static/app/assets/{useQuery-B42HOaUm.js → useQuery-CT2ChyuU.js} +1 -1
  85. package/static/app/assets/{users-D9PLGmAE.js → users-DlbfHBQV.js} +1 -1
  86. package/static/app/assets/{utils-CAuwfT8i.js → utils-fEGWreKB.js} +1 -1
  87. package/static/app/assets/workspace-ClDBz_0f.js +1 -0
  88. package/static/app/index.html +4 -4
  89. package/static/sw.js +1 -1
  90. package/static/app/assets/AdminConsole-D1PBR5Mk.js +0 -1
  91. package/static/app/assets/Brain-Cnb_YCYH.js +0 -321
  92. package/static/app/assets/Capture-DU1HZWqB.js +0 -1
  93. package/static/app/assets/CommandPalette-tU229JbI.js +0 -1
  94. package/static/app/assets/Library-BeutJd8j.js +0 -1
  95. package/static/app/assets/ProductFlow-CZp8oHh_.js +0 -1
  96. package/static/app/assets/System-CTYfZXfv.js +0 -1
  97. package/static/app/assets/brain-RRpEg40e.js +0 -1
  98. package/static/app/assets/index-B1n87vO-.css +0 -2
  99. package/static/app/assets/index-D10Yd0gh.js +0 -10
  100. package/static/app/assets/search-DtMikKf0.js +0 -1
  101. package/static/app/assets/workspace-DT2v1d3T.js +0 -1
@@ -80,8 +80,9 @@ from latticeai.core.permission_mode import (
80
80
  plan_requires_approval,
81
81
  should_stage_proposal,
82
82
  )
83
+ from latticeai.core.tool_governor import classify_tool_call
83
84
  from latticeai.core.tool_registry import SCOPED_KNOWLEDGE_TOOLS
84
- from latticeai.tools import ToolError
85
+ from latticeai.tools import ToolError, document_output_target
85
86
 
86
87
  __all__ = [
87
88
  # this module
@@ -293,6 +294,27 @@ class SingleAgentRuntime:
293
294
  self.deps, ctx, user_email=user_email, workspace_id=workspace_id,
294
295
  )
295
296
 
297
+ def _governed_path_exists(self, name: str, path: str) -> bool:
298
+ """Does this tool call's *real* target already exist?
299
+
300
+ The document creators sanitize ``filename`` into their own output
301
+ directory, so the raw argument is resolved through
302
+ :func:`document_output_target` first — checking it verbatim would
303
+ inspect a path nothing ever writes and the fail-closed overwrite guard
304
+ would never fire. Workspace-relative paths resolve under
305
+ ``deps.agent_root``; absolute paths (home-sandbox writes) are honored
306
+ as-is. Never raises: governance must not be able to crash the loop, and
307
+ an unresolvable path degrades to "new file", which the remaining gates
308
+ still cover.
309
+ """
310
+ try:
311
+ candidate = Path(document_output_target(name, path) or path)
312
+ if not candidate.is_absolute():
313
+ candidate = Path(self.deps.agent_root) / candidate
314
+ return candidate.exists()
315
+ except Exception: # noqa: BLE001 — classification is best-effort
316
+ return False
317
+
296
318
  def _governed_tools(self) -> FrozenSet[str]:
297
319
  governor = getattr(self.deps, "change_governor", None)
298
320
  if governor is None:
@@ -833,11 +855,11 @@ class SingleAgentRuntime:
833
855
  self, ctx: AgentRunContext, req: Any, name: str, thoughts: str, args: dict,
834
856
  policy: Mapping[str, Any], risk: str, current_user: str, governor_allows_additive: bool,
835
857
  ) -> bool:
836
- """Destructive / circuit-breaker / explicit-approval gates.
858
+ """Destructive / circuit-breaker / fail-closed-overwrite / approval gates.
837
859
 
838
860
  Returns True when the step was blocked. The active permission mode can
839
861
  widen what runs without an extra approval prompt, but never widens a
840
- circuit breaker or the destructive gate.
862
+ circuit breaker, the destructive gate, or the overwrite check.
841
863
  """
842
864
  d = self.deps
843
865
  mode = self.resolve_permission_mode(
@@ -876,6 +898,49 @@ class SingleAgentRuntime:
876
898
  )
877
899
  return True
878
900
 
901
+ # Fail-closed overwrite guard — mode-invariant, like the two above.
902
+ # A call that rewrites existing content but cannot be staged as a
903
+ # reviewable proposal (binary document creators, home-sandbox writes)
904
+ # has no safe apply path in ANY mode: trusted/bypass skip the approval
905
+ # *prompt*, they never remove the existence check. Without this the
906
+ # loop silently overwrote files that the HTTP surface refuses with 409
907
+ # (``ToolDispatchService.enforce_policy``).
908
+ overwrite = classify_tool_call(
909
+ name, args, policy=dict(policy),
910
+ path_exists=lambda candidate: self._governed_path_exists(name, candidate),
911
+ )
912
+ if overwrite.get("fail_closed"):
913
+ target = str(args.get("path") or args.get("filename") or "")
914
+ error = (
915
+ f"NEEDS_REVIEW: '{name}' 은(는) 이미 있는 파일 '{target}' 을(를) 덮어씁니다. "
916
+ "이 도구의 변경은 검토 가능한 제안으로 만들 수 없어 실행하지 않았습니다. "
917
+ "새 파일 이름으로 만들거나 write_file/edit_file 로 수정하세요."
918
+ )
919
+ ctx.trace.tool("execute", name=name, outcome="blocked_overwrite", risk=risk)
920
+ self._emit_step(ctx, "execute", "blocked", action=name, reason="overwrite")
921
+ ctx.transcript.append({
922
+ "state": AgentState.EXECUTING.value, "action": name,
923
+ "thoughts": thoughts,
924
+ # Same shape as a staged proposal: the payload is never worth
925
+ # replaying into the transcript, only the decision is.
926
+ "args": {k: v for k, v in args.items() if k != "content"},
927
+ "risk": risk,
928
+ "governance": dict(policy),
929
+ "permission_mode": mode.value,
930
+ "change_class": overwrite.get("change_class"),
931
+ "error": error,
932
+ })
933
+ d.audit(
934
+ "agent_blocked", user_email=current_user,
935
+ source=getattr(req, "source", None) or "agent",
936
+ action=name, reason="overwrite_fail_closed",
937
+ path=target or None,
938
+ change_class=overwrite.get("change_class"),
939
+ permission_mode=mode.value,
940
+ governance=dict(policy),
941
+ )
942
+ return True
943
+
879
944
  reason = block_reason_for_tool(
880
945
  mode, name, policy, args,
881
946
  approved_by_human=bool(ctx.approved_by_human),
@@ -95,6 +95,7 @@ class Config:
95
95
  allow_plaintext_api_keys: bool
96
96
  cors_allow_network: bool
97
97
  cors_extra_origins: List[str]
98
+ csrf_trusted_origins: List[str]
98
99
  rate_limit_enabled: bool
99
100
  open_registration: bool
100
101
  invite_code: str
@@ -163,6 +164,10 @@ class Config:
163
164
  externally_reachable = is_public or network_exposed
164
165
 
165
166
  cors_extra = [item.strip() for item in _value(env, "LATTICEAI_CORS_ALLOWED_ORIGINS", "").split(",") if item.strip()]
167
+ # Browser origins allowed to send *cookie-authenticated* writes. The
168
+ # server's own origin and loopback are added by the CSRF guard itself;
169
+ # this is the escape hatch for a reverse-proxied public hostname.
170
+ csrf_trusted_origins = [item.strip() for item in _value(env, "LATTICEAI_CSRF_TRUSTED_ORIGINS", "").split(",") if item.strip()]
166
171
  admin_emails = [item.strip().lower() for item in _value(env, "LATTICEAI_ADMIN_EMAILS", "").split(",") if item.strip()]
167
172
  trusted_proxies = [item.strip() for item in _value(env, "LATTICEAI_TRUSTED_PROXIES", "").split(",") if item.strip()]
168
173
 
@@ -201,6 +206,7 @@ class Config:
201
206
  allow_plaintext_api_keys=_bool(env, "LATTICEAI_ALLOW_PLAINTEXT_API_KEYS", default=False),
202
207
  cors_allow_network=_bool(env, "LATTICEAI_CORS_ALLOW_NETWORK", default=False),
203
208
  cors_extra_origins=cors_extra,
209
+ csrf_trusted_origins=csrf_trusted_origins,
204
210
  rate_limit_enabled=_str(env, "LATTICEAI_RATE_LIMIT", "1") != "0",
205
211
  # Public/LAN startup is closed-registration even if a stale or
206
212
  # unsafe environment file attempts to opt back in.
@@ -0,0 +1,293 @@
1
+ """Origin/Referer guard for cookie-authenticated state changes.
2
+
3
+ The ``session_token`` cookie is ``SameSite=Lax``, which stops cross-site
4
+ *top-level GET-like* navigations from carrying it but does **not** stop a
5
+ cross-site ``POST``/``PUT``/``PATCH``/``DELETE`` issued by script from a page
6
+ the browser considers a different site in every configuration we ship (Lax
7
+ blocks non-GET cross-site sends in current browsers, but the cookie is also
8
+ the only credential a browser attaches automatically, and Lax is not a
9
+ security boundary we should rely on alone). Every mutating endpoint accepted
10
+ that cookie with no second check, so a non-loopback deployment was one
11
+ malicious page away from forged state changes.
12
+
13
+ This module is the *decision*; :mod:`latticeai.runtime.web_runtime` is the
14
+ wiring. Keeping the two apart means the policy is testable without an ASGI
15
+ app, and the middleware stays small enough to audit.
16
+
17
+ Threat model, stated so the exemptions are checkable rather than vibes:
18
+
19
+ * **Only ambient credentials are CSRF-able.** ``Authorization: Bearer`` is set
20
+ by the caller's own code; an attacker's page cannot add it to a cross-site
21
+ request without a CORS preflight that our ``CORSMiddleware`` allowlist
22
+ rejects. Bearer-authenticated requests are therefore exempt.
23
+ * **No cookie, nothing to forge.** A request without ``session_token`` cannot
24
+ be authenticated by cookie, so it is not this guard's business.
25
+ * **``Origin`` is attacker-honest.** A browser sets it; a page cannot lie about
26
+ it. ``Referer`` is the fallback for the handful of clients that omit
27
+ ``Origin``.
28
+ * **Neither header present** means the caller is not a browser (curl, the CLI,
29
+ the desktop shell, the VS Code extension). That is trusted only while the
30
+ server is bound to loopback, where "not a browser" and "already on this
31
+ machine" coincide. On a reachable bind it fails closed.
32
+ """
33
+
34
+ from __future__ import annotations
35
+
36
+ import json
37
+ from dataclasses import dataclass
38
+ from typing import (
39
+ Any,
40
+ Awaitable,
41
+ Callable,
42
+ Iterable,
43
+ List,
44
+ MutableMapping,
45
+ Optional,
46
+ Sequence,
47
+ Tuple,
48
+ )
49
+ from urllib.parse import urlsplit
50
+
51
+ __all__ = [
52
+ "CSRFDecision",
53
+ "CSRFOriginPolicy",
54
+ "CSRFOriginGuardMiddleware",
55
+ "SAFE_METHODS",
56
+ "SESSION_COOKIE_NAME",
57
+ "normalize_origin",
58
+ ]
59
+
60
+ # The ASGI contract, spelled out locally so the policy stays importable (and
61
+ # unit-testable) without pulling in a web framework.
62
+ Scope = MutableMapping[str, Any]
63
+ Message = MutableMapping[str, Any]
64
+ Receive = Callable[[], Awaitable[Message]]
65
+ Send = Callable[[Message], Awaitable[None]]
66
+ ASGIApp = Callable[[Scope, Receive, Send], Awaitable[None]]
67
+
68
+ # Methods that must not change state. Everything else is guarded.
69
+ SAFE_METHODS = frozenset({"GET", "HEAD", "OPTIONS", "TRACE"})
70
+
71
+ SESSION_COOKIE_NAME = "session_token"
72
+
73
+ _DEFAULT_PORTS = {"http": 80, "https": 443, "ws": 80, "wss": 443}
74
+
75
+ _DENIED_DETAIL = (
76
+ "요청 출처를 확인할 수 없어 거부했습니다. "
77
+ "다른 사이트에서 보낸 요청일 수 있습니다."
78
+ )
79
+
80
+
81
+ def normalize_origin(value: Optional[str]) -> Optional[Tuple[str, str, Optional[int]]]:
82
+ """``"HTTP://Localhost:80/x"`` → ``("http", "localhost", None)``.
83
+
84
+ Returns ``None`` for anything that is not a usable origin (empty, the
85
+ literal ``"null"`` that sandboxed iframes and ``file://`` pages send, or a
86
+ value with no host). ``"null"`` is deliberately *not* normalized into a
87
+ matchable origin: it is what an opaque origin sends, and opaque origins are
88
+ exactly the ones we must not trust.
89
+ """
90
+ if not value:
91
+ return None
92
+ candidate = value.strip()
93
+ if not candidate or candidate.lower() == "null":
94
+ return None
95
+ if "//" not in candidate:
96
+ # A bare authority ("example.com:4825") — the Host header shape.
97
+ candidate = "//" + candidate
98
+ parts = urlsplit(candidate)
99
+ host = (parts.hostname or "").lower()
100
+ if not host:
101
+ return None
102
+ scheme = (parts.scheme or "").lower()
103
+ try:
104
+ port = parts.port
105
+ except ValueError:
106
+ # Malformed port ("example.com:notaport"): unusable, so untrusted.
107
+ return None
108
+ if port is not None and _DEFAULT_PORTS.get(scheme) == port:
109
+ port = None
110
+ return (scheme, host, port)
111
+
112
+
113
+ def _same_site(left: Tuple[str, str, Optional[int]], right: Tuple[str, str, Optional[int]]) -> bool:
114
+ """Host+port equality, ignoring scheme when one side does not carry one.
115
+
116
+ The ``Host`` header has no scheme, and a TLS-terminating reverse proxy
117
+ speaks ``http`` to us while the browser reports ``https``. Comparing the
118
+ authority is what makes "this page came from this server" answerable in
119
+ both deployments; the scheme is only compared when both sides state one.
120
+ """
121
+ if left[1] != right[1] or left[2] != right[2]:
122
+ return False
123
+ if left[0] and right[0]:
124
+ return left[0] == right[0]
125
+ return True
126
+
127
+
128
+ @dataclass(frozen=True)
129
+ class CSRFDecision:
130
+ """Why a request was allowed or refused — the reason is the audit trail."""
131
+
132
+ allowed: bool
133
+ reason: str
134
+
135
+
136
+ class CSRFOriginPolicy:
137
+ """Decide whether one request may change state with a cookie credential."""
138
+
139
+ def __init__(
140
+ self,
141
+ *,
142
+ trusted_origins: Iterable[str] = (),
143
+ server_host: str = "127.0.0.1",
144
+ server_port: int = 4825,
145
+ bind_is_loopback: bool = True,
146
+ ) -> None:
147
+ self.bind_is_loopback = bool(bind_is_loopback)
148
+ self._trusted: List[Tuple[str, str, Optional[int]]] = []
149
+ for origin in self._default_origins(server_host, server_port):
150
+ self._add(origin)
151
+ for origin in trusted_origins:
152
+ self._add(origin)
153
+
154
+ @staticmethod
155
+ def _default_origins(server_host: str, server_port: int) -> List[str]:
156
+ """The server's own origin plus loopback, in both schemes."""
157
+ hosts = [server_host, "localhost", "127.0.0.1", "[::1]"]
158
+ origins: List[str] = []
159
+ for host in hosts:
160
+ if not host:
161
+ continue
162
+ for scheme in ("http", "https"):
163
+ origins.append(f"{scheme}://{host}:{server_port}")
164
+ return origins
165
+
166
+ def _add(self, origin: str) -> None:
167
+ normalized = normalize_origin(origin)
168
+ if normalized is not None and normalized not in self._trusted:
169
+ self._trusted.append(normalized)
170
+
171
+ @property
172
+ def trusted_origins(self) -> Sequence[Tuple[str, str, Optional[int]]]:
173
+ return tuple(self._trusted)
174
+
175
+ def _origin_is_trusted(
176
+ self,
177
+ origin: Tuple[str, str, Optional[int]],
178
+ host_header: Optional[str],
179
+ ) -> bool:
180
+ if any(_same_site(origin, trusted) for trusted in self._trusted):
181
+ return True
182
+ # Same-origin by the request's own Host. A browser sets Host from the
183
+ # URL it is fetching, so `Origin == Host` can only be produced by a
184
+ # page this server actually served — which is precisely "not cross
185
+ # site". This is what keeps reverse-proxied hostnames working without
186
+ # every operator having to enumerate them.
187
+ own = normalize_origin(host_header)
188
+ return own is not None and _same_site(origin, own)
189
+
190
+ def evaluate(
191
+ self,
192
+ *,
193
+ method: str,
194
+ origin: Optional[str],
195
+ referer: Optional[str],
196
+ host: Optional[str],
197
+ cookie_header: Optional[str],
198
+ authorization: Optional[str],
199
+ ) -> CSRFDecision:
200
+ """Allow/deny one request. Pure: no I/O, no app state."""
201
+ if method.upper() in SAFE_METHODS:
202
+ return CSRFDecision(True, "safe-method")
203
+ if (authorization or "").strip().lower().startswith("bearer "):
204
+ # Not ambient: a cross-site page cannot attach this header.
205
+ return CSRFDecision(True, "bearer-auth")
206
+ if not _has_session_cookie(cookie_header):
207
+ return CSRFDecision(True, "no-session-cookie")
208
+
209
+ stated = normalize_origin(origin)
210
+ if stated is None and origin:
211
+ # An opaque origin ("null") explicitly claims *untrusted* provenance.
212
+ return CSRFDecision(False, "opaque-origin")
213
+ if stated is None:
214
+ stated = normalize_origin(referer)
215
+ if stated is None:
216
+ if self.bind_is_loopback:
217
+ # Non-browser client on the same machine (CLI, desktop
218
+ # shell, curl). Nothing on the network can reach this bind.
219
+ return CSRFDecision(True, "no-origin-loopback-bind")
220
+ return CSRFDecision(False, "no-origin-reachable-bind")
221
+
222
+ if self._origin_is_trusted(stated, host):
223
+ return CSRFDecision(True, "same-site-or-trusted-origin")
224
+ return CSRFDecision(False, "cross-site-origin")
225
+
226
+
227
+ def _has_session_cookie(cookie_header: Optional[str]) -> bool:
228
+ """Whether the raw Cookie header carries ``session_token``.
229
+
230
+ Parsed by hand rather than through ``http.cookies`` so a malformed pair
231
+ cannot make the whole header unreadable — a request whose cookie jar we
232
+ cannot parse must still be treated as cookie-bearing.
233
+ """
234
+ if not cookie_header:
235
+ return False
236
+ for pair in cookie_header.split(";"):
237
+ name, _, _value = pair.partition("=")
238
+ if name.strip() == SESSION_COOKIE_NAME:
239
+ return True
240
+ return False
241
+
242
+
243
+ class CSRFOriginGuardMiddleware:
244
+ """Pure-ASGI guard: inspects request headers, never touches the response.
245
+
246
+ Written against the raw ASGI interface instead of ``BaseHTTPMiddleware``
247
+ on purpose — this app streams (SSE chat, live agent steps), and wrapping
248
+ the response path to make a decision that only needs request headers would
249
+ put a buffering layer in front of every stream for no benefit.
250
+ """
251
+
252
+ def __init__(self, app: ASGIApp, *, policy: CSRFOriginPolicy) -> None:
253
+ self.app = app
254
+ self.policy = policy
255
+
256
+ async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None:
257
+ if scope.get("type") != "http":
258
+ await self.app(scope, receive, send)
259
+ return
260
+ headers = {
261
+ key.decode("latin-1").lower(): value.decode("latin-1")
262
+ for key, value in scope.get("headers") or []
263
+ }
264
+ decision = self.policy.evaluate(
265
+ method=str(scope.get("method") or "GET"),
266
+ origin=headers.get("origin"),
267
+ referer=headers.get("referer"),
268
+ host=headers.get("host"),
269
+ cookie_header=headers.get("cookie"),
270
+ authorization=headers.get("authorization"),
271
+ )
272
+ if decision.allowed:
273
+ await self.app(scope, receive, send)
274
+ return
275
+ await _send_forbidden(send, decision.reason)
276
+
277
+
278
+ async def _send_forbidden(send: Send, reason: str) -> None:
279
+ body = json.dumps(
280
+ {"detail": _DENIED_DETAIL, "error": "csrf_origin_rejected", "reason": reason},
281
+ ensure_ascii=False,
282
+ ).encode("utf-8")
283
+ await send(
284
+ {
285
+ "type": "http.response.start",
286
+ "status": 403,
287
+ "headers": [
288
+ (b"content-type", b"application/json; charset=utf-8"),
289
+ (b"content-length", str(len(body)).encode("ascii")),
290
+ ],
291
+ }
292
+ )
293
+ await send({"type": "http.response.body", "body": body})
@@ -13,7 +13,7 @@ from dataclasses import dataclass
13
13
  from pathlib import Path
14
14
  from typing import Any, Dict, List
15
15
 
16
- LEGACY_COMPATIBILITY_VERSION = "10.5.0"
16
+ LEGACY_COMPATIBILITY_VERSION = "10.6.2"
17
17
 
18
18
 
19
19
  @dataclass(frozen=True)
@@ -10,7 +10,7 @@ from __future__ import annotations
10
10
  from copy import deepcopy
11
11
  from typing import Any, Dict, List, Optional
12
12
 
13
- MARKETPLACE_VERSION = "10.5.0"
13
+ MARKETPLACE_VERSION = "10.6.2"
14
14
  TEMPLATE_KINDS = ("plugin", "workflow", "agent", "ingestion_bridge")
15
15
 
16
16
 
@@ -10,7 +10,7 @@ from __future__ import annotations
10
10
 
11
11
  from typing import Dict
12
12
 
13
- WORKSPACE_OS_VERSION = "10.5.0"
13
+ WORKSPACE_OS_VERSION = "10.6.2"
14
14
 
15
15
  # Workspace types separate single-user Personal workspaces from shared
16
16
  # Organization workspaces. Both keep the same local-first JSON store; the type
@@ -118,6 +118,36 @@ def normalize_branding(text: Optional[str]) -> str:
118
118
  return normalized
119
119
 
120
120
 
121
+ class ModelStreamError(RuntimeError):
122
+ """A backend failed mid-stream. This is an error, never model output.
123
+
124
+ Streaming backends used to hand their failure to the caller as a chunk of
125
+ text (``"⚠️ Error: ..."``), which every consumer then treated as the
126
+ model's answer: it was echoed to the client as content and persisted as a
127
+ successful turn. The failure now travels as this typed exception instead.
128
+
129
+ The MLX generators run on a worker thread that cannot raise into the
130
+ consuming coroutine, so the thread puts an instance on the chunk queue and
131
+ :meth:`LLMRouter._drain_stream_queue` re-raises it. The SSE endpoints
132
+ (``latticeai.api.chat_stream.stream_chat`` and the document stream in
133
+ ``latticeai.api.chat_documents``) already wrap their ``async for`` in
134
+ ``except Exception`` and emit an ``error`` frame plus a ``[stream_error]``
135
+ marker on the persisted answer, so the stream framing is unchanged.
136
+ """
137
+
138
+
139
+ def _stream_failure(stage: str, exc: BaseException) -> ModelStreamError:
140
+ """Envelope a backend exception for transport across the chunk queue.
141
+
142
+ ``raise ... from exc`` is unavailable on the worker thread (nothing there
143
+ consumes the traceback), so the cause is attached explicitly and stays
144
+ visible in logs when the consumer re-raises.
145
+ """
146
+ error = ModelStreamError(f"{stage}: {exc}")
147
+ error.__cause__ = exc
148
+ return error
149
+
150
+
121
151
  # Returns a display payload whose `source_display_order` value is a list,
122
152
  # so the value type is Any rather than str.
123
153
  def source_metadata_for_model(
@@ -720,16 +750,32 @@ class LLMRouter:
720
750
  for chunk in gen:
721
751
  text = chunk.text if hasattr(chunk, "text") else (chunk[0] if isinstance(chunk, tuple) else str(chunk))
722
752
  loop.call_soon_threadsafe(queue.put_nowait, text)
723
- except Exception as e:
724
- loop.call_soon_threadsafe(queue.put_nowait, f"⚠️ Error: {e}")
753
+ except Exception as exc:
754
+ loop.call_soon_threadsafe(
755
+ queue.put_nowait, _stream_failure("MLX chat stream failed", exc)
756
+ )
725
757
  finally:
726
758
  loop.call_soon_threadsafe(queue.put_nowait, None)
727
759
 
728
760
  loop.run_in_executor(executor, _stream)
761
+ async for chunk in self._drain_stream_queue(queue):
762
+ yield chunk
763
+
764
+ @staticmethod
765
+ async def _drain_stream_queue(queue: "asyncio.Queue[Any]") -> AsyncIterator[str]:
766
+ """Yield worker-thread chunks until the terminator; raise failures.
767
+
768
+ ``None`` terminates the stream. A :class:`ModelStreamError` on the
769
+ queue is a backend failure envelope, not model text, so it is raised
770
+ into the consuming coroutine — callers must never be able to mistake
771
+ it for an answer.
772
+ """
729
773
  while True:
730
774
  chunk = await queue.get()
731
775
  if chunk is None:
732
- break
776
+ return
777
+ if isinstance(chunk, ModelStreamError):
778
+ raise chunk
733
779
  yield normalize_branding(chunk)
734
780
 
735
781
  async def stream_generate(
@@ -759,9 +805,10 @@ class LLMRouter:
759
805
  temperature=temperature,
760
806
  stream=True,
761
807
  )
762
- except Exception as e:
763
- yield f"⚠️ {self._local_server_error_hint(cloud, e)}"
764
- return
808
+ except Exception as exc:
809
+ # Same invariant as the MLX path: a backend that never produced a
810
+ # token failed, and that is an error — not the model's answer.
811
+ raise ModelStreamError(self._local_server_error_hint(cloud, exc)) from exc
765
812
  async for event in stream:
766
813
  if not event.choices:
767
814
  continue
@@ -927,17 +974,16 @@ class LLMRouter:
927
974
  for chunk in gen:
928
975
  text = chunk.text if hasattr(chunk, "text") else (chunk[0] if isinstance(chunk, tuple) else str(chunk))
929
976
  loop.call_soon_threadsafe(queue.put_nowait, text)
930
- except Exception as e:
931
- loop.call_soon_threadsafe(queue.put_nowait, f"⚠️ Error: {e}")
977
+ except Exception as exc:
978
+ loop.call_soon_threadsafe(
979
+ queue.put_nowait, _stream_failure("MLX document stream failed", exc)
980
+ )
932
981
  finally:
933
982
  loop.call_soon_threadsafe(queue.put_nowait, None)
934
983
 
935
984
  loop.run_in_executor(executor, _stream)
936
- while True:
937
- chunk = await queue.get()
938
- if chunk is None:
939
- break
940
- yield normalize_branding(chunk)
985
+ async for chunk in self._drain_stream_queue(queue):
986
+ yield chunk
941
987
 
942
988
  async def _cloud_stream_document(self, cloud: CloudModel, message: str, system_prompt: str, max_tokens: int, temperature: float) -> AsyncIterator[str]:
943
989
  try:
@@ -951,9 +997,8 @@ class LLMRouter:
951
997
  temperature=temperature,
952
998
  stream=True,
953
999
  )
954
- except Exception as e:
955
- yield f"⚠️ {self._local_server_error_hint(cloud, e)}"
956
- return
1000
+ except Exception as exc:
1001
+ raise ModelStreamError(self._local_server_error_hint(cloud, exc)) from exc
957
1002
  async for event in stream:
958
1003
  if not event.choices:
959
1004
  continue
@@ -88,6 +88,7 @@ def phase_config(ctx: RuntimeContext) -> None:
88
88
  "ALLOW_PLAINTEXT_API_KEYS",
89
89
  "CORS_ALLOW_NETWORK",
90
90
  "CORS_EXTRA_ORIGINS",
91
+ "CSRF_TRUSTED_ORIGINS",
91
92
  "PUBLIC_MODEL",
92
93
  "LOCAL_MODEL",
93
94
  "LOCAL_DRAFT_MODEL",
@@ -875,6 +876,7 @@ def phase_web(ctx: RuntimeContext) -> None:
875
876
  cors_extra_origins=ctx.CORS_EXTRA_ORIGINS,
876
877
  cors_allow_network=ctx.CORS_ALLOW_NETWORK,
877
878
  static_dir=ctx.STATIC_DIR,
879
+ csrf_trusted_origins=ctx.CSRF_TRUSTED_ORIGINS,
878
880
  )
879
881
  ctx.adopt(web_runtime, "app")
880
882
  ensure_agent_root()
@@ -28,6 +28,7 @@ class ConfigRuntime(RuntimeStage):
28
28
  ALLOW_PLAINTEXT_API_KEYS: bool
29
29
  CORS_ALLOW_NETWORK: bool
30
30
  CORS_EXTRA_ORIGINS: Any
31
+ CSRF_TRUSTED_ORIGINS: Any
31
32
  PUBLIC_MODEL: str
32
33
  LOCAL_MODEL: str
33
34
  LOCAL_DRAFT_MODEL: str
@@ -59,6 +60,7 @@ def build_config_runtime(config: "Optional[Config]" = None) -> ConfigRuntime:
59
60
  ALLOW_PLAINTEXT_API_KEYS=cfg.allow_plaintext_api_keys,
60
61
  CORS_ALLOW_NETWORK=cfg.cors_allow_network,
61
62
  CORS_EXTRA_ORIGINS=cfg.cors_extra_origins,
63
+ CSRF_TRUSTED_ORIGINS=cfg.csrf_trusted_origins,
62
64
  PUBLIC_MODEL=cfg.public_model,
63
65
  LOCAL_MODEL=cfg.local_model,
64
66
  LOCAL_DRAFT_MODEL=cfg.local_draft_model,
@@ -55,6 +55,7 @@ class RuntimeContext:
55
55
  ALLOW_PLAINTEXT_API_KEYS: bool
56
56
  CORS_ALLOW_NETWORK: bool
57
57
  CORS_EXTRA_ORIGINS: Any
58
+ CSRF_TRUSTED_ORIGINS: Any
58
59
  PUBLIC_MODEL: str
59
60
  LOCAL_MODEL: Any
60
61
  LOCAL_DRAFT_MODEL: Any