loki-mode 7.84.0 → 7.85.0
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/autonomy/lib/proof-generator.py +407 -13
- package/autonomy/lib/proof-template.html +309 -1
- package/autonomy/lib/proof-verify.py +483 -0
- package/autonomy/loki +32 -1
- package/autonomy/run.sh +26 -2
- package/dashboard/__init__.py +1 -1
- package/docs/INSTALLATION.md +2 -2
- package/loki-ts/dist/loki.js +2 -2
- package/mcp/__init__.py +1 -1
- package/package.json +1 -1
- package/plugins/loki-mode/.claude-plugin/plugin.json +1 -1
|
@@ -0,0 +1,483 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Deterministic re-verifier for Loki Mode proof-of-run receipts.
|
|
3
|
+
|
|
4
|
+
Companion to proof-generator.py. The generator writes proof.json with an
|
|
5
|
+
integrity hash so a skeptic can prove the JSON was not edited. This module
|
|
6
|
+
goes one step further: it re-checks the receipt's recorded FACTS against the
|
|
7
|
+
live repo, so a skeptic can prove the facts are STILL TRUE (the diff the
|
|
8
|
+
receipt claims still matches what is in git), not merely that the JSON bytes
|
|
9
|
+
are unaltered. That re-check is what makes the receipt non-forgeable: you
|
|
10
|
+
cannot hand-write a proof.json whose recorded diff survives a re-run against
|
|
11
|
+
the repo it claims to describe.
|
|
12
|
+
|
|
13
|
+
Three checks (mirrors dashboard/audit.py verify-CLI style):
|
|
14
|
+
|
|
15
|
+
1. TAMPER CHECK (hash_ok): strip verification.hash, re-canonicalize exactly
|
|
16
|
+
as the generator does (sort_keys=True, compact separators, the same
|
|
17
|
+
ensure_ascii setting), sha256, compare to the recorded verification.hash.
|
|
18
|
+
Any mismatch means the JSON was edited after signing.
|
|
19
|
+
|
|
20
|
+
2. DRIFT CHECK (diff_drift): from the recorded git base sha, re-run
|
|
21
|
+
`git diff --shortstat <base> <head>` in the repo and compare the file /
|
|
22
|
+
insertion / deletion counts (and diff_sha256 when present) to what the
|
|
23
|
+
receipt recorded. A mismatch means the repo no longer matches the receipt.
|
|
24
|
+
|
|
25
|
+
3. GPG (gpg_ok): if a detached signature is present and gpg is available,
|
|
26
|
+
verify it over the canonical bytes. Otherwise "n/a".
|
|
27
|
+
|
|
28
|
+
Honesty rules (CLAUDE.md binding):
|
|
29
|
+
- Never claim "verified" when a check could not run. If the base ref is
|
|
30
|
+
missing or unresolvable, diff_drift is reported as None and `ok` is False
|
|
31
|
+
with a reason; we never silently pass an undrifted-but-unchecked receipt.
|
|
32
|
+
- Surface honesty.degraded from the proof so the verifier output also shows
|
|
33
|
+
the gaps the generator already disclosed.
|
|
34
|
+
- set -u / robust: missing file, malformed JSON, missing fields produce a
|
|
35
|
+
clear error and exit 2, never a traceback-as-UX.
|
|
36
|
+
|
|
37
|
+
Schema compatibility:
|
|
38
|
+
- Generator schema v1.0 records the diff under top-level files_changed{} and
|
|
39
|
+
diffs[], with NO recorded base sha, so drift cannot be re-derived (the
|
|
40
|
+
verifier says so honestly rather than pretending).
|
|
41
|
+
- Schema v1.1 (if/when the generator slice lands it) records
|
|
42
|
+
facts.git.{base_sha, head_sha, diff, diff_sha256}. This verifier prefers
|
|
43
|
+
facts.git when present and falls back to the v1.0 layout otherwise.
|
|
44
|
+
|
|
45
|
+
CLI:
|
|
46
|
+
python3 autonomy/lib/proof-verify.py <proof.json> [repo_dir]
|
|
47
|
+
Prints the JSON result. Exit 0 if ok, 1 on tamper / drift / bad signature,
|
|
48
|
+
2 on a usage / load error (missing file, malformed JSON).
|
|
49
|
+
"""
|
|
50
|
+
|
|
51
|
+
import hashlib
|
|
52
|
+
import json
|
|
53
|
+
import os
|
|
54
|
+
import subprocess
|
|
55
|
+
import sys
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
# ---------------------------------------------------------------------------
|
|
59
|
+
# canonicalization (MUST match proof-generator._canonical exactly)
|
|
60
|
+
# ---------------------------------------------------------------------------
|
|
61
|
+
|
|
62
|
+
def _canonical(obj):
|
|
63
|
+
"""Canonical JSON form used for the integrity hash.
|
|
64
|
+
|
|
65
|
+
Mirrors proof-generator.py _canonical(): json.dumps with sort_keys=True
|
|
66
|
+
and compact separators. The generator does not pass ensure_ascii, so it
|
|
67
|
+
defaults to True; we match that here so the recomputed hash is identical.
|
|
68
|
+
"""
|
|
69
|
+
return json.dumps(obj, sort_keys=True, separators=(",", ":"))
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
# ---------------------------------------------------------------------------
|
|
73
|
+
# git helpers
|
|
74
|
+
# ---------------------------------------------------------------------------
|
|
75
|
+
|
|
76
|
+
def _git(repo_dir, args, timeout=30):
|
|
77
|
+
"""Run git in repo_dir. Returns stdout string, or None on any failure."""
|
|
78
|
+
try:
|
|
79
|
+
out = subprocess.run(
|
|
80
|
+
["git", "-C", repo_dir] + args,
|
|
81
|
+
capture_output=True, text=True, timeout=timeout,
|
|
82
|
+
)
|
|
83
|
+
if out.returncode != 0:
|
|
84
|
+
return None
|
|
85
|
+
return out.stdout
|
|
86
|
+
except Exception:
|
|
87
|
+
return None
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def _is_git_repo(repo_dir):
|
|
91
|
+
return _git(repo_dir, ["rev-parse", "--is-inside-work-tree"]) is not None
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def _rev_resolvable(repo_dir, ref):
|
|
95
|
+
"""True iff `ref` resolves to a commit in repo_dir."""
|
|
96
|
+
if not ref:
|
|
97
|
+
return False
|
|
98
|
+
out = _git(repo_dir, ["rev-parse", "--verify", "--quiet", str(ref) + "^{commit}"])
|
|
99
|
+
return bool(out and out.strip())
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def _numstat(repo_dir, base, head):
|
|
103
|
+
"""Return {count, insertions, deletions, files} for
|
|
104
|
+
`git diff --numstat base head`, or None if the diff could not be computed.
|
|
105
|
+
|
|
106
|
+
The `files` list mirrors proof-generator._git_diffstat EXACTLY (path /
|
|
107
|
+
insertions / deletions / status) so that hashing the canonical stat here
|
|
108
|
+
reproduces the generator's diff_sha256. (Earlier this hashed the full patch
|
|
109
|
+
text while the generator hashed the stat object -- so every untampered v1.1
|
|
110
|
+
proof falsely reported drift. BUG-DIFFSHA.)"""
|
|
111
|
+
raw = _git(repo_dir, ["diff", "--numstat", str(base), str(head)])
|
|
112
|
+
if raw is None:
|
|
113
|
+
return None
|
|
114
|
+
files = []
|
|
115
|
+
ins_total = 0
|
|
116
|
+
del_total = 0
|
|
117
|
+
for line in raw.splitlines():
|
|
118
|
+
parts = line.split("\t")
|
|
119
|
+
if len(parts) < 3:
|
|
120
|
+
continue
|
|
121
|
+
ins_s, del_s, path = parts[0], parts[1], parts[2]
|
|
122
|
+
# binary files show "-" for both columns; count the file, add 0.
|
|
123
|
+
ins = 0 if ins_s == "-" else _to_int(ins_s)
|
|
124
|
+
dele = 0 if del_s == "-" else _to_int(del_s)
|
|
125
|
+
ins_total += ins
|
|
126
|
+
del_total += dele
|
|
127
|
+
files.append({
|
|
128
|
+
"path": path,
|
|
129
|
+
"insertions": ins,
|
|
130
|
+
"deletions": dele,
|
|
131
|
+
"status": "binary" if ins_s == "-" else "modified",
|
|
132
|
+
})
|
|
133
|
+
return {
|
|
134
|
+
"count": len(files),
|
|
135
|
+
"insertions": ins_total,
|
|
136
|
+
"deletions": del_total,
|
|
137
|
+
"files": files,
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
def _diff_sha256_from_stat(files_changed):
|
|
142
|
+
"""Recompute the generator's diff_sha256 from a stat object.
|
|
143
|
+
|
|
144
|
+
MUST match proof-generator._diff_sha256: sha256 of the canonical
|
|
145
|
+
{count, insertions, deletions, files} object (NOT the full patch text)."""
|
|
146
|
+
fc = files_changed or {}
|
|
147
|
+
canon = {
|
|
148
|
+
"count": fc.get("count", 0),
|
|
149
|
+
"insertions": fc.get("insertions", 0),
|
|
150
|
+
"deletions": fc.get("deletions", 0),
|
|
151
|
+
"files": fc.get("files", []),
|
|
152
|
+
}
|
|
153
|
+
return hashlib.sha256(_canonical(canon).encode("utf-8")).hexdigest()
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
def _full_diff(repo_dir, base, head):
|
|
157
|
+
"""Return the full `git diff base head` patch text, or None."""
|
|
158
|
+
return _git(repo_dir, ["diff", str(base), str(head)])
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
def _to_int(v, default=0):
|
|
162
|
+
try:
|
|
163
|
+
return int(v)
|
|
164
|
+
except Exception:
|
|
165
|
+
return default
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
# ---------------------------------------------------------------------------
|
|
169
|
+
# proof field extraction (schema v1.0 + v1.1 tolerant)
|
|
170
|
+
# ---------------------------------------------------------------------------
|
|
171
|
+
|
|
172
|
+
def _recorded_git_refs(proof):
|
|
173
|
+
"""Return (base_sha, head_sha) the receipt recorded, or (None, None).
|
|
174
|
+
|
|
175
|
+
Prefers schema v1.1 facts.git.{base_sha,head_sha}. Schema v1.0 records no
|
|
176
|
+
base sha, so this returns (None, None) there and the caller reports drift
|
|
177
|
+
as unverifiable rather than passing it silently.
|
|
178
|
+
"""
|
|
179
|
+
facts = proof.get("facts")
|
|
180
|
+
if isinstance(facts, dict):
|
|
181
|
+
git = facts.get("git")
|
|
182
|
+
if isinstance(git, dict):
|
|
183
|
+
base = git.get("base_sha")
|
|
184
|
+
head = git.get("head_sha")
|
|
185
|
+
return (str(base) if base else None,
|
|
186
|
+
str(head) if head else None)
|
|
187
|
+
return None, None
|
|
188
|
+
|
|
189
|
+
|
|
190
|
+
def _recorded_diff_stat(proof):
|
|
191
|
+
"""Return the recorded {count, insertions, deletions}, schema-tolerant.
|
|
192
|
+
|
|
193
|
+
v1.1 records facts.git.diff = {count, insertions, deletions, ...}.
|
|
194
|
+
v1.0 records top-level files_changed = {count, insertions, deletions, ...}.
|
|
195
|
+
Returns None if neither is present / usable.
|
|
196
|
+
"""
|
|
197
|
+
facts = proof.get("facts")
|
|
198
|
+
if isinstance(facts, dict):
|
|
199
|
+
git = facts.get("git")
|
|
200
|
+
if isinstance(git, dict) and isinstance(git.get("diff"), dict):
|
|
201
|
+
d = git["diff"]
|
|
202
|
+
return {
|
|
203
|
+
"count": _to_int(d.get("count")),
|
|
204
|
+
"insertions": _to_int(d.get("insertions")),
|
|
205
|
+
"deletions": _to_int(d.get("deletions")),
|
|
206
|
+
}
|
|
207
|
+
fc = proof.get("files_changed")
|
|
208
|
+
if isinstance(fc, dict):
|
|
209
|
+
return {
|
|
210
|
+
"count": _to_int(fc.get("count")),
|
|
211
|
+
"insertions": _to_int(fc.get("insertions")),
|
|
212
|
+
"deletions": _to_int(fc.get("deletions")),
|
|
213
|
+
}
|
|
214
|
+
return None
|
|
215
|
+
|
|
216
|
+
|
|
217
|
+
def _recorded_diff_sha256(proof):
|
|
218
|
+
"""Return facts.git.diff_sha256 if recorded (v1.1 only), else None."""
|
|
219
|
+
facts = proof.get("facts")
|
|
220
|
+
if isinstance(facts, dict):
|
|
221
|
+
git = facts.get("git")
|
|
222
|
+
if isinstance(git, dict):
|
|
223
|
+
v = git.get("diff_sha256")
|
|
224
|
+
if v:
|
|
225
|
+
return str(v)
|
|
226
|
+
return None
|
|
227
|
+
|
|
228
|
+
|
|
229
|
+
def _recorded_degraded(proof):
|
|
230
|
+
"""Return the honesty.degraded list the generator disclosed, or []."""
|
|
231
|
+
honesty = proof.get("honesty")
|
|
232
|
+
if isinstance(honesty, dict):
|
|
233
|
+
deg = honesty.get("degraded")
|
|
234
|
+
if isinstance(deg, list):
|
|
235
|
+
return [str(x) for x in deg]
|
|
236
|
+
return []
|
|
237
|
+
|
|
238
|
+
|
|
239
|
+
# ---------------------------------------------------------------------------
|
|
240
|
+
# gpg
|
|
241
|
+
# ---------------------------------------------------------------------------
|
|
242
|
+
|
|
243
|
+
def _gpg_available():
|
|
244
|
+
try:
|
|
245
|
+
out = subprocess.run(["gpg", "--version"], capture_output=True,
|
|
246
|
+
text=True, timeout=10)
|
|
247
|
+
return out.returncode == 0
|
|
248
|
+
except Exception:
|
|
249
|
+
return False
|
|
250
|
+
|
|
251
|
+
|
|
252
|
+
def _verify_gpg(canonical_bytes, signature):
|
|
253
|
+
"""Verify a detached signature over canonical_bytes.
|
|
254
|
+
|
|
255
|
+
Returns True (good sig), False (bad sig / gpg failure), or "n/a" when no
|
|
256
|
+
signature is present or gpg is unavailable.
|
|
257
|
+
"""
|
|
258
|
+
if not signature:
|
|
259
|
+
return "n/a"
|
|
260
|
+
if not _gpg_available():
|
|
261
|
+
return "n/a"
|
|
262
|
+
import tempfile
|
|
263
|
+
data_path = None
|
|
264
|
+
sig_path = None
|
|
265
|
+
try:
|
|
266
|
+
with tempfile.NamedTemporaryFile(delete=False, suffix=".bin") as df:
|
|
267
|
+
df.write(canonical_bytes)
|
|
268
|
+
data_path = df.name
|
|
269
|
+
sig_bytes = signature
|
|
270
|
+
if isinstance(sig_bytes, str):
|
|
271
|
+
sig_bytes = sig_bytes.encode("utf-8")
|
|
272
|
+
with tempfile.NamedTemporaryFile(delete=False, suffix=".sig") as sf:
|
|
273
|
+
sf.write(sig_bytes)
|
|
274
|
+
sig_path = sf.name
|
|
275
|
+
out = subprocess.run(
|
|
276
|
+
["gpg", "--verify", sig_path, data_path],
|
|
277
|
+
capture_output=True, text=True, timeout=30,
|
|
278
|
+
)
|
|
279
|
+
return out.returncode == 0
|
|
280
|
+
except Exception:
|
|
281
|
+
return False
|
|
282
|
+
finally:
|
|
283
|
+
for p in (data_path, sig_path):
|
|
284
|
+
if p:
|
|
285
|
+
try:
|
|
286
|
+
os.unlink(p)
|
|
287
|
+
except OSError:
|
|
288
|
+
pass
|
|
289
|
+
|
|
290
|
+
|
|
291
|
+
# ---------------------------------------------------------------------------
|
|
292
|
+
# the verifier
|
|
293
|
+
# ---------------------------------------------------------------------------
|
|
294
|
+
|
|
295
|
+
class ProofLoadError(Exception):
|
|
296
|
+
"""Raised for a missing file / malformed JSON / unusable proof shape."""
|
|
297
|
+
|
|
298
|
+
|
|
299
|
+
def _load_proof(proof_path):
|
|
300
|
+
if not os.path.isfile(proof_path):
|
|
301
|
+
raise ProofLoadError("proof file not found: %s" % proof_path)
|
|
302
|
+
try:
|
|
303
|
+
with open(proof_path, "r") as f:
|
|
304
|
+
data = json.load(f)
|
|
305
|
+
except json.JSONDecodeError as exc:
|
|
306
|
+
raise ProofLoadError("malformed JSON in %s: %s" % (proof_path, exc))
|
|
307
|
+
except OSError as exc:
|
|
308
|
+
raise ProofLoadError("could not read %s: %s" % (proof_path, exc))
|
|
309
|
+
if not isinstance(data, dict):
|
|
310
|
+
raise ProofLoadError("proof root is not a JSON object: %s" % proof_path)
|
|
311
|
+
return data
|
|
312
|
+
|
|
313
|
+
|
|
314
|
+
def verify(proof_path, repo_dir="."):
|
|
315
|
+
"""Re-verify a proof.json against the repo.
|
|
316
|
+
|
|
317
|
+
Returns a dict:
|
|
318
|
+
{
|
|
319
|
+
hash_ok: bool tamper check passed
|
|
320
|
+
diff_drift: bool | None True=drift, False=match,
|
|
321
|
+
None=could not check
|
|
322
|
+
diff_recheck: {recorded, current} the two diff stats compared
|
|
323
|
+
gpg_ok: True | False | "n/a" signature verdict
|
|
324
|
+
degraded: [str] honesty.degraded from the proof
|
|
325
|
+
reason: str why ok is False (when it is)
|
|
326
|
+
ok: bool overall verdict
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
`ok` = hash_ok AND diff_drift is False AND gpg_ok in (True, "n/a").
|
|
330
|
+
Note: diff_drift None (unverifiable) makes ok False, by design -- we never
|
|
331
|
+
report "verified" when the central fact could not be re-checked.
|
|
332
|
+
"""
|
|
333
|
+
proof = _load_proof(proof_path)
|
|
334
|
+
|
|
335
|
+
result = {
|
|
336
|
+
"hash_ok": False,
|
|
337
|
+
"diff_drift": None,
|
|
338
|
+
"diff_recheck": {"recorded": None, "current": None},
|
|
339
|
+
"gpg_ok": "n/a",
|
|
340
|
+
"degraded": _recorded_degraded(proof),
|
|
341
|
+
"reason": "",
|
|
342
|
+
"ok": False,
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
# ----- 1. TAMPER CHECK -------------------------------------------------
|
|
346
|
+
verification = proof.get("verification")
|
|
347
|
+
if not isinstance(verification, dict) or not verification.get("hash"):
|
|
348
|
+
result["hash_ok"] = False
|
|
349
|
+
result["reason"] = "no verification.hash recorded; cannot prove integrity"
|
|
350
|
+
return result
|
|
351
|
+
recorded_hash = str(verification.get("hash"))
|
|
352
|
+
|
|
353
|
+
# Recompute over the canonical form with verification REMOVED, exactly as
|
|
354
|
+
# the generator hashed it (hash computed before verification was attached).
|
|
355
|
+
unsigned = dict(proof)
|
|
356
|
+
unsigned.pop("verification", None)
|
|
357
|
+
canonical_str = _canonical(unsigned)
|
|
358
|
+
canonical_bytes = canonical_str.encode("utf-8")
|
|
359
|
+
recomputed = hashlib.sha256(canonical_bytes).hexdigest()
|
|
360
|
+
result["hash_ok"] = (recomputed == recorded_hash)
|
|
361
|
+
if not result["hash_ok"]:
|
|
362
|
+
result["reason"] = "integrity hash mismatch (proof.json was edited after signing)"
|
|
363
|
+
# Continue to gather drift/gpg signals for the report, but ok stays False.
|
|
364
|
+
|
|
365
|
+
# ----- 3. GPG (compute before returning so the report is complete) -----
|
|
366
|
+
gpg_sig = verification.get("gpg_signature")
|
|
367
|
+
result["gpg_ok"] = _verify_gpg(canonical_bytes, gpg_sig)
|
|
368
|
+
|
|
369
|
+
# ----- 2. DRIFT CHECK --------------------------------------------------
|
|
370
|
+
recorded_stat = _recorded_diff_stat(proof)
|
|
371
|
+
result["diff_recheck"]["recorded"] = recorded_stat
|
|
372
|
+
|
|
373
|
+
base_sha, head_sha = _recorded_git_refs(proof)
|
|
374
|
+
|
|
375
|
+
if not _is_git_repo(repo_dir):
|
|
376
|
+
result["diff_drift"] = None
|
|
377
|
+
if not result["reason"]:
|
|
378
|
+
result["reason"] = "repo_dir is not a git work tree; drift unverifiable"
|
|
379
|
+
elif not base_sha:
|
|
380
|
+
# Schema v1.0 (or a v1.1 proof missing base_sha): no recorded base ref,
|
|
381
|
+
# so the diff cannot be re-derived. Report honestly, do NOT pass.
|
|
382
|
+
result["diff_drift"] = None
|
|
383
|
+
if not result["reason"]:
|
|
384
|
+
result["reason"] = "base ref unresolvable (no recorded base_sha; drift unverifiable)"
|
|
385
|
+
elif not _rev_resolvable(repo_dir, base_sha):
|
|
386
|
+
result["diff_drift"] = None
|
|
387
|
+
if not result["reason"]:
|
|
388
|
+
result["reason"] = ("base ref unresolvable (%s not found in repo; "
|
|
389
|
+
"drift unverifiable)" % base_sha)
|
|
390
|
+
else:
|
|
391
|
+
# Drift answers "does this receipt still describe the CURRENT branch
|
|
392
|
+
# state". A receipt is for verifying the work as it stands now, so we
|
|
393
|
+
# diff base..live-HEAD: a new commit since the receipt was generated is
|
|
394
|
+
# genuine drift (the receipt no longer matches the branch). The recorded
|
|
395
|
+
# head_sha is used for the tamper/hash check, not here. (The integrity
|
|
396
|
+
# hash already proves the receipt's own bytes are unedited; drift proves
|
|
397
|
+
# the recorded FACTS still match the repo.)
|
|
398
|
+
head_ref = "HEAD"
|
|
399
|
+
current_stat = _numstat(repo_dir, base_sha, head_ref)
|
|
400
|
+
result["diff_recheck"]["current"] = current_stat
|
|
401
|
+
|
|
402
|
+
if current_stat is None:
|
|
403
|
+
result["diff_drift"] = None
|
|
404
|
+
if not result["reason"]:
|
|
405
|
+
result["reason"] = "git diff could not be computed; drift unverifiable"
|
|
406
|
+
else:
|
|
407
|
+
drift = False
|
|
408
|
+
if recorded_stat is not None:
|
|
409
|
+
drift = (
|
|
410
|
+
recorded_stat.get("count") != current_stat.get("count")
|
|
411
|
+
or recorded_stat.get("insertions") != current_stat.get("insertions")
|
|
412
|
+
or recorded_stat.get("deletions") != current_stat.get("deletions")
|
|
413
|
+
)
|
|
414
|
+
else:
|
|
415
|
+
# We can re-derive the diff but the receipt recorded no stat to
|
|
416
|
+
# compare against -- cannot confirm the facts match.
|
|
417
|
+
result["diff_drift"] = None
|
|
418
|
+
if not result["reason"]:
|
|
419
|
+
result["reason"] = ("no recorded diff stat to compare; "
|
|
420
|
+
"drift unverifiable")
|
|
421
|
+
|
|
422
|
+
# diff_sha256: a stronger content check than the counts. Only when
|
|
423
|
+
# the receipt recorded one (v1.1).
|
|
424
|
+
recorded_dsha = _recorded_diff_sha256(proof)
|
|
425
|
+
if result["diff_drift"] is not False and recorded_stat is not None:
|
|
426
|
+
# only evaluate sha when we are still in the comparable branch
|
|
427
|
+
pass
|
|
428
|
+
if recorded_dsha is not None and current_stat is not None:
|
|
429
|
+
# Recompute the SAME canonical stat-hash the generator wrote
|
|
430
|
+
# (proof-generator._diff_sha256), NOT a hash of the patch text.
|
|
431
|
+
cur_dsha = _diff_sha256_from_stat(current_stat)
|
|
432
|
+
result["diff_recheck"]["current_diff_sha256"] = cur_dsha
|
|
433
|
+
result["diff_recheck"]["recorded_diff_sha256"] = recorded_dsha
|
|
434
|
+
if cur_dsha != recorded_dsha:
|
|
435
|
+
drift = True
|
|
436
|
+
|
|
437
|
+
if recorded_stat is not None:
|
|
438
|
+
result["diff_drift"] = drift
|
|
439
|
+
if drift and not result["reason"]:
|
|
440
|
+
result["reason"] = "recorded diff no longer matches the repo (drift detected)"
|
|
441
|
+
|
|
442
|
+
# ----- overall verdict -------------------------------------------------
|
|
443
|
+
result["ok"] = bool(
|
|
444
|
+
result["hash_ok"]
|
|
445
|
+
and result["diff_drift"] is False
|
|
446
|
+
and result["gpg_ok"] in (True, "n/a")
|
|
447
|
+
)
|
|
448
|
+
if result["ok"]:
|
|
449
|
+
result["reason"] = ""
|
|
450
|
+
elif not result["reason"]:
|
|
451
|
+
if result["gpg_ok"] is False:
|
|
452
|
+
result["reason"] = "gpg signature verification failed"
|
|
453
|
+
else:
|
|
454
|
+
result["reason"] = "verification failed"
|
|
455
|
+
return result
|
|
456
|
+
|
|
457
|
+
|
|
458
|
+
# ---------------------------------------------------------------------------
|
|
459
|
+
# CLI shim (mirrors dashboard/audit.py _unified_cli style)
|
|
460
|
+
# ---------------------------------------------------------------------------
|
|
461
|
+
|
|
462
|
+
def _cli(argv=None):
|
|
463
|
+
argv = list(sys.argv[1:] if argv is None else argv)
|
|
464
|
+
if not argv or argv[0] in ("-h", "--help"):
|
|
465
|
+
print(json.dumps(
|
|
466
|
+
{"error": "usage: proof-verify.py <proof.json> [repo_dir]"}))
|
|
467
|
+
return 2
|
|
468
|
+
proof_path = argv[0]
|
|
469
|
+
repo_dir = argv[1] if len(argv) > 1 else "."
|
|
470
|
+
try:
|
|
471
|
+
result = verify(proof_path, repo_dir)
|
|
472
|
+
except ProofLoadError as exc:
|
|
473
|
+
print(json.dumps({"ok": False, "error": str(exc)}))
|
|
474
|
+
return 2
|
|
475
|
+
except Exception as exc: # defensive: never a traceback-as-UX
|
|
476
|
+
print(json.dumps({"ok": False, "error": "verify failed: %s" % exc}))
|
|
477
|
+
return 2
|
|
478
|
+
print(json.dumps(result, indent=2))
|
|
479
|
+
return 0 if result.get("ok") else 1
|
|
480
|
+
|
|
481
|
+
|
|
482
|
+
if __name__ == "__main__":
|
|
483
|
+
sys.exit(_cli())
|
package/autonomy/loki
CHANGED
|
@@ -16495,7 +16495,9 @@ main() {
|
|
|
16495
16495
|
_deprecated_alias share "report share" "$@"
|
|
16496
16496
|
cmd_report share "$@"
|
|
16497
16497
|
;;
|
|
16498
|
-
proof)
|
|
16498
|
+
proof|receipt)
|
|
16499
|
+
# `loki receipt` is a friendly alias for `loki proof` (the Evidence
|
|
16500
|
+
# Receipt surface): same subcommands (list/show/verify/open/share).
|
|
16499
16501
|
cmd_proof "$@"
|
|
16500
16502
|
;;
|
|
16501
16503
|
bench)
|
|
@@ -30451,6 +30453,8 @@ cmd_proof() {
|
|
|
30451
30453
|
echo "Subcommands:"
|
|
30452
30454
|
echo " list List proof-of-run artifacts in .loki/proofs/"
|
|
30453
30455
|
echo " show <id> Pretty-print .loki/proofs/<id>/proof.json"
|
|
30456
|
+
echo " verify <id> Re-check a receipt against the repo (tamper + drift);"
|
|
30457
|
+
echo " exit 0 clean, 1 tamper/drift. Verify it yourself."
|
|
30454
30458
|
echo " open <id> Open .loki/proofs/<id>/index.html in a browser"
|
|
30455
30459
|
echo " share <id> Publish the proof page as a GitHub Gist (opt-in)"
|
|
30456
30460
|
echo ""
|
|
@@ -30526,6 +30530,33 @@ PYEOF
|
|
|
30526
30530
|
fi
|
|
30527
30531
|
exit 0
|
|
30528
30532
|
;;
|
|
30533
|
+
verify)
|
|
30534
|
+
# Deterministic re-check of a receipt against the repo: re-hashes the
|
|
30535
|
+
# canonical proof (tamper check) and re-derives the diff from the
|
|
30536
|
+
# recorded base_sha vs live HEAD (drift check). Exit 0 = clean, 1 =
|
|
30537
|
+
# tamper/drift, 2 = unusable input. This is the "verify it yourself"
|
|
30538
|
+
# path that makes the Evidence Receipt non-forgeable.
|
|
30539
|
+
local id="${1:-}"
|
|
30540
|
+
if [ -z "$id" ]; then
|
|
30541
|
+
echo -e "${RED}Missing proof id.${NC} Use 'loki proof list'."
|
|
30542
|
+
exit 2
|
|
30543
|
+
fi
|
|
30544
|
+
local pj="${proofs_dir}/${id}/proof.json"
|
|
30545
|
+
if [ ! -f "$pj" ]; then
|
|
30546
|
+
echo -e "${RED}Proof not found: ${id}${NC}"
|
|
30547
|
+
echo "Use 'loki proof list' to see available proofs."
|
|
30548
|
+
exit 1
|
|
30549
|
+
fi
|
|
30550
|
+
# The verifier ships beside the generator under autonomy/lib/ (same
|
|
30551
|
+
# dir resolution run.sh uses for proof-generator.py).
|
|
30552
|
+
local verifier="${_LOKI_SCRIPT_DIR}/lib/proof-verify.py"
|
|
30553
|
+
if [ ! -f "$verifier" ]; then
|
|
30554
|
+
echo -e "${RED}Verifier not found (autonomy/lib/proof-verify.py).${NC}"
|
|
30555
|
+
exit 2
|
|
30556
|
+
fi
|
|
30557
|
+
python3 "$verifier" "$pj" "${TARGET_DIR:-.}"
|
|
30558
|
+
exit $?
|
|
30559
|
+
;;
|
|
30529
30560
|
open)
|
|
30530
30561
|
local id="${1:-}"
|
|
30531
30562
|
if [ -z "$id" ]; then
|
package/autonomy/run.sh
CHANGED
|
@@ -7758,7 +7758,7 @@ sys.stdout.write(t.strip())
|
|
|
7758
7758
|
# non-blocking behavior for legitimate no-test projects.
|
|
7759
7759
|
touch "$quality_dir/unit-tests.pass"
|
|
7760
7760
|
cat > "$quality_dir/test-results.json" << TREOF
|
|
7761
|
-
{"timestamp":"$(date -u +%Y-%m-%dT%H:%M:%SZ)","runner":"none","pass":"inconclusive","summary":"No test runner detected"}
|
|
7761
|
+
{"timestamp":"$(date -u +%Y-%m-%dT%H:%M:%SZ)","runner":"none","pass":"inconclusive","summary":"No test runner detected","command":null,"exit_code":null,"status":"not_run","passed_count":null,"failed_count":null}
|
|
7762
7762
|
TREOF
|
|
7763
7763
|
# Finding #598: stamp the per-iteration freshness marker so a later
|
|
7764
7764
|
# completion-route capture (ensure_completion_test_evidence) reuses this
|
|
@@ -7771,8 +7771,32 @@ TREOF
|
|
|
7771
7771
|
# Sanitize details for JSON
|
|
7772
7772
|
details=$(echo "$details" | tr '"' "'" | tr '\n' ' ' | head -c 500)
|
|
7773
7773
|
|
|
7774
|
+
# Evidence Receipt provenance (v7.85.0): record the deterministic FACTS a
|
|
7775
|
+
# non-forgeable receipt needs -- the command that ran, its exit code, and a
|
|
7776
|
+
# status enum -- alongside the legacy pass/runner/min_coverage keys the
|
|
7777
|
+
# completion-council evidence gate reads (those are UNCHANGED for back-compat).
|
|
7778
|
+
# A receipt that says "tests passed" without the command+exit_code is exactly
|
|
7779
|
+
# the "trust me" transcript we are replacing. counts are best-effort parsed
|
|
7780
|
+
# from the runner summary; null (not 0) when unparseable, so "unknown" never
|
|
7781
|
+
# reads as "0 failures".
|
|
7782
|
+
local _tr_cmd _tr_exit _tr_status
|
|
7783
|
+
case "$test_runner" in
|
|
7784
|
+
pytest) _tr_cmd="pytest" ;;
|
|
7785
|
+
go-test) _tr_cmd="go test ./..." ;;
|
|
7786
|
+
cargo-test) _tr_cmd="cargo test" ;;
|
|
7787
|
+
npm-test|jest|vitest) _tr_cmd="$test_runner" ;;
|
|
7788
|
+
*) _tr_cmd="$test_runner" ;;
|
|
7789
|
+
esac
|
|
7790
|
+
if [ "$test_passed" = "true" ]; then _tr_exit=0; _tr_status="verified"; else _tr_exit=1; _tr_status="failed"; fi
|
|
7791
|
+
# Best-effort pass/fail counts from the summary text (null when not found).
|
|
7792
|
+
local _tr_passed_n _tr_failed_n
|
|
7793
|
+
_tr_passed_n=$(printf '%s' "$details" | grep -oE '[0-9]+ passed' | grep -oE '[0-9]+' | head -1)
|
|
7794
|
+
_tr_failed_n=$(printf '%s' "$details" | grep -oE '[0-9]+ failed' | grep -oE '[0-9]+' | head -1)
|
|
7795
|
+
[ -n "$_tr_passed_n" ] || _tr_passed_n=null
|
|
7796
|
+
[ -n "$_tr_failed_n" ] || _tr_failed_n=null
|
|
7797
|
+
|
|
7774
7798
|
cat > "$quality_dir/test-results.json" << TREOF
|
|
7775
|
-
{"timestamp":"$(date -u +%Y-%m-%dT%H:%M:%SZ)","runner":"$test_runner","pass":$test_passed,"min_coverage":$min_coverage,"summary":"$details"}
|
|
7799
|
+
{"timestamp":"$(date -u +%Y-%m-%dT%H:%M:%SZ)","runner":"$test_runner","pass":$test_passed,"min_coverage":$min_coverage,"summary":"$details","command":"$_tr_cmd","exit_code":$_tr_exit,"status":"$_tr_status","passed_count":$_tr_passed_n,"failed_count":$_tr_failed_n}
|
|
7776
7800
|
TREOF
|
|
7777
7801
|
# Finding #598: stamp the per-iteration freshness marker (see above).
|
|
7778
7802
|
printf '%s\n' "${ITERATION_COUNT:-0}" > "$quality_dir/.test-results.iter" 2>/dev/null || true
|
package/dashboard/__init__.py
CHANGED
package/docs/INSTALLATION.md
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
The flagship product of [Autonomi](https://www.autonomi.dev/). Loki Mode is a spec-driven autonomous builder with a built-in trust layer that takes any spec to a deployed product and verifies completion with evidence (quality gates plus a completion council), not just a "done" claim. Complete installation instructions for all platforms and use cases.
|
|
4
4
|
|
|
5
|
-
**Version:** v7.
|
|
5
|
+
**Version:** v7.85.0
|
|
6
6
|
|
|
7
7
|
---
|
|
8
8
|
|
|
@@ -395,7 +395,7 @@ provider works inside the container. Provide auth with your Anthropic API key:
|
|
|
395
395
|
# Run Loki Mode in Docker (Claude provider, API-key auth)
|
|
396
396
|
docker run --rm -e ANTHROPIC_API_KEY="$ANTHROPIC_API_KEY" \
|
|
397
397
|
-v $(pwd):/workspace -w /workspace \
|
|
398
|
-
asklokesh/loki-mode:7.
|
|
398
|
+
asklokesh/loki-mode:7.85.0 start ./my-spec.md
|
|
399
399
|
```
|
|
400
400
|
|
|
401
401
|
##### docker compose + .env (no host install)
|
package/loki-ts/dist/loki.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
// @bun
|
|
2
|
-
var QQ=Object.defineProperty;var ZQ=($)=>$;function zQ($,Q){this[$]=ZQ.bind(null,Q)}var b=($,Q)=>{for(var Z in Q)QQ($,Z,{get:Q[Z],enumerable:!0,configurable:!0,set:zQ.bind(Q,Z)})};var L=($,Q)=>()=>($&&(Q=$($=0)),Q);var q$=import.meta.require;var h1={};b(h1,{lokiDir:()=>P,homeLokiDir:()=>i$,findRepoRootForVersion:()=>t$,REPO_ROOT:()=>g});import{resolve as a,dirname as r$}from"path";import{fileURLToPath as XQ}from"url";import{existsSync as R$}from"fs";import{homedir as KQ}from"os";function qQ(){let $=b1;for(let Q=0;Q<6;Q++){if(R$(a($,"VERSION"))&&R$(a($,"autonomy/run.sh")))return $;let Z=r$($);if(Z===$)break;$=Z}return a(b1,"..","..","..")}function t$($){let Q=$;for(let Z=0;Z<6;Z++){if(R$(a(Q,"VERSION"))&&R$(a(Q,"autonomy/run.sh")))return Q;let z=r$(Q);if(z===Q)break;Q=z}return a($,"..","..","..")}function P(){return process.env.LOKI_DIR??a(process.cwd(),".loki")}function i$(){return a(KQ(),".loki")}var b1,g;var C=L(()=>{b1=r$(XQ(import.meta.url));g=qQ()});import{readFileSync as VQ}from"fs";import{resolve as JQ,dirname as UQ}from"path";import{fileURLToPath as WQ}from"url";function E$(){if(Q$!==null)return Q$;let $="7.
|
|
2
|
+
var QQ=Object.defineProperty;var ZQ=($)=>$;function zQ($,Q){this[$]=ZQ.bind(null,Q)}var b=($,Q)=>{for(var Z in Q)QQ($,Z,{get:Q[Z],enumerable:!0,configurable:!0,set:zQ.bind(Q,Z)})};var L=($,Q)=>()=>($&&(Q=$($=0)),Q);var q$=import.meta.require;var h1={};b(h1,{lokiDir:()=>P,homeLokiDir:()=>i$,findRepoRootForVersion:()=>t$,REPO_ROOT:()=>g});import{resolve as a,dirname as r$}from"path";import{fileURLToPath as XQ}from"url";import{existsSync as R$}from"fs";import{homedir as KQ}from"os";function qQ(){let $=b1;for(let Q=0;Q<6;Q++){if(R$(a($,"VERSION"))&&R$(a($,"autonomy/run.sh")))return $;let Z=r$($);if(Z===$)break;$=Z}return a(b1,"..","..","..")}function t$($){let Q=$;for(let Z=0;Z<6;Z++){if(R$(a(Q,"VERSION"))&&R$(a(Q,"autonomy/run.sh")))return Q;let z=r$(Q);if(z===Q)break;Q=z}return a($,"..","..","..")}function P(){return process.env.LOKI_DIR??a(process.cwd(),".loki")}function i$(){return a(KQ(),".loki")}var b1,g;var C=L(()=>{b1=r$(XQ(import.meta.url));g=qQ()});import{readFileSync as VQ}from"fs";import{resolve as JQ,dirname as UQ}from"path";import{fileURLToPath as WQ}from"url";function E$(){if(Q$!==null)return Q$;let $="7.85.0";if(typeof $==="string"&&$.length>0)return Q$=$,Q$;try{let Q=UQ(WQ(import.meta.url)),Z=t$(Q);Q$=VQ(JQ(Z,"VERSION"),"utf-8").trim()}catch{Q$="unknown"}return Q$}var Q$=null;var e$=L(()=>{C()});var g1={};b(g1,{runOrThrow:()=>HQ,run:()=>F,readStreamCapped:()=>m1,commandVersion:()=>BQ,commandExists:()=>f,ShellError:()=>$1,MAX_STDOUT_BYTES:()=>v1});async function m1($,Q=v1){let Z=$.getReader(),z=new TextDecoder,X="",q=0;try{while(q<Q){let{done:K,value:U}=await Z.read();if(K)break;if(!U)continue;if(q+=U.byteLength,q>Q){let J=U.byteLength-(q-Q);X+=z.decode(U.subarray(0,J),{stream:!0});break}X+=z.decode(U,{stream:!0})}X+=z.decode()}finally{try{await Z.cancel()}catch{}Z.releaseLock()}return X}async function F($,Q={}){let Z=Bun.spawn({cmd:[...$],stdout:"pipe",stderr:"pipe",env:Q.env?{...process.env,...Q.env}:process.env,cwd:Q.cwd}),z,X;if(Q.timeoutMs&&Q.timeoutMs>0)z=setTimeout(()=>{try{Z.kill("SIGTERM")}catch{}X=setTimeout(()=>{try{Z.kill("SIGKILL")}catch{}},2000)},Q.timeoutMs);try{let[q,K,U]=await Promise.all([m1(Z.stdout),new Response(Z.stderr).text(),Z.exited]);return{stdout:q,stderr:K,exitCode:U}}finally{if(z)clearTimeout(z);if(X)clearTimeout(X)}}async function HQ($,Q={}){let Z=await F($,Q);if(Z.exitCode!==0)throw new $1(`command failed (${Z.exitCode}): ${$.join(" ")}`,Z.exitCode,Z.stdout,Z.stderr);return Z}async function f($){let Q=GQ($),Z=await F(["sh","-c",`command -v ${Q}`],{timeoutMs:5000});if(Z.exitCode===0)return Z.stdout.trim()||null;return null}function GQ($){if(!/^[A-Za-z0-9._/-]+$/.test($))throw Error(`refused to shell-escape suspect token: ${$}`);return $}async function BQ($,Q="--version"){if(!await f($))return null;let z=await F([$,Q],{timeoutMs:5000});if(z.exitCode!==0)return null;return((z.stdout||z.stderr).split(/\r?\n/)[0]?.trim()??"")||null}var v1=16777216,$1;var d=L(()=>{$1=class $1 extends Error{message;exitCode;stdout;stderr;constructor($,Q,Z,z){super($);this.message=$;this.exitCode=Q;this.stdout=Z;this.stderr=z;this.name="ShellError"}}});function s($){return YQ?"":$}var YQ,O,S,_,_Z,I,k,h,V;var c=L(()=>{YQ=(process.env.NO_COLOR??"").length>0;O=s("\x1B[0;31m"),S=s("\x1B[0;32m"),_=s("\x1B[1;33m"),_Z=s("\x1B[0;34m"),I=s("\x1B[0;36m"),k=s("\x1B[1m"),h=s("\x1B[2m"),V=s("\x1B[0m")});import{existsSync as jQ}from"fs";async function Z$(){if(Y$!==void 0)return Y$;let $="/opt/homebrew/bin/python3.12";if(jQ($))return Y$=$,$;let Q=await f("python3.12");if(Q)return Y$=Q,Q;let Z=await f("python3");return Y$=Z,Z}async function z$($,Q={}){let Z=await Z$();if(!Z)return{stdout:"",stderr:"python3 not found",exitCode:127};return F([Z,"-c",$],Q)}var Y$;var V$=L(()=>{d()});var X0={};b(X0,{runStatus:()=>oQ});import{existsSync as y,readFileSync as U$,readdirSync as r1,statSync as t1}from"fs";import{resolve as D,basename as vQ}from"path";import{homedir as mQ}from"os";function i1($){let Q=Math.trunc($);if(Q>=1e6)return`${(Math.trunc(Q/1e6*10)/10).toFixed(1)}M`;if(Q>=1000)return`${(Math.trunc(Q/1000*10)/10).toFixed(1)}K`;return String(Q)}function e1($,Q,Z){if(Q===0)return null;let z=Math.trunc($*100/Q),X=Math.trunc($*N$/Q);if(X>N$)X=N$;let q=N$-X,K=S;if(z>=80)K=O;else if(z>=50)K=_;let U="=".repeat(Math.max(0,X))+" ".repeat(Math.max(0,q)),J=i1($),W=i1(Q);return` ${k}${Z}${V} ${K}[${U}]${V} ${z}% (${J} / ${W})`}async function fQ(){if(await f("jq"))return!0;return process.stdout.write(`${O}Error: jq is required but not installed.${V}
|
|
3
3
|
`),process.stdout.write(`Install with:
|
|
4
4
|
`),process.stdout.write(` brew install jq (macOS)
|
|
5
5
|
`),process.stdout.write(` apt install jq (Debian/Ubuntu)
|
|
@@ -796,4 +796,4 @@ Set LOKI_LEGACY_BASH=1 to force the bash CLI for every command.
|
|
|
796
796
|
`),2}default:return process.stderr.write(`Unknown command: ${Q}
|
|
797
797
|
`),process.stderr.write($Q),2}}s1();process.on("SIGINT",()=>process.exit(130));process.on("SIGTERM",()=>process.exit(143));var qZ=await KZ(Bun.argv.slice(2));process.exit(qZ);
|
|
798
798
|
|
|
799
|
-
//# debugId=
|
|
799
|
+
//# debugId=05CD23D774CD678C64756E2164756E21
|
package/mcp/__init__.py
CHANGED
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": "7.
|
|
4
|
+
"version": "7.85.0",
|
|
5
5
|
"description": "Loki Mode by Autonomi. Autonomous spec-to-product system: takes a PRD, GitHub issue, OpenAPI/JSON/YAML, or one-line brief to a deployed app via the RARV-C closure loop with 8 quality gates. Provider-agnostic (Claude Code, OpenAI Codex, Cline, Aider).",
|
|
6
6
|
"keywords": [
|
|
7
7
|
"agent",
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
"$schema": "https://json.schemastore.org/claude-code-plugin-manifest.json",
|
|
3
3
|
"name": "loki-mode",
|
|
4
4
|
"displayName": "Loki Mode",
|
|
5
|
-
"version": "7.
|
|
5
|
+
"version": "7.85.0",
|
|
6
6
|
"description": "Autonomous spec-to-product build system with a built-in trust layer (RARV-C closure loop, 8 quality gates, completion council). Ships Loki's spec-hardening, drift-detection, and deterministic PR verification commands plus the Loki MCP server.",
|
|
7
7
|
"author": {
|
|
8
8
|
"name": "Autonomi",
|