ltcai 10.6.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.
- package/README.md +61 -52
- package/docs/CHANGELOG.md +126 -0
- package/docs/COMMUNITY_AND_PLUGINS.md +1 -1
- package/docs/DEVELOPMENT.md +1 -1
- package/docs/ONBOARDING.md +1 -1
- package/docs/OPERATIONS.md +1 -1
- package/docs/TRUST_MODEL.md +1 -1
- package/docs/WHY_LATTICE.md +1 -1
- package/docs/kg-schema.md +1 -1
- package/lattice_brain/__init__.py +1 -1
- package/lattice_brain/graph/_kg_contract.py +15 -0
- package/lattice_brain/graph/provenance.py +61 -5
- package/lattice_brain/graph/retrieval.py +16 -4
- package/lattice_brain/graph/retrieval_reads.py +136 -23
- package/lattice_brain/graph/retrieval_vector.py +156 -8
- package/lattice_brain/ingestion.py +14 -1
- package/lattice_brain/ingestion_jobs.py +255 -5
- package/lattice_brain/portability.py +5 -2
- package/lattice_brain/runtime/multi_agent.py +1 -1
- package/latticeai/__init__.py +1 -1
- package/latticeai/api/browser.py +8 -14
- package/latticeai/api/knowledge_graph.py +46 -16
- package/latticeai/api/workspace.py +4 -11
- package/latticeai/api/workspace_scope.py +125 -0
- package/latticeai/core/agent.py +68 -3
- package/latticeai/core/config.py +6 -0
- package/latticeai/core/csrf.py +293 -0
- package/latticeai/core/legacy_compatibility.py +1 -1
- package/latticeai/core/marketplace.py +1 -1
- package/latticeai/core/workspace_os_constants.py +1 -1
- package/latticeai/models/router.py +61 -16
- package/latticeai/runtime/build_phases.py +2 -0
- package/latticeai/runtime/config_runtime.py +2 -0
- package/latticeai/runtime/runtime_context.py +1 -0
- package/latticeai/runtime/web_runtime.py +31 -4
- package/latticeai/services/architecture_readiness.py +1 -1
- package/latticeai/services/product_readiness.py +1 -1
- package/latticeai/services/search_service.py +9 -1
- package/latticeai/services/tool_dispatch.py +13 -7
- package/latticeai/tools/__init__.py +1 -0
- package/latticeai/tools/documents.py +44 -13
- package/package.json +1 -1
- package/scripts/build_frontend_assets.mjs +21 -4
- package/scripts/check_current_release_docs.mjs +1 -1
- package/scripts/check_frontend_build_freshness.mjs +170 -0
- package/src-tauri/Cargo.lock +1 -1
- package/src-tauri/Cargo.toml +1 -1
- package/src-tauri/tauri.conf.json +1 -1
- package/static/app/asset-manifest.json +39 -39
- package/static/app/assets/{Act-B3MSgNsJ.js → Act-aNud-lKL.js} +1 -1
- package/static/app/assets/{AdminConsole-Ds8u36lf.js → AdminConsole-QiCTH68K.js} +1 -1
- package/static/app/assets/{Brain-XdHCIB6a.js → Brain-CMFh5q6k.js} +1 -1
- package/static/app/assets/{BrainHome-Dp8gzQoF.js → BrainHome-B4Ar3atu.js} +2 -2
- package/static/app/assets/{BrainSignals-D4yZflVt.js → BrainSignals-RVMlmTGC.js} +1 -1
- package/static/app/assets/{Capture-BwSZmiZ8.js → Capture-DEBo-0vZ.js} +1 -1
- package/static/app/assets/{CommandPalette-Ds0DnRSC.js → CommandPalette-bW5WxWWZ.js} +1 -1
- package/static/app/assets/{Library-SqzHjyfx.js → Library-5gFexm83.js} +1 -1
- package/static/app/assets/{LivingBrain-DpKt-NKE.js → LivingBrain-FlHFfu9i.js} +1 -1
- package/static/app/assets/ProductFlow-CFUNDOHu.js +1 -0
- package/static/app/assets/{ReviewCard-DVPi1LPZ.js → ReviewCard-BHj86h2Z.js} +2 -2
- package/static/app/assets/{System-w-9miIG8.js → System-CeNHoZRu.js} +1 -1
- package/static/app/assets/{activity-C0QavXjd.js → activity-7_ZmZqN0.js} +1 -1
- package/static/app/assets/arrow-left-Dv2Tiwhe.js +1 -0
- package/static/app/assets/{bot-C1-MbzCF.js → bot-D1gX4xks.js} +1 -1
- package/static/app/assets/{brain-CeRqzJVc.js → brain-Dn_bDfl4.js} +1 -1
- package/static/app/assets/{button-Z-2N8PUb.js → button-DPFZ9lGw.js} +1 -1
- package/static/app/assets/{circle-pause-CgjCLdmO.js → circle-pause-B2P72YOO.js} +1 -1
- package/static/app/assets/{circle-play-Bj45uClm.js → circle-play-BiC8z2vg.js} +1 -1
- package/static/app/assets/{cpu-B-2Chwfy.js → cpu-Cx-wRR_V.js} +1 -1
- package/static/app/assets/{download-CKmiOG3C.js → download-CKxlzxsK.js} +1 -1
- package/static/app/assets/{folder-open-fE7lLvhZ.js → folder-open-Bbjju0tt.js} +1 -1
- package/static/app/assets/{hard-drive-rDl1xsJ1.js → hard-drive-Bon1VBvq.js} +1 -1
- package/static/app/assets/index-DYUs0cWy.css +2 -0
- package/static/app/assets/{index-AEIqmjwZ.js → index-mLP0-YNO.js} +3 -3
- package/static/app/assets/{input-BflJYT5-.js → input-DrMc0Xns.js} +1 -1
- package/static/app/assets/{permissionCopy-BNNvkXSX.js → permissionCopy-BGUsI7vw.js} +1 -1
- package/static/app/assets/{primitives-CP68OWk2.js → primitives-CEMTjBz1.js} +1 -1
- package/static/app/assets/search-HsIji1wY.js +1 -0
- package/static/app/assets/{share-2-CyjG2_yY.js → share-2-D7THHq5K.js} +1 -1
- package/static/app/assets/{shield-alert-C_JBPWYd.js → shield-alert-BoU4_8r9.js} +1 -1
- package/static/app/assets/{textarea-Ds1Exelb.js → textarea-rU1Lb7ia.js} +1 -1
- package/static/app/assets/{useFocusTrap-BgIZQ4if.js → useFocusTrap-BgvK4Nkx.js} +1 -1
- package/static/app/assets/{useQuery-6Bu27NQe.js → useQuery-CT2ChyuU.js} +1 -1
- package/static/app/assets/{users-31lxlciQ.js → users-DlbfHBQV.js} +1 -1
- package/static/app/assets/{utils-C0-C5mZc.js → utils-fEGWreKB.js} +1 -1
- package/static/app/assets/workspace-ClDBz_0f.js +1 -0
- package/static/app/index.html +4 -4
- package/static/sw.js +1 -1
- package/static/app/assets/ProductFlow-BimeVtWH.js +0 -1
- package/static/app/assets/arrow-left-TsAKz-s_.js +0 -1
- package/static/app/assets/index-DcMODGjM.css +0 -2
- package/static/app/assets/search-B4O4iIgg.js +0 -1
- package/static/app/assets/workspace-DAB-urHL.js +0 -1
package/latticeai/core/agent.py
CHANGED
|
@@ -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 /
|
|
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
|
|
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),
|
package/latticeai/core/config.py
CHANGED
|
@@ -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})
|
|
@@ -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.6.
|
|
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.6.
|
|
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
|
|
724
|
-
loop.call_soon_threadsafe(
|
|
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
|
-
|
|
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
|
|
763
|
-
|
|
764
|
-
|
|
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
|
|
931
|
-
loop.call_soon_threadsafe(
|
|
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
|
-
|
|
937
|
-
chunk
|
|
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
|
|
955
|
-
|
|
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,
|