loki-mode 8.1.0 → 8.2.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.
@@ -97,8 +97,14 @@ _loki_prd_enrich_invoke() {
97
97
  # Decide whether enrichment should even be attempted. Returns 0 (attempt)
98
98
  # only when the active provider is claude and not in degraded mode.
99
99
  _loki_prd_enrich_provider_ok() {
100
- [ "${LOKI_PROVIDER:-claude}" = "claude" ] || return 1
101
100
  [ "${PROVIDER_DEGRADED:-false}" != "true" ] || return 1
101
+ # v8.2.0: capability, not identity. A provider exposing the timeout-able
102
+ # argv seam can run enrichment regardless of which CLI it is. Mirrors
103
+ # _loki_done_recog_provider_ok (autonomy/lib/done-recognition.sh).
104
+ if type provider_invoke_argv >/dev/null 2>&1; then
105
+ return 0
106
+ fi
107
+ [ "${LOKI_PROVIDER:-claude}" = "claude" ] || return 1
102
108
  # v8: the raw-SDK enrich path (LOKI_SDK_PRD_ENRICH=1) needs no claude binary,
103
109
  # so attempt is viable when that path is usable (bridge + bun). The invoke fn
104
110
  # still fails closed to claude on an SDK miss.
@@ -243,6 +243,70 @@ def _norm_gate_status(raw):
243
243
  return "inconclusive"
244
244
 
245
245
 
246
+ # TRUST-4: gate provenance. The discriminating property of a verification signal
247
+ # is NOT how many checks ran -- it is whether the checker sits OUTSIDE the agent's
248
+ # control. arXiv 2606.28438 shows AI-self-gates "look strong early but later lose
249
+ # their filtering effect", drifting into "a rubber-stamp regime where acceptance
250
+ # scores rise while benchmark correctness falls". arXiv 2607.05904 shows a judge
251
+ # conditioned on a candidate "scores plausibility, not correctness": self-play
252
+ # drove judge pass rate 0.72 -> 0.94 while TRUE accuracy stayed 0.20, and "a
253
+ # strict three-judge ensemble still accepts 55% of them". So a model-coupled gate
254
+ # is REPORTED but may never lift the headline.
255
+ #
256
+ # ADVISORY = the agent (or a model it prompts) authored the verdict. Everything
257
+ # else is EXOGENOUS: a deterministic script whose output the agent cannot write.
258
+ #
259
+ # Membership is keyed on the ADVISORY side only, and an UNKNOWN gate defaults to
260
+ # EXOGENOUS. That direction is load-bearing and must never be inverted: an
261
+ # unrecognized gate then still counts against VERIFIED (fail-closed). Defaulting
262
+ # unknown gates to advisory would let any newly-added or renamed gate silently
263
+ # lose its power to block -- the exact fake-green vector this split exists to
264
+ # close. Names are matched on a normalized key because run.sh emits BOTH
265
+ # spellings for the same gate (static-analysis / static_analysis, test-mutation /
266
+ # mutation_integrity), verified against run.sh's gate_failures writers.
267
+ _ADVISORY_GATES = frozenset((
268
+ # The agent writes both the test and the fix, so a green suite is a claim
269
+ # about its own work, not an independent measurement.
270
+ "test_coverage", "unit_tests", "test_suite", "semantic_tests", "tests",
271
+ # LLM-judgment gates: blind council, devil's advocate, magic-module debate.
272
+ "code_review", "devils_advocate", "devil_advocate", "magic_debate",
273
+ "council", "anti_sycophancy",
274
+ ))
275
+
276
+
277
+ def _gate_key(name):
278
+ """Normalize a gate name for provenance lookup.
279
+
280
+ run.sh emits the same gate under multiple spellings (`static-analysis` vs
281
+ `static_analysis`), and track_gate_failure appends `_PAUSED`/`_ESCALATED`
282
+ /`_not_run` suffixes. Fold all of them onto one key so classification cannot
283
+ be defeated by a cosmetic rename.
284
+ """
285
+ s = str(name or "").strip().lower().replace("-", "_")
286
+ s = re.sub(r"_(paused|escalated|not_run|blocked)$", "", s)
287
+ return s
288
+
289
+
290
+ def _gate_provenance(name):
291
+ """'advisory' for a model-authored gate, else 'exogenous' (fail-closed)."""
292
+ return "advisory" if _gate_key(name) in _ADVISORY_GATES else "exogenous"
293
+
294
+
295
+ def _is_exogenous(gate):
296
+ """Provenance of a collected gate dict, honoring the stamped value.
297
+
298
+ Reads the `provenance` key stamped by _collect_quality_gates so the
299
+ `unresolved` override (a gate that HALTED the run counts as an execution
300
+ fact) is respected. Falls back to name lookup for a gate dict that never
301
+ passed through the collector. Fail-closed: anything not positively
302
+ identified as advisory counts as exogenous.
303
+ """
304
+ stamped = gate.get("provenance")
305
+ if stamped:
306
+ return stamped == "exogenous"
307
+ return _gate_provenance(gate.get("name")) == "exogenous"
308
+
309
+
246
310
  def _collect_quality_gates(loki_dir):
247
311
  gates_raw = _read_json(
248
312
  os.path.join(loki_dir, "state", "quality-gates.json"), default=None
@@ -354,14 +418,48 @@ def _collect_quality_gates(loki_dir):
354
418
  for name in failed_names:
355
419
  if name in by_name:
356
420
  by_name[name]["status"] = "failed"
421
+ by_name[name]["unresolved"] = True
357
422
  else:
358
- gate = {"name": name, "status": "failed"}
423
+ gate = {"name": name, "status": "failed", "unresolved": True}
359
424
  gates.append(gate)
360
425
  by_name[name] = gate
361
426
 
427
+ # TRUST-4: stamp provenance on every gate at the single point the list is
428
+ # finalized, so every downstream reader (headline, template, verifier) sees
429
+ # the same classification and none can drift.
430
+ # An UNRESOLVED gate (listed in gate-failures.txt) is classified EXOGENOUS
431
+ # even when the gate itself is model-coupled. The fact being recorded is not
432
+ # "a judge disliked the code" -- it is "the run halted here and never
433
+ # cleared this blocker", which is an execution outcome the agent did not
434
+ # author. Without this, a run stopped dead by an unresolved code_review
435
+ # would emit a green receipt: the "cryptographically valid but semantically
436
+ # false green" the gate-failures merge above exists to prevent.
437
+ for gate in gates:
438
+ gate["provenance"] = (
439
+ "exogenous" if gate.get("unresolved")
440
+ else _gate_provenance(gate.get("name"))
441
+ )
442
+
362
443
  total = len(gates)
363
444
  passed = sum(1 for gate in gates if gate.get("status") == "passed")
364
- return {"passed": passed, "total": total, "gates": gates}
445
+ exo = [g for g in gates if g.get("provenance") == "exogenous"]
446
+ adv = [g for g in gates if g.get("provenance") == "advisory"]
447
+ return {
448
+ "passed": passed,
449
+ "total": total,
450
+ "gates": gates,
451
+ # Pre-split counts so the renderer never has to re-derive provenance.
452
+ "exogenous": {
453
+ "passed": sum(1 for g in exo if g.get("status") == "passed"),
454
+ "total": len(exo),
455
+ "gates": exo,
456
+ },
457
+ "advisory": {
458
+ "passed": sum(1 for g in adv if g.get("status") == "passed"),
459
+ "total": len(adv),
460
+ "gates": adv,
461
+ },
462
+ }
365
463
 
366
464
 
367
465
  def _collect_build(loki_dir):
@@ -1032,8 +1130,18 @@ def _build_proof(args, loki_dir, target_dir, repo_root):
1032
1130
  "execution": termination,
1033
1131
  "build": build,
1034
1132
  "tests": tests,
1133
+ # TRUST-4: carry `provenance` into the facts projection. _compute_headline
1134
+ # and _compute_degraded read THIS list, so dropping the field silently
1135
+ # sent them back to name-only lookup -- which mis-classified an
1136
+ # UNRESOLVED code_review (a run-halting execution fact) as advisory and
1137
+ # green-washed a blocked run.
1035
1138
  "quality_gates": [
1036
- {"name": g.get("name", ""), "status": g.get("status", "not_run")}
1139
+ {
1140
+ "name": g.get("name", ""),
1141
+ "status": g.get("status", "not_run"),
1142
+ "provenance": g.get("provenance")
1143
+ or _gate_provenance(g.get("name")),
1144
+ }
1037
1145
  for g in (quality_gates.get("gates") or [])
1038
1146
  ],
1039
1147
  "security": security,
@@ -1197,8 +1305,14 @@ def _compute_degraded(facts):
1197
1305
  else ("exit_code=%s" % build.get("exit_code"))
1198
1306
  out.append({"item": "build", "status": build.get("status"),
1199
1307
  "reason": reason})
1308
+ # TRUST-4: only EXOGENOUS gates enter the degraded ledger, because degraded[]
1309
+ # is an INPUT to the headline (a non-empty ledger blocks VERIFIED). Letting an
1310
+ # advisory gate in here would give a model-authored verdict the power to
1311
+ # downgrade the headline through the back door, which is exactly what this
1312
+ # split forbids. Advisory outcomes are still reported in full -- they render
1313
+ # from quality_gates.advisory, which the template shows verbatim.
1200
1314
  for g in facts.get("quality_gates") or []:
1201
- if g.get("status") in weak:
1315
+ if g.get("status") in weak and _is_exogenous(g):
1202
1316
  out.append({"item": "quality_gate:%s" % g.get("name", ""),
1203
1317
  "status": g.get("status"),
1204
1318
  "reason": "gate %s" % g.get("status")})
@@ -1289,12 +1403,21 @@ def _compute_headline(facts, degraded):
1289
1403
  and execution_outcome not in ("complete", "completed", "success")
1290
1404
  )
1291
1405
  )
1406
+ # TRUST-4: only an EXOGENOUS gate failure forces NOT VERIFIED. An advisory
1407
+ # (model-authored) gate is reported but cannot move the verdict in EITHER
1408
+ # direction -- see _ADVISORY_GATES for the research basis. Note the
1409
+ # asymmetry is deliberate and one-way: advisory results are barred from
1410
+ # UPGRADING a verdict (below, in any_verified), and barred from downgrading
1411
+ # one here, because a judge that scores plausibility is not a measurement.
1412
+ # tests.status keeps its own hard-fail check: it is the recorded suite
1413
+ # outcome (an exit code), not the model's opinion of the suite.
1292
1414
  any_failed = (
1293
1415
  execution_failed
1294
1416
  or tests.get("status") == "failed"
1295
1417
  or build.get("status") == "failed"
1296
1418
  or any(g.get("status") == "failed"
1297
- for g in (facts.get("quality_gates") or []))
1419
+ for g in (facts.get("quality_gates") or [])
1420
+ if _is_exogenous(g))
1298
1421
  or sec_high
1299
1422
  or fn_failed
1300
1423
  )
@@ -1315,11 +1438,15 @@ def _compute_headline(facts, degraded):
1315
1438
  # produced code emit "VERIFIED WITH GAPS" - a fake-green at the receipt. Only
1316
1439
  # a fact that actually ran and passed (tests/build verified, or a passed gate)
1317
1440
  # may qualify; otherwise the honest headline is NOT VERIFIED.
1441
+ # TRUST-4: an advisory PASS is not positive evidence. A run whose ONLY green
1442
+ # signals are a council vote and a devil's-advocate nod has proven nothing
1443
+ # deterministically, so it must not reach "VERIFIED WITH GAPS" on that basis.
1318
1444
  any_verified = (
1319
1445
  tests.get("status") == "verified"
1320
1446
  or build.get("status") == "verified"
1321
1447
  or any(g.get("status") == "passed"
1322
- for g in (facts.get("quality_gates") or []))
1448
+ for g in (facts.get("quality_gates") or [])
1449
+ if _is_exogenous(g))
1323
1450
  )
1324
1451
  if any_verified and degraded:
1325
1452
  return "VERIFIED WITH GAPS"
@@ -1481,6 +1608,19 @@ def _render_fallback_html(proof):
1481
1608
  ver = proof.get("verification", {})
1482
1609
  rows.append('<p class="hash">Integrity hash (%s): %s</p>' % (
1483
1610
  esc(ver.get("algo", "sha256")), esc(ver.get("hash", ""))))
1611
+ # Signing state, stated plainly. Mirrors renderProvenance in
1612
+ # proof-template.html (the primary renderer); this fallback path must not
1613
+ # be quieter about provenance than the page it stands in for.
1614
+ if ver.get("gpg_signature"):
1615
+ rows.append("<p>Signature: SIGNED (detached GPG over the canonical "
1616
+ "bytes). A verifier holding the signer public key can "
1617
+ "confirm provenance offline: loki proof verify &lt;id&gt;</p>")
1618
+ else:
1619
+ rows.append("<p>Signature: UNSIGNED. The integrity hash proves the "
1620
+ "bytes were not edited after hashing; it does NOT prove "
1621
+ "who produced them, so this receipt trusts its generator. "
1622
+ "To sign future receipts, set LOKI_PROOF_GPG_KEY to a gpg "
1623
+ "key id (see docs/SIGNED-RECEIPTS.md).</p>")
1484
1624
  red = proof.get("redaction", {})
1485
1625
  rows.append("<p>Redaction applied: %s (%s redactions, rules v%s)</p>" % (
1486
1626
  esc(red.get("applied")), esc(red.get("redactions_count")),
@@ -1117,6 +1117,24 @@
1117
1117
  card.innerHTML = (head ? '<div style="margin-bottom:14px;">' + head + "</div>" : "") + body + link;
1118
1118
  }
1119
1119
 
1120
+ // Render one row of gate chips. Shared by the exogenous and advisory blocks.
1121
+ function gateChips(gates) {
1122
+ var chips = "";
1123
+ if (!Array.isArray(gates)) return "";
1124
+ for (var i = 0; i < gates.length; i++) {
1125
+ var st = String(g(gates[i], "status", "")).toLowerCase();
1126
+ var cls = (st === "pass" || st === "passed") ? "pass" : (st === "skip" || st === "skipped") ? "skip" : "fail";
1127
+ var mark = cls === "pass" ? "OK" : cls === "skip" ? "-" : "X";
1128
+ chips += '<span class="gate ' + cls + '"><span class="mk">' + mark + "</span>" +
1129
+ esc(g(gates[i], "name", "gate")) + "</span>";
1130
+ }
1131
+ return chips ? '<div class="gates">' + chips + "</div>" : "";
1132
+ }
1133
+
1134
+ // TRUST-4: quality gates split by PROVENANCE, not by outcome. The two blocks
1135
+ // read from quality_gates.exogenous / quality_gates.advisory, the exact field
1136
+ // names proof-generator.py writes. Falls back to the flat pre-split list so an
1137
+ // older receipt still renders.
1120
1138
  function renderGates(p) {
1121
1139
  var sec = document.getElementById("secGates");
1122
1140
  var card = document.getElementById("gatesCard");
@@ -1127,18 +1145,34 @@
1127
1145
  sec.classList.add("hide");
1128
1146
  return;
1129
1147
  }
1130
- var head = '<p class="note" style="margin:0 0 12px;">' + passed + " of " + total + " gates passed.</p>";
1131
- var chips = "";
1132
- if (Array.isArray(gates)) {
1133
- for (var i = 0; i < gates.length; i++) {
1134
- var st = String(g(gates[i], "status", "")).toLowerCase();
1135
- var cls = (st === "pass" || st === "passed") ? "pass" : (st === "skip" || st === "skipped") ? "skip" : "fail";
1136
- var mark = cls === "pass" ? "OK" : cls === "skip" ? "-" : "X";
1137
- chips += '<span class="gate ' + cls + '"><span class="mk">' + mark + "</span>" +
1138
- esc(g(gates[i], "name", "gate")) + "</span>";
1139
- }
1148
+ var exo = g(p, "quality_gates.exogenous", null);
1149
+ var adv = g(p, "quality_gates.advisory", null);
1150
+ if (!exo && !adv) {
1151
+ // Pre-TRUST-4 receipt: render the flat list exactly as before.
1152
+ card.innerHTML = '<p class="note" style="margin:0 0 12px;">' + passed +
1153
+ " of " + total + " gates passed.</p>" + gateChips(gates);
1154
+ return;
1155
+ }
1156
+ var html = "";
1157
+ var eg = g(exo, "gates", []);
1158
+ if (Array.isArray(eg) && eg.length > 0) {
1159
+ html += '<h3 style="margin:0 0 4px;font-size:14px;">Independent checks (count toward the verdict)</h3>' +
1160
+ '<p class="note" style="margin:0 0 10px;">' +
1161
+ num(g(exo, "passed", 0)) + " of " + num(g(exo, "total", 0)) +
1162
+ " passed. These are deterministic programs the agent cannot write the " +
1163
+ "result of, so only these set the verdict above.</p>" + gateChips(eg);
1164
+ }
1165
+ var ag = g(adv, "gates", []);
1166
+ if (Array.isArray(ag) && ag.length > 0) {
1167
+ html += '<h3 style="margin:18px 0 4px;font-size:14px;">Advisory checks (reported, not counted)</h3>' +
1168
+ '<p class="opinion-note"><span aria-hidden="true">!</span>' +
1169
+ num(g(adv, "passed", 0)) + " of " + num(g(adv, "total", 0)) +
1170
+ " passed. These are the agent judging its own work (it wrote both the " +
1171
+ "tests and the code, or a model graded the result), so a model that is " +
1172
+ "confidently wrong scores itself green here. They are shown for context " +
1173
+ "and can never raise or lower the verdict.</p>" + gateChips(ag);
1140
1174
  }
1141
- card.innerHTML = head + (chips ? '<div class="gates">' + chips + "</div>" : "");
1175
+ card.innerHTML = html;
1142
1176
  }
1143
1177
 
1144
1178
  function renderFlagged(p) {
@@ -1204,9 +1238,23 @@
1204
1238
  if (hash) {
1205
1239
  kv += '<div class="k">Integrity hash</div><div class="v">' + esc(algo) + ":" + esc(hash) + "</div>";
1206
1240
  }
1241
+ // Signing state, named explicitly. The hash-note below already says the
1242
+ // hash does not prove provenance; without this row a reader is told what
1243
+ // the receipt CANNOT do but never which state this particular receipt is
1244
+ // in, nor how to change it. Signing is gated on LOKI_PROOF_GPG_KEY in
1245
+ // proof-generator.py and is OFF by default -- deliberately, because a
1246
+ // self-minted key would flip proof-verify.py's generator_trusted to false
1247
+ // while the key came from the very process under audit.
1248
+ var signed = !!g(p, "verification.gpg_signature", "");
1249
+ kv += '<div class="k">Signature</div><div class="v">' +
1250
+ (signed ? "SIGNED (detached GPG)" : "UNSIGNED") + "</div>";
1251
+ var signNote = signed
1252
+ ? '<p class="hash-note">This receipt carries a detached GPG signature over the same canonical bytes that were hashed. A verifier holding the signer public key can confirm its provenance offline with <code>loki proof verify</code>.</p>'
1253
+ : '<p class="hash-note">This receipt is UNSIGNED, so it trusts the generator that produced it (<code>loki proof verify</code> reports <code>generator_trusted: true</code>). To sign future receipts, set <code>LOKI_PROOF_GPG_KEY</code> to a GPG key id before the run.</p>';
1207
1254
  card.innerHTML = '<div class="kv">' + kv + "</div>" +
1208
1255
  (hash ? '<p class="hash-note">The integrity hash is a ' + esc(algo) +
1209
- " digest of this proof. It proves the artifact has not been altered after it was emitted. It does not, by itself, prove who or what produced the work.</p>" : "");
1256
+ " digest of this proof. It proves the artifact has not been altered after it was emitted. It does not, by itself, prove who or what produced the work.</p>" : "") +
1257
+ signNote;
1210
1258
  }
1211
1259
 
1212
1260
  function renderCta(p) {
@@ -65,6 +65,7 @@ CLI:
65
65
  import hashlib
66
66
  import json
67
67
  import os
68
+ import re
68
69
  import subprocess
69
70
  import sys
70
71
 
@@ -176,6 +177,57 @@ def _to_int(v, default=0):
176
177
  # headline re-derivation (MUST match proof-generator._compute_headline exactly)
177
178
  # ---------------------------------------------------------------------------
178
179
 
180
+ # DRIFT-GUARD: mirrored from proof-generator.py. A gate the AGENT authored the
181
+ # input to (it writes both the test and the fix) or that is an LLM judgment
182
+ # cannot be independent evidence, so it must never force or lift a headline.
183
+ # Keyed on the ADVISORY set so an unknown or renamed gate defaults to EXOGENOUS
184
+ # and keeps its power to block -- fail-closed, never fail-open.
185
+ #
186
+ # This block exists because the generator gained a provenance split while this
187
+ # file did not, and the two then disagreed about what a receipt meant. The
188
+ # verifier is the INDEPENDENT re-derivation users are told to trust; if it
189
+ # cannot reproduce the generator's headline, `loki proof verify` reports drift
190
+ # on a receipt that was never tampered with -- a false alarm from our own trust
191
+ # artifact, which is worse than no verifier at all.
192
+ _ADVISORY_GATES = frozenset((
193
+ "test_coverage", "unit_tests", "test_suite", "semantic_tests", "tests",
194
+ "code_review", "devils_advocate", "devil_advocate", "magic_debate",
195
+ "council", "anti_sycophancy",
196
+ ))
197
+
198
+
199
+ def _gate_key(name):
200
+ """Normalize a gate name for provenance lookup (mirrors the generator).
201
+
202
+ run.sh emits the same gate under multiple spellings (`static-analysis` vs
203
+ `static_analysis`) and track_gate_failure appends `_PAUSED`/`_ESCALATED`,
204
+ so an exact-match lookup would misfile real gates.
205
+ """
206
+ s = str(name or "").strip().lower().replace("-", "_")
207
+ return re.sub(r"_(paused|escalated|not_run|blocked)$", "", s)
208
+
209
+
210
+ def _gate_provenance(name):
211
+ """'advisory' for a model-authored gate, else 'exogenous' (fail-closed)."""
212
+ return "advisory" if _gate_key(name) in _ADVISORY_GATES else "exogenous"
213
+
214
+
215
+ def _is_exogenous(gate):
216
+ """Provenance of a gate dict, honoring a stamped value when present.
217
+
218
+ The generator stamps `provenance` so its `unresolved` override survives (a
219
+ gate that HALTED the run is an execution fact, not a model opinion). We
220
+ honor that stamp and fall back to name lookup for older receipts written
221
+ before the split existed.
222
+ """
223
+ if not isinstance(gate, dict):
224
+ return True
225
+ stamped = gate.get("provenance")
226
+ if stamped:
227
+ return stamped == "exogenous"
228
+ return _gate_provenance(gate.get("name")) == "exogenous"
229
+
230
+
179
231
  def _compute_headline(facts, degraded):
180
232
  """Deterministic headline re-derived from the recorded facts.
181
233