ltcai 9.8.0 → 9.9.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 (74) hide show
  1. package/README.md +40 -28
  2. package/docs/BENCHMARKS.md +107 -0
  3. package/docs/CHANGELOG.md +35 -0
  4. package/docs/CI_AND_RELEASE_GATES.md +106 -0
  5. package/docs/COMMUNITY_AND_PLUGINS.md +1 -1
  6. package/docs/DEVELOPMENT.md +11 -8
  7. package/docs/ONBOARDING.md +1 -1
  8. package/docs/OPERATIONS.md +15 -6
  9. package/docs/PERFORMANCE.md +9 -1
  10. package/docs/SECURITY_AUDIT.md +91 -0
  11. package/docs/TRUST_MODEL.md +1 -1
  12. package/docs/USABILITY_AUDIT.md +164 -0
  13. package/docs/WHY_LATTICE.md +1 -1
  14. package/docs/architecture.md +4 -2
  15. package/docs/kg-schema.md +1 -1
  16. package/docs/spec-vs-impl.md +8 -1
  17. package/lattice_brain/__init__.py +1 -1
  18. package/lattice_brain/runtime/multi_agent.py +1 -1
  19. package/latticeai/__init__.py +1 -1
  20. package/latticeai/api/review_queue.py +7 -0
  21. package/latticeai/core/agent.py +105 -8
  22. package/latticeai/core/agent_eval.py +99 -1
  23. package/latticeai/core/legacy_compatibility.py +1 -1
  24. package/latticeai/core/marketplace.py +1 -1
  25. package/latticeai/core/tool_governor.py +92 -1
  26. package/latticeai/core/workspace_os.py +1 -1
  27. package/latticeai/services/architecture_readiness.py +1 -1
  28. package/latticeai/services/change_proposals.py +165 -23
  29. package/latticeai/services/product_readiness.py +1 -1
  30. package/latticeai/services/tool_dispatch.py +33 -0
  31. package/package.json +5 -3
  32. package/scripts/bench_models.py +312 -0
  33. package/scripts/check_bundle_budget.mjs +103 -0
  34. package/scripts/check_current_release_docs.mjs +1 -1
  35. package/scripts/check_doc_status.mjs +149 -0
  36. package/scripts/generate_sbom.py +70 -0
  37. package/src-tauri/Cargo.lock +1 -1
  38. package/src-tauri/Cargo.toml +1 -1
  39. package/src-tauri/tauri.conf.json +1 -1
  40. package/static/app/asset-manifest.json +27 -11
  41. package/static/app/assets/Act-CfOPUKPs.js +2 -0
  42. package/static/app/assets/{Brain-BMkgdWnI.js → Brain-Cgkh0Hpn.js} +2 -2
  43. package/static/app/assets/BrainHome-D8e3wQiW.js +3 -0
  44. package/static/app/assets/BrainSignals-BjRBA18L.js +1 -0
  45. package/static/app/assets/Capture-CQQYQ1Ga.js +1 -0
  46. package/static/app/assets/CommandPalette-pss56Mg4.js +1 -0
  47. package/static/app/assets/Library-CoI3xYJk.js +1 -0
  48. package/static/app/assets/LivingBrain-CaU_hCxQ.js +1 -0
  49. package/static/app/assets/ProductFlow-Db-1O71-.js +1 -0
  50. package/static/app/assets/System-Dfocn0zN.js +1 -0
  51. package/static/app/assets/bot-Bp2x1i6F.js +1 -0
  52. package/static/app/assets/circle-check-CyGal42W.js +1 -0
  53. package/static/app/assets/cpu-DQFk82hG.js +1 -0
  54. package/static/app/assets/download-C70gKQVi.js +1 -0
  55. package/static/app/assets/folder-open-DNGytsVw.js +1 -0
  56. package/static/app/assets/hard-drive-CYo_bEl4.js +1 -0
  57. package/static/app/assets/i18n-BBMJshCW.js +7 -0
  58. package/static/app/assets/index-BjaXCeOZ.js +10 -0
  59. package/static/app/assets/{index-7gY9t9Sd.css → index-BqRcLZR3.css} +1 -1
  60. package/static/app/assets/input-ChWD-Fsh.js +1 -0
  61. package/static/app/assets/navigation-D9D9_FPC.js +1 -0
  62. package/static/app/assets/network-DzLDGmtV.js +1 -0
  63. package/static/app/assets/primitives-CmaSAyGG.js +1 -0
  64. package/static/app/assets/sparkles-DXiaM1NS.js +1 -0
  65. package/static/app/assets/textarea-CV43-o79.js +1 -0
  66. package/static/app/index.html +4 -2
  67. package/static/sw.js +1 -1
  68. package/static/app/assets/Act-Dd3z8AzF.js +0 -2
  69. package/static/app/assets/Capture-D2Aw9gkv.js +0 -1
  70. package/static/app/assets/Library-Yreq-KW5.js +0 -1
  71. package/static/app/assets/System-CXNmmtEo.js +0 -1
  72. package/static/app/assets/index-CndfILiF.js +0 -18
  73. package/static/app/assets/primitives-DxsIXb6G.js +0 -1
  74. package/static/app/assets/textarea-DH7ne8VI.js +0 -1
@@ -44,6 +44,63 @@ _ADDITIVE_TOOLS = frozenset({
44
44
  "knowledge_graph_ingest",
45
45
  })
46
46
 
47
+ # ── Mutating-tool inventory (single source of truth) ─────────────────────
48
+ # Every tool with a side effect is classified into exactly one category so a
49
+ # CI check can prove nothing slips through ungoverned. Categories:
50
+ # new_artifact — only ever creates new content (additive)
51
+ # existing_content_update — can rewrite content that already exists
52
+ # delete — removes existing content
53
+ # external_side_effect — acts outside the Brain (shell, deploy, desktop, net)
54
+ # internal_state — agent-internal bookkeeping, not user content
55
+ NEW_ARTIFACT = "new_artifact"
56
+ EXISTING_CONTENT_UPDATE = "existing_content_update"
57
+ DELETE = "delete"
58
+ EXTERNAL_SIDE_EFFECT = "external_side_effect"
59
+ INTERNAL_STATE = "internal_state"
60
+
61
+ MUTATING_TOOL_INVENTORY: Dict[str, str] = {
62
+ # text files — fully proposal-capable (staged + applied as reviewed)
63
+ "write_file": EXISTING_CONTENT_UPDATE,
64
+ "edit_file": EXISTING_CONTENT_UPDATE,
65
+ # binary/document creators — can overwrite an existing file, but their
66
+ # output cannot be staged as a text diff, so an overwrite is fail-closed.
67
+ "create_docx": EXISTING_CONTENT_UPDATE,
68
+ "create_xlsx": EXISTING_CONTENT_UPDATE,
69
+ "create_pptx": EXISTING_CONTENT_UPDATE,
70
+ "create_pdf": EXISTING_CONTENT_UPDATE,
71
+ # home-sandbox write — same story: overwrite is fail-closed until the
72
+ # proposal service learns to stage home-sandbox paths.
73
+ "local_write": EXISTING_CONTENT_UPDATE,
74
+ # additive-only writes
75
+ "create_web_project": NEW_ARTIFACT,
76
+ "knowledge_save": NEW_ARTIFACT,
77
+ "obsidian_save": NEW_ARTIFACT,
78
+ "knowledge_graph_ingest": NEW_ARTIFACT,
79
+ # agent-internal state
80
+ "todo_write": INTERNAL_STATE,
81
+ # deletions
82
+ "delete_file": DELETE,
83
+ "remove_file": DELETE,
84
+ "clear_history": DELETE,
85
+ # external side effects (approval-gated, never proposal-based)
86
+ "run_command": EXTERNAL_SIDE_EFFECT,
87
+ "build_project": EXTERNAL_SIDE_EFFECT,
88
+ "deploy_project": EXTERNAL_SIDE_EFFECT,
89
+ "computer_click": EXTERNAL_SIDE_EFFECT,
90
+ "computer_type": EXTERNAL_SIDE_EFFECT,
91
+ "computer_key": EXTERNAL_SIDE_EFFECT,
92
+ "computer_scroll": EXTERNAL_SIDE_EFFECT,
93
+ "computer_drag": EXTERNAL_SIDE_EFFECT,
94
+ "computer_move": EXTERNAL_SIDE_EFFECT,
95
+ "computer_open_app": EXTERNAL_SIDE_EFFECT,
96
+ "computer_open_url": EXTERNAL_SIDE_EFFECT,
97
+ }
98
+
99
+ # Tools whose existing-content update the ChangeProposalService can actually
100
+ # stage AND apply as a reviewed proposal. A tool that is proposal_required but
101
+ # NOT here is fail-closed (blocked) rather than silently applied.
102
+ PROPOSAL_CAPABLE_TOOLS = frozenset({"write_file", "edit_file"})
103
+
47
104
 
48
105
  def classify_tool_call(
49
106
  name: str,
@@ -83,19 +140,53 @@ def classify_tool_call(
83
140
  change = CHANGE_ADDITIVE
84
141
  reason = "write tool without an existing target"
85
142
 
143
+ proposal_required = change in {CHANGE_MUTATION, CHANGE_DESTRUCTIVE}
144
+ proposal_supported = name in PROPOSAL_CAPABLE_TOOLS
145
+ # A change that must be reviewed as a proposal but that we cannot stage is
146
+ # fail-closed: callers must block it instead of applying it silently.
147
+ fail_closed = proposal_required and not proposal_supported
148
+
86
149
  return {
87
150
  "tool": name,
88
151
  "change_class": change,
89
- "proposal_required": change in {CHANGE_MUTATION, CHANGE_DESTRUCTIVE},
152
+ "proposal_required": proposal_required,
153
+ "proposal_supported": proposal_supported,
154
+ "fail_closed": fail_closed,
90
155
  "reason": reason,
91
156
  }
92
157
 
93
158
 
159
+ def assert_governance_coverage(tool_names: "Any") -> None:
160
+ """Raise if any side-effecting registry tool is not classified.
161
+
162
+ A new mutating tool added to the registry without an inventory entry makes
163
+ this raise, so CI fails closed instead of shipping an ungoverned mutator.
164
+ Read-only tools (risk ``read``) are intentionally exempt.
165
+ """
166
+ missing = [
167
+ name for name in tool_names
168
+ if name not in MUTATING_TOOL_INVENTORY
169
+ ]
170
+ if missing:
171
+ raise ValueError(
172
+ "ungoverned mutating tools (add to MUTATING_TOOL_INVENTORY): "
173
+ + ", ".join(sorted(missing))
174
+ )
175
+
176
+
94
177
  __all__ = [
95
178
  "CHANGE_READ",
96
179
  "CHANGE_ADDITIVE",
97
180
  "CHANGE_MUTATION",
98
181
  "CHANGE_DESTRUCTIVE",
99
182
  "CHANGE_EXEC",
183
+ "NEW_ARTIFACT",
184
+ "EXISTING_CONTENT_UPDATE",
185
+ "DELETE",
186
+ "EXTERNAL_SIDE_EFFECT",
187
+ "INTERNAL_STATE",
188
+ "MUTATING_TOOL_INVENTORY",
189
+ "PROPOSAL_CAPABLE_TOOLS",
100
190
  "classify_tool_call",
191
+ "assert_governance_coverage",
101
192
  ]
@@ -49,7 +49,7 @@ __all__ = [
49
49
  "remove_skill_directory",
50
50
  ]
51
51
 
52
- WORKSPACE_OS_VERSION = "9.8.0"
52
+ WORKSPACE_OS_VERSION = "9.9.0"
53
53
 
54
54
  # Workspace types separate single-user Personal workspaces from shared
55
55
  # Organization workspaces. Both keep the same local-first JSON store; the type
@@ -17,7 +17,7 @@ from typing import Any, Dict, List
17
17
  from latticeai.core.legacy_compatibility import legacy_shim_report
18
18
 
19
19
 
20
- ARCHITECTURE_VERSION_TARGET = "9.8.0"
20
+ ARCHITECTURE_VERSION_TARGET = "9.9.0"
21
21
 
22
22
  PREFERRED_REFACTORING_ORDER = [
23
23
  "agent-runtime",
@@ -21,9 +21,13 @@ change audit log. The classification itself lives in
21
21
  from __future__ import annotations
22
22
 
23
23
  import difflib
24
+ import hashlib
24
25
  import logging
26
+ import os
27
+ import tempfile
28
+ import threading
25
29
  from pathlib import Path
26
- from typing import Any, Callable, Dict, List, Optional
30
+ from typing import Any, Callable, Dict, List, Optional, Tuple
27
31
 
28
32
  from latticeai.core.tool_governor import classify_tool_call
29
33
 
@@ -34,6 +38,55 @@ _MAX_DIFF_LINES = 400
34
38
  _SMALL_TIER_DIFF_LINES = 40
35
39
 
36
40
 
41
+ class ProposalConflictError(ValueError):
42
+ """The target file changed (or the proposal was already resolved) between
43
+ staging and approval — applying now would silently destroy user edits.
44
+
45
+ Subclasses :class:`ValueError` deliberately: API surfaces that only know
46
+ ``except ValueError`` degrade to a 4xx instead of applying or crashing,
47
+ while conflict-aware surfaces catch this class first and answer **409**
48
+ with a rebase hint.
49
+ """
50
+
51
+ def __init__(
52
+ self,
53
+ *,
54
+ reason: str,
55
+ path: str,
56
+ kind: str,
57
+ base_sha256: str = "",
58
+ current_sha256: str = "",
59
+ rebase_hint: str = "",
60
+ ) -> None:
61
+ self.reason = reason
62
+ self.path = path
63
+ self.kind = kind
64
+ self.base_sha256 = base_sha256
65
+ self.current_sha256 = current_sha256
66
+ self.rebase_hint = rebase_hint or (
67
+ "제안 생성 이후 파일 상태가 바뀌었습니다. 이 제안을 거부하고 "
68
+ "현재 파일 내용을 기준으로 제안을 다시 생성하세요."
69
+ )
70
+ super().__init__(f"change proposal conflict ({reason}): {path}")
71
+
72
+ def to_detail(self) -> Dict[str, Any]:
73
+ """409 response body — no staged content, just what the UI needs."""
74
+ return {
75
+ "error": "change_proposal_conflict",
76
+ "conflict": True,
77
+ "reason": self.reason,
78
+ "path": self.path,
79
+ "kind": self.kind,
80
+ "base_sha256": self.base_sha256,
81
+ "current_sha256": self.current_sha256,
82
+ "rebase_hint": self.rebase_hint,
83
+ }
84
+
85
+
86
+ def _sha256_text(content: str) -> str:
87
+ return hashlib.sha256(content.encode("utf-8")).hexdigest()
88
+
89
+
37
90
  def _unified_diff(before: str, after: str, path: str) -> List[str]:
38
91
  lines = list(
39
92
  difflib.unified_diff(
@@ -60,6 +113,11 @@ class ChangeProposalService:
60
113
  self._review_queue = review_queue
61
114
  self._resolve_path = resolve_path
62
115
  self._audit = audit or (lambda *a, **kw: None)
116
+ # One protected section for status-check → conflict-check → apply →
117
+ # transition. In-process serialization is the right weight here: the
118
+ # review queue lives in this process and the write itself is atomic
119
+ # (temp file + os.replace), so a cross-process file lock adds nothing.
120
+ self._apply_lock = threading.Lock()
63
121
 
64
122
  # ── classification / staging (agent-loop governor port) ─────────────
65
123
 
@@ -125,15 +183,24 @@ class ChangeProposalService:
125
183
  return False
126
184
 
127
185
  def _read_before(self, path: str) -> str:
186
+ return self._snapshot(path)[1]
187
+
188
+ def _snapshot(self, path: str) -> Tuple[bool, str]:
189
+ """(exists, content) of the target *as the proposal pipeline sees it*.
190
+
191
+ Both staging and the approve-time conflict check go through this one
192
+ normalization (truncate → utf-8 with replacement), so an unchanged
193
+ file always hashes identically at both points in time.
194
+ """
128
195
  try:
129
196
  target = self._resolve_path(path)
130
197
  if not target.is_file():
131
- return ""
198
+ return False, ""
132
199
  raw = target.read_bytes()[:_MAX_STAGED_BYTES]
133
- return raw.decode("utf-8", errors="replace")
200
+ return True, raw.decode("utf-8", errors="replace")
134
201
  except Exception:
135
202
  LOGGER.exception("change proposal read failed")
136
- return ""
203
+ return False, ""
137
204
 
138
205
  def _staged_content(self, name: str, args: Dict[str, Any]) -> Optional[str]:
139
206
  if name == "write_file":
@@ -164,7 +231,7 @@ class ChangeProposalService:
164
231
  workspace_id: Optional[str] = None,
165
232
  context: Optional[Dict[str, Any]] = None,
166
233
  ) -> Dict[str, Any]:
167
- before = self._read_before(path)
234
+ base_exists, before = self._snapshot(path)
168
235
  diff = _unified_diff(before, new_content, path)
169
236
  tier = "small" if len(diff) <= _SMALL_TIER_DIFF_LINES else "large"
170
237
  provenance: Dict[str, Any] = {"proposed_by": proposed_by, "reason": reason}
@@ -183,6 +250,11 @@ class ChangeProposalService:
183
250
  "tier": tier,
184
251
  "before_bytes": len(before.encode("utf-8")),
185
252
  "after_bytes": len(new_content.encode("utf-8")),
253
+ # Base snapshot for the approve-time conflict check: an empty
254
+ # base_sha256 with base_exists=False means "proposed against a
255
+ # missing file", never "hash of the empty string".
256
+ "base_exists": base_exists,
257
+ "base_sha256": _sha256_text(before) if base_exists else "",
186
258
  },
187
259
  provenance=provenance,
188
260
  user_email=user_email,
@@ -203,7 +275,7 @@ class ChangeProposalService:
203
275
  user_email: Optional[str] = None,
204
276
  workspace_id: Optional[str] = None,
205
277
  ) -> Dict[str, Any]:
206
- before = self._read_before(path)
278
+ base_exists, before = self._snapshot(path)
207
279
  item = self._review_queue.create(
208
280
  title=f"파일 삭제 제안: {path}",
209
281
  summary=reason or "기존 파일을 삭제하는 작업이라 검토 후 적용됩니다.",
@@ -215,6 +287,8 @@ class ChangeProposalService:
215
287
  "tier": "large",
216
288
  "before_bytes": len(before.encode("utf-8")),
217
289
  "after_bytes": 0,
290
+ "base_exists": base_exists,
291
+ "base_sha256": _sha256_text(before) if base_exists else "",
218
292
  },
219
293
  provenance={"proposed_by": proposed_by, "reason": reason},
220
294
  user_email=user_email,
@@ -274,28 +348,96 @@ class ChangeProposalService:
274
348
  self, item_id: str, *, user_email: Optional[str] = None,
275
349
  workspace_id: Optional[str] = None,
276
350
  ) -> Dict[str, Any]:
277
- item = self._review_queue.get(item_id, workspace_id=workspace_id)
278
- if item.get("source") != "change_proposal":
279
- raise KeyError(f"not a change proposal: {item_id}")
280
- payload = item.get("payload") or {}
281
- kind = item.get("kind")
282
- path = str(payload.get("path") or "")
283
- target = self._resolve_path(path)
284
- if kind == "file_update":
285
- target.parent.mkdir(parents=True, exist_ok=True)
286
- target.write_text(str(payload.get("new_content") or ""), encoding="utf-8")
287
- elif kind == "file_delete":
288
- if target.is_file():
289
- target.unlink()
290
- else:
291
- raise ValueError(f"unknown change proposal kind: {kind}")
292
- approved = self._review_queue.approve(item_id, workspace_id=workspace_id)
351
+ """Apply the staged change exactly as reviewed — but only if the file
352
+ on disk still matches the base snapshot the reviewer looked at.
353
+
354
+ Raises :class:`ProposalConflictError` (→ HTTP 409) when the target
355
+ drifted since staging or the proposal was already resolved; nothing
356
+ touches disk in that case, so out-of-band user edits are preserved.
357
+ """
358
+ with self._apply_lock:
359
+ item = self._review_queue.get(item_id, workspace_id=workspace_id)
360
+ if item.get("source") != "change_proposal":
361
+ raise KeyError(f"not a change proposal: {item_id}")
362
+ payload = item.get("payload") or {}
363
+ kind = str(item.get("kind") or "")
364
+ path = str(payload.get("path") or "")
365
+ if kind not in ("file_update", "file_delete"):
366
+ raise ValueError(f"unknown change proposal kind: {kind}")
367
+
368
+ # Duplicate/concurrent approval guard: a resolved proposal must
369
+ # never re-apply (the disk may have moved on since it was applied).
370
+ status = str(item.get("status") or "pending")
371
+ if status not in ("pending", "snoozed"):
372
+ raise ProposalConflictError(
373
+ reason=f"already_{status}", path=path, kind=kind,
374
+ rebase_hint="이미 처리된 제안입니다. 다시 적용할 수 없습니다.",
375
+ )
376
+
377
+ self._check_base_unchanged(payload, path=path, kind=kind)
378
+
379
+ target = self._resolve_path(path)
380
+ if kind == "file_update":
381
+ self._atomic_write(target, str(payload.get("new_content") or ""))
382
+ else: # file_delete — existence re-verified inside the lock
383
+ if target.is_file():
384
+ target.unlink()
385
+ approved = self._review_queue.approve(item_id, workspace_id=workspace_id)
293
386
  self._audit(
294
387
  "change_proposal_applied", user_email=user_email,
295
388
  proposal_id=item_id, path=path, kind=kind,
296
389
  )
297
390
  return {"item": approved, "applied": True, "path": path, "kind": kind}
298
391
 
392
+ def _check_base_unchanged(
393
+ self, payload: Dict[str, Any], *, path: str, kind: str
394
+ ) -> None:
395
+ """Compare the disk state *now* against the staged base snapshot."""
396
+ if "base_sha256" not in payload or "base_exists" not in payload:
397
+ # Legacy proposal staged before base snapshots existed — keep the
398
+ # historical apply-as-reviewed behavior rather than rejecting it.
399
+ return
400
+ base_exists = bool(payload.get("base_exists"))
401
+ base_sha256 = str(payload.get("base_sha256") or "")
402
+ current_exists, current_content = self._snapshot(path)
403
+ current_sha256 = _sha256_text(current_content) if current_exists else ""
404
+ if not base_exists:
405
+ if current_exists:
406
+ raise ProposalConflictError(
407
+ reason="file_created_since_proposal", path=path, kind=kind,
408
+ current_sha256=current_sha256,
409
+ )
410
+ return
411
+ if not current_exists:
412
+ raise ProposalConflictError(
413
+ reason="file_deleted_since_proposal", path=path, kind=kind,
414
+ base_sha256=base_sha256,
415
+ )
416
+ if current_sha256 != base_sha256:
417
+ raise ProposalConflictError(
418
+ reason="file_modified_since_proposal", path=path, kind=kind,
419
+ base_sha256=base_sha256, current_sha256=current_sha256,
420
+ )
421
+
422
+ @staticmethod
423
+ def _atomic_write(target: Path, content: str) -> None:
424
+ """Write via a same-directory temp file + ``os.replace`` so readers
425
+ never observe a partially written proposal."""
426
+ target.parent.mkdir(parents=True, exist_ok=True)
427
+ fd, tmp_name = tempfile.mkstemp(
428
+ dir=str(target.parent), prefix=f".{target.name}.", suffix=".staged"
429
+ )
430
+ try:
431
+ with os.fdopen(fd, "w", encoding="utf-8") as handle:
432
+ handle.write(content)
433
+ os.replace(tmp_name, target)
434
+ except BaseException:
435
+ try:
436
+ os.unlink(tmp_name)
437
+ except OSError:
438
+ pass
439
+ raise
440
+
299
441
  def reject(
300
442
  self, item_id: str, *, user_email: Optional[str] = None,
301
443
  workspace_id: Optional[str] = None, reason: str = "",
@@ -319,4 +461,4 @@ class ChangeProposalService:
319
461
  return {"item": dismissed, "applied": False, "reason": reason}
320
462
 
321
463
 
322
- __all__ = ["ChangeProposalService"]
464
+ __all__ = ["ChangeProposalService", "ProposalConflictError"]
@@ -18,7 +18,7 @@ from typing import Any, Dict, List
18
18
 
19
19
  from latticeai.services.architecture_readiness import architecture_readiness
20
20
 
21
- PRODUCT_VERSION_TARGET = "9.8.0"
21
+ PRODUCT_VERSION_TARGET = "9.9.0"
22
22
 
23
23
 
24
24
  @dataclass(frozen=True)
@@ -21,6 +21,7 @@ from latticeai.core.agent_prompts import (
21
21
  PLANNER_PROMPT,
22
22
  )
23
23
  from latticeai.core.policy import role_has_capability
24
+ from latticeai.core.tool_governor import classify_tool_call
24
25
  from latticeai.core.tool_registry import ToolPermission, ToolPolicy
25
26
  from latticeai.tools import AGENT_ROOT, DEFAULT_TOOL_REGISTRY, ToolError, ensure_agent_root
26
27
 
@@ -122,6 +123,22 @@ class ToolDispatchService:
122
123
  detail=f"'{tool_name}' 툴에는 '{capability}' capability가 필요합니다.",
123
124
  )
124
125
 
126
+ def _governed_path_exists(self, path: str) -> bool:
127
+ """Best-effort existence check for governance classification.
128
+
129
+ Resolves workspace-relative paths under ``AGENT_ROOT`` and honors
130
+ absolute paths (home-sandbox writes). Never raises — an unresolvable
131
+ path is treated as non-existent (additive), which the fail-closed
132
+ guard only escalates when the target actually exists.
133
+ """
134
+ try:
135
+ candidate = Path(path)
136
+ if not candidate.is_absolute():
137
+ candidate = Path(AGENT_ROOT) / candidate
138
+ return candidate.exists()
139
+ except Exception:
140
+ return False
141
+
125
142
  def user_role(self, current_user: str) -> str:
126
143
  users = self.load_users()
127
144
  try:
@@ -162,6 +179,22 @@ class ToolDispatchService:
162
179
  status_code=403,
163
180
  detail=f"'{tool_name}' 툴은 파괴적 작업으로 차단되었습니다.",
164
181
  )
182
+ # Fail-closed governance: a call that would rewrite existing content but
183
+ # cannot be staged as a reviewable proposal is blocked, never applied
184
+ # silently. New-file (additive) calls are unaffected.
185
+ verdict = classify_tool_call(
186
+ tool_name, args or {},
187
+ policy=dict(policy), path_exists=self._governed_path_exists,
188
+ )
189
+ if verdict.get("fail_closed"):
190
+ raise HTTPException(
191
+ status_code=409,
192
+ detail=(
193
+ f"'{tool_name}' 툴은 기존 콘텐츠를 변경하지만 검토 가능한 제안으로 "
194
+ "적용할 수 없어 차단되었습니다. 새 파일 이름으로 생성하거나 "
195
+ "지원되는 편집 도구(write_file/edit_file)를 사용하세요."
196
+ ),
197
+ )
165
198
  if (
166
199
  require_auto_approval
167
200
  and not trusted_admin
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ltcai",
3
- "version": "9.8.0",
3
+ "version": "9.9.0",
4
4
  "description": "Lattice AI — local-first Digital Brain that keeps your knowledge durable across any AI model.",
5
5
  "homepage": "https://github.com/TaeSooPark-PTS/LatticeAI#readme",
6
6
  "repository": {
@@ -24,10 +24,12 @@
24
24
  "build:assets": "vite build && node scripts/build_frontend_assets.mjs",
25
25
  "build:python": "node scripts/run_python.mjs -m build",
26
26
  "check:python": "node scripts/run_python.mjs scripts/check_python.py",
27
- "lint": "npm run lint:python && node --check tests/visual/mock_server.cjs && node --check tests/visual/v3.spec.js && npm run lint:frontend && npm run frontend:openapi:check && node scripts/check_i18n_literals.mjs && npm run test:browser-extension",
27
+ "lint": "npm run lint:python && node --check tests/visual/mock_server.cjs && node --check tests/visual/v3.spec.js && npm run lint:frontend && npm run frontend:openapi:check && node scripts/check_i18n_literals.mjs && npm run check:bundle && npm run test:browser-extension",
28
28
  "lint:python": "node scripts/run_python.mjs -m ruff check .",
29
29
  "lint:frontend": "node scripts/lint_frontend.mjs",
30
- "docs:check-links": "node scripts/check_markdown_links.mjs",
30
+ "check:bundle": "node scripts/check_bundle_budget.mjs",
31
+ "docs:check-links": "node scripts/check_markdown_links.mjs && node scripts/check_doc_status.mjs",
32
+ "docs:check-status": "node scripts/check_doc_status.mjs",
31
33
  "docs:check-current": "node scripts/check_current_release_docs.mjs",
32
34
  "typecheck": "npm run typecheck:frontend && cd vscode-extension && npm run build",
33
35
  "typecheck:frontend": "npx tsc -p tsconfig.json --noEmit",