loki-mode 11.66.2 → 11.67.1
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 +1 -1
- package/SKILL.md +2 -2
- package/VERSION +1 -1
- package/autonomy/route.sh +3 -2
- package/dashboard/__init__.py +1 -1
- package/docs/CLI-REFERENCE.md +1 -1
- package/docs/COMPONENTS.md +1 -1
- package/docs/INSTALLATION.md +1 -1
- package/docs/WANG-PRINCIPLES-PLAN.md +1 -1
- package/docs/v10/FAILURE-CLASSES.md +32 -0
- package/loki-ts/dist/loki.js +1283 -1275
- package/mcp/__init__.py +1 -1
- package/mcp/server.py +31 -1
- package/mcp/tests/test_read_only.py +1 -1
- package/mcp/tests/test_v10_tools.py +212 -0
- package/mcp/v10_tools.py +83 -0
- package/package.json +2 -2
- package/packages/control-plane/dist/server.js +106 -12
- package/plugins/loki-mode/.claude-plugin/plugin.json +1 -1
- package/web-app/dist/assets/{AdminPage-DUUSP-zy.js → AdminPage-BtVpg8_T.js} +1 -1
- package/web-app/dist/assets/{Avatar-B8BH9fQZ.js → Avatar-C3I7YiJ1.js} +1 -1
- package/web-app/dist/assets/{Badge-BXVq433G.js → Badge-DsjUF4b1.js} +1 -1
- package/web-app/dist/assets/{Button-BFK_qowE.js → Button-Cv_fLuDO.js} +1 -1
- package/web-app/dist/assets/{CockpitPage-B5uaJ0Z_.js → CockpitPage-CPccpsAb.js} +1 -1
- package/web-app/dist/assets/{ComparePage-BOJidJKo.js → ComparePage-Bg0PwPg5.js} +1 -1
- package/web-app/dist/assets/{ErrorBoundary-BSDwUoJy.js → ErrorBoundary-CCDg-ONt.js} +1 -1
- package/web-app/dist/assets/{EvidenceReceiptPanel-DFIrryk0.js → EvidenceReceiptPanel-8_bvJsKL.js} +1 -1
- package/web-app/dist/assets/{GitHubIssuesPanel-Bpy9Q7KU.js → GitHubIssuesPanel-Dx7gh4rO.js} +1 -1
- package/web-app/dist/assets/{GitHubPRsPanel-C40Uwyjl.js → GitHubPRsPanel-CnLE8VPU.js} +1 -1
- package/web-app/dist/assets/{HomePage-CrKeQ9kt.js → HomePage-zENRZ2D1.js} +2 -2
- package/web-app/dist/assets/{LoginPage-DZLHMRAF.js → LoginPage-BiOqKbkH.js} +1 -1
- package/web-app/dist/assets/{MagicPage-wSsYtbHa.js → MagicPage-jLmkP87D.js} +1 -1
- package/web-app/dist/assets/{MetricsPage-DKzav4Xg.js → MetricsPage-CVEoZoC6.js} +1 -1
- package/web-app/dist/assets/{NotFoundPage-tFWP8KUK.js → NotFoundPage-mmQRmD8R.js} +1 -1
- package/web-app/dist/assets/{ProjectPage-52h79KFr.js → ProjectPage-CJmupIud.js} +3 -3
- package/web-app/dist/assets/{ProjectsPage-DRSpM7E2.js → ProjectsPage-BARdLMPp.js} +1 -1
- package/web-app/dist/assets/{SettingsPage-B5_eQXf1.js → SettingsPage-Cmrmt2KR.js} +1 -1
- package/web-app/dist/assets/{ShowcasePage-mlk1VZ5N.js → ShowcasePage-uwsZdS5N.js} +1 -1
- package/web-app/dist/assets/{SystemSettingsPage-L7NP65y7.js → SystemSettingsPage-7tt-pqsE.js} +1 -1
- package/web-app/dist/assets/{TeamsPage-C5gbHIhR.js → TeamsPage-CQDznmbk.js} +1 -1
- package/web-app/dist/assets/{TemplatesPage-CpRsgu5-.js → TemplatesPage-DvUcZiJJ.js} +1 -1
- package/web-app/dist/assets/{TerminalOutput-C_tCR_Km.js → TerminalOutput-CsnBC0fm.js} +1 -1
- package/web-app/dist/assets/{activity-DDAkha-U.js → activity-B0fkdbAZ.js} +1 -1
- package/web-app/dist/assets/{bell-d3C_NwgU.js → bell-Cmx08Gc7.js} +1 -1
- package/web-app/dist/assets/{bot-xx2FrcyK.js → bot-mHlwlDBE.js} +1 -1
- package/web-app/dist/assets/{check-B1wjphtz.js → check-CHbtOnFG.js} +1 -1
- package/web-app/dist/assets/{chevron-left-CxIjKCcc.js → chevron-left-CmaIVO-9.js} +1 -1
- package/web-app/dist/assets/{circle-alert-BkUkszys.js → circle-alert-CleAQ1iS.js} +1 -1
- package/web-app/dist/assets/{clock-Da0Cxc6b.js → clock-BZNwavS8.js} +1 -1
- package/web-app/dist/assets/{cloud-CemmtpAa.js → cloud-DmPDssWE.js} +1 -1
- package/web-app/dist/assets/{code-xml-D1PFPtf5.js → code-xml-C1wP07qL.js} +1 -1
- package/web-app/dist/assets/{database-4tOYZHTT.js → database-Cudj812p.js} +1 -1
- package/web-app/dist/assets/{dollar-sign-CGwkXp1P.js → dollar-sign-ClfPDwwb.js} +1 -1
- package/web-app/dist/assets/{file-code-corner-Cv3xwFb6.js → file-code-corner-BjLiR_DM.js} +1 -1
- package/web-app/dist/assets/{file-plus-DR0S1Q6M.js → file-plus-B3S15wdF.js} +1 -1
- package/web-app/dist/assets/{globe-CnM5-QEh.js → globe-FLJd7IqB.js} +1 -1
- package/web-app/dist/assets/{hammer-BybFanZ-.js → hammer-uXcz6raP.js} +1 -1
- package/web-app/dist/assets/{index-CsEKqPjD.js → index-8LjkUT_r.js} +3 -3
- package/web-app/dist/assets/{layers-AsoI5QVF.js → layers-BPi2kYUA.js} +1 -1
- package/web-app/dist/assets/{loader-circle-BP-Dgm4_.js → loader-circle-BI5Jgauk.js} +1 -1
- package/web-app/dist/assets/{lock-9heWJfZK.js → lock-XYI8VTfk.js} +1 -1
- package/web-app/dist/assets/{package-B3oxVBPu.js → package-D5dabXGu.js} +1 -1
- package/web-app/dist/assets/{plus-Dk3jSp9T.js → plus-C3uOyi3T.js} +1 -1
- package/web-app/dist/assets/{refresh-cw-D_EqfAM1.js → refresh-cw-emf7OJ8L.js} +1 -1
- package/web-app/dist/assets/{rotate-ccw-BYWk_Sh7.js → rotate-ccw-BHrmxW3U.js} +1 -1
- package/web-app/dist/assets/{scroll-text-CLU6QkSK.js → scroll-text-0AFHZ8SW.js} +1 -1
- package/web-app/dist/assets/{server-BQVeYEi-.js → server-DNeU7_2A.js} +1 -1
- package/web-app/dist/assets/{shield-alert-CGdnbbuO.js → shield-alert-DnACnSDh.js} +1 -1
- package/web-app/dist/assets/{thumbs-up-CqAiD7w7.js → thumbs-up-BjHFuW7u.js} +1 -1
- package/web-app/dist/assets/{trash-2-DOZ4MX9o.js → trash-2-BdNn79gn.js} +1 -1
- package/web-app/dist/assets/{trending-down-vm1A3ywr.js → trending-down-LQcvJtlW.js} +1 -1
- package/web-app/dist/assets/{trending-up-CdGlmr5H.js → trending-up-DnAsnTeg.js} +1 -1
- package/web-app/dist/assets/{upload--DV5H13K.js → upload-CywYeREK.js} +1 -1
- package/web-app/dist/assets/{usePolling-CTvwKWlp.js → usePolling-B7sPkY8h.js} +1 -1
- package/web-app/dist/assets/{user-CQ-pSxyg.js → user-oYnoDRHM.js} +1 -1
- package/web-app/dist/index.html +1 -1
package/mcp/__init__.py
CHANGED
package/mcp/server.py
CHANGED
|
@@ -653,7 +653,7 @@ _TRUSTED_TOOLS = frozenset({
|
|
|
653
653
|
"loki_memory_store_pattern", "loki_task_queue_add",
|
|
654
654
|
"loki_memory_capture_session_summary",
|
|
655
655
|
"loki_consolidate_memory", "loki_complete_task", "loki_v10_run",
|
|
656
|
-
"loki_v10_verify", "loki_start_project", "loki_code_search_stats",
|
|
656
|
+
"loki_v10_verify", "loki_seal_diff", "loki_start_project", "loki_code_search_stats",
|
|
657
657
|
"loki_memory_redact",
|
|
658
658
|
"loki_magic_stats", "loki_magic_tokens_extract",
|
|
659
659
|
"cp_stats", "cp_cost", "cp_repos_list",
|
|
@@ -1900,6 +1900,36 @@ async def loki_v10_verify(receipt_path: str = "", repo_path: str = "") -> str:
|
|
|
1900
1900
|
raise
|
|
1901
1901
|
|
|
1902
1902
|
|
|
1903
|
+
@mcp.tool()
|
|
1904
|
+
async def loki_seal_diff(repo_path: str, intent: str) -> str:
|
|
1905
|
+
"""
|
|
1906
|
+
Seal changes you already made in a repo into a signed Loki receipt.
|
|
1907
|
+
Use it after editing a repo yourself, to get the same Wall-verified receipt a
|
|
1908
|
+
Loki run gives. The Wall is written from the intent alone, without seeing the
|
|
1909
|
+
diff. Do not use it to build something (use loki_v10_run) or to re-check an
|
|
1910
|
+
existing receipt (use loki_v10_verify).
|
|
1911
|
+
|
|
1912
|
+
Args:
|
|
1913
|
+
repo_path: Git repository with uncommitted changes to seal
|
|
1914
|
+
intent: What the changes are meant to do, in plain words
|
|
1915
|
+
|
|
1916
|
+
Returns:
|
|
1917
|
+
JSON with run_id, verdict, verdict_is_verified (the receipt verdict string, not a signature check), receipt_path, sealed_ref and not_proven
|
|
1918
|
+
"""
|
|
1919
|
+
_emit_tool_event_async('loki_seal_diff', 'start', parameters={'repo_path': repo_path, 'intent': intent})
|
|
1920
|
+
try:
|
|
1921
|
+
from mcp import v10_tools
|
|
1922
|
+
result = await asyncio.to_thread(
|
|
1923
|
+
v10_tools.seal_diff, repo_path, intent,
|
|
1924
|
+
lambda p: validate_path(p, allowed_dirs=['.']))
|
|
1925
|
+
_emit_tool_event_async('loki_seal_diff', 'complete', result_status='error' if isinstance(result, dict) and result.get('error') else 'success')
|
|
1926
|
+
return json.dumps(result)
|
|
1927
|
+
except Exception as e:
|
|
1928
|
+
logger.error(f"loki_seal_diff failed: {e}")
|
|
1929
|
+
_emit_tool_event_async('loki_seal_diff', 'complete', result_status='error', error=str(e))
|
|
1930
|
+
raise
|
|
1931
|
+
|
|
1932
|
+
|
|
1903
1933
|
@mcp.tool()
|
|
1904
1934
|
async def loki_start_project(prd_content: str = "", prd_path: str = "") -> str:
|
|
1905
1935
|
"""
|
|
@@ -27,7 +27,7 @@ EXPECTED_ALLOWLIST = {
|
|
|
27
27
|
EXCLUDED_TOOLS = {
|
|
28
28
|
"loki_memory_store_pattern", "loki_task_queue_add", "loki_task_queue_update",
|
|
29
29
|
"loki_memory_capture_session_summary", "loki_consolidate_memory",
|
|
30
|
-
"loki_complete_task", "loki_v10_run", "loki_v10_verify",
|
|
30
|
+
"loki_complete_task", "loki_v10_run", "loki_v10_verify", "loki_seal_diff",
|
|
31
31
|
"loki_start_project", "loki_checkpoint_restore", "loki_verify_fast",
|
|
32
32
|
"loki_counter_evidence_template", "loki_task_queue_list",
|
|
33
33
|
"loki_code_search_stats", "loki_memory_redact", "loki_graph_query",
|
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
import json
|
|
4
4
|
import os
|
|
5
|
+
import shutil
|
|
5
6
|
import subprocess
|
|
6
7
|
import sys
|
|
7
8
|
import tempfile
|
|
@@ -244,3 +245,214 @@ class VerifyTests(unittest.TestCase):
|
|
|
244
245
|
|
|
245
246
|
if __name__ == "__main__":
|
|
246
247
|
unittest.main()
|
|
248
|
+
|
|
249
|
+
|
|
250
|
+
_FAKE_LOKI = """#!/bin/sh
|
|
251
|
+
# stand-in engine: records what it was handed, writes a receipt with the verdict in $FAKE_VERDICT
|
|
252
|
+
[ "$2" = "--seal-existing" ] || exit 9
|
|
253
|
+
[ -z "$(git status --porcelain)" ] || exit 8
|
|
254
|
+
cp "$3" "$FAKE_OUT/patch"
|
|
255
|
+
printf '%s' "$1" > "$FAKE_OUT/intent"
|
|
256
|
+
RID="${FAKE_RUN_ID:-e10-fake}"
|
|
257
|
+
mkdir -p ".loki/runs/$RID"
|
|
258
|
+
printf '{"run_id":"%s"}' "$RID" > .loki/engine.json
|
|
259
|
+
printf '{"verdict":"%s","not_proven":["x"],"head_sha":"%s"}' "${FAKE_VERDICT:-VERIFIED}" "$(git rev-parse HEAD)" > ".loki/runs/$RID/receipt.json"
|
|
260
|
+
"""
|
|
261
|
+
|
|
262
|
+
|
|
263
|
+
def _git(cwd, *args):
|
|
264
|
+
return subprocess.run(["git", *args], cwd=cwd, capture_output=True, text=True, check=True).stdout
|
|
265
|
+
|
|
266
|
+
|
|
267
|
+
class SealDiffTests(unittest.TestCase):
|
|
268
|
+
def setUp(self):
|
|
269
|
+
self.tmp = tempfile.mkdtemp(prefix="loki-test-seal-")
|
|
270
|
+
self.addCleanup(__import__("shutil").rmtree, self.tmp, True)
|
|
271
|
+
self.repo = os.path.join(self.tmp, "repo")
|
|
272
|
+
os.makedirs(self.repo)
|
|
273
|
+
_git(self.repo, "init", "-q", "-b", "main")
|
|
274
|
+
_git(self.repo, "config", "user.name", "t")
|
|
275
|
+
_git(self.repo, "config", "user.email", "t@example.invalid")
|
|
276
|
+
with open(os.path.join(self.repo, "a.txt"), "w") as f:
|
|
277
|
+
f.write("base\n")
|
|
278
|
+
_git(self.repo, "add", "a.txt")
|
|
279
|
+
_git(self.repo, "commit", "-q", "-m", "base")
|
|
280
|
+
self.out = os.path.join(self.tmp, "out")
|
|
281
|
+
os.makedirs(self.out)
|
|
282
|
+
self.fake = os.path.join(self.tmp, "loki")
|
|
283
|
+
with open(self.fake, "w") as f:
|
|
284
|
+
f.write(_FAKE_LOKI)
|
|
285
|
+
os.chmod(self.fake, 0o755)
|
|
286
|
+
env = mock.patch.dict(os.environ, {"FAKE_OUT": self.out})
|
|
287
|
+
env.start()
|
|
288
|
+
self.addCleanup(env.stop)
|
|
289
|
+
bin_patch = mock.patch.object(v10_tools, "LOKI_BIN", self.fake)
|
|
290
|
+
bin_patch.start()
|
|
291
|
+
self.addCleanup(bin_patch.stop)
|
|
292
|
+
|
|
293
|
+
def _edit(self):
|
|
294
|
+
with open(os.path.join(self.repo, "a.txt"), "a") as f:
|
|
295
|
+
f.write("changed line\n")
|
|
296
|
+
with open(os.path.join(self.repo, "new.txt"), "w") as f:
|
|
297
|
+
f.write("brand new\n")
|
|
298
|
+
|
|
299
|
+
def test_seals_working_tree_diff_and_leaves_user_repo_untouched(self):
|
|
300
|
+
self._edit()
|
|
301
|
+
before = (_git(self.repo, "status", "--porcelain"), _git(self.repo, "rev-parse", "HEAD"),
|
|
302
|
+
_git(self.repo, "rev-parse", "--abbrev-ref", "HEAD"), _git(self.repo, "diff", "--cached"))
|
|
303
|
+
r = v10_tools.seal_diff(self.repo, "add a line", _ok)
|
|
304
|
+
self.assertNotIn("error", r)
|
|
305
|
+
self.assertEqual((r["verdict"], r["verdict_is_verified"], r["run_id"]), ("VERIFIED", True, "e10-fake"))
|
|
306
|
+
self.assertTrue(os.path.isfile(r["receipt_path"]))
|
|
307
|
+
patch = open(os.path.join(self.out, "patch")).read()
|
|
308
|
+
self.assertIn("+changed line", patch)
|
|
309
|
+
self.assertIn("+brand new", patch)
|
|
310
|
+
self.assertEqual(open(os.path.join(self.out, "intent")).read(), "add a line")
|
|
311
|
+
after = (_git(self.repo, "status", "--porcelain"), _git(self.repo, "rev-parse", "HEAD"),
|
|
312
|
+
_git(self.repo, "rev-parse", "--abbrev-ref", "HEAD"), _git(self.repo, "diff", "--cached"))
|
|
313
|
+
self.assertEqual(before[1:], after[1:])
|
|
314
|
+
self.assertEqual(before[3], "")
|
|
315
|
+
self.assertEqual(after[0].replace("?? .loki/\n", ""), before[0])
|
|
316
|
+
|
|
317
|
+
def test_run_id_from_the_clone_is_validated_before_any_path_join(self):
|
|
318
|
+
self._edit()
|
|
319
|
+
escape = os.path.join(self.tmp, "escaped")
|
|
320
|
+
for bad in (escape, "../../escaped", "a/../b", ".hidden/.."):
|
|
321
|
+
with mock.patch.dict(os.environ, {"FAKE_RUN_ID": bad}):
|
|
322
|
+
r = v10_tools.seal_diff(self.repo, "add a line", _ok)
|
|
323
|
+
self.assertIn("error", r, bad)
|
|
324
|
+
self.assertIn("run_id", r["error"], bad)
|
|
325
|
+
self.assertFalse(os.path.exists(os.path.join(self.repo, ".loki", "runs", "escaped")))
|
|
326
|
+
self.assertFalse(os.path.exists(os.path.join(self.repo, "escaped")))
|
|
327
|
+
|
|
328
|
+
def test_sealed_commit_lands_under_a_non_branch_ref_and_branches_are_untouched(self):
|
|
329
|
+
self._edit()
|
|
330
|
+
branches = _git(self.repo, "branch", "--list")
|
|
331
|
+
r = v10_tools.seal_diff(self.repo, "add a line", _ok)
|
|
332
|
+
self.assertEqual(r["sealed_ref"], "refs/loki/seal/e10-fake")
|
|
333
|
+
# the fake seals at the clone HEAD, which is the user's HEAD; the ref must resolve in the USER repo
|
|
334
|
+
self.assertEqual(_git(self.repo, "rev-parse", r["sealed_ref"]), _git(self.repo, "rev-parse", "HEAD"))
|
|
335
|
+
self.assertEqual(_git(self.repo, "branch", "--list"), branches)
|
|
336
|
+
self.assertIn(".loki/", r["note"])
|
|
337
|
+
|
|
338
|
+
def test_result_does_not_call_the_verdict_string_a_checked_signature(self):
|
|
339
|
+
self._edit()
|
|
340
|
+
r = v10_tools.seal_diff(self.repo, "add a line", _ok)
|
|
341
|
+
self.assertNotIn("verified", r)
|
|
342
|
+
self.assertIn("verdict_is_verified", r)
|
|
343
|
+
|
|
344
|
+
def test_failed_wall_is_not_verified(self):
|
|
345
|
+
self._edit()
|
|
346
|
+
with mock.patch.dict(os.environ, {"FAKE_VERDICT": "FAILED"}):
|
|
347
|
+
r = v10_tools.seal_diff(self.repo, "add a line", _ok)
|
|
348
|
+
self.assertEqual((r["verdict"], r["verdict_is_verified"]), ("FAILED", False))
|
|
349
|
+
|
|
350
|
+
def test_rejects_bad_input_and_empty_diff(self):
|
|
351
|
+
self.assertIn("error", v10_tools.seal_diff(self.repo, "", _ok))
|
|
352
|
+
self.assertIn("error", v10_tools.seal_diff(self.repo, "--help", _ok))
|
|
353
|
+
self.assertIn("error", v10_tools.seal_diff(self.repo, "x", _deny))
|
|
354
|
+
r = v10_tools.seal_diff(self.repo, "x", _ok)
|
|
355
|
+
self.assertIn("no changes to seal", r["error"])
|
|
356
|
+
|
|
357
|
+
def test_scratch_dir_removed(self):
|
|
358
|
+
self._edit()
|
|
359
|
+
made = []
|
|
360
|
+
real = tempfile.mkdtemp
|
|
361
|
+
def spy(*a, **k):
|
|
362
|
+
d = real(*a, **k)
|
|
363
|
+
made.append(d)
|
|
364
|
+
return d
|
|
365
|
+
with mock.patch("tempfile.mkdtemp", spy):
|
|
366
|
+
v10_tools.seal_diff(self.repo, "add a line", _ok)
|
|
367
|
+
self.assertEqual(len(made), 1)
|
|
368
|
+
self.assertFalse(os.path.exists(made[0]))
|
|
369
|
+
|
|
370
|
+
|
|
371
|
+
_E2E = os.path.join(_REPO_ROOT, "loki-ts", "tests", "engine10", "fixtures", "e2e")
|
|
372
|
+
|
|
373
|
+
|
|
374
|
+
@unittest.skipUnless(shutil.which("bun") and shutil.which("git"), "needs bun and git")
|
|
375
|
+
class RealEngineSealDiffTests(unittest.TestCase):
|
|
376
|
+
"""FC-ENGINE-ROUTE-UNMOCKED: seal_diff through the REAL bin/loki under v10_tools._env() (LOKI_ENGINE=v10), so
|
|
377
|
+
autonomy/route.sh must accept --seal-existing. Only the claude CLI is a stub (no billed call)."""
|
|
378
|
+
|
|
379
|
+
def setUp(self):
|
|
380
|
+
self.tmp = tempfile.mkdtemp(prefix="loki-test-real-seal-")
|
|
381
|
+
self.addCleanup(shutil.rmtree, self.tmp, True)
|
|
382
|
+
self.repo = os.path.join(self.tmp, "repo")
|
|
383
|
+
shutil.copytree(os.path.join(_E2E, "repo"), self.repo)
|
|
384
|
+
_git(self.repo, "init", "-q", "-b", "main")
|
|
385
|
+
_git(self.repo, "config", "user.name", "t")
|
|
386
|
+
_git(self.repo, "config", "user.email", "t@example.invalid")
|
|
387
|
+
_git(self.repo, "add", "calc.ts", "calc.test.ts", "bunfig.toml")
|
|
388
|
+
_git(self.repo, "commit", "-q", "-m", "base")
|
|
389
|
+
with open(os.path.join(self.repo, "calc.ts"), "a") as f:
|
|
390
|
+
f.write("\nexport function multiply(a: number, b: number): number {\n return a * b;\n}\n")
|
|
391
|
+
patches = [
|
|
392
|
+
mock.patch.object(v10_tools, "LOKI_BIN", os.path.join(_REPO_ROOT, "bin", "loki")),
|
|
393
|
+
mock.patch.dict(os.environ, {
|
|
394
|
+
"LOKI_E10_INVOKER": "cli", "LOKI_CLAUDE_CLI": os.path.join(_E2E, "bin", "claude"),
|
|
395
|
+
"PATH": os.path.join(_E2E, "bin") + os.pathsep + os.environ.get("PATH", ""),
|
|
396
|
+
"E2E_STUB_MODE": "wallcolor", "LOKI_NO_BROWSER": "1",
|
|
397
|
+
"LOKI_RECEIPT_SIGNING_KEY_FILE": os.path.join(self.tmp, "k.pem"),
|
|
398
|
+
"LOKI_TS_ENTRY": os.path.join(_REPO_ROOT, "loki-ts", "src", "cli.ts")}),
|
|
399
|
+
]
|
|
400
|
+
for p in patches:
|
|
401
|
+
p.start()
|
|
402
|
+
self.addCleanup(p.stop)
|
|
403
|
+
for k in ("LOKI_LEGACY_BASH", "LOKI_MODEL_OVERRIDE", "LOKI_RECEIPT_SIGNING_KEY", "LOKI_ENGINE", "LOKI_E10_PLAN"): # no plan mode: the shipped path
|
|
404
|
+
os.environ.pop(k, None)
|
|
405
|
+
|
|
406
|
+
def _loki(self, *args):
|
|
407
|
+
return subprocess.run([v10_tools.LOKI_BIN, *args], cwd=self.repo, env=v10_tools._env(), capture_output=True,
|
|
408
|
+
text=True, stdin=subprocess.DEVNULL, timeout=120)
|
|
409
|
+
|
|
410
|
+
def _seal(self):
|
|
411
|
+
r = v10_tools.seal_diff(self.repo, "add a multiply(a, b) function to calc.ts", _ok)
|
|
412
|
+
self.assertNotIn("error", r, r)
|
|
413
|
+
return r
|
|
414
|
+
|
|
415
|
+
def test_route_accepts_seal_existing_and_receipt_names_no_implementing_model(self):
|
|
416
|
+
r = self._seal()
|
|
417
|
+
self.assertEqual(r["verdict"], "VERIFIED")
|
|
418
|
+
receipt = json.load(open(r["receipt_path"]))
|
|
419
|
+
self.assertEqual(receipt["implement"]["exit"], "external")
|
|
420
|
+
self.assertEqual(receipt["model"], "external (no Loki implement session)")
|
|
421
|
+
self.assertIn("Wall author not filesystem-isolated from the external diff", r["not_proven"])
|
|
422
|
+
|
|
423
|
+
def test_wrong_external_body_never_seals_verified_on_the_shipped_path(self):
|
|
424
|
+
"""FC-EXTERNAL-SEAL-WITHOUT-WALL-VERIFIED: no LOKI_E10_PLAN, wrong multiply body, default env through the tool."""
|
|
425
|
+
calc = os.path.join(self.repo, "calc.ts")
|
|
426
|
+
_git(self.repo, "checkout", "--", "calc.ts")
|
|
427
|
+
with open(calc, "a") as f:
|
|
428
|
+
f.write("\nexport function multiply(a: number, b: number): number {\n return a + b + 1;\n}\n")
|
|
429
|
+
self.assertNotIn("LOKI_E10_PLAN", v10_tools._env())
|
|
430
|
+
r = v10_tools.seal_diff(self.repo, "add a multiply(a, b) function to calc.ts", _ok)
|
|
431
|
+
self.assertNotIn("error", r, r)
|
|
432
|
+
self.assertNotEqual(r["verdict"], "VERIFIED", r)
|
|
433
|
+
self.assertFalse(r["verdict_is_verified"])
|
|
434
|
+
|
|
435
|
+
def test_user_repo_verifies_strictly_with_no_drift_override(self):
|
|
436
|
+
before = (_git(self.repo, "rev-parse", "HEAD"), _git(self.repo, "diff", "--cached"), _git(self.repo, "branch", "--list"))
|
|
437
|
+
r = self._seal()
|
|
438
|
+
self.assertEqual(r["sealed_ref"], "refs/loki/seal/" + r["run_id"])
|
|
439
|
+
self.assertEqual((_git(self.repo, "rev-parse", "HEAD"), _git(self.repo, "diff", "--cached"), _git(self.repo, "branch", "--list")), before)
|
|
440
|
+
env = v10_tools._env()
|
|
441
|
+
self.assertNotIn("LOKI_VERIFY_ALLOW_HEAD_DRIFT", env)
|
|
442
|
+
# Before the user commits, HEAD is still the base and the sealed path is uncommitted: verify honestly says UNCHECKED.
|
|
443
|
+
pre = self._loki("verify", r["run_id"])
|
|
444
|
+
self.assertEqual(pre.returncode, 2, pre.stdout + pre.stderr)
|
|
445
|
+
self.assertIn("head drift", pre.stdout)
|
|
446
|
+
# After the user commits the sealed change, the fetched sealed head makes the drift computable: strict VERIFIED.
|
|
447
|
+
_git(self.repo, "add", "calc.ts")
|
|
448
|
+
_git(self.repo, "commit", "-q", "-m", "user commit of the sealed change")
|
|
449
|
+
p = self._loki("verify", r["run_id"])
|
|
450
|
+
self.assertEqual(p.returncode, 0, p.stdout + p.stderr)
|
|
451
|
+
self.assertIn("verdict: VERIFIED", p.stdout)
|
|
452
|
+
# Negative control: without the fetched ref the same verify cannot compute the drift and is UNCHECKED again.
|
|
453
|
+
_git(self.repo, "update-ref", "-d", r["sealed_ref"])
|
|
454
|
+
_git(self.repo, "reflog", "expire", "--expire=now", "--all")
|
|
455
|
+
_git(self.repo, "gc", "-q", "--prune=now")
|
|
456
|
+
gone = self._loki("verify", r["run_id"])
|
|
457
|
+
self.assertEqual(gone.returncode, 2, gone.stdout + gone.stderr)
|
|
458
|
+
self.assertIn("head drift", gone.stdout)
|
package/mcp/v10_tools.py
CHANGED
|
@@ -9,15 +9,19 @@ validator before use.
|
|
|
9
9
|
import json
|
|
10
10
|
import os
|
|
11
11
|
import re
|
|
12
|
+
import shutil
|
|
12
13
|
import subprocess
|
|
14
|
+
import tempfile
|
|
13
15
|
import time
|
|
14
16
|
from pathlib import Path
|
|
15
17
|
from typing import Callable, Dict, List, Optional
|
|
16
18
|
|
|
17
19
|
LOKI_BIN = str(Path(__file__).resolve().parent.parent / "bin" / "loki")
|
|
18
20
|
RUNS_REL = os.path.join(".loki", "runs")
|
|
21
|
+
RUN_ID_RE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$")
|
|
19
22
|
V10_RUN_ID_WAIT_S = 2.0
|
|
20
23
|
V10_VERIFY_TIMEOUT_S = 120
|
|
24
|
+
SEAL_DIFF_TIMEOUT_S = int(os.environ.get("LOKI_SEAL_DIFF_TIMEOUT_S", "1800"))
|
|
21
25
|
_TERMINAL = {"stage.completed", "stage.failed", "stage.skipped"}
|
|
22
26
|
|
|
23
27
|
|
|
@@ -195,6 +199,85 @@ def v10_verify(receipt_path: str, repo_path: str, validate: Callable[[str], str]
|
|
|
195
199
|
return {"exit_code": p.returncode, "verified": p.returncode == 0, "output": out[-2000:]}
|
|
196
200
|
|
|
197
201
|
|
|
202
|
+
def _git(cwd: str, *args: str, env: Optional[Dict[str, str]] = None) -> subprocess.CompletedProcess:
|
|
203
|
+
return subprocess.run(["git", *args], cwd=cwd, env=env, stdin=subprocess.DEVNULL,
|
|
204
|
+
capture_output=True, timeout=120)
|
|
205
|
+
|
|
206
|
+
|
|
207
|
+
def seal_diff(repo_path: str, intent: str, validate: Callable[[str], str]) -> dict:
|
|
208
|
+
"""Seal changes already made in repo_path (CW-12): the existing engine runs on a scratch clone of HEAD with the
|
|
209
|
+
working-tree diff handed to `--seal-existing`, so the Wall is authored from the intent alone and the diff only
|
|
210
|
+
reaches the tree at implement. The user's tree, index and branch are never touched; only the run dir is copied back."""
|
|
211
|
+
if not isinstance(intent, str) or not intent.strip() or intent.startswith("-") or "\x00" in intent:
|
|
212
|
+
return {"error": "invalid intent: must be a non-empty description that does not start with '-'"}
|
|
213
|
+
try:
|
|
214
|
+
repo = validate(repo_path)
|
|
215
|
+
except Exception as e:
|
|
216
|
+
return {"error": str(e)}
|
|
217
|
+
if not os.path.isdir(repo):
|
|
218
|
+
return {"error": f"repo_path is not a directory: {repo_path}"}
|
|
219
|
+
work = tempfile.mkdtemp(prefix="loki-seal-")
|
|
220
|
+
try:
|
|
221
|
+
if _git(repo, "rev-parse", "--verify", "-q", "HEAD").returncode != 0:
|
|
222
|
+
return {"error": "repo_path is not a git repository with a commit to diff against"}
|
|
223
|
+
# Snapshot the working tree (tracked edits plus new files) with a private index so the user's index is untouched.
|
|
224
|
+
idx_env = dict(os.environ, GIT_INDEX_FILE=os.path.join(work, "index"))
|
|
225
|
+
for step in (("read-tree", "HEAD"), ("add", "-A")):
|
|
226
|
+
r = _git(repo, *step, env=idx_env)
|
|
227
|
+
if r.returncode != 0:
|
|
228
|
+
return {"error": f"could not snapshot the working tree: {r.stderr.decode('utf-8', 'replace').strip()[-300:]}"}
|
|
229
|
+
d = _git(repo, "diff", "--cached", "--binary", "HEAD", env=idx_env)
|
|
230
|
+
if d.returncode != 0 or not d.stdout.strip():
|
|
231
|
+
return {"error": "no changes to seal: the working tree matches HEAD"}
|
|
232
|
+
patch = os.path.join(work, "external.patch")
|
|
233
|
+
with open(patch, "wb") as f:
|
|
234
|
+
f.write(d.stdout)
|
|
235
|
+
clone = os.path.join(work, "clone")
|
|
236
|
+
c = _git(work, "clone", "--quiet", "--no-hardlinks", repo, clone)
|
|
237
|
+
if c.returncode != 0:
|
|
238
|
+
return {"error": f"could not clone the repository: {c.stderr.decode('utf-8', 'replace').strip()[-300:]}"}
|
|
239
|
+
try:
|
|
240
|
+
p = subprocess.run([LOKI_BIN, intent, "--seal-existing", patch], cwd=clone, env=_env(), stdin=subprocess.DEVNULL,
|
|
241
|
+
capture_output=True, text=True, timeout=SEAL_DIFF_TIMEOUT_S)
|
|
242
|
+
except subprocess.TimeoutExpired:
|
|
243
|
+
return {"error": f"seal timed out after {SEAL_DIFF_TIMEOUT_S}s"}
|
|
244
|
+
except OSError as e:
|
|
245
|
+
return {"error": f"could not run loki: {e}"}
|
|
246
|
+
out = ((p.stdout or "") + (p.stderr or "")).strip()
|
|
247
|
+
try:
|
|
248
|
+
with open(os.path.join(clone, ".loki", "engine.json"), "r", encoding="utf-8") as f:
|
|
249
|
+
run_id = json.load(f)["run_id"]
|
|
250
|
+
if not isinstance(run_id, str) or not RUN_ID_RE.match(run_id) or ".." in run_id:
|
|
251
|
+
return {"error": "refusing a run_id that is not a plain run id"} # the clone is engine-written but agent-writable: never join it into a path unchecked
|
|
252
|
+
src = os.path.join(clone, RUNS_REL, run_id)
|
|
253
|
+
with open(os.path.join(src, "receipt.json"), "r", encoding="utf-8") as f:
|
|
254
|
+
receipt = json.load(f)
|
|
255
|
+
except (OSError, ValueError, KeyError):
|
|
256
|
+
return {"error": f"loki exited {p.returncode} without a receipt. Output tail:\n{out[-1500:]}"}
|
|
257
|
+
dest = os.path.join(repo, RUNS_REL, run_id)
|
|
258
|
+
os.makedirs(os.path.dirname(dest), exist_ok=True)
|
|
259
|
+
shutil.copytree(src, dest, dirs_exist_ok=True)
|
|
260
|
+
# B4: bring the sealed commit into the user repo under a non-branch ref so `loki verify <id>` resolves the sealed head
|
|
261
|
+
# without LOKI_VERIFY_ALLOW_HEAD_DRIFT. Never a branch: HEAD, the index and tracked files stay untouched.
|
|
262
|
+
ref = f"refs/loki/seal/{run_id}"
|
|
263
|
+
sealed_ref = None
|
|
264
|
+
head = receipt.get("head_sha")
|
|
265
|
+
if isinstance(head, str) and re.fullmatch(r"[0-9a-f]{40,64}", head):
|
|
266
|
+
if _git(clone, "update-ref", ref, head).returncode == 0 and \
|
|
267
|
+
_git(repo, "fetch", "--quiet", "--no-tags", clone, f"{ref}:{ref}").returncode == 0:
|
|
268
|
+
sealed_ref = ref
|
|
269
|
+
verdict = receipt.get("verdict")
|
|
270
|
+
return {"run_id": run_id, "verdict": verdict,
|
|
271
|
+
"verdict_is_verified": verdict == "VERIFIED", # the receipt's own verdict string; no signature is checked here, run loki_v10_verify for that
|
|
272
|
+
"receipt_path": os.path.join(dest, "receipt.json"), "sealed_ref": sealed_ref,
|
|
273
|
+
"not_proven": receipt.get("not_proven", []), "exit_code": p.returncode,
|
|
274
|
+
"note": "the new untracked .loki/ directory in the repo is expected (loki start creates it the same way); HEAD, the index and tracked files are unchanged"}
|
|
275
|
+
except subprocess.TimeoutExpired:
|
|
276
|
+
return {"error": "git timed out"}
|
|
277
|
+
finally:
|
|
278
|
+
shutil.rmtree(work, ignore_errors=True)
|
|
279
|
+
|
|
280
|
+
|
|
198
281
|
def _cwd_validator(p: str) -> str:
|
|
199
282
|
"""Resolve p and require it to sit inside the current working directory (the MCP server's, as for the SDK path)."""
|
|
200
283
|
root = os.path.realpath(os.getcwd())
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "loki-mode",
|
|
3
3
|
"mcpName": "io.github.asklokesh/loki-mode",
|
|
4
|
-
"version": "11.
|
|
4
|
+
"version": "11.67.1",
|
|
5
5
|
"description": "An autonomous software factory that knows what it is supposed to deliver, and proves it did.",
|
|
6
6
|
"keywords": [
|
|
7
7
|
"agent",
|
|
@@ -160,5 +160,5 @@
|
|
|
160
160
|
"dependencies": {
|
|
161
161
|
"@resvg/resvg-wasm": "^2.6.2"
|
|
162
162
|
},
|
|
163
|
-
"gitHead": "
|
|
163
|
+
"gitHead": "6ff9a3206d5e346bf72c0786a44fe02575091d93"
|
|
164
164
|
}
|