loki-mode 10.10.5 → 10.11.2
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/SKILL.md +2 -2
- package/VERSION +1 -1
- package/dashboard/__init__.py +1 -1
- package/docs/CLI-REFERENCE.md +1 -1
- package/docs/INSTALLATION.md +1 -1
- package/docs/v10/BOARD.md +6 -4
- package/docs/v10/CP-ASK-PLAN.md +85 -0
- package/docs/v10/DEPS.md +3 -0
- package/docs/v10/FAILURE-CLASSES.md +32 -0
- package/docs/v10/FC21B-FC22-PLAN.md +107 -0
- package/docs/v10/FOUNDER-QUEUE.md +2 -0
- package/docs/v10/METRICS.md +1 -0
- package/docs/v10/PROGRESS.md +21 -0
- package/loki-ts/dist/loki.js +683 -678
- package/mcp/__init__.py +1 -1
- package/mcp/server.py +86 -3
- package/mcp/tests/_read_only_wall_probe.py +156 -0
- package/mcp/tests/test_read_only.py +289 -0
- package/memory/storage.py +41 -3
- package/package.json +1 -1
- package/packages/control-plane/dist/server.js +289 -210
- package/packages/control-plane/drizzle/0005_ask.sql +39 -0
- package/packages/control-plane/drizzle/meta/0005_snapshot.json +800 -0
- package/packages/control-plane/drizzle/meta/_journal.json +7 -0
- package/packages/control-plane/package.json +3 -1
- package/packages/control-plane/src/ask/parse.ts +120 -0
- package/packages/control-plane/src/ask/tools.ts +111 -0
- package/packages/control-plane/src/ask/tools_server.ts +46 -0
- package/packages/control-plane/src/db/schema.ts +43 -1
- package/packages/control-plane/src/server/routes/artifacts.ts +3 -3
- package/plugins/loki-mode/.claude-plugin/plugin.json +1 -1
- package/web-app/dist/assets/{AdminPage-qj6cyTT6.js → AdminPage-CmgIgBFY.js} +1 -1
- package/web-app/dist/assets/{Avatar-Dg4WrYYd.js → Avatar-Dm3ypJzh.js} +1 -1
- package/web-app/dist/assets/{Badge-DHiwSg-p.js → Badge-0siYUwAA.js} +1 -1
- package/web-app/dist/assets/{Button-gi8XEzIU.js → Button-EMKQXvZe.js} +1 -1
- package/web-app/dist/assets/{CockpitPage-B1QNjPBn.js → CockpitPage-Z60eDvJ9.js} +1 -1
- package/web-app/dist/assets/{ComparePage-DW-oUaDu.js → ComparePage-D4L7nViy.js} +1 -1
- package/web-app/dist/assets/{ErrorBoundary-BK5zjqn-.js → ErrorBoundary--v9DugrJ.js} +1 -1
- package/web-app/dist/assets/{EvidenceReceiptPanel-F58cuFm9.js → EvidenceReceiptPanel-CA866XsM.js} +1 -1
- package/web-app/dist/assets/{GitHubIssuesPanel-QVyvF19M.js → GitHubIssuesPanel-Bi6FJmR2.js} +1 -1
- package/web-app/dist/assets/{GitHubPRsPanel-BRFHMzwy.js → GitHubPRsPanel-CgSfcWOg.js} +1 -1
- package/web-app/dist/assets/{HomePage-Cpskd4yM.js → HomePage-BxQtk9Ec.js} +2 -2
- package/web-app/dist/assets/{LoginPage-CteYWi5M.js → LoginPage-CCSxWlFA.js} +1 -1
- package/web-app/dist/assets/{MagicPage-CCLor0AU.js → MagicPage-B4eYm0jJ.js} +1 -1
- package/web-app/dist/assets/{MetricsPage-WgsOaLoq.js → MetricsPage-Bp4Hh0p2.js} +1 -1
- package/web-app/dist/assets/{NotFoundPage-DXSCr9P6.js → NotFoundPage-DTXk_JK4.js} +1 -1
- package/web-app/dist/assets/{ProjectPage-sR0kaaXV.js → ProjectPage-6S1v0GHS.js} +3 -3
- package/web-app/dist/assets/{ProjectsPage-J3TNS42-.js → ProjectsPage-9foqEwG2.js} +1 -1
- package/web-app/dist/assets/{SettingsPage-Cil5837P.js → SettingsPage-CWOG_hhD.js} +1 -1
- package/web-app/dist/assets/{ShowcasePage-C3_oWYJa.js → ShowcasePage-Du9LYlao.js} +1 -1
- package/web-app/dist/assets/{SystemSettingsPage-Sddw1fFS.js → SystemSettingsPage-BQconaDX.js} +1 -1
- package/web-app/dist/assets/{TeamsPage-B0poySCt.js → TeamsPage-_NsqtVmC.js} +1 -1
- package/web-app/dist/assets/{TemplatesPage-pcYSxHam.js → TemplatesPage-DyTEGBpR.js} +1 -1
- package/web-app/dist/assets/{TerminalOutput-CRWTZM7F.js → TerminalOutput-XHOYTpky.js} +1 -1
- package/web-app/dist/assets/{activity-D3aR95zO.js → activity-B9KiMdEs.js} +1 -1
- package/web-app/dist/assets/{bell-WdWaPIBo.js → bell-Oe4lG6kK.js} +1 -1
- package/web-app/dist/assets/{bot-CzD3xokc.js → bot-D86Qmxfy.js} +1 -1
- package/web-app/dist/assets/{check-DMYJiYcm.js → check-BvW6_ni0.js} +1 -1
- package/web-app/dist/assets/{chevron-left-DY0FSKvI.js → chevron-left-DYA62-3g.js} +1 -1
- package/web-app/dist/assets/{circle-alert-DVPtG3Pe.js → circle-alert-CHW8ye4p.js} +1 -1
- package/web-app/dist/assets/{clock-BjjLL6uI.js → clock-QbQ2XfbM.js} +1 -1
- package/web-app/dist/assets/{cloud-CnUCVZhm.js → cloud-ylzrY7eL.js} +1 -1
- package/web-app/dist/assets/{code-xml-BPoxcFNK.js → code-xml-DdKKw2Y9.js} +1 -1
- package/web-app/dist/assets/{database-ClVgDBYt.js → database-BLY_OWs3.js} +1 -1
- package/web-app/dist/assets/{dollar-sign-Ui4BIml8.js → dollar-sign-CsFIa1e2.js} +1 -1
- package/web-app/dist/assets/{file-code-corner-BNYl7J1Z.js → file-code-corner-Df4nyrCW.js} +1 -1
- package/web-app/dist/assets/{file-plus-BxkSyRVn.js → file-plus-_MWEY2WY.js} +1 -1
- package/web-app/dist/assets/{globe-b2poyDKT.js → globe-Dpc7QU9m.js} +1 -1
- package/web-app/dist/assets/{hammer-D_0BUpBG.js → hammer-B-PldT0c.js} +1 -1
- package/web-app/dist/assets/{index-D8TYtITk.js → index-gq0R3j-o.js} +3 -3
- package/web-app/dist/assets/{layers-Co1szEIf.js → layers-BjcNwHLZ.js} +1 -1
- package/web-app/dist/assets/{loader-circle-DylW-nwq.js → loader-circle-CoTgcqkV.js} +1 -1
- package/web-app/dist/assets/{lock-D7aFdKw7.js → lock-JEI8O6TS.js} +1 -1
- package/web-app/dist/assets/{package-ElSsRKiN.js → package-CXWkNS40.js} +1 -1
- package/web-app/dist/assets/{plus-CVWgRxDX.js → plus-CRK4U5b0.js} +1 -1
- package/web-app/dist/assets/{refresh-cw-B2azSZHT.js → refresh-cw-CrzWr4cw.js} +1 -1
- package/web-app/dist/assets/{rotate-ccw-BdQHOrf0.js → rotate-ccw-DV7UZyUw.js} +1 -1
- package/web-app/dist/assets/{scroll-text-9m-YKf83.js → scroll-text-CXEqRrXU.js} +1 -1
- package/web-app/dist/assets/{server-BYq99Fe1.js → server-CDFfFP10.js} +1 -1
- package/web-app/dist/assets/{shield-alert-BNCiHXhR.js → shield-alert-B5EC_m6_.js} +1 -1
- package/web-app/dist/assets/{thumbs-up-BB_EJkat.js → thumbs-up-BeLDemd2.js} +1 -1
- package/web-app/dist/assets/{trash-2-BMF4kFvy.js → trash-2-B7kS86-0.js} +1 -1
- package/web-app/dist/assets/{trending-down-BiqiTPXx.js → trending-down-BGvuLayo.js} +1 -1
- package/web-app/dist/assets/{trending-up-Bq6qyKub.js → trending-up-BkPM2tiK.js} +1 -1
- package/web-app/dist/assets/{upload-1HzIJHHR.js → upload-CXVI0GRU.js} +1 -1
- package/web-app/dist/assets/{usePolling-FNwpjURt.js → usePolling-CMGMVsMA.js} +1 -1
- package/web-app/dist/assets/{user-DrORfQv3.js → user-DPcXNtfj.js} +1 -1
- package/web-app/dist/index.html +1 -1
package/mcp/__init__.py
CHANGED
package/mcp/server.py
CHANGED
|
@@ -112,7 +112,9 @@ def _get_mcp_state_manager():
|
|
|
112
112
|
has disappeared (e.g., project changed) to prevent stale file handle errors.
|
|
113
113
|
"""
|
|
114
114
|
global _state_manager
|
|
115
|
-
if not STATE_MANAGER_AVAILABLE:
|
|
115
|
+
if not STATE_MANAGER_AVAILABLE or _READ_ONLY_MODE:
|
|
116
|
+
# Read-only mode: StateManager creates .loki dirs on construction, so
|
|
117
|
+
# callers use their direct-file-read fallbacks instead.
|
|
116
118
|
return None
|
|
117
119
|
loki_dir = os.path.join(os.getcwd(), '.loki')
|
|
118
120
|
if _state_manager is not None:
|
|
@@ -329,6 +331,10 @@ logger = logging.getLogger('loki-mcp')
|
|
|
329
331
|
# EVENT EMISSION - Non-blocking tool call events
|
|
330
332
|
# ============================================================
|
|
331
333
|
|
|
334
|
+
# Set by apply_read_only_mode(). Every emitter checks it and returns before any
|
|
335
|
+
# filesystem work, so a read-only server never writes .loki/events or signals.
|
|
336
|
+
_READ_ONLY_MODE = False
|
|
337
|
+
|
|
332
338
|
# Track tool call start times for duration calculation (per-tool stack)
|
|
333
339
|
_tool_call_start_times: Dict[str, List[float]] = {}
|
|
334
340
|
_tool_call_times_lock = threading.Lock()
|
|
@@ -345,6 +351,9 @@ def _emit_tool_event_async(tool_name: str, action: str, **kwargs) -> None:
|
|
|
345
351
|
"""
|
|
346
352
|
import time
|
|
347
353
|
|
|
354
|
+
if _READ_ONLY_MODE:
|
|
355
|
+
return
|
|
356
|
+
|
|
348
357
|
# Track timing for learning signals using a per-tool-name stack (thread-safe)
|
|
349
358
|
if action == 'start':
|
|
350
359
|
with _tool_call_times_lock:
|
|
@@ -411,7 +420,7 @@ def _emit_learning_signal_async(
|
|
|
411
420
|
error: Error message if failed
|
|
412
421
|
parameters: Tool parameters for context
|
|
413
422
|
"""
|
|
414
|
-
if not LEARNING_COLLECTOR_AVAILABLE:
|
|
423
|
+
if _READ_ONLY_MODE or not LEARNING_COLLECTOR_AVAILABLE:
|
|
415
424
|
return
|
|
416
425
|
|
|
417
426
|
def emit():
|
|
@@ -475,7 +484,7 @@ def _emit_context_relevance_signal(
|
|
|
475
484
|
retrieved_ids: IDs of retrieved items
|
|
476
485
|
context: Additional context
|
|
477
486
|
"""
|
|
478
|
-
if not LEARNING_COLLECTOR_AVAILABLE:
|
|
487
|
+
if _READ_ONLY_MODE or not LEARNING_COLLECTOR_AVAILABLE:
|
|
479
488
|
return
|
|
480
489
|
|
|
481
490
|
def emit():
|
|
@@ -1792,6 +1801,8 @@ def _maybe_autoreindex_code() -> None:
|
|
|
1792
1801
|
Default behavior is warn-if-stale (the tools just report the staleness
|
|
1793
1802
|
fields). Best-effort: never raises into the caller.
|
|
1794
1803
|
"""
|
|
1804
|
+
if _READ_ONLY_MODE:
|
|
1805
|
+
return
|
|
1795
1806
|
if os.environ.get("LOKI_CODE_INDEX_AUTOREINDEX", "0") != "1":
|
|
1796
1807
|
return
|
|
1797
1808
|
if not _code_index_staleness().get("stale"):
|
|
@@ -1809,6 +1820,12 @@ def _maybe_autoreindex_code() -> None:
|
|
|
1809
1820
|
logger.warning(f"Auto-reindex (LOKI_CODE_INDEX_AUTOREINDEX) failed: {e}")
|
|
1810
1821
|
|
|
1811
1822
|
|
|
1823
|
+
def _chroma_host_is_loopback() -> bool:
|
|
1824
|
+
"""True when LOKI_CHROMA_HOST is unset or a loopback name or address."""
|
|
1825
|
+
host = os.environ.get("LOKI_CHROMA_HOST", "").strip().strip("[]").lower()
|
|
1826
|
+
return host in ("", "localhost", "127.0.0.1", "::1")
|
|
1827
|
+
|
|
1828
|
+
|
|
1812
1829
|
def _get_chroma_collection():
|
|
1813
1830
|
"""Get or create ChromaDB collection (lazy connection).
|
|
1814
1831
|
|
|
@@ -1879,6 +1896,12 @@ async def loki_code_search(
|
|
|
1879
1896
|
await asyncio.to_thread(_maybe_autoreindex_code)
|
|
1880
1897
|
_staleness = _code_index_staleness()
|
|
1881
1898
|
|
|
1899
|
+
if _READ_ONLY_MODE and not _chroma_host_is_loopback():
|
|
1900
|
+
return json.dumps({
|
|
1901
|
+
"error": "read-only mode: code search refuses a non-loopback "
|
|
1902
|
+
"LOKI_CHROMA_HOST; set it to localhost or unset it"
|
|
1903
|
+
})
|
|
1904
|
+
|
|
1882
1905
|
collection = _get_chroma_collection()
|
|
1883
1906
|
if collection is None:
|
|
1884
1907
|
# Emit 'complete' to balance the 'start' above. Without this the
|
|
@@ -2978,6 +3001,59 @@ def _run_http_transport(port):
|
|
|
2978
3001
|
uvicorn.run(app, host=host, port=port, log_level="info")
|
|
2979
3002
|
|
|
2980
3003
|
|
|
3004
|
+
# ============================================================
|
|
3005
|
+
# READ-ONLY MODE (CP-ASK slice 1)
|
|
3006
|
+
# ============================================================
|
|
3007
|
+
# `--read-only` (or LOKI_MCP_READ_ONLY=1) keeps ONLY the tools named here.
|
|
3008
|
+
# Everything else is removed from the registry, so a tool added later is absent
|
|
3009
|
+
# in read-only mode until someone classifies it and adds it to this list (fail
|
|
3010
|
+
# closed). Tools that spawn processes or write, even ones that sound like a
|
|
3011
|
+
# read (loki_verify_fast, loki_v10_verify), are deliberately excluded.
|
|
3012
|
+
READ_ONLY_TOOL_ALLOWLIST = frozenset({
|
|
3013
|
+
"loki_memory_retrieve",
|
|
3014
|
+
"loki_state_get",
|
|
3015
|
+
"loki_metrics_efficiency",
|
|
3016
|
+
"loki_v10_status",
|
|
3017
|
+
"loki_project_status",
|
|
3018
|
+
"loki_agent_metrics",
|
|
3019
|
+
"loki_quality_report",
|
|
3020
|
+
"loki_code_search",
|
|
3021
|
+
"mem_search",
|
|
3022
|
+
"mem_timeline",
|
|
3023
|
+
"mem_get",
|
|
3024
|
+
"loki_get_hotspots",
|
|
3025
|
+
"loki_get_co_changes",
|
|
3026
|
+
"loki_get_doc_coverage",
|
|
3027
|
+
"loki_findings",
|
|
3028
|
+
"loki_learnings",
|
|
3029
|
+
})
|
|
3030
|
+
|
|
3031
|
+
|
|
3032
|
+
def read_only_env_enabled() -> bool:
|
|
3033
|
+
"""LOKI_MCP_READ_ONLY accepts 1, true, yes (case-insensitive)."""
|
|
3034
|
+
return os.environ.get('LOKI_MCP_READ_ONLY', '').strip().lower() in (
|
|
3035
|
+
'1', 'true', 'yes')
|
|
3036
|
+
|
|
3037
|
+
|
|
3038
|
+
def apply_read_only_mode(server=None):
|
|
3039
|
+
"""Remove every registered tool that is not in READ_ONLY_TOOL_ALLOWLIST.
|
|
3040
|
+
|
|
3041
|
+
Also sets _READ_ONLY_MODE, which the event and learning-signal emitters
|
|
3042
|
+
check so no call writes .loki/events or .loki/learning.
|
|
3043
|
+
"""
|
|
3044
|
+
global _READ_ONLY_MODE
|
|
3045
|
+
_READ_ONLY_MODE = True
|
|
3046
|
+
try:
|
|
3047
|
+
from memory.storage import MemoryStorage
|
|
3048
|
+
MemoryStorage.READ_ONLY = True
|
|
3049
|
+
except ImportError:
|
|
3050
|
+
pass
|
|
3051
|
+
server = server if server is not None else mcp
|
|
3052
|
+
for name in list(server._tool_manager._tools):
|
|
3053
|
+
if name not in READ_ONLY_TOOL_ALLOWLIST:
|
|
3054
|
+
server.remove_tool(name)
|
|
3055
|
+
|
|
3056
|
+
|
|
2981
3057
|
# ============================================================
|
|
2982
3058
|
# MAIN
|
|
2983
3059
|
# ============================================================
|
|
@@ -2997,6 +3073,10 @@ def main():
|
|
|
2997
3073
|
'server object built, non-zero otherwise. Used by '
|
|
2998
3074
|
'`loki mcp` to verify a venv before launching. '
|
|
2999
3075
|
'Does not start a server.'))
|
|
3076
|
+
parser.add_argument('--read-only', action='store_true',
|
|
3077
|
+
help=('Register only the read allowlist '
|
|
3078
|
+
'(READ_ONLY_TOOL_ALLOWLIST). Also enabled by '
|
|
3079
|
+
'LOKI_MCP_READ_ONLY=1.'))
|
|
3000
3080
|
args = parser.parse_args()
|
|
3001
3081
|
|
|
3002
3082
|
# --check-sdk: if we reached here, the module-level loader already imported
|
|
@@ -3009,6 +3089,9 @@ def main():
|
|
|
3009
3089
|
sys.exit(0)
|
|
3010
3090
|
sys.exit(1)
|
|
3011
3091
|
|
|
3092
|
+
if args.read_only or read_only_env_enabled():
|
|
3093
|
+
apply_read_only_mode()
|
|
3094
|
+
|
|
3012
3095
|
# Register cleanup to prevent file handle leaks on shutdown/restart
|
|
3013
3096
|
atexit.register(cleanup_mcp_singletons)
|
|
3014
3097
|
|
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
"""Subprocess probe for test_read_only.py: the read-only Wall check.
|
|
2
|
+
|
|
3
|
+
Usage: _read_only_wall_probe.py REPO_ROOT PROJECT_DIR
|
|
4
|
+
|
|
5
|
+
Runs inside PROJECT_DIR (cwd), applies read-only mode, then calls every
|
|
6
|
+
allowlisted tool and every resource while a sys.addaudithook records writes,
|
|
7
|
+
spawns and non-loopback connects. Prints one JSON document on the last line.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
import asyncio
|
|
11
|
+
import json
|
|
12
|
+
import os
|
|
13
|
+
import sys
|
|
14
|
+
import time
|
|
15
|
+
|
|
16
|
+
repo_root, project = sys.argv[1], sys.argv[2]
|
|
17
|
+
sys.path.insert(0, repo_root)
|
|
18
|
+
os.chdir(project)
|
|
19
|
+
os.environ["LOKI_NO_BROWSER"] = "1"
|
|
20
|
+
sys.dont_write_bytecode = True
|
|
21
|
+
|
|
22
|
+
from mcp import server # noqa: E402
|
|
23
|
+
|
|
24
|
+
# F1: arm the opt-in auto-reindex and point the manifest at a stale fixture
|
|
25
|
+
# kept OUTSIDE the project dir, so the gate in _maybe_autoreindex_code is the
|
|
26
|
+
# only thing standing between a call and a spawn. subprocess.run is replaced
|
|
27
|
+
# by a recorder so the control run (default mode) never starts a real indexer.
|
|
28
|
+
import json as _json # noqa: E402
|
|
29
|
+
import pathlib # noqa: E402
|
|
30
|
+
import subprocess # noqa: E402
|
|
31
|
+
import atexit # noqa: E402
|
|
32
|
+
import shutil # noqa: E402
|
|
33
|
+
import tempfile # noqa: E402
|
|
34
|
+
|
|
35
|
+
os.environ["LOKI_CODE_INDEX_AUTOREINDEX"] = "1"
|
|
36
|
+
_manifest_dir = tempfile.mkdtemp(prefix="ro-manifest-")
|
|
37
|
+
atexit.register(shutil.rmtree, _manifest_dir, True)
|
|
38
|
+
_manifest = pathlib.Path(_manifest_dir) / "code-index-manifest.json"
|
|
39
|
+
_manifest.write_text(_json.dumps({"files": {"does-not-exist.py": {"mtime": 1}}}))
|
|
40
|
+
server.CODE_INDEX_MANIFEST_PATH = _manifest
|
|
41
|
+
assert server._code_index_staleness()["stale"] is True, "fixture is not stale"
|
|
42
|
+
|
|
43
|
+
_spawned = []
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def _recording_run(*a, **k):
|
|
47
|
+
_spawned.append((current["name"], str(a[0] if a else k.get("args"))[:200]))
|
|
48
|
+
return subprocess.CompletedProcess(a[0] if a else [], 0, b"", b"")
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
subprocess.run = _recording_run
|
|
52
|
+
|
|
53
|
+
DEFAULT_MODE = len(sys.argv) > 3 and sys.argv[3] == "default"
|
|
54
|
+
if not DEFAULT_MODE:
|
|
55
|
+
server.apply_read_only_mode()
|
|
56
|
+
|
|
57
|
+
CALLS = {
|
|
58
|
+
"loki_memory_retrieve": {"query": "auth"},
|
|
59
|
+
"loki_state_get": {},
|
|
60
|
+
"loki_metrics_efficiency": {},
|
|
61
|
+
"loki_v10_status": {"repo_path": project},
|
|
62
|
+
"loki_project_status": {},
|
|
63
|
+
"loki_agent_metrics": {},
|
|
64
|
+
"loki_quality_report": {},
|
|
65
|
+
"loki_code_search": {"query": "rate limit"},
|
|
66
|
+
"mem_search": {"query": "auth"},
|
|
67
|
+
"mem_timeline": {},
|
|
68
|
+
"mem_get": {"ids": "x"},
|
|
69
|
+
"loki_get_hotspots": {},
|
|
70
|
+
"loki_get_co_changes": {"file_path": "a.py"},
|
|
71
|
+
"loki_get_doc_coverage": {},
|
|
72
|
+
"loki_findings": {},
|
|
73
|
+
"loki_learnings": {},
|
|
74
|
+
}
|
|
75
|
+
RESOURCES = ["loki://state/continuity", "loki://memory/index",
|
|
76
|
+
"loki://queue/pending"]
|
|
77
|
+
|
|
78
|
+
LOOPBACK = ("127.0.0.1", "::1", "localhost")
|
|
79
|
+
current = {"name": None}
|
|
80
|
+
events = []
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def _is_write_mode(mode, flags):
|
|
84
|
+
if isinstance(mode, str):
|
|
85
|
+
return any(c in mode for c in "wax+")
|
|
86
|
+
if isinstance(flags, int):
|
|
87
|
+
return bool(flags & (os.O_WRONLY | os.O_RDWR | os.O_CREAT | os.O_APPEND
|
|
88
|
+
| os.O_TRUNC))
|
|
89
|
+
return False
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def hook(event, args):
|
|
93
|
+
who = current["name"]
|
|
94
|
+
if who is None:
|
|
95
|
+
return
|
|
96
|
+
rec = None
|
|
97
|
+
if event == "open":
|
|
98
|
+
path, mode, flags = args
|
|
99
|
+
if _is_write_mode(mode, flags) and path not in ("/dev/null",):
|
|
100
|
+
rec = ("write", str(path))
|
|
101
|
+
elif event in ("os.mkdir", "os.rename", "os.remove", "os.rmdir",
|
|
102
|
+
"os.replace", "shutil.rmtree"):
|
|
103
|
+
rec = (event, str(args[0]))
|
|
104
|
+
elif event in ("subprocess.Popen", "os.system", "os.posix_spawn",
|
|
105
|
+
"os.exec", "os.fork", "os.spawn"):
|
|
106
|
+
rec = ("spawn", str(args[:2]))
|
|
107
|
+
elif event == "socket.connect":
|
|
108
|
+
addr = args[1]
|
|
109
|
+
host = addr[0] if isinstance(addr, tuple) else str(addr)
|
|
110
|
+
if isinstance(addr, str) or host in LOOPBACK:
|
|
111
|
+
return
|
|
112
|
+
rec = ("connect", str(addr))
|
|
113
|
+
if rec:
|
|
114
|
+
events.append({"tool": who, "kind": rec[0], "detail": rec[1]})
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def snapshot():
|
|
118
|
+
out = set()
|
|
119
|
+
for root, dirs, files in os.walk(project):
|
|
120
|
+
if os.sep + ".git" + os.sep in root + os.sep or root.endswith(".git"):
|
|
121
|
+
continue
|
|
122
|
+
for n in dirs + files:
|
|
123
|
+
out.add(os.path.relpath(os.path.join(root, n), project))
|
|
124
|
+
return out
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
async def run():
|
|
128
|
+
results = {}
|
|
129
|
+
before = snapshot()
|
|
130
|
+
sys.addaudithook(hook)
|
|
131
|
+
calls = {"loki_code_search": CALLS["loki_code_search"]} if DEFAULT_MODE else CALLS
|
|
132
|
+
for name, args in calls.items():
|
|
133
|
+
current["name"] = name
|
|
134
|
+
try:
|
|
135
|
+
await server.mcp.call_tool(name, args)
|
|
136
|
+
results[name] = "ok"
|
|
137
|
+
except Exception as e: # tool errors are fine; writes are not
|
|
138
|
+
results[name] = "raised %s" % type(e).__name__
|
|
139
|
+
for uri in ([] if DEFAULT_MODE else RESOURCES):
|
|
140
|
+
current["name"] = uri
|
|
141
|
+
try:
|
|
142
|
+
await server.mcp.read_resource(uri)
|
|
143
|
+
results[uri] = "ok"
|
|
144
|
+
except Exception as e:
|
|
145
|
+
results[uri] = "raised %s" % type(e).__name__
|
|
146
|
+
current["name"] = None
|
|
147
|
+
for who, cmd in _spawned:
|
|
148
|
+
events.append({"tool": who, "kind": "spawn", "detail": cmd})
|
|
149
|
+
time.sleep(0.5) # let any stray emitter thread surface
|
|
150
|
+
after = snapshot()
|
|
151
|
+
return results, sorted(after - before)
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
results, new_files = asyncio.run(run())
|
|
155
|
+
print(json.dumps({"results": results, "events": events,
|
|
156
|
+
"new_files": new_files}))
|
|
@@ -0,0 +1,289 @@
|
|
|
1
|
+
"""CP-ASK slice 1: the MCP server's --read-only tool surface and write floor."""
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import os
|
|
5
|
+
import subprocess
|
|
6
|
+
import sys
|
|
7
|
+
import tempfile
|
|
8
|
+
import unittest
|
|
9
|
+
|
|
10
|
+
_REPO_ROOT = os.path.abspath(os.path.join(os.path.dirname(__file__), '..', '..'))
|
|
11
|
+
_PROBE = os.path.join(os.path.dirname(__file__), "_read_only_wall_probe.py")
|
|
12
|
+
|
|
13
|
+
# Written out here (not imported) so the test is an independent statement of
|
|
14
|
+
# the contract: changing the server's allowlist alone turns this red.
|
|
15
|
+
EXPECTED_ALLOWLIST = {
|
|
16
|
+
"loki_memory_retrieve", "loki_state_get", "loki_metrics_efficiency",
|
|
17
|
+
"loki_v10_status", "loki_project_status", "loki_agent_metrics",
|
|
18
|
+
"loki_quality_report", "loki_code_search", "mem_search", "mem_timeline",
|
|
19
|
+
"mem_get", "loki_get_hotspots", "loki_get_co_changes",
|
|
20
|
+
"loki_get_doc_coverage", "loki_findings", "loki_learnings",
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
# Tools that write, spawn processes, or are otherwise not on the allowlist.
|
|
24
|
+
EXCLUDED_TOOLS = {
|
|
25
|
+
"loki_memory_store_pattern", "loki_task_queue_add", "loki_task_queue_update",
|
|
26
|
+
"loki_memory_capture_session_summary", "loki_consolidate_memory",
|
|
27
|
+
"loki_complete_task", "loki_v10_run", "loki_v10_verify",
|
|
28
|
+
"loki_start_project", "loki_checkpoint_restore", "loki_verify_fast",
|
|
29
|
+
"loki_counter_evidence_template", "loki_task_queue_list",
|
|
30
|
+
"loki_code_search_stats", "loki_memory_redact", "loki_graph_query",
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
# Known resources and prompts in read-only mode: a new one must fail this test.
|
|
34
|
+
EXPECTED_RESOURCES = {
|
|
35
|
+
"loki://state/continuity", "loki://memory/index", "loki://queue/pending",
|
|
36
|
+
}
|
|
37
|
+
EXPECTED_PROMPTS = {"loki_start", "loki_phase_report"}
|
|
38
|
+
|
|
39
|
+
_LIST_PROBE = """
|
|
40
|
+
import asyncio, json, sys
|
|
41
|
+
sys.path.insert(0, %r)
|
|
42
|
+
from mcp import server
|
|
43
|
+
if %r:
|
|
44
|
+
server.apply_read_only_mode()
|
|
45
|
+
async def go():
|
|
46
|
+
m = server.mcp
|
|
47
|
+
return {
|
|
48
|
+
"tools": sorted(t.name for t in await m.list_tools()),
|
|
49
|
+
"resources": sorted(str(r.uri) for r in await m.list_resources()),
|
|
50
|
+
"prompts": sorted(p.name for p in await m.list_prompts()),
|
|
51
|
+
}
|
|
52
|
+
print(json.dumps(asyncio.run(go())))
|
|
53
|
+
"""
|
|
54
|
+
|
|
55
|
+
# Drives main() itself: real argv, real env, mcp.run patched to capture the
|
|
56
|
+
# tool list at the moment the server would start serving.
|
|
57
|
+
_MAIN_PROBE = """
|
|
58
|
+
import asyncio, json, sys
|
|
59
|
+
sys.path.insert(0, %r)
|
|
60
|
+
sys.argv = ['server.py'] + %r
|
|
61
|
+
from mcp import server
|
|
62
|
+
captured = {}
|
|
63
|
+
def fake_run(*a, **k):
|
|
64
|
+
captured['tools'] = sorted(t.name for t in asyncio.run(server.mcp.list_tools()))
|
|
65
|
+
server.mcp.run = fake_run
|
|
66
|
+
server.main()
|
|
67
|
+
print(json.dumps(captured))
|
|
68
|
+
"""
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def _listing(read_only):
|
|
72
|
+
out = subprocess.run(
|
|
73
|
+
[sys.executable, "-c", _LIST_PROBE % (_REPO_ROOT, read_only)],
|
|
74
|
+
capture_output=True, text=True, timeout=120,
|
|
75
|
+
env={**os.environ, "LOKI_NO_BROWSER": "1"})
|
|
76
|
+
assert out.returncode == 0, out.stderr
|
|
77
|
+
return json.loads(out.stdout.strip().splitlines()[-1])
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def _tools(read_only):
|
|
81
|
+
return set(_listing(read_only)["tools"])
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def _main_tools(argv, env_value=None):
|
|
85
|
+
env = {k: v for k, v in os.environ.items() if k != "LOKI_MCP_READ_ONLY"}
|
|
86
|
+
env["LOKI_NO_BROWSER"] = "1"
|
|
87
|
+
if env_value is not None:
|
|
88
|
+
env["LOKI_MCP_READ_ONLY"] = env_value
|
|
89
|
+
out = subprocess.run(
|
|
90
|
+
[sys.executable, "-c", _MAIN_PROBE % (_REPO_ROOT, argv)],
|
|
91
|
+
capture_output=True, text=True, timeout=120, env=env)
|
|
92
|
+
assert out.returncode == 0, out.stderr
|
|
93
|
+
return set(json.loads(out.stdout.strip().splitlines()[-1])["tools"])
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
class ReadOnlyTests(unittest.TestCase):
|
|
97
|
+
def test_read_only_surface_equals_allowlist(self):
|
|
98
|
+
self.assertEqual(_tools(True), EXPECTED_ALLOWLIST)
|
|
99
|
+
|
|
100
|
+
def test_write_tools_absent_in_read_only(self):
|
|
101
|
+
self.assertFalse(_tools(True) & EXCLUDED_TOOLS)
|
|
102
|
+
|
|
103
|
+
def test_default_mode_registers_full_set(self):
|
|
104
|
+
full = _tools(False)
|
|
105
|
+
self.assertTrue(EXPECTED_ALLOWLIST <= full)
|
|
106
|
+
self.assertTrue(EXCLUDED_TOOLS <= full)
|
|
107
|
+
self.assertGreaterEqual(len(full), 39)
|
|
108
|
+
|
|
109
|
+
def test_resources_and_prompts_are_a_known_list(self):
|
|
110
|
+
listing = _listing(True)
|
|
111
|
+
self.assertEqual(set(listing["resources"]), EXPECTED_RESOURCES)
|
|
112
|
+
self.assertEqual(set(listing["prompts"]), EXPECTED_PROMPTS)
|
|
113
|
+
|
|
114
|
+
def test_main_flag_enables_read_only(self):
|
|
115
|
+
self.assertEqual(_main_tools(["--read-only"]), EXPECTED_ALLOWLIST)
|
|
116
|
+
|
|
117
|
+
def test_main_env_enables_read_only(self):
|
|
118
|
+
for value in ("1", "true", "TRUE", "Yes", "yes"):
|
|
119
|
+
self.assertEqual(_main_tools([], value), EXPECTED_ALLOWLIST, value)
|
|
120
|
+
|
|
121
|
+
def test_main_default_is_full_set(self):
|
|
122
|
+
self.assertGreaterEqual(len(_main_tools([])), 39)
|
|
123
|
+
self.assertGreaterEqual(len(_main_tools([], "0")), 39)
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
def _git(cwd, *args):
|
|
127
|
+
subprocess.run(["git", *args], cwd=cwd, check=True, capture_output=True,
|
|
128
|
+
env={**os.environ, "GIT_AUTHOR_NAME": "t",
|
|
129
|
+
"GIT_AUTHOR_EMAIL": "t@t", "GIT_COMMITTER_NAME": "t",
|
|
130
|
+
"GIT_COMMITTER_EMAIL": "t@t"})
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
def _make_empty(root):
|
|
134
|
+
_git(root, "init", "-q")
|
|
135
|
+
os.makedirs(os.path.join(root, ".loki"))
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
def _make_seeded(root):
|
|
139
|
+
_make_empty(root)
|
|
140
|
+
mem = os.path.join(root, ".loki", "memory")
|
|
141
|
+
for sub in ("episodic", "semantic", "skills"):
|
|
142
|
+
os.makedirs(os.path.join(mem, sub))
|
|
143
|
+
with open(os.path.join(mem, "episodic", "ep-1.json"), "w") as f:
|
|
144
|
+
json.dump({"id": "ep-1", "task_id": "t1", "summary": "auth work",
|
|
145
|
+
"timestamp": "2026-10-01T00:00:00Z", "outcome": "success",
|
|
146
|
+
"context": {"goal": "auth"}}, f)
|
|
147
|
+
with open(os.path.join(mem, "index.json"), "w") as f:
|
|
148
|
+
json.dump({"version": "1.0", "topics": []}, f)
|
|
149
|
+
with open(os.path.join(mem, "timeline.json"), "w") as f:
|
|
150
|
+
json.dump({"version": "1.0", "entries": []}, f)
|
|
151
|
+
os.makedirs(os.path.join(root, ".loki", "state"))
|
|
152
|
+
with open(os.path.join(root, ".loki", "state", "orchestrator.json"), "w") as f:
|
|
153
|
+
json.dump({"currentPhase": "DEVELOPMENT"}, f)
|
|
154
|
+
with open(os.path.join(root, "a.py"), "w") as f:
|
|
155
|
+
f.write("x = 1\n")
|
|
156
|
+
_git(root, "add", "a.py")
|
|
157
|
+
_git(root, "commit", "-q", "-m", "seed")
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
def _wall(maker, extra_env=None, mode=""):
|
|
161
|
+
with tempfile.TemporaryDirectory(prefix="ro-wall-") as root:
|
|
162
|
+
root = os.path.realpath(root)
|
|
163
|
+
maker(root)
|
|
164
|
+
env = {k: v for k, v in os.environ.items()
|
|
165
|
+
if not k.startswith("LOKI_CHROMA")}
|
|
166
|
+
env.update(extra_env or {})
|
|
167
|
+
out = subprocess.run([sys.executable, _PROBE, _REPO_ROOT, root] + ([mode] if mode else []),
|
|
168
|
+
capture_output=True, text=True, timeout=300,
|
|
169
|
+
env=env)
|
|
170
|
+
assert out.returncode == 0, out.stderr[-2000:]
|
|
171
|
+
return json.loads(out.stdout.strip().splitlines()[-1])
|
|
172
|
+
|
|
173
|
+
|
|
174
|
+
class WallTests(unittest.TestCase):
|
|
175
|
+
"""Every allowlisted tool, called in read-only mode, writes nothing."""
|
|
176
|
+
|
|
177
|
+
def _assert_floor(self, report):
|
|
178
|
+
self.assertEqual(report["events"], [], "writes/spawns/connects seen")
|
|
179
|
+
self.assertEqual(report["new_files"], [], "new files created")
|
|
180
|
+
self.assertEqual(
|
|
181
|
+
set(report["results"]),
|
|
182
|
+
EXPECTED_ALLOWLIST | EXPECTED_RESOURCES)
|
|
183
|
+
|
|
184
|
+
def test_empty_project(self):
|
|
185
|
+
self._assert_floor(_wall(_make_empty))
|
|
186
|
+
|
|
187
|
+
def test_seeded_memory_store(self):
|
|
188
|
+
self._assert_floor(_wall(_make_seeded))
|
|
189
|
+
|
|
190
|
+
def test_autoreindex_gate_blocks_spawn_in_read_only(self):
|
|
191
|
+
# Positive control: with the stale manifest and the opt-in armed, the
|
|
192
|
+
# DEFAULT mode does reach the indexer spawn, so the probe can see it.
|
|
193
|
+
control = _wall(_make_empty, mode="default")
|
|
194
|
+
self.assertTrue([e for e in control["events"] if e["kind"] == "spawn"],
|
|
195
|
+
"control: default mode never reached the indexer spawn")
|
|
196
|
+
# Read-only: the same setup must spawn nothing.
|
|
197
|
+
ro = _wall(_make_empty)
|
|
198
|
+
self.assertEqual([e for e in ro["events"] if e["kind"] == "spawn"], [])
|
|
199
|
+
|
|
200
|
+
def test_non_loopback_chroma_host_is_refused_without_connecting(self):
|
|
201
|
+
report = _wall(_make_empty, {"LOKI_CHROMA_HOST": "chroma.example.com"})
|
|
202
|
+
self.assertEqual(report["events"], [])
|
|
203
|
+
|
|
204
|
+
|
|
205
|
+
# F2: every public mutator of MemoryStorage refuses under READ_ONLY.
|
|
206
|
+
MUTATORS = {
|
|
207
|
+
"save_episode": (None,), "delete_episode": ("ep-1",),
|
|
208
|
+
"save_pattern": (None,), "update_pattern": (None,),
|
|
209
|
+
"update_pattern_with_merge": ("p-1", lambda x: x),
|
|
210
|
+
"save_skill": (None,), "update_index": (), "update_timeline": ({},),
|
|
211
|
+
"set_active_context": ({},), "ensure_directory": ("newdir",),
|
|
212
|
+
"write_json": ("new.json", {}), "delete_file": ("index.json",),
|
|
213
|
+
"persist_boost": ({},), "increment_pattern_usage": ("p-1",),
|
|
214
|
+
"batch_apply_decay": (), "copy_to_namespace": ("other",),
|
|
215
|
+
"merge_from_namespace": ("other",),
|
|
216
|
+
}
|
|
217
|
+
# Public members that only read or compute in memory. A new public member that
|
|
218
|
+
# is in neither set fails test_every_public_method_is_classified.
|
|
219
|
+
READERS = {
|
|
220
|
+
"namespace", "root_path", "with_namespace", "load_episode", "list_episodes",
|
|
221
|
+
"load_pattern", "list_patterns", "load_skill", "list_skills", "get_index",
|
|
222
|
+
"get_timeline", "get_active_context", "read_json", "list_files",
|
|
223
|
+
"calculate_importance", "apply_decay", "boost_on_retrieval",
|
|
224
|
+
"list_namespaces", "get_namespace_stats",
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
|
|
228
|
+
def _tree(base):
|
|
229
|
+
return sorted(os.path.relpath(os.path.join(r, n), base)
|
|
230
|
+
for r, ds, fs in os.walk(base) for n in ds + fs)
|
|
231
|
+
|
|
232
|
+
|
|
233
|
+
class StorageReadOnlyTests(unittest.TestCase):
|
|
234
|
+
def setUp(self):
|
|
235
|
+
sys.path.insert(0, _REPO_ROOT)
|
|
236
|
+
from memory.storage import MemoryStorage
|
|
237
|
+
self.cls = MemoryStorage
|
|
238
|
+
self.tmp = tempfile.TemporaryDirectory(prefix="ro-store-")
|
|
239
|
+
self.store = MemoryStorage(self.tmp.name) # created in default mode
|
|
240
|
+
|
|
241
|
+
def tearDown(self):
|
|
242
|
+
self.cls.READ_ONLY = False
|
|
243
|
+
self.tmp.cleanup()
|
|
244
|
+
|
|
245
|
+
def test_every_public_method_is_classified(self):
|
|
246
|
+
public = {n for n in dir(self.cls) if not n.startswith("_")}
|
|
247
|
+
public -= {"VERSION", "READ_ONLY"}
|
|
248
|
+
unknown = public - set(MUTATORS) - READERS
|
|
249
|
+
self.assertEqual(unknown, set(), "classify new MemoryStorage members")
|
|
250
|
+
|
|
251
|
+
def test_every_mutator_raises_and_creates_nothing(self):
|
|
252
|
+
before = _tree(self.tmp.name)
|
|
253
|
+
self.cls.READ_ONLY = True
|
|
254
|
+
for name, args in MUTATORS.items():
|
|
255
|
+
with self.subTest(method=name):
|
|
256
|
+
with self.assertRaises(PermissionError):
|
|
257
|
+
getattr(self.store, name)(*args)
|
|
258
|
+
self.assertEqual(_tree(self.tmp.name), before)
|
|
259
|
+
|
|
260
|
+
def test_every_mutator_calls_check_writable_directly(self):
|
|
261
|
+
# A mutator relying only on a downstream _atomic_write re-check would
|
|
262
|
+
# survive a mutation of its own guard; require the direct call.
|
|
263
|
+
import ast
|
|
264
|
+
import inspect
|
|
265
|
+
tree = ast.parse(inspect.getsource(self.cls))
|
|
266
|
+
funcs = {n.name: n for n in ast.walk(tree)
|
|
267
|
+
if isinstance(n, ast.FunctionDef)}
|
|
268
|
+
for name in MUTATORS:
|
|
269
|
+
with self.subTest(method=name):
|
|
270
|
+
calls = [
|
|
271
|
+
c for c in ast.walk(funcs[name])
|
|
272
|
+
if isinstance(c, ast.Call)
|
|
273
|
+
and isinstance(c.func, ast.Attribute)
|
|
274
|
+
and c.func.attr == "_check_writable"
|
|
275
|
+
and isinstance(c.func.value, ast.Name)
|
|
276
|
+
and c.func.value.id == "self"
|
|
277
|
+
]
|
|
278
|
+
self.assertTrue(calls, "%s lacks self._check_writable()" % name)
|
|
279
|
+
|
|
280
|
+
def test_default_mode_mutators_still_work(self):
|
|
281
|
+
self.store.write_json("x.json", {"a": 1})
|
|
282
|
+
self.assertEqual(self.store.read_json("x.json"), {"a": 1})
|
|
283
|
+
self.assertTrue(self.store.delete_file("x.json"))
|
|
284
|
+
self.store.ensure_directory("newdir")
|
|
285
|
+
self.assertTrue(os.path.isdir(os.path.join(self.tmp.name, "newdir")))
|
|
286
|
+
|
|
287
|
+
|
|
288
|
+
if __name__ == "__main__":
|
|
289
|
+
unittest.main()
|