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.
- package/README.md +43 -42
- package/docs/CHANGELOG.md +228 -0
- package/docs/COMMUNITY_AND_PLUGINS.md +1 -1
- package/docs/DEVELOPMENT.md +1 -1
- package/docs/ONBOARDING.md +1 -1
- package/docs/OPERATIONS.md +3 -1
- package/docs/PERMISSION_MODE.md +107 -0
- package/docs/SURFACE_PARITY.md +45 -9
- package/docs/TRUST_MODEL.md +1 -1
- package/docs/WHY_LATTICE.md +1 -1
- package/docs/kg-schema.md +1 -1
- package/lattice_brain/__init__.py +1 -1
- package/lattice_brain/context.py +39 -4
- package/lattice_brain/graph/_kg_common.py +189 -14
- package/lattice_brain/graph/curator.py +62 -0
- package/lattice_brain/graph/discovery.py +76 -0
- package/lattice_brain/graph/ingest.py +37 -7
- package/lattice_brain/graph/rerank.py +160 -0
- package/lattice_brain/graph/retrieval.py +28 -442
- package/lattice_brain/graph/retrieval_reads.py +459 -0
- package/lattice_brain/graph/retrieval_vector.py +10 -0
- package/lattice_brain/graph/store.py +2 -0
- package/lattice_brain/ingestion.py +9 -106
- package/lattice_brain/ingestion_jobs.py +133 -0
- package/lattice_brain/quality.py +26 -6
- package/lattice_brain/runtime/multi_agent.py +1 -1
- package/latticeai/__init__.py +1 -1
- package/latticeai/api/brain_intelligence.py +8 -0
- package/latticeai/api/chat.py +9 -0
- package/latticeai/api/chat_agent_http.py +206 -48
- package/latticeai/api/chat_contracts.py +10 -1
- package/latticeai/api/chat_documents.py +16 -0
- package/latticeai/api/evidence_actions.py +48 -0
- package/latticeai/api/permission_mode.py +61 -0
- package/latticeai/api/project_sessions.py +112 -0
- package/latticeai/api/voice_capture.py +89 -0
- package/latticeai/app_factory.py +53 -0
- package/latticeai/core/agent.py +536 -60
- package/latticeai/core/agent_permission.py +160 -0
- package/latticeai/core/agent_profiles.py +110 -0
- package/latticeai/core/artifact_ledger.py +109 -0
- package/latticeai/core/context_builder.py +101 -2
- package/latticeai/core/legacy_compatibility.py +1 -1
- package/latticeai/core/marketplace.py +1 -1
- package/latticeai/core/permission_mode.py +296 -0
- package/latticeai/core/project_sessions.py +337 -0
- package/latticeai/core/run_explain.py +421 -0
- package/latticeai/core/run_store.py +7 -0
- package/latticeai/core/workspace_os.py +15 -80
- package/latticeai/core/workspace_review_items.py +107 -0
- package/latticeai/integrations/telegram_bot.py +270 -23
- package/latticeai/runtime/chat_wiring.py +8 -0
- package/latticeai/runtime/context_runtime.py +11 -0
- package/latticeai/runtime/permission_mode_wiring.py +116 -0
- package/latticeai/runtime/router_registration.py +9 -0
- package/latticeai/services/app_context.py +3 -0
- package/latticeai/services/architecture_readiness.py +1 -1
- package/latticeai/services/brain_intelligence.py +105 -0
- package/latticeai/services/evidence_actions.py +254 -0
- package/latticeai/services/funnel_metrics.py +111 -16
- package/latticeai/services/local_knowledge.py +26 -0
- package/latticeai/services/memory_service.py +12 -0
- package/latticeai/services/permission_mode_service.py +170 -0
- package/latticeai/services/product_readiness.py +1 -1
- package/latticeai/services/tool_dispatch.py +129 -4
- package/latticeai/services/voice_capture.py +189 -0
- package/package.json +6 -3
- package/scripts/check_bundle_budget.mjs +11 -0
- package/scripts/check_current_release_docs.mjs +1 -1
- package/scripts/check_i18n_namespace_coverage.mjs +148 -0
- package/scripts/run_sidecar_e2e.mjs +159 -0
- package/skills/meeting_notes/SKILL.md +82 -0
- package/skills/meeting_notes/examples.md +41 -0
- package/skills/meeting_notes/risk.json +9 -0
- package/skills/meeting_notes/schema.json +55 -0
- package/skills/weekly_review/SKILL.md +75 -0
- package/skills/weekly_review/examples.md +39 -0
- package/skills/weekly_review/risk.json +9 -0
- package/skills/weekly_review/schema.json +55 -0
- package/src-tauri/Cargo.lock +1 -1
- package/src-tauri/Cargo.toml +1 -1
- package/src-tauri/tauri.conf.json +1 -1
- package/static/app/asset-manifest.json +37 -29
- package/static/app/assets/Act-BFbpl2_Y.js +1 -0
- package/static/app/assets/AdminConsole-DaZPha9Y.js +1 -0
- package/static/app/assets/Brain-DGrV0heZ.js +321 -0
- package/static/app/assets/BrainHome-Re6Zxh_W.js +2 -0
- package/static/app/assets/BrainSignals-BQ11jDpJ.js +1 -0
- package/static/app/assets/Capture-DRaf1zLH.js +1 -0
- package/static/app/assets/CommandPalette-DAKPm4uJ.js +1 -0
- package/static/app/assets/LanguageSwitcher-BgTwHoWv.js +1 -0
- package/static/app/assets/Library-BVtrSt12.js +1 -0
- package/static/app/assets/LivingBrain-7glOmK0R.js +1 -0
- package/static/app/assets/ProductFlow-cB3EFinZ.js +1 -0
- package/static/app/assets/ReviewCard-CShhHS-w.js +3 -0
- package/static/app/assets/System-B4nQDy8a.js +1 -0
- package/static/app/assets/{bot-nB_buEZD.js → bot-DtKkLfXj.js} +1 -1
- package/static/app/assets/brain-wVV63_1z.js +1 -0
- package/static/app/assets/button-BTxZDMFJ.js +1 -0
- package/static/app/assets/{circle-pause-DLNw6Ucp.js → circle-pause--qxF9-EO.js} +1 -1
- package/static/app/assets/{circle-play-B-IsFL1y.js → circle-play-89x3S6jm.js} +1 -1
- package/static/app/assets/{cpu-CEPBHaBl.js → cpu-DTUI63vo.js} +1 -1
- package/static/app/assets/download-EJS5_O0f.js +1 -0
- package/static/app/assets/{folder-open-hmN0N9cX.js → folder-open-BL9zYTnc.js} +1 -1
- package/static/app/assets/{hard-drive-CBV_B_Yd.js → hard-drive-BFnwi1Lu.js} +1 -1
- package/static/app/assets/index-BFtDYI6l.js +10 -0
- package/static/app/assets/index-BY9bpxyx.css +2 -0
- package/static/app/assets/input-DEz5UjBz.js +1 -0
- package/static/app/assets/{network-jE42eKfT.js → network-zKa3jAwl.js} +1 -1
- package/static/app/assets/primitives-jNrZm2O9.js +1 -0
- package/static/app/assets/search-ku6-_6wY.js +1 -0
- package/static/app/assets/shield-alert-CpHeCsaF.js +1 -0
- package/static/app/assets/textarea-DpcQkn75.js +1 -0
- package/static/app/assets/{useFocusTrap-B7RPGfFy.js → useFocusTrap-58TNCONM.js} +1 -1
- package/static/app/assets/{navigation-Bot0hvuv.js → useQuery-CcVlXlBd.js} +1 -1
- package/static/app/assets/users-C0cFMj6o.js +1 -0
- package/static/app/assets/utils-DYf7pqrR.js +7 -0
- package/static/app/assets/workspace-CjthHsLN.js +1 -0
- package/static/app/index.html +4 -4
- package/static/sw.js +1 -1
- package/static/app/assets/Act-BCmTU0E2.js +0 -1
- package/static/app/assets/Brain-CTnjox7w.js +0 -321
- package/static/app/assets/BrainHome-BJ3sFNX0.js +0 -2
- package/static/app/assets/BrainSignals-C52lwZVD.js +0 -1
- package/static/app/assets/Capture-B6vBhFa3.js +0 -1
- package/static/app/assets/CommandPalette-90u9FWFH.js +0 -1
- package/static/app/assets/Library-pKCK0_tk.js +0 -1
- package/static/app/assets/LivingBrain-Jlf2wFqI.js +0 -1
- package/static/app/assets/ProductFlow-yg1fKP1P.js +0 -1
- package/static/app/assets/ReviewCard-DWvD7n9h.js +0 -3
- package/static/app/assets/System-BAEuHqNY.js +0 -1
- package/static/app/assets/index-7FAfYm4v.css +0 -2
- package/static/app/assets/index-DrmOCySv.js +0 -10
- package/static/app/assets/input-gtVCg-ll.js +0 -1
- package/static/app/assets/primitives-CX2Komon.js +0 -1
- package/static/app/assets/shield-alert-BftATuAA.js +0 -1
- package/static/app/assets/textarea-CiMJfOSI.js +0 -1
- 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"]
|
|
@@ -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
|
|
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
|
-
"
|
|
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
|
|