ltcai 9.9.4 → 9.9.9

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 (138) hide show
  1. package/README.md +43 -42
  2. package/docs/CHANGELOG.md +228 -0
  3. package/docs/COMMUNITY_AND_PLUGINS.md +1 -1
  4. package/docs/DEVELOPMENT.md +1 -1
  5. package/docs/ONBOARDING.md +1 -1
  6. package/docs/OPERATIONS.md +3 -1
  7. package/docs/PERMISSION_MODE.md +107 -0
  8. package/docs/SURFACE_PARITY.md +45 -9
  9. package/docs/TRUST_MODEL.md +1 -1
  10. package/docs/WHY_LATTICE.md +1 -1
  11. package/docs/kg-schema.md +1 -1
  12. package/lattice_brain/__init__.py +1 -1
  13. package/lattice_brain/context.py +39 -4
  14. package/lattice_brain/graph/_kg_common.py +189 -14
  15. package/lattice_brain/graph/curator.py +62 -0
  16. package/lattice_brain/graph/discovery.py +76 -0
  17. package/lattice_brain/graph/ingest.py +37 -7
  18. package/lattice_brain/graph/rerank.py +160 -0
  19. package/lattice_brain/graph/retrieval.py +28 -442
  20. package/lattice_brain/graph/retrieval_reads.py +459 -0
  21. package/lattice_brain/graph/retrieval_vector.py +10 -0
  22. package/lattice_brain/graph/store.py +2 -0
  23. package/lattice_brain/ingestion.py +9 -106
  24. package/lattice_brain/ingestion_jobs.py +133 -0
  25. package/lattice_brain/quality.py +26 -6
  26. package/lattice_brain/runtime/multi_agent.py +1 -1
  27. package/latticeai/__init__.py +1 -1
  28. package/latticeai/api/brain_intelligence.py +8 -0
  29. package/latticeai/api/chat.py +9 -0
  30. package/latticeai/api/chat_agent_http.py +206 -48
  31. package/latticeai/api/chat_contracts.py +10 -1
  32. package/latticeai/api/chat_documents.py +16 -0
  33. package/latticeai/api/evidence_actions.py +48 -0
  34. package/latticeai/api/permission_mode.py +61 -0
  35. package/latticeai/api/project_sessions.py +112 -0
  36. package/latticeai/api/voice_capture.py +89 -0
  37. package/latticeai/app_factory.py +53 -0
  38. package/latticeai/core/agent.py +536 -60
  39. package/latticeai/core/agent_permission.py +160 -0
  40. package/latticeai/core/agent_profiles.py +110 -0
  41. package/latticeai/core/artifact_ledger.py +109 -0
  42. package/latticeai/core/context_builder.py +101 -2
  43. package/latticeai/core/legacy_compatibility.py +1 -1
  44. package/latticeai/core/marketplace.py +1 -1
  45. package/latticeai/core/permission_mode.py +296 -0
  46. package/latticeai/core/project_sessions.py +337 -0
  47. package/latticeai/core/run_explain.py +421 -0
  48. package/latticeai/core/run_store.py +7 -0
  49. package/latticeai/core/workspace_os.py +15 -80
  50. package/latticeai/core/workspace_review_items.py +107 -0
  51. package/latticeai/integrations/telegram_bot.py +270 -23
  52. package/latticeai/runtime/chat_wiring.py +8 -0
  53. package/latticeai/runtime/context_runtime.py +11 -0
  54. package/latticeai/runtime/permission_mode_wiring.py +116 -0
  55. package/latticeai/runtime/router_registration.py +9 -0
  56. package/latticeai/services/app_context.py +3 -0
  57. package/latticeai/services/architecture_readiness.py +1 -1
  58. package/latticeai/services/brain_intelligence.py +105 -0
  59. package/latticeai/services/evidence_actions.py +254 -0
  60. package/latticeai/services/funnel_metrics.py +111 -16
  61. package/latticeai/services/local_knowledge.py +26 -0
  62. package/latticeai/services/memory_service.py +12 -0
  63. package/latticeai/services/permission_mode_service.py +170 -0
  64. package/latticeai/services/product_readiness.py +1 -1
  65. package/latticeai/services/tool_dispatch.py +129 -4
  66. package/latticeai/services/voice_capture.py +189 -0
  67. package/package.json +6 -3
  68. package/scripts/check_bundle_budget.mjs +11 -0
  69. package/scripts/check_current_release_docs.mjs +1 -1
  70. package/scripts/check_i18n_namespace_coverage.mjs +148 -0
  71. package/scripts/run_sidecar_e2e.mjs +159 -0
  72. package/skills/meeting_notes/SKILL.md +82 -0
  73. package/skills/meeting_notes/examples.md +41 -0
  74. package/skills/meeting_notes/risk.json +9 -0
  75. package/skills/meeting_notes/schema.json +55 -0
  76. package/skills/weekly_review/SKILL.md +75 -0
  77. package/skills/weekly_review/examples.md +39 -0
  78. package/skills/weekly_review/risk.json +9 -0
  79. package/skills/weekly_review/schema.json +55 -0
  80. package/src-tauri/Cargo.lock +1 -1
  81. package/src-tauri/Cargo.toml +1 -1
  82. package/src-tauri/tauri.conf.json +1 -1
  83. package/static/app/asset-manifest.json +37 -29
  84. package/static/app/assets/Act-BFbpl2_Y.js +1 -0
  85. package/static/app/assets/AdminConsole-DaZPha9Y.js +1 -0
  86. package/static/app/assets/Brain-DGrV0heZ.js +321 -0
  87. package/static/app/assets/BrainHome-Re6Zxh_W.js +2 -0
  88. package/static/app/assets/BrainSignals-BQ11jDpJ.js +1 -0
  89. package/static/app/assets/Capture-DRaf1zLH.js +1 -0
  90. package/static/app/assets/CommandPalette-DAKPm4uJ.js +1 -0
  91. package/static/app/assets/LanguageSwitcher-BgTwHoWv.js +1 -0
  92. package/static/app/assets/Library-BVtrSt12.js +1 -0
  93. package/static/app/assets/LivingBrain-7glOmK0R.js +1 -0
  94. package/static/app/assets/ProductFlow-cB3EFinZ.js +1 -0
  95. package/static/app/assets/ReviewCard-CShhHS-w.js +3 -0
  96. package/static/app/assets/System-B4nQDy8a.js +1 -0
  97. package/static/app/assets/{bot-nB_buEZD.js → bot-DtKkLfXj.js} +1 -1
  98. package/static/app/assets/brain-wVV63_1z.js +1 -0
  99. package/static/app/assets/button-BTxZDMFJ.js +1 -0
  100. package/static/app/assets/{circle-pause-DLNw6Ucp.js → circle-pause--qxF9-EO.js} +1 -1
  101. package/static/app/assets/{circle-play-B-IsFL1y.js → circle-play-89x3S6jm.js} +1 -1
  102. package/static/app/assets/{cpu-CEPBHaBl.js → cpu-DTUI63vo.js} +1 -1
  103. package/static/app/assets/download-EJS5_O0f.js +1 -0
  104. package/static/app/assets/{folder-open-hmN0N9cX.js → folder-open-BL9zYTnc.js} +1 -1
  105. package/static/app/assets/{hard-drive-CBV_B_Yd.js → hard-drive-BFnwi1Lu.js} +1 -1
  106. package/static/app/assets/index-BFtDYI6l.js +10 -0
  107. package/static/app/assets/index-BY9bpxyx.css +2 -0
  108. package/static/app/assets/input-DEz5UjBz.js +1 -0
  109. package/static/app/assets/{network-jE42eKfT.js → network-zKa3jAwl.js} +1 -1
  110. package/static/app/assets/primitives-jNrZm2O9.js +1 -0
  111. package/static/app/assets/search-ku6-_6wY.js +1 -0
  112. package/static/app/assets/shield-alert-CpHeCsaF.js +1 -0
  113. package/static/app/assets/textarea-DpcQkn75.js +1 -0
  114. package/static/app/assets/{useFocusTrap-B7RPGfFy.js → useFocusTrap-58TNCONM.js} +1 -1
  115. package/static/app/assets/{navigation-Bot0hvuv.js → useQuery-CcVlXlBd.js} +1 -1
  116. package/static/app/assets/users-C0cFMj6o.js +1 -0
  117. package/static/app/assets/utils-DYf7pqrR.js +7 -0
  118. package/static/app/assets/workspace-CjthHsLN.js +1 -0
  119. package/static/app/index.html +4 -4
  120. package/static/sw.js +1 -1
  121. package/static/app/assets/Act-BCmTU0E2.js +0 -1
  122. package/static/app/assets/Brain-CTnjox7w.js +0 -321
  123. package/static/app/assets/BrainHome-BJ3sFNX0.js +0 -2
  124. package/static/app/assets/BrainSignals-C52lwZVD.js +0 -1
  125. package/static/app/assets/Capture-B6vBhFa3.js +0 -1
  126. package/static/app/assets/CommandPalette-90u9FWFH.js +0 -1
  127. package/static/app/assets/Library-pKCK0_tk.js +0 -1
  128. package/static/app/assets/LivingBrain-Jlf2wFqI.js +0 -1
  129. package/static/app/assets/ProductFlow-yg1fKP1P.js +0 -1
  130. package/static/app/assets/ReviewCard-DWvD7n9h.js +0 -3
  131. package/static/app/assets/System-BAEuHqNY.js +0 -1
  132. package/static/app/assets/index-7FAfYm4v.css +0 -2
  133. package/static/app/assets/index-DrmOCySv.js +0 -10
  134. package/static/app/assets/input-gtVCg-ll.js +0 -1
  135. package/static/app/assets/primitives-CX2Komon.js +0 -1
  136. package/static/app/assets/shield-alert-BftATuAA.js +0 -1
  137. package/static/app/assets/textarea-CiMJfOSI.js +0 -1
  138. package/static/app/assets/utils-SJUNVOj5.js +0 -7
@@ -293,6 +293,32 @@ def create_local_knowledge_router(
293
293
  payload["watch"] = watch_status
294
294
  return payload
295
295
 
296
+ @router.get("/knowledge-graph/local/health")
297
+ async def knowledge_graph_local_health(request: Request, error_samples: int = 3):
298
+ """Per-folder memory state (v9.9.7): indexing coverage, failures with
299
+ their stored reasons, and watch state.
300
+
301
+ Vector freshness rides along as an explicitly **global** figure — the
302
+ vector index is not per-folder, and claiming otherwise would invent a
303
+ number.
304
+ """
305
+ require_user(request)
306
+ kg = graph()
307
+ payload = kg.local_source_health(error_samples=error_samples)
308
+ watch_status = watcher.status() if watcher else {"available": False, "active": {}}
309
+ active = watch_status.get("active", {})
310
+ for folder in payload.get("folders", []):
311
+ folder["watch_active"] = folder.get("id") in active
312
+ payload["watch"] = watch_status
313
+ try:
314
+ payload["vector_freshness_global"] = kg.vector_freshness()
315
+ except Exception as exc: # noqa: BLE001 — one missing signal, not a 500
316
+ payload["vector_freshness_global"] = {
317
+ "status": "unavailable", "detail": str(exc),
318
+ "pending_items": 0, "total_items": 0,
319
+ }
320
+ return payload
321
+
296
322
  @router.get("/knowledge-graph/local/watch/status")
297
323
  async def knowledge_graph_local_watch_status(request: Request):
298
324
  require_user(request)
@@ -930,6 +930,15 @@ class MemoryService:
930
930
  similarity = round(float(hit.get("score") or 0.0), 4)
931
931
  if similarity <= 0:
932
932
  continue
933
+ # Citation precision (v9.9.6): a chunk hit knows where in the
934
+ # document it came from (section heading / page). Carry that
935
+ # locator onto the recall row so the citation can say it.
936
+ hit_metadata = hit.get("metadata")
937
+ locator = (
938
+ str(hit_metadata.get("locator") or "")
939
+ if isinstance(hit_metadata, dict)
940
+ else ""
941
+ )
933
942
  existing = by_node_id.get(node_id)
934
943
  if existing is not None:
935
944
  existing["vector_score"] = max(existing.get("vector_score", 0.0), similarity)
@@ -937,6 +946,8 @@ class MemoryService:
937
946
  max(existing.get("score", 0.0), 0.4 * existing.get("score", 0.0) + 0.6 * similarity),
938
947
  4,
939
948
  )
949
+ if locator and not existing.get("locator"):
950
+ existing["locator"] = locator
940
951
  else:
941
952
  matched = _matched_terms(hit.get("title"), hit.get("summary"))
942
953
  row = {
@@ -948,6 +959,7 @@ class MemoryService:
948
959
  "score": round(max(_lexical_score(matched), 0.6 * similarity), 4),
949
960
  "matched_terms": matched,
950
961
  "vector_score": similarity,
962
+ **({"locator": locator} if locator else {}),
951
963
  }
952
964
  results.append(row)
953
965
  if node_id:
@@ -0,0 +1,170 @@
1
+ """Persisted permission-mode preference (v9.9.8).
2
+
3
+ Stores the active mode under the data dir so every surface (web, VS Code,
4
+ Telegram, agent loop) shares one dial. Scope:
5
+
6
+ * global default (env ``LATTICEAI_PERMISSION_MODE`` or strict)
7
+ * per-user override
8
+ * per-workspace override (wins over user)
9
+
10
+ Changing to ``bypass`` requires ``acknowledge_risk=True`` once; the ack is
11
+ audited. Circuit breakers are enforced by ``latticeai.core.permission_mode``,
12
+ not by this store.
13
+ """
14
+
15
+ from __future__ import annotations
16
+
17
+ import json
18
+ import threading
19
+ from pathlib import Path
20
+ from typing import Any, Callable, Dict, Optional
21
+
22
+ from latticeai.core.io_utils import atomic_write_json
23
+ from latticeai.core.permission_mode import (
24
+ DEFAULT_MODE,
25
+ PermissionMode,
26
+ mode_catalog,
27
+ mode_contract,
28
+ normalize_mode,
29
+ )
30
+
31
+
32
+ class PermissionModeService:
33
+ """Load / save the autonomy dial."""
34
+
35
+ def __init__(
36
+ self,
37
+ *,
38
+ data_dir: Path,
39
+ default_mode: PermissionMode | str = DEFAULT_MODE,
40
+ audit: Optional[Callable[..., None]] = None,
41
+ ) -> None:
42
+ self._path = Path(data_dir) / "permission_mode.json"
43
+ self._default = normalize_mode(default_mode)
44
+ self._audit = audit or (lambda *a, **kw: None)
45
+ self._lock = threading.Lock()
46
+
47
+ def rebind_data_dir(self, data_dir: Path) -> None:
48
+ """Point the store at the app's real data dir.
49
+
50
+ The wiring may instantiate this service lazily before routers know the
51
+ configured data dir; rebinding keeps one file of record instead of
52
+ stranding writes under the fallback path.
53
+ """
54
+ with self._lock:
55
+ self._path = Path(data_dir) / "permission_mode.json"
56
+
57
+ def rebind_audit(self, audit: Callable[..., None]) -> None:
58
+ """Attach the real audit sink once app wiring provides one."""
59
+ with self._lock:
60
+ self._audit = audit
61
+
62
+ def _read(self) -> Dict[str, Any]:
63
+ if not self._path.exists():
64
+ return {"default": self._default.value, "users": {}, "workspaces": {}}
65
+ try:
66
+ data = json.loads(self._path.read_text(encoding="utf-8"))
67
+ except Exception:
68
+ return {"default": self._default.value, "users": {}, "workspaces": {}}
69
+ if not isinstance(data, dict):
70
+ return {"default": self._default.value, "users": {}, "workspaces": {}}
71
+ data.setdefault("default", self._default.value)
72
+ data.setdefault("users", {})
73
+ data.setdefault("workspaces", {})
74
+ return data
75
+
76
+ def _write(self, data: Dict[str, Any]) -> None:
77
+ self._path.parent.mkdir(parents=True, exist_ok=True)
78
+ atomic_write_json(self._path, data)
79
+
80
+ def _resolve_from(
81
+ self,
82
+ data: Dict[str, Any],
83
+ *,
84
+ user_email: Optional[str],
85
+ workspace_id: Optional[str],
86
+ ) -> PermissionMode:
87
+ """Scope precedence: workspace → user → process default.
88
+
89
+ Pure over ``data`` and lock-free, so holders of ``_lock`` can reuse it
90
+ without re-entering a non-reentrant lock.
91
+ """
92
+ if workspace_id:
93
+ ws = (data.get("workspaces") or {}).get(str(workspace_id))
94
+ if ws:
95
+ return normalize_mode(ws)
96
+ if user_email:
97
+ user = (data.get("users") or {}).get(str(user_email).lower())
98
+ if user:
99
+ return normalize_mode(user)
100
+ return normalize_mode(data.get("default") or self._default)
101
+
102
+ def resolve(
103
+ self,
104
+ *,
105
+ user_email: Optional[str] = None,
106
+ workspace_id: Optional[str] = None,
107
+ ) -> PermissionMode:
108
+ with self._lock:
109
+ data = self._read()
110
+ return self._resolve_from(
111
+ data, user_email=user_email, workspace_id=workspace_id,
112
+ )
113
+
114
+ def get(
115
+ self,
116
+ *,
117
+ user_email: Optional[str] = None,
118
+ workspace_id: Optional[str] = None,
119
+ ) -> Dict[str, Any]:
120
+ mode = self.resolve(user_email=user_email, workspace_id=workspace_id)
121
+ contract = mode_contract(mode)
122
+ contract["catalog"] = mode_catalog()
123
+ contract["scope"] = {
124
+ "user_email": user_email,
125
+ "workspace_id": workspace_id,
126
+ }
127
+ return contract
128
+
129
+ def set_mode(
130
+ self,
131
+ mode: PermissionMode | str,
132
+ *,
133
+ user_email: Optional[str] = None,
134
+ workspace_id: Optional[str] = None,
135
+ acknowledge_risk: bool = False,
136
+ source: str = "api",
137
+ ) -> Dict[str, Any]:
138
+ mode = normalize_mode(mode)
139
+ if mode == PermissionMode.BYPASS and not acknowledge_risk:
140
+ raise PermissionError(
141
+ "bypass mode requires acknowledge_risk=true "
142
+ "(YOLO inside the agent workspace; circuit breakers still apply)"
143
+ )
144
+ with self._lock:
145
+ data = self._read()
146
+ # Lock-free helper on purpose: calling ``resolve`` here would
147
+ # re-enter ``_lock`` and deadlock every mode change.
148
+ previous = self._resolve_from(
149
+ data, user_email=user_email, workspace_id=workspace_id,
150
+ )
151
+ if workspace_id:
152
+ data.setdefault("workspaces", {})[str(workspace_id)] = mode.value
153
+ elif user_email:
154
+ data.setdefault("users", {})[str(user_email).lower()] = mode.value
155
+ else:
156
+ data["default"] = mode.value
157
+ self._write(data)
158
+ self._audit(
159
+ "permission_mode_changed",
160
+ user_email=user_email,
161
+ workspace_id=workspace_id,
162
+ previous=previous.value,
163
+ mode=mode.value,
164
+ source=source,
165
+ acknowledge_risk=bool(acknowledge_risk),
166
+ )
167
+ return self.get(user_email=user_email, workspace_id=workspace_id)
168
+
169
+
170
+ __all__ = ["PermissionModeService"]
@@ -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.9.4"
21
+ PRODUCT_VERSION_TARGET = "9.9.9"
22
22
 
23
23
 
24
24
  @dataclass(frozen=True)
@@ -14,12 +14,19 @@ from typing import Any, Callable, Dict, Mapping, Optional
14
14
  from fastapi import HTTPException
15
15
 
16
16
  from latticeai.core.agent import AgentDeps, SingleAgentRuntime
17
+ from latticeai.core.agent_permission import call_mode_source
17
18
  from latticeai.core.agent_prompts import (
18
19
  CRITIC_PROMPT,
19
20
  EXECUTOR_PROMPT,
20
21
  MEMORY_UPDATER_PROMPT,
21
22
  PLANNER_PROMPT,
22
23
  )
24
+ from latticeai.core.permission_mode import (
25
+ DEFAULT_MODE,
26
+ effective_auto_approve,
27
+ is_circuit_breaker,
28
+ normalize_mode,
29
+ )
23
30
  from latticeai.core.policy import role_has_capability
24
31
  from latticeai.core.tool_governor import classify_tool_call
25
32
  from latticeai.core.tool_registry import ToolPermission, ToolPolicy
@@ -34,6 +41,10 @@ def _default_get_user_role(_email, _users=None) -> str:
34
41
  return "user"
35
42
 
36
43
 
44
+ def _default_permission_mode() -> Any:
45
+ return DEFAULT_MODE
46
+
47
+
37
48
  FILE_CREATE_ACTIONS = set(DEFAULT_TOOL_REGISTRY.file_create_actions)
38
49
  TOOL_GOVERNANCE: Dict[str, ToolPolicy] = dict(DEFAULT_TOOL_REGISTRY.governance)
39
50
  TOOL_GOVERNANCE_DEFAULT: ToolPolicy = DEFAULT_TOOL_REGISTRY.default_policy
@@ -62,6 +73,10 @@ class ToolDispatchService:
62
73
  registry: Any = field(default_factory=lambda: DEFAULT_TOOL_REGISTRY)
63
74
  load_users: Callable[[], Dict[str, Any]] = field(default=_default_load_users)
64
75
  get_user_role: Callable[..., str] = field(default=_default_get_user_role)
76
+ # Static mode, or a callable resolved per enforce_policy call. A callable
77
+ # may accept ``user_email``/``workspace_id`` scope kwargs (preferred) or no
78
+ # arguments at all — see ``call_mode_source``.
79
+ permission_mode: Any = field(default_factory=lambda: _default_permission_mode)
65
80
 
66
81
  @property
67
82
  def file_create_actions(self) -> frozenset[str]:
@@ -80,9 +95,29 @@ class ToolDispatchService:
80
95
  *,
81
96
  load_users: Callable[[], Dict[str, Any]],
82
97
  get_user_role: Callable[..., str],
98
+ permission_mode: Any = None,
83
99
  ) -> None:
84
100
  self.load_users = load_users
85
101
  self.get_user_role = get_user_role
102
+ if permission_mode is not None:
103
+ self.permission_mode = permission_mode
104
+
105
+ def resolve_permission_mode(
106
+ self,
107
+ *,
108
+ user_email: Optional[str] = None,
109
+ workspace_id: Optional[str] = None,
110
+ ) -> Any:
111
+ """Active autonomy dial for this caller.
112
+
113
+ The scope matters: per-user and per-workspace overrides are stored by
114
+ ``PermissionModeService``, so resolving without a scope would silently
115
+ collapse every caller onto the process-wide default.
116
+ """
117
+ raw = call_mode_source(
118
+ self.permission_mode, user_email=user_email, workspace_id=workspace_id,
119
+ )
120
+ return normalize_mode(raw if raw is not None else DEFAULT_MODE)
86
121
 
87
122
  def policy_for(self, action_name: str, args: dict) -> ToolPolicy:
88
123
  return self.registry.policy_for(action_name, args)
@@ -155,6 +190,8 @@ class ToolDispatchService:
155
190
  source: str,
156
191
  require_auto_approval: bool = True,
157
192
  trusted_admin: bool = False,
193
+ permission_mode: Any = None,
194
+ workspace_id: Optional[str] = None,
158
195
  ) -> ToolPolicy:
159
196
  """Authorize a tool call before any hook or handler can execute.
160
197
 
@@ -162,8 +199,18 @@ class ToolDispatchService:
162
199
  runtime uses the same policy data and blocks non-auto-approved steps in
163
200
  its state machine so Computer Use direct endpoints can remain unchanged
164
201
  when explicitly excluded from a hardening pass.
202
+
203
+ The active :class:`PermissionMode` widens what may run without an extra
204
+ approval prompt; it never widens a circuit breaker.
165
205
  """
166
206
  policy = self.policy_for(tool_name, args or {})
207
+ mode = normalize_mode(
208
+ permission_mode
209
+ if permission_mode is not None
210
+ else self.resolve_permission_mode(
211
+ user_email=current_user, workspace_id=workspace_id,
212
+ )
213
+ )
167
214
  if source in {"mcp", "plugin"} and tool_name in EXPLICIT_CONSENT_TOOLS:
168
215
  raise HTTPException(
169
216
  status_code=403,
@@ -174,14 +221,22 @@ class ToolDispatchService:
174
221
  )
175
222
  if not trusted_admin:
176
223
  self.check_role(tool_name, current_user)
224
+
225
+ breaker = is_circuit_breaker(tool_name, policy, args or {})
226
+ if breaker:
227
+ raise HTTPException(status_code=403, detail=f"'{tool_name}' 차단: {breaker}")
228
+
177
229
  if policy["destructive"] or policy["risk"] == "destructive":
178
230
  raise HTTPException(
179
231
  status_code=403,
180
232
  detail=f"'{tool_name}' 툴은 파괴적 작업으로 차단되었습니다.",
181
233
  )
234
+
182
235
  # Fail-closed governance: a call that would rewrite existing content but
183
236
  # cannot be staged as a reviewable proposal is blocked, never applied
184
- # silently. New-file (additive) calls are unaffected.
237
+ # silently. New-file (additive) calls are unaffected. This is
238
+ # mode-invariant — trusted/bypass skip *proposal staging*, but a binary
239
+ # overwrite has no safe apply path in any mode, so it stays blocked.
185
240
  verdict = classify_tool_call(
186
241
  tool_name, args or {},
187
242
  policy=dict(policy), path_exists=self._governed_path_exists,
@@ -195,17 +250,22 @@ class ToolDispatchService:
195
250
  "지원되는 편집 도구(write_file/edit_file)를 사용하세요."
196
251
  ),
197
252
  )
253
+
254
+ auto = policy["auto_approve"] or effective_auto_approve(
255
+ mode, tool_name, policy, change_class=verdict.get("change_class"), args=args or {},
256
+ )
198
257
  if (
199
258
  require_auto_approval
200
259
  and not trusted_admin
201
- and not policy["auto_approve"]
260
+ and not auto
202
261
  and self.user_role(current_user) not in {"admin", "owner"}
203
262
  ):
204
263
  raise HTTPException(
205
264
  status_code=403,
206
265
  detail=(
207
- f"'{tool_name}' 툴은 명시 승인이 필요합니다. "
208
- "현재 릴리스에서는 승인 UI가 없는 직접 실행 경로에서 기본 차단됩니다."
266
+ f"'{tool_name}' 툴은 명시 승인이 필요합니다 "
267
+ f"(permission_mode={mode.value}). "
268
+ "승인 UI가 없는 직접 실행 경로에서 차단되었습니다."
209
269
  ),
210
270
  )
211
271
  return policy
@@ -220,6 +280,60 @@ class ToolDispatchService:
220
280
  )
221
281
  return {"path": path, "ok": r.returncode == 0, "stderr": r.stderr[:200]}
222
282
 
283
+ # ── snapshot rollback ports (review L7) ──────────────────────────────
284
+ # Pre-write capture + restore for workspaces where git rollback does not
285
+ # apply (untracked new files, no git). Strictly confined to AGENT_ROOT.
286
+
287
+ _SNAPSHOT_MAX_BYTES = 512 * 1024
288
+
289
+ def _workspace_path(self, path: str) -> Optional[Path]:
290
+ raw = Path(path)
291
+ candidate = raw if raw.is_absolute() else AGENT_ROOT / raw
292
+ try:
293
+ resolved = candidate.resolve()
294
+ root = AGENT_ROOT.resolve()
295
+ except OSError:
296
+ return None
297
+ if resolved != root and root not in resolved.parents:
298
+ return None
299
+ return resolved
300
+
301
+ def snapshot_file(self, path: str) -> Dict[str, Any]:
302
+ """Pre-write state of a workspace file: existence + bounded content."""
303
+ target = self._workspace_path(path)
304
+ if target is None:
305
+ return {"existed": False, "content": None, "too_large": False,
306
+ "error": "path escapes the agent workspace"}
307
+ if not target.exists() or not target.is_file():
308
+ return {"existed": False, "content": None, "too_large": False}
309
+ try:
310
+ if target.stat().st_size > self._SNAPSHOT_MAX_BYTES:
311
+ return {"existed": True, "content": None, "too_large": True}
312
+ return {
313
+ "existed": True,
314
+ "content": target.read_text(encoding="utf-8", errors="replace"),
315
+ "too_large": False,
316
+ }
317
+ except OSError as exc:
318
+ return {"existed": True, "content": None, "too_large": True,
319
+ "error": str(exc)}
320
+
321
+ def restore_snapshot(self, path: str, content: Optional[str]) -> Dict[str, Any]:
322
+ """Restore a pre-write snapshot: rewrite prior content, or delete a
323
+ file the run created (``content=None``)."""
324
+ target = self._workspace_path(path)
325
+ if target is None:
326
+ return {"path": path, "ok": False, "error": "path escapes the agent workspace"}
327
+ try:
328
+ if content is None:
329
+ target.unlink(missing_ok=True)
330
+ return {"path": path, "ok": True, "action": "deleted"}
331
+ target.parent.mkdir(parents=True, exist_ok=True)
332
+ target.write_text(content, encoding="utf-8")
333
+ return {"path": path, "ok": True, "action": "restored"}
334
+ except OSError as exc:
335
+ return {"path": path, "ok": False, "error": str(exc)}
336
+
223
337
 
224
338
  DEFAULT_TOOL_DISPATCH_SERVICE = ToolDispatchService()
225
339
 
@@ -228,10 +342,12 @@ def configure_tool_dispatch(
228
342
  *,
229
343
  load_users: Callable[[], Dict[str, Any]],
230
344
  get_user_role: Callable[..., str],
345
+ permission_mode: Any = None,
231
346
  ) -> None:
232
347
  DEFAULT_TOOL_DISPATCH_SERVICE.configure(
233
348
  load_users=load_users,
234
349
  get_user_role=get_user_role,
350
+ permission_mode=permission_mode,
235
351
  )
236
352
 
237
353
 
@@ -271,6 +387,8 @@ def enforce_tool_policy(
271
387
  source: str,
272
388
  require_auto_approval: bool = True,
273
389
  trusted_admin: bool = False,
390
+ permission_mode: Any = None,
391
+ workspace_id: Optional[str] = None,
274
392
  ) -> ToolPolicy:
275
393
  return DEFAULT_TOOL_DISPATCH_SERVICE.enforce_policy(
276
394
  tool_name,
@@ -279,6 +397,8 @@ def enforce_tool_policy(
279
397
  source=source,
280
398
  require_auto_approval=require_auto_approval,
281
399
  trusted_admin=trusted_admin,
400
+ workspace_id=workspace_id,
401
+ permission_mode=permission_mode,
282
402
  )
283
403
 
284
404
 
@@ -354,8 +474,10 @@ def build_agent_runtime(
354
474
  hooks: Any = None,
355
475
  brain_memory: Any = None,
356
476
  dispatch_service: ToolDispatchService = DEFAULT_TOOL_DISPATCH_SERVICE,
477
+ permission_mode: Any = None,
357
478
  ) -> SingleAgentRuntime:
358
479
  ensure_agent_root()
480
+ mode = permission_mode if permission_mode is not None else dispatch_service.permission_mode
359
481
  deps = AgentDeps(
360
482
  generate_as=model_router.generate_as,
361
483
  generate=model_router.generate,
@@ -375,8 +497,11 @@ def build_agent_runtime(
375
497
  memory_updater_prompt=MEMORY_UPDATER_PROMPT,
376
498
  agent_root=AGENT_ROOT,
377
499
  rollback_file=dispatch_service.rollback_file,
500
+ snapshot_file=dispatch_service.snapshot_file,
501
+ restore_snapshot=dispatch_service.restore_snapshot,
378
502
  hooks=hooks,
379
503
  brain_memory=brain_memory,
504
+ permission_mode=mode,
380
505
  )
381
506
  return SingleAgentRuntime(deps)
382
507