ltcai 3.4.0 → 3.5.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 (56) hide show
  1. package/README.md +175 -225
  2. package/docs/RUNTIME_HOOK_COVERAGE_v3.5.0.md +56 -0
  3. package/docs/assets/v3.4.1/e2e_runtime_log.txt +42 -0
  4. package/docs/assets/v3.4.1/hooks-dispatch.png +0 -0
  5. package/docs/assets/v3.4.1/local-agent.png +0 -0
  6. package/latticeai/__init__.py +1 -1
  7. package/latticeai/api/auth.py +37 -9
  8. package/latticeai/api/chat.py +6 -1
  9. package/latticeai/api/computer_use.py +21 -8
  10. package/latticeai/api/local_files.py +76 -10
  11. package/latticeai/api/tools.py +35 -35
  12. package/latticeai/core/agent.py +13 -2
  13. package/latticeai/core/builtin_hooks.py +106 -0
  14. package/latticeai/core/config.py +3 -0
  15. package/latticeai/core/hooks.py +76 -2
  16. package/latticeai/core/marketplace.py +1 -1
  17. package/latticeai/core/multi_agent.py +1 -1
  18. package/latticeai/core/oidc.py +205 -0
  19. package/latticeai/core/security.py +59 -5
  20. package/latticeai/core/workflow_engine.py +3 -3
  21. package/latticeai/core/workspace_os.py +1 -1
  22. package/latticeai/server_app.py +22 -34
  23. package/latticeai/services/platform_runtime.py +18 -6
  24. package/latticeai/services/tool_dispatch.py +2 -0
  25. package/latticeai/services/upload_service.py +24 -4
  26. package/local_knowledge_api.py +27 -1
  27. package/package.json +3 -3
  28. package/requirements.txt +1 -0
  29. package/scripts/check_python.py +87 -0
  30. package/static/css/reference/account.css +1 -1
  31. package/static/css/reference/admin.css +1 -1
  32. package/static/css/reference/base.css +8 -5
  33. package/static/css/reference/chat.css +8 -8
  34. package/static/css/reference/graph.css +2 -2
  35. package/static/css/responsive.css +2 -2
  36. package/static/v3/asset-manifest.json +9 -9
  37. package/static/v3/css/{lattice.shell.6ceea7c8.css → lattice.shell.8fcc9d33.css} +2 -1
  38. package/static/v3/css/lattice.shell.css +2 -1
  39. package/static/v3/js/{app.c4acfdd8.js → app.d086489d.js} +1 -1
  40. package/static/v3/js/core/{components.35f02e4c.js → components.f25b3b93.js} +1 -1
  41. package/static/v3/js/core/components.js +1 -1
  42. package/static/v3/js/core/{shell.80a6ad82.js → shell.d05266f5.js} +1 -1
  43. package/static/v3/js/views/{hooks.13845954.js → hooks.37895880.js} +12 -7
  44. package/static/v3/js/views/hooks.js +12 -7
  45. package/static/v3/js/views/{my-computer.c3ef5283.js → my-computer.d9d9ae1c.js} +7 -4
  46. package/static/v3/js/views/my-computer.js +7 -4
  47. package/static/workspace.css +1 -1
  48. package/tools/__init__.py +276 -0
  49. package/tools/commands.py +188 -0
  50. package/tools/computer.py +185 -0
  51. package/tools/documents.py +243 -0
  52. package/tools/filesystem.py +560 -0
  53. package/tools/knowledge.py +97 -0
  54. package/tools/local_files.py +69 -0
  55. package/tools/network.py +66 -0
  56. package/tools.py +0 -1525
@@ -1,17 +1,21 @@
1
1
  """Authentication API router: register, login, logout, SSO, profile."""
2
2
 
3
- import base64
4
- import json
5
3
  import logging
6
4
  import secrets
7
5
  import time
8
- from typing import Any, Callable, Dict, Optional
6
+ from typing import Any, Awaitable, Callable, Dict, Optional, Tuple
9
7
  from urllib.parse import urlencode
10
8
 
11
9
  from fastapi import APIRouter, HTTPException, Request
12
10
  from fastapi.responses import JSONResponse, RedirectResponse
13
11
  from pydantic import BaseModel
14
12
 
13
+ from latticeai.core.oidc import (
14
+ OIDCValidationError,
15
+ fetch_jwks as _default_fetch_jwks,
16
+ verify_id_token as _default_verify_id_token,
17
+ )
18
+
15
19
 
16
20
  class UserRegister(BaseModel):
17
21
  email: str
@@ -35,7 +39,9 @@ class UpdateProfileRequest(BaseModel):
35
39
  nickname: Optional[str] = None
36
40
 
37
41
 
38
- _sso_states: Dict[str, float] = {}
42
+ # state → (issued_at, nonce). The nonce binds the eventual ID token to *this*
43
+ # login attempt (replay / token-injection defence); the timestamp expires it.
44
+ _sso_states: Dict[str, Tuple[float, str]] = {}
39
45
 
40
46
 
41
47
  def create_auth_router(
@@ -58,6 +64,8 @@ def create_auth_router(
58
64
  open_registration: bool,
59
65
  session_ttl: int,
60
66
  require_auth: bool = True,
67
+ verify_id_token: Callable[..., Dict] = _default_verify_id_token,
68
+ fetch_jwks: Callable[[str], Awaitable[Dict]] = _default_fetch_jwks,
61
69
  ) -> APIRouter:
62
70
  router = APIRouter()
63
71
 
@@ -114,13 +122,15 @@ def create_auth_router(
114
122
  if not settings.get("enabled") or not discovery:
115
123
  raise HTTPException(status_code=503, detail="SSO가 설정되지 않았습니다.")
116
124
  state = secrets.token_urlsafe(16)
117
- _sso_states[state] = time.time()
125
+ nonce = secrets.token_urlsafe(16)
126
+ _sso_states[state] = (time.time(), nonce)
118
127
  params = urlencode({
119
128
  "client_id": settings["client_id"],
120
129
  "response_type": "code",
121
130
  "redirect_uri": settings["redirect_uri"],
122
131
  "scope": settings.get("scopes") or "openid email profile",
123
132
  "state": state,
133
+ "nonce": nonce,
124
134
  })
125
135
  return RedirectResponse(f"{discovery['authorization_endpoint']}?{params}")
126
136
 
@@ -128,9 +138,10 @@ def create_auth_router(
128
138
  async def sso_callback(code: str = "", state: str = "", error: str = ""):
129
139
  if error:
130
140
  return RedirectResponse(f"/?sso_error={error}")
131
- ts = _sso_states.pop(state, None)
132
- if ts is None or time.time() - ts > 300:
141
+ entry = _sso_states.pop(state, None)
142
+ if entry is None or time.time() - entry[0] > 300:
133
143
  raise HTTPException(status_code=400, detail="유효하지 않은 SSO 상태입니다.")
144
+ _, nonce = entry
134
145
  settings = get_sso_settings()
135
146
  discovery = await get_sso_discovery()
136
147
  if not settings.get("enabled") or not discovery:
@@ -148,8 +159,25 @@ def create_auth_router(
148
159
  id_token = tokens.get("id_token")
149
160
  if not id_token:
150
161
  raise HTTPException(status_code=400, detail="ID 토큰을 받지 못했습니다.")
151
- padded = id_token.split(".")[1] + "=="
152
- payload = json.loads(base64.urlsafe_b64decode(padded))
162
+ # Never trust a decoded JWT payload: verify signature (against the
163
+ # provider JWKS), issuer, audience, expiry and the login nonce before
164
+ # using any claim. Any failure is fail-closed (401).
165
+ issuer = discovery.get("issuer") or ""
166
+ try:
167
+ jwks = await fetch_jwks(discovery.get("jwks_uri", ""))
168
+ payload = verify_id_token(
169
+ id_token,
170
+ jwks=jwks,
171
+ issuer=issuer,
172
+ audience=settings["client_id"],
173
+ nonce=nonce,
174
+ )
175
+ except OIDCValidationError as exc:
176
+ logging.warning("SSO ID token rejected: %s", exc)
177
+ raise HTTPException(status_code=401, detail="SSO 토큰 검증에 실패했습니다.")
178
+ except Exception as exc: # discovery/JWKS fetch failure → fail closed
179
+ logging.warning("SSO token validation error: %s", exc)
180
+ raise HTTPException(status_code=502, detail="SSO 공급자 검증에 실패했습니다.")
153
181
  email = payload.get("email") or payload.get("preferred_username") or payload.get("upn") or ""
154
182
  if not email:
155
183
  raise HTTPException(status_code=400, detail="이메일을 확인할 수 없습니다.")
@@ -24,6 +24,7 @@ from PIL import Image
24
24
  from latticeai.core.agent import AgentRunContext, AgentState
25
25
  from latticeai.core.context_builder import format_sources_footnote, retrieve_context_for_generation
26
26
  from latticeai.core.document_generator import DocumentGenerationSession, detect_document_intent
27
+ from latticeai.core.hooks import dispatch_tool
27
28
  from latticeai.services.chat_service import ChatService
28
29
  from latticeai.services.tool_dispatch import build_agent_runtime, collect_created_files
29
30
  from telegram_bot import broadcast_web_chat
@@ -144,6 +145,7 @@ def create_chat_router(
144
145
  knowledge_graph,
145
146
  public_model: str,
146
147
  base_dir: Path,
148
+ hooks=None,
147
149
  ) -> APIRouter:
148
150
  api_router = APIRouter()
149
151
  router = model_router
@@ -247,6 +249,7 @@ def create_chat_router(
247
249
  clear_history=clear_history,
248
250
  knowledge_save=knowledge_save,
249
251
  audit=append_audit_event,
252
+ hooks=hooks,
250
253
  )
251
254
 
252
255
  @api_router.post("/chat")
@@ -651,7 +654,9 @@ def create_chat_router(
651
654
  for case in eval_cases:
652
655
  case_id = case.get("id", "?")
653
656
  try:
654
- result = execute_tool(action_name, case.get("input", {}))
657
+ case_input = case.get("input", {})
658
+ result = dispatch_tool(hooks, action_name, case_input,
659
+ lambda: execute_tool(action_name, case_input), source="eval")
655
660
  criteria = case.get("pass_criteria", "")
656
661
  if "success == true" in criteria:
657
662
  passed = result.get("success") is True
@@ -11,6 +11,7 @@ from fastapi.responses import StreamingResponse
11
11
  from pydantic import BaseModel
12
12
 
13
13
  from latticeai.core.agent import extract_action as _extract_agent_action
14
+ from latticeai.core.hooks import dispatch_tool
14
15
  from tools import (
15
16
  ToolError,
16
17
  computer_click,
@@ -105,9 +106,15 @@ class CuDragRequest(BaseModel):
105
106
  y2: int
106
107
 
107
108
 
108
- def create_computer_use_router(*, model_router, require_user, tool_response, save_to_history) -> APIRouter:
109
+ def create_computer_use_router(*, model_router, require_user, tool_response, save_to_history, hooks=None) -> APIRouter:
109
110
  router = APIRouter()
110
111
 
112
+ def _dispatch(name, args, fn):
113
+ # Run a computer-use action through the unified pre_tool/post_tool
114
+ # lifecycle. With hooks=None this is a transparent pass-through, so the
115
+ # behaviour is unchanged when hooks are absent.
116
+ return dispatch_tool(hooks, name, dict(args or {}), fn, source="computer_use")
117
+
111
118
  @router.get("/tools/chrome_status")
112
119
  async def tools_chrome_status(request: Request):
113
120
  require_user(request)
@@ -122,7 +129,9 @@ def create_computer_use_router(*, model_router, require_user, tool_response, sav
122
129
  async def cu_status(request: Request):
123
130
  require_user(request)
124
131
  try:
125
- return computer_status()
132
+ return _dispatch("computer_status", {}, computer_status)
133
+ except PermissionError as exc:
134
+ raise HTTPException(status_code=403, detail=str(exc))
126
135
  except ToolError as exc:
127
136
  raise HTTPException(status_code=400, detail=str(exc))
128
137
 
@@ -130,7 +139,9 @@ def create_computer_use_router(*, model_router, require_user, tool_response, sav
130
139
  async def cu_screenshot(request: Request):
131
140
  require_user(request)
132
141
  try:
133
- return computer_screenshot()
142
+ return _dispatch("computer_screenshot", {}, computer_screenshot)
143
+ except PermissionError as exc:
144
+ raise HTTPException(status_code=403, detail=str(exc))
134
145
  except ToolError as exc:
135
146
  raise HTTPException(status_code=400, detail=str(exc))
136
147
 
@@ -196,7 +207,8 @@ def create_computer_use_router(*, model_router, require_user, tool_response, sav
196
207
  "action",
197
208
  {"step": 1, "action": "computer_open_url", "args": {"url": url, "app": "Google Chrome"}},
198
209
  )
199
- result = computer_open_url(url, "Google Chrome")
210
+ result = _dispatch("computer_open_url", {"url": url, "app": "Google Chrome"},
211
+ lambda: computer_open_url(url, "Google Chrome"))
200
212
  yield _send("result", {"step": 1, "action": "computer_open_url", "result": result})
201
213
  message = f"Google Chrome에서 {url}을 열었습니다."
202
214
  action_name = "computer_open_url"
@@ -205,14 +217,15 @@ def create_computer_use_router(*, model_router, require_user, tool_response, sav
205
217
  "action",
206
218
  {"step": 1, "action": "computer_open_app", "args": {"app": "Google Chrome"}},
207
219
  )
208
- result = computer_open_app("Google Chrome")
220
+ result = _dispatch("computer_open_app", {"app": "Google Chrome"},
221
+ lambda: computer_open_app("Google Chrome"))
209
222
  yield _send("result", {"step": 1, "action": "computer_open_app", "result": result})
210
223
  message = "Google Chrome을 열었습니다."
211
224
  action_name = "computer_open_app"
212
225
  save_to_history("user", req.task, source="web", conversation_id=req.conversation_id)
213
226
  save_to_history("assistant", message, source="web", conversation_id=req.conversation_id)
214
227
  yield _send("final", {"message": message, "steps": [{"step": 1, "action": action_name, "result": result}]})
215
- except ToolError as exc:
228
+ except (ToolError, PermissionError) as exc:
216
229
  yield _send("tool_error", {"step": 1, "action": "computer_open_app", "error": str(exc)})
217
230
  return
218
231
 
@@ -256,7 +269,7 @@ def create_computer_use_router(*, model_router, require_user, tool_response, sav
256
269
 
257
270
  yield _send("action", {"step": step + 1, "action": name, "args": args})
258
271
  try:
259
- result = execute_tool(name, args)
272
+ result = _dispatch(name, args, lambda: execute_tool(name, args))
260
273
  if name == "computer_screenshot" and "screenshot_b64" in result:
261
274
  last_screenshot_b64 = result["screenshot_b64"]
262
275
  result_summary = {k: v for k, v in result.items() if k != "screenshot_b64"}
@@ -275,7 +288,7 @@ def create_computer_use_router(*, model_router, require_user, tool_response, sav
275
288
  last_screenshot_b64 = None
276
289
  transcript.append({"step": step + 1, "action": name, "args": args, "result": result})
277
290
  yield _send("result", {"step": step + 1, "action": name, "result": result})
278
- except (ToolError, KeyError, TypeError) as exc:
291
+ except (ToolError, PermissionError, KeyError, TypeError) as exc:
279
292
  error_str = str(exc)
280
293
  transcript.append({"step": step + 1, "action": name, "args": args, "error": error_str})
281
294
  yield _send("tool_error", {"step": step + 1, "action": name, "error": error_str})
@@ -2,7 +2,11 @@
2
2
 
3
3
  from __future__ import annotations
4
4
 
5
+ import os
5
6
  import platform
7
+ import time
8
+ import uuid
9
+ from datetime import datetime
6
10
  from pathlib import Path
7
11
  from typing import Optional
8
12
 
@@ -14,6 +18,11 @@ from knowledge_graph_api import create_knowledge_graph_router
14
18
  from local_knowledge_api import create_local_knowledge_router
15
19
  from tools import local_list, local_read, local_write
16
20
 
21
+ try:
22
+ from latticeai import __version__ as _LATTICE_VERSION
23
+ except Exception: # pragma: no cover - defensive
24
+ _LATTICE_VERSION = "unknown"
25
+
17
26
 
18
27
  class LocalAccessRequest(BaseModel):
19
28
  path: str
@@ -37,47 +46,103 @@ def create_local_files_router(
37
46
  require_graph,
38
47
  static_dir: Path,
39
48
  local_kg_watcher,
49
+ hooks=None,
50
+ data_dir: Optional[Path] = None,
40
51
  ) -> APIRouter:
41
52
  router = APIRouter()
42
53
 
43
54
  @router.get("/api/local-agent/status")
44
55
  async def local_agent_status(request: Request):
45
56
  """Real on-device runtime status — the 'Local Agent' is the Lattice
46
- server running on this machine (filesystem access + folder watching +
47
- local inference), not a separate desktop process. Everything reported
48
- here is observed, never faked."""
57
+ server running on this machine. Every field below is *probed*, not
58
+ hardcoded: filesystem access is a real write/read/delete; the graph and
59
+ watcher are reached live; the mode/handshake are derived from those
60
+ probes. A failing subsystem yields ``degraded``/``error`` honestly.
61
+
62
+ Allowed modes: offline · starting · online · degraded · error.
63
+ """
49
64
  require_user(request)
65
+ started = time.perf_counter()
66
+ errors = []
67
+
68
+ # ── filesystem capability: a real write → read → delete probe ─────────
69
+ fs_ok = False
70
+ probe_dir = Path(data_dir) if data_dir else Path(static_dir).parent
71
+ try:
72
+ probe_dir.mkdir(parents=True, exist_ok=True)
73
+ probe = probe_dir / f".local_agent_probe_{uuid.uuid4().hex}"
74
+ token = uuid.uuid4().hex
75
+ probe.write_text(token, encoding="utf-8")
76
+ fs_ok = probe.read_text(encoding="utf-8") == token
77
+ probe.unlink()
78
+ except Exception as exc:
79
+ errors.append(f"filesystem: {exc}")
80
+
81
+ # ── graph subsystem reachability (real call) ──────────────────────────
82
+ graph_reachable = None
83
+ if knowledge_graph is not None:
84
+ try:
85
+ knowledge_graph.stats()
86
+ graph_reachable = True
87
+ except Exception as exc:
88
+ graph_reachable = False
89
+ errors.append(f"graph: {exc}")
90
+
91
+ # ── watcher + connected sources (real) ────────────────────────────────
50
92
  watch = local_kg_watcher.status() if local_kg_watcher else {"available": False, "active": {}}
51
93
  sources = []
52
94
  try:
53
95
  if knowledge_graph is not None:
54
96
  sources = (knowledge_graph.local_sources() or {}).get("sources", [])
55
- except Exception:
56
- sources = []
97
+ except Exception as exc:
98
+ errors.append(f"sources: {exc}")
57
99
  watched = len(watch.get("active", {}) or {})
100
+
101
+ # ── derive mode + handshake from the probes (no constants) ────────────
102
+ if not fs_ok:
103
+ mode = "error"
104
+ elif graph_reachable is False:
105
+ mode = "degraded"
106
+ else:
107
+ mode = "online"
108
+ handshake_ok = fs_ok and graph_reachable is not False
109
+ latency_ms = round((time.perf_counter() - started) * 1000, 2)
110
+
58
111
  return {
59
112
  "agent": {
60
113
  "id": "lattice-local-runtime",
61
114
  "name": "Lattice Local Agent",
62
115
  "kind": "on-device-runtime",
63
- "online": True,
116
+ "online": mode == "online",
64
117
  "platform": platform.platform(),
65
118
  "machine": platform.machine(),
66
119
  "python": platform.python_version(),
67
120
  },
121
+ "online": mode == "online",
122
+ "mode": mode,
123
+ "version": _LATTICE_VERSION,
124
+ "pid": os.getpid(),
68
125
  "handshake": {
69
- "ok": True,
126
+ "ok": handshake_ok,
70
127
  "transport": "in-process",
71
- "detail": "The local Lattice runtime serves this workspace on-device; no external agent is required.",
128
+ "latency_ms": latency_ms,
129
+ "detail": "Probed the in-process runtime (filesystem + graph); the local Lattice server is the on-device agent — no separate desktop process.",
72
130
  },
73
131
  "health": {
74
- "status": "ok",
75
- "filesystem_access": True,
132
+ "status": mode,
133
+ "filesystem_access": fs_ok,
134
+ "graph_reachable": graph_reachable,
76
135
  "watcher_available": bool(watch.get("available")),
77
136
  },
137
+ "filesystem_access": fs_ok,
138
+ "watcher_available": bool(watch.get("available")),
139
+ "connected_folders": len(sources),
140
+ "watched_folders": watched,
78
141
  "folders": {"connected": len(sources), "watching": watched},
79
142
  "watch": watch,
80
143
  "sources": sources,
144
+ "last_seen": datetime.now().isoformat(timespec="seconds"),
145
+ "error": "; ".join(errors) if errors else None,
81
146
  }
82
147
 
83
148
  @router.post("/local/list")
@@ -157,6 +222,7 @@ def create_local_files_router(
157
222
  local_permission_response=permission_gateway.local_permission_response,
158
223
  require_local_approval=permission_gateway.require_local_approval,
159
224
  watcher=local_kg_watcher,
225
+ hooks=hooks,
160
226
  )
161
227
  )
162
228
 
@@ -15,6 +15,7 @@ from pydantic import BaseModel
15
15
 
16
16
  from latticeai.api.computer_use import create_computer_use_router
17
17
  from latticeai.api.local_files import create_local_files_router
18
+ from latticeai.core.hooks import dispatch_tool
18
19
  from latticeai.api.mcp import create_mcp_router
19
20
  from latticeai.api.permissions import create_permissions_router
20
21
  from latticeai.services.upload_service import process_uploaded_document
@@ -220,23 +221,30 @@ def create_tools_router(
220
221
 
221
222
  # ── Direct Tool API ───────────────────────────────────────────────────────────
222
223
 
223
- def _tool_response(fn, *args):
224
+ def _tool_response(fn, *args, **kwargs):
225
+ # Shared tool lifecycle (same path as the agent + workflow tool calls):
226
+ # pre_tool (may block) → execute → post_tool. Keyword args are forwarded
227
+ # to the tool and surfaced in the hook payload so read_file / edit_file /
228
+ # grep (which need kwargs) run through the SAME lifecycle as every other
229
+ # tool instead of bypassing it.
224
230
  tool_name = getattr(fn, "__name__", "tool")
225
- # ── pre_tool hooks ── a blocking pre_tool hook (e.g. a permission gate)
226
- # stops the tool call before it runs.
227
- if HOOKS is not None:
228
- pre = HOOKS.fire_hook("pre_tool", f"tool.{tool_name}", payload={"tool": tool_name, "argc": len(args)})
229
- if pre.get("blocked"):
230
- raise HTTPException(status_code=403, detail=pre.get("block_reason") or f"Tool '{tool_name}' blocked by a pre_tool hook.")
231
231
  try:
232
- result = fn(*args)
232
+ result = dispatch_tool(HOOKS, tool_name, dict(kwargs), lambda: fn(*args, **kwargs), source="http")
233
+ except PermissionError as exc:
234
+ raise HTTPException(status_code=403, detail=str(exc))
233
235
  except ToolError as exc:
234
- if HOOKS is not None:
235
- HOOKS.fire_hook("post_tool", f"tool.{tool_name}", payload={"tool": tool_name, "status": "error", "detail": str(exc)})
236
236
  raise HTTPException(status_code=400, detail=str(exc))
237
- if HOOKS is not None:
238
- HOOKS.fire_hook("post_tool", f"tool.{tool_name}", payload={"tool": tool_name, "status": "ok"})
239
237
  return {"status": "ok", "workspace": str(AGENT_ROOT), "result": result}
238
+
239
+ def _dispatch(tool_name, args, fn):
240
+ # Lifecycle wrapper for callables that aren't a plain tools.* function
241
+ # (e.g. the server's clear_history). Same pre_tool/post_tool path.
242
+ try:
243
+ return dispatch_tool(HOOKS, tool_name, dict(args or {}), fn, source="http")
244
+ except PermissionError as exc:
245
+ raise HTTPException(status_code=403, detail=str(exc))
246
+ except ToolError as exc:
247
+ raise HTTPException(status_code=400, detail=str(exc))
240
248
 
241
249
 
242
250
  @api_router.post("/tools/list_dir")
@@ -254,11 +262,7 @@ def create_tools_router(
254
262
  @api_router.post("/tools/read_file")
255
263
  async def tools_read_file(req: ToolReadFileRequest, request: Request):
256
264
  require_user(request)
257
- try:
258
- return {"status": "ok", "workspace": str(AGENT_ROOT),
259
- "result": read_file(req.path, offset=req.offset, limit=req.limit, line_numbers=req.line_numbers)}
260
- except ToolError as exc:
261
- raise HTTPException(status_code=400, detail=str(exc))
265
+ return _tool_response(read_file, req.path, offset=req.offset, limit=req.limit, line_numbers=req.line_numbers)
262
266
 
263
267
 
264
268
  @api_router.post("/tools/write_file")
@@ -270,11 +274,7 @@ def create_tools_router(
270
274
  @api_router.post("/tools/edit_file")
271
275
  async def tools_edit_file(req: ToolEditFileRequest, request: Request):
272
276
  require_user(request)
273
- try:
274
- return {"status": "ok", "workspace": str(AGENT_ROOT),
275
- "result": edit_file(req.path, req.old_string, req.new_string, replace_all=req.replace_all)}
276
- except ToolError as exc:
277
- raise HTTPException(status_code=400, detail=str(exc))
277
+ return _tool_response(edit_file, req.path, req.old_string, req.new_string, replace_all=req.replace_all)
278
278
 
279
279
 
280
280
  @api_router.post("/tools/search_files")
@@ -286,18 +286,15 @@ def create_tools_router(
286
286
  @api_router.post("/tools/grep")
287
287
  async def tools_grep(req: ToolGrepRequest, request: Request):
288
288
  require_user(request)
289
- try:
290
- return {"status": "ok", "workspace": str(AGENT_ROOT),
291
- "result": grep(
292
- req.pattern,
293
- path=req.path,
294
- glob=req.glob,
295
- max_results=req.max_results,
296
- case_insensitive=req.case_insensitive,
297
- context_lines=req.context_lines,
298
- )}
299
- except ToolError as exc:
300
- raise HTTPException(status_code=400, detail=str(exc))
289
+ return _tool_response(
290
+ grep,
291
+ req.pattern,
292
+ path=req.path,
293
+ glob=req.glob,
294
+ max_results=req.max_results,
295
+ case_insensitive=req.case_insensitive,
296
+ context_lines=req.context_lines,
297
+ )
301
298
 
302
299
 
303
300
  @api_router.post("/tools/todo_read")
@@ -315,7 +312,7 @@ def create_tools_router(
315
312
  @api_router.post("/tools/clear_history")
316
313
  async def tools_clear_history(req: ToolClearHistoryRequest, request: Request):
317
314
  current_user = require_user(request)
318
- result = clear_history(req.keep_last)
315
+ result = _dispatch("clear_history", {"keep_last": req.keep_last}, lambda: clear_history(req.keep_last))
319
316
  append_audit_event(
320
317
  "history_delete",
321
318
  user_email=current_user,
@@ -458,12 +455,15 @@ def create_tools_router(
458
455
  require_graph=_require_graph,
459
456
  static_dir=STATIC_DIR,
460
457
  local_kg_watcher=LOCAL_KG_WATCHER,
458
+ hooks=HOOKS,
459
+ data_dir=DATA_DIR,
461
460
  ))
462
461
  api_router.include_router(create_computer_use_router(
463
462
  model_router=router,
464
463
  require_user=require_user,
465
464
  tool_response=_tool_response,
466
465
  save_to_history=save_to_history,
466
+ hooks=HOOKS,
467
467
  ))
468
468
 
469
469
  @api_router.post("/tools/knowledge_save")
@@ -28,6 +28,7 @@ from enum import Enum
28
28
  from pathlib import Path
29
29
  from typing import Any, Awaitable, Callable, Dict, FrozenSet, List, Optional
30
30
 
31
+ from latticeai.core.hooks import dispatch_tool
31
32
  from tools import ToolError
32
33
 
33
34
 
@@ -122,6 +123,11 @@ class AgentDeps:
122
123
  memory_updater_prompt: str
123
124
  agent_root: Path
124
125
 
126
+ # ── lifecycle hooks port (optional) ──────────────────────────────
127
+ # When present, every tool execution fires the shared pre_tool/post_tool
128
+ # lifecycle, so the agent tool path no longer bypasses hooks.
129
+ hooks: Any = None
130
+
125
131
 
126
132
  class AgentRuntime:
127
133
  """Drives the agent state machine over injected :class:`AgentDeps`."""
@@ -289,13 +295,18 @@ class AgentRuntime:
289
295
 
290
296
  try:
291
297
  d.check_role(name, current_user)
292
- result = d.execute_tool(name, args)
298
+ # Shared tool lifecycle: pre_tool (may block) → execute → post_tool.
299
+ result = dispatch_tool(
300
+ d.hooks, name, args,
301
+ lambda: d.execute_tool(name, args),
302
+ user_email=current_user, source="agent",
303
+ )
293
304
  ctx.transcript.append({
294
305
  "state": AgentState.EXECUTING.value, "action": name,
295
306
  "thoughts": thoughts, "args": args,
296
307
  "risk": risk, "governance": dict(policy), "result": result,
297
308
  })
298
- except (ToolError, KeyError, TypeError) as exc:
309
+ except (ToolError, KeyError, TypeError, PermissionError) as exc:
299
310
  ctx.transcript.append({
300
311
  "state": AgentState.EXECUTING.value, "action": name,
301
312
  "thoughts": thoughts, "args": args,
@@ -0,0 +1,106 @@
1
+ """Real runners for the built-in lifecycle hooks.
2
+
3
+ Each built-in hook in :data:`latticeai.core.hooks.BUILTIN_HOOKS` is bound here to
4
+ an actual callable so dispatch performs real platform work rather than a silent
5
+ no-op. Kept out of ``server_app`` to keep the assembly file lean; ``server_app``
6
+ calls :func:`register_builtin_hook_runners` once with the platform dependencies.
7
+
8
+ A runner receives a :class:`~latticeai.core.hooks.HookContext` and returns a
9
+ status dict (``{status, output, block?, detail?}``). It may mutate the context
10
+ payload (e.g. redaction) or call ``context.block()`` to gate a ``pre_*`` action.
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ from typing import Any, Callable
16
+
17
+ _SECRET_KEY_HINTS = (
18
+ "token", "password", "passwd", "secret", "api_key", "apikey",
19
+ "authorization", "auth", "cookie", "session", "private_key",
20
+ )
21
+
22
+
23
+ def register_builtin_hook_runners(
24
+ registry: Any,
25
+ *,
26
+ append_audit_event: Callable[..., None],
27
+ get_tool_permission: Callable[..., dict],
28
+ classify_sensitive_message: Callable[..., dict],
29
+ ) -> None:
30
+ """Bind a real runner to every built-in hook on ``registry``."""
31
+
32
+ def redact_secrets(context):
33
+ """pre_run — strip secret-like keys from the agent context packet."""
34
+ payload = context.payload if isinstance(context.payload, dict) else {}
35
+ redacted = []
36
+ for key in list(payload.keys()):
37
+ if any(s in str(key).lower() for s in _SECRET_KEY_HINTS):
38
+ payload[key] = "***redacted***"
39
+ redacted.append(key)
40
+ return {"status": "ok", "output": f"redacted {len(redacted)} field(s)" if redacted else "no secrets present"}
41
+
42
+ def audit_agent_run(context):
43
+ """post_run — append the completed agent run to the workspace audit log."""
44
+ p = context.payload if isinstance(context.payload, dict) else {}
45
+ append_audit_event(
46
+ "hook_post_run", user_email=context.user_email,
47
+ run_id=p.get("run_id"), agent_id=p.get("agent_id"), status=p.get("status"),
48
+ )
49
+ return {"status": "ok", "output": f"audited run {p.get('run_id') or ''}".strip()}
50
+
51
+ def pipeline_index_status(context):
52
+ """post_index — record ingest/embed/graph-build pipeline state."""
53
+ p = context.payload if isinstance(context.payload, dict) else {}
54
+ return {"status": "ok", "output": f"pipeline {context.event}: indexed={p.get('indexed')}"}
55
+
56
+ def research_memory_snapshot(context):
57
+ """agent — record that a short-term memory snapshot was captured."""
58
+ p = context.payload if isinstance(context.payload, dict) else {}
59
+ n = p.get("context_items")
60
+ return {"status": "ok", "output": f"memory snapshot recorded ({n if n is not None else '0'} context items)"}
61
+
62
+ def tool_permission_gate(context):
63
+ """pre_tool — evaluate the real governance policy for the tool and record it.
64
+
65
+ Enforcement (admin-only gating, approval tokens) stays in the tool
66
+ dispatcher; this surfaces the policy into the run log and only blocks when
67
+ the governance policy itself marks the tool denied.
68
+ """
69
+ p = context.payload if isinstance(context.payload, dict) else {}
70
+ tool = p.get("tool") or ""
71
+ try:
72
+ perm = dict(get_tool_permission(tool))
73
+ except Exception as exc: # pragma: no cover - defensive
74
+ return {"status": "ok", "output": f"policy unavailable for '{tool}': {exc}"}
75
+ if perm.get("policy") == "deny" or perm.get("risk") == "deny":
76
+ return {"status": "blocked", "block": True, "detail": f"governance policy denies '{tool}'"}
77
+ return {"status": "ok", "output": f"policy[{tool}]: risk={perm.get('risk')} approval={perm.get('requires_approval')}"}
78
+
79
+ def sensitive_data_guard(context):
80
+ """pre_tool — classify the outgoing tool payload for sensitive data."""
81
+ p = context.payload if isinstance(context.payload, dict) else {}
82
+ content = " ".join(str(v) for v in p.values() if isinstance(v, (str, int, float)))
83
+ try:
84
+ verdict = classify_sensitive_message(
85
+ {"role": "tool", "content": content, "user_email": context.user_email}, -1
86
+ )
87
+ except Exception as exc: # pragma: no cover - defensive
88
+ return {"status": "ok", "output": f"classifier unavailable: {exc}"}
89
+ labels = verdict.get("labels") or []
90
+ return {"status": "ok", "output": f"sensitivity={verdict.get('sensitivity')} labels={','.join(labels) if labels else 'none'}"}
91
+
92
+ def workflow_replay_log(context):
93
+ """post_workflow — record the workflow run so it can be replayed."""
94
+ p = context.payload if isinstance(context.payload, dict) else {}
95
+ return {"status": "ok", "output": f"workflow {p.get('workflow_id') or '?'} -> {p.get('status') or 'recorded'} ({p.get('steps', '?')} steps)"}
96
+
97
+ registry.register_hook("builtin:redact-secrets", redact_secrets)
98
+ registry.register_hook("builtin:audit-agent-run", audit_agent_run)
99
+ registry.register_hook("builtin:pipeline-index-status", pipeline_index_status)
100
+ registry.register_hook("builtin:research-memory-snapshot", research_memory_snapshot)
101
+ registry.register_hook("builtin:tool-permission-gate", tool_permission_gate)
102
+ registry.register_hook("builtin:sensitive-data-guard", sensitive_data_guard)
103
+ registry.register_hook("builtin:workflow-replay-log", workflow_replay_log)
104
+
105
+
106
+ __all__ = ["register_builtin_hook_runners"]