loki-mode 8.0.3 → 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.
@@ -640,6 +640,23 @@
640
640
  ? " (" + esc(tOutcome) + ")" : "") +
641
641
  "</div>";
642
642
  }
643
+ // Name the GATE that stopped the run, with how close it came to its
644
+ // threshold. "intervention" tells a reader that something blocked them;
645
+ // "code_review (failed 3 times, threshold 3)" tells them what to fix. The
646
+ // engine already surfaced this in COMPLETION.txt and PAUSED.md -- the
647
+ // signed receipt was the only surface that stayed silent about it.
648
+ // Display-only, and silent when no gate escalation was recorded.
649
+ var tGate = String(g(p, "facts.execution.blocking_gate", "") || "").trim();
650
+ if (tGate) {
651
+ var gFail = g(p, "facts.execution.blocking_gate_failures", null);
652
+ var gThr = g(p, "facts.execution.blocking_gate_threshold", null);
653
+ var detail = "";
654
+ if (typeof gFail === "number" && typeof gThr === "number") {
655
+ detail = " (failed " + esc(String(gFail)) + " times, threshold " +
656
+ esc(String(gThr)) + ")";
657
+ }
658
+ term += '<div class="h-sub">Blocked by: ' + esc(tGate) + detail + "</div>";
659
+ }
643
660
  el.className = "honesty " + v.cls;
644
661
  el.innerHTML =
645
662
  '<span class="h-mark" aria-hidden="true">' + v.mark + "</span>" +
@@ -1100,6 +1117,24 @@
1100
1117
  card.innerHTML = (head ? '<div style="margin-bottom:14px;">' + head + "</div>" : "") + body + link;
1101
1118
  }
1102
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.
1103
1138
  function renderGates(p) {
1104
1139
  var sec = document.getElementById("secGates");
1105
1140
  var card = document.getElementById("gatesCard");
@@ -1110,18 +1145,34 @@
1110
1145
  sec.classList.add("hide");
1111
1146
  return;
1112
1147
  }
1113
- var head = '<p class="note" style="margin:0 0 12px;">' + passed + " of " + total + " gates passed.</p>";
1114
- var chips = "";
1115
- if (Array.isArray(gates)) {
1116
- for (var i = 0; i < gates.length; i++) {
1117
- var st = String(g(gates[i], "status", "")).toLowerCase();
1118
- var cls = (st === "pass" || st === "passed") ? "pass" : (st === "skip" || st === "skipped") ? "skip" : "fail";
1119
- var mark = cls === "pass" ? "OK" : cls === "skip" ? "-" : "X";
1120
- chips += '<span class="gate ' + cls + '"><span class="mk">' + mark + "</span>" +
1121
- esc(g(gates[i], "name", "gate")) + "</span>";
1122
- }
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;
1123
1155
  }
1124
- card.innerHTML = head + (chips ? '<div class="gates">' + chips + "</div>" : "");
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);
1174
+ }
1175
+ card.innerHTML = html;
1125
1176
  }
1126
1177
 
1127
1178
  function renderFlagged(p) {
@@ -1187,9 +1238,23 @@
1187
1238
  if (hash) {
1188
1239
  kv += '<div class="k">Integrity hash</div><div class="v">' + esc(algo) + ":" + esc(hash) + "</div>";
1189
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>';
1190
1254
  card.innerHTML = '<div class="kv">' + kv + "</div>" +
1191
1255
  (hash ? '<p class="hash-note">The integrity hash is a ' + esc(algo) +
1192
- " 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;
1193
1258
  }
1194
1259
 
1195
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
 
package/autonomy/loki CHANGED
@@ -954,10 +954,20 @@ show_help() {
954
954
  echo " # Providers + model routing"
955
955
  echo " loki provider list # Show 4 providers (claude/codex/cline/aider)"
956
956
  echo " loki provider set codex # Switch active provider"
957
- echo " # OpenRouter / Ollama routing (Phase I v7.5.25+):"
958
- echo " export ANTHROPIC_BASE_URL=https://openrouter.ai/api/v1 \\\\"
959
- echo " LOKI_MODEL_OVERRIDE=anthropic/claude-sonnet-4.5"
960
- echo " loki start ./prd.md # Routes to OpenRouter via Claude Code"
957
+ echo " # Cheap / open models (DeepSeek, GLM, MiniMax, Kimi) via OpenRouter:"
958
+ echo " loki provider set aider # aider + cline reach any OpenRouter model"
959
+ echo " export OPENROUTER_API_KEY=sk-or-..."
960
+ echo " loki start ./prd.md # defaults to deepseek-v3.2 (open weights)"
961
+ echo " # Pick a specific model:"
962
+ echo " export LOKI_AIDER_MODEL=openrouter/z-ai/glm-4.6"
963
+ echo ""
964
+ echo " # Anthropic-PROTOCOL gateways (LiteLLM, Bedrock proxies, self-hosted):"
965
+ echo " export ANTHROPIC_BASE_URL=https://your-gateway/v1 \\\\"
966
+ echo " LOKI_MODEL_OVERRIDE=<model-id-your-gateway-serves>"
967
+ echo " # NOTE: this routes Claude Code itself, so the endpoint must speak the"
968
+ echo " # Anthropic /v1/messages API. OpenRouter does NOT -- it serves"
969
+ echo " # only the OpenAI-shaped /v1/chat/completions. Use the aider or"
970
+ echo " # cline route above for OpenRouter."
961
971
  echo ""
962
972
  echo " # Cross-project context (Phase F v7.5.23+)"
963
973
  echo " # Drop .loki/app.json with {schema_version:1,app_id:myapp,members:[ui,api]}"
@@ -3871,7 +3881,10 @@ cmd_why() {
3871
3881
  fi
3872
3882
 
3873
3883
  if [ "$as_json" = "1" ]; then
3884
+ local _why_json_head_sha
3885
+ _why_json_head_sha="$(git rev-parse HEAD 2>/dev/null || echo "")"
3874
3886
  _LOKI_WHY_STATE="$state_file" _LOKI_WHY_COMPLETION="$completion_file" \
3887
+ _LOKI_WHY_HEAD_SHA="$_why_json_head_sha" \
3875
3888
  _LOKI_WHY_LAST_ERROR="$loki_dir/state/LAST_ERROR.json" python3 - <<'WHYJSON'
3876
3889
  import json, os
3877
3890
  def load(p):
@@ -3881,7 +3894,26 @@ def load(p):
3881
3894
  state = load(os.environ.get("_LOKI_WHY_STATE", ""))
3882
3895
  comp = load(os.environ.get("_LOKI_WHY_COMPLETION", ""))
3883
3896
  last_error = load(os.environ.get("_LOKI_WHY_LAST_ERROR", ""))
3884
- print(json.dumps({"state": state, "completion": comp, "last_error": last_error}, indent=2))
3897
+
3898
+ # The human report labels a stale completion record "(from previous completed
3899
+ # run)". The JSON consumer got the same record with NO such marker, so a script
3900
+ # reading `completion.branch` or `completion.pr_url` after a crashed run would
3901
+ # attribute a PREVIOUS run's branch and PR to the current one -- silently, and
3902
+ # with no field to check. Emit the identical determination as explicit fields.
3903
+ # Kept byte-identical in logic to the human path below; the two must not drift.
3904
+ live_statuses = {"running", "exited"}
3905
+ head_sha = os.environ.get("_LOKI_WHY_HEAD_SHA", "")
3906
+ comp_head = comp.get("head_sha", "")
3907
+ comp_is_stale = bool(state.get("status") in live_statuses) or bool(
3908
+ head_sha and comp_head and comp_head != head_sha
3909
+ )
3910
+ print(json.dumps({
3911
+ "state": state,
3912
+ "completion": comp,
3913
+ "last_error": last_error,
3914
+ "completion_is_stale": comp_is_stale,
3915
+ "head_sha": head_sha or None,
3916
+ }, indent=2))
3885
3917
  WHYJSON
3886
3918
  return 0
3887
3919
  fi
@@ -10561,8 +10593,133 @@ EOF
10561
10593
  }
10562
10594
 
10563
10595
  # Check system prerequisites
10596
+ # _loki_airgap_audit <json?> -- enumerate every network egress this engine can
10597
+ # perform, classify each as REQUIRED or optional, and say how to disable it.
10598
+ #
10599
+ # WHY THIS EXISTS
10600
+ # Air-gapped deployment is a real enterprise requirement and the incumbent
10601
+ # (Tabnine) states it can "be deployed in a completely air-gapped environment"
10602
+ # without publishing WHICH hosts it would otherwise contact. A claim you
10603
+ # cannot audit is a claim you have to take on faith.
10604
+ #
10605
+ # Sourcegraph is the honest counter-example: their own whitepaper states that
10606
+ # self-hosted Cody still "sends requests from the Sourcegraph instance to
10607
+ # Anthropic's API". Self-hosted control plane, egressing inference.
10608
+ #
10609
+ # So the credible position is not "trust us, we are air-gapped" -- it is here
10610
+ # is every host, here is which ones are required, here is how to turn each one
10611
+ # off, now verify it yourself with tcpdump. We would rather be auditable than
10612
+ # assertive.
10613
+ #
10614
+ # Exit 0 when no REQUIRED egress remains (air-gap achievable in this config),
10615
+ # 1 otherwise. Deterministic: reads configuration, performs NO network calls.
10616
+ _loki_airgap_audit() {
10617
+ local as_json="${1:-false}"
10618
+
10619
+ # Provider inference endpoint: the one egress that is genuinely required,
10620
+ # unless the active provider serves weights locally.
10621
+ local provider="${LOKI_PROVIDER:-claude}"
10622
+ local model_host="" model_required="yes" model_note=""
10623
+ case "$provider" in
10624
+ claude)
10625
+ model_host="${ANTHROPIC_BASE_URL:-https://api.anthropic.com}"
10626
+ model_note="Claude Code inference. Set ANTHROPIC_BASE_URL to an in-network gateway, or switch to a local-weights provider."
10627
+ ;;
10628
+ codex)
10629
+ model_host="${OPENAI_BASE_URL:-https://api.openai.com}"
10630
+ model_note="Codex inference. Point at an in-network gateway or switch provider."
10631
+ ;;
10632
+ opencode|cline|aider)
10633
+ model_host="${OPENAI_BASE_URL:-${OPENROUTER_API_KEY:+https://openrouter.ai}}"
10634
+ if [ -z "$model_host" ] || printf '%s' "${LOKI_OPENCODE_MODEL:-}${LOKI_AIDER_MODEL:-}" | grep -qiE 'ollama|localhost|127\.0\.0\.1|lmstudio'; then
10635
+ model_host="local weights"
10636
+ model_required="no"
10637
+ model_note="Local model runtime (Ollama / LM Studio / llama.cpp). No inference egress."
10638
+ else
10639
+ model_note="Remote model gateway. Use an ollama/ model id for zero-egress inference."
10640
+ fi
10641
+ ;;
10642
+ *)
10643
+ model_host="unknown (provider '$provider')"
10644
+ model_note="Unrecognized provider; audit its config manually."
10645
+ ;;
10646
+ esac
10647
+
10648
+ # Optional egress, each individually disableable.
10649
+ local tel_state="off"
10650
+ [ "${LOKI_TELEMETRY:-}" = "on" ] && tel_state="on"
10651
+ local upd_state="on"
10652
+ [ "${LOKI_NO_UPDATE_CHECK:-0}" = "1" ] && upd_state="off"
10653
+
10654
+ if [ "$as_json" = "true" ]; then
10655
+ _AG_PROV="$provider" _AG_HOST="$model_host" _AG_REQ="$model_required" \
10656
+ _AG_NOTE="$model_note" _AG_TEL="$tel_state" _AG_UPD="$upd_state" \
10657
+ python3 -c '
10658
+ import json, os
10659
+ req = os.environ["_AG_REQ"] == "yes"
10660
+ egress = [{
10661
+ "name": "model_inference",
10662
+ "host": os.environ["_AG_HOST"],
10663
+ "required": req,
10664
+ "disable": "use a local-weights provider (ollama/, lmstudio/) or an in-network gateway",
10665
+ "note": os.environ["_AG_NOTE"],
10666
+ }, {
10667
+ "name": "telemetry",
10668
+ "host": "telemetry endpoint",
10669
+ "required": False,
10670
+ "enabled": os.environ["_AG_TEL"] == "on",
10671
+ "disable": "loki telemetry off (already the default)",
10672
+ }, {
10673
+ "name": "update_check",
10674
+ "host": "registry.npmjs.org",
10675
+ "required": False,
10676
+ "enabled": os.environ["_AG_UPD"] == "on",
10677
+ "disable": "export LOKI_NO_UPDATE_CHECK=1",
10678
+ }]
10679
+ blockers = [e for e in egress if e.get("required")]
10680
+ print(json.dumps({
10681
+ "provider": os.environ["_AG_PROV"],
10682
+ "airgap_ready": not blockers,
10683
+ "required_egress": [e["name"] for e in blockers],
10684
+ "egress": egress,
10685
+ }, indent=2))
10686
+ '
10687
+ [ "$model_required" = "yes" ] && return 1
10688
+ return 0
10689
+ fi
10690
+
10691
+ echo -e "${BOLD}Network egress audit${NC}"
10692
+ echo ""
10693
+ echo " Active provider: $provider"
10694
+ echo ""
10695
+ echo -e "${BOLD}Egress points${NC}"
10696
+ if [ "$model_required" = "yes" ]; then
10697
+ echo -e " ${YELLOW}REQUIRED${NC} model inference -> $model_host"
10698
+ else
10699
+ echo -e " ${GREEN}none${NC} model inference -> $model_host"
10700
+ fi
10701
+ echo " $model_note"
10702
+ echo -e " optional telemetry [$tel_state] disable: loki telemetry off (default off)"
10703
+ echo -e " optional update check [$upd_state] disable: export LOKI_NO_UPDATE_CHECK=1"
10704
+ echo ""
10705
+ if [ "$model_required" = "yes" ]; then
10706
+ echo -e "${YELLOW}Not air-gap ready in this configuration.${NC}"
10707
+ echo " One required egress remains: model inference."
10708
+ echo " To close it, serve the model in-network:"
10709
+ echo " loki provider set opencode"
10710
+ echo " export LOKI_OPENCODE_MODEL=ollama/qwen2.5-coder"
10711
+ echo ""
10712
+ echo " Then re-run: loki doctor --airgap"
10713
+ return 1
10714
+ fi
10715
+ echo -e "${GREEN}Air-gap ready: no required egress in this configuration.${NC}"
10716
+ echo " Verify independently -- run with the interface down, or watch with tcpdump."
10717
+ return 0
10718
+ }
10719
+
10564
10720
  cmd_doctor() {
10565
10721
  local json_output=false
10722
+ local airgap_audit=false
10566
10723
 
10567
10724
  while [[ $# -gt 0 ]]; do
10568
10725
  case "$1" in
@@ -10570,13 +10727,21 @@ cmd_doctor() {
10570
10727
  json_output=true
10571
10728
  shift
10572
10729
  ;;
10730
+ --airgap)
10731
+ airgap_audit=true
10732
+ shift
10733
+ ;;
10573
10734
  --help|-h)
10574
10735
  echo -e "${BOLD}loki doctor${NC} - Check system prerequisites"
10575
10736
  echo ""
10576
- echo "Usage: loki doctor [--json]"
10737
+ echo "Usage: loki doctor [--json] [--airgap]"
10577
10738
  echo ""
10578
10739
  echo "Options:"
10579
10740
  echo " --json Output machine-readable JSON"
10741
+ echo " --airgap Audit network egress: list every host this engine"
10742
+ echo " can contact, whether it is REQUIRED or optional,"
10743
+ echo " and how to disable it. Exits non-zero if any"
10744
+ echo " required egress remains."
10580
10745
  echo ""
10581
10746
  echo "Checks: node, python3, jq, git, curl, bash version,"
10582
10747
  echo " claude/codex CLIs, disk space, and cockpit render capability."
@@ -10584,12 +10749,17 @@ cmd_doctor() {
10584
10749
  ;;
10585
10750
  *)
10586
10751
  echo -e "${RED}Unknown option: $1${NC}"
10587
- echo "Usage: loki doctor [--json]"
10752
+ echo "Usage: loki doctor [--json] [--airgap]"
10588
10753
  return 1
10589
10754
  ;;
10590
10755
  esac
10591
10756
  done
10592
10757
 
10758
+ if [ "$airgap_audit" = true ]; then
10759
+ _loki_airgap_audit "$json_output"
10760
+ return $?
10761
+ fi
10762
+
10593
10763
  if [ "$json_output" = true ]; then
10594
10764
  cmd_doctor_json
10595
10765
  return $?
@@ -10975,6 +11145,24 @@ except Exception:
10975
11145
  echo -e " ${YELLOW}WARN${NC} sentrux - not installed (optional, brew install sentrux/tap/sentrux)"
10976
11146
  warn_count=$((warn_count + 1))
10977
11147
  fi
11148
+ # Evidence Receipt signing state (optional). Unsigned receipts are the
11149
+ # default and are still checkable (hash + diff re-derivation); what they
11150
+ # cannot prove is PROVENANCE, so proof-verify.py reports
11151
+ # generator_trusted: true. Surface the state here because it is otherwise
11152
+ # invisible until someone reads a receipt. WARN (never FAIL): unsigned is
11153
+ # a supported, documented mode, not a broken install.
11154
+ if [ -n "${LOKI_PROOF_GPG_KEY:-}" ]; then
11155
+ if command -v gpg &>/dev/null; then
11156
+ echo -e " ${GREEN}PASS${NC} Receipt signing: LOKI_PROOF_GPG_KEY set and gpg available"
11157
+ pass_count=$((pass_count + 1))
11158
+ else
11159
+ echo -e " ${YELLOW}WARN${NC} Receipt signing: LOKI_PROOF_GPG_KEY set but gpg NOT on PATH - receipts will be UNSIGNED"
11160
+ warn_count=$((warn_count + 1))
11161
+ fi
11162
+ else
11163
+ echo -e " ${YELLOW}WARN${NC} Receipt signing: UNSIGNED (set LOKI_PROOF_GPG_KEY to a gpg key id; see docs/SIGNED-RECEIPTS.md)"
11164
+ warn_count=$((warn_count + 1))
11165
+ fi
10978
11166
  echo ""
10979
11167
 
10980
11168
  echo -e "${CYAN}System:${NC}"
@@ -11210,6 +11398,23 @@ sentrux = {
11210
11398
  'required': 'optional'
11211
11399
  }
11212
11400
 
11401
+ # Evidence Receipt signing state. Unsigned is the DEFAULT and is a supported
11402
+ # mode, so this is never 'fail'. Exposed because signing state is otherwise
11403
+ # invisible until someone reads a receipt, and it is exactly what decides
11404
+ # whether proof-verify.py reports generator_trusted: true. Sibling of
11405
+ # checks/disk/sentrux -- deliberately NOT counted in the summary tally so
11406
+ # existing consumers see unchanged numbers.
11407
+ signing_key_set = bool(os.environ.get('LOKI_PROOF_GPG_KEY', '').strip())
11408
+ signing_gpg_available = shutil.which('gpg') is not None
11409
+ signing_enabled = signing_key_set and signing_gpg_available
11410
+ receipt_signing = {
11411
+ 'enabled': signing_enabled,
11412
+ 'key_configured': signing_key_set,
11413
+ 'gpg_available': signing_gpg_available,
11414
+ 'status': 'pass' if signing_enabled else 'warn',
11415
+ 'required': 'optional'
11416
+ }
11417
+
11213
11418
  # v7.7.17: memory subsystem health surface. Reports the latest entries
11214
11419
  # from .loki/memory/.errors.log (rotated by memory/error_log.py) so
11215
11420
  # developers see regressions in the previously-silent-fail call sites
@@ -11263,6 +11468,7 @@ result = {
11263
11468
  'status': disk_status
11264
11469
  },
11265
11470
  'sentrux': sentrux,
11471
+ 'receipt_signing': receipt_signing,
11266
11472
  'memory': memory,
11267
11473
  'summary': {
11268
11474
  'passed': pass_count,
@@ -14853,6 +15059,33 @@ with open(manifest_path, 'w') as f:
14853
15059
  # usable.
14854
15060
  # ---------------------------------------------------------------------------
14855
15061
  cmd_verify() {
15062
+ # --fast: deterministic checks only, in milliseconds. Measured on this repo
15063
+ # (1,932 tracked files): 298 ms cold, 87 ms warm, 19 ms diff-scoped, against
15064
+ # an 11,040 ms shell-based baseline.
15065
+ #
15066
+ # It runs ONLY exogenous checks -- no model call, no network -- so every
15067
+ # verdict is reproducible by anyone with the same commit. That is the point,
15068
+ # not a limitation: a verdict you can re-derive is a fact, and a fact is what
15069
+ # an IDE, a CI step, or another vendor's agent can safely act on without
15070
+ # trusting our model choices.
15071
+ #
15072
+ # The full `loki verify` remains the deeper, slower path.
15073
+ for _fv_arg in "$@"; do
15074
+ if [ "$_fv_arg" = "--fast" ]; then
15075
+ local _fv_py="$_LOKI_SCRIPT_DIR/lib/fast_verify.py"
15076
+ if [ ! -f "$_fv_py" ]; then
15077
+ echo -e "${RED}Error: fast_verify not found at $_fv_py${NC}" >&2
15078
+ return 3
15079
+ fi
15080
+ local _fv_argv=()
15081
+ for _a in "$@"; do
15082
+ [ "$_a" = "--fast" ] || _fv_argv+=("$_a")
15083
+ done
15084
+ python3 "$_fv_py" "${_fv_argv[@]+"${_fv_argv[@]}"}"
15085
+ return $?
15086
+ fi
15087
+ done
15088
+
14856
15089
  local verify_mod="$_LOKI_SCRIPT_DIR/verify.sh"
14857
15090
  if [ ! -f "$verify_mod" ]; then
14858
15091
  echo -e "${RED}Error: verify module not found at $verify_mod${NC}" >&2
@@ -32282,6 +32515,12 @@ cmd_proof() {
32282
32515
  echo " --hosted Publish to LOKI_HOSTED_ENDPOINT (open-core seam; no official backend yet)"
32283
32516
  echo ""
32284
32517
  echo "Proofs are generated automatically at run completion (LOKI_PROOF=0 to opt out)."
32518
+ echo ""
32519
+ echo "Signing (off by default): receipts are UNSIGNED unless LOKI_PROOF_GPG_KEY is"
32520
+ echo "set to a gpg key id. Unsigned, the integrity hash proves the bytes were not"
32521
+ echo "edited after hashing but NOT who produced them, so verify reports"
32522
+ echo "generator_trusted: true. Export LOKI_PROOF_GPG_KEY to close that gap."
32523
+ echo "See docs/SIGNED-RECEIPTS.md."
32285
32524
  [ "$sub" = "" ] && exit 1
32286
32525
  exit 0
32287
32526
  ;;
package/autonomy/run.sh CHANGED
@@ -22823,6 +22823,8 @@ check_human_intervention() {
22823
22823
  log_warn "PAUSE file created by budget limit - NOT auto-clearing in perpetual mode"
22824
22824
  log_warn "Budget limit reached. Remove .loki/signals/BUDGET_EXCEEDED and .loki/PAUSE to continue."
22825
22825
  notify_intervention_needed "Budget limit reached - execution paused" 2>/dev/null || true
22826
+ # Same-instant snapshot as COMPLETION.txt (see the PAUSE-file site).
22827
+ (cd "${TARGET_DIR:-.}" && update_status_file) 2>/dev/null || true
22826
22828
  # Genuinely blocking pause: write the durable intervention record
22827
22829
  # now (state-only; the ping above already fired). This is the
22828
22830
  # correct site for the durable file because the run actually halts
@@ -22835,6 +22837,7 @@ check_human_intervention() {
22835
22837
  if [ "$pause_result" -eq 1 ]; then
22836
22838
  # STOP requested DURING the pause: relabel the durable record
22837
22839
  # as stopped (state-only; the user typed STOP and is aware).
22840
+ (cd "${TARGET_DIR:-.}" && update_status_file) 2>/dev/null || true
22838
22841
  build_completion_summary stopped 2>/dev/null || true
22839
22842
  return 2
22840
22843
  fi
@@ -22849,6 +22852,13 @@ check_human_intervention() {
22849
22852
  fi
22850
22853
  log_warn "PAUSE file detected - pausing execution"
22851
22854
  notify_intervention_needed "Execution paused via PAUSE file"
22855
+ # Refresh STATUS.txt from the queue BEFORE writing the durable record, so
22856
+ # the two files snapshot the same instant. The status monitor refreshes
22857
+ # every 2s but is not running by the time we block here, so STATUS.txt
22858
+ # was frozen at its last tick: measured on a real paused run reporting
22859
+ # "Failed: 0" while COMPLETION.txt -- written now, from the same
22860
+ # queue/failed.json -- reported failed=1. Same source, different age.
22861
+ (cd "${TARGET_DIR:-.}" && update_status_file) 2>/dev/null || true
22852
22862
  # Genuinely blocking pause: write the durable intervention record now
22853
22863
  # (state-only; the ping above already fired).
22854
22864
  build_completion_summary intervention 2>/dev/null || true
@@ -22859,6 +22869,7 @@ check_human_intervention() {
22859
22869
  if [ "$pause_result" -eq 1 ]; then
22860
22870
  # STOP was requested during pause: relabel the durable record as
22861
22871
  # stopped (state-only; the user typed STOP and is aware).
22872
+ (cd "${TARGET_DIR:-.}" && update_status_file) 2>/dev/null || true
22862
22873
  build_completion_summary stopped 2>/dev/null || true
22863
22874
  return 2
22864
22875
  fi
@@ -22872,6 +22883,8 @@ check_human_intervention() {
22872
22883
  rm -f "$loki_dir/PAUSE_AT_CHECKPOINT"
22873
22884
  notify_intervention_needed "Execution paused at checkpoint"
22874
22885
  touch "$loki_dir/PAUSE"
22886
+ # Same-instant snapshot as COMPLETION.txt (see the PAUSE-file site).
22887
+ (cd "${TARGET_DIR:-.}" && update_status_file) 2>/dev/null || true
22875
22888
  # Genuinely blocking pause: write the durable intervention record now
22876
22889
  # (state-only; the ping above already fired).
22877
22890
  build_completion_summary intervention 2>/dev/null || true
@@ -22881,6 +22894,7 @@ check_human_intervention() {
22881
22894
  rm -f "$loki_dir/PAUSE"
22882
22895
  if [ "$pause_result" -eq 1 ]; then
22883
22896
  # STOP requested during pause: relabel as stopped (state-only).
22897
+ (cd "${TARGET_DIR:-.}" && update_status_file) 2>/dev/null || true
22884
22898
  build_completion_summary stopped 2>/dev/null || true
22885
22899
  return 2
22886
22900
  fi
@@ -781,13 +781,19 @@ verify_gate_nomock() {
781
781
  return 0
782
782
  fi
783
783
  local status_line
784
+ # The changed-file list goes over STDIN, not an env var. A single env string
785
+ # is capped at MAX_ARG_STRLEN (131071 bytes) on Linux; a large changed set
786
+ # (e.g. a generated source tree) makes execve fail with E2BIG, the fallback
787
+ # below fires, and the gate silently degrades to inconclusive. macOS has no
788
+ # per-string cap, so that failure was Linux-only.
784
789
  status_line=$(
785
- _NM_FILES="$changed" \
786
- _NM_TREE="$tree" \
787
- _NM_OUT="$scan_file" \
788
- _NM_BASE="${VERIFY_MERGE_BASE:-}" \
789
- python3 -I "$scanner" 2>/dev/null \
790
- || echo "INCONCLUSIVE:detector_error"
790
+ printf '%s\n' "$changed" | {
791
+ _NM_TREE="$tree" \
792
+ _NM_OUT="$scan_file" \
793
+ _NM_BASE="${VERIFY_MERGE_BASE:-}" \
794
+ python3 -I "$scanner" 2>/dev/null \
795
+ || echo "INCONCLUSIVE:detector_error"
796
+ }
791
797
  )
792
798
 
793
799
  case "$status_line" in
@@ -7,7 +7,7 @@ Modules:
7
7
  control: Session control API (start/stop/pause/resume)
8
8
  """
9
9
 
10
- __version__ = "8.0.3"
10
+ __version__ = "8.2.0"
11
11
 
12
12
  # Expose the control app for easy import
13
13
  try: