ltcai 5.0.0 → 5.2.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 (48) hide show
  1. package/README.md +75 -55
  2. package/docs/CHANGELOG.md +96 -2354
  3. package/docs/TRUST_MODEL.md +66 -0
  4. package/docs/V4_1_VALIDATION_REPORT.md +1 -1
  5. package/docs/V4_3_PRODUCT_HARDENING_REPORT.md +2 -2
  6. package/docs/V4_5_1_VALIDATION_REPORT.md +2 -1
  7. package/docs/WHY_LATTICE.md +54 -0
  8. package/frontend/src/App.tsx +1 -1
  9. package/frontend/src/components/primitives.tsx +1 -1
  10. package/frontend/src/i18n.ts +6 -4
  11. package/frontend/src/pages/Library.tsx +29 -4
  12. package/frontend/src/pages/System.tsx +1 -1
  13. package/lattice_brain/__init__.py +1 -1
  14. package/lattice_brain/portability.py +11 -7
  15. package/lattice_brain/runtime/multi_agent.py +1 -1
  16. package/latticeai/__init__.py +1 -1
  17. package/latticeai/api/chat.py +19 -11
  18. package/latticeai/api/marketplace.py +2 -2
  19. package/latticeai/api/models.py +26 -4
  20. package/latticeai/api/security_dashboard.py +3 -15
  21. package/latticeai/api/static_routes.py +16 -0
  22. package/latticeai/app_factory.py +114 -40
  23. package/latticeai/core/audit.py +3 -1
  24. package/latticeai/core/builtin_hooks.py +7 -9
  25. package/latticeai/core/logging_safety.py +5 -21
  26. package/latticeai/core/marketplace.py +1 -1
  27. package/latticeai/core/security.py +67 -9
  28. package/latticeai/core/workspace_os.py +18 -4
  29. package/latticeai/services/model_capability_registry.py +483 -0
  30. package/latticeai/services/model_catalog.py +99 -96
  31. package/latticeai/services/model_recommendation.py +12 -1
  32. package/package.json +2 -2
  33. package/scripts/clean_release_artifacts.mjs +16 -1
  34. package/scripts/com.pts.claudecode.discord.plist +31 -0
  35. package/scripts/pts-claudecode-discord-bridge.mjs +189 -0
  36. package/scripts/run_integration_tests.mjs +91 -0
  37. package/scripts/start-pts-claudecode-discord.sh +51 -0
  38. package/scripts/verify_hf_model_registry.py +308 -0
  39. package/src-tauri/Cargo.lock +1 -1
  40. package/src-tauri/Cargo.toml +1 -1
  41. package/src-tauri/tauri.conf.json +3 -2
  42. package/static/app/asset-manifest.json +5 -5
  43. package/static/app/assets/index-CQmHhk8Q.css +2 -0
  44. package/static/app/assets/{index-FR1UZkCD.js → index-DsnfomFs.js} +2 -2
  45. package/static/app/assets/index-DsnfomFs.js.map +1 -0
  46. package/static/app/index.html +2 -2
  47. package/static/app/assets/index-DuYYT2oh.css +0 -2
  48. package/static/app/assets/index-FR1UZkCD.js.map +0 -1
@@ -0,0 +1,66 @@
1
+ # Lattice AI Trust Model
2
+
3
+ Lattice AI's trust model is local-first, opt-in for external communication, and
4
+ honest when something is unavailable.
5
+
6
+ ## Local By Default
7
+
8
+ By default, Lattice AI binds the API to `127.0.0.1`, stores Brain data under the
9
+ local data directory, and does not send prompts, documents, graph content, or
10
+ archives to Lattice-owned servers.
11
+
12
+ Local data includes:
13
+
14
+ - local profile and sessions;
15
+ - conversations and memory records;
16
+ - Knowledge Graph nodes, edges, provenance, and search indexes;
17
+ - uploaded document blobs;
18
+ - audit and admin operation logs;
19
+ - backups and encrypted `.latticebrain` archives.
20
+
21
+ ## Explicit External Paths
22
+
23
+ Some features can contact third parties, but they require explicit user/admin
24
+ action or configuration:
25
+
26
+ - model downloads from model registries;
27
+ - cloud model API calls after keys are configured and a cloud model is chosen;
28
+ - Telegram bridge after the integration is enabled;
29
+ - Brain Network peer actions after pairing/initiating network flows;
30
+ - Docker/Postgres setup after opt-in scale configuration;
31
+ - update checks only when update checking is enabled;
32
+ - remote marketplace/registry refreshes only through explicit user actions.
33
+
34
+ Token presence alone must not start external communication.
35
+
36
+ ## Consent And Honesty Gates
37
+
38
+ Lattice AI should fail closed or report unavailable state for:
39
+
40
+ - no model loaded;
41
+ - local model not installed;
42
+ - installed model not loaded;
43
+ - missing cloud key;
44
+ - deterministic/model-free preview;
45
+ - dry-run versus real execution;
46
+ - no graph/context evidence available;
47
+ - unavailable external integration;
48
+ - wrong archive passphrase;
49
+ - archive path traversal or tampering.
50
+
51
+ ## Admin Boundary
52
+
53
+ The normal user product is Brain Chat, memory, topics, relationships, graph
54
+ exploration, model state, and Brain ownership. Admin Console is for users,
55
+ roles, audit logs, security events, retention, and operations. Admin visibility
56
+ does not mean secrets should appear in clear text.
57
+
58
+ ## Known Limitations
59
+
60
+ - Local files are only as protected as the user's machine, account, backups, and
61
+ disk encryption.
62
+ - Cloud model prompts follow the selected provider's policy.
63
+ - A local admin can inspect local files and process memory outside Lattice AI.
64
+ - Marketplace and model registries are third-party services when explicitly
65
+ contacted.
66
+
@@ -43,5 +43,5 @@ FastAPI backend compatibility, release artifacts, and installed-wheel smoke.
43
43
  `block v0.1.6`; the current Tauri 2.0 build passes on Rust 1.96.
44
44
  - Release artifact validation warns that historical artifacts remain in
45
45
  `dist/`; this is expected and reinforces the rule to upload only exact
46
- v4.1.0 filenames, never `dist/*`.
46
+ v4.1.0 filenames, never a wildcard from the `dist` directory.
47
47
  - No external registry publish was performed.
@@ -44,8 +44,8 @@ architecture.
44
44
  - Release artifact validation now checks the exact Tauri DMG path.
45
45
  - Release artifact build script cleans only target-version outputs before
46
46
  rebuilding.
47
- - Historical artifacts remain visible so `dist/*` upload mistakes are still
48
- detectable.
47
+ - Historical artifacts remain visible so wildcard upload mistakes from `dist`
48
+ are still detectable.
49
49
 
50
50
  ## Registry Policy
51
51
 
@@ -51,4 +51,5 @@ Expected exact-version artifacts:
51
51
 
52
52
  `npm run release:validate` confirmed all exact-version RC artifacts are present.
53
53
  It also warned that historical artifacts remain in `dist/`, so publish commands
54
- must continue to use explicit v4.5.1 filenames rather than a `dist/*` glob.
54
+ must continue to use explicit v4.5.1 filenames rather than a wildcard from the
55
+ `dist` directory.
@@ -0,0 +1,54 @@
1
+ # Why Lattice AI Exists
2
+
3
+ **Your private AI memory layer. Keep your knowledge. Switch any model.**
4
+
5
+ **모델은 바꿔도, 내 지식은 남는 로컬 AI 브레인.**
6
+
7
+ AI models change quickly. A model you use today may be replaced next month, and
8
+ the conversation history, project context, decisions, and sources you built
9
+ around it can become scattered across tools. Lattice AI exists so the durable
10
+ asset is not the model. The durable asset is your Brain.
11
+
12
+ ## The Problem
13
+
14
+ Most AI products begin with a model and treat your context as temporary prompt
15
+ material. That works for short questions, but it fails for long-running work:
16
+
17
+ - project decisions disappear into old chats;
18
+ - documents and notes are disconnected from conversations;
19
+ - switching models often means losing useful context;
20
+ - graph or database tools expose implementation details before user value;
21
+ - cloud-only products make it hard to inspect, back up, or move your knowledge.
22
+
23
+ ## The Lattice Answer
24
+
25
+ Lattice AI is a local-first private AI memory layer. It keeps conversations,
26
+ documents, decisions, relationships, and project history in a Brain that belongs
27
+ to the user. Models can be local, cloud, current, or future. The Brain remains.
28
+
29
+ The graph is real, but it is not the product identity. Users start with Brain
30
+ Chat, memory, topics, relationships, ownership, backup, and graph exploration.
31
+ Advanced admin logs, roles, hooks, workflows, Telegram, Brain Network, Docker,
32
+ Postgres, and plugin details stay outside the normal user flow.
33
+
34
+ ## Practical Reasons To Use It
35
+
36
+ - Ask what the team decided last week and see the source.
37
+ - Drop in documents and build a searchable personal memory.
38
+ - Prepare for a meeting from past notes, project decisions, and files.
39
+ - Preserve context when moving from one model to another.
40
+ - Export or back up the Brain as an encrypted `.latticebrain` archive.
41
+ - Use Korean or English without changing the underlying Brain.
42
+ - Avoid fake answers when no model or evidence is available.
43
+
44
+ ## What Lattice AI Is Not
45
+
46
+ - Not a hosted SaaS by default.
47
+ - Not just a model launcher.
48
+ - Not just a graph viewer.
49
+ - Not a generic dashboard.
50
+ - Not a note-taking clone.
51
+ - Not a ChatGPT or Claude clone.
52
+
53
+ Lattice AI is for people who want their knowledge to survive model changes.
54
+
@@ -245,7 +245,7 @@ function BrainHome({
245
245
  if (result.error) {
246
246
  setMessages((items) => {
247
247
  const next = [...items];
248
- next[next.length - 1] = { role: "assistant", content: `Unavailable: ${result.error}` };
248
+ next[next.length - 1] = { role: "assistant", content: `${t(language, "brain.unavailable")}: ${result.error}` };
249
249
  return next;
250
250
  });
251
251
  } else {
@@ -316,7 +316,7 @@ export function ModeGate({
316
316
  <div className="text-lg font-semibold">{title}</div>
317
317
  <p className="mt-1 max-w-2xl text-sm leading-6 text-muted-foreground">{detail}</p>
318
318
  </div>
319
- <Button onClick={() => setMode(target)}>{target === "admin" ? "Switch to Admin" : "Switch to Advanced"}</Button>
319
+ <Button onClick={() => setMode(target)}>{target === "admin" ? "Switch to Admin Console" : "Switch to Advanced"}</Button>
320
320
  </CardContent>
321
321
  </Card>
322
322
  );
@@ -27,7 +27,7 @@ export const COPY: Record<Language, TextMap> = {
27
27
  "brain.local": "로컬 우선",
28
28
  "brain.portable": "이동 가능",
29
29
  "brain.private": "개인 소유",
30
- "brain.admin": "관리자",
30
+ "brain.admin": "관리자 콘솔",
31
31
  "brain.empty.kicker": "내 오래가는 기억",
32
32
  "brain.empty.title": "잊으면 안 되는 일부터 말해 주세요.",
33
33
  "brain.empty.body": "문서, 대화, 프로젝트, 결정이 Brain에 쌓이고 나중에 주제와 관계로 다시 보입니다.",
@@ -36,6 +36,7 @@ export const COPY: Record<Language, TextMap> = {
36
36
  "brain.prompt.plan": "이 프로젝트 맥락을 계획으로 바꿔줘: ",
37
37
  "brain.placeholder": "Brain에게 말하기...",
38
38
  "brain.image": "이미지",
39
+ "brain.unavailable": "지금은 답할 수 없음",
39
40
  "brain.imageAttached": "이미지 첨부됨",
40
41
  "brain.send": "보내기",
41
42
  "brain.saved": "기억에 저장됨 · 연결된 주제 {topics}개 · 관련 기억 {memories}개",
@@ -74,7 +75,7 @@ export const COPY: Record<Language, TextMap> = {
74
75
  "admin.kicker": "분리된 관리자 작업공간",
75
76
  "admin.title": "Admin Console",
76
77
  "admin.body": "사용자, 로그, 보안, Brain 상태는 일반 사용자 화면과 분리됩니다.",
77
- "flow.shell": "내 Brain 깨우기",
78
+ "flow.shell": "내 로컬 Brain 만들기",
78
79
  "flow.login.title": "내 Brain을 시작합니다.",
79
80
  "flow.login.body": "모델은 바뀔 수 있지만, 내 문서와 대화, 결정, 기억은 사라지면 안 됩니다. Lattice는 이 지식을 내가 소유하는 개인 Brain으로 모읍니다.",
80
81
  "flow.name": "이름",
@@ -139,7 +140,7 @@ export const COPY: Record<Language, TextMap> = {
139
140
  "brain.local": "Local-first",
140
141
  "brain.portable": "Portable",
141
142
  "brain.private": "Private",
142
- "brain.admin": "Admin",
143
+ "brain.admin": "Admin Console",
143
144
  "brain.empty.kicker": "Durable memory",
144
145
  "brain.empty.title": "Start with what should not be forgotten.",
145
146
  "brain.empty.body": "Documents, conversations, projects, and decisions accumulate in your Brain, then reappear as topics and relationships.",
@@ -148,6 +149,7 @@ export const COPY: Record<Language, TextMap> = {
148
149
  "brain.prompt.plan": "Turn this project context into a plan: ",
149
150
  "brain.placeholder": "Talk to your Brain...",
150
151
  "brain.image": "Image",
152
+ "brain.unavailable": "Unavailable",
151
153
  "brain.imageAttached": "Image attached",
152
154
  "brain.send": "Send",
153
155
  "brain.saved": "Saved to memory · {topics} linked topics · {memories} related memories",
@@ -186,7 +188,7 @@ export const COPY: Record<Language, TextMap> = {
186
188
  "admin.kicker": "Separate admin workspace",
187
189
  "admin.title": "Admin Console",
188
190
  "admin.body": "Users, logs, security, and Brain health stay out of the normal user experience.",
189
- "flow.shell": "Awaken your Brain",
191
+ "flow.shell": "Create your local Brain",
190
192
  "flow.login.title": "Start your Brain.",
191
193
  "flow.login.body": "Models can change, but your documents, conversations, decisions, and memories should stay yours. Lattice gathers them into a personal Brain you control.",
192
194
  "flow.name": "Name",
@@ -103,7 +103,7 @@ function ModelsPanel() {
103
103
  return (
104
104
  <div className="grid gap-4 xl:grid-cols-[1.2fr_0.8fr]">
105
105
  <div className="space-y-4">
106
- <DataPanel title="Guided model setup" description="Analyze this Mac, recommend a model, install only with consent, validate it, then load it." result={recs.data}>
106
+ <DataPanel title="Guided model setup" description="5.2 registry: hardware-fit multimodal models with HF verification, download/load strategies, and clear RAM notes. Analyze, consent, download only on click." result={recs.data}>
107
107
  {(data) => {
108
108
  const recommendation = (data as Record<string, unknown>).recommendations as Record<string, unknown> | undefined;
109
109
  const profile = (data as Record<string, unknown>).profile as Record<string, unknown> | undefined;
@@ -160,6 +160,13 @@ function ModelsPanel() {
160
160
  const loadId = String(model.recommended_load_id || id);
161
161
  const engine = String(model.recommended_engine || model.engine || "");
162
162
  const recommendation = recommendationById.get(id) || recommendationById.get(loadId) || {};
163
+ const modelVerification = asRecord(model.verification);
164
+ const recommendationVerification = asRecord(recommendation.verification);
165
+ const modelHardware = asRecord(model.hardware);
166
+ const recommendationHardware = asRecord(recommendation.hardware);
167
+ const hardwareNote = modelHardware.notes || recommendationHardware.notes || (modelHardware.recommended_ram_gb ? `~${modelHardware.recommended_ram_gb}GB RAM rec` : "");
168
+ const safetyNotes = model.safety_notes || recommendation.safety_notes;
169
+ const licenseText = model.license || recommendation.license;
163
170
  const compatibility = (model.runtime_compatibility || recommendation.runtime_compatibility || {}) as Record<string, unknown>;
164
171
  const fallbackAvailable = String(compatibility.status || "") === "fallback_available";
165
172
  const unsupported = model.load_status === "unsupported" || compatibility.supported === false;
@@ -176,7 +183,9 @@ function ModelsPanel() {
176
183
  <div className="min-w-0">
177
184
  <div className="flex flex-wrap items-center gap-2">
178
185
  <div className="text-base font-semibold">{String(model.name || id)}</div>
179
- {topPick?.id === id ? <Badge variant="success">recommended</Badge> : null}
186
+ {topPick?.id === id || model.recommended_default ? <Badge variant="success">recommended</Badge> : null}
187
+ {String(model.modality || recommendation.modality || "").includes("multi") || String(model.modality || "") === "multimodal" ? <Badge variant="muted">multimodal</Badge> : null}
188
+ {modelVerification.verified || recommendationVerification.verified ? <Badge variant="success" title="HF verified (config+tokenizer present)">✓ HF</Badge> : null}
180
189
  </div>
181
190
  <div className="mt-1 text-sm text-muted-foreground">
182
191
  {mode === "basic"
@@ -186,6 +195,11 @@ function ModelsPanel() {
186
195
  ].filter(Boolean).map(String).join(" · ")
187
196
  : [model.family || recommendation.family || "local", model.size || recommendation.size].filter(Boolean).map(String).join(" · ")}
188
197
  </div>
198
+ {(model.hardware || recommendation.hardware) ? (
199
+ <div className="mt-1 text-[11px] text-muted-foreground/80">
200
+ {String(hardwareNote)}
201
+ </div>
202
+ ) : null}
189
203
  {unsupported ? (
190
204
  <div className="mt-3 rounded-lg border border-amber-500/30 bg-amber-500/10 p-3 text-sm">
191
205
  <div className="font-medium">{mode === "basic" ? "Needs attention before loading" : actionLabel}</div>
@@ -198,8 +212,15 @@ function ModelsPanel() {
198
212
  </div>
199
213
  ) : !loaded && !loadAvailable ? <div className="mt-1 text-xs text-muted-foreground">{unavailableReason}</div> : null}
200
214
  {mode !== "basic" ? (
201
- <div className="mt-2 text-xs text-muted-foreground">
202
- {runtimeLabel} · {loadId}
215
+ <div className="mt-2 space-y-1 text-xs text-muted-foreground">
216
+ <div>
217
+ {runtimeLabel} · {loadId}
218
+ {model.load_strategy || recommendation.load_strategy ? ` · ${String(model.load_strategy || recommendation.load_strategy)}` : ""}
219
+ {modelVerification.notes ? ` · ${String(modelVerification.notes).slice(0,60)}` : ""}
220
+ </div>
221
+ {safetyNotes || licenseText ? (
222
+ <div>{[licenseText ? `License: ${String(licenseText)}` : "", safetyNotes ? String(safetyNotes) : ""].filter(Boolean).join(" · ")}</div>
223
+ ) : null}
203
224
  </div>
204
225
  ) : null}
205
226
  {unsupported || fallbackAvailable ? <AlternativeModels compatibility={compatibility} /> : null}
@@ -277,6 +298,10 @@ function ModelRecovery({ error }: { error: Record<string, unknown> }) {
277
298
  );
278
299
  }
279
300
 
301
+ function asRecord(value: unknown): Record<string, unknown> {
302
+ return value && typeof value === "object" && !Array.isArray(value) ? value as Record<string, unknown> : {};
303
+ }
304
+
280
305
  function SkillsPanel() {
281
306
  const qc = useQueryClient();
282
307
  const skills = useQuery({ queryKey: ["skills"], queryFn: latticeApi.skills });
@@ -19,7 +19,7 @@ const tabs: Array<{ id: SystemTab; label: string }> = [
19
19
  { id: "activity", label: "History" },
20
20
  { id: "network", label: "Devices" },
21
21
  { id: "settings", label: "Preferences" },
22
- { id: "admin", label: "Admin" },
22
+ { id: "admin", label: "Admin Console" },
23
23
  ];
24
24
 
25
25
  export function SystemPage({ initialTab }: { initialTab?: string }) {
@@ -26,7 +26,7 @@ from .storage import (
26
26
  storage_from_env,
27
27
  )
28
28
 
29
- __version__ = "5.0.0"
29
+ __version__ = "5.2.0"
30
30
 
31
31
  __all__ = [
32
32
  "AgentRuntime",
@@ -75,8 +75,8 @@ def _sqlite_siblings(db_path: Path) -> tuple[Path, Path, Path]:
75
75
  def _restore_sibling(path: Path, backup: Path) -> None:
76
76
  if backup.exists():
77
77
  shutil.copy2(backup, path)
78
- elif path.exists():
79
- path.unlink()
78
+ else:
79
+ path.unlink(missing_ok=True)
80
80
 
81
81
 
82
82
  def _replace_sqlite_atomically(src: Path, dest: Path, backup_dir: Path) -> None:
@@ -85,14 +85,18 @@ def _replace_sqlite_atomically(src: Path, dest: Path, backup_dir: Path) -> None:
85
85
  shutil.copyfile(src, tmp)
86
86
  backups: dict[Path, Path] = {}
87
87
  try:
88
+ # -wal/-shm are transient: another live connection can checkpoint and
89
+ # remove them between exists() and the copy/unlink. Treat a vanished
90
+ # sibling as "nothing to preserve" instead of crashing the restore.
88
91
  for sibling in _sqlite_siblings(dest):
89
- if sibling.exists():
90
- backup = backup_dir / sibling.name
92
+ backup = backup_dir / sibling.name
93
+ try:
91
94
  shutil.copy2(sibling, backup)
92
- backups[sibling] = backup
95
+ except FileNotFoundError:
96
+ continue
97
+ backups[sibling] = backup
93
98
  for sibling in _sqlite_siblings(dest)[1:]:
94
- if sibling.exists():
95
- sibling.unlink()
99
+ sibling.unlink(missing_ok=True)
96
100
  os.replace(tmp, dest)
97
101
  except Exception:
98
102
  if tmp.exists():
@@ -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 = "5.0.0"
17
+ MULTI_AGENT_VERSION = "5.2.0"
18
18
 
19
19
  AGENT_ROLES = ("researcher", "planner", "executor", "reviewer", "release")
20
20
  CORE_PIPELINE = ("planner", "executor", "reviewer")
@@ -1,3 +1,3 @@
1
1
  """Lattice AI - modular server package."""
2
2
 
3
- __version__ = "5.0.0"
3
+ __version__ = "5.2.0"
@@ -27,7 +27,7 @@ from latticeai.core.document_generator import DocumentGenerationSession, detect_
27
27
  from lattice_brain.runtime.hooks import dispatch_tool
28
28
  from latticeai.services.app_context import AppContext
29
29
  from latticeai.services.tool_dispatch import build_agent_runtime, collect_created_files
30
- from tools import AGENT_ROOT, ToolError, ensure_agent_root, execute_tool, knowledge_save, local_read, network_status
30
+ from tools import AGENT_ROOT, ToolError, ensure_agent_root, execute_tool, knowledge_save, network_status
31
31
 
32
32
  class ChatRequest(BaseModel):
33
33
  message: str
@@ -42,6 +42,7 @@ class ChatRequest(BaseModel):
42
42
  user_email: Optional[str] = None
43
43
  user_nickname: Optional[str] = None
44
44
  image_data: Optional[str] = None
45
+ allow_file_context: bool = False
45
46
 
46
47
 
47
48
  class AgentRequest(BaseModel):
@@ -450,16 +451,23 @@ def create_chat_router(context: AppContext) -> APIRouter:
450
451
 
451
452
  if CONFIG.auto_read_chat_paths:
452
453
  _file_path_re = re.compile(r'(?:^|[\s\'\"(])((~|/[\w.])[^\s\'")\]]*)', re.MULTILINE)
453
- for _m in _file_path_re.finditer(req.message or ""):
454
- _fpath = _m.group(1).strip()
455
- try:
456
- _result = local_read(_fpath)
457
- _fcontent = _result.get("content", "")
458
- if _fcontent:
459
- context += f"\n\n[FILE: {_fpath}]\n```\n{_fcontent[:6000]}\n```"
460
- print(f"📂 Auto-injected file context: {_fpath}")
461
- except Exception:
462
- pass
454
+ requested_paths = [_m.group(1).strip() for _m in _file_path_re.finditer(req.message or "")]
455
+ if requested_paths:
456
+ append_audit_event(
457
+ "auto_file_context_blocked",
458
+ user_email=effective_email,
459
+ path_count=len(requested_paths),
460
+ allow_file_context=req.allow_file_context,
461
+ reason="local file context requires an explicit approved file/tool flow",
462
+ )
463
+ if req.allow_file_context:
464
+ raise HTTPException(
465
+ status_code=400,
466
+ detail=(
467
+ "Automatic local file reads are disabled in chat. "
468
+ "Attach the file, upload it, or use an approved local-file tool flow."
469
+ ),
470
+ )
463
471
 
464
472
  trace_seed = CHAT_SERVICE.build_graph_trace(
465
473
  req.message,
@@ -88,7 +88,7 @@ def create_marketplace_router(
88
88
  @router.get("/marketplace/templates/registry")
89
89
  async def template_registry(request: Request):
90
90
  require_user(request)
91
- gate_read(request)
92
- return {"registry": store.list_template_registry()}
91
+ scope = gate_read(request)
92
+ return {"registry": store.list_template_registry(workspace_id=scope)}
93
93
 
94
94
  return router
@@ -77,6 +77,7 @@ class SetApiKeyRequest(BaseModel):
77
77
 
78
78
  class PullModelRequest(BaseModel):
79
79
  model: str
80
+ allow_download: bool = False
80
81
 
81
82
 
82
83
  class PrepareModelRequest(BaseModel):
@@ -291,6 +292,11 @@ def create_models_router(
291
292
  @router.post("/engines/pull-model")
292
293
  async def pull_ollama_model(req: PullModelRequest, request: Request):
293
294
  require_user(request)
295
+ if not req.allow_download:
296
+ raise HTTPException(
297
+ status_code=403,
298
+ detail="Model downloads require explicit user consent (allow_download=true).",
299
+ )
294
300
  model_ref = normalize_local_model_request(req.model, None)
295
301
  if not model_ref:
296
302
  raise HTTPException(status_code=400, detail="모델 식별자가 비어 있습니다.")
@@ -419,6 +425,12 @@ def create_models_router(
419
425
  loaded_ids=_router.loaded_model_ids,
420
426
  current_id=_router.current_model_id,
421
427
  )
428
+ # 5.2.0: surface structured registry info (verified status, hf, hardware, strategies) for UX
429
+ try:
430
+ from latticeai.services.model_catalog import get_verified_models
431
+ verified = get_verified_models()
432
+ except Exception:
433
+ verified = []
422
434
  return {
423
435
  "recommended": recommended,
424
436
  "cloud": _router.detected_cloud_models(),
@@ -427,6 +439,12 @@ def create_models_router(
427
439
  "current": _router.current_model_id,
428
440
  "compat_profiles": _list_compat_profiles(),
429
441
  "vision": _vision_capability(_router.current_model_id, engines),
442
+ # 5.2+ transparent model capability registry
443
+ "registry": {
444
+ "version": "5.2.0",
445
+ "verified_count": len(verified),
446
+ "verified": verified[:12], # compact; full via /models/recommendations or future dedicated
447
+ },
430
448
  }
431
449
 
432
450
  @router.get("/models/compat-profiles")
@@ -488,9 +506,8 @@ def create_models_router(
488
506
  async def model_recommendations(request: Request, engine: str = "local_mlx"):
489
507
  """Hardware-aware tri-state model recommendation for this machine.
490
508
 
491
- Detects the system profile (OS/RAM/CPU/GPU/disk) and classifies the
492
- ``engine`` catalog into recommended / compatible / not_recommended,
493
- grouped by family. Used by the onboarding and model-picker UIs.
509
+ 5.2.0: now includes rich capability fields (hf_repo_id, verification,
510
+ hardware, load_strategy, license, safety_notes) from the structured registry.
494
511
  """
495
512
  require_user(request)
496
513
  from auto_setup import probe as auto_setup_probe
@@ -498,6 +515,11 @@ def create_models_router(
498
515
 
499
516
  profile = await asyncio.to_thread(lambda: auto_setup_probe().to_json())
500
517
  catalog = recommend_catalog(profile, engine=engine)
501
- return {"profile": profile, "recommendations": catalog}
518
+ try:
519
+ from latticeai.services.model_catalog import get_verified_models
520
+ reg_meta = {"version": "5.2.0", "verified_total": len(get_verified_models())}
521
+ except Exception:
522
+ reg_meta = {"version": "5.2.0"}
523
+ return {"profile": profile, "recommendations": catalog, "registry": reg_meta}
502
524
 
503
525
  return router
@@ -26,7 +26,6 @@ from __future__ import annotations
26
26
  import io
27
27
  import json
28
28
  import logging
29
- import re
30
29
  from collections import defaultdict
31
30
  from datetime import datetime
32
31
  from typing import Any, Callable, Dict, List, Optional
@@ -36,6 +35,7 @@ from fastapi.responses import Response
36
35
  from pydantic import BaseModel
37
36
 
38
37
  from ..core import timezones
38
+ from ..core.security import SECRET_TEXT_PATTERNS, redact_secret_text
39
39
 
40
40
  logger = logging.getLogger(__name__)
41
41
 
@@ -43,23 +43,11 @@ logger = logging.getLogger(__name__)
43
43
  # ── Hard secret patterns ──────────────────────────────────────────────────────
44
44
  # 이 값들은 관리자도 절대 원문으로 보면 안 된다.
45
45
 
46
- HARD_SECRET_PATTERNS = [
47
- re.compile(r"(?i)(api[_-]?key|secret|access[_-]?token|password|passwd|bearer)\s*[:=]\s*\S+"),
48
- re.compile(r"sk-[A-Za-z0-9]{20,}"),
49
- re.compile(r"ghp_[A-Za-z0-9]{30,}"),
50
- re.compile(r"xox[baprs]-[A-Za-z0-9-]{10,}"),
51
- re.compile(r"AKIA[0-9A-Z]{16}"),
52
- re.compile(r"-----BEGIN [A-Z ]+PRIVATE KEY-----[\s\S]+?-----END [A-Z ]+PRIVATE KEY-----"),
53
- ]
46
+ HARD_SECRET_PATTERNS = SECRET_TEXT_PATTERNS
54
47
 
55
48
 
56
49
  def redact_hard_secrets(text: str) -> str:
57
- if not text:
58
- return text or ""
59
- out = text
60
- for pat in HARD_SECRET_PATTERNS:
61
- out = pat.sub("[REDACTED_SECRET]", out)
62
- return out
50
+ return redact_secret_text(text)
63
51
 
64
52
 
65
53
  def soft_mask(text: str, *, keep: int = 4) -> str:
@@ -12,11 +12,27 @@ from fastapi.responses import FileResponse, HTMLResponse
12
12
 
13
13
  from latticeai.api.ui_redirects import app_redirect
14
14
 
15
+ PRODUCTION_CSP = (
16
+ "default-src 'self'; "
17
+ "script-src 'self'; "
18
+ "style-src 'self' 'unsafe-inline'; "
19
+ "img-src 'self' data: blob: http://127.0.0.1:*; "
20
+ "font-src 'self' data:; "
21
+ "connect-src 'self' http://127.0.0.1:* ws://127.0.0.1:*; "
22
+ "frame-src 'none'; "
23
+ "object-src 'none'; "
24
+ "base-uri 'none'; "
25
+ "form-action 'self'; "
26
+ "frame-ancestors 'none'"
27
+ )
28
+
29
+
15
30
  def ui_file_response(path: Path) -> FileResponse:
16
31
  response = FileResponse(path)
17
32
  response.headers["Cache-Control"] = "no-cache, no-store, must-revalidate"
18
33
  response.headers["Pragma"] = "no-cache"
19
34
  response.headers["Expires"] = "0"
35
+ response.headers["Content-Security-Policy"] = PRODUCTION_CSP
20
36
  return response
21
37
 
22
38
  @dataclass(frozen=True)