ltcai 9.0.0 → 9.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 (189) hide show
  1. package/README.md +88 -46
  2. package/auto_setup.py +7 -853
  3. package/desktop/electron/README.md +9 -0
  4. package/desktop/electron/main.cjs +5 -3
  5. package/docs/CHANGELOG.md +178 -2
  6. package/docs/COMMUNITY_AND_PLUGINS.md +4 -2
  7. package/docs/DEVELOPMENT.md +53 -14
  8. package/docs/LEGACY_COMPATIBILITY.md +4 -4
  9. package/docs/ONBOARDING.md +10 -2
  10. package/docs/OPERATIONS.md +8 -4
  11. package/docs/PRODUCT_DIRECTION_REVIEW.md +4 -0
  12. package/docs/TRUST_MODEL.md +5 -2
  13. package/docs/WHY_LATTICE.md +15 -10
  14. package/docs/WORKFLOW_DESIGNER.md +22 -0
  15. package/docs/kg-schema.md +13 -2
  16. package/docs/mcp-tools.md +17 -6
  17. package/docs/privacy.md +19 -3
  18. package/docs/public-deploy.md +32 -3
  19. package/docs/security-model.md +46 -11
  20. package/lattice_brain/__init__.py +1 -1
  21. package/lattice_brain/archive.py +1 -6
  22. package/lattice_brain/context.py +66 -9
  23. package/lattice_brain/graph/_kg_fsutil.py +1 -5
  24. package/lattice_brain/graph/discovery_index.py +174 -20
  25. package/lattice_brain/graph/documents.py +44 -9
  26. package/lattice_brain/graph/ingest.py +47 -20
  27. package/lattice_brain/graph/provenance.py +13 -6
  28. package/lattice_brain/graph/retrieval.py +140 -39
  29. package/lattice_brain/graph/retrieval_docgen.py +37 -4
  30. package/lattice_brain/ingestion.py +4 -7
  31. package/lattice_brain/portability.py +28 -14
  32. package/lattice_brain/runtime/agent_runtime.py +5 -9
  33. package/lattice_brain/runtime/hooks.py +30 -13
  34. package/lattice_brain/runtime/multi_agent.py +27 -8
  35. package/lattice_brain/runtime/statuses.py +10 -0
  36. package/lattice_brain/utils.py +20 -2
  37. package/lattice_brain/workflow.py +1 -5
  38. package/latticeai/__init__.py +1 -1
  39. package/latticeai/api/admin.py +1 -1
  40. package/latticeai/api/agent_registry.py +10 -8
  41. package/latticeai/api/agents.py +22 -3
  42. package/latticeai/api/auth.py +78 -16
  43. package/latticeai/api/browser.py +303 -34
  44. package/latticeai/api/chat.py +320 -850
  45. package/latticeai/api/chat_agent_http.py +356 -0
  46. package/latticeai/api/chat_contracts.py +54 -0
  47. package/latticeai/api/chat_documents.py +256 -0
  48. package/latticeai/api/chat_helpers.py +28 -1
  49. package/latticeai/api/chat_history.py +99 -0
  50. package/latticeai/api/chat_intents.py +316 -0
  51. package/latticeai/api/chat_stream.py +115 -0
  52. package/latticeai/api/computer_use.py +62 -9
  53. package/latticeai/api/health.py +9 -3
  54. package/latticeai/api/hooks.py +17 -6
  55. package/latticeai/api/knowledge_graph.py +78 -14
  56. package/latticeai/api/local_files.py +7 -2
  57. package/latticeai/api/mcp.py +102 -23
  58. package/latticeai/api/models.py +72 -33
  59. package/latticeai/api/network.py +5 -5
  60. package/latticeai/api/permissions.py +67 -26
  61. package/latticeai/api/plugins.py +21 -7
  62. package/latticeai/api/portability.py +5 -5
  63. package/latticeai/api/realtime.py +33 -5
  64. package/latticeai/api/setup.py +2 -2
  65. package/latticeai/api/static_routes.py +123 -24
  66. package/latticeai/api/tools.py +96 -14
  67. package/latticeai/api/workflow_designer.py +37 -0
  68. package/latticeai/api/workspace.py +2 -1
  69. package/latticeai/app_factory.py +110 -52
  70. package/latticeai/core/agent.py +50 -13
  71. package/latticeai/core/agent_prompts.py +5 -0
  72. package/latticeai/core/agent_registry.py +1 -5
  73. package/latticeai/core/config.py +23 -3
  74. package/latticeai/core/context_builder.py +21 -3
  75. package/latticeai/core/file_generation.py +451 -0
  76. package/latticeai/core/invitations.py +1 -4
  77. package/latticeai/core/io_utils.py +9 -1
  78. package/latticeai/core/legacy_compatibility.py +24 -3
  79. package/latticeai/core/marketplace.py +1 -1
  80. package/latticeai/core/plugins.py +15 -0
  81. package/latticeai/core/policy.py +1 -1
  82. package/latticeai/core/realtime.py +38 -15
  83. package/latticeai/core/sessions.py +7 -2
  84. package/latticeai/core/timeutil.py +10 -0
  85. package/latticeai/core/tool_registry.py +90 -18
  86. package/latticeai/core/users.py +1 -5
  87. package/latticeai/core/workspace_graph_trace.py +31 -5
  88. package/latticeai/core/workspace_memory.py +3 -1
  89. package/latticeai/core/workspace_os.py +18 -7
  90. package/latticeai/core/workspace_os_utils.py +0 -5
  91. package/latticeai/core/workspace_permissions.py +2 -1
  92. package/latticeai/core/workspace_plugins.py +1 -1
  93. package/latticeai/core/workspace_runs.py +14 -5
  94. package/latticeai/core/workspace_skills.py +1 -1
  95. package/latticeai/core/workspace_snapshots.py +2 -1
  96. package/latticeai/core/workspace_timeline.py +2 -1
  97. package/latticeai/integrations/telegram_bot.py +94 -43
  98. package/latticeai/models/router.py +130 -36
  99. package/latticeai/runtime/access_runtime.py +62 -4
  100. package/latticeai/runtime/automation_runtime.py +13 -7
  101. package/latticeai/runtime/brain_runtime.py +19 -7
  102. package/latticeai/runtime/chat_wiring.py +2 -0
  103. package/latticeai/runtime/config_runtime.py +58 -26
  104. package/latticeai/runtime/context_runtime.py +16 -4
  105. package/latticeai/runtime/hooks_runtime.py +1 -1
  106. package/latticeai/runtime/model_wiring.py +33 -5
  107. package/latticeai/runtime/namespace_runtime.py +62 -72
  108. package/latticeai/runtime/platform_runtime_wiring.py +4 -3
  109. package/latticeai/runtime/review_wiring.py +1 -1
  110. package/latticeai/runtime/router_registration.py +34 -1
  111. package/latticeai/runtime/security_runtime.py +110 -13
  112. package/latticeai/runtime/stages.py +27 -0
  113. package/latticeai/server_app.py +5 -1
  114. package/latticeai/services/architecture_readiness.py +74 -5
  115. package/latticeai/services/brain_automation.py +3 -26
  116. package/latticeai/services/chat_service.py +205 -15
  117. package/latticeai/services/local_knowledge.py +423 -0
  118. package/latticeai/services/memory_service.py +55 -21
  119. package/latticeai/services/model_engines.py +48 -33
  120. package/latticeai/services/model_errors.py +17 -0
  121. package/latticeai/services/model_loading.py +28 -25
  122. package/latticeai/services/model_runtime.py +228 -162
  123. package/latticeai/services/p_reinforce.py +22 -3
  124. package/latticeai/services/platform_runtime.py +83 -23
  125. package/latticeai/services/product_readiness.py +19 -17
  126. package/latticeai/services/review_queue.py +12 -0
  127. package/latticeai/services/router_context.py +1 -0
  128. package/latticeai/services/run_executor.py +4 -9
  129. package/latticeai/services/search_service.py +203 -28
  130. package/latticeai/services/tool_dispatch.py +28 -5
  131. package/latticeai/services/triggers.py +53 -4
  132. package/latticeai/services/upload_service.py +13 -2
  133. package/latticeai/setup/__init__.py +25 -0
  134. package/latticeai/setup/auto_setup.py +857 -0
  135. package/latticeai/setup/wizard.py +1264 -0
  136. package/latticeai/tools/__init__.py +278 -0
  137. package/{tools → latticeai/tools}/commands.py +67 -7
  138. package/{tools → latticeai/tools}/computer.py +1 -1
  139. package/{tools → latticeai/tools}/documents.py +1 -1
  140. package/{tools → latticeai/tools}/filesystem.py +3 -3
  141. package/latticeai/tools/knowledge.py +178 -0
  142. package/{tools → latticeai/tools}/local_files.py +1 -1
  143. package/{tools → latticeai/tools}/network.py +0 -1
  144. package/local_knowledge_api.py +4 -342
  145. package/package.json +22 -2
  146. package/scripts/brain_quality_eval.py +3 -1
  147. package/scripts/bump_version.py +3 -0
  148. package/scripts/capture_release_evidence.mjs +180 -0
  149. package/scripts/check_current_release_docs.mjs +142 -0
  150. package/scripts/check_i18n_literals.mjs +107 -31
  151. package/scripts/check_openapi_drift.mjs +56 -0
  152. package/scripts/export_openapi.py +67 -7
  153. package/scripts/i18n_literal_allowlist.json +22 -24
  154. package/scripts/run_integration_tests.mjs +72 -10
  155. package/scripts/validate_release_artifacts.py +49 -7
  156. package/scripts/wheel_smoke.py +5 -0
  157. package/setup_wizard.py +3 -1260
  158. package/src-tauri/Cargo.lock +1 -1
  159. package/src-tauri/Cargo.toml +1 -1
  160. package/src-tauri/tauri.conf.json +1 -1
  161. package/static/app/asset-manifest.json +11 -11
  162. package/static/app/assets/Act-C3dBrWE-.js +1 -0
  163. package/static/app/assets/Brain-DBYgdcjt.js +321 -0
  164. package/static/app/assets/Capture-Cf3hqRtN.js +1 -0
  165. package/static/app/assets/Library-CFfkNn3s.js +1 -0
  166. package/static/app/assets/System-BOurbT-v.js +1 -0
  167. package/static/app/assets/index-A3M9sElj.js +17 -0
  168. package/static/app/assets/index-Bmx9rzTc.css +2 -0
  169. package/static/app/assets/primitives-DcUUmhdC.js +1 -0
  170. package/static/app/assets/textarea-BklR6zN4.js +1 -0
  171. package/static/app/index.html +2 -2
  172. package/static/sw.js +1 -1
  173. package/tools/__init__.py +21 -269
  174. package/docs/CODE_REVIEW_2026-07-06.md +0 -764
  175. package/latticeai/runtime/app_context_runtime.py +0 -13
  176. package/latticeai/runtime/tail_wiring.py +0 -21
  177. package/scripts/com.pts.claudecode.discord.plist +0 -31
  178. package/scripts/pts-claudecode-discord-bridge.mjs +0 -207
  179. package/scripts/start-pts-claudecode-discord.sh +0 -51
  180. package/static/app/assets/Act-21lIXx2E.js +0 -1
  181. package/static/app/assets/Brain-BqUd5UJJ.js +0 -321
  182. package/static/app/assets/Capture-BA7Z2Q1u.js +0 -1
  183. package/static/app/assets/Library-bFMtyni3.js +0 -1
  184. package/static/app/assets/System-K6krGCqn.js +0 -1
  185. package/static/app/assets/index-C4R3ws30.js +0 -17
  186. package/static/app/assets/index-ChSeOB02.css +0 -2
  187. package/static/app/assets/primitives-sQU3it5I.js +0 -1
  188. package/static/app/assets/textarea-DK3Fd_lR.js +0 -1
  189. package/tools/knowledge.py +0 -95
@@ -0,0 +1,278 @@
1
+ """Safe local tools for Lattice AI agent mode.
2
+
3
+ All filesystem operations are confined to LATTICEAI_AGENT_ROOT, defaulting to
4
+ ./agent_workspace. Command execution runs without a shell and from inside that
5
+ workspace.
6
+
7
+ The physical implementation belongs to ``latticeai.tools``. The historical
8
+ root ``tools`` package aliases this exact module object, preserving existing
9
+ imports and ``tools.AGENT_ROOT`` monkeypatch/state identity.
10
+ """
11
+
12
+ import base64
13
+ import json
14
+ import os
15
+ import platform
16
+ import re
17
+ import shlex
18
+ import socket
19
+ import subprocess
20
+ import tempfile
21
+ from html.parser import HTMLParser
22
+ from pathlib import Path
23
+ from typing import Any, Callable, Dict, List, Optional
24
+
25
+ from latticeai.core.tool_registry import ToolRegistry
26
+ from p_reinforce import BRAIN_DIR, STRUCTURE
27
+
28
+ _PLATFORM = platform.system() # "Darwin" | "Windows" | "Linux"
29
+
30
+
31
+ # ── base: agent-root sandbox, shared constants, path helpers ──────────────────
32
+ AGENT_ROOT = Path(os.getenv("LATTICEAI_AGENT_ROOT") or "agent_workspace").resolve()
33
+ MAX_FILE_BYTES = 512_000
34
+ MAX_COMMAND_SECONDS = 30
35
+ MAX_BUILD_SECONDS = 180
36
+ MAX_DEPLOY_SECONDS = 300
37
+ MAX_COMMAND_OUTPUT = 12_000
38
+
39
+ BLOCKED_COMMANDS = {
40
+ "rm",
41
+ "rmdir",
42
+ "sudo",
43
+ "su",
44
+ "chmod",
45
+ "chown",
46
+ "curl",
47
+ "wget",
48
+ "ssh",
49
+ "scp",
50
+ "rsync",
51
+ "dd",
52
+ "mkfs",
53
+ "diskutil",
54
+ "launchctl",
55
+ }
56
+
57
+ ALLOWED_COMMANDS = {
58
+ "pwd",
59
+ "ls",
60
+ "find",
61
+ "cat",
62
+ "head",
63
+ "tail",
64
+ "wc",
65
+ "rg",
66
+ "git",
67
+ }
68
+
69
+ BUILD_SCRIPT_NAMES = {"build", "compile", "typecheck", "test"}
70
+ DEPLOY_SCRIPT_NAMES = {
71
+ "deploy",
72
+ "preview",
73
+ "release",
74
+ "package",
75
+ "dist",
76
+ "make",
77
+ "build:installer",
78
+ "build:pkg",
79
+ "build:exe",
80
+ "package:mac",
81
+ "package:win",
82
+ }
83
+
84
+ ALLOWED_GIT_SUBCOMMANDS = {"status", "diff", "log", "show"}
85
+
86
+ TEXT_EXTENSIONS = {
87
+ ".css",
88
+ ".csv",
89
+ ".html",
90
+ ".js",
91
+ ".json",
92
+ ".jsx",
93
+ ".md",
94
+ ".py",
95
+ ".ts",
96
+ ".tsx",
97
+ ".txt",
98
+ ".xml",
99
+ ".yaml",
100
+ ".yml",
101
+ }
102
+
103
+ DOCUMENT_OUTPUT_DIR = "generated_documents"
104
+ PRESENTATION_OUTPUT_DIR = "generated_presentations"
105
+ SPREADSHEET_OUTPUT_DIR = "generated_spreadsheets"
106
+
107
+
108
+ class ToolError(ValueError):
109
+ pass
110
+
111
+
112
+ def ensure_agent_root() -> Path:
113
+ AGENT_ROOT.mkdir(parents=True, exist_ok=True)
114
+ return AGENT_ROOT
115
+
116
+
117
+ def _resolve_path(path: str = "") -> Path:
118
+ ensure_agent_root()
119
+ if not path:
120
+ return AGENT_ROOT
121
+ candidate = (AGENT_ROOT / path).resolve()
122
+ if candidate != AGENT_ROOT and AGENT_ROOT not in candidate.parents:
123
+ raise ToolError("Path escapes the agent workspace.")
124
+ return candidate
125
+
126
+
127
+ def _relative(path: Path) -> str:
128
+ return str(path.relative_to(AGENT_ROOT))
129
+
130
+
131
+ # ── document / local / read constants (shared by submodules) ──────────────────
132
+ PDF_OUTPUT_DIR = "generated_pdfs"
133
+ LOCAL_MAX_FILE_BYTES = 2_000_000 # 2 MB cap for local reads
134
+
135
+
136
+ # CJK-capable fonts (Korean + Chinese + Japanese)
137
+ _CJK_FONT_CANDIDATES = [
138
+ "/System/Library/Fonts/AppleSDGothicNeo.ttc", # Korean (macOS)
139
+ "/System/Library/Fonts/STHeiti Light.ttc", # Chinese (macOS)
140
+ "/System/Library/Fonts/PingFang.ttc", # Chinese (macOS)
141
+ "/Library/Fonts/NanumGothic.ttf", # Korean
142
+ "/usr/share/fonts/truetype/nanum/NanumGothic.ttf",
143
+ ]
144
+
145
+ _SUPPORTED_READ_EXTENSIONS = {".pdf", ".docx", ".xlsx", ".pptx", ".txt", ".md", ".csv"}
146
+ DOCUMENT_MAX_READ_BYTES = 10_000_000 # 10 MB
147
+
148
+
149
+ # ── focused tool submodules (re-exported flat for import compatibility) ───────
150
+ from latticeai.tools.computer import * # noqa: E402,F401,F403
151
+ from latticeai.tools.filesystem import * # noqa: E402,F401,F403
152
+ from latticeai.tools.documents import * # noqa: E402,F401,F403
153
+ from latticeai.tools.local_files import * # noqa: E402,F401,F403
154
+ from latticeai.tools.knowledge import * # noqa: E402,F401,F403
155
+ from latticeai.tools.network import * # noqa: E402,F401,F403
156
+ from latticeai.tools.commands import * # noqa: E402,F401,F403
157
+
158
+
159
+ # ── tool registry: the single name → invocation source of truth ───────────────
160
+ def _h_create_xlsx(args: Dict[str, Any]) -> Dict[str, Any]:
161
+ rows = args.get("rows", [])
162
+ if isinstance(rows, str):
163
+ rows = json.loads(rows)
164
+ return create_xlsx(rows, args.get("filename", "spreadsheet.xlsx"), args.get("sheet_name", "Sheet1"))
165
+
166
+
167
+ def _h_create_pptx(args: Dict[str, Any]) -> Dict[str, Any]:
168
+ slides = args.get("slides", [])
169
+ if isinstance(slides, str):
170
+ slides = json.loads(slides)
171
+ return create_pptx(args.get("title", ""), slides, args.get("filename", "presentation.pptx"))
172
+
173
+
174
+ def _knowledge_scope(args: Dict[str, Any]) -> Dict[str, str]:
175
+ """Return the authenticated scope injected by server/runtime adapters."""
176
+ workspace_id = str(args.get("workspace_id") or "").strip()
177
+ user_email = str(args.get("user_email") or "").strip().lower()
178
+ if not workspace_id or not user_email:
179
+ raise ToolError(
180
+ "Knowledge tool execution requires an authenticated workspace and user scope."
181
+ )
182
+ return {"workspace_id": workspace_id, "user_email": user_email}
183
+
184
+
185
+ # ── Tool registry: the single source of truth for name → invocation ───────────
186
+ # Each entry binds the args dict to a tool function. ``execute_tool`` is a
187
+ # lookup over this table — adding a tool means adding one entry here, not
188
+ # editing an if/elif chain. server.py's governance map and catalog brief are
189
+ # checked against ``registered_tools()`` so the three never silently drift.
190
+ TOOL_HANDLERS: Dict[str, Callable[[Dict[str, Any]], Dict[str, Any]]] = {
191
+ # filesystem
192
+ "list_dir": lambda a: list_dir(a.get("path", ".")),
193
+ "workspace_tree": lambda a: workspace_tree(a.get("path", "."), a.get("max_depth", 3)),
194
+ "read_file": lambda a: read_file(a["path"], offset=a.get("offset", 0), limit=a.get("limit", 0), line_numbers=a.get("line_numbers", True)),
195
+ "write_file": lambda a: write_file(a["path"], a.get("content", "")),
196
+ "edit_file": lambda a: edit_file(a["path"], a["old_string"], a["new_string"], replace_all=bool(a.get("replace_all", False))),
197
+ "grep": lambda a: grep(a["pattern"], path=a.get("path", "."), glob=a.get("glob"), max_results=a.get("max_results", 50), case_insensitive=bool(a.get("case_insensitive", False)), context_lines=a.get("context_lines", 0)),
198
+ "search_files": lambda a: search_files(a["query"], a.get("path", "."), a.get("max_results", 20)),
199
+ "inspect_html": lambda a: inspect_html(a["path"]),
200
+ "preview_url": lambda a: preview_url(a.get("path", "index.html")),
201
+ # planning
202
+ "todo_read": lambda a: todo_read(),
203
+ "todo_write": lambda a: todo_write(a.get("todos") or []),
204
+ # documents
205
+ "create_docx": lambda a: create_docx(a.get("title", ""), a.get("body", ""), a.get("filename", "document.docx")),
206
+ "create_xlsx": _h_create_xlsx,
207
+ "create_pptx": _h_create_pptx,
208
+ "create_pdf": lambda a: create_pdf(a.get("title", ""), a.get("body", ""), a.get("filename", "document.pdf")),
209
+ "create_web_project": lambda a: create_web_project(a.get("path", ""), a.get("framework", "react"), a.get("template", "vite")),
210
+ # local filesystem
211
+ "local_list": lambda a: local_list(a["path"]),
212
+ "local_read": lambda a: local_read(a["path"]),
213
+ "local_write": lambda a: local_write(a["path"], a.get("content", "")),
214
+ "read_document": lambda a: read_document(a["path"]),
215
+ "network_status": lambda a: network_status(),
216
+ # computer use
217
+ "computer_screenshot": lambda a: computer_screenshot(),
218
+ "computer_open_app": lambda a: computer_open_app(a.get("app", "Google Chrome")),
219
+ "computer_open_url": lambda a: computer_open_url(a["url"], a.get("app", "Google Chrome")),
220
+ "computer_click": lambda a: computer_click(a.get("x", 0), a.get("y", 0), a.get("button", "left"), a.get("double", False)),
221
+ "computer_type": lambda a: computer_type(a["text"], a.get("interval", 0.04)),
222
+ "computer_key": lambda a: computer_key(a["key"]),
223
+ "computer_scroll": lambda a: computer_scroll(a.get("x", 0), a.get("y", 0), a.get("direction", "down"), a.get("clicks", 3)),
224
+ "computer_move": lambda a: computer_move(a.get("x", 0), a.get("y", 0)),
225
+ "computer_drag": lambda a: computer_drag(a.get("x1", 0), a.get("y1", 0), a.get("x2", 0), a.get("y2", 0)),
226
+ "computer_status": lambda a: computer_status(),
227
+ "chrome_status": lambda a: desktop_bridge_status(),
228
+ "computer_use_status": lambda a: desktop_bridge_status(),
229
+ "vision_analyze": lambda a: vision_analyze(a.get("image_b64", ""), a.get("prompt", "Describe this image in detail. Be concise.")),
230
+ # knowledge / obsidian
231
+ "knowledge_save": lambda a: knowledge_save(a["content"], a.get("folder", "00_Raw"), a.get("title"), **_knowledge_scope(a)),
232
+ "knowledge_search": lambda a: knowledge_search(a["query"], a.get("max_results", 5), **_knowledge_scope(a)),
233
+ "knowledge_tree": lambda a: knowledge_tree(**_knowledge_scope(a)),
234
+ "obsidian_save": lambda a: obsidian_save(a["content"], a.get("folder", "00_Raw"), a.get("title"), **_knowledge_scope(a)),
235
+ "obsidian_search": lambda a: obsidian_search(a["query"], a.get("max_results", 5), **_knowledge_scope(a)),
236
+ "obsidian_tree": lambda a: obsidian_tree(**_knowledge_scope(a)),
237
+ # git (read-only)
238
+ "git_status": lambda a: git_status(a.get("cwd")),
239
+ "git_diff": lambda a: git_diff(a.get("path"), a.get("cwd")),
240
+ "git_log": lambda a: git_log(a.get("max_count", 5), a.get("cwd")),
241
+ "git_show": lambda a: git_show(a.get("revision", "HEAD"), a.get("cwd")),
242
+ # exec
243
+ "run_command": lambda a: run_command(a["command"], a.get("cwd")),
244
+ "build_project": lambda a: build_project(a.get("cwd"), a.get("script", "build")),
245
+ "deploy_project": lambda a: deploy_project(a.get("cwd"), a.get("script", "deploy")),
246
+ }
247
+
248
+
249
+ DEFAULT_TOOL_REGISTRY = ToolRegistry(TOOL_HANDLERS)
250
+
251
+
252
+ def registered_tools() -> frozenset:
253
+ """Names dispatchable through ``execute_tool`` — the seam other modules verify against."""
254
+ return DEFAULT_TOOL_REGISTRY.registered_tools()
255
+
256
+
257
+ def execute_tool(action: str, args: Dict[str, Any]) -> Dict[str, Any]:
258
+ return DEFAULT_TOOL_REGISTRY.execute(action, args, error_cls=ToolError)
259
+
260
+
261
+ __all__ = [
262
+ "AGENT_ROOT", "ToolError", "ensure_agent_root",
263
+ "list_dir", "workspace_tree", "read_file", "write_file", "edit_file", "grep",
264
+ "search_files", "inspect_html", "preview_url", "create_web_project",
265
+ "todo_read", "todo_write",
266
+ "create_docx", "create_xlsx", "create_pptx", "create_pdf", "read_document",
267
+ "local_list", "local_read", "local_write", "desktop_bridge_status",
268
+ "knowledge_save", "knowledge_search", "knowledge_tree", "knowledge_scope_root",
269
+ "obsidian_save", "obsidian_search", "obsidian_tree",
270
+ "network_status",
271
+ "computer_screenshot", "computer_open_app", "computer_open_url",
272
+ "computer_click", "computer_type", "computer_key", "computer_scroll",
273
+ "computer_move", "computer_drag", "computer_status", "vision_analyze",
274
+ "run_command", "build_project", "deploy_project",
275
+ "git_status", "git_diff", "git_log", "git_show",
276
+ "TOOL_HANDLERS", "DEFAULT_TOOL_REGISTRY", "registered_tools", "execute_tool",
277
+ "BRAIN_DIR", "STRUCTURE",
278
+ ]
@@ -2,13 +2,15 @@
2
2
 
3
3
  from __future__ import annotations
4
4
 
5
+ import os
5
6
  import shlex
7
+ import shutil
6
8
  import subprocess
7
9
  from pathlib import Path
8
10
  from typing import Any, Dict, List, Optional
9
11
 
10
- import tools
11
- from tools import (
12
+ import latticeai.tools as tools
13
+ from latticeai.tools import (
12
14
  ToolError,
13
15
  ensure_agent_root,
14
16
  _resolve_path,
@@ -25,7 +27,46 @@ from tools import (
25
27
  )
26
28
 
27
29
  # find(1) flags that execute or delete; checked in run_command.
28
- _BLOCKED_FIND_FLAGS = {"-exec", "-execdir", "-delete", "-ok", "-okdir"}
30
+ _BLOCKED_FIND_FLAGS = {
31
+ "-delete",
32
+ "-exec",
33
+ "-execdir",
34
+ "-fls",
35
+ "-fprint",
36
+ "-fprintf",
37
+ "-ok",
38
+ "-okdir",
39
+ }
40
+ _BLOCKED_RG_FLAGS = {"--pre", "--pre-glob"}
41
+ _SAFE_EXECUTABLE_PATH = "/usr/bin:/bin:/usr/sbin:/sbin:/opt/homebrew/bin:/usr/local/bin"
42
+
43
+
44
+ def _argument_value(argument: str) -> str:
45
+ if argument.startswith("-") and "=" in argument:
46
+ return argument.split("=", 1)[1]
47
+ return argument
48
+
49
+
50
+ def _validate_command_paths(parts: List[str], workdir: Path) -> None:
51
+ """Reject traversal and symlink escapes hidden in command arguments."""
52
+ for argument in parts[1:]:
53
+ value = _argument_value(argument)
54
+ if not value or value == "/dev/null" or (argument.startswith("-") and "=" not in argument):
55
+ continue
56
+ candidate_path = Path(value).expanduser()
57
+ if candidate_path.is_absolute():
58
+ raise ToolError(f"Absolute paths in command arguments are not allowed: {value}")
59
+ if ".." in candidate_path.parts:
60
+ raise ToolError(f"Path traversal in command arguments is not allowed: {value}")
61
+
62
+ candidate = workdir / candidate_path
63
+ # Existing arguments (including symlinks) and explicit path-shaped
64
+ # values must resolve inside the workspace. Other values can be search
65
+ # patterns, counts, or expressions and are not interpreted as paths.
66
+ if candidate.exists() or candidate.is_symlink() or "/" in value or "\\" in value:
67
+ resolved = candidate.resolve(strict=False)
68
+ if resolved != tools.AGENT_ROOT and tools.AGENT_ROOT not in resolved.parents:
69
+ raise ToolError(f"Path escapes the agent workspace: {value}")
29
70
 
30
71
 
31
72
  def run_command(command: str, cwd: Optional[str] = None) -> Dict[str, Any]:
@@ -35,6 +76,8 @@ def run_command(command: str, cwd: Optional[str] = None) -> Dict[str, Any]:
35
76
  raise ToolError("Command is empty.")
36
77
 
37
78
  executable = Path(parts[0]).name
79
+ if parts[0] != executable:
80
+ raise ToolError("Executable paths are not allowed.")
38
81
  if executable in BLOCKED_COMMANDS or executable not in ALLOWED_COMMANDS:
39
82
  raise ToolError(f"Command is not allowed: {executable}")
40
83
  if executable == "git":
@@ -45,20 +88,37 @@ def run_command(command: str, cwd: Optional[str] = None) -> Dict[str, Any]:
45
88
  blocked = [f for f in parts[1:] if f in _BLOCKED_FIND_FLAGS]
46
89
  if blocked:
47
90
  raise ToolError(f"find flags are not allowed: {', '.join(blocked)}")
48
- abs_args = [a for a in parts[1:] if a.startswith("/") and a not in ("/dev/null",)]
49
- if abs_args:
50
- raise ToolError(f"Absolute paths in command arguments are not allowed: {abs_args[0]}")
91
+ if executable == "rg":
92
+ blocked = [
93
+ flag
94
+ for flag in parts[1:]
95
+ if any(flag == denied or flag.startswith(f"{denied}=") for denied in _BLOCKED_RG_FLAGS)
96
+ ]
97
+ if blocked:
98
+ raise ToolError(f"rg flags are not allowed: {', '.join(blocked)}")
51
99
 
52
100
  workdir = _resolve_path(cwd or ".")
53
101
  if not workdir.exists() or not workdir.is_dir():
54
102
  raise ToolError("Working directory does not exist.")
103
+ _validate_command_paths(parts, workdir)
104
+
105
+ executable_path = shutil.which(executable, path=_SAFE_EXECUTABLE_PATH)
106
+ if not executable_path:
107
+ raise ToolError(f"Allowed command is not installed: {executable}")
108
+ environment = {
109
+ "HOME": str(tools.AGENT_ROOT),
110
+ "LANG": os.environ.get("LANG", "C.UTF-8"),
111
+ "LC_ALL": os.environ.get("LC_ALL", "C.UTF-8"),
112
+ "PATH": _SAFE_EXECUTABLE_PATH,
113
+ }
55
114
 
56
115
  try:
57
116
  completed = subprocess.run(
58
- parts,
117
+ [executable_path, *parts[1:]],
59
118
  cwd=workdir,
60
119
  capture_output=True,
61
120
  text=True,
121
+ env=environment,
62
122
  timeout=MAX_COMMAND_SECONDS,
63
123
  check=False,
64
124
  )
@@ -9,7 +9,7 @@ import subprocess
9
9
  import tempfile
10
10
  from typing import Any, Dict
11
11
 
12
- from tools import ToolError
12
+ from latticeai.tools import ToolError
13
13
 
14
14
  _PLATFORM = platform.system()
15
15
 
@@ -5,7 +5,7 @@ from __future__ import annotations
5
5
  from pathlib import Path
6
6
  from typing import Any, Dict, List
7
7
 
8
- from tools import (
8
+ from latticeai.tools import (
9
9
  ToolError,
10
10
  _resolve_path,
11
11
  _relative,
@@ -1,7 +1,7 @@
1
1
  """Filesystem tools confined to the agent workspace.
2
2
 
3
3
  read/write/edit/grep/search, todo list, HTML inspection and web scaffolding.
4
- Path resolution reads ``tools.AGENT_ROOT`` so tests can redirect the sandbox.
4
+ Path resolution reads ``latticeai.tools.AGENT_ROOT`` so tests can redirect the sandbox.
5
5
  """
6
6
 
7
7
  from __future__ import annotations
@@ -12,8 +12,8 @@ from html.parser import HTMLParser
12
12
  from pathlib import Path
13
13
  from typing import Any, Dict, List, Optional
14
14
 
15
- import tools
16
- from tools import (
15
+ import latticeai.tools as tools
16
+ from latticeai.tools import (
17
17
  ToolError,
18
18
  ensure_agent_root,
19
19
  _resolve_path,
@@ -0,0 +1,178 @@
1
+ """Knowledge-base / Obsidian vault tools over the local brain directory."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import hashlib
6
+ from pathlib import Path
7
+ from typing import Any, Dict, List, Optional
8
+
9
+ from p_reinforce import BRAIN_DIR, STRUCTURE
10
+ from latticeai.tools import MAX_FILE_BYTES, ToolError
11
+
12
+
13
+ def _safe_brain_folder(folder: str) -> str:
14
+ if folder not in STRUCTURE:
15
+ raise ToolError(f"Unknown knowledge folder: {folder}")
16
+ return folder
17
+
18
+
19
+ def _scope_digest(kind: str, value: str) -> str:
20
+ digest = hashlib.sha256(f"{kind}\0{value}".encode("utf-8")).hexdigest()[:24]
21
+ return f"{kind}-{digest}"
22
+
23
+
24
+ def knowledge_scope_root(
25
+ *,
26
+ workspace_id: Optional[str] = None,
27
+ user_email: Optional[str] = None,
28
+ ) -> Path:
29
+ """Resolve a private vault partition for an authenticated workspace user.
30
+
31
+ Calls without either value preserve the historical single-user/local vault
32
+ API. Authenticated tool surfaces always provide both values. A half-scoped
33
+ request fails closed so a missing identity can never fall back to the
34
+ shared legacy vault.
35
+ """
36
+ workspace = str(workspace_id or "").strip()
37
+ user = str(user_email or "").strip().lower()
38
+ if not workspace and not user:
39
+ return BRAIN_DIR
40
+ if not workspace or not user:
41
+ raise ToolError("Knowledge tools require both workspace_id and user_email.")
42
+ return (
43
+ BRAIN_DIR
44
+ / ".lattice-scopes"
45
+ / _scope_digest("workspace", workspace)
46
+ / _scope_digest("user", user)
47
+ )
48
+
49
+
50
+ def knowledge_save(
51
+ content: str,
52
+ folder: str = "00_Raw",
53
+ title: Optional[str] = None,
54
+ *,
55
+ workspace_id: Optional[str] = None,
56
+ user_email: Optional[str] = None,
57
+ ) -> Dict[str, Any]:
58
+ folder = _safe_brain_folder(folder)
59
+ if not content:
60
+ raise ToolError("Knowledge content is required.")
61
+ if len(content.encode("utf-8")) > MAX_FILE_BYTES:
62
+ raise ToolError("Knowledge content is too large.")
63
+
64
+ root = knowledge_scope_root(workspace_id=workspace_id, user_email=user_email)
65
+ target_dir = root / folder
66
+ target_dir.mkdir(parents=True, exist_ok=True)
67
+ safe_title = title or content.strip().splitlines()[0][:60] or "note"
68
+ safe_title = "".join(ch if ch.isalnum() or ch in (" ", "-", "_") else "" for ch in safe_title).strip()
69
+ safe_title = "_".join(safe_title.split()) or "note"
70
+ filename = f"{safe_title}.md"
71
+ target = target_dir / filename
72
+ counter = 2
73
+ while target.exists():
74
+ target = target_dir / f"{safe_title}_{counter}.md"
75
+ counter += 1
76
+ target.write_text(content, encoding="utf-8")
77
+ return {"folder": folder, "filename": target.name, "path": str(target)}
78
+
79
+
80
+ def knowledge_search(
81
+ query: str,
82
+ max_results: int = 5,
83
+ *,
84
+ workspace_id: Optional[str] = None,
85
+ user_email: Optional[str] = None,
86
+ ) -> Dict[str, Any]:
87
+ if not query:
88
+ raise ToolError("Query is required.")
89
+ max_results = max(1, min(int(max_results), 20))
90
+ query_lower = query.lower()
91
+ results: List[Dict[str, Any]] = []
92
+ root = knowledge_scope_root(workspace_id=workspace_id, user_email=user_email)
93
+
94
+ for file_path in root.rglob("*.md"):
95
+ if len(results) >= max_results:
96
+ break
97
+ try:
98
+ content = file_path.read_text(encoding="utf-8")
99
+ except UnicodeDecodeError:
100
+ continue
101
+ if query_lower in content.lower() or query_lower in file_path.name.lower():
102
+ results.append(
103
+ {
104
+ "path": str(file_path),
105
+ "relative_path": str(file_path.relative_to(root)),
106
+ "preview": content[:500],
107
+ }
108
+ )
109
+
110
+ return {"query": query, "results": results}
111
+
112
+
113
+ def knowledge_tree(
114
+ *,
115
+ workspace_id: Optional[str] = None,
116
+ user_email: Optional[str] = None,
117
+ ) -> Dict[str, Any]:
118
+ entries: List[Dict[str, Any]] = []
119
+ scope_root = knowledge_scope_root(workspace_id=workspace_id, user_email=user_email)
120
+ for folder in STRUCTURE:
121
+ root = scope_root / folder
122
+ root.mkdir(parents=True, exist_ok=True)
123
+ for file_path in sorted(root.rglob("*.md")):
124
+ entries.append(
125
+ {
126
+ "folder": folder,
127
+ "relative_path": str(file_path.relative_to(scope_root)),
128
+ "size": file_path.stat().st_size,
129
+ }
130
+ )
131
+ return {"root": str(scope_root), "entries": entries}
132
+
133
+
134
+ def obsidian_save(
135
+ content: str,
136
+ folder: str = "00_Raw",
137
+ title: Optional[str] = None,
138
+ *,
139
+ workspace_id: Optional[str] = None,
140
+ user_email: Optional[str] = None,
141
+ ) -> Dict[str, Any]:
142
+ root = knowledge_scope_root(workspace_id=workspace_id, user_email=user_email)
143
+ result = knowledge_save(
144
+ content,
145
+ folder,
146
+ title,
147
+ workspace_id=workspace_id,
148
+ user_email=user_email,
149
+ )
150
+ result["vault_root"] = str(root)
151
+ result["obsidian_uri_hint"] = f"obsidian://open?path={result['path']}"
152
+ return result
153
+
154
+
155
+ def obsidian_search(
156
+ query: str,
157
+ max_results: int = 5,
158
+ *,
159
+ workspace_id: Optional[str] = None,
160
+ user_email: Optional[str] = None,
161
+ ) -> Dict[str, Any]:
162
+ root = knowledge_scope_root(workspace_id=workspace_id, user_email=user_email)
163
+ result = knowledge_search(
164
+ query,
165
+ max_results,
166
+ workspace_id=workspace_id,
167
+ user_email=user_email,
168
+ )
169
+ result["vault_root"] = str(root)
170
+ return result
171
+
172
+
173
+ def obsidian_tree(
174
+ *,
175
+ workspace_id: Optional[str] = None,
176
+ user_email: Optional[str] = None,
177
+ ) -> Dict[str, Any]:
178
+ return knowledge_tree(workspace_id=workspace_id, user_email=user_email)
@@ -6,7 +6,7 @@ from pathlib import Path
6
6
  from typing import Any, Dict
7
7
 
8
8
  from latticeai.core.tool_registry import LOCAL_WRITE_BLOCKED_PREFIXES
9
- from tools import ToolError, LOCAL_MAX_FILE_BYTES
9
+ from latticeai.tools import ToolError, LOCAL_MAX_FILE_BYTES
10
10
 
11
11
 
12
12
  def local_list(path: str) -> Dict[str, Any]:
@@ -61,4 +61,3 @@ def network_status() -> Dict[str, Any]:
61
61
  "ifconfig_available": bool(ifconfig_text),
62
62
  "note": "local_ip은 같은 네트워크 안에서 보이는 내부 IP이고, public_ip는 인터넷에서 보이는 외부 IP입니다.",
63
63
  }
64
-