loki-mode 9.50.4 → 9.52.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/README.md CHANGED
@@ -282,7 +282,8 @@ loki proof show <id> # the facts, the assessments, and the headline
282
282
  loki proof verify <id> # re-hash the receipt and re-derive the diff
283
283
  ```
284
284
 
285
- `loki proof verify` exits 0 clean, 1 on tamper or drift. Receipts are attached
285
+ `loki proof verify` exits 0 clean, 1 on tamper or drift, 2 when it could not check
286
+ (full table: [docs/exit-codes.md](docs/exit-codes.md)). Receipts are attached
286
287
  to pull requests automatically (`LOKI_PROVEN_PR=0` to opt out), so a reviewer
287
288
  sees the evidence next to the code.
288
289
 
@@ -368,7 +369,7 @@ Receipts are written to `.loki/proofs/<run_id>/` automatically at run completion
368
369
  ```bash
369
370
  loki proof list # every receipt: run id, time, council verdict, cost, files
370
371
  loki proof show <id> # the full proof.json (facts, assessments, honesty)
371
- loki proof verify <id> # re-check the receipt against the repo (exit 0 clean, 1 tamper/drift)
372
+ loki proof verify <id> # re-check the receipt against the repo (exit 0 clean, 1 tamper/drift, 2 could not check)
372
373
  ```
373
374
 
374
375
  `loki proof verify` does two independent checks and prints the result as JSON:
package/SKILL.md CHANGED
@@ -3,13 +3,13 @@ name: loki-mode
3
3
  description: Autonomous spec-driven build system with a built-in trust layer. It does not call work done until it is verified (RARV-C closure loop, 8 quality gates, completion council, verified-completion evidence gate). Triggers on "Loki Mode". Takes a spec (PRD, GitHub issue, OpenAPI doc, etc.) to deployed product with minimal human intervention. Provider-agnostic. Requires --dangerously-skip-permissions flag.
4
4
  ---
5
5
 
6
- # Loki Mode v9.50.4
6
+ # Loki Mode v9.52.0
7
7
 
8
8
  **You are an autonomous agent. You make decisions. You do not ask questions. You do not stop.**
9
9
 
10
10
  **Spec in, verified product out.** Spec-driven: a "spec" is whatever describes the work -- a Markdown PRD, a GitHub issue, an OpenAPI doc, a Jira ticket (a PRD is one form of spec). The differentiator is the trust layer: Loki does not call work done until it is verified. The RARV-C closure loop, 8 quality gates, the completion council, and the verified-completion evidence gate must all clear before completion is accepted. The evidence gate blocks on empty-diff, red tests, an unhealthy serveable app (runtime-boot axis, `LOKI_EVIDENCE_BOOT_GATE=0` to opt out), and a leaked credential in the changed files (secret-leak axis, `LOKI_EVIDENCE_SECRET_GATE=0` to opt out) -- v8.0.0.
11
11
 
12
- **Evidence Receipt (verify it yourself).** Every run writes a receipt to `.loki/proofs/<run_id>/` (opt out with `LOKI_PROOF=0`) that separates deterministic FACTS (git diff with base/head SHAs and a `diff_sha256`, the test command + exit code, the build command + exit code, each gate verdict) from AI ASSESSMENTS (the council verdict, labeled judgment not proof). The headline is computed only from the facts: VERIFIED (tests ran a real command and exited 0, diff non-empty, nothing skipped), VERIFIED WITH GAPS (each gap listed by name), or NOT VERIFIED (a check ran and failed). Inspect and re-check with `loki proof list|show <id>|verify <id>` (aliased `loki receipt`); `loki proof verify` re-hashes the receipt (tamper) and re-derives the diff from the recorded base SHA against the live repo (drift), exiting 0 clean / 1 tamper-or-drift. This is honesty-of-done, not a claim that the code is bug-free.
12
+ **Evidence Receipt (verify it yourself).** Every run writes a receipt to `.loki/proofs/<run_id>/` (opt out with `LOKI_PROOF=0`) that separates deterministic FACTS (git diff with base/head SHAs and a `diff_sha256`, the test command + exit code, the build command + exit code, each gate verdict) from AI ASSESSMENTS (the council verdict, labeled judgment not proof). The headline is computed only from the facts: VERIFIED (tests ran a real command and exited 0, diff non-empty, nothing skipped), VERIFIED WITH GAPS (each gap listed by name), or NOT VERIFIED (a check ran and failed). Inspect and re-check with `loki proof list|show <id>|verify <id>` (aliased `loki receipt`); `loki proof verify` re-hashes the receipt (tamper) and re-derives the diff from the recorded base SHA against the live repo (drift), exiting 0 clean / 1 tamper-or-drift / 2 could not check (64 usage, 66 unknown id; see `docs/exit-codes.md`). This is honesty-of-done, not a claim that the code is bug-free.
13
13
 
14
14
  **Provider-agnostic (stable since v5.0.0):** runs on Claude/Codex/Cline/Aider with abstract model tiers and degraded mode for non-Claude providers; no vendor lock-in. Gemini deprecated v7.5.18. See `skills/providers.md`. **Current track (v8.0.0):** the Anthropic Agent SDK route (see below), spec-mode expansion for OpenAPI/GraphQL/Postman contracts, the runtime-boot and secret-leak evidence axes, and `loki steer` / `loki why` for mid-run control. Earlier tracks: LSP grounding as a first-class agent tool (v7.7.x) and Phase 1 RARV-C closure (real provider judges, gate-failure flock, synthetic PRD e2e, status `--json`).
15
15
 
@@ -470,4 +470,4 @@ See `CHANGELOG.md` entries [7.5.7], [7.5.8], [7.5.13] for the per-fix list and r
470
470
 
471
471
  ---
472
472
 
473
- **v9.50.4 | [Autonomi](https://www.autonomi.dev/) flagship product | ~410 lines core**
473
+ **v9.52.0 | [Autonomi](https://www.autonomi.dev/) flagship product | ~410 lines core**
package/VERSION CHANGED
@@ -1 +1 @@
1
- 9.50.4
1
+ 9.52.0
@@ -1937,7 +1937,9 @@ except (json.JSONDecodeError, IOError, KeyError, ValueError):
1937
1937
  print('INCONCLUSIVE:none:true')
1938
1938
  sys.exit(0)
1939
1939
  runner = d.get('runner', 'none')
1940
- passed = d.get('pass', True)
1940
+ # No default of True: a results file with no "pass" key recorded no outcome, and
1941
+ # an unrecorded outcome is not a pass. None falls to INCONCLUSIVE below.
1942
+ passed = d.get('pass')
1941
1943
  status = d.get('status', '')
1942
1944
  #82 (zero-test-file hardening): a runner that ran but executed ZERO real tests
1943
1945
  # (node --test on a *.test.js with no test() calls; jest --passWithNoTests with
@@ -3558,9 +3560,11 @@ council_evaluate_member() {
3558
3560
  # the member stays CONTINUE.
3559
3561
  #
3560
3562
  # Parse verdict mirrors council_evidence_gate: runner=="none" => PASS,
3561
- # pass is False => FAIL, else PASS. Unparseable/missing => not present.
3563
+ # pass is False => FAIL, only a boolean True (and not status no_tests_run)
3564
+ # => PASS; a missing, null or non-boolean pass key recorded no outcome =>
3565
+ # INCONCLUSIVE (not red, not positive). Unparseable/missing file => absent.
3562
3566
  local tr_file="$loki_dir/quality/test-results.json"
3563
- local test_evidence="absent" # absent | pass | fail
3567
+ local test_evidence="absent" # absent | pass | fail | inconclusive
3564
3568
  local test_runner_seen="none"
3565
3569
  if [ -f "$tr_file" ]; then
3566
3570
  local _tr_status
@@ -3573,11 +3577,13 @@ except (json.JSONDecodeError, IOError, KeyError, ValueError):
3573
3577
  print('absent:none')
3574
3578
  sys.exit(0)
3575
3579
  runner = d.get('runner', 'none')
3576
- passed = d.get('pass', True)
3580
+ passed = d.get('pass')
3577
3581
  if runner == 'none':
3578
3582
  print('pass:none')
3579
3583
  elif passed is False:
3580
3584
  print('fail:%s' % runner)
3585
+ elif passed is not True or d.get('status') == 'no_tests_run':
3586
+ print('inconclusive:%s' % runner)
3581
3587
  else:
3582
3588
  print('pass:%s' % runner)
3583
3589
  " 2>/dev/null || echo "absent:none")
@@ -3595,19 +3601,42 @@ else:
3595
3601
  local positive="false"
3596
3602
  case "$role" in
3597
3603
  requirements_verifier)
3598
- # Positive: tests not red AND no pending tasks. A present queue file
3599
- # with pending>0 is a hard "not done"; an ABSENT queue file is not
3600
- # itself disqualifying (a legit run need not have one), it just means
3601
- # this member relies on the base test evidence.
3602
- local pending=0
3603
- if [ -f "$loki_dir/queue/pending.json" ]; then
3604
- pending=$(_QUEUE_FILE="$loki_dir/queue/pending.json" python3 -c "import json, os; print(len(json.load(open(os.environ['_QUEUE_FILE']))))" 2>/dev/null || echo "0")
3605
- if [ "$pending" -gt 0 ]; then
3604
+ # Positive: tests not red AND no UNFINISHED work in any queue.
3605
+ #
3606
+ # UNFINISHED WORK SPANS EVERY QUEUE THAT HOLDS IT, not just pending.
3607
+ #
3608
+ # This counted ONLY pending.json, which was safe purely by accident:
3609
+ # nothing in the runtime ever promoted a task out of pending, so the
3610
+ # count never dropped and this member stayed negative. The moment a
3611
+ # selector claims an item (pending -> in-progress), pending hits 0
3612
+ # and this member would vote COMPLETE with the work still running --
3613
+ # a gate weakened as a SIDE EFFECT of a throughput change, which is
3614
+ # the one thing the trust core must never do.
3615
+ #
3616
+ # in-progress: claimed and running. blocked: parked awaiting a fact.
3617
+ # Both are unfinished. An ABSENT queue file is still not
3618
+ # disqualifying (a legit run need not have one); only a present file
3619
+ # with a non-zero count blocks.
3620
+ #
3621
+ # Pinned by tests/test-completion-council-affirmative-evidence.sh
3622
+ # Cases 6 and 7, with Case 8 as the positive control proving this
3623
+ # can still reach COMPLETE when every queue really is empty.
3624
+ local unfinished=0
3625
+ local _q _qcount
3626
+ for _q in pending in-progress blocked; do
3627
+ [ -f "$loki_dir/queue/${_q}.json" ] || continue
3628
+ _qcount=$(_QUEUE_FILE="$loki_dir/queue/${_q}.json" python3 -c "import json, os
3629
+ d = json.load(open(os.environ['_QUEUE_FILE']))
3630
+ print(len(d.get('tasks', d) if isinstance(d, dict) else d))" 2>/dev/null || echo "0")
3631
+ # Guard against a non-numeric read (malformed file, python absent).
3632
+ case "$_qcount" in ''|*[!0-9]*) _qcount=0 ;; esac
3633
+ if [ "$_qcount" -gt 0 ]; then
3634
+ unfinished=$((unfinished + _qcount))
3606
3635
  blocked="true"
3607
- reasons="${reasons}$pending tasks still pending; "
3636
+ reasons="${reasons}$_qcount tasks still ${_q}; "
3608
3637
  fi
3609
- fi
3610
- if [ "$test_evidence" = "pass" ] && [ "$pending" -eq 0 ]; then
3638
+ done
3639
+ if [ "$test_evidence" = "pass" ] && [ "$unfinished" -eq 0 ]; then
3611
3640
  positive="true"
3612
3641
  fi
3613
3642
  ;;
@@ -3619,6 +3648,8 @@ else:
3619
3648
  # a no-tests / greenfield project leaves test_auditor at CONTINUE.
3620
3649
  if [ "$test_evidence" = "absent" ]; then
3621
3650
  reasons="${reasons}no structured test results found; "
3651
+ elif [ "$test_evidence" = "inconclusive" ]; then
3652
+ reasons="${reasons}structured test results inconclusive (runner '$test_runner_seen' recorded no boolean pass); "
3622
3653
  elif [ "$test_runner_seen" = "none" ]; then
3623
3654
  reasons="${reasons}no real test suite ran (runner none); "
3624
3655
  elif [ "$test_evidence" = "pass" ]; then
@@ -3823,7 +3854,12 @@ council_devils_advocate_review() {
3823
3854
  # (.loki/quality/test-results.json, written by run.sh:ensure_completion_test
3824
3855
  # _evidence; parsed the same way as council_evaluate_member ~2414-2438 and the
3825
3856
  # evidence gate). Parse verdict: runner=="none" => PASS (no real suite to
3826
- # contradict completion), pass is False => FAIL, else PASS. The legacy log
3857
+ # contradict completion), pass is False => FAIL, a boolean True => PASS,
3858
+ # anything else (missing/null/non-boolean pass, status no_tests_run) =>
3859
+ # INCONCLUSIVE. Only FAIL is an issue here: inconclusive does not contradict
3860
+ # completion, exactly as council_evidence_gate lets it through, and vetoing
3861
+ # it would stall a zero-test run (#82) that is meant to reach this vote.
3862
+ # The legacy log
3827
3863
  # glob is kept ONLY as an ADDITIONAL red signal -- its absence is NOT an issue
3828
3864
  # (nothing writes .loki/logs/test-*.log, so an empty glob is the normal case
3829
3865
  # and must never veto a unanimous COMPLETE on its own).
@@ -3839,11 +3875,13 @@ except (json.JSONDecodeError, IOError, KeyError, ValueError):
3839
3875
  print('absent')
3840
3876
  sys.exit(0)
3841
3877
  runner = d.get('runner', 'none')
3842
- passed = d.get('pass', True)
3878
+ passed = d.get('pass')
3843
3879
  if runner == 'none':
3844
3880
  print('pass')
3845
3881
  elif passed is False:
3846
3882
  print('fail')
3883
+ elif passed is not True or d.get('status') == 'no_tests_run':
3884
+ print('inconclusive')
3847
3885
  else:
3848
3886
  print('pass')
3849
3887
  " 2>/dev/null || echo "absent")
@@ -4353,9 +4391,12 @@ try:
4353
4391
  except (json.JSONDecodeError, IOError, KeyError, ValueError):
4354
4392
  print('no'); sys.exit(0)
4355
4393
  runner = d.get('runner', 'none')
4356
- passed = d.get('pass', True)
4357
- # Affirmative green requires a REAL suite (runner != none) that did not fail.
4358
- print('yes' if (runner != 'none' and passed is not False) else 'no')
4394
+ passed = d.get('pass')
4395
+ # Affirmative green requires a REAL suite (runner != none) that recorded a
4396
+ # boolean True pass. A missing/null/non-boolean pass key or a zero-test run
4397
+ # (status no_tests_run) is inconclusive, which is not green.
4398
+ print('yes' if (runner != 'none' and passed is True
4399
+ and d.get('status') != 'no_tests_run') else 'no')
4359
4400
  " 2>/dev/null || echo "no")
4360
4401
  [ "$tr_state" = "yes" ] || return 1
4361
4402
 
package/autonomy/loki CHANGED
@@ -786,7 +786,13 @@ loki_remote_gpg_status() {
786
786
  local pj="$1"
787
787
  local d="${TMPDIR:-/tmp}/loki-sigcheck.$$"
788
788
  mkdir -p "$d" || return 0
789
- python3 - "$pj" "$d" <<'PY' 2>/dev/null || { rm -rf "$d"; return 0; }
789
+ python3 -E - "$pj" "$d" <<'PY' 2>/dev/null || { rm -rf "$d"; return 0; }
790
+ # The cwd is the checkout being verified: drop it from sys.path before any
791
+ # other import, or a committed hashlib.py/json.py could print the verdict.
792
+ # The interpreter runs with -E so PYTHONPATH (an empty component would add
793
+ # the cwd as an absolute path) and a committed sitecustomize.py cannot act
794
+ # before this line.
795
+ import sys; sys.path[:] = [p for p in sys.path if p not in ("", ".")]
790
796
  import json, sys, os
791
797
  proof = json.load(open(sys.argv[1]))
792
798
  d = sys.argv[2]
@@ -825,16 +831,22 @@ loki_remote_attestation_status() {
825
831
  local tok
826
832
  tok="$(jq -r '.verification.attestation // empty' "$pj" 2>/dev/null)"
827
833
  [ -n "$tok" ] || { printf ''; return 0; }
828
- python3 - "$pj" "$url" "${_LOKI_SCRIPT_DIR}" <<'PY' 2>/dev/null || printf ''
834
+ python3 -E - "$pj" "$url" "${_LOKI_SCRIPT_DIR}" <<'PY' 2>/dev/null || printf ''
835
+ # The cwd is the checkout being verified: drop it from sys.path before any
836
+ # other import, or a committed hashlib.py/json.py could print the verdict.
837
+ # The interpreter runs with -E so PYTHONPATH (an empty component would add
838
+ # the cwd as an absolute path) and a committed sitecustomize.py cannot act
839
+ # before this line.
840
+ import sys; sys.path[:] = [p for p in sys.path if p not in ("", ".")]
829
841
  import json, sys, hashlib, urllib.request
830
842
  sys.path.insert(0, sys.argv[3])
831
843
  try:
832
- from receipt_jwt import verify_attestation
844
+ from receipt_jwt import verify_attestation, _CRYPTO_AVAILABLE
833
845
  except ImportError:
834
846
  sys.exit(1) # -> "" : cannot check, never a verdict
835
847
  proof = json.load(open(sys.argv[1]))
836
- tok = (proof.get("verification") or {}).get("attestation") or ""
837
- if not tok:
848
+ tok = (proof.get("verification") or {}).get("attestation")
849
+ if tok is None or tok == "":
838
850
  sys.exit(1)
839
851
  try:
840
852
  with urllib.request.urlopen(
@@ -842,7 +854,28 @@ try:
842
854
  jwks = json.loads(r.read())
843
855
  except Exception:
844
856
  sys.exit(1) # unreachable JWKS is UNCHECKED, not a failure
845
- ok, claims = verify_attestation(tok, jwks)
857
+ # A key set that is not {"keys": [{...}, ...]} is a key-set problem: UNCHECKED,
858
+ # never an accusation against the receipt. Checked BEFORE anything below can
859
+ # print "bad", so a bad key set can never read as TAMPERED.
860
+ _keys = jwks.get("keys") if isinstance(jwks, dict) else None
861
+ if not isinstance(_keys, list) or not all(isinstance(k, dict) for k in _keys):
862
+ sys.exit(1)
863
+ # The receipt's builder controls the token. An attestation that is not a JWT
864
+ # string is a malformed receipt: TAMPERED, decided without any crypto.
865
+ if not isinstance(tok, str):
866
+ print("bad"); sys.exit(0)
867
+ # Without `cryptography`, verify_attestation returns False and this would print
868
+ # "bad" -- a TAMPERED accusation for a check that never ran. A missing verifier
869
+ # dependency is UNCHECKED. Same guard in loki_proof_attestation_check below.
870
+ if not _CRYPTO_AVAILABLE:
871
+ sys.exit(1)
872
+ # The key set is known-good here, so any crash inside verification comes from
873
+ # the token: refuse it. Letting it propagate read as UNCHECKED, which let a
874
+ # forger turn TAMPERED into "install python3 cryptography".
875
+ try:
876
+ ok, claims = verify_attestation(tok, jwks)
877
+ except Exception:
878
+ print("bad"); sys.exit(0)
846
879
  if not ok:
847
880
  print("bad"); sys.exit(0)
848
881
  # The signature verifying is not enough on its own: it must attest to THIS
@@ -880,19 +913,25 @@ loki_proof_attestation_check() {
880
913
  local pj="$1" src="$2"
881
914
  [ -n "$src" ] || { printf ''; return 0; }
882
915
  command -v python3 >/dev/null 2>&1 || { printf ''; return 0; }
883
- python3 - "$pj" "$src" "${_LOKI_SCRIPT_DIR}" <<'PY' 2>/dev/null || printf ''
916
+ python3 -E - "$pj" "$src" "${_LOKI_SCRIPT_DIR}" <<'PY' 2>/dev/null || printf ''
917
+ # The cwd is the checkout being verified: drop it from sys.path before any
918
+ # other import, or a committed hashlib.py/json.py could print the verdict.
919
+ # The interpreter runs with -E so PYTHONPATH (an empty component would add
920
+ # the cwd as an absolute path) and a committed sitecustomize.py cannot act
921
+ # before this line.
922
+ import sys; sys.path[:] = [p for p in sys.path if p not in ("", ".")]
884
923
  import json, sys, os, hashlib, urllib.request
885
924
  sys.path.insert(0, sys.argv[3])
886
925
  try:
887
- from receipt_jwt import verify_attestation
926
+ from receipt_jwt import verify_attestation, _CRYPTO_AVAILABLE
888
927
  except ImportError:
889
928
  sys.exit(1) # "" : cannot check
890
929
  try:
891
930
  proof = json.load(open(sys.argv[1]))
892
931
  except Exception:
893
932
  sys.exit(1)
894
- tok = (proof.get("verification") or {}).get("attestation") or ""
895
- if not tok:
933
+ tok = (proof.get("verification") or {}).get("attestation")
934
+ if tok is None or tok == "":
896
935
  print("none"); sys.exit(0) # a FACT about the receipt, not an error
897
936
  src = sys.argv[2]
898
937
  try:
@@ -907,7 +946,30 @@ try:
907
946
  jwks = json.loads(r.read())
908
947
  except Exception:
909
948
  sys.exit(1) # "" : unreachable/unreadable, no verdict
910
- ok, claims = verify_attestation(tok, jwks)
949
+ # A key set that is not {"keys": [{...}, ...]} is a key-set problem: NOT
950
+ # CHECKED, never an accusation. Checked BEFORE anything below can print "bad",
951
+ # so a bad key set can never read as FAILED. Same order in
952
+ # loki_remote_attestation_status above -- change both.
953
+ _keys = jwks.get("keys") if isinstance(jwks, dict) else None
954
+ if not isinstance(_keys, list) or not all(isinstance(k, dict) for k in _keys):
955
+ sys.exit(1) # "" : cannot check
956
+ # The receipt's builder controls the token. An attestation that is not a JWT
957
+ # string is a malformed receipt: FAILED, decided without any crypto.
958
+ if not isinstance(tok, str):
959
+ print("bad"); sys.exit(0)
960
+ # receipt_jwt imports without `cryptography`, and verify_attestation then
961
+ # returns False, which would print "bad": a FAILED verdict for a check that
962
+ # never ran. A missing verifier dependency is NOT CHECKED. Same guard in
963
+ # loki_remote_attestation_status above -- change both.
964
+ if not _CRYPTO_AVAILABLE:
965
+ sys.exit(1) # "" : cannot check
966
+ # The key set is known-good here, so any crash inside verification comes from
967
+ # the token: refuse it. Letting it propagate read as NOT CHECKED (exit 2), which
968
+ # let a forger turn FAILED into "could not check".
969
+ try:
970
+ ok, claims = verify_attestation(tok, jwks)
971
+ except Exception:
972
+ print("bad"); sys.exit(0)
911
973
  if not ok:
912
974
  print("bad"); sys.exit(0)
913
975
  # A verifying signature is not enough: it must attest to THESE bytes. Recompute
@@ -951,7 +1013,7 @@ loki_remote_verify_receipt() {
951
1013
  # empty for a local receipt, which simply means no attestation is checked.
952
1014
  local _attest_url="${2:-}" _attest=""
953
1015
  _attest="$(loki_remote_attestation_status "$pj" "$_attest_url")"
954
- out="$(python3 "$verifier" "$pj" "${TARGET_DIR:-.}" 2>/dev/null)"
1016
+ out="$(python3 -E "$verifier" "$pj" "${TARGET_DIR:-.}" 2>/dev/null)"
955
1017
  hash_ok="$(printf '%s' "$out" | jq -r '.hash_ok // false' 2>/dev/null)"
956
1018
  gpg_ok="$(printf '%s' "$out" | jq -r '.gpg_ok | tostring' 2>/dev/null)"
957
1019
 
@@ -1057,6 +1119,18 @@ loki_remote_verify_receipt() {
1057
1119
  return 0
1058
1120
  fi
1059
1121
 
1122
+ # The receipt carries an attestation that could not be checked here (JWKS
1123
+ # unreachable, or python cryptography missing). Calling it UNSIGNED would
1124
+ # mislabel a signed receipt and point at the wrong fix. Same policy as the
1125
+ # gpg-unavailable branch above: NOT CHECKED, return 0, provenance unproven.
1126
+ if [ -z "$_attest" ] && jq -e '.verification.attestation // empty' "$pj" >/dev/null 2>&1; then
1127
+ echo -e " ${YELLOW}NOT CHECKED${NC}: the receipt carries an attestation, but it could not be checked"
1128
+ echo " against ${_attest_url:-<no url>} (JWKS unreachable, or python cryptography missing)."
1129
+ echo " Its integrity hash matches. Provenance is unproven here, not disproven."
1130
+ echo " Install python3 cryptography, or re-run where the build server is reachable."
1131
+ return 0
1132
+ fi
1133
+
1060
1134
  echo -e " ${YELLOW}UNSIGNED${NC}: integrity hash matches. This is NOT proof of provenance."
1061
1135
  echo " Proven: the receipt is internally consistent with its own hash."
1062
1136
  echo " NOT proven: who produced it. An unsigned receipt is forgeable -- whoever"
@@ -9019,7 +9093,7 @@ _deploy_receipt_verdict() {
9019
9093
  fi
9020
9094
 
9021
9095
  local out hash_ok gpg_ok signed=0
9022
- out="$(python3 "$verifier" "$pj" "${TARGET_DIR:-.}" 2>/dev/null || true)"
9096
+ out="$(python3 -E "$verifier" "$pj" "${TARGET_DIR:-.}" 2>/dev/null || true)"
9023
9097
 
9024
9098
  # "The verifier did not RUN" and "the verifier says the hash is bad" are
9025
9099
  # different facts and must not collapse. If they do, a broken python3
@@ -13097,12 +13171,25 @@ _loki_airgap_audit() {
13097
13171
  ;;
13098
13172
  opencode|cline|aider)
13099
13173
  model_host="${OPENAI_BASE_URL:-${OPENROUTER_API_KEY:+https://openrouter.ai}}"
13100
- if [ -z "$model_host" ] || printf '%s' "${LOKI_OPENCODE_MODEL:-}${LOKI_AIDER_MODEL:-}" | grep -qiE 'ollama|localhost|127\.0\.0\.1|lmstudio'; then
13174
+ # Local ONLY on positive evidence: a model id naming a local runtime.
13175
+ # An unset endpoint is not that evidence -- the opencode catalog
13176
+ # default is an openrouter/ model, so inference still leaves the host.
13177
+ # Judge only the ACTIVE provider's model variable (the one
13178
+ # providers/<provider>.sh reads): another provider's local model
13179
+ # says nothing about where this provider sends inference.
13180
+ local model_var model_id
13181
+ case "$provider" in
13182
+ opencode) model_var="LOKI_OPENCODE_MODEL" model_id="${LOKI_OPENCODE_MODEL:-}" ;;
13183
+ cline) model_var="LOKI_CLINE_MODEL" model_id="${LOKI_CLINE_MODEL:-}" ;;
13184
+ aider) model_var="LOKI_AIDER_MODEL" model_id="${LOKI_AIDER_MODEL:-}" ;;
13185
+ esac
13186
+ if printf '%s' "$model_id" | grep -qiE 'ollama|localhost|127\.0\.0\.1|lmstudio'; then
13101
13187
  model_host="local weights"
13102
13188
  model_required="no"
13103
13189
  model_note="Local model runtime (Ollama / LM Studio / llama.cpp). No inference egress."
13104
13190
  else
13105
- model_note="Remote model gateway. Use an ollama/ model id for zero-egress inference."
13191
+ [ -n "$model_host" ] || model_host="remote model provider (no local model configured)"
13192
+ model_note="Remote model gateway. Set $model_var to an ollama/ model id for zero-egress inference."
13106
13193
  fi
13107
13194
  ;;
13108
13195
  *)
@@ -36193,8 +36280,11 @@ cmd_proof() {
36193
36280
  echo " phases [--json] Measured phase history from real phase_change"
36194
36281
  echo " events; unrecorded boundaries read UNKNOWN"
36195
36282
  echo " show <id> Pretty-print .loki/proofs/<id>/proof.json"
36196
- echo " verify <id> Re-check a receipt against the repo (tamper + drift);"
36197
- echo " exit 0 clean, 1 tamper/drift. Verify it yourself."
36283
+ echo " verify <id> Re-check a receipt against the repo (tamper + drift)."
36284
+ echo " Exit 0 clean, 1 tamper/drift, 2 could not check,"
36285
+ echo " 64 usage (no id, more than one id, unknown option,"
36286
+ echo " -h/--help: verify never exits 0 without a verdict),"
36287
+ echo " 66 unknown id. Verify it yourself."
36198
36288
  echo " passport <contract> <id|proof.json> <output.json>"
36199
36289
  echo " Bind work from any agent to a portable Outcome Contract"
36200
36290
  echo " and independently rechecked Evidence Receipt"
@@ -36204,9 +36294,15 @@ cmd_proof() {
36204
36294
  echo " --jwks <url|file> Also check the receipt's attestation against a published"
36205
36295
  echo " key set. Accepts a server URL or a local jwks.json, so a"
36206
36296
  echo " third party can check WHO produced a receipt with no API"
36207
- echo " token, no gpg key import, and no network."
36208
- echo " md <id> Paste-able Markdown for a PR comment or Slack
36209
- open <id> Open .loki/proofs/<id>/index.html in a browser"
36297
+ echo " token, no gpg key import, and no network. Exit 1 if the"
36298
+ echo " token FAILED or the receipt is unsigned (ABSENT); 2 if"
36299
+ echo " NOT CHECKED (key set unreadable or verifier dependency"
36300
+ echo " missing); 64 if --jwks has no value or an empty one."
36301
+ echo " A 1 is never softened to 2."
36302
+ echo ""
36303
+ echo "More subcommands:"
36304
+ echo " md <id> Paste-able Markdown for a PR comment or Slack"
36305
+ echo " open <id> Open .loki/proofs/<id>/index.html in a browser"
36210
36306
  echo " share <id> Publish the proof page as a GitHub Gist (opt-in)"
36211
36307
  echo ""
36212
36308
  echo "Options for 'share':"
@@ -36412,7 +36508,7 @@ PYEOF
36412
36508
  echo -e "${RED}Proof Passport tool not found: ${passport_tool}${NC}" >&2
36413
36509
  exit 3
36414
36510
  fi
36415
- python3 "$passport_tool" "$contract" "$receipt_path" "$output" "$@"
36511
+ python3 -E "$passport_tool" "$contract" "$receipt_path" "$output" "$@"
36416
36512
  exit $?
36417
36513
  ;;
36418
36514
  chain)
@@ -36435,10 +36531,27 @@ PYEOF
36435
36531
  echo -e "${RED}python3 unavailable: the chain could not be verified (UNAVAILABLE, not a pass)${NC}" >&2
36436
36532
  exit 2
36437
36533
  fi
36534
+ # A verifier never exits 0 without a verdict: -h/--help (before any
36535
+ # "--") shows usage on stderr and exits 64, where argparse would
36536
+ # exit 0 with nothing checked. The tool itself keeps its exit-0
36537
+ # --help for direct use (tests/test_tool_exit_contract.py).
36538
+ local _ca
36539
+ for _ca in "$@"; do
36540
+ [ "$_ca" = "--" ] && break
36541
+ # argparse also treats --h/--he/--hel and any short-option
36542
+ # cluster containing h (-hq) as help.
36543
+ case "$_ca" in
36544
+ -h*|--h|--he|--hel|--help|-[!-]*h*)
36545
+ python3 -E "$chain_tool" --help >&2 || true
36546
+ echo "Nothing was checked (exit 64). Full help: loki proof help" >&2
36547
+ exit 64
36548
+ ;;
36549
+ esac
36550
+ done
36438
36551
  # `set -e` is active (loki:22) and a FAILED verdict is a NORMAL
36439
36552
  # result here, so a bare call would abort before the status is read.
36440
36553
  local _chain_rc=0
36441
- python3 "$chain_tool" "$@" || _chain_rc=$?
36554
+ python3 -E "$chain_tool" "$@" || _chain_rc=$?
36442
36555
  exit $_chain_rc
36443
36556
  ;;
36444
36557
  md)
@@ -36495,35 +36608,84 @@ PYEOF
36495
36608
  # so an air-gapped reviewer, handed the receipt and the key set on
36496
36609
  # disk, can still verify -- requiring the network would exclude
36497
36610
  # exactly the reviewer who most needs this.
36498
- local _pv_human=0 _pv_jwks="" _pv_args=()
36499
- local _pv_a _pv_want_jwks=0
36611
+ # An EMPTY --jwks value ("--jwks ''", "--jwks=") is a usage error,
36612
+ # sticky across every occurrence. It used to skip the attestation
36613
+ # check and exit 0 on an unsigned receipt, and a later empty
36614
+ # --jwks cancelled an earlier real one.
36615
+ # An UNKNOWN option, or a second positional, is a usage error too:
36616
+ # "--jwk keys.json" and "-jwks keys.json" used to be kept as extra
36617
+ # positionals and ignored, so an unsigned receipt exited 0 as if a
36618
+ # signature had been checked.
36619
+ local _pv_human=0 _pv_jwks="" _pv_jwks_empty=0 _pv_args=()
36620
+ local _pv_a _pv_want_jwks=0 _pv_npos=0 _pv_end_opts=0
36500
36621
  for _pv_a in "$@"; do
36622
+ # After "--" everything is a proof id, so a caller can pass an
36623
+ # id that begins with "-" without it being read as an option.
36624
+ if [ "$_pv_end_opts" -eq 1 ]; then
36625
+ _pv_args+=("$_pv_a"); _pv_npos=$((_pv_npos + 1)); continue
36626
+ fi
36501
36627
  if [ "$_pv_want_jwks" -eq 1 ]; then
36502
- _pv_jwks="$_pv_a"; _pv_want_jwks=0; continue
36628
+ _pv_jwks="$_pv_a"; _pv_want_jwks=0
36629
+ [ -n "$_pv_jwks" ] || _pv_jwks_empty=1
36630
+ continue
36503
36631
  fi
36504
36632
  case "$_pv_a" in
36505
36633
  --human) _pv_human=1 ;;
36634
+ --) _pv_end_opts=1 ;;
36635
+ -h|--help)
36636
+ # verify never exits 0 without a verdict. The documented
36637
+ # call is "loki proof verify <id>", so an id taken from
36638
+ # untrusted content could be "-h"; an exit-0 help would
36639
+ # turn a provenance gate into a pass. Usage goes to
36640
+ # stderr with 64; full exit-0 help is "loki proof help".
36641
+ {
36642
+ echo "Usage: loki proof verify [--human] [--jwks <url|file>] [--] <id>"
36643
+ echo " Exit 0 clean, 1 tamper/drift (or --jwks FAILED/ABSENT), 2 could not check,"
36644
+ echo " 64 usage (no id, more than one id, unknown option, empty --jwks, -h/--help),"
36645
+ echo " 66 unknown id. '--' ends options, for an id that starts with '-'."
36646
+ echo " Nothing was checked. Full help: loki proof help"
36647
+ } >&2
36648
+ exit 64 # usage (docs/exit-codes.md)
36649
+ ;;
36506
36650
  --jwks) _pv_want_jwks=1 ;;
36507
- --jwks=*) _pv_jwks="${_pv_a#--jwks=}" ;;
36508
- *) _pv_args+=("$_pv_a") ;;
36651
+ --jwks=*)
36652
+ _pv_jwks="${_pv_a#--jwks=}"
36653
+ [ -n "$_pv_jwks" ] || _pv_jwks_empty=1
36654
+ ;;
36655
+ -*)
36656
+ echo -e "${RED}Unknown option for 'loki proof verify': '${_pv_a}'.${NC}" >&2
36657
+ echo " Valid options: --human, --jwks <url|file>. Nothing was checked." >&2
36658
+ exit 64 # usage (docs/exit-codes.md)
36659
+ ;;
36660
+ *) _pv_args+=("$_pv_a"); _pv_npos=$((_pv_npos + 1)) ;;
36509
36661
  esac
36510
36662
  done
36663
+ if [ "$_pv_npos" -gt 1 ]; then
36664
+ echo -e "${RED}'loki proof verify' takes one proof id; got ${_pv_npos}: ${_pv_args[*]}${NC}" >&2
36665
+ echo " Nothing was checked." >&2
36666
+ exit 64 # usage (docs/exit-codes.md)
36667
+ fi
36511
36668
  if [ "$_pv_want_jwks" -eq 1 ]; then
36512
36669
  echo -e "${RED}--jwks needs a URL or file path.${NC}" >&2
36513
- exit 2
36670
+ exit 64 # usage (docs/exit-codes.md)
36671
+ fi
36672
+ if [ "$_pv_jwks_empty" -eq 1 ]; then
36673
+ echo -e "${RED}--jwks was given an empty value; pass a URL or file path.${NC}" >&2
36674
+ echo " Nothing was checked: an empty key set must not read as a skipped check." >&2
36675
+ exit 64 # usage (docs/exit-codes.md)
36514
36676
  fi
36515
36677
  set -- "${_pv_args[@]+"${_pv_args[@]}"}"
36516
36678
 
36517
36679
  local id="${1:-}"
36518
36680
  if [ -z "$id" ]; then
36519
36681
  echo -e "${RED}Missing proof id.${NC} Use 'loki proof list'."
36520
- exit 2
36682
+ exit 64 # usage (docs/exit-codes.md); 2 means "could not check"
36521
36683
  fi
36522
36684
  local pj="${proofs_dir}/${id}/proof.json"
36523
36685
  if [ ! -f "$pj" ]; then
36524
36686
  echo -e "${RED}Proof not found: ${id}${NC}"
36525
36687
  echo "Use 'loki proof list' to see available proofs."
36526
- exit 1
36688
+ exit 66 # input missing; 1 is reserved for tamper/drift
36527
36689
  fi
36528
36690
  # The verifier ships beside the generator under autonomy/lib/ (same
36529
36691
  # dir resolution run.sh uses for proof-generator.py).
@@ -36537,11 +36699,17 @@ PYEOF
36537
36699
  # its NORMAL result -- aborts the whole command before the exit
36538
36700
  # status can be read. Without the guard the attestation block below
36539
36701
  # is unreachable on exactly the receipts it exists to check.
36702
+ # No python3 means the verifier cannot run: could not check (2),
36703
+ # never a shell's 127 and never a pass.
36704
+ if ! command -v python3 >/dev/null 2>&1; then
36705
+ echo -e "${YELLOW}NOT CHECKED${NC}: python3 is not on PATH, so the verifier could not run (exit 2)." >&2
36706
+ exit 2
36707
+ fi
36540
36708
  local _pv_rc=0
36541
36709
  if [ "$_pv_human" -eq 1 ]; then
36542
- python3 "$verifier" --human "$pj" "${TARGET_DIR:-.}" || _pv_rc=$?
36710
+ python3 -E "$verifier" --human "$pj" "${TARGET_DIR:-.}" || _pv_rc=$?
36543
36711
  else
36544
- python3 "$verifier" "$pj" "${TARGET_DIR:-.}" || _pv_rc=$?
36712
+ python3 -E "$verifier" "$pj" "${TARGET_DIR:-.}" || _pv_rc=$?
36545
36713
  fi
36546
36714
 
36547
36715
  # The attestation check runs AFTER, and can only make the verdict
@@ -36565,16 +36733,25 @@ PYEOF
36565
36733
  _pv_rc=1
36566
36734
  ;;
36567
36735
  none)
36736
+ # Exit 1: the caller supplied a key set and asked who
36737
+ # signed this receipt; it is unsigned, so it is NOT
36738
+ # verified. Exiting 0 here let a stripped signature pass
36739
+ # any CI step that asked for provenance.
36568
36740
  echo -e "${YELLOW}attestation: ABSENT -- this receipt carries no attestation.${NC}" >&2
36569
36741
  echo " Nothing was checked against ${_pv_jwks}. The receipt may still be" >&2
36570
36742
  echo " gpg-signed; this says only that it has no JWKS attestation." >&2
36743
+ echo " Not verified: a key set was supplied, but the receipt is unsigned (exit 1)." >&2
36744
+ _pv_rc=1
36571
36745
  ;;
36572
36746
  *)
36573
36747
  # Unreachable key set, unreadable file, missing python
36574
36748
  # deps. NOT a verdict: an absent measurement must never
36575
- # read as either a pass or an accusation.
36576
- echo -e "${YELLOW}attestation: NOT CHECKED -- could not read a key set from ${_pv_jwks}.${NC}" >&2
36577
- echo " This is not a failure of the receipt. Nothing was proven either way." >&2
36749
+ # read as either a pass or an accusation, so it exits 2
36750
+ # (could not check). A tamper/drift 1 from the base
36751
+ # verifier is a finding and is never softened to 2.
36752
+ echo -e "${YELLOW}attestation: NOT CHECKED -- could not check against ${_pv_jwks} (key set unreadable, or verifier dependency missing).${NC}" >&2
36753
+ echo " This is not a failure of the receipt. Nothing was proven either way (exit 2)." >&2
36754
+ if [ "$_pv_rc" -eq 0 ]; then _pv_rc=2; fi
36578
36755
  ;;
36579
36756
  esac
36580
36757
  fi
@@ -277,6 +277,11 @@ def verify_attestation(token: str, jwks: dict):
277
277
  """
278
278
  if not _CRYPTO_AVAILABLE:
279
279
  return False, "cryptography is not installed"
280
+ # The token comes from the receipt, which its builder controls. Anything
281
+ # that is not the expected shape is REFUSED here rather than raising, so a
282
+ # caller can never mistake a crash for "could not check".
283
+ if not isinstance(token, str):
284
+ return False, "token is not a string"
280
285
  parts = token.split(".")
281
286
  if len(parts) != 3:
282
287
  return False, "malformed token"
@@ -286,6 +291,8 @@ def verify_attestation(token: str, jwks: dict):
286
291
  sig = _b64url_decode(parts[2])
287
292
  except (ValueError, json.JSONDecodeError):
288
293
  return False, "malformed token"
294
+ if not isinstance(header, dict) or not isinstance(payload, dict):
295
+ return False, "malformed token: header and payload must be JSON objects"
289
296
  if header.get("alg") != "EdDSA":
290
297
  # Refusing an unexpected alg is what blocks the classic "alg: none" and
291
298
  # algorithm-substitution attacks.
@@ -7,7 +7,7 @@ Modules:
7
7
  control: Session control API (start/stop/pause/resume)
8
8
  """
9
9
 
10
- __version__ = "9.50.4"
10
+ __version__ = "9.52.0"
11
11
 
12
12
  # Expose the control app for easy import
13
13
  try: