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.
Files changed (88) hide show
  1. package/SKILL.md +2 -2
  2. package/VERSION +1 -1
  3. package/dashboard/__init__.py +1 -1
  4. package/docs/CLI-REFERENCE.md +1 -1
  5. package/docs/INSTALLATION.md +1 -1
  6. package/docs/v10/BOARD.md +6 -4
  7. package/docs/v10/CP-ASK-PLAN.md +85 -0
  8. package/docs/v10/DEPS.md +3 -0
  9. package/docs/v10/FAILURE-CLASSES.md +32 -0
  10. package/docs/v10/FC21B-FC22-PLAN.md +107 -0
  11. package/docs/v10/FOUNDER-QUEUE.md +2 -0
  12. package/docs/v10/METRICS.md +1 -0
  13. package/docs/v10/PROGRESS.md +21 -0
  14. package/loki-ts/dist/loki.js +683 -678
  15. package/mcp/__init__.py +1 -1
  16. package/mcp/server.py +86 -3
  17. package/mcp/tests/_read_only_wall_probe.py +156 -0
  18. package/mcp/tests/test_read_only.py +289 -0
  19. package/memory/storage.py +41 -3
  20. package/package.json +1 -1
  21. package/packages/control-plane/dist/server.js +289 -210
  22. package/packages/control-plane/drizzle/0005_ask.sql +39 -0
  23. package/packages/control-plane/drizzle/meta/0005_snapshot.json +800 -0
  24. package/packages/control-plane/drizzle/meta/_journal.json +7 -0
  25. package/packages/control-plane/package.json +3 -1
  26. package/packages/control-plane/src/ask/parse.ts +120 -0
  27. package/packages/control-plane/src/ask/tools.ts +111 -0
  28. package/packages/control-plane/src/ask/tools_server.ts +46 -0
  29. package/packages/control-plane/src/db/schema.ts +43 -1
  30. package/packages/control-plane/src/server/routes/artifacts.ts +3 -3
  31. package/plugins/loki-mode/.claude-plugin/plugin.json +1 -1
  32. package/web-app/dist/assets/{AdminPage-qj6cyTT6.js → AdminPage-CmgIgBFY.js} +1 -1
  33. package/web-app/dist/assets/{Avatar-Dg4WrYYd.js → Avatar-Dm3ypJzh.js} +1 -1
  34. package/web-app/dist/assets/{Badge-DHiwSg-p.js → Badge-0siYUwAA.js} +1 -1
  35. package/web-app/dist/assets/{Button-gi8XEzIU.js → Button-EMKQXvZe.js} +1 -1
  36. package/web-app/dist/assets/{CockpitPage-B1QNjPBn.js → CockpitPage-Z60eDvJ9.js} +1 -1
  37. package/web-app/dist/assets/{ComparePage-DW-oUaDu.js → ComparePage-D4L7nViy.js} +1 -1
  38. package/web-app/dist/assets/{ErrorBoundary-BK5zjqn-.js → ErrorBoundary--v9DugrJ.js} +1 -1
  39. package/web-app/dist/assets/{EvidenceReceiptPanel-F58cuFm9.js → EvidenceReceiptPanel-CA866XsM.js} +1 -1
  40. package/web-app/dist/assets/{GitHubIssuesPanel-QVyvF19M.js → GitHubIssuesPanel-Bi6FJmR2.js} +1 -1
  41. package/web-app/dist/assets/{GitHubPRsPanel-BRFHMzwy.js → GitHubPRsPanel-CgSfcWOg.js} +1 -1
  42. package/web-app/dist/assets/{HomePage-Cpskd4yM.js → HomePage-BxQtk9Ec.js} +2 -2
  43. package/web-app/dist/assets/{LoginPage-CteYWi5M.js → LoginPage-CCSxWlFA.js} +1 -1
  44. package/web-app/dist/assets/{MagicPage-CCLor0AU.js → MagicPage-B4eYm0jJ.js} +1 -1
  45. package/web-app/dist/assets/{MetricsPage-WgsOaLoq.js → MetricsPage-Bp4Hh0p2.js} +1 -1
  46. package/web-app/dist/assets/{NotFoundPage-DXSCr9P6.js → NotFoundPage-DTXk_JK4.js} +1 -1
  47. package/web-app/dist/assets/{ProjectPage-sR0kaaXV.js → ProjectPage-6S1v0GHS.js} +3 -3
  48. package/web-app/dist/assets/{ProjectsPage-J3TNS42-.js → ProjectsPage-9foqEwG2.js} +1 -1
  49. package/web-app/dist/assets/{SettingsPage-Cil5837P.js → SettingsPage-CWOG_hhD.js} +1 -1
  50. package/web-app/dist/assets/{ShowcasePage-C3_oWYJa.js → ShowcasePage-Du9LYlao.js} +1 -1
  51. package/web-app/dist/assets/{SystemSettingsPage-Sddw1fFS.js → SystemSettingsPage-BQconaDX.js} +1 -1
  52. package/web-app/dist/assets/{TeamsPage-B0poySCt.js → TeamsPage-_NsqtVmC.js} +1 -1
  53. package/web-app/dist/assets/{TemplatesPage-pcYSxHam.js → TemplatesPage-DyTEGBpR.js} +1 -1
  54. package/web-app/dist/assets/{TerminalOutput-CRWTZM7F.js → TerminalOutput-XHOYTpky.js} +1 -1
  55. package/web-app/dist/assets/{activity-D3aR95zO.js → activity-B9KiMdEs.js} +1 -1
  56. package/web-app/dist/assets/{bell-WdWaPIBo.js → bell-Oe4lG6kK.js} +1 -1
  57. package/web-app/dist/assets/{bot-CzD3xokc.js → bot-D86Qmxfy.js} +1 -1
  58. package/web-app/dist/assets/{check-DMYJiYcm.js → check-BvW6_ni0.js} +1 -1
  59. package/web-app/dist/assets/{chevron-left-DY0FSKvI.js → chevron-left-DYA62-3g.js} +1 -1
  60. package/web-app/dist/assets/{circle-alert-DVPtG3Pe.js → circle-alert-CHW8ye4p.js} +1 -1
  61. package/web-app/dist/assets/{clock-BjjLL6uI.js → clock-QbQ2XfbM.js} +1 -1
  62. package/web-app/dist/assets/{cloud-CnUCVZhm.js → cloud-ylzrY7eL.js} +1 -1
  63. package/web-app/dist/assets/{code-xml-BPoxcFNK.js → code-xml-DdKKw2Y9.js} +1 -1
  64. package/web-app/dist/assets/{database-ClVgDBYt.js → database-BLY_OWs3.js} +1 -1
  65. package/web-app/dist/assets/{dollar-sign-Ui4BIml8.js → dollar-sign-CsFIa1e2.js} +1 -1
  66. package/web-app/dist/assets/{file-code-corner-BNYl7J1Z.js → file-code-corner-Df4nyrCW.js} +1 -1
  67. package/web-app/dist/assets/{file-plus-BxkSyRVn.js → file-plus-_MWEY2WY.js} +1 -1
  68. package/web-app/dist/assets/{globe-b2poyDKT.js → globe-Dpc7QU9m.js} +1 -1
  69. package/web-app/dist/assets/{hammer-D_0BUpBG.js → hammer-B-PldT0c.js} +1 -1
  70. package/web-app/dist/assets/{index-D8TYtITk.js → index-gq0R3j-o.js} +3 -3
  71. package/web-app/dist/assets/{layers-Co1szEIf.js → layers-BjcNwHLZ.js} +1 -1
  72. package/web-app/dist/assets/{loader-circle-DylW-nwq.js → loader-circle-CoTgcqkV.js} +1 -1
  73. package/web-app/dist/assets/{lock-D7aFdKw7.js → lock-JEI8O6TS.js} +1 -1
  74. package/web-app/dist/assets/{package-ElSsRKiN.js → package-CXWkNS40.js} +1 -1
  75. package/web-app/dist/assets/{plus-CVWgRxDX.js → plus-CRK4U5b0.js} +1 -1
  76. package/web-app/dist/assets/{refresh-cw-B2azSZHT.js → refresh-cw-CrzWr4cw.js} +1 -1
  77. package/web-app/dist/assets/{rotate-ccw-BdQHOrf0.js → rotate-ccw-DV7UZyUw.js} +1 -1
  78. package/web-app/dist/assets/{scroll-text-9m-YKf83.js → scroll-text-CXEqRrXU.js} +1 -1
  79. package/web-app/dist/assets/{server-BYq99Fe1.js → server-CDFfFP10.js} +1 -1
  80. package/web-app/dist/assets/{shield-alert-BNCiHXhR.js → shield-alert-B5EC_m6_.js} +1 -1
  81. package/web-app/dist/assets/{thumbs-up-BB_EJkat.js → thumbs-up-BeLDemd2.js} +1 -1
  82. package/web-app/dist/assets/{trash-2-BMF4kFvy.js → trash-2-B7kS86-0.js} +1 -1
  83. package/web-app/dist/assets/{trending-down-BiqiTPXx.js → trending-down-BGvuLayo.js} +1 -1
  84. package/web-app/dist/assets/{trending-up-Bq6qyKub.js → trending-up-BkPM2tiK.js} +1 -1
  85. package/web-app/dist/assets/{upload-1HzIJHHR.js → upload-CXVI0GRU.js} +1 -1
  86. package/web-app/dist/assets/{usePolling-FNwpjURt.js → usePolling-CMGMVsMA.js} +1 -1
  87. package/web-app/dist/assets/{user-DrORfQv3.js → user-DPcXNtfj.js} +1 -1
  88. package/web-app/dist/index.html +1 -1
package/mcp/__init__.py CHANGED
@@ -75,4 +75,4 @@ try:
75
75
  except ImportError:
76
76
  __all__ = ['mcp']
77
77
 
78
- __version__ = '10.10.5'
78
+ __version__ = '10.11.2'
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()